Browse Source

Add device authorization grant support in the OpenIddict client

pull/1753/head
Kévin Chalet 3 years ago
parent
commit
4685332df5
  1. 25
      gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs
  2. 19
      sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs
  3. 19
      sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs
  4. 2
      sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs
  5. 90
      sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs
  6. 5
      sandbox/OpenIddict.Sandbox.Console.Client/Program.cs
  7. 2
      src/OpenIddict.Abstractions/OpenIddictConstants.cs
  8. 51
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  9. 10
      src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs
  10. 18
      src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs
  11. 9
      src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs
  12. 70
      src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs
  13. 9
      src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs
  14. 70
      src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs
  15. 1
      src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs
  16. 131
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.Device.cs
  17. 1
      src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs
  18. 63
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Device.cs
  19. 43
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.Discovery.cs
  20. 1
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs
  21. 6
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml
  22. 28
      src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd
  23. 10
      src/OpenIddict.Client/OpenIddictClientBuilder.cs
  24. 123
      src/OpenIddict.Client/OpenIddictClientEvents.Device.cs
  25. 19
      src/OpenIddict.Client/OpenIddictClientEvents.Exchange.cs
  26. 145
      src/OpenIddict.Client/OpenIddictClientEvents.cs
  27. 3
      src/OpenIddict.Client/OpenIddictClientExtensions.cs
  28. 52
      src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs
  29. 246
      src/OpenIddict.Client/OpenIddictClientHandlers.Device.cs
  30. 117
      src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs
  31. 9
      src/OpenIddict.Client/OpenIddictClientHandlers.Exchange.cs
  32. 790
      src/OpenIddict.Client/OpenIddictClientHandlers.cs
  33. 3
      src/OpenIddict.Client/OpenIddictClientRegistration.cs
  34. 847
      src/OpenIddict.Client/OpenIddictClientService.cs
  35. 6
      src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs
  36. 11
      src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs
  37. 57
      src/OpenIddict.Server/OpenIddictServerHandlers.cs
  38. 3
      src/OpenIddict.Validation/OpenIddictValidationHandlers.Introspection.cs
  39. 2
      src/OpenIddict.Validation/OpenIddictValidationHandlers.cs
  40. 14
      test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs
  41. 36
      test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs

25
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<string>) Array.Empty<string>()
},
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<string>) 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<string>) new[] { ClientAuthenticationMethods.ClientSecretPost }
}
},

