Browse Source

Update the client stack to support using custom grant types

pull/2034/head
Kévin Chalet 2 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>
</data>
<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 name="ID0295" xml:space="preserve">
<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>
</data>
<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 name="ID0311" xml:space="preserve">
<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()
=> 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>
/// Enables device code flow support. For more information about this
/// 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));
}
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))
{
throw new InvalidOperationException(SR.FormatID0359(context.GrantType));
}
if (context.GrantType is GrantTypes.DeviceCode &&
string.IsNullOrEmpty(context.DeviceCode))
if (context.GrantType is GrantTypes.DeviceCode && string.IsNullOrEmpty(context.DeviceCode))
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0396));
}
@ -358,8 +349,7 @@ public static partial class OpenIddictClientHandlers
}
}
if (context.GrantType is GrantTypes.RefreshToken &&
string.IsNullOrEmpty(context.RefreshToken))
if (context.GrantType is GrantTypes.RefreshToken && string.IsNullOrEmpty(context.RefreshToken))
{
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.
GrantTypes.AuthorizationCode or GrantTypes.Implicit when
context.ResponseType?.Split(Separators.Space) is IList<string> types &&
types.Contains(ResponseTypes.Code)
=> true,
types.Contains(ResponseTypes.Code) => 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
// credentials and refresh token requests, always send a token request.
GrantTypes.ClientCredentials or GrantTypes.DeviceCode or
GrantTypes.Password or GrantTypes.RefreshToken => true,
// By default, always send a token request for custom grant types.
{ Length: > 0 } => true,
_ => false
};
@ -2331,7 +2327,7 @@ public static partial class OpenIddictClientHandlers
// Attach the selected grant type.
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
// 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
// code, resource owner password credentials and refresh token responses.
GrantTypes.ClientCredentials or GrantTypes.DeviceCode or
GrantTypes.Password or GrantTypes.RefreshToken
GrantTypes.Password or GrantTypes.RefreshToken
=> (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)
};
@ -2768,6 +2768,10 @@ public static partial class OpenIddictClientHandlers
// the same routine (except nonce validation) if it is present in the token response.
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)
};
@ -2795,9 +2799,13 @@ public static partial class OpenIddictClientHandlers
// depending on the policy adopted by the remote authorization server. As such,
// a refresh token is never considered required for such token responses.
GrantTypes.ClientCredentials or GrantTypes.DeviceCode or
GrantTypes.Password or GrantTypes.RefreshToken
GrantTypes.Password or GrantTypes.RefreshToken
=> (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)
};
@ -3574,16 +3582,26 @@ public static partial class OpenIddictClientHandlers
context.SendUserinfoRequest = context.GrantType switch
{
// Information about the authenticated user can be retrieved from the userinfo
// endpoint when a frontchannel or backchannel access token is available.
//
// Note: the userinfo endpoint is an optional endpoint and may not be supported.
GrantTypes.AuthorizationCode or GrantTypes.Implicit or
GrantTypes.DeviceCode or GrantTypes.Password or GrantTypes.RefreshToken
// Never send a userinfo request when using the client credentials grant.
GrantTypes.ClientCredentials => false,
// Never send a userinfo request when using the special response_type=none flow.
null when context.ResponseType is ResponseTypes.None => false,
// 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 &&
(!string.IsNullOrEmpty(context.BackchannelAccessToken) ||
!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
};
@ -3601,15 +3619,17 @@ public static partial class OpenIddictClientHandlers
// 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
// 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
// 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.
GrantTypes.RefreshToken or
GrantTypes.RefreshToken => !context.Scopes.Contains(Scopes.OpenId),
// For unknown grant types, disable userinfo validation, unless the openid scope was explicitly added.
_ => !context.Scopes.Contains(Scopes.OpenId)
// For unknown grant types, disable userinfo validation unless the openid scope was explicitly added.
{ Length: > 0 } => !context.Scopes.Contains(Scopes.OpenId),
_ => true
};
return default;
@ -3735,18 +3755,27 @@ public static partial class OpenIddictClientHandlers
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.RequireUserinfoToken,
context.ValidateUserinfoToken,
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.DeviceCode or GrantTypes.Password or GrantTypes.RefreshToken
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;

119
src/OpenIddict.Client/OpenIddictClientModels.cs

@ -386,6 +386,125 @@ public static class OpenIddictClientModels
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>
/// Represents a device authentication request.
/// </summary>

103
src/OpenIddict.Client/OpenIddictClientService.cs

@ -5,6 +5,7 @@
*/
using System.Collections.Immutable;
using System.ComponentModel;
using System.Diagnostics;
using System.Security.Claims;
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>
/// Authenticates using the specified device authorization code.
/// </summary>

Loading…
Cancel
Save