diff --git a/IdentityServer/v7/DPoP/Api/Api.csproj b/IdentityServer/v7/DPoP/Api/Api.csproj index 4969d1c2..a7051c50 100644 --- a/IdentityServer/v7/DPoP/Api/Api.csproj +++ b/IdentityServer/v7/DPoP/Api/Api.csproj @@ -8,7 +8,7 @@ - + diff --git a/IdentityServer/v7/DPoP/Api/DPoP/ConfigureJwtBearerOptions.cs b/IdentityServer/v7/DPoP/Api/DPoP/ConfigureJwtBearerOptions.cs deleted file mode 100644 index 53279060..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/ConfigureJwtBearerOptions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Options; - -namespace Api; - -public class ConfigureJwtBearerOptions : IPostConfigureOptions -{ - private readonly string _configScheme; - - public ConfigureJwtBearerOptions(string configScheme) - { - _configScheme = configScheme; - } - - public void PostConfigure(string? name, JwtBearerOptions options) - { - if (_configScheme == name) - { - if (options.EventsType != null && !typeof(DPoPJwtBearerEvents).IsAssignableFrom(options.EventsType)) - { - throw new Exception("EventsType on JwtBearerOptions must derive from DPoPJwtBearerEvents to work with the DPoP support."); - } - if (options.Events != null && !typeof(DPoPJwtBearerEvents).IsAssignableFrom(options.Events.GetType())) - { - throw new Exception("Events on JwtBearerOptions must derive from DPoPJwtBearerEvents to work with the DPoP support."); - } - - if (options.Events == null && options.EventsType == null) - { - options.EventsType = typeof(DPoPJwtBearerEvents); - } - } - } -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DPoPExtensions.cs b/IdentityServer/v7/DPoP/Api/DPoP/DPoPExtensions.cs deleted file mode 100644 index b56c7bc1..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DPoPExtensions.cs +++ /dev/null @@ -1,66 +0,0 @@ -using IdentityModel; -using Microsoft.AspNetCore.Authentication; -using Microsoft.IdentityModel.Tokens; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; - -namespace Api; - -/// -/// Extensions methods for DPoP -/// -static class DPoPExtensions -{ - const string DPoPPrefix = OidcConstants.AuthenticationSchemes.AuthorizationHeaderDPoP + " "; - - public static bool IsDPoPAuthorizationScheme(this HttpRequest request) - { - var authz = request.Headers.Authorization.FirstOrDefault(); - return authz?.StartsWith(DPoPPrefix, System.StringComparison.Ordinal) == true; - } - - public static bool TryGetDPoPAccessToken(this HttpRequest request, [NotNullWhen(true)]out string? token) - { - token = null; - - var authz = request.Headers.Authorization.FirstOrDefault(); - if (authz?.StartsWith(DPoPPrefix, System.StringComparison.Ordinal) == true) - { - token = authz[DPoPPrefix.Length..].Trim(); - return true; - } - return false; - } - - public static string? GetAuthorizationScheme(this HttpRequest request) - { - return request.Headers.Authorization.FirstOrDefault()?.Split(' ', System.StringSplitOptions.RemoveEmptyEntries)[0]; - } - - public static string? GetDPoPProofToken(this HttpRequest request) - { - return request.Headers[OidcConstants.HttpHeaders.DPoP].FirstOrDefault(); - } - - public static string? GetDPoPNonce(this AuthenticationProperties props) - { - if (props.Items.ContainsKey("DPoP-Nonce")) - { - return props.Items["DPoP-Nonce"] as string; - } - return null; - } - public static void SetDPoPNonce(this AuthenticationProperties props, string nonce) - { - props.Items["DPoP-Nonce"] = nonce; - } - - /// - /// Create the value of a thumbprint - /// - public static string CreateThumbprint(this JsonWebKey jwk) - { - var jkt = Base64Url.Encode(jwk.ComputeJwkThumbprint()); - return jkt; - } -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DPoPJwtBearerEvents.cs b/IdentityServer/v7/DPoP/Api/DPoP/DPoPJwtBearerEvents.cs deleted file mode 100644 index a20af3ba..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DPoPJwtBearerEvents.cs +++ /dev/null @@ -1,155 +0,0 @@ -using IdentityModel; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Options; -using Microsoft.Net.Http.Headers; -using System.Text; -using static IdentityModel.OidcConstants; - -namespace Api; - -public class DPoPJwtBearerEvents : JwtBearerEvents -{ - private readonly IOptionsMonitor _optionsMonitor; - private readonly DPoPProofValidator _validator; - - public DPoPJwtBearerEvents(IOptionsMonitor optionsMonitor, DPoPProofValidator validator) - { - _optionsMonitor = optionsMonitor; - _validator = validator; - } - - public override Task MessageReceived(MessageReceivedContext context) - { - var dpopOptions = _optionsMonitor.Get(context.Scheme.Name); - - if (context.HttpContext.Request.TryGetDPoPAccessToken(out var token)) - { - context.Token = token; - } - else if (dpopOptions.Mode == DPoPMode.DPoPOnly) - { - // this rejects the attempt for this handler, - // since we don't want to attempt Bearer given the Mode - context.NoResult(); - } - - return Task.CompletedTask; - } - - public override async Task TokenValidated(TokenValidatedContext context) - { - var dpopOptions = _optionsMonitor.Get(context.Scheme.Name); - - if (context.HttpContext.Request.TryGetDPoPAccessToken(out var at)) - { - var proofToken = context.HttpContext.Request.GetDPoPProofToken(); - if (proofToken == null) - { - throw new InvalidOperationException("Missing DPoP (proof token) HTTP header"); - } - var result = await _validator.ValidateAsync(new DPoPProofValidatonContext - { - Scheme = context.Scheme.Name, - ProofToken = proofToken, - AccessToken = at, - Method = context.HttpContext.Request.Method, - Url = context.HttpContext.Request.Scheme + "://" + context.HttpContext.Request.Host + context.HttpContext.Request.PathBase + context.HttpContext.Request.Path - }); - - if (result.IsError) - { - // fails the result - context.Fail(result.ErrorDescription ?? result.Error ?? throw new Exception("No ErrorDescription or Error set.")); - - // we need to stash these values away so they are available later when the Challenge method is called later - context.HttpContext.Items["DPoP-Error"] = result.Error; - if (!string.IsNullOrWhiteSpace(result.ErrorDescription)) - { - context.HttpContext.Items["DPoP-ErrorDescription"] = result.ErrorDescription; - } - if (!string.IsNullOrWhiteSpace(result.ServerIssuedNonce)) - { - context.HttpContext.Items["DPoP-Nonce"] = result.ServerIssuedNonce; - } - } - } - else if (dpopOptions.Mode == DPoPMode.DPoPAndBearer) - { - // if the scheme used was not DPoP, then it was Bearer - // and if a access token was presented with a cnf, then the - // client should have sent it as DPoP, so we fail the request - if (context.Principal?.HasClaim(x => x.Type == JwtClaimTypes.Confirmation) ?? false) - { - context.HttpContext.Items["Bearer-ErrorDescription"] = "Must use DPoP when using an access token with a 'cnf' claim"; - context.Fail("Must use DPoP when using an access token with a 'cnf' claim"); - } - } - } - - public override Task Challenge(JwtBearerChallengeContext context) - { - var dpopOptions = _optionsMonitor.Get(context.Scheme.Name); - - if (dpopOptions.Mode == DPoPMode.DPoPOnly) - { - // if we are using DPoP only, then we don't need/want the default - // JwtBearerHandler to add its WWW-Authenticate response header - // so we have to set the status code ourselves - context.Response.StatusCode = 401; - context.HandleResponse(); - } - else if (context.HttpContext.Items.ContainsKey("Bearer-ErrorDescription")) - { - var description = context.HttpContext.Items["Bearer-ErrorDescription"] as string; - context.ErrorDescription = description; - } - - if (context.HttpContext.Request.IsDPoPAuthorizationScheme()) - { - // if we are challening due to dpop, then don't allow bearer www-auth to emit an error - context.Error = null; - } - - // now we always want to add our WWW-Authenticate for DPoP - // For example: - // WWW-Authenticate: DPoP error="invalid_dpop_proof", error_description="Invalid 'iat' value." - var sb = new StringBuilder(); - sb.Append(OidcConstants.AuthenticationSchemes.AuthorizationHeaderDPoP); - - if (context.HttpContext.Items.ContainsKey("DPoP-Error")) - { - var error = context.HttpContext.Items["DPoP-Error"] as string; - sb.Append(" error=\""); - sb.Append(error); - sb.Append('\"'); - - if (context.HttpContext.Items.ContainsKey("DPoP-ErrorDescription")) - { - var description = context.HttpContext.Items["DPoP-ErrorDescription"] as string; - - sb.Append(", error_description=\""); - sb.Append(description); - sb.Append('\"'); - } - } - - context.Response.Headers.Append(HeaderNames.WWWAuthenticate, sb.ToString()); - - - if (context.HttpContext.Items.ContainsKey("DPoP-Nonce")) - { - var nonce = context.HttpContext.Items["DPoP-Nonce"] as string; - context.Response.Headers[HttpHeaders.DPoPNonce] = nonce; - } - else - { - var nonce = context.Properties.GetDPoPNonce(); - if (nonce != null) - { - context.Response.Headers[HttpHeaders.DPoPNonce] = nonce; - } - } - - return Task.CompletedTask; - } -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DPoPMode.cs b/IdentityServer/v7/DPoP/Api/DPoP/DPoPMode.cs deleted file mode 100644 index 14e674fb..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DPoPMode.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Api; - -public enum DPoPMode -{ - /// - /// Only DPoP tokens will be accepted - /// - DPoPOnly, - /// - /// Both DPoP and Bearer tokens will be accepted - /// - DPoPAndBearer -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DPoPOptions.cs b/IdentityServer/v7/DPoP/Api/DPoP/DPoPOptions.cs deleted file mode 100644 index befffa72..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DPoPOptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Api; - -public class DPoPOptions -{ - public DPoPMode Mode { get; set; } = DPoPMode.DPoPOnly; - - public TimeSpan ProofTokenValidityDuration { get; set; } = TimeSpan.FromSeconds(1); - public TimeSpan ClientClockSkew { get; set; } = TimeSpan.FromMinutes(0); - public TimeSpan ServerClockSkew { get; set; } = TimeSpan.FromMinutes(5); - - public bool ValidateIat { get; set; } = true; - public bool ValidateNonce { get; set; } = false; -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidatonContext.cs b/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidatonContext.cs deleted file mode 100644 index a4ca897e..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidatonContext.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Security.Claims; - -namespace Api; - -public class DPoPProofValidatonContext -{ - /// - /// The ASP.NET Core authentication scheme triggering the validation - /// - public required string Scheme { get; set; } - - /// - /// The HTTP URL to validate - /// - public required string Url { get; set; } - - /// - /// The HTTP method to validate - /// - public required string Method { get; set; } - - /// - /// The DPoP proof token to validate - /// - public required string ProofToken { get; set; } - - /// - /// The access token - /// - public required string AccessToken { get; set; } - - /// - /// The claims associated with the access token. - /// - public IEnumerable AccessTokenClaims { get; set; } = Enumerable.Empty(); - -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidatonResult.cs b/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidatonResult.cs deleted file mode 100644 index 2c958a3a..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidatonResult.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace Api; - -public class DPoPProofValidatonResult -{ - public static DPoPProofValidatonResult Success = new DPoPProofValidatonResult { IsError = false }; - - /// - /// Indicates if the result was successful or not - /// - public bool IsError { get; set; } - - /// - /// The error code for the validation result - /// - public string? Error { get; set; } - - /// - /// The error description code for the validation result - /// - public string? ErrorDescription { get; set; } - - /// - /// The serialized JWK from the validated DPoP proof token. - /// - public string? JsonWebKey { get; set; } - - /// - /// The JWK thumbprint from the validated DPoP proof token. - /// - public string? JsonWebKeyThumbprint { get; set; } - - /// - /// The cnf value for the DPoP proof token - /// - public string? Confirmation { get; set; } - - /// - /// The payload value of the DPoP proof token. - /// - public IDictionary? Payload { get; internal set; } - - /// - /// The jti value read from the payload. - /// - public string? TokenId { get; set; } - - /// - /// The ath value read from the payload. - /// - public string? AccessTokenHash { get; set; } - - /// - /// The nonce value read from the payload. - /// - public string? Nonce { get; set; } - - /// - /// The iat value read from the payload. - /// - public long? IssuedAt { get; set; } - - /// - /// The nonce value issued by the server. - /// - public string? ServerIssuedNonce { get; set; } -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidator.cs b/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidator.cs deleted file mode 100644 index df0348a3..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DPoPProofValidator.cs +++ /dev/null @@ -1,511 +0,0 @@ -using IdentityModel; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; - -namespace Api; - -public class DPoPProofValidator -{ - const string ReplayCachePurpose = "DPoPJwtBearerEvents-DPoPReplay-jti-"; - const string DataProtectorPurpose = "DPoPJwtBearerEvents-DPoPProofValidation-nonce"; - - public readonly static IEnumerable SupportedDPoPSigningAlgorithms = new[] - { - SecurityAlgorithms.RsaSha256, - SecurityAlgorithms.RsaSha384, - SecurityAlgorithms.RsaSha512, - - SecurityAlgorithms.RsaSsaPssSha256, - SecurityAlgorithms.RsaSsaPssSha384, - SecurityAlgorithms.RsaSsaPssSha512, - - SecurityAlgorithms.EcdsaSha256, - SecurityAlgorithms.EcdsaSha384, - SecurityAlgorithms.EcdsaSha512 - }; - - protected readonly IOptionsMonitor OptionsMonitor; - protected readonly IDataProtector DataProtector; - protected readonly IReplayCache ReplayCache; - protected readonly ILogger Logger; - - public DPoPProofValidator(IOptionsMonitor optionsMonitor, IDataProtectionProvider dataProtectionProvider, IReplayCache replayCache, ILogger logger) - { - OptionsMonitor = optionsMonitor; - DataProtector = dataProtectionProvider.CreateProtector(DataProtectorPurpose); - ReplayCache = replayCache; - Logger = logger; - } - - /// - public async Task ValidateAsync(DPoPProofValidatonContext context) - { - var result = new DPoPProofValidatonResult() { IsError = false }; - - try - { - if (String.IsNullOrEmpty(context?.ProofToken)) - { - result.IsError = true; - result.ErrorDescription = "Missing DPoP proof value."; - return result; - } - - await ValidateHeaderAsync(context, result); - if (result.IsError) - { - Logger.LogDebug("Failed to validate DPoP header"); - return result; - } - - await ValidateSignatureAsync(context, result); - if (result.IsError) - { - Logger.LogDebug("Failed to validate DPoP signature"); - return result; - } - - await ValidatePayloadAsync(context, result); - if (result.IsError) - { - Logger.LogDebug("Failed to validate DPoP payload"); - return result; - } - - Logger.LogDebug("Successfully validated DPoP proof token"); - result.IsError = false; - } - finally - { - if (result.IsError) - { - result.Error = OidcConstants.TokenErrors.InvalidDPoPProof; - } - } - - return result; - } - - /// - /// Validates the header. - /// - protected virtual Task ValidateHeaderAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - JsonWebToken token; - var handler = new JsonWebTokenHandler(); - - try - { - token = handler.ReadJsonWebToken(context.ProofToken); - } - catch (Exception ex) - { - Logger.LogDebug("Error parsing DPoP token: {error}", ex.Message); - result.IsError = true; - result.ErrorDescription = "Malformed DPoP token."; - return Task.CompletedTask; - } - - if (!token.TryGetHeaderValue("typ", out var typ) || typ != JwtClaimTypes.JwtTypes.DPoPProofToken) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'typ' value."; - return Task.CompletedTask; - } - - if (!token.TryGetHeaderValue("alg", out var alg) || !SupportedDPoPSigningAlgorithms.Contains(alg)) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'alg' value."; - return Task.CompletedTask; - } - - if (!token.TryGetHeaderValue(JwtClaimTypes.JsonWebKey, out var jwkValues)) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'jwk' value."; - return Task.CompletedTask; - } - - var jwkJson = JsonSerializer.Serialize(jwkValues); - - JsonWebKey jwk; - try - { - jwk = new JsonWebKey(jwkJson); - } - catch (Exception ex) - { - Logger.LogDebug("Error parsing DPoP jwk value: {error}", ex.Message); - result.IsError = true; - result.ErrorDescription = "Invalid 'jwk' value."; - return Task.CompletedTask; - } - - if (jwk.HasPrivateKey) - { - result.IsError = true; - result.ErrorDescription = "'jwk' value contains a private key."; - return Task.CompletedTask; - } - - result.JsonWebKey = jwkJson; - result.JsonWebKeyThumbprint = jwk.CreateThumbprint(); - - var accessToken = handler.ReadJsonWebToken(context.AccessToken); - var cnf = accessToken.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Confirmation); - if (cnf == null) - { - result.IsError = true; - result.ErrorDescription = "Missing 'cnf' value."; - return Task.CompletedTask; - } - var json = JsonSerializer.Deserialize>(cnf.Value); - if (json == null) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'cnf' value."; - return Task.CompletedTask; - } - if (json.TryGetValue(JwtClaimTypes.ConfirmationMethods.JwkThumbprint, out var jktJson)) - { - var accessTokenJkt = jktJson.ToString(); - if (accessTokenJkt != result.JsonWebKeyThumbprint) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'cnf' value."; - return Task.CompletedTask; - } - result.Confirmation = cnf.Value; - } - - return Task.CompletedTask; - } - - /// - /// Validates the signature. - /// - protected virtual async Task ValidateSignatureAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - TokenValidationResult? tokenValidationResult = null; - - try - { - var key = new JsonWebKey(result.JsonWebKey); - var tvp = new TokenValidationParameters - { - ValidateAudience = false, - ValidateIssuer = false, - ValidateLifetime = false, - IssuerSigningKey = key, - }; - - var handler = new JsonWebTokenHandler(); - tokenValidationResult = await handler.ValidateTokenAsync(context.ProofToken, tvp); - } - catch (Exception ex) - { - Logger.LogDebug("Error parsing DPoP token: {error}", ex.Message); - result.IsError = true; - result.ErrorDescription = "Invalid signature on DPoP token."; - } - - if (tokenValidationResult?.Exception != null) - { - Logger.LogDebug("Error parsing DPoP token: {error}", tokenValidationResult.Exception.Message); - result.IsError = true; - result.ErrorDescription = "Invalid signature on DPoP token."; - } - - if (tokenValidationResult != null) - { - result.Payload = tokenValidationResult.Claims; - } - } - - /// - /// Validates the payload. - /// - protected virtual async Task ValidatePayloadAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - if(result.Payload is null ) - { - result.IsError = true; - result.ErrorDescription = "Missing payload"; - return; - } - - if (result.Payload.TryGetValue(JwtClaimTypes.DPoPAccessTokenHash, out var ath)) - { - result.AccessTokenHash = ath as string; - } - - if (String.IsNullOrEmpty(result.AccessTokenHash)) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'ath' value."; - return; - } - - using (var sha = SHA256.Create()) - { - var bytes = Encoding.UTF8.GetBytes(context.AccessToken); - var hash = sha.ComputeHash(bytes); - - var accessTokenHash = Base64Url.Encode(hash); - if (accessTokenHash != result.AccessTokenHash) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'ath' value."; - return; - } - } - - if (result.Payload.TryGetValue(JwtClaimTypes.JwtId, out var jti)) - { - result.TokenId = jti as string; - } - - if (String.IsNullOrEmpty(result.TokenId)) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'jti' value."; - return; - } - - if (!result.Payload.TryGetValue(JwtClaimTypes.DPoPHttpMethod, out var htm) || !context.Method.Equals(htm)) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'htm' value."; - return; - } - - if (!result.Payload.TryGetValue(JwtClaimTypes.DPoPHttpUrl, out var htu) || !context.Url.Equals(htu)) - { - result.IsError = true; - result.ErrorDescription = "Invalid 'htu' value."; - return; - } - - if (result.Payload.TryGetValue(JwtClaimTypes.IssuedAt, out var iat)) - { - if (iat is int) - { - result.IssuedAt = (int) iat; - } - if (iat is long) - { - result.IssuedAt = (long) iat; - } - } - - if (!result.IssuedAt.HasValue) - { - result.IsError = true; - result.ErrorDescription = "Missing 'iat' value."; - return; - } - - if (result.Payload.TryGetValue(JwtClaimTypes.Nonce, out var nonce)) - { - result.Nonce = nonce as string; - } - - await ValidateFreshnessAsync(context, result); - if (result.IsError) - { - Logger.LogDebug("Failed to validate DPoP token freshness"); - return; - } - - // we do replay at the end so we only add to the reply cache if everything else is ok - await ValidateReplayAsync(context, result); - if (result.IsError) - { - Logger.LogDebug("Detected replay of DPoP token"); - return; - } - } - - /// - /// Validates is the token has been replayed. - /// - protected virtual async Task ValidateReplayAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - var dpopOptions = OptionsMonitor.Get(context.Scheme); - - if (await ReplayCache.ExistsAsync(ReplayCachePurpose, result.TokenId!)) // jti is required by an earlier validation - { - result.IsError = true; - result.ErrorDescription = "Detected DPoP proof token replay."; - return; - } - - // get largest skew based on how client's freshness is validated - var validateIat = dpopOptions.ValidateIat; - var validateNonce = dpopOptions.ValidateNonce; - var skew = TimeSpan.Zero; - if (validateIat && dpopOptions.ClientClockSkew > skew) - { - skew = dpopOptions.ClientClockSkew; - } - if (validateNonce && dpopOptions.ServerClockSkew > skew) - { - skew = dpopOptions.ServerClockSkew; - } - - // we do x2 here because clock might be might be before or after, so we're making cache duration - // longer than the likelyhood of proof token expiration, which is done before replay - skew *= 2; - var cacheDuration = dpopOptions.ProofTokenValidityDuration + skew; - await ReplayCache.AddAsync(ReplayCachePurpose, result.TokenId!, DateTimeOffset.UtcNow.Add(cacheDuration)); - } - - /// - /// Validates the freshness. - /// - protected virtual async Task ValidateFreshnessAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - var dpopOptions = OptionsMonitor.Get(context.Scheme); - - var validateIat = dpopOptions.ValidateIat; - if (validateIat) - { - await ValidateIatAsync(context, result); - if (result.IsError) - { - return; - } - } - - var validateNonce = dpopOptions.ValidateNonce; - if (validateNonce) - { - await ValidateNonceAsync(context, result); - if (result.IsError) - { - return; - } - } - } - - /// - /// Validates the freshness of the iat value. - /// - protected virtual Task ValidateIatAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - var dpopOptions = OptionsMonitor.Get(context.Scheme); - - if (IsExpired(context, result, dpopOptions.ClientClockSkew, result.IssuedAt!.Value)) // iat is required by an earlier validation - { - result.IsError = true; - result.ErrorDescription = "Invalid 'iat' value."; - return Task.CompletedTask; - } - - return Task.CompletedTask; - } - - /// - /// Validates the freshness of the nonce value. - /// - protected virtual async Task ValidateNonceAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - if (String.IsNullOrWhiteSpace(result.Nonce)) - { - result.IsError = true; - result.Error = OidcConstants.TokenErrors.UseDPoPNonce; - result.ErrorDescription = "Missing 'nonce' value."; - result.ServerIssuedNonce = CreateNonce(context, result); - return; - } - - var time = await GetUnixTimeFromNonceAsync(context, result); - if (time <= 0) - { - Logger.LogDebug("Invalid time value read from the 'nonce' value"); - - result.IsError = true; - result.ErrorDescription = "Invalid 'nonce' value."; - result.ServerIssuedNonce = CreateNonce(context, result); - return; - } - - var dpopOptions = OptionsMonitor.Get(context.Scheme); - - if (IsExpired(context, result, dpopOptions.ServerClockSkew, time)) - { - Logger.LogDebug("DPoP 'nonce' expiration failed. It's possible that the server farm clocks might not be closely synchronized, so consider setting the ServerClockSkew on the DPoPOptions on the IdentityServerOptions."); - - result.IsError = true; - result.ErrorDescription = "Invalid 'nonce' value."; - result.ServerIssuedNonce = CreateNonce(context, result); - return; - } - } - - /// - /// Creates a nonce value to return to the client. - /// - /// - protected virtual string CreateNonce(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - return DataProtector.Protect(now.ToString()); - } - - /// - /// Reads the time the nonce was created. - /// - /// - protected virtual ValueTask GetUnixTimeFromNonceAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) - { - try - { - var value = DataProtector.Unprotect(result.Nonce!); // nonce is required by an earlier validation - if (Int64.TryParse(value, out long iat)) - { - return ValueTask.FromResult(iat); - } - } - catch (Exception ex) - { - Logger.LogDebug("Error parsing DPoP 'nonce' value: {error}", ex.ToString()); - } - - return ValueTask.FromResult(0); - } - - /// - /// Validates the expiration of the DPoP proof. - /// Returns true if the time is beyond the allowed limits, false otherwise. - /// - protected virtual bool IsExpired(DPoPProofValidatonContext context, DPoPProofValidatonResult result, TimeSpan clockSkew, long issuedAtTime) - { - var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var start = now + (int) clockSkew.TotalSeconds; - if (start < issuedAtTime) - { - var diff = issuedAtTime - now; - Logger.LogDebug("Expiration check failed. Creation time was too far in the future. The time being checked was {iat}, and clock is now {now}. The time difference is {diff}", issuedAtTime, now, diff); - return true; - } - - var dpopOptions = OptionsMonitor.Get(context.Scheme); - var expiration = issuedAtTime + (int) dpopOptions.ProofTokenValidityDuration.TotalSeconds; - var end = now - (int) clockSkew.TotalSeconds; - if (expiration < end) - { - var diff = now - expiration; - Logger.LogDebug("Expiration check failed. Expiration has already happened. The expiration was at {exp}, and clock is now {now}. The time difference is {diff}", expiration, now, diff); - return true; - } - - return false; - } -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DPoPServiceCollectionExtensions.cs b/IdentityServer/v7/DPoP/Api/DPoP/DPoPServiceCollectionExtensions.cs deleted file mode 100644 index 300a8361..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DPoPServiceCollectionExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Options; - -namespace Api; - -static class DPoPServiceCollectionExtensions -{ - public static IServiceCollection ConfigureDPoPTokensForScheme(this IServiceCollection services, string scheme) - { - services.AddOptions(); - - services.AddTransient(); - services.AddTransient(); - services.AddDistributedMemoryCache(); - services.AddTransient(); - - services.AddSingleton>(new ConfigureJwtBearerOptions(scheme)); - - - return services; - } - - public static IServiceCollection ConfigureDPoPTokensForScheme(this IServiceCollection services, string scheme, Action configure) - { - services.Configure(scheme, configure); - return services.ConfigureDPoPTokensForScheme(scheme); - } -} diff --git a/IdentityServer/v7/DPoP/Api/DPoP/DefaultReplayCache.cs b/IdentityServer/v7/DPoP/Api/DPoP/DefaultReplayCache.cs deleted file mode 100644 index 93128ec8..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/DefaultReplayCache.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; - -namespace Api; - -/// -/// Default implementation of the replay cache using IDistributedCache -/// -public class DefaultReplayCache : IReplayCache -{ - private const string Prefix = nameof(DefaultReplayCache) + "-"; - - private readonly IDistributedCache _cache; - - /// - /// ctor - /// - /// - public DefaultReplayCache(IDistributedCache cache) - { - _cache = cache; - } - - /// - public async Task AddAsync(string purpose, string handle, DateTimeOffset expiration) - { - var options = new DistributedCacheEntryOptions - { - AbsoluteExpiration = expiration - }; - - await _cache.SetAsync(Prefix + purpose + handle, new byte[] { }, options); - } - - /// - public async Task ExistsAsync(string purpose, string handle) - { - return (await _cache.GetAsync(Prefix + purpose + handle, default)) != null; - } -} \ No newline at end of file diff --git a/IdentityServer/v7/DPoP/Api/DPoP/IReplayCache.cs b/IdentityServer/v7/DPoP/Api/DPoP/IReplayCache.cs deleted file mode 100644 index c08018e6..00000000 --- a/IdentityServer/v7/DPoP/Api/DPoP/IReplayCache.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Api; - -public interface IReplayCache -{ - /// - /// Adds a handle to the cache - /// - /// - /// - /// - /// - Task AddAsync(string purpose, string handle, DateTimeOffset expiration); - - - /// - /// Checks if a cached handle exists - /// - /// - /// - /// - Task ExistsAsync(string purpose, string handle); -} diff --git a/IdentityServer/v7/DPoP/Api/IdentityController.cs b/IdentityServer/v7/DPoP/Api/IdentityController.cs index 770905cf..5fb22660 100644 --- a/IdentityServer/v7/DPoP/Api/IdentityController.cs +++ b/IdentityServer/v7/DPoP/Api/IdentityController.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; +using IdentityModel; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; @@ -20,20 +19,19 @@ public ActionResult Get() var claims = User.Claims.Select(c => new { c.Type, c.Value }); _logger.LogInformation("claims: {claims}", claims); - var scheme = Request.GetAuthorizationScheme(); - var proofToken = Request.GetDPoPProofToken(); + var scheme = GetAuthorizationScheme(Request); + var proofToken = GetDPoPProofToken(Request); return new JsonResult(new { scheme, proofToken, claims }); } - - [HttpGet("TestNonce")] - [AllowAnonymous] - public ActionResult TestNonce() + + public static string? GetAuthorizationScheme(HttpRequest request) { - var x = Request.GetDPoPProofToken(); - var props = new AuthenticationProperties(); - props.SetDPoPNonce("custom-nonce"); + return request.Headers.Authorization.FirstOrDefault()?.Split(' ', System.StringSplitOptions.RemoveEmptyEntries)[0]; + } - return Challenge(props); + public static string? GetDPoPProofToken(HttpRequest request) + { + return request.Headers[OidcConstants.HttpHeaders.DPoP].FirstOrDefault(); } } \ No newline at end of file diff --git a/IdentityServer/v7/DPoP/Api/Program.cs b/IdentityServer/v7/DPoP/Api/Program.cs index 7ffde596..a60c3abe 100644 --- a/IdentityServer/v7/DPoP/Api/Program.cs +++ b/IdentityServer/v7/DPoP/Api/Program.cs @@ -1,4 +1,4 @@ -using Api; +using Duende.AspNetCore.Authentication.JwtBearer.DPoP; using Serilog; using Serilog.Sinks.SystemConsole.Themes; @@ -24,11 +24,23 @@ options.TokenValidationParameters.ValidateAudience = false; options.MapInboundClaims = false; - options.TokenValidationParameters.ValidTypes = new[] { "at+jwt" }; + options.TokenValidationParameters.ValidTypes = ["at+jwt"]; }); // layers DPoP onto the "token" scheme above -builder.Services.ConfigureDPoPTokensForScheme("token"); +builder.Services.ConfigureDPoPTokensForScheme("token", opt => +{ + // Chose a validation mode: either Nonce or IssuedAt. With nonce validation, + // the api supplies a nonce that must be used to prove that the token was + // not pre-generated. With IssuedAt validation, the client includes the + // current time in the proof token, which is compared to the clock. Nonce + // validation provides protection against some attacks that are possible + // with IssuedAt validation, at the cost of an additional HTTP request being + // required each time the API is invoked. + // + // See RFC 9449 for more details. + opt.ValidationMode = ExpirationValidationMode.IssuedAt; // IssuedAt is the default. +}); var app = builder.Build();