19
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<string, string>
{
// 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,

19
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<string, string>
{
// 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,

2
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,

90
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<string> GetSelectedProviderAsync(CancellationToken cancellationToken)
static Task<bool> 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<string> GetSelectedProviderAsync(CancellationToken cancellationToken)
{
static string Prompt() => AnsiConsole.Prompt(new SelectionPrompt<string>()
.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<T> WaitAsync<T>(Task<T> 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<bool>(TaskCreationOptions.None);
using (cancellationToken.Register(static state => ((TaskCompletionSource<bool>) state!).SetResult(true), source))

5
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

2
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";

51
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -1486,6 +1486,39 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
<data name="ID0395" xml:space="preserve">
<value>The issuer attached to the static configuration must be the same as the one configured in the client registration.</value>
</data>
<data name="ID0396" xml:space="preserve">
<value>A device code must be specified when using the device authorization code grant.</value>
</data>
<data name="ID0397" xml:space="preserve">
<value>The client registration corresponding to the specified provider name cannot be found in the client options.</value>
</data>
<data name="ID0398" xml:space="preserve">
<value>An error occurred while preparing the device authorization request.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0399" xml:space="preserve">
<value>An error occurred while sending the device authorization request.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0400" xml:space="preserve">
<value>An error occurred while extracting the device authorization response.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0401" xml:space="preserve">
<value>An error occurred while handling the device authorization response.
Error: {0}
Error description: {1}
Error URI: {2}</value>
</data>
<data name="ID0402" xml:space="preserve">
<value>The grant type '{0}' is not supported by the ASP.NET Core and OWIN integrations.</value>
</data>
<data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value>
</data>
@ -1984,6 +2017,15 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
<data name="ID2166" xml:space="preserve">
<value>The received authorization response is not valid for this instance of the application.</value>
</data>
<data name="ID2167" xml:space="preserve">
<value>The device authorization request was rejected by the remote server.</value>
</data>
<data name="ID2168" xml:space="preserve">
<value>The mandatory '{0}' parameter couldn't be found in the device authorization response.</value>
</data>
<data name="ID2169" xml:space="preserve">
<value>The '{0}' parameter returned in the device authorization response is not valid absolute URI.</value>
</data>
<data name="ID4000" xml:space="preserve">
<value>The '{0}' parameter shouldn't be null or empty at this point.</value>
</data>
@ -2653,6 +2695,15 @@ This may indicate that the hashed entry is corrupted or malformed.</value>
<data name="ID6215" xml:space="preserve">
<value>An error occurred while redirecting a protocol activation to the '{Identifier}' instance.</value>
</data>
<data name="ID6216" xml:space="preserve">
<value>The device authorization request was rejected by the remote authorization server: {Response}.</value>
</data>
<data name="ID6217" xml:space="preserve">
<value>The device authorization request was successfully sent to {Uri}: {Request}.</value>
</data>
<data name="ID6218" xml:space="preserve">
<value>The device authorization response returned by {Uri} was successfully extracted: {Response}.</value>
</data>
<data name="ID8000" xml:space="preserve">
<value>https://documentation.openiddict.com/errors/{0}</value>
</data>

10
src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs

@ -31,6 +31,16 @@ public sealed class OpenIddictConfiguration
/// </summary>
public HashSet<string> CodeChallengeMethodsSupported { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets or sets the URI of the device authorization endpoint.
/// </summary>
public Uri? DeviceAuthorizationEndpoint { get; set; }
/// <summary>
/// Gets the client authentication methods supported by the device authorization endpoint.
/// </summary>
public HashSet<string> DeviceAuthorizationEndpointAuthMethodsSupported { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets or sets the URI of the end session endpoint.
/// </summary>

18
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);
}
/// <summary>
/// Gets or sets the "verification_uri" parameter.
/// </summary>
public string? VerificationUri
{
get => (string?) GetParameter(OpenIddictConstants.Parameters.VerificationUri);
set => SetParameter(OpenIddictConstants.Parameters.VerificationUri, value);
}
/// <summary>
/// Gets or sets the "verification_uri_complete" parameter.
/// </summary>
public string? VerificationUriComplete
{
get => (string?) GetParameter(OpenIddictConstants.Parameters.VerificationUriComplete);
set => SetParameter(OpenIddictConstants.Parameters.VerificationUriComplete, value);
}
}

9
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<Op
else
{
Debug.Assert(context.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
// A single main claims-based principal instance can be attached to an authentication ticket.
// To return the most appropriate one, the principal is selected based on the endpoint type.
// Independently of the selected main principal, all principals resolved from validated tokens
@ -170,10 +173,10 @@ public sealed class OpenIddictClientAspNetCoreHandler : AuthenticationHandler<Op
return AuthenticateResult.NoResult();
}
// Attach the identity of the authorization to the returned principal to allow resolving it even if no other
// 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.StateTokenPrincipal?.GetClaim(Claims.AuthorizationServer))
.SetClaim(Claims.Private.ProviderName, context.StateTokenPrincipal?.GetClaim(Claims.Private.ProviderName));
principal.SetClaim(Claims.AuthorizationServer, context.Issuer.AbsoluteUri)
.SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName);
// Restore or create a new authentication properties collection and populate it.
var properties = CreateProperties(context.StateTokenPrincipal);

70
src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs

@ -40,12 +40,14 @@ public static partial class OpenIddictClientAspNetCoreHandlers
/*
* Authentication processing:
*/
ValidateAuthenticationType.Descriptor,
ValidateAuthenticationNonce.Descriptor,
ResolveRequestForgeryProtection.Descriptor,
/*
* Challenge processing:
*/
ValidateChallengeType.Descriptor,
ResolveHostChallengeProperties.Descriptor,
ValidateTransportSecurityRequirementForChallenge.Descriptor,
GenerateLoginCorrelationCookie.Descriptor,
@ -294,6 +296,40 @@ public static partial class OpenIddictClientAspNetCoreHandlers
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class ValidateAuthenticationType : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireHttpRequest>()
.UseSingletonHandler<ValidateAuthenticationType>()
.SetOrder(ValidateAuthenticationNonce.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class ValidateChallengeType : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireHttpRequest>()
.UseSingletonHandler<ValidateChallengeType>()
.SetOrder(ResolveHostChallengeProperties.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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.

9
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<OpenIddi
else
{
Debug.Assert(context.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
// A single main claims-based principal instance can be attached to an authentication ticket.
var principal = context.EndpointType switch
{
@ -185,10 +188,10 @@ public sealed class OpenIddictClientOwinHandler : AuthenticationHandler<OpenIddi
return null;
}
// Attach the identity of the authorization to the returned principal to allow resolving it even if no other
// 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.StateTokenPrincipal?.GetClaim(Claims.AuthorizationServer))
.SetClaim(Claims.Private.ProviderName, context.StateTokenPrincipal?.GetClaim(Claims.Private.ProviderName));
principal.SetClaim(Claims.AuthorizationServer, context.Issuer.AbsoluteUri)
.SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName);
// Restore or create a new authentication properties collection and populate it.
var properties = CreateProperties(context.StateTokenPrincipal);

70
src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs

@ -35,12 +35,14 @@ public static partial class OpenIddictClientOwinHandlers
/*
* Authentication processing:
*/
ValidateAuthenticationType.Descriptor,
ValidateAuthenticationNonce.Descriptor,
ResolveRequestForgeryProtection.Descriptor,
/*
* Challenge processing:
*/
ValidateChallengeType.Descriptor,
ResolveHostChallengeProperties.Descriptor,
ValidateTransportSecurityRequirementForChallenge.Descriptor,
GenerateLoginCorrelationCookie.Descriptor,
@ -295,6 +297,40 @@ public static partial class OpenIddictClientOwinHandlers
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class ValidateAuthenticationType : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireOwinRequest>()
.UseSingletonHandler<ValidateAuthenticationType>()
.SetOrder(ValidateAuthenticationNonce.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class ValidateChallengeType : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireOwinRequest>()
.UseSingletonHandler<ValidateChallengeType>()
.SetOrder(ResolveHostChallengeProperties.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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.

1
src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs

@ -1484,6 +1484,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireInteractiveSession>()
.AddFilter<RequireInteractiveGrantType>()
.UseSingletonHandler<AttachDynamicPortToRedirectUri>()
.SetOrder(AttachRedirectUri.Descriptor.Order + 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)

131
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<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; } = ImmutableArray.Create(
/*
* DeviceAuthorization request processing:
*/
CreateHttpClient<PrepareDeviceAuthorizationRequestContext>.Descriptor,
PreparePostHttpRequest<PrepareDeviceAuthorizationRequestContext>.Descriptor,
AttachHttpVersion<PrepareDeviceAuthorizationRequestContext>.Descriptor,
AttachJsonAcceptHeaders<PrepareDeviceAuthorizationRequestContext>.Descriptor,
AttachUserAgentHeader<PrepareDeviceAuthorizationRequestContext>.Descriptor,
AttachFromHeader<PrepareDeviceAuthorizationRequestContext>.Descriptor,
AttachBasicAuthenticationCredentials.Descriptor,
AttachFormParameters<PrepareDeviceAuthorizationRequestContext>.Descriptor,
SendHttpRequest<ApplyDeviceAuthorizationRequestContext>.Descriptor,
DisposeHttpRequest<ApplyDeviceAuthorizationRequestContext>.Descriptor,
/*
* DeviceAuthorization response processing:
*/
DecompressResponseContent<ExtractDeviceAuthorizationResponseContext>.Descriptor,
ExtractJsonHttpResponse<ExtractDeviceAuthorizationResponseContext>.Descriptor,
ExtractWwwAuthenticateHeader<ExtractDeviceAuthorizationResponseContext>.Descriptor,
ValidateHttpResponse<ExtractDeviceAuthorizationResponseContext>.Descriptor,
DisposeHttpResponse<ExtractDeviceAuthorizationResponseContext>.Descriptor);
/// <summary>
/// Contains the logic responsible for attaching the client credentials to the HTTP Authorization header.
/// </summary>
public sealed class AttachBasicAuthenticationCredentials : IOpenIddictClientHandler<PrepareDeviceAuthorizationRequestContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<PrepareDeviceAuthorizationRequestContext>()
.AddFilter<RequireHttpMetadataUri>()
.UseSingletonHandler<AttachBasicAuthenticationCredentials>()
.SetOrder(AttachFormParameters<PrepareDeviceAuthorizationRequestContext>.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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", "+");
}
}
}
}

1
src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs

@ -22,6 +22,7 @@ public static partial class OpenIddictClientSystemNetHttpHandlers
{
public static ImmutableArray<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; }
= ImmutableArray.Create<OpenIddictClientHandlerDescriptor>()
.AddRange(Device.DefaultHandlers)
.AddRange(Discovery.DefaultHandlers)
.AddRange(Exchange.DefaultHandlers)
.AddRange(Userinfo.DefaultHandlers);

63
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<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; } = ImmutableArray.Create(
/*
* Token response extraction:
*/
MapNonStandardResponseParameters.Descriptor);
/// <summary>
/// Contains the logic responsible for mapping non-standard response parameters
/// to their standard equivalent for the providers that require it.
/// </summary>
public sealed class MapNonStandardResponseParameters : IOpenIddictClientHandler<ExtractDeviceAuthorizationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ExtractDeviceAuthorizationResponseContext>()
.UseSingletonHandler<MapNonStandardResponseParameters>()
.SetOrder(int.MaxValue - 50_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
}
}

43
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
}
}
/// <summary>
/// Contains the logic responsible for amending the client authentication methods
/// supported by the device authorization endpoint for the providers that require it.
/// </summary>
public sealed class AmendDeviceAuthorizationEndpointClientAuthenticationMethods : IOpenIddictClientHandler<HandleConfigurationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<AmendDeviceAuthorizationEndpointClientAuthenticationMethods>()
.SetOrder(ExtractTokenEndpointClientAuthenticationMethods.Descriptor.Order + 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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<HandleConfigurationResponseContext>()
.UseSingletonHandler<AmendTokenEndpointClientAuthenticationMethods>()
.SetOrder(ExtractTokenEndpointClientAuthenticationMethods.Descriptor.Order + 500)
.SetOrder(AmendDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();

1
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)

6
src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xml

@ -316,8 +316,12 @@
<Provider Name="GitHub" Documentation="https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps">
<Environment Issuer="https://github.com/">
<Configuration AuthorizationEndpoint="https://github.com/login/oauth/authorize"
DeviceAuthorizationEndpoint="https://github.com/login/device/code"
TokenEndpoint="https://github.com/login/oauth/access_token"
UserinfoEndpoint="https://api.github.com/user" />
UserinfoEndpoint="https://api.github.com/user">
<GrantType Value="authorization_code" />
<GrantType Value="urn:ietf:params:oauth:grant-type:device_code" />
</Configuration>
</Environment>
</Provider>

28
src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationProviders.xsd

@ -50,6 +50,27 @@
</xs:complexType>
</xs:element>
<xs:element name="DeviceAuthorizationEndpointAuthMethod" minOccurs="0" maxOccurs="10">
<xs:annotation>
<xs:documentation>The device authorization endpoint authentication methods supported by the environment.</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:attribute name="Value" use="required">
<xs:annotation>
<xs:documentation>The device authorization endpoint authentication method name (e.g client_secret_basic).</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="client_secret_basic" />
<xs:enumeration value="client_secret_post" />
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:complexType>
</xs:element>
<xs:element name="GrantType" minOccurs="0" maxOccurs="10">
<xs:annotation>
<xs:documentation>The grant types supported by the environment.</xs:documentation>
@ -67,6 +88,7 @@
<xs:enumeration value="client_credentials" />
<xs:enumeration value="implicit" />
<xs:enumeration value="refresh_token" />
<xs:enumeration value="urn:ietf:params:oauth:grant-type:device_code" />
</xs:restriction>
</xs:simpleType>
</xs:attribute>
@ -162,6 +184,12 @@
</xs:annotation>
</xs:attribute>
<xs:attribute name="DeviceAuthorizationEndpoint" type="xs:string" use="optional">
<xs:annotation>
<xs:documentation>The device authorization endpoint offered by the environment.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="TokenEndpoint" type="xs:string" use="optional">
<xs:annotation>
<xs:documentation>The token endpoint offered by the environment.</xs:documentation>

10
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));
/// <summary>
/// Enables device code flow support. For more information about this
/// specific OAuth 2.0 flow, visit https://tools.ietf.org/html/rfc8628.
/// </summary>
/// <returns>The <see cref="OpenIddictClientBuilder"/> instance.</returns>
[RequiresPreviewFeatures]
public OpenIddictClientBuilder AllowDeviceCodeFlow()
=> Configure(options => options.GrantTypes.Add(GrantTypes.DeviceCode));
/// <summary>
/// Enables hybrid flow support. For more information
/// about this specific OpenID Connect flow, visit

