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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<RootNamespace>Fhi.Authentication</RootNamespace>
<Version>2.1.0-local3</Version>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove before merge!

</PropertyGroup>
<ItemGroup>
<PackageReference Include="Duende.AccessTokenManagement.OpenIdConnect" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Globalization;
using Duende.AccessTokenManagement.OpenIdConnect;

namespace Fhi.Authentication.OpenIdConnect
{
Expand Down Expand Up @@ -44,8 +45,8 @@ public static async Task<TokenValidationResponse> ValidateToken(this CookieValid
var expiresAt = DateTimeOffset.Parse(tokens.SingleOrDefault(t => t.Name == ExpiresAt)?.Value ?? string.Empty, CultureInfo.InvariantCulture);
if (expiresAt <= DateTimeOffset.UtcNow)
{
var tokenService = context.HttpContext.RequestServices.GetRequiredService<ITokenService>();
var refreshedTokens = await tokenService.RefreshAccessTokenAsync(refreshToken.Value);
var userTokenEndpointService = context.HttpContext.RequestServices.GetRequiredService<IUserTokenEndpointService>();
var refreshedTokens = await userTokenEndpointService.RefreshAccessTokenAsync(new UserToken() { RefreshToken = refreshToken.Value }, new UserTokenRequestParameters());
if (refreshedTokens.IsError)
{
return new TokenValidationResponse(true, TokenValidationErrorCodes.ExpiredRefreshToken, "Refresh token is expired. Rejecting principal so that the user can re-authenticate");
Expand Down
62 changes: 41 additions & 21 deletions src/Fhi.Authentication.Extensions/OpenIdConnect/ITokenService.cs
Original file line number Diff line number Diff line change
@@ -1,39 +1,59 @@
using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.IdentityModel.Client;
using Fhi.Authentication.Setup;
using Microsoft.Extensions.Logging;

namespace Fhi.Authentication.OpenIdConnect
{
/// <summary>
/// Response for token validation.
/// </summary>
/// <param name="IsError"></param>
public record TokenResponse(bool IsError = false);
/// <summary>
/// Abstraction for token service.
/// TODO: response should be improved
/// </summary>
public record TokenResponse(string? AccessToken, bool IsError, string? ErrorDescription);
public interface ITokenService
{
/// <summary>
/// Refresh access token.
/// Create DPoP token
/// </summary>
/// <param name="refreshToken"></param>
/// <param name="authority">The OpenId connectprovider authority Url</param>
/// <param name="clientId">Client Identifier</param>
/// <param name="jwk">The private json web key for client assertion</param>
/// <param name="scopes">Separated list of scopes</param>
/// <param name="dPopJwk">The private json web key for DPoP</param>
/// <returns></returns>
Task<TokenResponse> RefreshAccessTokenAsync(string refreshToken);
public Task<TokenResponse> RequestDPoPToken(string authority, string clientId, string jwk, string scopes, string dPopJwk);
}

internal class DefaultTokenService(IUserTokenEndpointService UserTokenEndpointService, ILogger<DefaultTokenService> Logger) : ITokenService
public class TokenService(
ILogger<TokenService> Logger,
IHttpClientFactory HttpClientFactory) : ITokenService
{
public async Task<TokenResponse> RefreshAccessTokenAsync(string refreshToken)
public async Task<TokenResponse> RequestDPoPToken(
string authority,
string clientId,
string jwk,
string scopes,
string dPopJwk)
{
var userToken = await UserTokenEndpointService.RefreshAccessTokenAsync(new UserToken() { RefreshToken = refreshToken }, new UserTokenRequestParameters());
if (userToken.IsError)
var client = HttpClientFactory.CreateClient();
Logger.LogInformation("Get metadata from discovery endpoint from Authority {@Authority}", authority);
var discovery = await client.GetDiscoveryDocumentAsync(authority);
if (discovery is not null && !discovery.IsError && discovery.Issuer is not null && discovery.TokenEndpoint is not null)
{
Logger.LogError(message: userToken.Error);
return new TokenResponse(true);
var response = await client.RequestTokenWithDPoP(discovery, clientId, jwk, scopes, dPopJwk);

if (response.IsError &&
response.Error == "use_dpop_nonce" &&
response.HttpResponse?.Headers.Contains("DPoP-Nonce") == true)
{
var nonce = response.HttpResponse.Headers.GetValues("DPoP-Nonce").FirstOrDefault();
if (!string.IsNullOrEmpty(nonce))
{
response = await client.RequestTokenWithDPoP(discovery, clientId, jwk, scopes, dPopJwk, nonce);
}
}

return response.IsError ?
new TokenResponse(null, true, response.ErrorDescription) :
new TokenResponse(response.AccessToken, false, string.Empty);
}

return new TokenResponse();
return new TokenResponse(null, true, discovery is null ? "No discovery document" : discovery.Error);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Fhi.Authentication.OpenIdConnect;
using Duende.AccessTokenManagement.OpenIdConnect;
using Fhi.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
Expand All @@ -18,7 +19,8 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddOpenIdConnectCookieOptions(this IServiceCollection services)
{
services.AddTransient<OpenIdConnectCookieEventsForApi>();
services.AddTransient<ITokenService, DefaultTokenService>();
services.AddTransient<IUserTokenEndpointService, UserTokenEndpointService>();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trengs Tokenservice i denne lenger? Er jo flyttet opp til sampelet

services.AddTransient<ITokenService, TokenService>();
services.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>, OpenIdConnectCookieAuthenticationOptions>();

return services;
Expand Down
40 changes: 40 additions & 0 deletions src/Fhi.Authentication.Extensions/Setup/HttpClientExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Duende.IdentityModel;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

La oss ta en gjennomgang på denne. Jeg har noen tanker her.

using Duende.IdentityModel.Client;
using Fhi.Authentication.Tokens;

namespace Fhi.Authentication.Setup;

public static class HttpClientExtensions
{
public static async Task<TokenResponse> RequestTokenWithDPoP(
this HttpClient client,
DiscoveryDocumentResponse discovery,
string clientId,
string jwk,
string scopes,
string dPopJwk,
string? nonce = null)
{
var tokenRequest = new ClientCredentialsTokenRequest
{
ClientId = clientId,
Address = discovery.TokenEndpoint,
GrantType = OidcConstants.GrantTypes.ClientCredentials,
ClientCredentialStyle = ClientCredentialStyle.PostBody,
DPoPProofToken = DPoPProofGenerator.CreateDPoPProof(
discovery.TokenEndpoint!,
"POST",
dPopJwk,
"PS256",
dPoPNonce: nonce),
ClientAssertion = new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = ClientAssertionTokenHandler.CreateJwtToken(discovery.Issuer!, clientId, jwk)
},
Scope = scopes
};

return await client.RequestClientCredentialsTokenAsync(tokenRequest);
}
}
67 changes: 0 additions & 67 deletions src/Fhi.Authentication.Extensions/Tokens/DPoPProofHandler.cs

This file was deleted.

85 changes: 85 additions & 0 deletions src/Fhi.Authentication.Extensions/Tokens/DPopProofGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using Duende.IdentityModel;
using Microsoft.IdentityModel.Tokens;

namespace Fhi.Authentication.Tokens
{
// TODO: Need to go through this. Copied from HelseId samples and Duende. Should we handle multiple algorithms?
// need to figure out what algs, size etc. to support
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vi må se litt mer på denne. Mulig ny oppgave. Men skal den være sånn så bør vi la den være private.


public static class DPoPProofGenerator
{
/// <summary>
///
/// </summary>
/// <param name="url"></param>
/// <param name="httpMethod"></param>
/// <param name="key">public and private key</param>
/// <param name="keyAlgorithm"></param>
/// <param name="dPoPNonce"></param>
/// <param name="accessToken"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public static string CreateDPoPProof(string url, string httpMethod, string key, string keyAlgorithm, string? dPoPNonce = null, string? accessToken = null)
{
var securityKey = new JsonWebKey(key);
var signingCredentials = new SigningCredentials(securityKey, keyAlgorithm);

var jwk = securityKey.Kty switch
{
JsonWebAlgorithmsKeyTypes.EllipticCurve => new Dictionary<string, string>
{
[JsonWebKeyParameterNames.Kty] = securityKey.Kty,
[JsonWebKeyParameterNames.X] = securityKey.X,
[JsonWebKeyParameterNames.Y] = securityKey.Y,
[JsonWebKeyParameterNames.Crv] = securityKey.Crv,
},
JsonWebAlgorithmsKeyTypes.RSA => new Dictionary<string, string>
{
[JsonWebKeyParameterNames.Kty] = securityKey.Kty,
[JsonWebKeyParameterNames.N] = securityKey.N,
[JsonWebKeyParameterNames.E] = securityKey.E,
},
_ => throw new InvalidOperationException("Invalid key type for DPoP proof.")
};

var jwtHeader = new JwtHeader(signingCredentials)
{
[JwtClaimTypes.TokenType] = "dpop+jwt",
[JwtClaimTypes.JsonWebKey] = jwk,
};

var payload = new JwtPayload
{
[JwtClaimTypes.JwtId] = Guid.NewGuid().ToString(),
[JwtClaimTypes.DPoPHttpMethod] = httpMethod,
[JwtClaimTypes.DPoPHttpUrl] = url,
[JwtClaimTypes.IssuedAt] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
};

// Used when accessing the authentication server (HelseID):
if (!string.IsNullOrEmpty(dPoPNonce))
{
// nonce: A recent nonce provided via the DPoP-Nonce HTTP header.
payload[JwtClaimTypes.Nonce] = dPoPNonce;
}

// Used when accessing an API that requires a DPoP token:
if (!string.IsNullOrEmpty(accessToken))
{
// ath: hash of the access token. The value MUST be the result of a base64url encoding
// the SHA-256 [SHS] hash of the ASCII encoding of the associated access token's value.
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(accessToken));
var ath = Base64Url.Encode(hash);

payload[JwtClaimTypes.DPoPAccessTokenHash] = ath;
}

var jwtSecurityToken = new JwtSecurityToken(jwtHeader, payload);
return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
}
}
}
4 changes: 2 additions & 2 deletions tests/Fhi.Auth.IntegrationTests/AuthorizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public partial class Tests
[Test]
public async Task EndpointWithScopeAuthorization()
{
FakeAuthHandler.TestClaims =
Setup.Tests.FakeAuthHandler.TestClaims =
[
new System.Security.Claims.Claim("scope", "fhi:webapi/health-records.read"),
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "Test User")
Expand All @@ -31,7 +31,7 @@ public async Task EndpointWithScopeAuthorization()
.WithServices(services =>
{
services.AddAuthentication("Fake")
.AddScheme<AuthenticationSchemeOptions, FakeAuthHandler>("Fake", options => { });
.AddScheme<AuthenticationSchemeOptions, Setup.Tests.FakeAuthHandler>("Fake", options => { });
services.AddSingleton<IAuthorizationHandler, ScopeHandler>();
services.AddAuthorization();
});
Expand Down
2 changes: 1 addition & 1 deletion tests/Fhi.Auth.IntegrationTests/Setup/FakeAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Authentication;

namespace Fhi.Auth.IntegrationTests
namespace Fhi.Auth.IntegrationTests.Setup
{
public partial class Tests
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.Extensions.Logging;
using NSubstitute;
using System.Security.Claims;
using Duende.AccessTokenManagement.OpenIdConnect;

namespace Fhi.Authentication.Extensions.UnitTests.OpenIdConnect
{
Expand Down Expand Up @@ -121,11 +122,14 @@ internal class CookieContextBuilder
{
private readonly List<AuthenticationToken> _tokens = [];
private bool _isAuthenticated = true;
private static readonly ITokenService _tokenService = Substitute.For<ITokenService>();
private readonly IUserTokenEndpointService _userTokenEndpointService = Substitute.For<IUserTokenEndpointService>();

public CookieContextBuilder WithRefreshAccessTokenError(bool isError)
{
_tokenService.RefreshAccessTokenAsync(Arg.Any<string>()).Returns(Task.FromResult(new TokenResponse(isError)));
var userToken = isError ? new UserToken { Error = "invalid_grant" } : new UserToken();
_userTokenEndpointService
.RefreshAccessTokenAsync(Arg.Any<UserToken>(), Arg.Any<UserTokenRequestParameters>())
.Returns(Task.FromResult(userToken));
return this;
}

Expand All @@ -150,18 +154,10 @@ public CookieValidatePrincipalContext Build()
var principal = new ClaimsPrincipal(identity);
var props = new AuthenticationProperties();
props.StoreTokens(_tokens);
DefaultHttpContext httpContext;
if (_tokenService != null)
{
var services = new ServiceCollection();
services.AddSingleton<ITokenService>(_tokenService);
var serviceProvider = services.BuildServiceProvider();
httpContext = new DefaultHttpContext { RequestServices = serviceProvider };
}
else
{
httpContext = new DefaultHttpContext();
}
var services = new ServiceCollection();
services.AddSingleton(_userTokenEndpointService);
var serviceProvider = services.BuildServiceProvider();
var httpContext = new DefaultHttpContext { RequestServices = serviceProvider };
return new CookieValidatePrincipalContext(
httpContext,
new AuthenticationScheme("oidc", "oidc", typeof(CookieAuthenticationHandler)),
Expand Down
Loading