Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public HealthRecordService(IHttpClientFactory factory)
var response = await client.GetAsync("api/v1/me/health-records");
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
throw new UnauthorizedAccessException();
var wwwAuthenticate = response.Headers.WwwAuthenticate.ToString();
throw new UnauthorizedAccessException(wwwAuthenticate);
}
response.EnsureSuccessStatusCode();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ public async Task<IActionResult> Get()
catch (UnauthorizedAccessException ex)
{
logger.LogWarning(ex, "Downstream WebApi at {Url} returned 401 (token rejected or refresh failed)", DownstreamUrl);
var detail = string.IsNullOrWhiteSpace(ex.Message)
? $"The downstream WebApi at {DownstreamUrl} returned 401."
: $"The downstream WebApi at {DownstreamUrl} returned 401. WWW-Authenticate: {ex.Message}";
return Problem(
title: "Downstream WebApi rejected the access token",
detail: $"The downstream WebApi at {DownstreamUrl} returned 401.",
detail: detail,
statusCode: StatusCodes.Status502BadGateway);
}
catch (TaskCanceledException ex) when (!HttpContext.RequestAborted.IsCancellationRequested)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Duende.AccessTokenManagement;
using Duende.IdentityModel;
using Duende.IdentityModel.Client;
using Fhi.Authentication.Tokens;
using Microsoft.Extensions.Options;

namespace Fhi.Samples.Angular.BFFApi
{
internal class ClientAssertionService(IOptions<AuthenticationSettings> AuthOptions) : IClientAssertionService
{

public Task<ClientAssertion?> GetClientAssertionAsync(ClientCredentialsClientName? clientName = null, TokenRequestParameters? parameters = null, CancellationToken ct = default)
{
var clientAssertion = ClientAssertionTokenHandler.CreateJwtToken(
AuthOptions.Value.Authority,
AuthOptions.Value.ClientId,
AuthOptions.Value.ClientSecret);

return Task.FromResult<ClientAssertion?>(new ClientAssertion { Type = OidcConstants.ClientAssertionTypes.JwtBearer, Value = clientAssertion });
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using AngularBFF.Net8.Api.HealthRecords;
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.DPoP;
using Duende.AccessTokenManagement.OpenIdConnect;
using Fhi.Authentication;
using Fhi.Authentication.OpenIdConnect;
using Fhi.Samples.Angular.BFFApi;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
Expand Down Expand Up @@ -84,6 +86,7 @@
options.Scope.Add(scope);
}
});
builder.Services.AddTransient<IClientAssertionService, ClientAssertionService>();
builder.Services.AddOpenIdConnectCookieOptions();
builder.Services.AddSingleton<IPostConfigureOptions<OpenIdConnectOptions>, DefaultOpenIdConnectOptions>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,6 @@ public class HealthRecordController(IHealthRecordService healthRecordService) :
{
private readonly IHealthRecordService _healthRecordService = healthRecordService;

[HttpGet("helseid-bearer")]
[Scope("fhi:authextensions.samples/access")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.HelseIdBearer, Policy = Policies.IntegrationPolicy)]
public IEnumerable<HealthRecordDto> GetWithHelseIdBearerToken()
{
return _healthRecordService.GetHealthRecords().Select(r => new HealthRecordDto(r.Name, r.Description, r.CreatedAt));
}

[HttpGet("helseid-dpop")]
[Scope("fhi:authextensions.samples/access")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.HelseIdDPoP, Policy = Policies.IntegrationPolicy)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,11 @@ public class HealthRecordController(IHealthRecordService healthRecordService) :
{
private readonly IHealthRecordService _healthRecordService = healthRecordService;

[HttpGet("helseid-bearer")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.HelseIdBearer, Policy = Policies.EndUserPolicy)]
public IEnumerable<HealthRecordPersonDto> GetWithHelseIdBearer()
{
Task.Delay(1000).Wait();
return _healthRecordService.GetHealthRecords().Select(r => new HealthRecordPersonDto(r.Pid, r.Name, r.Description, r.CreatedAt));
}

[HttpGet]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.HelseIdBearer, Policy = Policies.EndUserPolicy)]
public ActionResult<UserDto> GetMe()
[Authorize(AuthenticationSchemes = $"{AuthenticationSchemes.HelseIdDPoP},{AuthenticationSchemes.Duende}", Policy = Policies.EndUserPolicy)]
public IEnumerable<HealthRecordPersonDto> GetHealthRecords()
{
return Ok(new UserDto(
User.Identity?.Name,
User.Claims.Select(c => new ClaimDto(c.Type, c.Value)).ToList(),
User.Identity?.AuthenticationType ?? string.Empty));
return _healthRecordService.GetHealthRecords().Select(r => new HealthRecordPersonDto(r.Pid, r.Name, r.Description, r.CreatedAt));
}
}
}
2 changes: 0 additions & 2 deletions samples/Fhi.Samples.WebApi/Hosting/AuthenticationSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ internal class AuthenticationSettings

