Browse Source

Update the client stack to support using custom grant types

pull/2034/head
Kévin Chalet 3 years ago
parent
commit
6b300e770f
  1. 4
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  2. 16
      src/OpenIddict.Client/OpenIddictClientBuilder.cs
  3. 89
      src/OpenIddict.Client/OpenIddictClientHandlers.cs
  4. 119
      src/OpenIddict.Client/OpenIddictClientModels.cs
  5. 103
      src/OpenIddict.Client/OpenIddictClientService.cs

4
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1138,7 +1138,7 @@ To validate tokens received by custom API endpoints, the OpenIddict validation h
<value>The signing algorithm cannot be resolved from the specified frontchannel identity token.</value> <value>The signing algorithm cannot be resolved from the specified frontchannel identity token.</value>
</data> </data>
<data name="ID0294" xml:space="preserve"> <data name="ID0294" xml:space="preserve">
<value>The negotiated grant type cannot be extracted from the state principal.</value> <value>The negotiated grant type cannot be resolved from the authentication context.</value>
</data> </data>
<data name="ID0295" xml:space="preserve"> <data name="ID0295" xml:space="preserve">
<value>The signing algorithm cannot be resolved from the specified backchannel identity token.</value> <value>The signing algorithm cannot be resolved from the specified backchannel identity token.</value>
@ -1188,7 +1188,7 @@ To apply redirection responses, create a class implementing 'IOpenIddictClientHa
<value>A grant type must be specified when triggering authentication demands from endpoints that are not managed by the OpenIddict client stack. This error may also indicate that the redirection endpoint was not correctly enabled in the OpenIddict client options.</value> <value>A grant type must be specified when triggering authentication demands from endpoints that are not managed by the OpenIddict client stack. This error may also indicate that the redirection endpoint was not correctly enabled in the OpenIddict client options.</value>
</data> </data>
<data name="ID0310" xml:space="preserve"> <data name="ID0310" xml:space="preserve">
<value>The specified grant type ({0}) is not currently supported for authentication demands.</value> <value>The specified grant type ({0}) cannot be used with this method.</value>
</data> </data>
<data name="ID0311" xml:space="preserve"> <data name="ID0311" xml:space="preserve">
<value>A refresh token must be specified when using the refresh token grant.</value> <value>A refresh token must be specified when using the refresh token grant.</value>

16
src/OpenIddict.Client/OpenIddictClientBuilder.cs

@ -933,6 +933,22 @@ public sealed class OpenIddictClientBuilder
public OpenIddictClientBuilder AllowClientCredentialsFlow() public OpenIddictClientBuilder AllowClientCredentialsFlow()
=> Configure(options => options.GrantTypes.Add(GrantTypes.ClientCredentials)); => Configure(options => options.GrantTypes.Add(GrantTypes.ClientCredentials));
/// <summary>
/// Enables custom grant type support.
/// </summary>
/// <param name="type">The grant type associated with the flow.</param>
/// <returns>The <see cref="OpenIddictClientBuilder"/> instance.</returns>
[EditorBrowsable(EditorBrowsableState.Advanced)]
public OpenIddictClientBuilder AllowCustomFlow(string type)
{
if (string.IsNullOrEmpty(type))
{
throw new ArgumentException(SR.GetResourceString(SR.ID0071), nameof(type));
}
return Configure(options => options.GrantTypes.Add(type));
}
/// <summary> /// <summary>
/// Enables device code flow support. For more information about this /// Enables device code flow support. For more information about this
/// specific OAuth 2.0 flow, visit https://tools.ietf.org/html/rfc8628. /// specific OAuth 2.0 flow, visit https://tools.ietf.org/html/rfc8628.

89
src/OpenIddict.Client/OpenIddictClientHandlers.cs