123
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
{
/// <summary>
/// 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.
/// </summary>
public sealed class PrepareDeviceAuthorizationRequestContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="PrepareDeviceAuthorizationRequestContext"/> class.
/// </summary>
public PrepareDeviceAuthorizationRequestContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
}
/// <summary>
/// Represents an event called for each request to the device authorization endpoint
/// to send the device authorization request to the remote authorization server.
/// </summary>
public sealed class ApplyDeviceAuthorizationRequestContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="ApplyDeviceAuthorizationRequestContext"/> class.
/// </summary>
public ApplyDeviceAuthorizationRequestContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
}
/// <summary>
/// Represents an event called for each device authorization response
/// to extract the response parameters from the server response.
/// </summary>
public sealed class ExtractDeviceAuthorizationResponseContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="ExtractDeviceAuthorizationResponseContext"/> class.
/// </summary>
public ExtractDeviceAuthorizationResponseContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
/// <summary>
/// Gets or sets the response, or <see langword="null"/> if it wasn't extracted yet.
/// </summary>
public OpenIddictResponse? Response
{
get => Transaction.Response;
set => Transaction.Response = value;
}
}
/// <summary>
/// Represents an event called for each device authorization response.
/// </summary>
public sealed class HandleDeviceAuthorizationResponseContext : BaseExternalContext
{
/// <summary>
/// Creates a new instance of the <see cref="HandleDeviceAuthorizationResponseContext"/> class.
/// </summary>
public HandleDeviceAuthorizationResponseContext(OpenIddictClientTransaction transaction)
: base(transaction)
{
}
/// <summary>
/// Gets or sets the request.
/// </summary>
public OpenIddictRequest Request
{
get => Transaction.Request!;
set => Transaction.Request = value;
}
/// <summary>
/// Gets or sets the response.
/// </summary>
public OpenIddictResponse Response
{
get => Transaction.Response!;
set => Transaction.Response = value;
}
}
}

