diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 023e228c..5aa91f05 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1355,6 +1355,9 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad The nonce cannot be resolved from the state token. + + No issuer was specified in the authentication context. + The security token is missing. diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.cs b/src/OpenIddict.Client/OpenIddictClientEvents.cs index 100e9be2..c5e775fa 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.cs @@ -294,6 +294,11 @@ public static partial class OpenIddictClientEvents set => Transaction.Request = value; } + /// + /// Gets the user-defined authentication properties, if available. + /// + public Dictionary Properties { get; } = new(StringComparer.Ordinal); + /// /// Gets or sets the grant type used for the authentication demand, if applicable. /// @@ -309,6 +314,11 @@ public static partial class OpenIddictClientEvents /// public string? RequestForgeryProtection { get; set; } + /// + /// Gets the scopes that will be sent to the authorization server, if applicable. + /// + public HashSet Scopes { get; } = new(StringComparer.Ordinal); + /// /// Gets or sets the address of the token endpoint, if applicable. /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index ea66b47d..a663a72b 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -30,6 +30,7 @@ public static partial class OpenIddictClientHandlers * Authentication processing: */ ValidateAuthenticationDemand.Descriptor, + ResolveClientRegistrationFromAuthenticationContext.Descriptor, EvaluateValidatedUpfrontTokens.Descriptor, ResolveValidatedStateToken.Descriptor, ValidateRequiredStateToken.Descriptor, @@ -205,6 +206,16 @@ public static partial class OpenIddictClientHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0311)); } + // If no issuer was explicitly attached and a single client is registered, use it. + // Otherwise, throw an exception to indicate that setting an explicit issuer + // is required when multiple clients are registered. + context.Issuer ??= context.Options.Registrations.Count switch + { + 0 => throw new InvalidOperationException(SR.GetResourceString(SR.ID0304)), + 1 => context.Options.Registrations[0].Issuer, + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0355)) + }; + break; default: throw new InvalidOperationException(SR.GetResourceString(SR.ID0290)); @@ -214,6 +225,57 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for resolving the client registration applicable to the authentication demand. + /// + public sealed class ResolveClientRegistrationFromAuthenticationContext : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateAuthenticationDemand.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: this handler only applies to authentication demands triggered from unknown endpoints. + // + // Client registrations/configurations that need to be resolved as part of authentication demands + // triggered from the redirection or post-logout redirection requests are handled elsewhere. + if (context.EndpointType is not OpenIddictClientEndpointType.Unknown) + { + return; + } + + // Note: if the static registration cannot be found in the options, this may indicate + // the client was removed after the authorization dance started and thus, can no longer + // be used to authenticate users. In this case, throw an exception to abort the flow. + context.Registration ??= context.Options.Registrations.Find( + registration => registration.Issuer == context.Issuer) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); + + // Resolve and attach the server configuration to the context if none has been set already. + context.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 (context.Configuration.Issuer != context.Issuer) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); + } + } + } + /// /// Contains the logic responsible for determining the types of tokens to validate upfront. /// @@ -225,7 +287,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ValidateAuthenticationDemand.Descriptor.Order + 1_000) + .SetOrder(ResolveClientRegistrationFromAuthenticationContext.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -630,23 +692,19 @@ public static partial class OpenIddictClientHandlers // Note: if the static registration cannot be found in the options, this may indicate // the client was removed after the authorization dance started and thus, can no longer // be used to authenticate users. In this case, throw an exception to abort the flow. - var registration = context.Options.Registrations.Find(registration => registration.Issuer == issuer) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); - context.Issuer = issuer; - context.Registration = registration; + context.Registration = context.Options.Registrations.Find(registration => registration.Issuer == issuer) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); // Resolve and attach the server configuration to the context. - var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? + context.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) + if (context.Configuration.Issuer != context.Issuer) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); } - - context.Configuration = configuration; } } @@ -1776,11 +1834,14 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - // Try to extract the address of the token endpoint from the server configuration. - if (context.Configuration.TokenEndpoint is { IsAbsoluteUri: true }) + // If the address of the token endpoint wasn't explicitly set + // at this stage, try to extract it from the server configuration. + context.TokenEndpoint ??= context.Configuration.TokenEndpoint switch { - context.TokenEndpoint = context.Configuration.TokenEndpoint; - } + { IsAbsoluteUri: true } address when address.IsWellFormedOriginalString() => address, + + _ => null + }; return default; } @@ -1871,6 +1932,14 @@ public static partial class OpenIddictClientHandlers string type => type }; + if (context.Scopes.Count > 0) + { + // Note: the final OAuth 2.0 specification requires using a space as the scope separator. + // Clients that need to deal with older or non-compliant implementations can register + // a custom handler to use a different separator (typically, a comma). + context.TokenRequest.Scope = string.Join(" ", context.Scopes); + } + // If the token request uses an authorization code grant, retrieve the code_verifier and // the redirect_uri from the state token principal and attach them to the request, if available. if (context.TokenRequest.GrantType is GrantTypes.AuthorizationCode) @@ -2176,7 +2245,7 @@ public static partial class OpenIddictClientHandlers try { context.TokenResponse = await _service.SendTokenRequestAsync( - context.Registration, context.TokenEndpoint, context.TokenRequest); + context.Registration, context.TokenRequest, context.TokenEndpoint); } catch (ProtocolException exception) @@ -2858,7 +2927,7 @@ public static partial class OpenIddictClientHandlers SecurityAlgorithms.RsaSha512 or SecurityAlgorithms.RsaSsaPssSha512 => OpenIddictHelpers.ComputeSha512Hash(Encoding.ASCII.GetBytes(token)), - _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0293)) + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0295)) }; // Warning: only the left-most half of the access token and authorization code digest is used. @@ -3043,11 +3112,14 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - // Try to extract the address of the userinfo endpoint from the server configuration. - if (context.Configuration.UserinfoEndpoint is { IsAbsoluteUri: true }) + // If the address of the userinfo endpoint wasn't explicitly set + // at this stage, try to extract it from the server configuration. + context.UserinfoEndpoint ??= context.Configuration.UserinfoEndpoint switch { - context.UserinfoEndpoint = context.Configuration.UserinfoEndpoint; - } + { IsAbsoluteUri: true } address when address.IsWellFormedOriginalString() => address, + + _ => null + }; return default; } @@ -3174,7 +3246,7 @@ public static partial class OpenIddictClientHandlers try { (context.UserinfoResponse, (context.UserinfoTokenPrincipal, context.UserinfoToken)) = - await _service.SendUserinfoRequestAsync(context.Registration, context.UserinfoEndpoint, context.UserinfoRequest); + await _service.SendUserinfoRequestAsync(context.Registration, context.UserinfoRequest, context.UserinfoEndpoint); } catch (ProtocolException exception) @@ -3566,21 +3638,19 @@ public static partial class OpenIddictClientHandlers // Note: if the static registration cannot be found in the options, this may indicate // the client was removed after the authorization dance started and thus, can no longer // be used to authenticate users. In this case, throw an exception to abort the flow. - context.Registration = context.Options.Registrations.Find( + context.Registration ??= context.Options.Registrations.Find( registration => registration.Issuer == context.Issuer) ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); - // Resolve and attach the server configuration to the context. - var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? + // Resolve and attach the server configuration to the context if none has been set already. + context.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) + if (context.Configuration.Issuer != context.Issuer) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); } - - context.Configuration = configuration; } } @@ -4713,21 +4783,19 @@ public static partial class OpenIddictClientHandlers // Note: if the static registration cannot be found in the options, this may indicate // the client was removed after the authorization dance started and thus, can no longer // be used to authenticate users. In this case, throw an exception to abort the flow. - context.Registration = context.Options.Registrations.Find( + context.Registration ??= context.Options.Registrations.Find( registration => registration.Issuer == context.Issuer) ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); - // Resolve and attach the server configuration to the context. - var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? + // Resolve and attach the server configuration to the context if none has been set already. + context.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) + if (context.Configuration.Issuer != context.Issuer) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); } - - context.Configuration = configuration; } } diff --git a/src/OpenIddict.Client/OpenIddictClientService.cs b/src/OpenIddict.Client/OpenIddictClientService.cs index 2b8ddf09..8f162e6f 100644 --- a/src/OpenIddict.Client/OpenIddictClientService.cs +++ b/src/OpenIddict.Client/OpenIddictClientService.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using System.Security.Claims; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using OpenIddict.Extensions; using static OpenIddict.Abstractions.OpenIddictExceptions; @@ -25,6 +26,345 @@ public sealed class OpenIddictClientService public OpenIddictClientService(IServiceProvider provider) => _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + /// + /// Authenticates using the client credentials grant and resolves the corresponding tokens. + /// + /// The issuer. + /// The scopes to request to the authorization server. + /// The additional parameters to send as part of the token request. + /// The application-specific properties that will be added to the authentication context. + /// 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)> AuthenticateWithClientCredentialsAsync( + Uri issuer, string[]? scopes = null, + Dictionary? parameters = null, + Dictionary? properties = null, CancellationToken cancellationToken = default) + { + if (issuer is null) + { + throw new ArgumentNullException(nameof(issuer)); + } + + if (scopes is not null && scopes.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0074), nameof(scopes)); + } + + var options = _provider.GetRequiredService>(); + var registration = options.CurrentValue.Registrations.Find(registration => registration.Issuer == issuer) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); + + var configuration = await registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + if (configuration.TokenEndpoint is not { IsAbsoluteUri: true } address || !address.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) + { + Configuration = configuration, + GrantType = GrantTypes.ClientCredentials, + Issuer = registration.Issuer, + Registration = registration, + TokenEndpoint = address, + TokenRequest = parameters is not null ? new(parameters) : null, + }; + + if (scopes is { Length: > 0 }) + { + context.Scopes.UnionWith(scopes); + } + + if (properties is { Count: > 0 }) + { + foreach (var property in properties) + { + context.Properties[property.Key] = property.Value; + } + } + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0319(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, OpenIddictHelpers.CreateMergedPrincipal( + context.BackchannelIdentityTokenPrincipal, + context.UserinfoTokenPrincipal)); + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + + /// + /// Authenticates using the resource owner password credentials grant and resolves the corresponding tokens. + /// + /// The issuer. + /// The username to use. + /// The password to use. + /// The scopes to request to the authorization server. + /// The additional parameters to send as part of the token request. + /// The application-specific properties that will be added to the authentication context. + /// 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)> AuthenticateWithPasswordAsync( + Uri issuer, string username, string password, string[]? scopes = null, + Dictionary? parameters = null, + Dictionary? properties = null, CancellationToken cancellationToken = default) + { + if (issuer is null) + { + throw new ArgumentNullException(nameof(issuer)); + } + + if (string.IsNullOrEmpty(username)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0335), nameof(username)); + } + + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0336), nameof(password)); + } + + if (scopes is not null && scopes.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0074), nameof(scopes)); + } + + var options = _provider.GetRequiredService>(); + var registration = options.CurrentValue.Registrations.Find(registration => registration.Issuer == issuer) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); + + var configuration = await registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + if (configuration.TokenEndpoint is not { IsAbsoluteUri: true } address || !address.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) + { + Configuration = configuration, + GrantType = GrantTypes.Password, + Issuer = registration.Issuer, + Password = password, + Registration = registration, + TokenEndpoint = address, + TokenRequest = parameters is not null ? new(parameters) : null, + Username = username + }; + + if (scopes is { Length: > 0 }) + { + context.Scopes.UnionWith(scopes); + } + + if (properties is { Count: > 0 }) + { + foreach (var property in properties) + { + context.Properties[property.Key] = property.Value; + } + } + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0319(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, OpenIddictHelpers.CreateMergedPrincipal( + context.BackchannelIdentityTokenPrincipal, + context.UserinfoTokenPrincipal)); + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + + /// + /// Authenticates using the resource owner password credentials grant and resolves the corresponding tokens. + /// + /// The issuer. + /// The refresh token to use. + /// The scopes to request to the authorization server. + /// The additional parameters to send as part of the token request. + /// The application-specific properties that will be added to the authentication context. + /// 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)> AuthenticateWithRefreshTokenAsync( + Uri issuer, string token, string[]? scopes = null, + Dictionary? parameters = null, + Dictionary? properties = null, CancellationToken cancellationToken = default) + { + if (issuer is null) + { + throw new ArgumentNullException(nameof(issuer)); + } + + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0156), nameof(token)); + } + + if (scopes is not null && scopes.Any(string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0074), nameof(scopes)); + } + + var options = _provider.GetRequiredService>(); + var registration = options.CurrentValue.Registrations.Find(registration => registration.Issuer == issuer) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); + + var configuration = await registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + if (configuration.TokenEndpoint is not { IsAbsoluteUri: true } address || !address.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) + { + Configuration = configuration, + GrantType = GrantTypes.RefreshToken, + Issuer = registration.Issuer, + RefreshToken = token, + Registration = registration, + TokenEndpoint = address, + TokenRequest = parameters is not null ? new(parameters) : null, + }; + + if (scopes is { Length: > 0 }) + { + context.Scopes.UnionWith(scopes); + } + + if (properties is { Count: > 0 }) + { + foreach (var property in properties) + { + context.Properties[property.Key] = property.Value; + } + } + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0319(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, OpenIddictHelpers.CreateMergedPrincipal( + context.BackchannelIdentityTokenPrincipal, + context.UserinfoTokenPrincipal)); + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + /// /// Retrieves the OpenID Connect server configuration from the specified address. /// @@ -32,7 +372,7 @@ public sealed class OpenIddictClientService /// The address of the remote metadata endpoint. /// The that can be used to abort the operation. /// The OpenID Connect server configuration retrieved from the remote server. - public async ValueTask GetConfigurationAsync( + internal async ValueTask GetConfigurationAsync( OpenIddictClientRegistration registration, Uri address, CancellationToken cancellationToken = default) { if (registration is null) @@ -190,7 +530,7 @@ public sealed class OpenIddictClientService /// The address of the remote metadata endpoint. /// The that can be used to abort the operation. /// The security keys retrieved from the remote server. - public async ValueTask GetSecurityKeysAsync( + internal async ValueTask GetSecurityKeysAsync( OpenIddictClientRegistration registration, Uri address, CancellationToken cancellationToken = default) { if (registration is null) @@ -342,277 +682,28 @@ public sealed class OpenIddictClientService } } - /// - /// Authenticates using the client credentials grant and resolves the corresponding tokens. - /// - /// The client registration. - /// 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)> AuthenticateWithClientCredentialsAsync( - OpenIddictClientRegistration registration, CancellationToken cancellationToken = default) - { - if (registration is null) - { - throw new ArgumentNullException(nameof(registration)); - } - - 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) - { - Configuration = configuration, - GrantType = GrantTypes.ClientCredentials, - Issuer = registration.Issuer, - Registration = registration - }; - - await dispatcher.DispatchAsync(context); - - if (context.IsRejected) - { - throw new ProtocolException( - SR.FormatID0319(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, OpenIddictHelpers.CreateMergedPrincipal( - context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal)); - } - - finally - { - if (scope is IAsyncDisposable disposable) - { - await disposable.DisposeAsync(); - } - - else - { - scope.Dispose(); - } - } - } - - /// - /// Authenticates using the resource owner password credentials grant and resolves the corresponding tokens. - /// - /// The client registration. - /// The username to use. - /// The password 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)> AuthenticateWithPasswordAsync( - OpenIddictClientRegistration registration, string username, string password, CancellationToken cancellationToken = default) - { - if (registration is null) - { - throw new ArgumentNullException(nameof(registration)); - } - - if (string.IsNullOrEmpty(username)) - { - throw new ArgumentException(SR.GetResourceString(SR.ID0335), nameof(username)); - } - - if (string.IsNullOrEmpty(password)) - { - throw new ArgumentException(SR.GetResourceString(SR.ID0336), nameof(password)); - } - - 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) - { - Configuration = configuration, - GrantType = GrantTypes.Password, - Issuer = registration.Issuer, - Password = password, - Registration = registration, - Username = username - }; - - await dispatcher.DispatchAsync(context); - - if (context.IsRejected) - { - throw new ProtocolException( - SR.FormatID0319(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, OpenIddictHelpers.CreateMergedPrincipal( - context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal)); - } - - finally - { - if (scope is IAsyncDisposable disposable) - { - await disposable.DisposeAsync(); - } - - else - { - scope.Dispose(); - } - } - } - - /// - /// Authenticates using the refresh token grant and resolves the corresponding tokens. - /// - /// 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)> AuthenticateWithRefreshTokenAsync( - 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) - { - Configuration = configuration, - GrantType = GrantTypes.RefreshToken, - Issuer = registration.Issuer, - RefreshToken = token, - Registration = registration - }; - - await dispatcher.DispatchAsync(context); - - if (context.IsRejected) - { - throw new ProtocolException( - SR.FormatID0319(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, OpenIddictHelpers.CreateMergedPrincipal( - context.BackchannelIdentityTokenPrincipal, - context.UserinfoTokenPrincipal)); - } - - finally - { - if (scope is IAsyncDisposable disposable) - { - await disposable.DisposeAsync(); - } - - else - { - scope.Dispose(); - } - } - } - /// /// Sends the token request and retrieves the corresponding response. /// /// The client registration. - /// The address of the token endpoint. /// The token request. + /// The address of the remote token endpoint. /// The that can be used to abort the operation. /// The token response. - public async ValueTask SendTokenRequestAsync( - OpenIddictClientRegistration registration, Uri address, OpenIddictRequest request, CancellationToken cancellationToken = default) + internal async ValueTask SendTokenRequestAsync( + OpenIddictClientRegistration registration, OpenIddictRequest request, + Uri? address = null, CancellationToken cancellationToken = default) { if (registration is null) { throw new ArgumentNullException(nameof(registration)); } + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + if (address is null) { throw new ArgumentNullException(nameof(address)); @@ -626,12 +717,6 @@ public sealed class OpenIddictClientService 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 @@ -772,12 +857,12 @@ public sealed class OpenIddictClientService /// Sends the userinfo request and retrieves the corresponding response. /// /// The client registration. - /// The address of the userinfo endpoint. /// The userinfo request. + /// The address of the remote userinfo endpoint. /// The that can be used to abort the operation. /// The response and the principal extracted from the userinfo response or the userinfo token. - public async ValueTask<(OpenIddictResponse Response, (ClaimsPrincipal? Principal, string? Token))> SendUserinfoRequestAsync( - OpenIddictClientRegistration registration, Uri address, OpenIddictRequest request, CancellationToken cancellationToken = default) + internal async ValueTask<(OpenIddictResponse Response, (ClaimsPrincipal? Principal, string? Token))> SendUserinfoRequestAsync( + OpenIddictClientRegistration registration, OpenIddictRequest request, Uri address, CancellationToken cancellationToken = default) { if (registration is null) { @@ -797,12 +882,6 @@ public sealed class OpenIddictClientService 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 diff --git a/src/OpenIddict.Validation/OpenIddictValidationService.cs b/src/OpenIddict.Validation/OpenIddictValidationService.cs index 546af2c3..a19247f1 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationService.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationService.cs @@ -24,13 +24,75 @@ public sealed class OpenIddictValidationService public OpenIddictValidationService(IServiceProvider provider) => _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + /// + /// Validates the specified access token and returns the principal extracted from the token. + /// + /// The access token to validate. + /// The that can be used to abort the operation. + /// The principal containing the claims extracted from the token. + public async ValueTask ValidateAccessTokenAsync(string token, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0162), nameof(token)); + } + + 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 ValidateTokenContext(transaction) + { + Token = token, + ValidTokenTypes = { TokenTypeHints.AccessToken } + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0163(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + return context.Principal; + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + /// /// Retrieves the OpenID Connect server configuration from the specified address. /// /// The address of the remote metadata endpoint. /// The that can be used to abort the operation. /// The OpenID Connect server configuration retrieved from the remote server. - public async ValueTask GetConfigurationAsync(Uri address, CancellationToken cancellationToken = default) + internal async ValueTask GetConfigurationAsync(Uri address, CancellationToken cancellationToken = default) { if (address is null) { @@ -173,7 +235,7 @@ public sealed class OpenIddictValidationService /// The address of the remote metadata endpoint. /// The that can be used to abort the operation. /// The security keys retrieved from the remote server. - public async ValueTask GetSecurityKeysAsync(Uri address, CancellationToken cancellationToken = default) + internal async ValueTask GetSecurityKeysAsync(Uri address, CancellationToken cancellationToken = default) { if (address is null) { @@ -310,16 +372,6 @@ public sealed class OpenIddictValidationService } } - /// - /// Sends an introspection request to the specified address and returns the corresponding principal. - /// - /// The address of the remote metadata endpoint. - /// The token to introspect. - /// The that can be used to abort the operation. - /// The claims principal created from the claim retrieved from the remote server. - public ValueTask IntrospectTokenAsync(Uri address, string token, CancellationToken cancellationToken = default) - => IntrospectTokenAsync(address, token, hint: null, cancellationToken); - /// /// Sends an introspection request to the specified address and returns the corresponding principal. /// @@ -328,7 +380,7 @@ public sealed class OpenIddictValidationService /// The token type to introspect, used as a hint by the authorization server. /// The that can be used to abort the operation. /// The claims principal created from the claim retrieved from the remote server. - public async ValueTask IntrospectTokenAsync( + internal async ValueTask IntrospectTokenAsync( Uri address, string token, string? hint, CancellationToken cancellationToken = default) { if (address is null) @@ -475,66 +527,4 @@ public sealed class OpenIddictValidationService } } } - - /// - /// Validates the specified access token and returns the principal extracted from the token. - /// - /// The access token to validate. - /// The that can be used to abort the operation. - /// The principal containing the claims extracted from the token. - public async ValueTask ValidateAccessTokenAsync(string token, CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(token)) - { - throw new ArgumentException(SR.GetResourceString(SR.ID0162), nameof(token)); - } - - 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 ValidateTokenContext(transaction) - { - Token = token, - ValidTokenTypes = { TokenTypeHints.AccessToken } - }; - - await dispatcher.DispatchAsync(context); - - if (context.IsRejected) - { - throw new ProtocolException( - SR.FormatID0163(context.Error, context.ErrorDescription, context.ErrorUri), - context.Error, context.ErrorDescription, context.ErrorUri); - } - - Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - - return context.Principal; - } - - finally - { - if (scope is IAsyncDisposable disposable) - { - await disposable.DisposeAsync(); - } - - else - { - scope.Dispose(); - } - } - } }