From 5ec1ce631d17d1012e5b614c373d8dcde27fa614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Sun, 27 Mar 2022 19:52:19 +0200 Subject: [PATCH] Implement grant_type=refresh_token support in the OpenIddict client --- .../OpenIddictResources.resx | 11 +- .../OpenIddictClientEvents.cs | 10 + .../OpenIddictClientExtensions.cs | 1 - .../OpenIddictClientHandlerFilters.cs | 16 - .../OpenIddictClientHandlers.cs | 364 ++++++++++-------- .../OpenIddictClientService.cs | 123 ++++++ 6 files changed, 352 insertions(+), 173 deletions(-) diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 7fa4af06..25370af1 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1129,7 +1129,7 @@ Make sure that neither DefaultAuthenticateScheme, DefaultSignInScheme, DefaultSi An identity cannot be extracted from this request. -This generally indicates that the OpenIddict client stack was asked to validate a token for an endpoint it doesn't manage. +This generally indicates that the OpenIddict client stack was asked to validate a token for an invalid endpoint. To validate tokens received by custom API endpoints, the OpenIddict validation handler (e.g OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme or OpenIddictValidationOwinDefaults.AuthenticationType) must be used instead. @@ -1188,6 +1188,15 @@ To apply redirection responses, create a class implementing 'IOpenIddictClientHa The specified list of valid token types is not valid. + + A grant type must be specified when triggering authentication demands from endpoints that are not managed by the OpenIddict client stack. + + + The specified grant type ({0}) is not currently supported for authentication demands. + + + A refresh token must be specified when using the refresh token grant. + The security token is missing. diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.cs b/src/OpenIddict.Client/OpenIddictClientEvents.cs index 86380185..0920e3a3 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.cs @@ -284,6 +284,16 @@ public static partial class OpenIddictClientEvents set => Transaction.Request = value; } + /// + /// Gets or sets the grant type used for the authentication demand, if applicable. + /// + public string? GrantType { get; set; } + + /// + /// Gets or sets the response type used for the authentication demand, if applicable. + /// + public string? ResponseType { get; set; } + /// /// Gets or sets a boolean indicating whether an authorization /// code should be extracted from the current context. diff --git a/src/OpenIddict.Client/OpenIddictClientExtensions.cs b/src/OpenIddict.Client/OpenIddictClientExtensions.cs index 3ed65016..c0a76d10 100644 --- a/src/OpenIddict.Client/OpenIddictClientExtensions.cs +++ b/src/OpenIddict.Client/OpenIddictClientExtensions.cs @@ -36,7 +36,6 @@ public static class OpenIddictClientExtensions builder.Services.TryAddSingleton(); // Register the built-in filters used by the default OpenIddict client event handlers. - builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs index 79774859..cb8d8f4e 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs @@ -11,22 +11,6 @@ namespace OpenIddict.Client; [EditorBrowsable(EditorBrowsableState.Advanced)] public static class OpenIddictClientHandlerFilters { - /// - /// Represents a filter that excludes the associated handlers if no authorization code is extracted. - /// - public class RequireAuthorizationCodeExtracted : IOpenIddictClientHandlerFilter - { - public ValueTask IsActiveAsync(ProcessAuthenticationContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - return new ValueTask(context.ExtractAuthorizationCode); - } - } - /// /// Represents a filter that excludes the associated handlers if the challenge /// doesn't correspond to an authorization code or implicit grant operation. diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index 8f25bc61..cef650c0 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -33,7 +33,8 @@ public static partial class OpenIddictClientHandlers ResolveClientRegistrationFromStateToken.Descriptor, ValidateIssuerParameter.Descriptor, ValidateFrontchannelErrorParameters.Descriptor, - ValidateGrantType.Descriptor, + ResolveGrantTypeFromStateToken.Descriptor, + ResolveResponseTypeFromStateToken.Descriptor, EvaluateValidatedFrontchannelTokens.Descriptor, ResolveValidatedFrontchannelTokens.Descriptor, @@ -130,11 +131,33 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } + // Authentication demands can be triggered from the redirection endpoint + // to handle authorization callbacks but also from unknown endpoints + // when using the refresh token grant, to perform a token refresh dance. + switch (context.EndpointType) { case OpenIddictClientEndpointType.Redirection: break; + case OpenIddictClientEndpointType.Unknown: + if (string.IsNullOrEmpty(context.GrantType)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0309)); + } + + if (context.GrantType is not GrantTypes.RefreshToken) + { + throw new InvalidOperationException(SR.FormatID0310(context.GrantType)); + } + + if (string.IsNullOrEmpty(context.RefreshToken)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0311)); + } + + break; + default: throw new InvalidOperationException(SR.GetResourceString(SR.ID0290)); } @@ -483,6 +506,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateIssuerParameter.Descriptor.Order + 1_000) .Build(); @@ -515,10 +539,10 @@ public static partial class OpenIddictClientHandlers } /// - /// Contains the logic responsible of ensuring the authentication demand is valid - /// based on the grant type initially negotiated and stored in the state token. + /// Contains the logic responsible of resolving the grant type + /// initially negotiated and stored in the state token, if applicable. /// - public class ValidateGrantType : IOpenIddictClientHandler + public class ResolveGrantTypeFromStateToken : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. @@ -526,7 +550,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() - .UseSingletonHandler() + .UseSingletonHandler() .SetOrder(ValidateIssuerParameter.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -544,8 +568,8 @@ public static partial class OpenIddictClientHandlers // Resolve the negotiated grant type from the state token. var type = context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType); - // Note: OpenIddict currently only supports the implicit and authorization code - // grants but additional grants (like CIBA) may be supported in future versions. + // Note: OpenIddict currently only supports the implicit, authorization code and refresh + // token grants but additional grants (like CIBA) may be supported in future versions. switch (context.EndpointType) { // Authentication demands triggered from the redirection endpoint are only valid for @@ -561,14 +585,17 @@ public static partial class OpenIddictClientHandlers return default; } + context.GrantType = type; + return default; } } /// - /// Contains the logic responsible of determining the set of frontchannel tokens to validate. + /// Contains the logic responsible of resolving the response type + /// initially negotiated and stored in the state token, if applicable. /// - public class EvaluateValidatedFrontchannelTokens : IOpenIddictClientHandler + public class ResolveResponseTypeFromStateToken : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. @@ -576,8 +603,8 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() - .UseSingletonHandler() - .SetOrder(ValidateGrantType.Descriptor.Order + 1_000) + .UseSingletonHandler() + .SetOrder(ResolveGrantTypeFromStateToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -591,16 +618,39 @@ public static partial class OpenIddictClientHandlers Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - // Resolve the grant grant and the response type stored in the state token and extract its individual elements. - var types = ( - GrantType: context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType), - ResponseTypes: context.StateTokenPrincipal.GetClaim(Claims.Private.ResponseType) - !.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) - .ToImmutableHashSet()); + // Resolve the negotiated response type from the state token. + context.ResponseType = context.StateTokenPrincipal.GetClaim(Claims.Private.ResponseType); + + return default; + } + } + + /// + /// Contains the logic responsible of determining the set of frontchannel tokens to validate. + /// + public class EvaluateValidatedFrontchannelTokens : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ResolveResponseTypeFromStateToken.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } (context.ExtractAuthorizationCode, context.RequireAuthorizationCode, - context.ValidateAuthorizationCode) = types switch + context.ValidateAuthorizationCode) = context.GrantType switch { // An authorization code is returned for the authorization code and implicit grants when // the response type contains the "code" value, which includes the authorization code @@ -610,15 +660,15 @@ public static partial class OpenIddictClientHandlers // Note: since authorization codes are supposed to be opaque to the clients, they are never // validated by default. Clients that need to deal with non-standard implementations // can use custom handlers to validate access tokens that use a readable format (e.g JWT). - (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when set.Contains(ResponseTypes.Code) => (true, true, false), + GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Code) + => (true, true, false), _ => (false, false, false) }; (context.ExtractFrontchannelAccessToken, context.RequireFrontchannelAccessToken, - context.ValidateFrontchannelAccessToken) = types switch + context.ValidateFrontchannelAccessToken) = context.GrantType switch { // An access token is returned for the authorization code and implicit grants when // the response type contains the "token" value, which includes some variations of @@ -628,15 +678,15 @@ public static partial class OpenIddictClientHandlers // Note: since access tokens are supposed to be opaque to the clients, they are never // validated by default. Clients that need to deal with non-standard implementations // can use custom handlers to validate access tokens that use a readable format (e.g JWT). - (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when set.Contains(ResponseTypes.Token) => (true, true, false), + GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Token) + => (true, true, false), _ => (false, false, false) }; (context.ExtractFrontchannelIdentityToken, context.RequireFrontchannelIdentityToken, - context.ValidateFrontchannelIdentityToken) = types switch + context.ValidateFrontchannelIdentityToken) = context.GrantType switch { // An identity token is returned for the authorization code and implicit grants when // the response type contains the "id_token" value, which includes some variations @@ -645,13 +695,15 @@ public static partial class OpenIddictClientHandlers // // Note: the granted scopes list (returned as a "scope" parameter in authorization // responses) is not used in this case as it's not protected against tampering. - (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when set.Contains(ResponseTypes.IdToken) => (true, true, true), + GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.IdToken) + => (true, true, true), _ => (false, false, false) }; return default; + + bool HasResponseType(string value) => context.ResponseType!.Split(Separators.Space).Contains(value); } } @@ -976,7 +1028,7 @@ public static partial class OpenIddictClientHandlers Debug.Assert(context.FrontchannelIdentityTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); // Note: while an identity token typically contains a single audience represented - // as a JSON string, multiple values can be returned represented a a JSON array. + // as a JSON string, multiple values can be returned represented as a JSON array. // // In any case, the client identifier of the application MUST be included in the audiences. // See https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation for more information. @@ -1084,7 +1136,7 @@ public static partial class OpenIddictClientHandlers return default; // If the two nonces don't match, return an error. - case { FrontchannelIdentityTokenNonce: { } left, StateTokenNonce: { } right } when + case { FrontchannelIdentityTokenNonce: string left, StateTokenNonce: string right } when #if SUPPORTS_TIME_CONSTANT_COMPARISONS !CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(left), Encoding.ASCII.GetBytes(right)): #else @@ -1388,7 +1440,6 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() .UseSingletonHandler() .SetOrder(ValidateAuthorizationCode.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -1402,18 +1453,9 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - - // Resolve the grant grant and the response type stored in the state token and extract its individual elements. - var types = ( - GrantType: context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType), - ResponseTypes: context.StateTokenPrincipal.GetClaim(Claims.Private.ResponseType) - !.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) - .ToImmutableHashSet()); - (context.ExtractBackchannelAccessToken, context.RequireBackchannelAccessToken, - context.ValidateBackchannelAccessToken) = types switch + context.ValidateBackchannelAccessToken) = context.GrantType switch { // An access token is always returned as part of token responses, independently of // the negotiated response types or whether the server supports OpenID Connect or not. @@ -1422,46 +1464,61 @@ public static partial class OpenIddictClientHandlers // Note: since access tokens are supposed to be opaque to the clients, they are never // validated by default. Clients that need to deal with non-standard implementations // can use custom handlers to validate access tokens that use a readable format (e.g JWT). - (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when set.Contains(ResponseTypes.Code) => (true, true, false), + GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Code) + => (true, true, false), + + // An access token is always returned as part of refresh token responses. + GrantTypes.RefreshToken => (true, true, false), _ => (false, false, false) }; (context.ExtractBackchannelIdentityToken, context.RequireBackchannelIdentityToken, - context.ValidateBackchannelIdentityToken) = types switch + context.ValidateBackchannelIdentityToken) = context.GrantType switch { // An identity token is always returned as part of token responses for the code and // hybrid flows when the authorization server supports OpenID Connect. As such, // a backchannel identity token is only considered required if the negotiated scopes // include "openid", which indicates the initial request was an OpenID Connect request. - (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when set.Contains(ResponseTypes.Code) && - context.StateTokenPrincipal.HasScope(Scopes.OpenId) => (true, true, true), + GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Code) && + context.StateTokenPrincipal!.HasScope(Scopes.OpenId) => (true, true, true), + + // An identity token may or may not be returned as part of refresh token responses + // depending on the policy adopted by the remote authorization server. As such, + // the identity token is not considered required but will always be validated using + // the same routine (except nonce validation) if it is present in the token response. + GrantTypes.RefreshToken => (true, false, true), _ => (false, false, false) }; (context.ExtractRefreshToken, context.RequireRefreshToken, - context.ValidateRefreshToken) = types switch - { - // A refresh token may be returned as part of token responses, depending on the - // policy enforced by the remote authorization server (e.g the "offline_access" - // scope may be used). Since the requirements will differ between authorization - // servers, a refresh token is never considered required by default. - // - // Note: since refresh tokens are supposed to be opaque to the clients, they are never - // validated by default. Clients that need to deal with non-standard implementations - // can use custom handlers to validate access tokens that use a readable format (e.g JWT). - (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when set.Contains(ResponseTypes.Code) => (true, false, false), + context.ValidateRefreshToken) = context.GrantType switch + { + // A refresh token may be returned as part of token responses, depending on the + // policy enforced by the remote authorization server (e.g the "offline_access" + // scope may be used). Since the requirements will differ between authorization + // servers, a refresh token is never considered required by default. + // + // Note: since refresh tokens are supposed to be opaque to the clients, they are never + // validated by default. Clients that need to deal with non-standard implementations + // can use custom handlers to validate access tokens that use a readable format (e.g JWT). + GrantTypes.AuthorizationCode or GrantTypes.Implicit when HasResponseType(ResponseTypes.Code) + => (true, false, false), - _ => (false, false, false) - }; + // A refresh token may or may not be returned as part of refresh token responses + // depending on the policy adopted by the remote authorization server. As such, + // a refresh token is never considered required for refresh token responses. + GrantTypes.RefreshToken => (true, false, false), + + _ => (false, false, false) + }; return default; + + bool HasResponseType(string value) => context.ResponseType!.Split(Separators.Space).Contains(value); } } @@ -1475,8 +1532,6 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() - .AddFilter() .UseSingletonHandler() .SetOrder(EvaluateValidatedBackchannelTokens.Descriptor.Order + 1_000) .Build(); @@ -1489,18 +1544,21 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - - // Attach a new request instance if none was created already. + if (!context.ExtractBackchannelAccessToken && + !context.ExtractBackchannelIdentityToken && + !context.ExtractRefreshToken) + { + return default; + } + + // Attach a new request instance if necessary. context.TokenRequest ??= new OpenIddictRequest(); - // Attach the grant type selected during the challenge phase. - context.TokenRequest.GrantType = context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType) switch + // Attach the selected grant type. + context.TokenRequest.GrantType = context.GrantType switch { null => throw new InvalidOperationException(SR.GetResourceString(SR.ID0294)), - GrantTypes.AuthorizationCode => GrantTypes.AuthorizationCode, - // Note: in OpenID Connect, the hybrid flow doesn't have a dedicated grant_type and is // typically treated as a combination of both the implicit and authorization code grants. // If the implicit flow was selected during the challenge phase and an authorization code @@ -1508,7 +1566,7 @@ public static partial class OpenIddictClientHandlers // use grant_type=authorization_code when communicating with the remote token endpoint. GrantTypes.Implicit => GrantTypes.AuthorizationCode, - // If the grant_type is not natively supported or recognized, try to send it as-is. + // For other values, don't do any mapping. string type => type }; @@ -1521,11 +1579,22 @@ public static partial class OpenIddictClientHandlers // the redirect_uri from the state token principal and attach them to the request, if available. if (context.TokenRequest.GrantType is GrantTypes.AuthorizationCode) { + Debug.Assert(!string.IsNullOrEmpty(context.AuthorizationCode), SR.GetResourceString(SR.ID4010)); + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + context.TokenRequest.Code = context.AuthorizationCode; context.TokenRequest.CodeVerifier = context.StateTokenPrincipal.GetClaim(Claims.Private.CodeVerifier); context.TokenRequest.RedirectUri = context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri); } + // If the token request uses a refresh token grant, attach the refresh token to the request. + else if (context.TokenRequest.GrantType is GrantTypes.RefreshToken) + { + Debug.Assert(!string.IsNullOrEmpty(context.RefreshToken), SR.GetResourceString(SR.ID4010)); + + context.TokenRequest.RefreshToken = context.RefreshToken; + } + return default; } } @@ -1920,7 +1989,7 @@ public static partial class OpenIddictClientHandlers Debug.Assert(context.BackchannelIdentityTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); // Note: while an identity token typically contains a single audience represented - // as a JSON string, multiple values can be returned represented a a JSON array. + // as a JSON string, multiple values can be returned represented as a JSON array. // // In any case, the client identifier of the application MUST be included in the audiences. // See https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation for more information. @@ -1993,6 +2062,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(ValidateBackchannelIdentityTokenPresenter.Descriptor.Order + 1_000) .Build(); @@ -2027,7 +2097,7 @@ public static partial class OpenIddictClientHandlers return default; // If the two nonces don't match, return an error. - case { BackchannelIdentityTokenNonce: { } left, StateTokenNonce: { } right } when + case { BackchannelIdentityTokenNonce: string left, StateTokenNonce: string right } when #if SUPPORTS_TIME_CONSTANT_COMPARISONS !CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(left), Encoding.ASCII.GetBytes(right)): #else @@ -2304,49 +2374,51 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() .UseSingletonHandler() .SetOrder(ValidateRefreshToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); /// - public ValueTask HandleAsync(ProcessAuthenticationContext context) + public async ValueTask HandleAsync(ProcessAuthenticationContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); - // Resolve the grant grant and the response type stored in the state token and extract its individual elements. - var types = ( - GrantType: context.StateTokenPrincipal.GetClaim(Claims.Private.GrantType), - ResponseTypes: context.StateTokenPrincipal.GetClaim(Claims.Private.ResponseType) - !.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) - .ToImmutableHashSet()); + // Ensure the issuer resolved from the configuration matches the expected value. + if (configuration.Issuer != context.Issuer) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); + } (context.ExtractUserinfoToken, context.RequireUserinfoToken, - context.ValidateUserinfoToken) = types switch - { - // Information about the authenticated user can be retrieved from the userinfo - // endpoint when a backchannel access token is available. In this case, user data + context.ValidateUserinfoToken) = context.GrantType switch + { + // Information about the authenticated user can be retrieved from the userinfo endpoint + // when a frontchannel or backchannel access token is available. In this case, user data // will be returned either as a JSON object or as a signed and/or encrypted // JSON Web Token if the client registration indicates the client supports it. // - // By default, OpenIddict doesn't require that userinfo-as-JWT responses be used - // but userinfo tokens will be extracted and validated if they are available. - (GrantTypes.AuthorizationCode or GrantTypes.Implicit, ImmutableHashSet set) - when context.StateTokenPrincipal.HasScope(Scopes.OpenId) && - (set.Contains(ResponseTypes.Code) || set.Contains(ResponseTypes.Token)) - => (true, false, true), + // By default, OpenIddict doesn't require that userinfo be used but userinfo tokens + // or responses will be extracted and validated if the userinfo endpoint and either + // a frontchannel or backchannel access token was extracted and is available. + GrantTypes.AuthorizationCode or GrantTypes.Implicit or GrantTypes.RefreshToken + when configuration.UserinfoEndpoint is not null && context switch + { + { ExtractBackchannelAccessToken: true, BackchannelAccessToken.Length: > 0 } => true, + { ExtractFrontchannelAccessToken: true, FrontchannelAccessToken.Length: > 0 } => true, + + _ => false + } => (true, false, true), _ => (false, false, false) }; - - return default; } } @@ -2360,54 +2432,36 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() - .SetOrder(ValidateRefreshToken.Descriptor.Order + 1_000) + .SetOrder(EvaluateValidatedUserinfoToken.Descriptor.Order + 1_000) .Build(); /// - public async ValueTask HandleAsync(ProcessAuthenticationContext context) + public ValueTask HandleAsync(ProcessAuthenticationContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } - var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); - - // Ensure the issuer resolved from the configuration matches the expected value. - if (configuration.Issuer != context.Issuer) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); - } + // Attach a new request instance if necessary. + context.UserinfoRequest ??= new OpenIddictRequest(); - var token = context switch + // Attach the access token required to access the user information. + context.UserinfoRequest.AccessToken = context switch { // Note: the backchannel access token (retrieved from the token endpoint) is always preferred to // the frontchannel access token if available, as it may grant a greater access to user's resources. - { ExtractBackchannelAccessToken: true, BackchannelAccessToken: { Length: > 0 } value } - // If the userinfo endpoint is not available, skip the request. - when configuration.UserinfoEndpoint is not null => value, + { ExtractBackchannelAccessToken: true, BackchannelAccessToken: { Length: > 0 } token } => token, // If the backchannel access token is not available, try to use the frontchannel access token. - { ExtractFrontchannelAccessToken: true, FrontchannelAccessToken: { Length: > 0 } value } - // If the userinfo endpoint is not available, skip the request. - when configuration.UserinfoEndpoint is not null => value, + { ExtractFrontchannelAccessToken: true, FrontchannelAccessToken: { Length: > 0 } token } => token, - // Otherwise, skip the userinfo request. - _ => null + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0162)) }; - if (string.IsNullOrEmpty(token)) - { - return; - } - - // Attach a new request instance if none was created already. - context.UserinfoRequest ??= new OpenIddictRequest(); - - // Attach the access token. - context.UserinfoRequest.AccessToken = token; + return default; } } @@ -2603,7 +2657,6 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() .AddFilter() .UseSingletonHandler() .SetOrder(ValidateUserinfoToken.Descriptor.Order + 1_000) @@ -2611,21 +2664,29 @@ public static partial class OpenIddictClientHandlers .Build(); /// - public ValueTask HandleAsync(ProcessAuthenticationContext context) + public async ValueTask HandleAsync(ProcessAuthenticationContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); Debug.Assert(context.UserinfoTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + // Ensure the issuer resolved from the configuration matches the expected value. + if (configuration.Issuer != context.Issuer) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); + } + // The OpenIddict client is expected to be used with standard OpenID Connect userinfo endpoints // but must also support non-standard implementations, that are common with OAuth 2.0-only servers. // - // As such, protocol requirements are only enforced if an OpenID Connect request was initially sent. - if (context.StateTokenPrincipal.HasScope(Scopes.OpenId)) + // As such, protocol requirements are only enforced if the server supports OpenID Connect. + if (configuration.ScopesSupported.Contains(Scopes.OpenId)) { foreach (var group in context.UserinfoTokenPrincipal.Claims .GroupBy(claim => claim.Type) @@ -2641,12 +2702,10 @@ public static partial class OpenIddictClientHandlers description: SR.FormatID2131(group.Key), uri: SR.FormatID8000(SR.ID2131)); - return default; + return; } } - return default; - static bool ValidateClaimGroup(KeyValuePair> claims) => claims switch { // The following JWT claims MUST be represented as unique strings. @@ -2671,7 +2730,6 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() .AddFilter() .UseSingletonHandler() .SetOrder(ValidateUserinfoTokenWellknownClaims.Descriptor.Order + 1_000) @@ -2679,21 +2737,29 @@ public static partial class OpenIddictClientHandlers .Build(); /// - public ValueTask HandleAsync(ProcessAuthenticationContext context) + public async ValueTask HandleAsync(ProcessAuthenticationContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } - Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); Debug.Assert(context.UserinfoTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + // Ensure the issuer resolved from the configuration matches the expected value. + if (configuration.Issuer != context.Issuer) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); + } + // The OpenIddict client is expected to be used with standard OpenID Connect userinfo endpoints // but must also support non-standard implementations, that are common with OAuth 2.0-only servers. // - // As such, protocol requirements are only enforced if an OpenID Connect request was initially sent. - if (context.StateTokenPrincipal.HasScope(Scopes.OpenId)) + // As such, protocol requirements are only enforced if the server supports OpenID Connect. + if (configuration.ScopesSupported.Contains(Scopes.OpenId)) { // Standard OpenID Connect userinfo responses/tokens MUST contain a "sub" claim. For more // information, see https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse. @@ -2704,7 +2770,7 @@ public static partial class OpenIddictClientHandlers description: SR.FormatID2132(Claims.Subject), uri: SR.FormatID8000(SR.ID2132)); - return default; + return; } // The "sub" claim returned as part of the userinfo response/token MUST exactly match the value @@ -2719,7 +2785,7 @@ public static partial class OpenIddictClientHandlers description: SR.FormatID2133(Claims.Subject), uri: SR.FormatID8000(SR.ID2133)); - return default; + return; } // The "sub" claim returned as part of the userinfo response/token MUST exactly match the value @@ -2734,11 +2800,9 @@ public static partial class OpenIddictClientHandlers description: SR.FormatID2133(Claims.Subject), uri: SR.FormatID8000(SR.ID2133)); - return default; + return; } } - - return default; } } @@ -2996,11 +3060,9 @@ public static partial class OpenIddictClientHandlers context.ResponseType = ( context.GrantType, context.Registration.ResponseTypes.Select(types => - types.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) - .ToImmutableHashSet(StringComparer.Ordinal)).ToList(), + types.Split(Separators.Space).ToImmutableHashSet(StringComparer.Ordinal)).ToList(), configuration.ResponseTypesSupported.Select(types => - types.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) - .ToImmutableHashSet(StringComparer.Ordinal)).ToList()) switch + types.Split(Separators.Space).ToImmutableHashSet(StringComparer.Ordinal)).ToList()) switch { // Note: the OAuth 2.0 provider metadata and OpenID Connect discovery specification define // the supported response types as a required property. Nevertheless, to ensure OpenIddict @@ -3140,8 +3202,8 @@ public static partial class OpenIddictClientHandlers client.Any(static set => set.SetEquals(new[] { ResponseTypes.IdToken, ResponseTypes.Token })) => ResponseTypes.IdToken + ' ' + ResponseTypes.Token, - // Note: response_type=token is not considered considered secure enough as it allows - // malicious actors to inject access tokens that were issued to a different client. + // Note: response_type=token is not considered secure enough as it allows malicious + // actors to inject access tokens that were initially issued to a different client. // As such, while OpenIddict-based servers allow using response_type=token for backward // compatibility with legacy clients, OpenIddict-based clients are deliberately not // allowed to negotiate the unsafe and OAuth 2.0-only response_type=token flow. @@ -3210,8 +3272,7 @@ public static partial class OpenIddictClientHandlers // can never be used with a response type containing id_token or token, as required by the OAuth 2.0 // multiple response types specification. To prevent invalid combinations from being sent to the // remote server, the response types are taken into account when selecting the best response mode. - var types = context.ResponseType!.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) - .ToImmutableHashSet(StringComparer.Ordinal); + var types = context.ResponseType!.Split(Separators.Space).ToImmutableHashSet(StringComparer.Ordinal); context.ResponseMode = (context.Registration.ResponseModes, configuration.ResponseModesSupported) switch { @@ -3493,17 +3554,10 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - // Some specific response_type/response_mode combinations are not allowed (e.g response_mode=query - // can never be used with a response type containing id_token or token, as required by the OAuth 2.0 - // multiple response types specification. To prevent invalid combinations from being sent to the - // remote server, the response types are taken into account when selecting the best response mode. - var types = context.ResponseType!.Split(Separators.Space, StringSplitOptions.RemoveEmptyEntries) - .ToImmutableHashSet(StringComparer.Ordinal); - // Don't attach a code challenge method if no authorization code is requested as some implementations // (like OpenIddict server) are known to eagerly block authorization requests that specify an invalid // code_challenge/code_challenge_method/response_type combination (e.g response_type=id_token). - if (!types.Contains(ResponseTypes.Code)) + if (!context.ResponseType!.Split(Separators.Space).Contains(ResponseTypes.Code)) { return; } diff --git a/src/OpenIddict.Client/OpenIddictClientService.cs b/src/OpenIddict.Client/OpenIddictClientService.cs index 8776a13a..433e8c5f 100644 --- a/src/OpenIddict.Client/OpenIddictClientService.cs +++ b/src/OpenIddict.Client/OpenIddictClientService.cs @@ -311,6 +311,129 @@ public class OpenIddictClientService } } + /// + /// Refreshes the user tokens using the specified refresh token. + /// + /// The client registration. + /// The refresh token to use. + /// The that can be used to abort the operation. + /// The response and a merged principal containing the claims extracted from the tokens and userinfo response. + public async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> RefreshTokensAsync( + OpenIddictClientRegistration registration, string token, CancellationToken cancellationToken = default) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0156), nameof(token)); + } + + var configuration = await registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + if (configuration.TokenEndpoint is not { IsAbsoluteUri: true } || + !configuration.TokenEndpoint.IsWellFormedOriginalString()) + { + throw new InvalidOperationException(SR.FormatID0301(Metadata.TokenEndpoint)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Note: this service is registered as a singleton service. As such, it cannot + // directly depend on scoped services like the validation provider. To work around + // this limitation, a scope is manually created for each method to this service. + var scope = _provider.CreateScope(); + + // Note: a try/finally block is deliberately used here to ensure the service scope + // can be disposed of asynchronously if it implements IAsyncDisposable. + try + { + var dispatcher = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService(); + var transaction = await factory.CreateTransactionAsync(); + + var context = new ProcessAuthenticationContext(transaction) + { + GrantType = GrantTypes.RefreshToken, + Issuer = registration.Issuer, + RefreshToken = token, + Registration = registration + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new OpenIddictExceptions.GenericException( + SR.FormatID0163(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + Debug.Assert(context.TokenResponse is not null, SR.GetResourceString(SR.ID4007)); + + // Create a composite principal containing claims resolved from the + // backchannel identity token and the userinfo token, if available. + return (context.TokenResponse, CreatePrincipal( + context.BackchannelIdentityTokenPrincipal, + context.UserinfoTokenPrincipal)); + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + + static ClaimsPrincipal CreatePrincipal(params ClaimsPrincipal?[] principals) + { + // Note: the OpenIddict client handler can be used as a pure OAuth 2.0-only stack for + // delegation scenarios where the identity of the user is not needed. In this case, + // since no principal can be resolved from a token or a userinfo response to construct + // a user identity, a fake one containing an "unauthenticated" identity (i.e with its + // AuthenticationType property deliberately left to null) is used to allow ASP.NET Core + // to return a "successful" authentication result for these delegation-only scenarios. + if (!principals.Any(principal => principal?.Identity is ClaimsIdentity { IsAuthenticated: true })) + { + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + // Create a new composite identity containing the claims of all the principals. + var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType); + + foreach (var principal in principals) + { + // Note: the principal may be null if no value was extracted from the corresponding token. + if (principal is null) + { + continue; + } + + foreach (var claim in principal.Claims) + { + // If a claim with the same type and the same value already exist, skip it. + if (identity.HasClaim(claim.Type, claim.Value)) + { + continue; + } + + identity.AddClaim(claim); + } + } + + return new ClaimsPrincipal(identity); + } + } + /// /// Sends the token request and retrieves the corresponding response. ///