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.
///