@ -326,21 +326,12 @@ public static partial class OpenIddictClientHandlers
throw new InvalidOperationException(SR.GetResourceString(SR.ID0309)); throw new InvalidOperationException(SR.GetResourceString(SR.ID0309));
} }
if (context.GrantType is not (
GrantTypes.AuthorizationCode or GrantTypes.ClientCredentials or
GrantTypes.DeviceCode or GrantTypes.Implicit or
GrantTypes.Password or GrantTypes.RefreshToken))
{
throw new InvalidOperationException(SR.FormatID0310(context.GrantType));
}
if (!context.Options.GrantTypes.Contains(context.GrantType)) if (!context.Options.GrantTypes.Contains(context.GrantType))
{ {
throw new InvalidOperationException(SR.FormatID0359(context.GrantType)); throw new InvalidOperationException(SR.FormatID0359(context.GrantType));
} }
if (context.GrantType is GrantTypes.DeviceCode && if (context.GrantType is GrantTypes.DeviceCode && string.IsNullOrEmpty(context.DeviceCode))
string.IsNullOrEmpty(context.DeviceCode))
{ {
throw new InvalidOperationException(SR.GetResourceString(SR.ID0396)); throw new InvalidOperationException(SR.GetResourceString(SR.ID0396));
} }
@ -358,8 +349,7 @@ public static partial class OpenIddictClientHandlers
} }
} }
if (context.GrantType is GrantTypes.RefreshToken && if (context.GrantType is GrantTypes.RefreshToken && string.IsNullOrEmpty(context.RefreshToken))
string.IsNullOrEmpty(context.RefreshToken))
{ {
throw new InvalidOperationException(SR.GetResourceString(SR.ID0311)); throw new InvalidOperationException(SR.GetResourceString(SR.ID0311));
} }
@ -2287,14 +2277,20 @@ public static partial class OpenIddictClientHandlers
// if an authorization code was requested in the initial authorization request. // if an authorization code was requested in the initial authorization request.
GrantTypes.AuthorizationCode or GrantTypes.Implicit when GrantTypes.AuthorizationCode or GrantTypes.Implicit when
context.ResponseType?.Split(Separators.Space) is IList<string> types && context.ResponseType?.Split(Separators.Space) is IList<string> types &&
types.Contains(ResponseTypes.Code) types.Contains(ResponseTypes.Code) => true,
=> true,
// For the special response_type=none flow (that doesn't have a
// standard grant type associated), never send a token request.
null when context.ResponseType is ResponseTypes.None => false,
// For client credentials, device authorization, resource owner password // For client credentials, device authorization, resource owner password
// credentials and refresh token requests, always send a token request. // credentials and refresh token requests, always send a token request.
GrantTypes.ClientCredentials or GrantTypes.DeviceCode or GrantTypes.ClientCredentials or GrantTypes.DeviceCode or
GrantTypes.Password or GrantTypes.RefreshToken => true, GrantTypes.Password or GrantTypes.RefreshToken => true,
// By default, always send a token request for custom grant types.
{ Length: > 0 } => true,
_ => false _ => false
}; };
@ -2331,7 +2327,7 @@ public static partial class OpenIddictClientHandlers
// Attach the selected grant type. // Attach the selected grant type.
context.TokenRequest.GrantType = context.GrantType switch context.TokenRequest.GrantType = context.GrantType switch
{ {
null => throw new InvalidOperationException(SR.GetResourceString(SR.ID0294)), null or { Length: 0 } => throw new InvalidOperationException(SR.GetResourceString(SR.ID0294)),
// Note: in OpenID Connect, the hybrid flow doesn't have a dedicated grant_type and is // 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. // typically treated as a combination of both the implicit and authorization code grants.
@ -2731,9 +2727,13 @@ public static partial class OpenIddictClientHandlers
// An access token is always returned as part of client credentials, device // An access token is always returned as part of client credentials, device
// code, resource owner password credentials and refresh token responses. // code, resource owner password credentials and refresh token responses.
GrantTypes.ClientCredentials or GrantTypes.DeviceCode or GrantTypes.ClientCredentials or GrantTypes.DeviceCode or
GrantTypes.Password or GrantTypes.RefreshToken GrantTypes.Password or GrantTypes.RefreshToken
=> (true, true, false, false), => (true, true, false, false),
// By default, always extract and require a backchannel
// access token for custom grant types, but don't validate it.
{ Length: > 0 } => (true, true, false, false),
_ => (false, false, false, false) _ => (false, false, false, false)
}; };
@ -2768,6 +2768,10 @@ public static partial class OpenIddictClientHandlers
// the same routine (except nonce validation) if it is present in the token response. // the same routine (except nonce validation) if it is present in the token response.
GrantTypes.RefreshToken => (true, false, true, false), GrantTypes.RefreshToken => (true, false, true, false),
// By default, try to extract a backchannel identity token for custom grant
// types and validate it when present, but don't require that one be returned.
{ Length: > 0 } => (true, false, true, false),
_ => (false, false, false, false) _ => (false, false, false, false)
}; };
@ -2795,9 +2799,13 @@ public static partial class OpenIddictClientHandlers
// depending on the policy adopted by the remote authorization server. As such, // depending on the policy adopted by the remote authorization server. As such,
// a refresh token is never considered required for such token responses. // a refresh token is never considered required for such token responses.
GrantTypes.ClientCredentials or GrantTypes.DeviceCode or GrantTypes.ClientCredentials or GrantTypes.DeviceCode or
GrantTypes.Password or GrantTypes.RefreshToken GrantTypes.Password or GrantTypes.RefreshToken
=> (true, false, false, false), => (true, false, false, false),
// By default, always try to extract a refresh token for
// custom grant types, but don't require or validate it.
{ Length: > 0 } => (true, false, false, false),
_ => (false, false, false, false) _ => (false, false, false, false)
}; };
@ -3574,16 +3582,26 @@ public static partial class OpenIddictClientHandlers
context.SendUserinfoRequest = context.GrantType switch context.SendUserinfoRequest = context.GrantType switch
{ {
// Information about the authenticated user can be retrieved from the userinfo // Never send a userinfo request when using the client credentials grant.
// endpoint when a frontchannel or backchannel access token is available. GrantTypes.ClientCredentials => false,
//
// Note: the userinfo endpoint is an optional endpoint and may not be supported. // Never send a userinfo request when using the special response_type=none flow.
GrantTypes.AuthorizationCode or GrantTypes.Implicit or null when context.ResponseType is ResponseTypes.None => false,
GrantTypes.DeviceCode or GrantTypes.Password or GrantTypes.RefreshToken
// For the well-known grant types involving users, send a userinfo request if the
// userinfo endpoint is available and if a frontchannel or backchannel access token
// is available, unless userinfo retrieval was explicitly disabled by the user.
GrantTypes.AuthorizationCode or GrantTypes.DeviceCode or GrantTypes.Implicit or
GrantTypes.Password or GrantTypes.RefreshToken
when !context.DisableUserinfoRetrieval && context.UserinfoEndpoint is not null && when !context.DisableUserinfoRetrieval && context.UserinfoEndpoint is not null &&
(!string.IsNullOrEmpty(context.BackchannelAccessToken) || (!string.IsNullOrEmpty(context.BackchannelAccessToken) ||
!string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true, !string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true,
// Apply the same logic for custom grant types.
{ Length: > 0 } when !context.DisableUserinfoRetrieval && context.UserinfoEndpoint is not null &&
(!string.IsNullOrEmpty(context.BackchannelAccessToken) ||
!string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true,
_ => false _ => false
}; };
@ -3601,15 +3619,17 @@ public static partial class OpenIddictClientHandlers
// flawlessly with OpenID Connect implementations, the userinfo response returned by the server // flawlessly with OpenID Connect implementations, the userinfo response returned by the server
// for an OAuth 2.0-only flow might not be OpenID Connect-compliant. In this case, disable // for an OAuth 2.0-only flow might not be OpenID Connect-compliant. In this case, disable
// userinfo validation, unless the "openid" scope was explicitly requested by the application. // userinfo validation, unless the "openid" scope was explicitly requested by the application.
GrantTypes.DeviceCode or GrantTypes.Password or GrantTypes.DeviceCode or GrantTypes.Password => !context.Scopes.Contains(Scopes.OpenId),
// Note: when using grant_type=refresh_token, it is not possible to determine whether the refresh token // Note: when using grant_type=refresh_token, it is not possible to determine whether the refresh token
// was issued during an OAuth 2.0-only or OpenID Connect flow. In this case, only validate userinfo // was issued during an OAuth 2.0-only or OpenID Connect flow. In this case, only validate userinfo
// responses if the openid scope was explicitly added by the user to the list of requested scopes. // responses if the openid scope was explicitly added by the user to the list of requested scopes.
GrantTypes.RefreshToken or GrantTypes.RefreshToken => !context.Scopes.Contains(Scopes.OpenId),
// For unknown grant types, disable userinfo validation, unless the openid scope was explicitly added. // For unknown grant types, disable userinfo validation unless the openid scope was explicitly added.
_ => !context.Scopes.Contains(Scopes.OpenId) { Length: > 0 } => !context.Scopes.Contains(Scopes.OpenId),
_ => true
}; };
return default; return default;
@ -3735,18 +3755,27 @@ public static partial class OpenIddictClientHandlers
throw new ArgumentNullException(nameof(context)); throw new ArgumentNullException(nameof(context));
} }
// By default, OpenIddict doesn't require that userinfo tokens be used but
// they are extracted and validated when a userinfo request was sent.
(context.ExtractUserinfoToken, (context.ExtractUserinfoToken,
context.RequireUserinfoToken, context.RequireUserinfoToken,
context.ValidateUserinfoToken, context.ValidateUserinfoToken,
context.RejectUserinfoToken) = context.GrantType switch context.RejectUserinfoToken) = context.GrantType switch
{ {
// By default, OpenIddict doesn't require that userinfo tokens be used even for
// user flows but they are extracted and validated when a userinfo request was sent.
GrantTypes.AuthorizationCode or GrantTypes.Implicit or GrantTypes.AuthorizationCode or GrantTypes.Implicit or
GrantTypes.DeviceCode or GrantTypes.Password or GrantTypes.RefreshToken GrantTypes.DeviceCode or GrantTypes.Password or GrantTypes.RefreshToken
when context.SendUserinfoRequest => (true, false, true, true), when context.SendUserinfoRequest => (true, false, true, true),
_ => (false, false, false, false) // Userinfo tokens are typically not used with the client credentials grant,
// but they are extracted and validated when a userinfo request was sent.
GrantTypes.ClientCredentials when context.SendUserinfoRequest
=> (true, false, true, true),
// By default, don't require userinfo tokens for custom grants
// but extract and validate them when a userinfo request was sent.
{ Length: > 0 } when context.SendUserinfoRequest => (true, false, true, true),
_ => (false, false, false, false),
}; };
return default; return default;