internal class AuthenticationSchemes
{
public const string HelseIdBearer = "HelseIdBearer";
public const string HelseIdDPoP = "HelseIdDPoP";
public const string Duende = "Duende";
public const string MaskinPorten = "MaskinPorten";
public const string IdPorten = "ID-Porten";
}

internal class Policies
Expand Down
18 changes: 2 additions & 16 deletions samples/Fhi.Samples.WebApi/Hosting/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,7 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde
********************************************************************************************************/
var authenticationBuilder = builder.Services.AddAuthentication();

var helseIdSection = builder.Configuration.GetSection($"AuthenticationSchemes:{AuthenticationSchemes.HelseIdBearer}");
var helseIdOptions = helseIdSection.Get<AuthenticationSettings>() ?? new AuthenticationSettings();
authenticationBuilder
.AddJwtBearer(AuthenticationSchemes.HelseIdBearer, options =>
{
options.Audience = helseIdOptions.Audience;
options.Authority = helseIdOptions.Authority;
options.Events = new JwtBearerEvents()
{
OnChallenge = OnChallenge(AuthenticationSchemes.HelseIdBearer)
};

});

//TODO: DPoP bibliotek støtter ikke claim mapper og bruker nameidentifier i stede for sub.
var helseIdDpopSection = builder.Configuration.GetSection($"AuthenticationSchemes:{AuthenticationSchemes.HelseIdDPoP}");
var helseIdDpopOptions = helseIdDpopSection.Get<AuthenticationSettings>() ?? new AuthenticationSettings();
authenticationBuilder
Expand Down Expand Up @@ -85,8 +72,7 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde
.Build())
.AddPolicy(Policies.EndUserPolicy, policy =>
{
policy.RequireClaim(JwtClaimTypes.Subject);
//Ensure the end-user "sub" claim is present
//Ensure the end-user "sub" claim is present.
policy.RequireClaim(JwtClaimTypes.Subject);
policy.RequireAuthenticatedUser();
})
Expand Down
4 changes: 0 additions & 4 deletions samples/Fhi.Samples.WebApi/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@
}
},
"AuthenticationSchemes": {
"HelseIdBearer": {
"Authority": "https://helseid-sts.test.nhn.no/",
"Audience": "fhi:authextensions.samples"
},
"HelseIdDPoP": {
"Authority": "https://helseid-sts.test.nhn.no/",
"Audience": "fhi:authextensions.samples"
Expand Down
4 changes: 0 additions & 4 deletions samples/Fhi.Samples.WebApi/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,5 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Authentication": {
"Authority": "",
"Audience": ""
}
}
14 changes: 11 additions & 3 deletions src/Fhi.Authentication.Extensions/OpenIdConnect/ITokenService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.OpenIdConnect;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Fhi.Authentication.OpenIdConnect
{
Expand All @@ -23,12 +24,19 @@ public interface ITokenService
Task<TokenResponse> RefreshAccessTokenAsync(string refreshToken);
}

