diff --git a/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs b/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs index 56e96938..c68a0f97 100644 --- a/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs +++ b/gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs @@ -841,6 +841,10 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration AuthorizationEndpoint = new Uri($""{{ environment.configuration.authorization_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute), {{~ end ~}} + {{~ if environment.configuration.device_authorization_endpoint ~}} + DeviceAuthorizationEndpoint = new Uri($""{{ environment.configuration.device_authorization_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute), + {{~ end ~}} + {{~ if environment.configuration.token_endpoint ~}} TokenEndpoint = new Uri($""{{ environment.configuration.token_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute), {{~ end ~}} @@ -884,6 +888,13 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration {{~ end ~}} }, + DeviceAuthorizationEndpointAuthMethodsSupported = + { + {{~ for method in environment.configuration.device_authorization_endpoint_auth_methods_supported ~}} + ""{{ method }}"", + {{~ end ~}} + }, + TokenEndpointAuthMethodsSupported = { {{~ for method in environment.configuration.token_endpoint_auth_methods_supported ~}} @@ -946,6 +957,7 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration XElement configuration => new { AuthorizationEndpoint = (string?) configuration.Attribute("AuthorizationEndpoint"), + DeviceAuthorizationEndpoint = (string?) configuration.Attribute("DeviceAuthorizationEndpoint"), TokenEndpoint = (string?) configuration.Attribute("TokenEndpoint"), UserinfoEndpoint = (string?) configuration.Attribute("UserinfoEndpoint"), @@ -987,12 +999,21 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration _ => (IList) Array.Empty() }, + DeviceAuthorizationEndpointAuthMethodsSupported = configuration.Elements("DeviceAuthorizationEndpointAuthMethodsSupported").ToList() switch + { + { Count: > 0 } methods => methods.Select(type => (string?) type.Attribute("Value")).ToList(), + + // If no explicit client authentication method was set, assume the provider only supports + // flowing the client credentials as part of the device authorization request payload. + _ => (IList) new[] { ClientAuthenticationMethods.ClientSecretPost } + }, + TokenEndpointAuthMethodsSupported = configuration.Elements("TokenEndpointAuthMethod").ToList() switch { { Count: > 0 } methods => methods.Select(type => (string?) type.Attribute("Value")).ToList(), - // If no explicit response type was set, assume the provider only supports - // flowing the client credentials as part of the token request payload. + // If no explicit client authentication method was set, assume the provider only + // supports flowing the client credentials as part of the token request payload. _ => (IList) new[] { ClientAuthenticationMethods.ClientSecretPost } } }, diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs index 6b1beb83..b019190a 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs @@ -7,6 +7,7 @@ using System.Web; using System.Web.Mvc; using Microsoft.Owin.Security; using Microsoft.Owin.Security.Cookies; +using OpenIddict.Client; using OpenIddict.Client.Owin; using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; @@ -15,6 +16,11 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers { public class AuthenticationController : Controller { + private readonly OpenIddictClientService _service; + + public AuthenticationController(OpenIddictClientService service) + => _service = service; + [HttpPost, Route("~/login"), ValidateAntiForgeryToken] public ActionResult LogIn(string provider, string returnUrl) { @@ -92,16 +98,16 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers // Remove the local authentication cookie before triggering a redirection to the remote server. context.Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType); - // Resolve the issuer of the user identifier claim stored in the local authentication cookie. - // If the issuer is known to support remote sign-out, ask OpenIddict to initiate a logout request. - var issuer = identity.Claims.Select(claim => claim.Issuer).First(); - if (issuer is "https://localhost:44349/") + // Resolve the provider of the user identifier claim stored in the local authentication cookie. + // If the provider is known to support remote sign-out, ask OpenIddict to initiate a logout request. + if (Uri.TryCreate(identity.FindFirst(Claims.AuthorizationServer)?.Value, UriKind.Absolute, out Uri issuer) && + await _service.GetServerConfigurationAsync(issuer) is { EndSessionEndpoint: Uri }) { var properties = new AuthenticationProperties(new Dictionary { // Note: when only one client is registered in the client options, // setting the issuer property is not required and can be omitted. - [OpenIddictClientOwinConstants.Properties.Issuer] = issuer, + [OpenIddictClientOwinConstants.Properties.Issuer] = issuer.AbsoluteUri, // While not required, the specification encourages sending an id_token_hint // parameter containing an identity token returned by the server for this user. @@ -199,6 +205,9 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider" } => true, + // Preserve the identity of the authorization server as a dedicated claim. + { Type: Claims.AuthorizationServer } => true, + // Applications that use multiple client registrations can filter claims based on the issuer. { Type: "bio", Issuer: "https://github.com/" } => true, diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs index 6f08d0c4..3da2da33 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; +using OpenIddict.Client; using OpenIddict.Client.AspNetCore; using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; @@ -10,6 +11,11 @@ namespace OpenIddict.Sandbox.AspNetCore.Client.Controllers; public class AuthenticationController : Controller { + private readonly OpenIddictClientService _service; + + public AuthenticationController(OpenIddictClientService service) + => _service = service; + [HttpPost("~/login"), ValidateAntiForgeryToken] public ActionResult LogIn(string provider, string returnUrl) { @@ -83,16 +89,16 @@ public class AuthenticationController : Controller // Remove the local authentication cookie before triggering a redirection to the remote server. await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - // Resolve the issuer of the user identifier claim stored in the local authentication cookie. - // If the issuer is known to support remote sign-out, ask OpenIddict to initiate a logout request. - var issuer = identity.Claims.Select(claim => claim.Issuer).First(); - if (issuer is "https://localhost:44395/") + // Resolve the provider of the user identifier claim stored in the local authentication cookie. + // If the provider is known to support remote sign-out, ask OpenIddict to initiate a logout request. + if (Uri.TryCreate(identity.FindFirst(Claims.AuthorizationServer)?.Value, UriKind.Absolute, out Uri issuer) && + await _service.GetServerConfigurationAsync(issuer) is { EndSessionEndpoint: Uri }) { var properties = new AuthenticationProperties(new Dictionary { // Note: when only one client is registered in the client options, // setting the issuer property is not required and can be omitted. - [OpenIddictClientAspNetCoreConstants.Properties.Issuer] = issuer, + [OpenIddictClientAspNetCoreConstants.Properties.Issuer] = issuer.AbsoluteUri, // While not required, the specification encourages sending an id_token_hint // parameter containing an identity token returned by the server for this user. @@ -177,6 +183,9 @@ public class AuthenticationController : Controller // Preserve the basic claims that are necessary for the application to work correctly. { Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true, + // Preserve the identity of the authorization server as a dedicated claim. + { Type: Claims.AuthorizationServer } => true, + // Applications that use multiple client registrations can filter claims based on the issuer. { Type: "bio", Issuer: "https://github.com/" } => true, diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs index 3d439d71..a7ddd733 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs @@ -54,8 +54,10 @@ public class Worker : IHostedService Permissions = { Permissions.Endpoints.Authorization, + Permissions.Endpoints.Device, Permissions.Endpoints.Token, Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.DeviceCode, Permissions.GrantTypes.RefreshToken, Permissions.ResponseTypes.Code, Permissions.Scopes.Email, diff --git a/sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs b/sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs index f0254b5c..d2998b38 100644 --- a/sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs +++ b/sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Hosting; +using System.Security.Claims; +using Microsoft.Extensions.Hosting; using OpenIddict.Client; using Spectre.Console; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -37,16 +38,67 @@ public class InteractiveService : BackgroundService { var provider = await GetSelectedProviderAsync(stoppingToken); - AnsiConsole.MarkupLine("[cyan]Launching the system browser.[/]"); - try { - // Ask OpenIddict to initiate the authentication flow (typically, by - // starting the system browser) and wait for the user to complete it. - var (_, _, principal) = await _service.AuthenticateInteractivelyAsync( - provider, cancellationToken: stoppingToken); + ClaimsPrincipal principal; + + // Resolve the server configuration and determine the type of flow + // to use depending on the supported grants and the user selection. + var configuration = await _service.GetServerConfigurationAsync(provider, cancellationToken: stoppingToken); + if (configuration.GrantTypesSupported.Contains(GrantTypes.DeviceCode) && + configuration.DeviceAuthorizationEndpoint is not null && + await UseDeviceAuthorizationGrantAsync(stoppingToken)) + { + // Ask OpenIddict to send a device authorization request and write + // the complete verification endpoint URI to the console output. + var response = await _service.ChallengeUsingDeviceAsync(provider, cancellationToken: stoppingToken); + if (response.VerificationUriComplete is not null) + { + AnsiConsole.MarkupLineInterpolated( + $"[yellow]Please visit [link]{response.VerificationUriComplete}[/] and confirm the displayed code is '{response.UserCode}' to complete the authentication demand.[/]"); + } + + else + { + AnsiConsole.MarkupLineInterpolated( + $"[yellow]Please visit [link]{response.VerificationUri}[/] and enter '{response.UserCode}' to complete the authentication demand.[/]"); + } + + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + cancellationTokenSource.CancelAfter(response.ExpiresIn < TimeSpan.FromMinutes(5) ? + response.ExpiresIn : TimeSpan.FromMinutes(5)); + + // Wait for the user to complete the demand on the other device. + (_, principal) = await _service.AuthenticateWithDeviceAsync(provider, + response.DeviceCode, cancellationToken: cancellationTokenSource.Token); + } + + else + { + AnsiConsole.MarkupLine("[cyan]Launching the system browser.[/]"); + + // Ask OpenIddict to initiate the authentication flow (typically, by + // starting the system browser) and wait for the user to complete it. + (_, _, principal) = await _service.AuthenticateInteractivelyAsync( + provider, cancellationToken: stoppingToken); + } + + AnsiConsole.MarkupLine("[green]Authentication successful:[/]"); + + var table = new Table() + .AddColumn(new TableColumn("Claim type").Centered()) + .AddColumn(new TableColumn("Claim value type").Centered()) + .AddColumn(new TableColumn("Claim value").Centered()); + + foreach (var claim in principal.Claims) + { + table.AddRow( + claim.Type.EscapeMarkup(), + claim.ValueType.EscapeMarkup(), + claim.Value.EscapeMarkup()); + } - AnsiConsole.MarkupLineInterpolated($"[green]Welcome, {principal.FindFirst(Claims.Name)!.Value}.[/]"); + AnsiConsole.Write(table); } catch (OperationCanceledException) @@ -65,16 +117,32 @@ public class InteractiveService : BackgroundService } } - static async Task GetSelectedProviderAsync(CancellationToken cancellationToken) + static Task UseDeviceAuthorizationGrantAsync(CancellationToken cancellationToken) + { + static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt( + "Would you like to authenticate using the device authorization grant?") + { + DefaultValue = false, + ShowDefaultValue = true + }); + + return WaitAsync(Task.Run(Prompt, cancellationToken), cancellationToken); + } + + static Task GetSelectedProviderAsync(CancellationToken cancellationToken) { static string Prompt() => AnsiConsole.Prompt(new SelectionPrompt() .Title("Select the authentication provider you'd like to log in with.") .AddChoices("Local", Providers.GitHub, Providers.Twitter)); + return WaitAsync(Task.Run(Prompt, cancellationToken), cancellationToken); + } + + static async Task WaitAsync(Task task, CancellationToken cancellationToken) + { #if SUPPORTS_TASK_WAIT_ASYNC - return await Task.Run(Prompt, cancellationToken).WaitAsync(cancellationToken); + return await task.WaitAsync(cancellationToken); #else - var task = Task.Run(Prompt, cancellationToken); var source = new TaskCompletionSource(TaskCreationOptions.None); using (cancellationToken.Register(static state => ((TaskCompletionSource) state!).SetResult(true), source)) diff --git a/sandbox/OpenIddict.Sandbox.Console.Client/Program.cs b/sandbox/OpenIddict.Sandbox.Console.Client/Program.cs index 2d38ae02..1e5b244f 100644 --- a/sandbox/OpenIddict.Sandbox.Console.Client/Program.cs +++ b/sandbox/OpenIddict.Sandbox.Console.Client/Program.cs @@ -36,9 +36,10 @@ var host = new HostBuilder() // Register the OpenIddict client components. .AddClient(options => { - // Note: this sample uses the authorization code and refresh token - // flows, but you can enable the other flows if necessary. + // Note: this sample uses the authorization code, device authorization code + // and refresh token flows, but you can enable the other flows if necessary. options.AllowAuthorizationCodeFlow() + .AllowDeviceCodeFlow() .AllowRefreshTokenFlow(); // Register the signing and encryption credentials used to protect diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 4adc26f8..50b399c4 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -261,6 +261,7 @@ public static class OpenIddictConstants public const string ClaimTypesSupported = "claim_types_supported"; public const string CodeChallengeMethodsSupported = "code_challenge_methods_supported"; public const string DeviceAuthorizationEndpoint = "device_authorization_endpoint"; + public const string DeviceAuthorizationEndpointAuthMethodsSupported = "device_authorization_endpoint_auth_methods_supported"; public const string DisplayValuesSupported = "display_values_supported"; public const string EndSessionEndpoint = "end_session_endpoint"; public const string GrantTypesSupported = "grant_types_supported"; @@ -325,6 +326,7 @@ public static class OpenIddictConstants public const string IdentityProvider = "identity_provider"; public const string IdToken = "id_token"; public const string IdTokenHint = "id_token_hint"; + public const string Interval = "interval"; public const string Iss = "iss"; public const string LoginHint = "login_hint"; public const string Keys = "keys"; diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 033c7dcb..d84a1222 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1486,6 +1486,39 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId The issuer attached to the static configuration must be the same as the one configured in the client registration. + + A device code must be specified when using the device authorization code grant. + + + The client registration corresponding to the specified provider name cannot be found in the client options. + + + An error occurred while preparing the device authorization request. + Error: {0} + Error description: {1} + Error URI: {2} + + + An error occurred while sending the device authorization request. + Error: {0} + Error description: {1} + Error URI: {2} + + + An error occurred while extracting the device authorization response. + Error: {0} + Error description: {1} + Error URI: {2} + + + An error occurred while handling the device authorization response. + Error: {0} + Error description: {1} + Error URI: {2} + + + The grant type '{0}' is not supported by the ASP.NET Core and OWIN integrations. + The security token is missing. @@ -1984,6 +2017,15 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId The received authorization response is not valid for this instance of the application. + + The device authorization request was rejected by the remote server. + + + The mandatory '{0}' parameter couldn't be found in the device authorization response. + + + The '{0}' parameter returned in the device authorization response is not valid absolute URI. + The '{0}' parameter shouldn't be null or empty at this point. @@ -2653,6 +2695,15 @@ This may indicate that the hashed entry is corrupted or malformed. An error occurred while redirecting a protocol activation to the '{Identifier}' instance. + + The device authorization request was rejected by the remote authorization server: {Response}. + + + The device authorization request was successfully sent to {Uri}: {Request}. + + + The device authorization response returned by {Uri} was successfully extracted: {Response}. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs index df6aa479..fe8f69af 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs @@ -31,6 +31,16 @@ public sealed class OpenIddictConfiguration /// public HashSet CodeChallengeMethodsSupported { get; } = new(StringComparer.Ordinal); + /// + /// Gets or sets the URI of the device authorization endpoint. + /// + public Uri? DeviceAuthorizationEndpoint { get; set; } + + /// + /// Gets the client authentication methods supported by the device authorization endpoint. + /// + public HashSet DeviceAuthorizationEndpointAuthMethodsSupported { get; } = new(StringComparer.Ordinal); + /// /// Gets or sets the URI of the end session endpoint. /// diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs index f21098ef..9f6833d4 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs @@ -222,4 +222,22 @@ public class OpenIddictResponse : OpenIddictMessage get => (string?) GetParameter(OpenIddictConstants.Parameters.UserCode); set => SetParameter(OpenIddictConstants.Parameters.UserCode, value); } + + /// + /// Gets or sets the "verification_uri" parameter. + /// + public string? VerificationUri + { + get => (string?) GetParameter(OpenIddictConstants.Parameters.VerificationUri); + set => SetParameter(OpenIddictConstants.Parameters.VerificationUri, value); + } + + /// + /// Gets or sets the "verification_uri_complete" parameter. + /// + public string? VerificationUriComplete + { + get => (string?) GetParameter(OpenIddictConstants.Parameters.VerificationUriComplete); + set => SetParameter(OpenIddictConstants.Parameters.VerificationUriComplete, value); + } } diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs index e171588c..3265182e 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs @@ -5,6 +5,7 @@ */ using System.ComponentModel; +using System.Diagnostics; using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; @@ -147,6 +148,8 @@ public sealed class OpenIddictClientAspNetCoreHandler : AuthenticationHandler + /// Contains the logic responsible for rejecting authentication demands that specify an unsupported type. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public sealed class ValidateAuthenticationType : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateAuthenticationNonce.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.GrantType is GrantTypes.DeviceCode) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0402)); + } + + return default; + } + } + /// /// Contains the logic responsible for rejecting authentication demands that specify an explicit nonce property. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. @@ -452,6 +488,40 @@ public static partial class OpenIddictClientAspNetCoreHandlers } } + /// + /// Contains the logic responsible for rejecting challenge demands that specify an unsupported type. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public sealed class ValidateChallengeType : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ResolveHostChallengeProperties.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.GrantType is GrantTypes.DeviceCode) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0402)); + } + + return default; + } + } + /// /// Contains the logic responsible for resolving the context-specific properties and parameters stored in the /// ASP.NET Core authentication properties specified by the application that triggered the challenge operation. diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs index 041a0b55..64999c41 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs @@ -5,6 +5,7 @@ */ using System.ComponentModel; +using System.Diagnostics; using System.Security.Claims; using System.Text.Json; using Microsoft.Owin.Security.Infrastructure; @@ -165,6 +166,8 @@ public sealed class OpenIddictClientOwinHandler : AuthenticationHandler + /// Contains the logic responsible for rejecting authentication demands that specify an unsupported type. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public sealed class ValidateAuthenticationType : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateAuthenticationNonce.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.GrantType is GrantTypes.DeviceCode) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0402)); + } + + return default; + } + } + /// /// Contains the logic responsible for rejecting authentication demands that specify an explicit nonce property. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. @@ -462,6 +498,40 @@ public static partial class OpenIddictClientOwinHandlers } } + /// + /// Contains the logic responsible for rejecting challenge demands that specify an unsupported type. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public sealed class ValidateChallengeType : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ResolveHostChallengeProperties.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.GrantType is GrantTypes.DeviceCode) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0402)); + } + + return default; + } + } + /// /// Contains the logic responsible for resolving the context-specific properties and parameters stored in the /// OWIN authentication properties specified by the application that triggered the challenge operation. diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs index 102f7588..bfc84799 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs @@ -1484,6 +1484,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachRedirectUri.Descriptor.Order + 500) .SetType(OpenIddictClientHandlerType.BuiltIn) diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs new file mode 100644 index 00000000..07e7ac5f --- /dev/null +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs @@ -0,0 +1,131 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text; + +namespace OpenIddict.Client.SystemNetHttp; + +public static partial class OpenIddictClientSystemNetHttpHandlers +{ + public static class Device + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * DeviceAuthorization request processing: + */ + CreateHttpClient.Descriptor, + PreparePostHttpRequest.Descriptor, + AttachHttpVersion.Descriptor, + AttachJsonAcceptHeaders.Descriptor, + AttachUserAgentHeader.Descriptor, + AttachFromHeader.Descriptor, + AttachBasicAuthenticationCredentials.Descriptor, + AttachFormParameters.Descriptor, + SendHttpRequest.Descriptor, + DisposeHttpRequest.Descriptor, + + /* + * DeviceAuthorization response processing: + */ + DecompressResponseContent.Descriptor, + ExtractJsonHttpResponse.Descriptor, + ExtractWwwAuthenticateHeader.Descriptor, + ValidateHttpResponse.Descriptor, + DisposeHttpResponse.Descriptor); + + /// + /// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header. + /// + public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachFormParameters.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(PrepareDeviceAuthorizationRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Request is not null, SR.GetResourceString(SR.ID4008)); + + // This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved, + // this may indicate that the request was incorrectly processed by another client stack. + var request = context.Transaction.GetHttpRequestMessage() ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0173)); + + // The OAuth 2.0 specification recommends sending the client credentials using basic authentication. + // However, this authentication method is known to have severe compatibility/interoperability issues: + // + // - While restricted to clients that have been given a secret (i.e confidential clients) by the + // specification, basic authentication is also sometimes required by server implementations for + // public clients that don't have a client secret: in this case, an empty password is used and + // the client identifier is sent alone in the Authorization header (instead of being sent using + // the standard "client_id" parameter present in the request body). + // + // - While the OAuth 2.0 specification requires that the client credentials be formURL-encoded + // before being base64-encoded, many implementations are known to implement a non-standard + // encoding scheme, where neither the client_id nor the client_secret are formURL-encoded. + // + // To guarantee that the OpenIddict implementation can be used with most servers implementions, + // basic authentication is only used when a client secret is present and client_secret_post is + // always preferred when it's explicitly listed as a supported client authentication method. + // If client_secret_post is not listed or if the server returned an empty methods list, + // client_secret_basic is always used, as it MUST be implemented by all OAuth 2.0 servers. + // + // See https://tools.ietf.org/html/rfc8414#section-2 + // and https://tools.ietf.org/html/rfc6749#section-2.3.1 for more information. + if (request.Headers.Authorization is null && + !string.IsNullOrEmpty(context.Request.ClientId) && + !string.IsNullOrEmpty(context.Request.ClientSecret) && + UseBasicAuthentication(context.Configuration)) + { + // Important: the credentials MUST be formURL-encoded before being base64-encoded. + var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(new StringBuilder() + .Append(EscapeDataString(context.Request.ClientId)) + .Append(':') + .Append(EscapeDataString(context.Request.ClientSecret)) + .ToString())); + + // Attach the authorization header containing the client credentials to the HTTP request. + request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Basic, credentials); + + // Remove the client credentials from the request payload to ensure they are not sent twice. + context.Request.ClientId = context.Request.ClientSecret = null; + } + + return default; + + static bool UseBasicAuthentication(OpenIddictConfiguration configuration) + => configuration.DeviceAuthorizationEndpointAuthMethodsSupported switch + { + // If at least one authentication method was explicit added, only use basic authentication + // if it's supported AND if client_secret_post is not supported or enabled by the server. + { Count: > 0 } methods => methods.Contains(ClientAuthenticationMethods.ClientSecretBasic) && + !methods.Contains(ClientAuthenticationMethods.ClientSecretPost), + + // Otherwise, if no authentication method was explicit added, assume only basic is supported. + { Count: _ } => true + }; + + static string EscapeDataString(string value) => Uri.EscapeDataString(value).Replace("%20", "+"); + } + } + } +} diff --git a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs index a8c568b8..c25d7cff 100644 --- a/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs +++ b/src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs @@ -22,6 +22,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers { public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create() + .AddRange(Device.DefaultHandlers) .AddRange(Discovery.DefaultHandlers) .AddRange(Exchange.DefaultHandlers) .AddRange(Userinfo.DefaultHandlers); diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Device.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Device.cs new file mode 100644 index 00000000..991bf668 --- /dev/null +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Device.cs @@ -0,0 +1,63 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; + +namespace OpenIddict.Client.WebIntegration; + +public static partial class OpenIddictClientWebIntegrationHandlers +{ + public static class Device + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Token response extraction: + */ + MapNonStandardResponseParameters.Descriptor); + + /// + /// Contains the logic responsible for mapping non-standard response parameters + /// to their standard equivalent for the providers that require it. + /// + public sealed class MapNonStandardResponseParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MaxValue - 50_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ExtractDeviceAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Response is null) + { + return default; + } + + // Note: Google doesn't return a standard "verification_uri" parameter + // but returns a custom "verification_url" that serves the same purpose. + if (context.Registration.ProviderName is Providers.Google) + { + context.Response[Parameters.VerificationUri] = context.Response["verification_url"]; + context.Response["verification_url"] = null; + } + + return default; + } + } + } +} diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs index f3980929..df7e5549 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs @@ -22,6 +22,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers AmendGrantTypes.Descriptor, AmendCodeChallengeMethods.Descriptor, AmendScopes.Descriptor, + AmendDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor, AmendTokenEndpointClientAuthenticationMethods.Descriptor, AmendEndpoints.Descriptor); @@ -193,6 +194,46 @@ public static partial class OpenIddictClientWebIntegrationHandlers } } + /// + /// Contains the logic responsible for amending the client authentication methods + /// supported by the device authorization endpoint for the providers that require it. + /// + public sealed class AmendDeviceAuthorizationEndpointClientAuthenticationMethods : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractTokenEndpointClientAuthenticationMethods.Descriptor.Order + 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Google doesn't properly implement the device authorization grant, doesn't support + // client authentication method for the device authorization endpoint and returns a + // generic "invalid_request" request when using "client_secret_basic" instead of + // sending the client identifier in the request form. To work around this limitation, + // "client_secret_post" is listed as the only supported client authentication method. + if (context.Registration.ProviderName is Providers.Google) + { + context.Configuration.DeviceAuthorizationEndpointAuthMethodsSupported.Clear(); + context.Configuration.DeviceAuthorizationEndpointAuthMethodsSupported.Add( + ClientAuthenticationMethods.ClientSecretPost); + } + + return default; + } + } + /// /// Contains the logic responsible for amending the client authentication /// methods supported by the token endpoint for the providers that require it. @@ -205,7 +246,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ExtractTokenEndpointClientAuthenticationMethods.Descriptor.Order + 500) + .SetOrder(AmendDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 500) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs index 940ab599..bc484d53 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs @@ -42,6 +42,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers IncludeStateParameterInRedirectUri.Descriptor, AttachAdditionalChallengeParameters.Descriptor) .AddRange(Authentication.DefaultHandlers) + .AddRange(Device.DefaultHandlers) .AddRange(Discovery.DefaultHandlers) .AddRange(Exchange.DefaultHandlers) .AddRange(Protection.DefaultHandlers) diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml index ab8221c1..995e60d9 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml @@ -316,8 +316,12 @@ + UserinfoEndpoint="https://api.github.com/user"> + + + diff --git a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd index 17b4eaea..771bca7c 100644 --- a/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd +++ b/src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd @@ -50,6 +50,27 @@ + + + The device authorization endpoint authentication methods supported by the environment. + + + + + + The device authorization endpoint authentication method name (e.g client_secret_basic). + + + + + + + + + + + + The grant types supported by the environment. @@ -67,6 +88,7 @@ + @@ -162,6 +184,12 @@ + + + The device authorization endpoint offered by the environment. + + + The token endpoint offered by the environment. diff --git a/src/OpenIddict.Client/OpenIddictClientBuilder.cs b/src/OpenIddict.Client/OpenIddictClientBuilder.cs index fefeb1ad..7d72f769 100644 --- a/src/OpenIddict.Client/OpenIddictClientBuilder.cs +++ b/src/OpenIddict.Client/OpenIddictClientBuilder.cs @@ -8,6 +8,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -916,6 +917,15 @@ public sealed class OpenIddictClientBuilder public OpenIddictClientBuilder AllowClientCredentialsFlow() => Configure(options => options.GrantTypes.Add(GrantTypes.ClientCredentials)); + /// + /// Enables device code flow support. For more information about this + /// specific OAuth 2.0 flow, visit https://tools.ietf.org/html/rfc8628. + /// + /// The instance. + [RequiresPreviewFeatures] + public OpenIddictClientBuilder AllowDeviceCodeFlow() + => Configure(options => options.GrantTypes.Add(GrantTypes.DeviceCode)); + /// /// Enables hybrid flow support. For more information /// about this specific OpenID Connect flow, visit diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.Device.cs b/src/OpenIddict.Client/OpenIddictClientEvents.Device.cs new file mode 100644 index 00000000..56dbe2cf --- /dev/null +++ b/src/OpenIddict.Client/OpenIddictClientEvents.Device.cs @@ -0,0 +1,123 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +namespace OpenIddict.Client; + +public static partial class OpenIddictClientEvents +{ + /// + /// Represents an event called for each request to the device authorization endpoint + /// to give the user code a chance to add parameters to the device authorization request. + /// + public sealed class PrepareDeviceAuthorizationRequestContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public PrepareDeviceAuthorizationRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + } + + /// + /// Represents an event called for each request to the device authorization endpoint + /// to send the device authorization request to the remote authorization server. + /// + public sealed class ApplyDeviceAuthorizationRequestContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public ApplyDeviceAuthorizationRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + } + + /// + /// Represents an event called for each device authorization response + /// to extract the response parameters from the server response. + /// + public sealed class ExtractDeviceAuthorizationResponseContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public ExtractDeviceAuthorizationResponseContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the response, or if it wasn't extracted yet. + /// + public OpenIddictResponse? Response + { + get => Transaction.Response; + set => Transaction.Response = value; + } + } + + /// + /// Represents an event called for each device authorization response. + /// + public sealed class HandleDeviceAuthorizationResponseContext : BaseExternalContext + { + /// + /// Creates a new instance of the class. + /// + public HandleDeviceAuthorizationResponseContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the response. + /// + public OpenIddictResponse Response + { + get => Transaction.Response!; + set => Transaction.Response = value; + } + } +} diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.Exchange.cs b/src/OpenIddict.Client/OpenIddictClientEvents.Exchange.cs index 6cc93cb4..88e2034f 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.Exchange.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.Exchange.cs @@ -143,21 +143,34 @@ public static partial class OpenIddictClientEvents /// /// Gets or sets the access token resolved from the token response. /// - public string? AccessToken { get; set; } + public string? AccessToken + { + get => Response.AccessToken; + set => Response.AccessToken = value; + } /// /// Gets or sets the identity token resolved from the token response. /// - public string? IdentityToken { get; set; } + public string? IdentityToken + { + get => Response.IdToken; + set => Response.IdToken = value; + } /// /// Gets or sets the refresh token resolved from the token response. /// - public string? RefreshToken { get; set; } + public string? RefreshToken + { + get => Response.RefreshToken; + set => Response.RefreshToken = value; + } /// /// Gets or sets the principal containing the claims resolved from the token response. /// + [Obsolete] public ClaimsPrincipal? Principal { get; set; } } } diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.cs b/src/OpenIddict.Client/OpenIddictClientEvents.cs index 9781984b..a5fd9355 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.cs @@ -676,6 +676,11 @@ public static partial class OpenIddictClientEvents /// public string? BackchannelIdentityToken { get; set; } + /// + /// Gets or sets the device code to validate, if applicable. + /// + public string? DeviceCode { get; set; } + /// /// Gets or sets the frontchannel access token to validate, if applicable. /// @@ -975,6 +980,11 @@ public static partial class OpenIddictClientEvents /// public HashSet Scopes { get; } = new(StringComparer.Ordinal); + /// + /// Gets or sets the URI of the device authorization endpoint, if applicable. + /// + public Uri? DeviceAuthorizationEndpoint { get; set; } + /// /// Gets or sets a boolean indicating whether a state token /// should be generated (and optionally included in the request). @@ -1000,11 +1010,146 @@ public static partial class OpenIddictClientEvents /// public string? StateToken { get; set; } + /// + /// Gets or sets a boolean indicating whether a device authorization request should be sent. + /// + public bool SendDeviceAuthorizationRequest { get; set; } + + /// + /// Gets or sets a boolean indicating whether a client assertion + /// token should be generated (and optionally included in the request). + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool GenerateClientAssertionToken { get; set; } + + /// + /// Gets or sets a boolean indicating whether the generated client + /// assertion token should be included as part of the request. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool IncludeClientAssertionToken { get; set; } + + /// + /// Gets or sets the generated client assertion token, if applicable. + /// The client assertion token will only be returned if + /// is set to . + /// + public string? ClientAssertionToken { get; set; } + + /// + /// Gets or sets type of the generated client assertion token, if applicable. + /// The client assertion token type will only be returned if + /// is set to . + /// + public string? ClientAssertionTokenType { get; set; } + + /// + /// Gets or sets the principal containing the claims that will be + /// used to create the client assertion token, if applicable. + /// + public ClaimsPrincipal? ClientAssertionTokenPrincipal { get; set; } + /// /// Gets or sets the principal containing the claims that /// will be used to create the state token, if applicable. /// public ClaimsPrincipal? StateTokenPrincipal { get; set; } + + /// + /// Gets or sets the request sent to the device authorization endpoint, if applicable. + /// + public OpenIddictRequest? DeviceAuthorizationRequest { get; set; } + + /// + /// Gets or sets the response returned by the device authorization endpoint, if applicable. + /// + public OpenIddictResponse? DeviceAuthorizationResponse { get; set; } + + /// + /// Gets or sets a boolean indicating whether a device + /// code should be extracted from the current context. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool ExtractDeviceCode { get; set; } + + /// + /// Gets or sets a boolean indicating whether a user + /// code should be extracted from the current context. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool ExtractUserCode { get; set; } + + /// + /// Gets or sets a boolean indicating whether a device code must + /// be resolved for the authentication to be considered valid. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool RequireDeviceCode { get; set; } + + /// + /// Gets or sets a boolean indicating whether a user code must + /// be resolved for the authentication to be considered valid. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool RequireUserCode { get; set; } + + /// + /// Gets or sets a boolean indicating whether the device code + /// extracted from the current context should be validated. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool ValidateDeviceCode { get; set; } + + /// + /// Gets or sets a boolean indicating whether the user code + /// extracted from the current context should be validated. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool ValidateUserCode { get; set; } + + /// + /// Gets or sets a boolean indicating whether an invalid device code will + /// cause the authentication demand to be rejected or will be ignored. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool RejectDeviceCode { get; set; } + + /// + /// Gets or sets a boolean indicating whether an invalid user code will + /// cause the authentication demand to be rejected or will be ignored. + /// + /// + /// Note: overriding the value of this property is generally not recommended. + /// + public bool RejectUserCode { get; set; } + + /// + /// Gets or sets the device code to validate, if applicable. + /// + public string? DeviceCode { get; set; } + + /// + /// Gets or sets the user code to validate, if applicable. + /// + public string? UserCode { get; set; } } /// diff --git a/src/OpenIddict.Client/OpenIddictClientExtensions.cs b/src/OpenIddict.Client/OpenIddictClientExtensions.cs index dcd87f55..0b0ce0b0 100644 --- a/src/OpenIddict.Client/OpenIddictClientExtensions.cs +++ b/src/OpenIddict.Client/OpenIddictClientExtensions.cs @@ -41,7 +41,10 @@ public static class OpenIddictClientExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + 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 f4cc0d8c..b7d738d8 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs @@ -96,6 +96,23 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if no challenge client assertion token is generated. + /// + public sealed class RequireChallengeClientAssertionTokenGenerated : IOpenIddictClientHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.GenerateClientAssertionToken); + } + } + /// /// Represents a filter that excludes the associated handlers if no client assertion token is generated. /// @@ -113,6 +130,41 @@ public static class OpenIddictClientHandlerFilters } } + /// + /// Represents a filter that excludes the associated handlers if the challenge + /// doesn't correspond to a device authorization code grant operation. + /// + public sealed class RequireDeviceAuthorizationGrantType : IOpenIddictClientHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.GrantType is GrantTypes.DeviceCode); + } + } + + /// + /// Represents a filter that excludes the associated handlers if no device authorization request is expected to be sent. + /// + public sealed class RequireDeviceAuthorizationRequest : IOpenIddictClientHandlerFilter + { + /// + public ValueTask IsActiveAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.SendDeviceAuthorizationRequest); + } + } + /// /// Represents a filter that excludes the associated handlers if no frontchannel access token is validated. /// diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Device.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Device.cs new file mode 100644 index 00000000..6d606a13 --- /dev/null +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Device.cs @@ -0,0 +1,246 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace OpenIddict.Client; + +public static partial class OpenIddictClientHandlers +{ + public static class Device + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Device authorization response handling: + */ + ValidateWellKnownParameters.Descriptor, + HandleErrorResponse.Descriptor, + ValidateVerificationEndpointUri.Descriptor); + + /// + /// Contains the logic responsible for validating the well-known parameters contained in the device authorization response. + /// + public sealed class ValidateWellKnownParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleDeviceAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + foreach (var parameter in context.Response.GetParameters()) + { + if (!ValidateParameterType(parameter.Key, parameter.Value)) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2107(parameter.Key), + uri: SR.FormatID8000(SR.ID2107)); + + return default; + } + } + + return default; + + // Note: in the typical case, the response parameters should be deserialized from a + // JSON response and thus natively stored as System.Text.Json.JsonElement instances. + // + // In the rare cases where the underlying value wouldn't be a JsonElement instance + // (e.g when custom parameters are manually added to the response), the static + // conversion operator would take care of converting the underlying value to a + // JsonElement instance using the same value type as the original parameter value. + static bool ValidateParameterType(string name, OpenIddictParameter value) => name switch + { + // Error parameters MUST be formatted as unique strings: + Parameters.Error or Parameters.ErrorDescription or Parameters.ErrorUri + => ((JsonElement) value).ValueKind is JsonValueKind.String, + + // The following parameters MUST be formatted as unique strings: + Parameters.DeviceCode or Parameters.UserCode or + Parameters.VerificationUri or Parameters.VerificationUriComplete + => ((JsonElement) value).ValueKind is JsonValueKind.String, + + // The following parameters MUST be formatted as numeric dates: + Parameters.ExpiresIn => (JsonElement) value is { ValueKind: JsonValueKind.Number } element && + element.TryGetDecimal(out decimal result) && result is >= 0, + + // The following parameters MUST be formatted as positive integers: + Parameters.Interval => (JsonElement) value is { ValueKind: JsonValueKind.Number } element && + element.TryGetDecimal(out decimal result) && result is >= 0, + + // Parameters that are not in the well-known list can be of any type. + _ => true + }; + } + } + + /// + /// Contains the logic responsible for surfacing potential errors from the device authorization response. + /// + public sealed class HandleErrorResponse : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateWellKnownParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleDeviceAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // For more information, see https://www.rfc-editor.org/rfc/rfc8628#section-3.2. + if (!string.IsNullOrEmpty(context.Response.Error)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6216), context.Response); + + context.Reject( + error: context.Response.Error switch + { + Errors.InvalidClient => Errors.InvalidRequest, + Errors.InvalidScope => Errors.InvalidScope, + Errors.InvalidRequest => Errors.InvalidRequest, + Errors.UnauthorizedClient => Errors.UnauthorizedClient, + _ => Errors.ServerError + }, + description: SR.GetResourceString(SR.ID2167), + uri: SR.FormatID8000(SR.ID2167)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for validating the verification + /// endpoint URI contained in the device authorization response. + /// + public sealed class ValidateVerificationEndpointUri : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(HandleErrorResponse.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleDeviceAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Return an error if the mandatory "verification_uri" parameter is missing. + // For more information, see https://www.rfc-editor.org/rfc/rfc8628#section-3.2. + if (string.IsNullOrEmpty(context.Response.VerificationUri)) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2168(Parameters.VerificationUri), + uri: SR.FormatID8000(SR.ID2168)); + + return default; + } + + // Return an error if the "verification_uri" parameter is malformed. + if (!Uri.IsWellFormedUriString(context.Response.VerificationUri, UriKind.Absolute)) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2169(Parameters.VerificationUri), + uri: SR.FormatID8000(SR.ID2169)); + + return default; + } + + // Note: the "verification_uri_complete" parameter is optional and MUST not + // cause an error if it's missing from the device authorization response. + if (!string.IsNullOrEmpty(context.Response.VerificationUriComplete) && + !Uri.IsWellFormedUriString(context.Response.VerificationUriComplete, UriKind.Absolute)) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2169(Parameters.VerificationUriComplete), + uri: SR.FormatID8000(SR.ID2169)); + + return default; + } + + return default; + } + } + + /// + /// Contains the logic responsible for validating the "expires_in" + /// parameter contained in the device authorization response. + /// + public sealed class ValidateExpiration : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateVerificationEndpointUri.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleDeviceAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Return an error if the mandatory "expires_in" parameter is missing. + // For more information, see https://www.rfc-editor.org/rfc/rfc8628#section-3.2. + if (context.Response.ExpiresIn is null) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2168(Parameters.ExpiresIn), + uri: SR.FormatID8000(SR.ID2168)); + + return default; + } + + return default; + } + } + } +} diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs index 6987c314..b935b034 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs @@ -24,6 +24,7 @@ public static partial class OpenIddictClientHandlers ValidateIssuer.Descriptor, ExtractAuthorizationEndpoint.Descriptor, ExtractCryptographyEndpoint.Descriptor, + ExtractDeviceAuthorizationEndpoint.Descriptor, ExtractLogoutEndpoint.Descriptor, ExtractTokenEndpoint.Descriptor, ExtractUserinfoEndpoint.Descriptor, @@ -33,6 +34,7 @@ public static partial class OpenIddictClientHandlers ExtractCodeChallengeMethods.Descriptor, ExtractScopes.Descriptor, ExtractIssuerParameterRequirement.Descriptor, + ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor, ExtractTokenEndpointClientAuthenticationMethods.Descriptor, /* @@ -94,20 +96,22 @@ public static partial class OpenIddictClientHandlers => ((JsonElement) value).ValueKind is JsonValueKind.String, // The following parameters MUST be formatted as unique strings: - Metadata.AuthorizationEndpoint or - Metadata.EndSessionEndpoint or - Metadata.Issuer or - Metadata.JwksUri or - Metadata.TokenEndpoint or + Metadata.AuthorizationEndpoint or + Metadata.DeviceAuthorizationEndpoint or + Metadata.EndSessionEndpoint or + Metadata.Issuer or + Metadata.JwksUri or + Metadata.TokenEndpoint or Metadata.UserinfoEndpoint => ((JsonElement) value).ValueKind is JsonValueKind.String, // The following parameters MUST be formatted as arrays of strings: - Metadata.CodeChallengeMethodsSupported or - Metadata.GrantTypesSupported or - Metadata.ResponseModesSupported or - Metadata.ResponseTypesSupported or - Metadata.ScopesSupported or + Metadata.CodeChallengeMethodsSupported or + Metadata.DeviceAuthorizationEndpointAuthMethodsSupported or + Metadata.GrantTypesSupported or + Metadata.ResponseModesSupported or + Metadata.ResponseTypesSupported or + Metadata.ScopesSupported or Metadata.TokenEndpointAuthMethodsSupported => ((JsonElement) value) is JsonElement element && element.ValueKind is JsonValueKind.Array && ValidateStringArray(element), @@ -346,6 +350,49 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for extracting the device authorization endpoint URI from the discovery document. + /// + public sealed class ExtractDeviceAuthorizationEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractCryptographyEndpoint.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var endpoint = (string?) context.Response[Metadata.DeviceAuthorizationEndpoint]; + if (!string.IsNullOrEmpty(endpoint)) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri) || !uri.IsWellFormedOriginalString()) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2100(Metadata.DeviceAuthorizationEndpoint), + uri: SR.FormatID8000(SR.ID2100)); + + return default; + } + + context.Configuration.DeviceAuthorizationEndpoint = uri; + } + + return default; + } + } + /// /// Contains the logic responsible for extracting the logout endpoint URI from the discovery document. /// @@ -357,7 +404,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ExtractCryptographyEndpoint.Descriptor.Order + 1_000) + .SetOrder(ExtractDeviceAuthorizationEndpoint.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -716,6 +763,52 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for extracting the authentication methods + /// supported by the device authorization endpoint from the discovery document. + /// + public sealed class ExtractDeviceAuthorizationEndpointClientAuthenticationMethods : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractIssuerParameterRequirement.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Resolve the client authentication methods supported by the device authorization endpoint, if available. + // + // Note: "device_authorization_endpoint_auth_methods_supported" is not a standard parameter + // but is supported by OpenIddict 4.3.0 and higher for consistency with the other endpoints. + var methods = context.Response[Metadata.DeviceAuthorizationEndpointAuthMethodsSupported]?.GetUnnamedParameters(); + if (methods is { Count: > 0 }) + { + for (var index = 0; index < methods.Count; index++) + { + // Note: custom values are allowed in this case. + var method = (string?) methods[index]; + if (!string.IsNullOrEmpty(method)) + { + context.Configuration.DeviceAuthorizationEndpointAuthMethodsSupported.Add(method); + } + } + } + + return default; + } + } + /// /// Contains the logic responsible for extracting the authentication methods /// supported by the token endpoint from the discovery document. @@ -728,7 +821,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ExtractIssuerParameterRequirement.Descriptor.Order + 1_000) + .SetOrder(ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Exchange.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Exchange.cs index 2ab603de..4a9a7b3c 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Exchange.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Exchange.cs @@ -77,7 +77,8 @@ public static partial class OpenIddictClientHandlers => ((JsonElement) value).ValueKind is JsonValueKind.String, // The following parameters MUST be formatted as numeric dates: - Parameters.ExpiresIn => ((JsonElement) value).ValueKind is JsonValueKind.Number, + Parameters.ExpiresIn => (JsonElement) value is { ValueKind: JsonValueKind.Number } element && + element.TryGetDecimal(out decimal result) && result is >= 0, // Parameters that are not in the well-known list can be of any type. _ => true @@ -116,10 +117,14 @@ public static partial class OpenIddictClientHandlers context.Reject( error: context.Response.Error switch { - Errors.InvalidClient => Errors.InvalidRequest, + Errors.AccessDenied => Errors.AccessDenied, + Errors.AuthorizationPending => Errors.AuthorizationPending, + Errors.ExpiredToken => Errors.ExpiredToken, + Errors.InvalidClient => Errors.InvalidClient, Errors.InvalidGrant => Errors.InvalidGrant, Errors.InvalidScope => Errors.InvalidScope, Errors.InvalidRequest => Errors.InvalidRequest, + Errors.SlowDown => Errors.SlowDown, Errors.UnauthorizedClient => Errors.UnauthorizedClient, Errors.UnsupportedGrantType => Errors.UnsupportedGrantType, _ => Errors.ServerError diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index e542f60a..2c8af8aa 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -112,6 +112,18 @@ public static partial class OpenIddictClientHandlers GenerateLoginStateToken.Descriptor, AttachChallengeParameters.Descriptor, AttachCustomChallengeParameters.Descriptor, + ResolveDeviceAuthorizationEndpoint.Descriptor, + EvaluateDeviceAuthorizationRequest.Descriptor, + AttachDeviceAuthorizationRequestParameters.Descriptor, + EvaluateGeneratedChallengeClientAssertionToken.Descriptor, + PrepareChallengeClientAssertionTokenPrincipal.Descriptor, + GenerateChallengeClientAssertionToken.Descriptor, + AttachDeviceAuthorizationRequestClientCredentials.Descriptor, + SendDeviceAuthorizationRequest.Descriptor, + + EvaluateValidatedDeviceAuthorizationTokens.Descriptor, + ResolveValidatedDeviceAuthorizationTokens.Descriptor, + ValidateRequiredDeviceAuthorizationTokens.Descriptor, /* * Sign-out processing: @@ -135,6 +147,7 @@ public static partial class OpenIddictClientHandlers AttachErrorParameters.Descriptor) .AddRange(Authentication.DefaultHandlers) + .AddRange(Device.DefaultHandlers) .AddRange(Discovery.DefaultHandlers) .AddRange(Exchange.DefaultHandlers) .AddRange(Protection.DefaultHandlers) @@ -275,7 +288,8 @@ public static partial class OpenIddictClientHandlers if (context.GrantType is not ( GrantTypes.AuthorizationCode or GrantTypes.ClientCredentials or - GrantTypes.Implicit or GrantTypes.Password or GrantTypes.RefreshToken)) + GrantTypes.DeviceCode or GrantTypes.Implicit or + GrantTypes.Password or GrantTypes.RefreshToken)) { throw new InvalidOperationException(SR.FormatID0310(context.GrantType)); } @@ -285,6 +299,12 @@ public static partial class OpenIddictClientHandlers throw new InvalidOperationException(SR.FormatID0359(context.GrantType)); } + if (context.GrantType is GrantTypes.DeviceCode && + string.IsNullOrEmpty(context.DeviceCode)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0396)); + } + if (context.GrantType is GrantTypes.Password) { if (string.IsNullOrEmpty(context.Username)) @@ -329,6 +349,11 @@ public static partial class OpenIddictClientHandlers /// public sealed class ResolveClientRegistrationFromAuthenticationContext : IOpenIddictClientHandler { + private readonly OpenIddictClientService _service; + + public ResolveClientRegistrationFromAuthenticationContext(OpenIddictClientService service) + => _service = service ?? throw new ArgumentNullException(nameof(service)); + /// /// Gets the default descriptor definition assigned to this handler. /// @@ -359,9 +384,7 @@ 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( - registration => registration.Issuer == context.Issuer) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); + context.Registration ??= await _service.GetClientRegistrationAsync(context.Issuer, context.CancellationToken); // Resolve and attach the server configuration to the context if none has been set already. context.Configuration ??= await context.Registration.ConfigurationManager @@ -901,6 +924,11 @@ public static partial class OpenIddictClientHandlers /// public sealed class ResolveClientRegistrationFromStateToken : IOpenIddictClientHandler { + private readonly OpenIddictClientService _service; + + public ResolveClientRegistrationFromStateToken(OpenIddictClientService service) + => _service = service ?? throw new ArgumentNullException(nameof(service)); + /// /// Gets the default descriptor definition assigned to this handler. /// @@ -943,8 +971,7 @@ public static partial class OpenIddictClientHandlers // 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.Issuer = issuer; - context.Registration = context.Options.Registrations.Find(registration => registration.Issuer == issuer) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); + context.Registration = await _service.GetClientRegistrationAsync(issuer, context.CancellationToken); // If an explicit provider name was also added, ensure the two values point to the same issuer. var provider = context.StateTokenPrincipal.GetClaim(Claims.Private.ProviderName); @@ -2110,9 +2137,10 @@ public static partial class OpenIddictClientHandlers types.Contains(ResponseTypes.Code) => true, - // For client credentials, resource owner password credentials - // and refresh token requests, always send a token request. - GrantTypes.ClientCredentials or GrantTypes.Password or GrantTypes.RefreshToken => true, + // 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, _ => false }; @@ -2183,6 +2211,14 @@ public static partial class OpenIddictClientHandlers context.TokenRequest.RedirectUri = context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri); } + // If the token request uses a device code grant, attach the device code to the request. + else if (context.TokenRequest.GrantType is GrantTypes.DeviceCode) + { + Debug.Assert(!string.IsNullOrEmpty(context.DeviceCode), SR.GetResourceString(SR.ID4010)); + + context.TokenRequest.DeviceCode = context.DeviceCode; + } + // If the token request uses a resource owner password credentials grant, attach the credentials to the request. else if (context.TokenRequest.GrantType is GrantTypes.Password) { @@ -2502,7 +2538,6 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() .UseSingletonHandler() .SetOrder(SendTokenRequest.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -2520,93 +2555,99 @@ public static partial class OpenIddictClientHandlers context.RequireBackchannelAccessToken, context.ValidateBackchannelAccessToken, context.RejectBackchannelAccessToken) = 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. - // As such, a backchannel access token is always considered required if a code was received. - // - // 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 when - context.ResponseType?.Split(Separators.Space) is IList types && - types.Contains(ResponseTypes.Code) - => (true, true, false, false), - - // An access token is always returned as part of client credentials, - // resource owner password credentials and refresh token responses. - GrantTypes.ClientCredentials or GrantTypes.Password or GrantTypes.RefreshToken + { + // 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. + // As such, a backchannel access token is always considered required if a code was received. + // + // 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 when + context.SendTokenRequest && + context.ResponseType?.Split(Separators.Space) is IList types && + types.Contains(ResponseTypes.Code) => (true, true, false, false), - _ => (false, false, false, false) - }; + // 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 + => (true, true, false, false), + + _ => (false, false, false, false) + }; (context.ExtractBackchannelIdentityToken, context.RequireBackchannelIdentityToken, context.ValidateBackchannelIdentityToken, context.RejectBackchannelIdentityToken) = 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 when - context.ResponseType?.Split(Separators.Space) is IList types && - types.Contains(ResponseTypes.Code) && - context.StateTokenPrincipal is ClaimsPrincipal principal && - principal.HasScope(Scopes.OpenId) => (true, true, true, true), - - // The client credentials and resource owner password credentials grants don't have - // an equivalent in OpenID Connect so an identity token is typically never returned - // when using them. However, certain server implementations (like OpenIddict) - // allow returning it as a non-standard artifact. 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.ClientCredentials or GrantTypes.Password => (true, false, true, false), - - // 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, false, false) - }; + { + // 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 when + context.SendTokenRequest && + context.ResponseType?.Split(Separators.Space) is IList types && + types.Contains(ResponseTypes.Code) && + context.StateTokenPrincipal is ClaimsPrincipal principal && + principal.HasScope(Scopes.OpenId) => (true, true, true, true), + + // The client credentials, device code and resource owner password credentials grants + // don't have an equivalent in OpenID Connect so an identity token is typically never + // returned when using them. However, certain server implementations (like OpenIddict) + // allow returning it as a non-standard artifact. 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.ClientCredentials or GrantTypes.DeviceCode or GrantTypes.Password + => (true, false, true, false), + + // 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, false, false) + }; (context.ExtractRefreshToken, context.RequireRefreshToken, context.ValidateRefreshToken, context.RejectRefreshToken) = 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 - context.ResponseType?.Split(Separators.Space) is IList types && - types.Contains(ResponseTypes.Code) - => (true, false, false, false), - - // A refresh token may or may not be returned as part of client credentials, - // resource owner password credentials and refresh token responses 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.Password or GrantTypes.RefreshToken - => (true, false, false, false), - - _ => (false, false, false, false) - }; + { + // 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 + context.SendTokenRequest && + context.ResponseType?.Split(Separators.Space) is IList types && + types.Contains(ResponseTypes.Code) + => (true, false, false, false), + + // A refresh token may or may not be returned as part of client credentials, + // device code, resource owner password credentials and refresh token responses + // 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 + => (true, false, false, false), + + _ => (false, false, false, false) + }; return default; } } /// - /// Contains the logic responsible for resolving the backchannel tokens by sending a token request, if applicable. + /// Contains the logic responsible for resolving the backchannel tokens from the token response, if applicable. /// public sealed class ResolveValidatedBackchannelTokens : IOpenIddictClientHandler { @@ -2630,23 +2671,9 @@ public static partial class OpenIddictClientHandlers Debug.Assert(context.TokenResponse is not null, SR.GetResourceString(SR.ID4007)); - context.BackchannelAccessToken = context.ExtractBackchannelAccessToken switch - { - true => context.TokenResponse.AccessToken, - false => null - }; - - context.BackchannelIdentityToken = context.ExtractBackchannelIdentityToken switch - { - true => context.TokenResponse.IdToken, - false => null - }; - - context.RefreshToken = context.ExtractRefreshToken switch - { - true => context.TokenResponse.RefreshToken, - false => null - }; + context.BackchannelAccessToken = context.ExtractBackchannelAccessToken ? context.TokenResponse.AccessToken : null; + context.BackchannelIdentityToken = context.ExtractBackchannelIdentityToken ? context.TokenResponse.IdToken : null; + context.RefreshToken = context.ExtractRefreshToken ? context.TokenResponse.RefreshToken : null; return default; } @@ -3396,7 +3423,7 @@ public static partial class OpenIddictClientHandlers // // Note: the userinfo endpoint is an optional endpoint and may not be supported. GrantTypes.AuthorizationCode or GrantTypes.Implicit or - GrantTypes.Password or GrantTypes.RefreshToken + GrantTypes.DeviceCode or GrantTypes.Password or GrantTypes.RefreshToken when context.UserinfoEndpoint is not null && (!string.IsNullOrEmpty(context.BackchannelAccessToken) || !string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true, @@ -3518,7 +3545,6 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() .UseSingletonHandler() .SetOrder(SendUserinfoRequest.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -3532,12 +3558,19 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - // By default, OpenIddict doesn't require that userinfo be used but userinfo tokens - // or responses will be extracted and validated when a userinfo request was sent. + // 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) = (true, false, true, true); + context.RejectUserinfoToken) = context.GrantType switch + { + 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) + }; return default; } @@ -3820,7 +3853,8 @@ public static partial class OpenIddictClientHandlers // supported by OpenIddict and enabled in the client options. if (!string.IsNullOrEmpty(context.GrantType)) { - if (context.GrantType is not (GrantTypes.AuthorizationCode or GrantTypes.Implicit)) + if (context.GrantType is not ( + GrantTypes.AuthorizationCode or GrantTypes.DeviceCode or GrantTypes.Implicit)) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0296)); } @@ -3832,14 +3866,17 @@ public static partial class OpenIddictClientHandlers } // Ensure signing/and encryption credentials are present as they are required to protect state tokens. - if (context.Options.EncryptionCredentials.Count is 0) + if (context.GrantType is not GrantTypes.DeviceCode) { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0357)); - } + if (context.Options.EncryptionCredentials.Count is 0) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0357)); + } - if (context.Options.SigningCredentials.Count is 0) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0358)); + if (context.Options.SigningCredentials.Count is 0) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0358)); + } } // If a provider name was specified, resolve the corresponding issuer. @@ -3877,6 +3914,11 @@ public static partial class OpenIddictClientHandlers /// public sealed class ResolveClientRegistrationFromChallengeContext : IOpenIddictClientHandler { + private readonly OpenIddictClientService _service; + + public ResolveClientRegistrationFromChallengeContext(OpenIddictClientService service) + => _service = service ?? throw new ArgumentNullException(nameof(service)); + /// /// Gets the default descriptor definition assigned to this handler. /// @@ -3895,12 +3937,12 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } + Debug.Assert(context.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + // 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)); + context.Registration ??= await _service.GetClientRegistrationAsync(context.Issuer, context.CancellationToken); // Resolve and attach the server configuration to the context if none has been set already. context.Configuration ??= await context.Registration.ConfigurationManager @@ -4317,7 +4359,6 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() .UseSingletonHandler() .SetOrder(AttachResponseMode.Descriptor.Order + 1_000) .Build(); @@ -4383,7 +4424,6 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() .UseSingletonHandler() .SetOrder(AttachRedirectUri.Descriptor.Order + 1_000) .Build(); @@ -4403,11 +4443,13 @@ public static partial class OpenIddictClientHandlers } // If the server configuration indicates the identity provider supports OpenID Connect, - // always request the "openid" scope to identify the request as an OpenID Connect request. + // always request the "openid" scope to identify the request as an OpenID Connect request + // if the selected grant type is known to be natively supported by OpenID Connect. // // Developers who prefer sending OAuth 2.0/2.1 requests to an OpenID Connect server can // implement a custom event handler that manually replaces the set of requested scopes. - if (context.Configuration.ScopesSupported.Contains(Scopes.OpenId)) + if (context.GrantType is GrantTypes.AuthorizationCode or GrantTypes.Implicit && + context.Configuration.ScopesSupported.Contains(Scopes.OpenId)) { context.Scopes.Add(Scopes.OpenId); } @@ -4783,6 +4825,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() .SetOrder(GenerateLoginStateToken.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -4878,6 +4921,512 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for resolving the URI of the device authorization endpoint. + /// + public sealed class ResolveDeviceAuthorizationEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachCustomChallengeParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If the URI of the device authorization endpoint wasn't explicitly set + // at this stage, try to extract it from the server configuration. + context.DeviceAuthorizationEndpoint ??= context.Configuration.DeviceAuthorizationEndpoint switch + { + { IsAbsoluteUri: true } uri when uri.IsWellFormedOriginalString() => uri, + + _ => null + }; + + return default; + } + } + + /// + /// Contains the logic responsible for determining whether a device authorization request should be sent. + /// + public sealed class EvaluateDeviceAuthorizationRequest : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ResolveDeviceAuthorizationEndpoint.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.SendDeviceAuthorizationRequest = context.GrantType is GrantTypes.DeviceCode; + + return default; + } + } + + /// + /// Contains the logic responsible for attaching the parameters to the device authorization request, if applicable. + /// + public sealed class AttachDeviceAuthorizationRequestParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(EvaluateDeviceAuthorizationRequest.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Attach a new request instance if necessary. + context.DeviceAuthorizationRequest ??= new OpenIddictRequest(); + + 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.DeviceAuthorizationRequest.Scope = string.Join(" ", context.Scopes); + } + + return default; + } + } + + /// + /// Contains the logic responsible for selecting the token types that should + /// be generated and optionally sent as part of the challenge demand. + /// + public sealed class EvaluateGeneratedChallengeClientAssertionToken : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachDeviceAuthorizationRequestParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + (context.GenerateClientAssertionToken, + context.IncludeClientAssertionToken) = context.Registration.SigningCredentials.Count switch + { + // If a device authorization request is going to be sent and if at least one signing key + // was attached to the client registration, generate and include a client assertion + // token if the configuration indicates the server supports private_key_jwt. + > 0 when context.Configuration.DeviceAuthorizationEndpointAuthMethodsSupported.Contains( + ClientAuthenticationMethods.PrivateKeyJwt) => (true, true), + + _ => (false, false) + }; + + return default; + } + } + + /// + /// Contains the logic responsible for preparing and attaching the claims principal + /// used to generate the client assertion token, if one is going to be sent. + /// + public sealed class PrepareChallengeClientAssertionTokenPrincipal : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(EvaluateGeneratedChallengeClientAssertionToken.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + + // Create a new principal that will be used to store the client assertion claims. + var principal = new ClaimsPrincipal(new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType)); + principal.SetCreationDate(DateTimeOffset.UtcNow); + + var lifetime = context.Options.ClientAssertionTokenLifetime; + if (lifetime.HasValue) + { + principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value); + } + + // Use the issuer URI as the audience. Applications that need to + // use a different value can register a custom event handler. + principal.SetAudiences(context.Issuer.OriginalString); + + // Use the client_id as both the subject and the issuer, as required by the specifications. + principal.SetClaim(Claims.Private.Issuer, context.ClientId) + .SetClaim(Claims.Subject, context.ClientId); + + // Use a random GUID as the JWT unique identifier. + principal.SetClaim(Claims.JwtId, Guid.NewGuid().ToString()); + + context.ClientAssertionTokenPrincipal = principal; + + return default; + } + } + + /// + /// Contains the logic responsible for generating a client + /// assertion token for the current challenge operation. + /// + public sealed class GenerateChallengeClientAssertionToken : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public GenerateChallengeClientAssertionToken(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(PrepareChallengeClientAssertionTokenPrincipal.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new GenerateTokenContext(context.Transaction) + { + CreateTokenEntry = false, + IsReferenceToken = false, + PersistTokenPayload = false, + Principal = context.ClientAssertionTokenPrincipal!, + TokenFormat = TokenFormats.Jwt, + TokenType = TokenTypeHints.ClientAssertionToken + }; + + await _dispatcher.DispatchAsync(notification); + + if (notification.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (notification.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + + else if (notification.IsRejected) + { + context.Reject( + error: notification.Error ?? Errors.InvalidRequest, + description: notification.ErrorDescription, + uri: notification.ErrorUri); + return; + } + + context.ClientAssertionToken = notification.Token; + context.ClientAssertionTokenType = notification.TokenFormat switch + { + TokenFormats.Jwt => ClientAssertionTypes.JwtBearer, + TokenFormats.Saml2 => ClientAssertionTypes.Saml2Bearer, + + _ => null + }; + } + } + + /// + /// Contains the logic responsible for attaching the client credentials to the device authorization request, if applicable. + /// + public sealed class AttachDeviceAuthorizationRequestClientCredentials : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(GenerateChallengeClientAssertionToken.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.DeviceAuthorizationRequest is not null, SR.GetResourceString(SR.ID4008)); + + // Always attach the client_id to the request, even if an assertion is sent. + context.DeviceAuthorizationRequest.ClientId = context.ClientId; + + // Note: client authentication methods are mutually exclusive so the client_assertion + // and client_secret parameters MUST never be sent at the same time. For more information, + // see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3. + if (context.IncludeClientAssertionToken) + { + context.DeviceAuthorizationRequest.ClientAssertion = context.ClientAssertionToken; + context.DeviceAuthorizationRequest.ClientAssertionType = context.ClientAssertionTokenType; + } + + // Note: the client_secret may be null at this point (e.g for a public + // client or if a custom authentication method is used by the application). + else + { + context.DeviceAuthorizationRequest.ClientSecret = context.Registration.ClientSecret; + } + + return default; + } + } + + /// + /// Contains the logic responsible for sending the device authorization request, if applicable. + /// + public sealed class SendDeviceAuthorizationRequest : IOpenIddictClientHandler + { + private readonly OpenIddictClientService _service; + + public SendDeviceAuthorizationRequest(OpenIddictClientService service) + => _service = service ?? throw new ArgumentNullException(nameof(service)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachDeviceAuthorizationRequestClientCredentials.Descriptor.Order + 1_000) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.DeviceAuthorizationRequest is not null, SR.GetResourceString(SR.ID4008)); + + // Ensure the device authorization endpoint is present and is a valid absolute URI. + if (context.DeviceAuthorizationEndpoint is not { IsAbsoluteUri: true } || + !context.DeviceAuthorizationEndpoint.IsWellFormedOriginalString()) + { + throw new InvalidOperationException(SR.FormatID0301(Metadata.DeviceAuthorizationEndpoint)); + } + + try + { + context.DeviceAuthorizationResponse = await _service.SendDeviceAuthorizationRequestAsync( + context.Registration, context.DeviceAuthorizationRequest, context.DeviceAuthorizationEndpoint); + } + + catch (ProtocolException exception) + { + context.Reject( + error: exception.Error, + description: exception.ErrorDescription, + uri: exception.ErrorUri); + + return; + } + } + } + + /// + /// Contains the logic responsible for determining the set of device authorization tokens to validate. + /// + public sealed class EvaluateValidatedDeviceAuthorizationTokens : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(SendDeviceAuthorizationRequest.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + (context.ExtractDeviceCode, + context.RequireDeviceCode, + context.ValidateDeviceCode, + context.RejectDeviceCode) = context.GrantType switch + { + // A device code is always returned as part of device authorization responses. + // + // Note: since device 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 device codes that use a readable format (e.g JWT). + GrantTypes.DeviceCode => (true, true, false, false), + + _ => (false, false, false, false) + }; + + (context.ExtractUserCode, + context.RequireUserCode, + context.ValidateUserCode, + context.RejectUserCode) = context.GrantType switch + { + // A user code is always returned as part of device authorization responses. + // + // Note: since user 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 user codes that use a readable format (e.g JWT). + GrantTypes.DeviceCode => (true, true, false, false), + + _ => (false, false, false, false) + }; + + return default; + } + } + + /// + /// Contains the logic responsible for resolving the device authorization + /// tokens from the device authorization response, if applicable. + /// + public sealed class ResolveValidatedDeviceAuthorizationTokens : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(EvaluateValidatedDeviceAuthorizationTokens.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.DeviceAuthorizationResponse is not null, SR.GetResourceString(SR.ID4007)); + + context.DeviceCode = context.ExtractDeviceCode ? context.DeviceAuthorizationResponse.DeviceCode : null; + context.UserCode = context.ExtractUserCode ? context.DeviceAuthorizationResponse.UserCode : null; + + return default; + } + } + + /// + /// Contains the logic responsible for rejecting challenge demands that lack required tokens. + /// + public sealed class ValidateRequiredDeviceAuthorizationTokens : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + // Note: this handler is registered with a high gap to allow handlers + // that do token extraction to be executed before this handler runs. + .SetOrder(ResolveValidatedDeviceAuthorizationTokens.Descriptor.Order + 50_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if ((context.RequireDeviceCode && string.IsNullOrEmpty(context.DeviceCode)) || + (context.RequireUserCode && string.IsNullOrEmpty(context.UserCode))) + { + context.Reject( + error: Errors.MissingToken, + description: SR.GetResourceString(SR.ID2000), + uri: SR.FormatID8000(SR.ID2000)); + + return default; + } + + return default; + } + } + /// /// Contains the logic responsible for ensuring that the sign-out demand /// is compatible with the type of the endpoint that handled the request. @@ -4953,6 +5502,11 @@ public static partial class OpenIddictClientHandlers /// public sealed class ResolveClientRegistrationFromSignOutContext : IOpenIddictClientHandler { + private readonly OpenIddictClientService _service; + + public ResolveClientRegistrationFromSignOutContext(OpenIddictClientService service) + => _service = service ?? throw new ArgumentNullException(nameof(service)); + /// /// Gets the default descriptor definition assigned to this handler. /// @@ -4971,12 +5525,12 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } + Debug.Assert(context.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + // 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)); + context.Registration ??= await _service.GetClientRegistrationAsync(context.Issuer, context.CancellationToken); // Resolve and attach the server configuration to the context if none has been set already. context.Configuration ??= await context.Registration.ConfigurationManager diff --git a/src/OpenIddict.Client/OpenIddictClientRegistration.cs b/src/OpenIddict.Client/OpenIddictClientRegistration.cs index c018806a..68ffa056 100644 --- a/src/OpenIddict.Client/OpenIddictClientRegistration.cs +++ b/src/OpenIddict.Client/OpenIddictClientRegistration.cs @@ -148,7 +148,8 @@ public sealed class OpenIddictClientRegistration }; /// - /// Gets the list of scopes sent by default as part of authorization requests. + /// Gets the list of scopes sent by default as part of + /// authorization requests and device authorization requests. /// public HashSet Scopes { get; } = new(StringComparer.Ordinal); diff --git a/src/OpenIddict.Client/OpenIddictClientService.cs b/src/OpenIddict.Client/OpenIddictClientService.cs index 6ae2d4d0..757ec36c 100644 --- a/src/OpenIddict.Client/OpenIddictClientService.cs +++ b/src/OpenIddict.Client/OpenIddictClientService.cs @@ -27,6 +27,115 @@ public sealed class OpenIddictClientService public OpenIddictClientService(IServiceProvider provider) => _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + /// + /// Resolves the client registration associated with the specified . + /// + /// The issuer. + /// The that can be used to abort the operation. + /// The associated with the specified . + /// is . + /// + /// No was registered with the specified . + /// + public ValueTask GetClientRegistrationAsync( + Uri issuer, CancellationToken cancellationToken = default) + { + if (issuer is null) + { + throw new ArgumentNullException(nameof(issuer)); + } + + if (cancellationToken.IsCancellationRequested) + { + return new(Task.FromCanceled(cancellationToken)); + } + + var options = _provider.GetRequiredService>(); + + return new(options.CurrentValue.Registrations.Find(registration => registration.Issuer == issuer) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0292))); + } + + /// + /// Resolves the client registration associated with the specified . + /// + /// The provider name. + /// The that can be used to abort the operation. + /// The associated with the specified . + /// is . + /// + /// No was registered with the specified . + /// + public ValueTask GetClientRegistrationAsync( + string provider, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(provider)) + { + throw new ArgumentException(SR.FormatID0366(nameof(provider)), nameof(provider)); + } + + if (cancellationToken.IsCancellationRequested) + { + return new(Task.FromCanceled(cancellationToken)); + } + + var options = _provider.GetRequiredService>(); + + return new(options.CurrentValue.Registrations.Find(registration => string.Equals( + registration.ProviderName, provider, StringComparison.Ordinal)) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0397))); + } + + /// + /// Resolves the server configuration associated with the specified . + /// + /// The issuer. + /// The that can be used to abort the operation. + /// The associated with the specified . + /// is . + /// + /// No was registered with the specified . + /// + public async ValueTask GetServerConfigurationAsync( + Uri issuer, CancellationToken cancellationToken = default) + { + if (issuer is null) + { + throw new ArgumentNullException(nameof(issuer)); + } + + var registration = await GetClientRegistrationAsync(issuer, cancellationToken); + return await registration.ConfigurationManager + .GetConfigurationAsync(cancellationToken) + .WaitAsync(cancellationToken) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + } + + /// + /// Resolves the server configuration associated with the specified . + /// + /// The provider name. + /// The that can be used to abort the operation. + /// The associated with the specified . + /// is . + /// + /// No was registered with the specified . + /// + public async ValueTask GetServerConfigurationAsync( + string provider, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(provider)) + { + throw new ArgumentException(SR.FormatID0366(nameof(provider)), nameof(provider)); + } + + var registration = await GetClientRegistrationAsync(provider, cancellationToken); + return await registration.ConfigurationManager + .GetConfigurationAsync(cancellationToken) + .WaitAsync(cancellationToken) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + } + /// /// Initiates an interactive user authentication demand. /// @@ -47,12 +156,9 @@ public sealed class OpenIddictClientService throw new ArgumentNullException(nameof(issuer)); } - var options = _provider.GetRequiredService>(); - var registration = options.CurrentValue.Registrations.Find(registration => registration.Issuer == issuer) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); - - return await AuthenticateInteractivelyAsync(nonce: - await ChallengeInteractivelyAsync(registration, scopes, parameters, properties, cancellationToken), cancellationToken); + var registration = await GetClientRegistrationAsync(issuer, cancellationToken); + var nonce = await ChallengeInteractivelyAsync(registration, scopes, parameters, properties, cancellationToken); + return await AuthenticateInteractivelyAsync(nonce, cancellationToken); } /// @@ -75,13 +181,9 @@ public sealed class OpenIddictClientService throw new ArgumentException(SR.FormatID0366(nameof(provider)), nameof(provider)); } - var options = _provider.GetRequiredService>(); - var registration = options.CurrentValue.Registrations.Find(registration => string.Equals( - registration.ProviderName, provider, StringComparison.Ordinal)) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0348)); - - return await AuthenticateInteractivelyAsync(nonce: - await ChallengeInteractivelyAsync(registration, scopes, parameters, properties, cancellationToken), cancellationToken); + var registration = await GetClientRegistrationAsync(provider, cancellationToken); + var nonce = await ChallengeInteractivelyAsync(registration, scopes, parameters, properties, cancellationToken); + return await AuthenticateInteractivelyAsync(nonce, cancellationToken); } /// @@ -127,49 +229,406 @@ public sealed class OpenIddictClientService else { + Debug.Assert(context.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + var principal = OpenIddictHelpers.CreateMergedPrincipal( context.FrontchannelIdentityTokenPrincipal, context.BackchannelIdentityTokenPrincipal, context.UserinfoTokenPrincipal) ?? new ClaimsPrincipal(new ClaimsIdentity()); - // Attach the identity of the authorization to the returned principal to allow resolving it even if no other - // claim was added to the principal (e.g when no id_token was returned and no userinfo endpoint is available). - principal.SetClaim(Claims.AuthorizationServer, context.StateTokenPrincipal?.GetClaim(Claims.AuthorizationServer)) - .SetClaim(Claims.Private.ProviderName, context.StateTokenPrincipal?.GetClaim(Claims.Private.ProviderName)); + // Attach the identity of the authorization server to the returned principal to allow resolving it even if no other + // claim was added to the principal (e.g when no id_token was returned and no userinfo endpoint is available). + principal.SetClaim(Claims.AuthorizationServer, context.Issuer.AbsoluteUri) + .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName); + + return ( + AuthorizationResponse: context.Request is not null ? new(context.Request.GetParameters()) : new(), + TokenResponse : context.TokenResponse ?? new(), + Principal : principal); + } + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + + /// + /// Initiates an interactive user authentication demand. + /// + /// The client registration. + /// 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. + [RequiresPreviewFeatures] + private async ValueTask ChallengeInteractivelyAsync( + OpenIddictClientRegistration registration, string[]? scopes = null, + Dictionary? parameters = null, + Dictionary? properties = null, CancellationToken cancellationToken = default) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + if (scopes is not null && Array.Exists(scopes, string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0074), nameof(scopes)); + } + + var configuration = await registration.ConfigurationManager + .GetConfigurationAsync(cancellationToken) + .WaitAsync(cancellationToken) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + 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 ProcessChallengeContext(transaction) + { + CancellationToken = cancellationToken, + Configuration = configuration, + Issuer = registration.Issuer, + Principal = new ClaimsPrincipal(new ClaimsIdentity()), + Registration = registration, + Request = parameters is not null ? new(parameters) : new(), + }; + + 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( + message: SR.GetResourceString(SR.ID0374), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + if (string.IsNullOrEmpty(context.Nonce)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0352)); + } + + return context.Nonce; + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + + /// + /// 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)); + } + + var registration = await GetClientRegistrationAsync(issuer, cancellationToken); + return await AuthenticateWithClientCredentialsAsync(registration, scopes, parameters, properties, cancellationToken); + } + + /// + /// Authenticates using the client credentials grant and resolves the corresponding tokens. + /// + /// The name of the provider (see ). + /// 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( + string provider, string[]? scopes = null, + Dictionary? parameters = null, + Dictionary? properties = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(provider)) + { + throw new ArgumentException(SR.FormatID0366(nameof(provider)), nameof(provider)); + } + + var registration = await GetClientRegistrationAsync(provider, cancellationToken); + return await AuthenticateWithClientCredentialsAsync(registration, scopes, parameters, properties, cancellationToken); + } + + /// + /// Authenticates using the client credentials grant and resolves the corresponding tokens. + /// + /// The client registration. + /// 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. + private async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithClientCredentialsAsync( + OpenIddictClientRegistration registration, string[]? scopes = null, + Dictionary? parameters = null, + Dictionary? properties = null, CancellationToken cancellationToken = default) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + if (scopes is not null && Array.Exists(scopes, string.IsNullOrEmpty)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0074), nameof(scopes)); + } + + var configuration = await registration.ConfigurationManager + .GetConfigurationAsync(cancellationToken) + .WaitAsync(cancellationToken) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + if (configuration.TokenEndpoint is not { IsAbsoluteUri: true } uri || !uri.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) + { + CancellationToken = cancellationToken, + Configuration = configuration, + GrantType = GrantTypes.ClientCredentials, + Issuer = registration.Issuer, + Registration = registration, + TokenEndpoint = uri, + 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 specified device authorization code and resolves the corresponding tokens. + /// + /// The issuer. + /// The device authorization code returned by the server during the challenge process. + /// The interval at which the token requests are sent (by default, 5 seconds). + /// 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. + [RequiresPreviewFeatures] + public async ValueTask<(OpenIddictResponse TokenResponse, ClaimsPrincipal Principal)> AuthenticateWithDeviceAsync( + Uri issuer, string code, TimeSpan? interval = null, + Dictionary? parameters = null, + Dictionary? properties = null, CancellationToken cancellationToken = default) + { + if (issuer is null) + { + throw new ArgumentNullException(nameof(issuer)); + } + + var registration = await GetClientRegistrationAsync(issuer, cancellationToken); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return await AuthenticateWithDeviceAsync(registration, code, parameters, properties, cancellationToken); + } + + catch (ProtocolException exception) when (exception.Error is Errors.AuthorizationPending) + { + // Default to a standard 5-second interval if no explicit value was configured. + // See https://www.rfc-editor.org/rfc/rfc8628#section-3.5 for more information. + await Task.Delay(interval ?? TimeSpan.FromSeconds(5), cancellationToken); + } + + catch (ProtocolException exception) when (exception.Error is Errors.SlowDown) + { + // When the error indicates that token requests are sent too frequently, + // slow down the token redeeming process by increasing the interval. + // + // See https://www.rfc-editor.org/rfc/rfc8628#section-3.5 for more information. + interval = (interval ?? TimeSpan.FromSeconds(5)) + TimeSpan.FromSeconds(5); + + await Task.Delay(interval.GetValueOrDefault(), cancellationToken); + } + } + } + + /// + /// Authenticates using the specified device authorization code and resolves the corresponding tokens. + /// + /// The name of the provider (see ). + /// The device authorization code returned by the server during the challenge process. + /// The interval at which the token requests are sent (by default, 5 seconds). + /// 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. + [RequiresPreviewFeatures] + public async ValueTask<(OpenIddictResponse TokenResponse, ClaimsPrincipal Principal)> AuthenticateWithDeviceAsync( + string provider, string code, TimeSpan? interval = null, + Dictionary? parameters = null, + Dictionary? properties = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(provider)) + { + throw new ArgumentException(SR.FormatID0366(nameof(provider)), nameof(provider)); + } + + var registration = await GetClientRegistrationAsync(provider, cancellationToken); - return ( - AuthorizationResponse: context.Request is not null ? new(context.Request.GetParameters()) : new(), - TokenResponse : context.TokenResponse ?? new(), - Principal : principal); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return await AuthenticateWithDeviceAsync(registration, code, parameters, properties, cancellationToken); } - } - finally - { - if (scope is IAsyncDisposable disposable) + catch (ProtocolException exception) when (exception.Error is Errors.AuthorizationPending) { - await disposable.DisposeAsync(); + // Default to a standard 5-second interval if no explicit value was configured. + // See https://www.rfc-editor.org/rfc/rfc8628#section-3.5 for more information. + await Task.Delay(interval ?? TimeSpan.FromSeconds(5), cancellationToken); } - else + catch (ProtocolException exception) when (exception.Error is Errors.SlowDown) { - scope.Dispose(); + // When the error indicates that token requests are sent too frequently, + // slow down the token redeeming process by increasing the interval. + // + // See https://www.rfc-editor.org/rfc/rfc8628#section-3.5 for more information. + interval = (interval ?? TimeSpan.FromSeconds(5)) + TimeSpan.FromSeconds(5); + + await Task.Delay(interval.GetValueOrDefault(), cancellationToken); } } } /// - /// Initiates an interactive user authentication demand. + /// Authenticates using the specified device authorization code and resolves the corresponding tokens. /// /// The client registration. - /// The scopes to request to the authorization server. + /// The device code obtained after a challenge operation. /// 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. [RequiresPreviewFeatures] - private async ValueTask ChallengeInteractivelyAsync( - OpenIddictClientRegistration registration, string[]? scopes = null, + private async ValueTask<(OpenIddictResponse TokenResponse, ClaimsPrincipal Principal)> AuthenticateWithDeviceAsync( + OpenIddictClientRegistration registration, string code, Dictionary? parameters = null, Dictionary? properties = null, CancellationToken cancellationToken = default) { @@ -178,9 +637,9 @@ public sealed class OpenIddictClientService throw new ArgumentNullException(nameof(registration)); } - if (scopes is not null && Array.Exists(scopes, string.IsNullOrEmpty)) + if (string.IsNullOrEmpty(code)) { - throw new ArgumentException(SR.GetResourceString(SR.ID0074), nameof(scopes)); + throw new ArgumentException(SR.FormatID0366(nameof(code)), nameof(code)); } var configuration = await registration.ConfigurationManager @@ -188,6 +647,11 @@ public sealed class OpenIddictClientService .WaitAsync(cancellationToken) ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + if (configuration.TokenEndpoint is not { IsAbsoluteUri: true } uri || !uri.IsWellFormedOriginalString()) + { + throw new InvalidOperationException(SR.FormatID0301(Metadata.TokenEndpoint)); + } + cancellationToken.ThrowIfCancellationRequested(); // Note: this service is registered as a singleton service. As such, it cannot @@ -204,21 +668,17 @@ public sealed class OpenIddictClientService var transaction = await factory.CreateTransactionAsync(); - var context = new ProcessChallengeContext(transaction) + var context = new ProcessAuthenticationContext(transaction) { CancellationToken = cancellationToken, Configuration = configuration, + DeviceCode = code, + GrantType = GrantTypes.DeviceCode, Issuer = registration.Issuer, - Principal = new ClaimsPrincipal(new ClaimsIdentity()), - Registration = registration, - Request = parameters is not null ? new(parameters) : new(), + TokenEndpoint = uri, + 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) @@ -236,12 +696,22 @@ public sealed class OpenIddictClientService context.Error, context.ErrorDescription, context.ErrorUri); } - if (string.IsNullOrEmpty(context.Nonce)) + else { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0352)); - } + Debug.Assert(context.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); - return context.Nonce; + var principal = OpenIddictHelpers.CreateMergedPrincipal( + context.FrontchannelIdentityTokenPrincipal, + context.BackchannelIdentityTokenPrincipal, + context.UserinfoTokenPrincipal) ?? new ClaimsPrincipal(new ClaimsIdentity()); + + // Attach the identity of the authorization server to the returned principal to allow resolving it even if no other + // claim was added to the principal (e.g when no id_token was returned and no userinfo endpoint is available). + principal.SetClaim(Claims.AuthorizationServer, context.Issuer.AbsoluteUri) + .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName); + + return (TokenResponse: context.TokenResponse ?? new(), Principal: principal); + } } finally @@ -259,15 +729,16 @@ public sealed class OpenIddictClientService } /// - /// Authenticates using the client credentials grant and resolves the corresponding tokens. + /// Initiates a device authorization demand. /// /// 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 ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithClientCredentialsAsync( + /// The device authorization parameters. + [RequiresPreviewFeatures] + public async ValueTask<(string DeviceCode, string UserCode, Uri VerificationUri, Uri? VerificationUriComplete, TimeSpan ExpiresIn, TimeSpan Interval)> ChallengeUsingDeviceAsync( Uri issuer, string[]? scopes = null, Dictionary? parameters = null, Dictionary? properties = null, CancellationToken cancellationToken = default) @@ -277,23 +748,21 @@ public sealed class OpenIddictClientService throw new ArgumentNullException(nameof(issuer)); } - var options = _provider.GetRequiredService>(); - var registration = options.CurrentValue.Registrations.Find(registration => registration.Issuer == issuer) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); - - return AuthenticateWithClientCredentialsAsync(registration, scopes, parameters, properties, cancellationToken); + var registration = await GetClientRegistrationAsync(issuer, cancellationToken); + return await ChallengeUsingDeviceAsync(registration, scopes, parameters, properties, cancellationToken); } /// - /// Authenticates using the client credentials grant and resolves the corresponding tokens. + /// Initiates a device authorization demand. /// /// The name of the provider (see ). /// 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 ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithClientCredentialsAsync( + /// The device authorization parameters. + [RequiresPreviewFeatures] + public async ValueTask<(string DeviceCode, string UserCode, Uri VerificationUri, Uri? VerificationUriComplete, TimeSpan ExpiresIn, TimeSpan Interval)> ChallengeUsingDeviceAsync( string provider, string[]? scopes = null, Dictionary? parameters = null, Dictionary? properties = null, CancellationToken cancellationToken = default) @@ -303,24 +772,21 @@ public sealed class OpenIddictClientService throw new ArgumentException(SR.FormatID0366(nameof(provider)), nameof(provider)); } - var options = _provider.GetRequiredService>(); - var registration = options.CurrentValue.Registrations.Find(registration => string.Equals( - registration.ProviderName, provider, StringComparison.Ordinal)) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0348)); - - return AuthenticateWithClientCredentialsAsync(registration, scopes, parameters, properties, cancellationToken); + var registration = await GetClientRegistrationAsync(provider, cancellationToken); + return await ChallengeUsingDeviceAsync(registration, scopes, parameters, properties, cancellationToken); } /// - /// Authenticates using the client credentials grant and resolves the corresponding tokens. + /// Initiates a device authorization demand. /// /// The client registration. /// 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. - private async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithClientCredentialsAsync( + /// The device authorization parameters. + [RequiresPreviewFeatures] + private async ValueTask<(string DeviceCode, string UserCode, Uri VerificationUri, Uri? VerificationUriComplete, TimeSpan ExpiresIn, TimeSpan Interval)> ChallengeUsingDeviceAsync( OpenIddictClientRegistration registration, string[]? scopes = null, Dictionary? parameters = null, Dictionary? properties = null, CancellationToken cancellationToken = default) @@ -340,11 +806,6 @@ public sealed class OpenIddictClientService .WaitAsync(cancellationToken) ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); - if (configuration.TokenEndpoint is not { IsAbsoluteUri: true } uri || !uri.IsWellFormedOriginalString()) - { - throw new InvalidOperationException(SR.FormatID0301(Metadata.TokenEndpoint)); - } - cancellationToken.ThrowIfCancellationRequested(); // Note: this service is registered as a singleton service. As such, it cannot @@ -358,17 +819,18 @@ public sealed class OpenIddictClientService { var dispatcher = scope.ServiceProvider.GetRequiredService(); var factory = scope.ServiceProvider.GetRequiredService(); + var transaction = await factory.CreateTransactionAsync(); - var context = new ProcessAuthenticationContext(transaction) + var context = new ProcessChallengeContext(transaction) { CancellationToken = cancellationToken, Configuration = configuration, - GrantType = GrantTypes.ClientCredentials, + GrantType = GrantTypes.DeviceCode, Issuer = registration.Issuer, + Principal = new ClaimsPrincipal(new ClaimsIdentity()), Registration = registration, - TokenEndpoint = uri, - TokenRequest = parameters is not null ? new(parameters) : null, + Request = parameters is not null ? new(parameters) : new(), }; if (scopes is { Length: > 0 }) @@ -389,17 +851,17 @@ public sealed class OpenIddictClientService if (context.IsRejected) { throw new ProtocolException( - SR.FormatID0319(context.Error, context.ErrorDescription, context.ErrorUri), + message: SR.GetResourceString(SR.ID0374), 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)); + return (DeviceCode: context.DeviceCode!, + UserCode: context.UserCode!, + VerificationUri: new Uri(context.DeviceAuthorizationResponse?.VerificationUri!, UriKind.Absolute), + VerificationUriComplete: context.DeviceAuthorizationResponse?.VerificationUriComplete + is string value ? new Uri(value, UriKind.Absolute) : null, + ExpiresIn: TimeSpan.FromSeconds((double) context.DeviceAuthorizationResponse?.ExpiresIn!), + Interval: TimeSpan.FromSeconds((long?) context.DeviceAuthorizationResponse[Parameters.Interval] ?? 5)); } finally @@ -427,7 +889,7 @@ public sealed class OpenIddictClientService /// 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 ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithPasswordAsync( + 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) @@ -437,11 +899,8 @@ public sealed class OpenIddictClientService throw new ArgumentNullException(nameof(issuer)); } - var options = _provider.GetRequiredService>(); - var registration = options.CurrentValue.Registrations.Find(registration => registration.Issuer == issuer) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); - - return AuthenticateWithPasswordAsync(registration, username, password, scopes, parameters, properties, cancellationToken); + var registration = await GetClientRegistrationAsync(issuer, cancellationToken); + return await AuthenticateWithPasswordAsync(registration, username, password, scopes, parameters, properties, cancellationToken); } /// @@ -455,7 +914,7 @@ public sealed class OpenIddictClientService /// 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 ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithPasswordAsync( + public async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithPasswordAsync( string provider, string username, string password, string[]? scopes = null, Dictionary? parameters = null, Dictionary? properties = null, CancellationToken cancellationToken = default) @@ -465,12 +924,8 @@ public sealed class OpenIddictClientService throw new ArgumentException(SR.FormatID0366(nameof(provider)), nameof(provider)); } - var options = _provider.GetRequiredService>(); - var registration = options.CurrentValue.Registrations.Find(registration => string.Equals( - registration.ProviderName, provider, StringComparison.Ordinal)) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0348)); - - return AuthenticateWithPasswordAsync(registration, username, password, scopes, parameters, properties, cancellationToken); + var registration = await GetClientRegistrationAsync(provider, cancellationToken); + return await AuthenticateWithPasswordAsync(registration, username, password, scopes, parameters, properties, cancellationToken); } /// @@ -593,7 +1048,7 @@ public sealed class OpenIddictClientService } /// - /// Authenticates using the resource owner password credentials grant and resolves the corresponding tokens. + /// Authenticates using the specified refresh token and resolves the corresponding tokens. /// /// The issuer. /// The refresh token to use. @@ -602,7 +1057,7 @@ public sealed class OpenIddictClientService /// 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 ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithRefreshTokenAsync( + public async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithRefreshTokenAsync( Uri issuer, string token, string[]? scopes = null, Dictionary? parameters = null, Dictionary? properties = null, CancellationToken cancellationToken = default) @@ -612,15 +1067,12 @@ public sealed class OpenIddictClientService throw new ArgumentNullException(nameof(issuer)); } - var options = _provider.GetRequiredService>(); - var registration = options.CurrentValue.Registrations.Find(registration => registration.Issuer == issuer) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); - - return AuthenticateWithRefreshTokenAsync(registration, token, scopes, parameters, properties, cancellationToken); + var registration = await GetClientRegistrationAsync(issuer, cancellationToken); + return await AuthenticateWithRefreshTokenAsync(registration, token, scopes, parameters, properties, cancellationToken); } /// - /// Authenticates using the resource owner password credentials grant and resolves the corresponding tokens. + /// Authenticates using the specified refresh token and resolves the corresponding tokens. /// /// The name of the provider (see ). /// The refresh token to use. @@ -629,7 +1081,7 @@ public sealed class OpenIddictClientService /// 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 ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithRefreshTokenAsync( + public async ValueTask<(OpenIddictResponse Response, ClaimsPrincipal Principal)> AuthenticateWithRefreshTokenAsync( string provider, string token, string[]? scopes = null, Dictionary? parameters = null, Dictionary? properties = null, CancellationToken cancellationToken = default) @@ -639,16 +1091,12 @@ public sealed class OpenIddictClientService throw new ArgumentException(SR.FormatID0366(nameof(provider)), nameof(provider)); } - var options = _provider.GetRequiredService>(); - var registration = options.CurrentValue.Registrations.Find(registration => string.Equals( - registration.ProviderName, provider, StringComparison.Ordinal)) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0348)); - - return AuthenticateWithRefreshTokenAsync(registration, token, scopes, parameters, properties, cancellationToken); + var registration = await GetClientRegistrationAsync(provider, cancellationToken); + return await AuthenticateWithRefreshTokenAsync(registration, token, scopes, parameters, properties, cancellationToken); } /// - /// Authenticates using the resource owner password credentials grant and resolves the corresponding tokens. + /// Authenticates using the specified refresh token and resolves the corresponding tokens. /// /// The client registration. /// The refresh token to use. @@ -1076,6 +1524,179 @@ public sealed class OpenIddictClientService } } + /// + /// Sends the device authorization request and retrieves the corresponding response. + /// + /// The client registration. + /// The device authorization request. + /// The uri of the remote device authorization endpoint. + /// The that can be used to abort the operation. + /// The token response. + internal async ValueTask SendDeviceAuthorizationRequestAsync( + OpenIddictClientRegistration registration, OpenIddictRequest request, + Uri? uri = null, CancellationToken cancellationToken = default) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (uri is null) + { + throw new ArgumentNullException(nameof(uri)); + } + + if (!uri.IsAbsoluteUri || !uri.IsWellFormedOriginalString()) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0144), nameof(uri)); + } + + var configuration = await registration.ConfigurationManager + .GetConfigurationAsync(cancellationToken) + .WaitAsync(cancellationToken) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + 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(); + + request = await PrepareDeviceAuthorizationRequestAsync(); + request = await ApplyDeviceAuthorizationRequestAsync(); + + var response = await ExtractDeviceAuthorizationResponseAsync(); + + return await HandleDeviceAuthorizationResponseAsync(); + + async ValueTask PrepareDeviceAuthorizationRequestAsync() + { + var context = new PrepareDeviceAuthorizationRequestContext(transaction) + { + CancellationToken = cancellationToken, + RemoteUri = uri, + Configuration = configuration, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0398(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + return context.Request; + } + + async ValueTask ApplyDeviceAuthorizationRequestAsync() + { + var context = new ApplyDeviceAuthorizationRequestContext(transaction) + { + CancellationToken = cancellationToken, + RemoteUri = uri, + Configuration = configuration, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0399(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + context.Logger.LogInformation(SR.GetResourceString(SR.ID6217), context.RemoteUri, context.Request); + + return context.Request; + } + + async ValueTask ExtractDeviceAuthorizationResponseAsync() + { + var context = new ExtractDeviceAuthorizationResponseContext(transaction) + { + CancellationToken = cancellationToken, + RemoteUri = uri, + Configuration = configuration, + Registration = registration, + Request = request + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0400(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + Debug.Assert(context.Response is not null, SR.GetResourceString(SR.ID4007)); + + context.Logger.LogInformation(SR.GetResourceString(SR.ID6218), context.RemoteUri, context.Response); + + return context.Response; + } + + async ValueTask HandleDeviceAuthorizationResponseAsync() + { + var context = new HandleDeviceAuthorizationResponseContext(transaction) + { + CancellationToken = cancellationToken, + RemoteUri = uri, + Configuration = configuration, + Registration = registration, + Request = request, + Response = response + }; + + await dispatcher.DispatchAsync(context); + + if (context.IsRejected) + { + throw new ProtocolException( + SR.FormatID0401(context.Error, context.ErrorDescription, context.ErrorUri), + context.Error, context.ErrorDescription, context.ErrorUri); + } + + return context.Response; + } + } + + finally + { + if (scope is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + else + { + scope.Dispose(); + } + } + } + /// /// Sends the token request and retrieves the corresponding response. /// diff --git a/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs index df52c5fe..855b1a28 100644 --- a/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs @@ -142,6 +142,12 @@ public static partial class OpenIddictServerEvents /// public HashSet CodeChallengeMethods { get; } = new(StringComparer.Ordinal); + /// + /// Gets a list of client authentication methods supported by + /// the device endpoint provided by the authorization server. + /// + public HashSet DeviceEndpointAuthenticationMethods { get; } = new(StringComparer.Ordinal); + /// /// Gets the list of grant types /// supported by the authorization server. diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs index 994b3d3f..3cc8d27a 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs @@ -251,7 +251,8 @@ public static partial class OpenIddictServerHandlers [Metadata.SubjectTypesSupported] = notification.SubjectTypes.ToArray(), [Metadata.TokenEndpointAuthMethodsSupported] = notification.TokenEndpointAuthenticationMethods.ToArray(), [Metadata.IntrospectionEndpointAuthMethodsSupported] = notification.IntrospectionEndpointAuthenticationMethods.ToArray(), - [Metadata.RevocationEndpointAuthMethodsSupported] = notification.RevocationEndpointAuthenticationMethods.ToArray() + [Metadata.RevocationEndpointAuthMethodsSupported] = notification.RevocationEndpointAuthenticationMethods.ToArray(), + [Metadata.DeviceAuthorizationEndpointAuthMethodsSupported] = notification.DeviceEndpointAuthenticationMethods.ToArray() }; foreach (var metadata in notification.Metadata) @@ -505,6 +506,14 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } + // Note: "device_authorization_endpoint_auth_methods_supported" is not a standard parameter + // but is supported by OpenIddict 4.3.0 and higher for consistency with the other endpoints. + if (context.DeviceEndpoint is not null) + { + context.DeviceEndpointAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic); + context.DeviceEndpointAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretPost); + } + if (context.IntrospectionEndpoint is not null) { context.IntrospectionEndpointAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 19f2ba12..6d83ebc6 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -3079,16 +3079,17 @@ public static partial class OpenIddictServerHandlers if (context.AccessTokenPrincipal is not null) { // If an expiration date was set on the access token principal, return it to the client application. - var date = context.AccessTokenPrincipal.GetExpirationDate(); - if (date.HasValue && date.Value > DateTimeOffset.UtcNow) + if (context.AccessTokenPrincipal.GetExpirationDate() + is DateTimeOffset date && date > DateTimeOffset.UtcNow) { - context.Response.ExpiresIn = (long) ((date.Value - DateTimeOffset.UtcNow).TotalSeconds + .5); + context.Response.ExpiresIn = (long) ((date - DateTimeOffset.UtcNow).TotalSeconds + .5); } // If the granted access token scopes differ from the requested scopes, return the granted scopes // list as a parameter to inform the client application of the fact the scopes set will be reduced. var scopes = context.AccessTokenPrincipal.GetScopes().ToHashSet(StringComparer.Ordinal); - if ((context.EndpointType is OpenIddictServerEndpointType.Token && context.Request.IsAuthorizationCodeGrantType()) || + if ((context.EndpointType is OpenIddictServerEndpointType.Token && + context.Request.IsAuthorizationCodeGrantType()) || !scopes.SetEquals(context.Request.GetScopes())) { context.Response.Scope = string.Join(" ", scopes); @@ -3104,17 +3105,6 @@ public static partial class OpenIddictServerHandlers if (context.IncludeDeviceCode) { context.Response.DeviceCode = context.DeviceCode; - - // If the principal is available, attach additional metadata. - if (context.DeviceCodePrincipal is not null) - { - // If an expiration date was set on the device code principal, return it to the client application. - var date = context.DeviceCodePrincipal.GetExpirationDate(); - if (date.HasValue && date.Value > DateTimeOffset.UtcNow) - { - context.Response.ExpiresIn = (long) ((date.Value - DateTimeOffset.UtcNow).TotalSeconds + .5); - } - } } if (context.IncludeIdentityToken) @@ -3130,18 +3120,37 @@ public static partial class OpenIddictServerHandlers if (context.IncludeUserCode) { context.Response.UserCode = context.UserCode; + } - if (OpenIddictHelpers.CreateAbsoluteUri(context.BaseUri, - context.Options.VerificationEndpointUris.FirstOrDefault()) is Uri uri) - { - var builder = new UriBuilder(uri) - { - Query = string.Concat(Parameters.UserCode, "=", context.UserCode) - }; + if (context.EndpointType is OpenIddictServerEndpointType.Device) + { + var uri = OpenIddictHelpers.CreateAbsoluteUri( + left : context.BaseUri ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)), + right: context.Options.VerificationEndpointUris.First()); + + context.Response.VerificationUri = uri.AbsoluteUri; - context.Response[Parameters.VerificationUri] = uri.AbsoluteUri; - context.Response[Parameters.VerificationUriComplete] = builder.Uri.AbsoluteUri; + if (!string.IsNullOrEmpty(context.UserCode)) + { + // Build the "verification_uri_complete" parameter using the verification endpoint URI + // with the generated user code appended to the query string as a unique parameter. + context.Response.VerificationUriComplete = OpenIddictHelpers.AddQueryStringParameter( + uri, Parameters.UserCode, context.UserCode).AbsoluteUri; } + + context.Response.ExpiresIn = ( + context.DeviceCodePrincipal?.GetExpirationDate() ?? + context.UserCodePrincipal?.GetExpirationDate()) switch + { + // If an expiration date was set on the device code or user + // code principal, return it to the client application. + DateTimeOffset date when date > DateTimeOffset.UtcNow + => (long) ((date - DateTimeOffset.UtcNow).TotalSeconds + .5), + + // Otherwise, return an arbitrary value, as the "expires_in" + // parameter is required in device authorization responses. + _ => 5 * 60 // 5 minutes, in seconds. + }; } return default; diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs index 20fdaeab..aee8e2d9 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs @@ -159,7 +159,8 @@ public static partial class OpenIddictValidationHandlers // The following claims MUST be formatted as numeric dates: Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore - => ((JsonElement) value).ValueKind is JsonValueKind.Number, + => (JsonElement) value is { ValueKind: JsonValueKind.Number } element && + element.TryGetDecimal(out decimal result) && result is >= 0, // Claims that are not in the well-known list can be of any type. _ => true diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs index dfb389bc..df14a5b4 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.cs @@ -61,7 +61,7 @@ public static partial class OpenIddictValidationHandlers throw new ArgumentNullException(nameof(context)); } - context.Configuration = await context.Options.ConfigurationManager + context.Configuration ??= await context.Options.ConfigurationManager .GetConfigurationAsync(context.CancellationToken) .WaitAsync(context.CancellationToken) ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs index 91210bbd..50dcdcd6 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs @@ -112,6 +112,20 @@ public class OpenIddictResponseTests /* name: */ Parameters.UserCode, /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") }; + + yield return new object[] + { + /* property: */ nameof(OpenIddictResponse.VerificationUri), + /* name: */ Parameters.VerificationUri, + /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") + }; + + yield return new object[] + { + /* property: */ nameof(OpenIddictResponse.VerificationUriComplete), + /* name: */ Parameters.VerificationUriComplete, + /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") + }; } } diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs index cf9c04d2..eeafa39f 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs @@ -491,6 +491,42 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Contains(ClientAuthenticationMethods.ClientSecretPost, methods); } + [Fact] + public async Task HandleConfigurationRequest_NoClientAuthenticationMethodIsIncludedWhenDeviceEndpointIsDisabled() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode)); + options.SetDeviceEndpointUris(Array.Empty()); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/.well-known/openid-configuration"); + + // Assert + Assert.False(response.HasParameter(Metadata.DeviceAuthorizationEndpointAuthMethodsSupported)); + } + + [Fact] + public async Task HandleConfigurationRequest_SupportedClientAuthenticationMethodsAreIncludedWhenDeviceEndpointIsEnabled() + { + // Arrange + await using var server = await CreateServerAsync(); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/.well-known/openid-configuration"); + var methods = (string[]?) response[Metadata.DeviceAuthorizationEndpointAuthMethodsSupported]; + + // Assert + Assert.NotNull(methods); + Assert.Contains(ClientAuthenticationMethods.ClientSecretBasic, methods); + Assert.Contains(ClientAuthenticationMethods.ClientSecretPost, methods); + } + [Fact] public async Task HandleConfigurationRequest_ConfiguredGrantTypesAreReturned() {