119
src/OpenIddict.Client/OpenIddictClientModels.cs

@ -386,6 +386,125 @@ public static class OpenIddictClientModels
public required ClaimsPrincipal? UserinfoTokenPrincipal { get; init; } public required ClaimsPrincipal? UserinfoTokenPrincipal { get; init; }
} }
/// <summary>
/// Represents a custom grant authentication request.
/// </summary>
public sealed record class CustomGrantAuthenticationRequest
{
/// <summary>
/// Gets or sets the parameters that will be added to the token request.
/// </summary>
public Dictionary<string, OpenIddictParameter>? AdditionalTokenRequestParameters { get; init; }
/// <summary>
/// Gets or sets the cancellation token that will be
/// used to determine if the operation was aborted.
/// </summary>
public CancellationToken CancellationToken { get; init; }
/// <summary>
/// Gets or sets a boolean indicating whether userinfo should be disabled.
/// </summary>
public bool DisableUserinfo { get; set; }
/// <summary>
/// Gets or sets the custom grant type that will be used for the authentication request.
/// </summary>
public required string GrantType { get; init; }
/// <summary>
/// Gets or sets the application-specific properties that will be added to the context.
/// </summary>
public Dictionary<string, string?>? Properties { get; init; }
/// <summary>
/// Gets or sets the provider name used to resolve the client registration.
/// </summary>
/// <remarks>
/// Note: if multiple client registrations use the same provider name.
/// the <see cref="RegistrationId"/> property must be explicitly set.
/// </remarks>
public string? ProviderName { get; init; }
/// <summary>
/// Gets or sets the unique identifier of the client registration that will be used.
/// </summary>
public string? RegistrationId { get; init; }
/// <summary>
/// Gets the scopes that will be sent to the authorization server.
/// </summary>
public List<string>? Scopes { get; init; }
/// <summary>
/// Gets or sets the issuer used to resolve the client registration.
/// </summary>
/// <remarks>
/// Note: if multiple client registrations point to the same issuer,
/// the <see cref="RegistrationId"/> property must be explicitly set.
/// </remarks>
public Uri? Issuer { get; init; }
}
/// <summary>
/// Represents a custom grant authentication result.
/// </summary>
public sealed record class CustomGrantAuthenticationResult
{
/// <summary>
/// Gets or sets the access token.
/// </summary>
public required string AccessToken { get; init; }
/// <summary>
/// Gets or sets the expiration date of the access token, if available.
/// </summary>
public required DateTimeOffset? AccessTokenExpirationDate { get; init; }
/// <summary>
/// Gets or sets the identity token, if available.
/// </summary>
public required string? IdentityToken { get; init; }
/// <summary>
/// Gets or sets the principal extracted from the identity token, if available.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Advanced)]
public required ClaimsPrincipal? IdentityTokenPrincipal { get; init; }
/// <summary>
/// Gets or sets a merged principal containing all the claims
/// extracted from the identity token and userinfo token principals.
/// </summary>
public required ClaimsPrincipal Principal { get; init; }
/// <summary>
/// Gets or sets the application-specific properties that were present in the context.
/// </summary>
public required Dictionary<string, string?> Properties { get; init; }
/// <summary>
/// Gets or sets the refresh token, if available.
/// </summary>
public required string? RefreshToken { get; init; }
/// <summary>
/// Gets or sets the token response.
/// </summary>
public required OpenIddictResponse TokenResponse { get; init; }
/// <summary>
/// Gets or sets the userinfo token, if available.
/// </summary>
public required string? UserinfoToken { get; init; }
/// <summary>
/// Gets or sets the principal extracted from the userinfo token or response, if available.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Advanced)]
public required ClaimsPrincipal? UserinfoTokenPrincipal { get; init; }
}
/// <summary> /// <summary>
/// Represents a device authentication request. /// Represents a device authentication request.
/// </summary> /// </summary>