19
src/OpenIddict.Client/OpenIddictClientEvents.Exchange.cs

@ -143,21 +143,34 @@ public static partial class OpenIddictClientEvents
/// <summary>
/// Gets or sets the access token resolved from the token response.
/// </summary>
public string? AccessToken { get; set; }
public string? AccessToken
{
get => Response.AccessToken;
set => Response.AccessToken = value;
}
/// <summary>
/// Gets or sets the identity token resolved from the token response.
/// </summary>
public string? IdentityToken { get; set; }
public string? IdentityToken
{
get => Response.IdToken;
set => Response.IdToken = value;
}
/// <summary>
/// Gets or sets the refresh token resolved from the token response.
/// </summary>
public string? RefreshToken { get; set; }
public string? RefreshToken
{
get => Response.RefreshToken;
set => Response.RefreshToken = value;
}
/// <summary>
/// Gets or sets the principal containing the claims resolved from the token response.
/// </summary>
[Obsolete]
public ClaimsPrincipal? Principal { get; set; }
}
}

145
src/OpenIddict.Client/OpenIddictClientEvents.cs

@ -676,6 +676,11 @@ public static partial class OpenIddictClientEvents
/// </summary>
public string? BackchannelIdentityToken { get; set; }
/// <summary>
/// Gets or sets the device code to validate, if applicable.
/// </summary>
public string? DeviceCode { get; set; }
/// <summary>
/// Gets or sets the frontchannel access token to validate, if applicable.
/// </summary>
@ -975,6 +980,11 @@ public static partial class OpenIddictClientEvents
/// </summary>
public HashSet<string> Scopes { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets or sets the URI of the device authorization endpoint, if applicable.
/// </summary>
public Uri? DeviceAuthorizationEndpoint { get; set; }
/// <summary>
/// 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
/// </summary>
public string? StateToken { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether a device authorization request should be sent.
/// </summary>
public bool SendDeviceAuthorizationRequest { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether a client assertion
/// token should be generated (and optionally included in the request).
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool GenerateClientAssertionToken { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether the generated client
/// assertion token should be included as part of the request.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool IncludeClientAssertionToken { get; set; }
/// <summary>
/// Gets or sets the generated client assertion token, if applicable.
/// The client assertion token will only be returned if
/// <see cref="IncludeClientAssertionToken"/> is set to <see langword="true"/>.
/// </summary>
public string? ClientAssertionToken { get; set; }
/// <summary>
/// Gets or sets type of the generated client assertion token, if applicable.
/// The client assertion token type will only be returned if
/// <see cref="IncludeClientAssertionToken"/> is set to <see langword="true"/>.
/// </summary>
public string? ClientAssertionTokenType { get; set; }
/// <summary>
/// Gets or sets the principal containing the claims that will be
/// used to create the client assertion token, if applicable.
/// </summary>
public ClaimsPrincipal? ClientAssertionTokenPrincipal { get; set; }
/// <summary>
/// Gets or sets the principal containing the claims that
/// will be used to create the state token, if applicable.
/// </summary>
public ClaimsPrincipal? StateTokenPrincipal { get; set; }
/// <summary>
/// Gets or sets the request sent to the device authorization endpoint, if applicable.
/// </summary>
public OpenIddictRequest? DeviceAuthorizationRequest { get; set; }
/// <summary>
/// Gets or sets the response returned by the device authorization endpoint, if applicable.
/// </summary>
public OpenIddictResponse? DeviceAuthorizationResponse { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether a device
/// code should be extracted from the current context.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool ExtractDeviceCode { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether a user
/// code should be extracted from the current context.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool ExtractUserCode { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether a device code must
/// be resolved for the authentication to be considered valid.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool RequireDeviceCode { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether a user code must
/// be resolved for the authentication to be considered valid.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool RequireUserCode { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether the device code
/// extracted from the current context should be validated.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool ValidateDeviceCode { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether the user code
/// extracted from the current context should be validated.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool ValidateUserCode { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether an invalid device code will
/// cause the authentication demand to be rejected or will be ignored.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool RejectDeviceCode { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether an invalid user code will
/// cause the authentication demand to be rejected or will be ignored.
/// </summary>
/// <remarks>
/// Note: overriding the value of this property is generally not recommended.
/// </remarks>
public bool RejectUserCode { get; set; }
/// <summary>
/// Gets or sets the device code to validate, if applicable.
/// </summary>
public string? DeviceCode { get; set; }
/// <summary>
/// Gets or sets the user code to validate, if applicable.
/// </summary>
public string? UserCode { get; set; }
}
/// <summary>

3
src/OpenIddict.Client/OpenIddictClientExtensions.cs

@ -41,7 +41,10 @@ public static class OpenIddictClientExtensions
builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenNonceValidationEnabled>();
builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenValidated>();
builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenPrincipal>();
builder.Services.TryAddSingleton<RequireChallengeClientAssertionTokenGenerated>();
builder.Services.TryAddSingleton<RequireClientAssertionTokenGenerated>();
builder.Services.TryAddSingleton<RequireDeviceAuthorizationGrantType>();
builder.Services.TryAddSingleton<RequireDeviceAuthorizationRequest>();
builder.Services.TryAddSingleton<RequireFrontchannelAccessTokenValidated>();
builder.Services.TryAddSingleton<RequireFrontchannelIdentityTokenNonceValidationEnabled>();
builder.Services.TryAddSingleton<RequireFrontchannelIdentityTokenValidated>();

52
src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs

@ -96,6 +96,23 @@ public static class OpenIddictClientHandlerFilters
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if no challenge client assertion token is generated.
/// </summary>
public sealed class RequireChallengeClientAssertionTokenGenerated : IOpenIddictClientHandlerFilter<ProcessChallengeContext>
{
/// <inheritdoc/>
public ValueTask<bool> IsActiveAsync(ProcessChallengeContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new(context.GenerateClientAssertionToken);
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if no client assertion token is generated.
/// </summary>
@ -113,6 +130,41 @@ public static class OpenIddictClientHandlerFilters
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if the challenge
/// doesn't correspond to a device authorization code grant operation.
/// </summary>
public sealed class RequireDeviceAuthorizationGrantType : IOpenIddictClientHandlerFilter<ProcessChallengeContext>
{
/// <inheritdoc/>
public ValueTask<bool> IsActiveAsync(ProcessChallengeContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new(context.GrantType is GrantTypes.DeviceCode);
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if no device authorization request is expected to be sent.
/// </summary>
public sealed class RequireDeviceAuthorizationRequest : IOpenIddictClientHandlerFilter<ProcessChallengeContext>
{
/// <inheritdoc/>
public ValueTask<bool> IsActiveAsync(ProcessChallengeContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new(context.SendDeviceAuthorizationRequest);
}
}
/// <summary>
/// Represents a filter that excludes the associated handlers if no frontchannel access token is validated.
/// </summary>

246
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<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; } = ImmutableArray.Create(
/*
* Device authorization response handling:
*/
ValidateWellKnownParameters.Descriptor,
HandleErrorResponse.Descriptor,
ValidateVerificationEndpointUri.Descriptor);
/// <summary>
/// Contains the logic responsible for validating the well-known parameters contained in the device authorization response.
/// </summary>
public sealed class ValidateWellKnownParameters : IOpenIddictClientHandler<HandleDeviceAuthorizationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleDeviceAuthorizationResponseContext>()
.UseSingletonHandler<ValidateWellKnownParameters>()
.SetOrder(int.MinValue + 100_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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
};
}
}
/// <summary>
/// Contains the logic responsible for surfacing potential errors from the device authorization response.
/// </summary>
public sealed class HandleErrorResponse : IOpenIddictClientHandler<HandleDeviceAuthorizationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleDeviceAuthorizationResponseContext>()
.UseSingletonHandler<HandleErrorResponse>()
.SetOrder(ValidateWellKnownParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for validating the verification
/// endpoint URI contained in the device authorization response.
/// </summary>
public sealed class ValidateVerificationEndpointUri : IOpenIddictClientHandler<HandleDeviceAuthorizationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleDeviceAuthorizationResponseContext>()
.UseSingletonHandler<ValidateVerificationEndpointUri>()
.SetOrder(HandleErrorResponse.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for validating the "expires_in"
/// parameter contained in the device authorization response.
/// </summary>
public sealed class ValidateExpiration : IOpenIddictClientHandler<HandleDeviceAuthorizationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleDeviceAuthorizationResponseContext>()
.UseSingletonHandler<ValidateExpiration>()
.SetOrder(ValidateVerificationEndpointUri.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
}
}

117
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
}
}
/// <summary>
/// Contains the logic responsible for extracting the device authorization endpoint URI from the discovery document.
/// </summary>
public sealed class ExtractDeviceAuthorizationEndpoint : IOpenIddictClientHandler<HandleConfigurationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractDeviceAuthorizationEndpoint>()
.SetOrder(ExtractCryptographyEndpoint.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for extracting the logout endpoint URI from the discovery document.
/// </summary>
@ -357,7 +404,7 @@ public static partial class OpenIddictClientHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractLogoutEndpoint>()
.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
}
}
/// <summary>
/// Contains the logic responsible for extracting the authentication methods
/// supported by the device authorization endpoint from the discovery document.
/// </summary>
public sealed class ExtractDeviceAuthorizationEndpointClientAuthenticationMethods : IOpenIddictClientHandler<HandleConfigurationResponseContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractDeviceAuthorizationEndpointClientAuthenticationMethods>()
.SetOrder(ExtractIssuerParameterRequirement.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractTokenEndpointClientAuthenticationMethods>()
.SetOrder(ExtractIssuerParameterRequirement.Descriptor.Order + 1_000)
.SetOrder(ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();

9
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

790
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
/// </summary>
public sealed class ResolveClientRegistrationFromAuthenticationContext : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromAuthenticationContext(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
@ -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
/// </summary>
public sealed class ResolveClientRegistrationFromStateToken : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromStateToken(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
@ -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
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireTokenRequest>()
.UseSingletonHandler<EvaluateValidatedBackchannelTokens>()
.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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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;
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class ResolveValidatedBackchannelTokens : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
@ -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
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireUserinfoRequest>()
.UseSingletonHandler<EvaluateValidatedUserinfoToken>()
.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
/// </summary>
public sealed class ResolveClientRegistrationFromChallengeContext : IOpenIddictClientHandler<ProcessChallengeContext>
{
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromChallengeContext(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
@ -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
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireInteractiveGrantType>()
.UseSingletonHandler<AttachClientId>()
.SetOrder(AttachResponseMode.Descriptor.Order + 1_000)
.Build();
@ -4383,7 +4424,6 @@ public static partial class OpenIddictClientHandlers
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireInteractiveGrantType>()
.UseSingletonHandler<AttachScopes>()
.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
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireInteractiveGrantType>()
.UseSingletonHandler<AttachChallengeParameters>()
.SetOrder(GenerateLoginStateToken.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
@ -4878,6 +4921,512 @@ public static partial class OpenIddictClientHandlers
}
}
/// <summary>
/// Contains the logic responsible for resolving the URI of the device authorization endpoint.
/// </summary>
public sealed class ResolveDeviceAuthorizationEndpoint : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.UseSingletonHandler<ResolveDeviceAuthorizationEndpoint>()
.SetOrder(AttachCustomChallengeParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for determining whether a device authorization request should be sent.
/// </summary>
public sealed class EvaluateDeviceAuthorizationRequest : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.UseSingletonHandler<EvaluateDeviceAuthorizationRequest>()
.SetOrder(ResolveDeviceAuthorizationEndpoint.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessChallengeContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
context.SendDeviceAuthorizationRequest = context.GrantType is GrantTypes.DeviceCode;
return default;
}
}
/// <summary>
/// Contains the logic responsible for attaching the parameters to the device authorization request, if applicable.
/// </summary>
public sealed class AttachDeviceAuthorizationRequestParameters : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireDeviceAuthorizationRequest>()
.UseSingletonHandler<AttachDeviceAuthorizationRequestParameters>()
.SetOrder(EvaluateDeviceAuthorizationRequest.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for selecting the token types that should
/// be generated and optionally sent as part of the challenge demand.
/// </summary>
public sealed class EvaluateGeneratedChallengeClientAssertionToken : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireDeviceAuthorizationRequest>()
.UseSingletonHandler<EvaluateGeneratedChallengeClientAssertionToken>()
.SetOrder(AttachDeviceAuthorizationRequestParameters.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class PrepareChallengeClientAssertionTokenPrincipal : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireChallengeClientAssertionTokenGenerated>()
.UseSingletonHandler<PrepareChallengeClientAssertionTokenPrincipal>()
.SetOrder(EvaluateGeneratedChallengeClientAssertionToken.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for generating a client
/// assertion token for the current challenge operation.
/// </summary>
public sealed class GenerateChallengeClientAssertionToken : IOpenIddictClientHandler<ProcessChallengeContext>
{
private readonly IOpenIddictClientDispatcher _dispatcher;
public GenerateChallengeClientAssertionToken(IOpenIddictClientDispatcher dispatcher)
=> _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireChallengeClientAssertionTokenGenerated>()
.UseScopedHandler<GenerateChallengeClientAssertionToken>()
.SetOrder(PrepareChallengeClientAssertionTokenPrincipal.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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
};
}
}
/// <summary>
/// Contains the logic responsible for attaching the client credentials to the device authorization request, if applicable.
/// </summary>
public sealed class AttachDeviceAuthorizationRequestClientCredentials : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireDeviceAuthorizationRequest>()
.UseSingletonHandler<AttachDeviceAuthorizationRequestClientCredentials>()
.SetOrder(GenerateChallengeClientAssertionToken.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for sending the device authorization request, if applicable.
/// </summary>
public sealed class SendDeviceAuthorizationRequest : IOpenIddictClientHandler<ProcessChallengeContext>
{
private readonly OpenIddictClientService _service;
public SendDeviceAuthorizationRequest(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireDeviceAuthorizationRequest>()
.UseSingletonHandler<SendDeviceAuthorizationRequest>()
.SetOrder(AttachDeviceAuthorizationRequestClientCredentials.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
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;
}
}
}
/// <summary>
/// Contains the logic responsible for determining the set of device authorization tokens to validate.
/// </summary>
public sealed class EvaluateValidatedDeviceAuthorizationTokens : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.UseSingletonHandler<EvaluateValidatedDeviceAuthorizationTokens>()
.SetOrder(SendDeviceAuthorizationRequest.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for resolving the device authorization
/// tokens from the device authorization response, if applicable.
/// </summary>
public sealed class ResolveValidatedDeviceAuthorizationTokens : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireDeviceAuthorizationRequest>()
.UseSingletonHandler<ResolveValidatedDeviceAuthorizationTokens>()
.SetOrder(EvaluateValidatedDeviceAuthorizationTokens.Descriptor.Order + 1_000)
.Build();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Contains the logic responsible for rejecting challenge demands that lack required tokens.
/// </summary>
public sealed class ValidateRequiredDeviceAuthorizationTokens : IOpenIddictClientHandler<ProcessChallengeContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireDeviceAuthorizationRequest>()
.UseSingletonHandler<ValidateRequiredDeviceAuthorizationTokens>()
// 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();
/// <inheritdoc/>
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;
}
}
/// <summary>
/// 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
/// </summary>
public sealed class ResolveClientRegistrationFromSignOutContext : IOpenIddictClientHandler<ProcessSignOutContext>
{
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromSignOutContext(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
@ -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

3
src/OpenIddict.Client/OpenIddictClientRegistration.cs

@ -148,7 +148,8 @@ public sealed class OpenIddictClientRegistration
};
/// <summary>
/// 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.
/// </summary>
public HashSet<string> Scopes { get; } = new(StringComparer.Ordinal);

847
src/OpenIddict.Client/OpenIddictClientService.cs

File diff suppressed because it is too large

6
src/OpenIddict.Server/OpenIddictServerEvents.Discovery.cs

@ -142,6 +142,12 @@ public static partial class OpenIddictServerEvents
/// </summary>
public HashSet<string> CodeChallengeMethods { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets a list of client authentication methods supported by
/// the device endpoint provided by the authorization server.
/// </summary>
public HashSet<string> DeviceEndpointAuthenticationMethods { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets the list of grant types
/// supported by the authorization server.

11
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);

57
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;

3
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

2
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));

14
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")
};
}
}

36
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<Uri>());
});
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()
{

Loading…
Cancel
Save