Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,9 @@ FodyWeavers.xsd
# Sample/example projects - not part of the SDK
ReactApp1.Server/
reactapp1.client/

# Integration/acceptance test local credentials
NHSDigital.ApiPlatform.Sdk.Tests.Integration/appsettings.Development.json
NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration/appsettings.Development.json
NHSDigital.ApiPlatform.Sdk.Tests.Acceptance/appsettings.Development.json
NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Acceptance/appsettings.Development.json
18 changes: 12 additions & 6 deletions Documentation/DependencyGraph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,18 @@ enabled once in the repository's Settings → Pages (source: GitHub Actions).
services split `HttpRequestException`: a 4xx becomes a
`*DependencyValidationException` (the caller sent something the dependency
rejected), a 5xx or a transport failure becomes a `*DependencyException`.
- **The storage brokers are the extension seam.** `IApiPlatformStateBroker`
and `IApiPlatformTokenBroker` each have an in-memory implementation in the
Sdk and a session-backed one in Sdk.AspNetCore. Both are registered with
`TryAdd`, so whichever the host registers first wins — call
`AddApiPlatformSdkAspNetCore()` before `AddApiPlatformSdkInMemoryStorage()`
in a web host, or you get the process-wide singletons.
- **The storage brokers are the extension seam, and order does not matter.**
`IApiPlatformStateBroker` and `IApiPlatformTokenBroker` each have an
in-memory implementation in the Sdk and a session-backed one in
Sdk.AspNetCore. `AddApiPlatformSdkInMemoryStorage` uses `TryAddSingleton`,
but `AddApiPlatformSdkAspNetCore` uses plain `AddScoped` — which appends
rather than no-ops, and the last registration wins. So calling both in
either order leaves a web host on the session-backed brokers. One caveat:
last-wins governs `GetService`/`GetRequiredService` only. If
`AddApiPlatformSdkInMemoryStorage` ran first its singleton descriptor is
still in the collection, so `GetServices<IApiPlatformStateBroker>()` returns
both — a host that enumerates implementations can still reach the
process-wide singleton.
- **The in-memory brokers are singletons and hold one user's state.** Fine
for a console app or a test; wrong for a multi-user web host.
- **CIS2 runs without PKCE** — the code says so explicitly; only `client_id`,
Expand Down
4 changes: 2 additions & 2 deletions Documentation/DependencyGraph/graph-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,11 @@
------------------------------------------------------------------ */
C({ id: "StateBroker", name: "IApiPlatformStateBroker", project: "sdk", layer: "broker", col: 5,
methods: ["StoreCsrfStateAsync", "GetCsrfStateAsync", "ClearCsrfStateAsync"],
description: "Holds the CSRF state between the login redirect and the callback. AddApiPlatformSdkInMemoryStorage registers the in-memory copy with TryAdd, so a host that has already registered the session one keeps it." });
description: "Holds the CSRF state between the login redirect and the callback. AddApiPlatformSdkInMemoryStorage registers the in-memory copy with TryAdd; AddApiPlatformSdkAspNetCore registers the session one with AddScoped, which appends and therefore wins whichever order the two are called in." });
C({ id: "TokenBroker", name: "IApiPlatformTokenBroker", project: "sdk", layer: "broker", col: 5,
methods: ["StoreAccessTokenAsync", "GetAccessTokenAsync", "ClearAccessTokenAsync",
"StoreRefreshTokenAsync", "GetRefreshTokenAsync", "ClearRefreshTokenAsync"],
description: "Holds the access and refresh tokens with their expiry instants. Same TryAdd registration story as the state broker." });
description: "Holds the access and refresh tokens with their expiry instants. Same registration story as the state broker - the session implementation wins in an ASP.NET Core host regardless of call order." });