103
src/OpenIddict.Client/OpenIddictClientService.cs

@ -5,6 +5,7 @@
*/ */
using System.Collections.Immutable; using System.Collections.Immutable;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -535,6 +536,108 @@ public class OpenIddictClientService
} }
} }
/// <summary>
/// Authenticates using a custom grant.
/// </summary>
/// <param name="request">The custom grant authentication request.</param>
/// <returns>The custom grant authentication result.</returns>
[EditorBrowsable(EditorBrowsableState.Advanced)]
public async ValueTask<CustomGrantAuthenticationResult> AuthenticateWithCustomGrantAsync(CustomGrantAuthenticationRequest request)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
// Prevent well-known/non-custom grant types from being used with this API.
if (request.GrantType is GrantTypes.AuthorizationCode or GrantTypes.ClientCredentials or
GrantTypes.DeviceCode or GrantTypes.Implicit or
GrantTypes.Password or GrantTypes.RefreshToken)
{
throw new InvalidOperationException(SR.FormatID0310(request.GrantType));
}
request.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<IOpenIddictClientDispatcher>();
var factory = scope.ServiceProvider.GetRequiredService<IOpenIddictClientFactory>();
var transaction = await factory.CreateTransactionAsync();
var context = new ProcessAuthenticationContext(transaction)
{
CancellationToken = request.CancellationToken,
DisableUserinfoRetrieval = request.DisableUserinfo,
DisableUserinfoValidation = request.DisableUserinfo,
GrantType = request.GrantType,
ProviderName = request.ProviderName,
RegistrationId = request.RegistrationId,
TokenRequest = request.AdditionalTokenRequestParameters
is Dictionary<string, OpenIddictParameter> parameters ? new(parameters) : new()
};
if (request.Scopes is { Count: > 0 })
{
context.Scopes.UnionWith(request.Scopes);
}
if (request.Properties is { Count: > 0 })
{
foreach (var property in request.Properties)
{
context.Properties[property.Key] = property.Value;
}
}
await dispatcher.DispatchAsync(context);
if (context.IsRejected)
{
throw new ProtocolException(
SR.FormatID0374(context.Error, context.ErrorDescription, context.ErrorUri),
context.Error, context.ErrorDescription, context.ErrorUri);
}
Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
Debug.Assert(context.TokenResponse is not null, SR.GetResourceString(SR.ID4007));
return new()
{
AccessToken = context.BackchannelAccessToken!,
AccessTokenExpirationDate = context.BackchannelAccessTokenExpirationDate,
IdentityToken = context.BackchannelIdentityToken,
IdentityTokenPrincipal = context.BackchannelIdentityTokenPrincipal,
Principal = context.MergedPrincipal,
Properties = context.Properties,
RefreshToken = context.RefreshToken,
TokenResponse = context.TokenResponse,
UserinfoToken = context.UserinfoToken,
UserinfoTokenPrincipal = context.UserinfoTokenPrincipal
};
}
finally
{
if (scope is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
else
{
scope.Dispose();
}
}
}
/// <summary> /// <summary>
/// Authenticates using the specified device authorization code. /// Authenticates using the specified device authorization code.
/// </summary> /// </summary>

Loading…
Cancel
Save