internal class DefaultTokenService(IOpenIdConnectUserTokenEndpoint UserTokenEndpointService, ILogger<DefaultTokenService> Logger) : ITokenService
internal class DefaultTokenService(
IOpenIdConnectUserTokenEndpoint UserTokenEndpointService,
IOptions<UserTokenManagementOptions> Options,
ILogger<DefaultTokenService> Logger) : ITokenService
{
public async Task<TokenResponse> RefreshAccessTokenAsync(string refreshToken)
{
var userToken = await UserTokenEndpointService.RefreshAccessTokenAsync(new UserRefreshToken(RefreshToken.Parse(refreshToken), null), new UserTokenRequestParameters());

var dPoPKey = Options.Value.DPoPJsonWebKey;

var userToken = await UserTokenEndpointService.RefreshAccessTokenAsync(
new UserRefreshToken(RefreshToken.Parse(refreshToken), dPoPKey),
new UserTokenRequestParameters());

if (userToken.FailedResult is not null)
{
Logger.LogError(message: userToken.FailedResult.Error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.DPoP;
using Fhi.Auth.IntegrationTests.Setup;
using Fhi.Authentication;
using Microsoft.AspNetCore.Builder;
Expand Down Expand Up @@ -37,9 +38,8 @@ public async Task HelseIdClientTest()
authority!,
configuration.GetValue<string>("HelseIdClient:ClientId")!,
PrivateJwk.ParseFromJson(privateJwk!),
configuration.GetValue<string>("HelseIdClient:Scope")!)
// With DPoP support
////DPoPProofKey.ParseOrDefault(PrivateJwk.ParseFromJson(privateJwk!)))
configuration.GetValue<string>("HelseIdClient:Scope")!,
DPoPProofKey.ParseOrDefault(PrivateJwk.ParseFromJson(privateJwk!)))
.AddClientCredentialsHttpClient(client =>
{
client.BaseAddress = new Uri("http://localhost:8888");
Expand Down Expand Up @@ -69,7 +69,7 @@ public async Task HelseIdClientTest()
var response = await clientApp.Services.GetRequiredService<MyService>().Get();
var content = await response.Content.ReadAsStringAsync();
Assert.That(response.IsSuccessStatusCode, Is.True);
Assert.That(content, Does.StartWith("Bearer "));
Assert.That(content, Does.StartWith("DPoP "));

await clientApp.StopAsync();
await apiApp.StopAsync();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,67 @@
using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.DPoP;
using Duende.AccessTokenManagement.OpenIdConnect;
using Fhi.Authentication.OpenIdConnect;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;

namespace Fhi.Authentication.Extensions.UnitTests.OpenIdConnect
{
//public class TokenServiceTest
//{
// [Test]
// public async Task RefreshAccessToken_ReturnsSuccess_WhenNoError()
// {
// var userTokenEndpointService = Substitute.For<IUserTokenEndpointService>();
// var logger = Substitute.For<ILogger<DefaultTokenService>>();
// var refreshToken = "refresh-token";
// userTokenEndpointService.RefreshAccessTokenAsync(
// Arg.Any<UserToken>(), Arg.Any<UserTokenRequestParameters>())
// .Returns(Task.FromResult(new UserToken()));

// var service = new DefaultTokenService(userTokenEndpointService, logger);
// var result = await service.RefreshAccessTokenAsync(refreshToken);

// Assert.That(result.IsError, Is.False);
// }

// [Test]
// public async Task RefreshAccessToken_ReturnsError_WhenUserTokenIsError()
// {
// var userTokenEndpointService = Substitute.For<IUserTokenEndpointService>();
// var logger = Substitute.For<ILogger<DefaultTokenService>>();
// var refreshToken = "refresh-token";
// userTokenEndpointService.RefreshAccessTokenAsync(
// Arg.Any<UserToken>(), Arg.Any<UserTokenRequestParameters>())
// .Returns(Task.FromResult(new UserToken { Error = "invalid grant" }));

// var service = new DefaultTokenService(userTokenEndpointService, logger);
// var result = await service.RefreshAccessTokenAsync(refreshToken);

// Assert.That(result.IsError, Is.True);
// }
//}
public class TokenServiceTest
{
[Test]
public async Task RefreshAccessToken_ReturnsSuccess_WhenNoError()
{
var endpoint = Substitute.For<IOpenIdConnectUserTokenEndpoint>();
endpoint.RefreshAccessTokenAsync(Arg.Any<UserRefreshToken>(), Arg.Any<UserTokenRequestParameters>())
.Returns(TokenResult.Success(new UserToken { AccessTokenType = AccessTokenType.Parse("Bearer"), Expiration = DateTime.UtcNow.AddHours(1), ClientId = ClientId.Parse("clientId"), AccessToken = AccessToken.Parse("new-at") }));

var result = await CreateService(endpoint).RefreshAccessTokenAsync("refresh-token");

Assert.That(result.IsError, Is.False);
}

[Test]
public async Task RefreshAccessToken_ReturnsError_WhenEndpointFails()
{
var endpoint = Substitute.For<IOpenIdConnectUserTokenEndpoint>();
endpoint.RefreshAccessTokenAsync(Arg.Any<UserRefreshToken>(), Arg.Any<UserTokenRequestParameters>())
.Returns(TokenResult.Failure("invalid_grant", null));

var result = await CreateService(endpoint).RefreshAccessTokenAsync("refresh-token");

Assert.That(result.IsError, Is.True);
}

[Test]
public async Task RefreshAccessToken_SendsNullDPoPKey_WhenNoDPoPKeyInOptions()
{
var endpoint = Substitute.For<IOpenIdConnectUserTokenEndpoint>();
endpoint.RefreshAccessTokenAsync(Arg.Any<UserRefreshToken>(), Arg.Any<UserTokenRequestParameters>())
.Returns(TokenResult.Success(new UserToken { AccessTokenType = AccessTokenType.Parse("Bearer"), Expiration = DateTime.UtcNow.AddHours(1), ClientId = ClientId.Parse("clientId"), AccessToken = AccessToken.Parse("at") }));

await CreateService(endpoint, CreateOptions(dPoPJsonWebKey: null)).RefreshAccessTokenAsync("refresh-token");

await endpoint.Received(1).RefreshAccessTokenAsync(
Arg.Is<UserRefreshToken>(t => t.DPoPProofKey == null),
Arg.Any<UserTokenRequestParameters>());
}

private static IOptions<UserTokenManagementOptions> CreateOptions(string? dPoPJsonWebKey = null)
{
var options = Substitute.For<IOptions<UserTokenManagementOptions>>();
options.Value.Returns(new UserTokenManagementOptions { DPoPJsonWebKey = dPoPJsonWebKey is null ? null : DPoPProofKey.Parse(dPoPJsonWebKey) });
return options;
}

private static ILogger<DefaultTokenService> CreateLogger() =>
Substitute.For<ILogger<DefaultTokenService>>();

private static DefaultTokenService CreateService(
IOpenIdConnectUserTokenEndpoint endpoint,
IOptions<UserTokenManagementOptions>? options = null,
ILogger<DefaultTokenService>? logger = null) =>
new(endpoint, options ?? CreateOptions(), logger ?? CreateLogger());
}
}