C({ id: "MemoryStateBroker", name: "MemoryApiPlatformStateBroker", project: "sdk", layer: "broker", col: 6,
methods: ["StoreCsrfStateAsync", "GetCsrfStateAsync", "ClearCsrfStateAsync"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// ---------------------------------------------------------
// Copyright (c) North East London ICB. All rights reserved.
// ---------------------------------------------------------

using Microsoft.Extensions.Configuration;
using NHSDigital.ApiPlatform.Sdk.Models.Configurations;

namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration
{
/// <summary>
/// Builds the API Platform configuration used by the integration tests.
///
/// Endpoints come from appsettings.json. Credentials are deliberately left blank there and must
/// be supplied out of band — either through appsettings.Development.json (git ignored) or through
/// environment variables, for example:
///
/// ApiPlatform__CareIdentity__ClientId
/// ApiPlatform__CareIdentity__ClientSecret
/// </summary>
internal static class ConfigurationProvider
{
internal static ApiPlatformConfigurations GetApiPlatformConfigurations()
{
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: false)
.AddEnvironmentVariables()
.Build();

return configuration
.GetSection("ApiPlatform")
.Get<ApiPlatformConfigurations>() ?? new ApiPlatformConfigurations();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// ---------------------------------------------------------
// Copyright (c) North East London ICB. All rights reserved.
// ---------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using NHSDigital.ApiPlatform.Sdk.Brokers.Storages;
using NHSDigital.ApiPlatform.Sdk.Clients.ApiPlatforms;
using NHSDigital.ApiPlatform.Sdk.Models.Configurations;
using Xunit;

namespace NHSDigital.ApiPlatform.Sdk.AspNetCore.Tests.Integration
{
public class ServiceCollectionExtensionsTests
{
[Fact]
public void ShouldResolveApiPlatformClientFromTheComposedAspNetCoreContainer()
{
// given
ServiceProvider serviceProvider = BuildServiceProvider();
using IServiceScope serviceScope = serviceProvider.CreateScope();

// when
var actualClient = serviceScope.ServiceProvider.GetRequiredService<IApiPlatformClient>();

// then
actualClient.CareIdentityServiceClient.Should().NotBeNull();
actualClient.PersonalDemographicsServiceClient.Should().NotBeNull();
}

[Fact]
public void ShouldOverrideTheInMemoryStorageBrokersWithSessionBackedOnes()
{
// given
// AddApiPlatformSdkInMemoryStorage uses TryAdd, so the session brokers only win if
// AddApiPlatformSdkAspNetCore has already registered them. Registering the in-memory
// ones here is what makes this assertion capable of failing.
ServiceProvider serviceProvider = BuildServiceProvider(withInMemoryStorage: true);
using IServiceScope serviceScope = serviceProvider.CreateScope();

// when
var actualStateBroker =
serviceScope.ServiceProvider.GetRequiredService<IApiPlatformStateBroker>();

var actualTokenBroker =
serviceScope.ServiceProvider.GetRequiredService<IApiPlatformTokenBroker>();

// then
actualStateBroker.GetType().Name.Should().Be("SessionApiPlatformStateBroker");
actualTokenBroker.GetType().Name.Should().Be("SessionApiPlatformTokenBroker");
}

[Fact]
public void ShouldStillUseTheSessionBrokersWhenInMemoryStorageIsRegisteredFirst()
{
// given
// Registration order does NOT matter here, contrary to what one might expect from
// TryAdd: AddApiPlatformSdkAspNetCore uses AddScoped, which appends rather than
// no-ops, and the last registration for a service is the one that resolves. So the
// session brokers win either way, and a web host cannot accidentally end up on the
// process-wide singletons by ordering these two calls the "wrong" way round.
ApiPlatformConfigurations configurations =
ConfigurationProvider.GetApiPlatformConfigurations();

IServiceCollection services = new ServiceCollection();

services.AddSingleton<IHttpContextAccessor>(
new HttpContextAccessor { HttpContext = CreateHttpContext() });

services.AddApiPlatformSdkCore(configurations);
services.AddApiPlatformSdkInMemoryStorage();
services.AddApiPlatformSdkAspNetCore();

using ServiceProvider serviceProvider = services.BuildServiceProvider();
using IServiceScope serviceScope = serviceProvider.CreateScope();

// when
var actualStateBroker =
serviceScope.ServiceProvider.GetRequiredService<IApiPlatformStateBroker>();

// then
actualStateBroker.GetType().Name.Should().Be("SessionApiPlatformStateBroker");
}

[Fact]
public async Task ShouldRoundTripTheCsrfStateThroughTheSessionAsync()
{
// given
ServiceProvider serviceProvider = BuildServiceProvider();
using IServiceScope serviceScope = serviceProvider.CreateScope();

var stateBroker =
serviceScope.ServiceProvider.GetRequiredService<IApiPlatformStateBroker>();

string randomState = Guid.NewGuid().ToString();

// when
await stateBroker.StoreCsrfStateAsync(randomState);

// then
string actualState = await stateBroker.GetCsrfStateAsync();
actualState.Should().Be(randomState);
}

[Fact]
public async Task ShouldRoundTripTheAccessTokenThroughTheSessionAsync()
{
// given
ServiceProvider serviceProvider = BuildServiceProvider();
using IServiceScope serviceScope = serviceProvider.CreateScope();

var tokenBroker =
serviceScope.ServiceProvider.GetRequiredService<IApiPlatformTokenBroker>();

string randomAccessToken = Guid.NewGuid().ToString();
DateTimeOffset expiresAtUtc = DateTimeOffset.UtcNow.AddHours(1);

// when
await tokenBroker.StoreAccessTokenAsync(randomAccessToken, expiresAtUtc);

// then
var (actualToken, _) = await tokenBroker.GetAccessTokenAsync();
actualToken.Should().Be(randomAccessToken);
}

private static DefaultHttpContext CreateHttpContext() =>
new DefaultHttpContext
{
Session = new IntegrationSession()
};

private static ServiceProvider BuildServiceProvider(bool withInMemoryStorage = false)
{
ApiPlatformConfigurations configurations =
ConfigurationProvider.GetApiPlatformConfigurations();

IServiceCollection services = new ServiceCollection();

services.AddSingleton<IHttpContextAccessor>(
new HttpContextAccessor { HttpContext = CreateHttpContext() });

services.AddApiPlatformSdkCore(configurations);
services.AddApiPlatformSdkAspNetCore();

if (withInMemoryStorage)
{
services.AddApiPlatformSdkInMemoryStorage();
}

return services.BuildServiceProvider();
}

private sealed class IntegrationSession : ISession
{
private readonly Dictionary<string, byte[]> store = new Dictionary<string, byte[]>();

public bool IsAvailable => true;
public string Id => "integration-session";
public IEnumerable<string> Keys => this.store.Keys;

public void Clear() => this.store.Clear();

public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;

public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;

public void Remove(string key) => this.store.Remove(key);

public void Set(string key, byte[] value) => this.store[key] = value;

public bool TryGetValue(string key, out byte[] value) => this.store.TryGetValue(key, out value);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,16 @@
{
"ApiPlatform": {
"CareIdentity": {
"AuthEndpoint": "https://int.api.service.nhs.uk/oauth2/authorize",
"TokenEndpoint": "https://int.api.service.nhs.uk/oauth2/token",
"UserInfoEndpoint": "https://int.api.service.nhs.uk/oauth2/userinfo",
"RedirectUri": "https://localhost:5174/auth/callback",
"ClientId": "",
"ClientSecret": "",
"AcrValues": ""
},
"PersonalDemographicsService": {
"BaseUrl": "https://int.api.service.nhs.uk/personal-demographics/FHIR/R4"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// ---------------------------------------------------------
// Copyright (c) North East London ICB. All rights reserved.
// ---------------------------------------------------------

using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using NHSDigital.ApiPlatform.Sdk.Models.Clients.CareIdentityService.Exceptions;
using Xunit;

namespace NHSDigital.ApiPlatform.Sdk.Tests.Integration.Clients.CareIdentityServices
{
public partial class CareIdentityServiceClientTests
{
[Fact]
public async Task ShouldThrowDependencyValidationExceptionOnGetUserInfoIfStateWasNeverIssuedAsync()
{
// given
string unknownState = GetRandomString();
string authorisationCode = GetRandomString();

// when
CareIdentityServiceClientDependencyValidationException actualException =
await Assert.ThrowsAsync<CareIdentityServiceClientDependencyValidationException>(async () =>
await this.careIdentityServiceClient.GetUserInfoAsync(authorisationCode, unknownState));

// then
actualException.InnerException.Message.Should().Be("Invalid state parameter.");
}

[Fact]
public async Task ShouldThrowValidationExceptionOnGetUserInfoIfCodeIsMissingAsync()
{
// given
string emptyCode = string.Empty;
string randomState = GetRandomString();

// when
// then
await Assert.ThrowsAsync<CareIdentityServiceClientValidationException>(async () =>
await this.careIdentityServiceClient.GetUserInfoAsync(emptyCode, randomState));
}

[Fact]
public async Task ShouldThrowOperationCanceledExceptionOnBuildLoginUrlIfTokenIsAlreadyCancelledAsync()
{
// given
using var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.Cancel();

// when
// then
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await this.careIdentityServiceClient.BuildLoginUrlAsync(cancellationTokenSource.Token));
}

[Fact(Skip = "Requires NHS CIS2 credentials and reaches the live INT token endpoint.")]
public async Task ShouldThrowDependencyExceptionOnGetUserInfoIfAuthorisationCodeIsRejectedAsync()
{
// given
string loginUrl = await this.careIdentityServiceClient.BuildLoginUrlAsync();
string state = ExtractQueryValue(loginUrl, "state");
string rejectedCode = GetRandomString();

// when
// then
await Assert.ThrowsAsync<CareIdentityServiceClientDependencyException>(async () =>
await this.careIdentityServiceClient.GetUserInfoAsync(rejectedCode, state));
}
}
}
Loading
Loading