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), AuthorizationEndpoint = new Uri($""{{ environment.configuration.authorization_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute),
{{~ end ~}} {{~ 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 ~}} {{~ if environment.configuration.token_endpoint ~}}
TokenEndpoint = new Uri($""{{ environment.configuration.token_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute), TokenEndpoint = new Uri($""{{ environment.configuration.token_endpoint | string.replace '\'' '""' }}"", UriKind.Absolute),
{{~ end ~}} {{~ end ~}}
@ -884,6 +888,13 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
{{~ end ~}} {{~ end ~}}
}, },
DeviceAuthorizationEndpointAuthMethodsSupported =
{
{{~ for method in environment.configuration.device_authorization_endpoint_auth_methods_supported ~}}
""{{ method }}"",
{{~ end ~}}
},
TokenEndpointAuthMethodsSupported = TokenEndpointAuthMethodsSupported =
{ {
{{~ for method in environment.configuration.token_endpoint_auth_methods_supported ~}} {{~ for method in environment.configuration.token_endpoint_auth_methods_supported ~}}
@ -946,6 +957,7 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
XElement configuration => new XElement configuration => new
{ {
AuthorizationEndpoint = (string?) configuration.Attribute("AuthorizationEndpoint"), AuthorizationEndpoint = (string?) configuration.Attribute("AuthorizationEndpoint"),
DeviceAuthorizationEndpoint = (string?) configuration.Attribute("DeviceAuthorizationEndpoint"),
TokenEndpoint = (string?) configuration.Attribute("TokenEndpoint"), TokenEndpoint = (string?) configuration.Attribute("TokenEndpoint"),
UserinfoEndpoint = (string?) configuration.Attribute("UserinfoEndpoint"), UserinfoEndpoint = (string?) configuration.Attribute("UserinfoEndpoint"),
@ -987,12 +999,21 @@ public sealed partial class OpenIddictClientWebIntegrationConfiguration
_ => (IList<string>) Array.Empty<string>() _ => (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 TokenEndpointAuthMethodsSupported = configuration.Elements("TokenEndpointAuthMethod").ToList() switch
{ {
{ Count: > 0 } methods => methods.Select(type => (string?) type.Attribute("Value")).ToList(), { Count: > 0 } methods => methods.Select(type => (string?) type.Attribute("Value")).ToList(),
// If no explicit response type was set, assume the provider only supports // If no explicit client authentication method was set, assume the provider only
// flowing the client credentials as part of the token request payload. // supports flowing the client credentials as part of the token request payload.
_ => (IList<string>) new[] { ClientAuthenticationMethods.ClientSecretPost } _ => (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 System.Web.Mvc;
using Microsoft.Owin.Security; using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies; using Microsoft.Owin.Security.Cookies;
using OpenIddict.Client;
using OpenIddict.Client.Owin; using OpenIddict.Client.Owin;
using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants;
@ -15,6 +16,11 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers
{ {
public class AuthenticationController : Controller public class AuthenticationController : Controller
{ {
private readonly OpenIddictClientService _service;
public AuthenticationController(OpenIddictClientService service)
=> _service = service;
[HttpPost, Route("~/login"), ValidateAntiForgeryToken] [HttpPost, Route("~/login"), ValidateAntiForgeryToken]
public ActionResult LogIn(string provider, string returnUrl) 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. // Remove the local authentication cookie before triggering a redirection to the remote server.
context.Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType); context.Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType);
// Resolve the issuer of the user identifier claim stored in the local authentication cookie. // Resolve the provider 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. // If the provider is known to support remote sign-out, ask OpenIddict to initiate a logout request.
var issuer = identity.Claims.Select(claim => claim.Issuer).First(); if (Uri.TryCreate(identity.FindFirst(Claims.AuthorizationServer)?.Value, UriKind.Absolute, out Uri issuer) &&
if (issuer is "https://localhost:44349/") await _service.GetServerConfigurationAsync(issuer) is { EndSessionEndpoint: Uri })
{ {
var properties = new AuthenticationProperties(new Dictionary<string, string> var properties = new AuthenticationProperties(new Dictionary<string, string>
{ {
// Note: when only one client is registered in the client options, // Note: when only one client is registered in the client options,
// setting the issuer property is not required and can be omitted. // 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 // While not required, the specification encourages sending an id_token_hint
// parameter containing an identity token returned by the server for this user. // 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" "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider"
} => true, } => 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. // Applications that use multiple client registrations can filter claims based on the issuer.
{ Type: "bio", Issuer: "https://github.com/" } => true, { 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;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OpenIddict.Client;
using OpenIddict.Client.AspNetCore; using OpenIddict.Client.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants; using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants;
@ -10,6 +11,11 @@ namespace OpenIddict.Sandbox.AspNetCore.Client.Controllers;
public class AuthenticationController : Controller public class AuthenticationController : Controller
{ {
private readonly OpenIddictClientService _service;
public AuthenticationController(OpenIddictClientService service)
=> _service = service;
[HttpPost("~/login"), ValidateAntiForgeryToken] [HttpPost("~/login"), ValidateAntiForgeryToken]
public ActionResult LogIn(string provider, string returnUrl) 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. // Remove the local authentication cookie before triggering a redirection to the remote server.
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Resolve the issuer of the user identifier claim stored in the local authentication cookie. // Resolve the provider 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. // If the provider is known to support remote sign-out, ask OpenIddict to initiate a logout request.
var issuer = identity.Claims.Select(claim => claim.Issuer).First(); if (Uri.TryCreate(identity.FindFirst(Claims.AuthorizationServer)?.Value, UriKind.Absolute, out Uri issuer) &&
if (issuer is "https://localhost:44395/") await _service.GetServerConfigurationAsync(issuer) is { EndSessionEndpoint: Uri })
{ {
var properties = new AuthenticationProperties(new Dictionary<string, string> var properties = new AuthenticationProperties(new Dictionary<string, string>
{ {
// Note: when only one client is registered in the client options, // Note: when only one client is registered in the client options,
// setting the issuer property is not required and can be omitted. // 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 // While not required, the specification encourages sending an id_token_hint
// parameter containing an identity token returned by the server for this user. // 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. // Preserve the basic claims that are necessary for the application to work correctly.
{ Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true, { 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. // Applications that use multiple client registrations can filter claims based on the issuer.
{ Type: "bio", Issuer: "https://github.com/" } => true, { 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 =
{ {
Permissions.Endpoints.Authorization, Permissions.Endpoints.Authorization,
Permissions.Endpoints.Device,
Permissions.Endpoints.Token, Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.DeviceCode,
Permissions.GrantTypes.RefreshToken, Permissions.GrantTypes.RefreshToken,
Permissions.ResponseTypes.Code, Permissions.ResponseTypes.Code,
Permissions.Scopes.Email, 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 OpenIddict.Client;
using Spectre.Console; using Spectre.Console;
using static OpenIddict.Abstractions.OpenIddictConstants; using static OpenIddict.Abstractions.OpenIddictConstants;
@ -37,16 +38,67 @@ public class InteractiveService : BackgroundService
{ {
var provider = await GetSelectedProviderAsync(stoppingToken); var provider = await GetSelectedProviderAsync(stoppingToken);
AnsiConsole.MarkupLine("[cyan]Launching the system browser.[/]");
try try
{ {
// Ask OpenIddict to initiate the authentication flow (typically, by ClaimsPrincipal principal;
// starting the system browser) and wait for the user to complete it.
var (_, _, principal) = await _service.AuthenticateInteractivelyAsync( // Resolve the server configuration and determine the type of flow
provider, cancellationToken: stoppingToken); // 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) 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>() static string Prompt() => AnsiConsole.Prompt(new SelectionPrompt<string>()
.Title("Select the authentication provider you'd like to log in with.") .Title("Select the authentication provider you'd like to log in with.")
.AddChoices("Local", Providers.GitHub, Providers.Twitter)); .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 #if SUPPORTS_TASK_WAIT_ASYNC
return await Task.Run(Prompt, cancellationToken).WaitAsync(cancellationToken); return await task.WaitAsync(cancellationToken);
#else #else
var task = Task.Run(Prompt, cancellationToken);
var source = new TaskCompletionSource<bool>(TaskCreationOptions.None); var source = new TaskCompletionSource<bool>(TaskCreationOptions.None);
using (cancellationToken.Register(static state => ((TaskCompletionSource<bool>) state!).SetResult(true), source)) 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. // Register the OpenIddict client components.
.AddClient(options => .AddClient(options =>
{ {
// Note: this sample uses the authorization code and refresh token // Note: this sample uses the authorization code, device authorization code
// flows, but you can enable the other flows if necessary. // and refresh token flows, but you can enable the other flows if necessary.
options.AllowAuthorizationCodeFlow() options.AllowAuthorizationCodeFlow()
.AllowDeviceCodeFlow()
.AllowRefreshTokenFlow(); .AllowRefreshTokenFlow();
// Register the signing and encryption credentials used to protect // 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 ClaimTypesSupported = "claim_types_supported";
public const string CodeChallengeMethodsSupported = "code_challenge_methods_supported"; public const string CodeChallengeMethodsSupported = "code_challenge_methods_supported";
public const string DeviceAuthorizationEndpoint = "device_authorization_endpoint"; 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 DisplayValuesSupported = "display_values_supported";
public const string EndSessionEndpoint = "end_session_endpoint"; public const string EndSessionEndpoint = "end_session_endpoint";
public const string GrantTypesSupported = "grant_types_supported"; public const string GrantTypesSupported = "grant_types_supported";
@ -325,6 +326,7 @@ public static class OpenIddictConstants
public const string IdentityProvider = "identity_provider"; public const string IdentityProvider = "identity_provider";
public const string IdToken = "id_token"; public const string IdToken = "id_token";
public const string IdTokenHint = "id_token_hint"; public const string IdTokenHint = "id_token_hint";
public const string Interval = "interval";
public const string Iss = "iss"; public const string Iss = "iss";
public const string LoginHint = "login_hint"; public const string LoginHint = "login_hint";
public const string Keys = "keys"; 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"> <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> <value>The issuer attached to the static configuration must be the same as the one configured in the client registration.</value>
</data> </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"> <data name="ID2000" xml:space="preserve">
<value>The security token is missing.</value> <value>The security token is missing.</value>
</data> </data>
@ -1984,6 +2017,15 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId
<data name="ID2166" xml:space="preserve"> <data name="ID2166" xml:space="preserve">
<value>The received authorization response is not valid for this instance of the application.</value> <value>The received authorization response is not valid for this instance of the application.</value>
</data> </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"> <data name="ID4000" xml:space="preserve">
<value>The '{0}' parameter shouldn't be null or empty at this point.</value> <value>The '{0}' parameter shouldn't be null or empty at this point.</value>
</data> </data>
@ -2653,6 +2695,15 @@ This may indicate that the hashed entry is corrupted or malformed.</value>
<data name="ID6215" xml:space="preserve"> <data name="ID6215" xml:space="preserve">
<value>An error occurred while redirecting a protocol activation to the '{Identifier}' instance.</value> <value>An error occurred while redirecting a protocol activation to the '{Identifier}' instance.</value>
</data> </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"> <data name="ID8000" xml:space="preserve">
<value>https://documentation.openiddict.com/errors/{0}</value> <value>https://documentation.openiddict.com/errors/{0}</value>
</data> </data>

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

@ -31,6 +31,16 @@ public sealed class OpenIddictConfiguration
/// </summary> /// </summary>
public HashSet<string> CodeChallengeMethodsSupported { get; } = new(StringComparer.Ordinal); 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> /// <summary>
/// Gets or sets the URI of the end session endpoint. /// Gets or sets the URI of the end session endpoint.
/// </summary> /// </summary>

18
src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs

@ -222,4 +222,22 @@ public class OpenIddictResponse : OpenIddictMessage
get => (string?) GetParameter(OpenIddictConstants.Parameters.UserCode); get => (string?) GetParameter(OpenIddictConstants.Parameters.UserCode);
set => SetParameter(OpenIddictConstants.Parameters.UserCode, value); 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.ComponentModel;
using System.Diagnostics;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
@ -147,6 +148,8 @@ public sealed class OpenIddictClientAspNetCoreHandler : AuthenticationHandler<Op
else 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. // 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. // 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 // 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(); 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). // 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)) principal.SetClaim(Claims.AuthorizationServer, context.Issuer.AbsoluteUri)
.SetClaim(Claims.Private.ProviderName, context.StateTokenPrincipal?.GetClaim(Claims.Private.ProviderName)); .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName);
// Restore or create a new authentication properties collection and populate it. // Restore or create a new authentication properties collection and populate it.
var properties = CreateProperties(context.StateTokenPrincipal); var properties = CreateProperties(context.StateTokenPrincipal);

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

@ -40,12 +40,14 @@ public static partial class OpenIddictClientAspNetCoreHandlers
/* /*
* Authentication processing: * Authentication processing:
*/ */
ValidateAuthenticationType.Descriptor,
ValidateAuthenticationNonce.Descriptor, ValidateAuthenticationNonce.Descriptor,
ResolveRequestForgeryProtection.Descriptor, ResolveRequestForgeryProtection.Descriptor,
/* /*
* Challenge processing: * Challenge processing:
*/ */
ValidateChallengeType.Descriptor,
ResolveHostChallengeProperties.Descriptor, ResolveHostChallengeProperties.Descriptor,
ValidateTransportSecurityRequirementForChallenge.Descriptor, ValidateTransportSecurityRequirementForChallenge.Descriptor,
GenerateLoginCorrelationCookie.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> /// <summary>
/// Contains the logic responsible for rejecting authentication demands that specify an explicit nonce property. /// 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. /// 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> /// <summary>
/// Contains the logic responsible for resolving the context-specific properties and parameters stored in the /// 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. /// 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.ComponentModel;
using System.Diagnostics;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Microsoft.Owin.Security.Infrastructure; using Microsoft.Owin.Security.Infrastructure;
@ -165,6 +166,8 @@ public sealed class OpenIddictClientOwinHandler : AuthenticationHandler<OpenIddi
else 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. // A single main claims-based principal instance can be attached to an authentication ticket.
var principal = context.EndpointType switch var principal = context.EndpointType switch
{ {
@ -185,10 +188,10 @@ public sealed class OpenIddictClientOwinHandler : AuthenticationHandler<OpenIddi
return null; 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). // 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)) principal.SetClaim(Claims.AuthorizationServer, context.Issuer.AbsoluteUri)
.SetClaim(Claims.Private.ProviderName, context.StateTokenPrincipal?.GetClaim(Claims.Private.ProviderName)); .SetClaim(Claims.Private.ProviderName, context.Registration.ProviderName);
// Restore or create a new authentication properties collection and populate it. // Restore or create a new authentication properties collection and populate it.
var properties = CreateProperties(context.StateTokenPrincipal); var properties = CreateProperties(context.StateTokenPrincipal);

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

@ -35,12 +35,14 @@ public static partial class OpenIddictClientOwinHandlers
/* /*
* Authentication processing: * Authentication processing:
*/ */
ValidateAuthenticationType.Descriptor,
ValidateAuthenticationNonce.Descriptor, ValidateAuthenticationNonce.Descriptor,
ResolveRequestForgeryProtection.Descriptor, ResolveRequestForgeryProtection.Descriptor,
/* /*
* Challenge processing: * Challenge processing:
*/ */
ValidateChallengeType.Descriptor,
ResolveHostChallengeProperties.Descriptor, ResolveHostChallengeProperties.Descriptor,
ValidateTransportSecurityRequirementForChallenge.Descriptor, ValidateTransportSecurityRequirementForChallenge.Descriptor,
GenerateLoginCorrelationCookie.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> /// <summary>
/// Contains the logic responsible for rejecting authentication demands that specify an explicit nonce property. /// 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. /// 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> /// <summary>
/// Contains the logic responsible for resolving the context-specific properties and parameters stored in the /// 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. /// 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; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireInteractiveSession>() .AddFilter<RequireInteractiveSession>()
.AddFilter<RequireInteractiveGrantType>()
.UseSingletonHandler<AttachDynamicPortToRedirectUri>() .UseSingletonHandler<AttachDynamicPortToRedirectUri>()
.SetOrder(AttachRedirectUri.Descriptor.Order + 500) .SetOrder(AttachRedirectUri.Descriptor.Order + 500)
.SetType(OpenIddictClientHandlerType.BuiltIn) .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; } public static ImmutableArray<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; }
= ImmutableArray.Create<OpenIddictClientHandlerDescriptor>() = ImmutableArray.Create<OpenIddictClientHandlerDescriptor>()
.AddRange(Device.DefaultHandlers)
.AddRange(Discovery.DefaultHandlers) .AddRange(Discovery.DefaultHandlers)
.AddRange(Exchange.DefaultHandlers) .AddRange(Exchange.DefaultHandlers)
.AddRange(Userinfo.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, AmendGrantTypes.Descriptor,
AmendCodeChallengeMethods.Descriptor, AmendCodeChallengeMethods.Descriptor,
AmendScopes.Descriptor, AmendScopes.Descriptor,
AmendDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor,
AmendTokenEndpointClientAuthenticationMethods.Descriptor, AmendTokenEndpointClientAuthenticationMethods.Descriptor,
AmendEndpoints.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> /// <summary>
/// Contains the logic responsible for amending the client authentication /// Contains the logic responsible for amending the client authentication
/// methods supported by the token endpoint for the providers that require it. /// 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; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<AmendTokenEndpointClientAuthenticationMethods>() .UseSingletonHandler<AmendTokenEndpointClientAuthenticationMethods>()
.SetOrder(ExtractTokenEndpointClientAuthenticationMethods.Descriptor.Order + 500) .SetOrder(AmendDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 500)
.SetType(OpenIddictClientHandlerType.BuiltIn) .SetType(OpenIddictClientHandlerType.BuiltIn)
.Build(); .Build();

1
src/OpenIddict.Client.WebIntegration/OpenIddictClientWebIntegrationHandlers.cs

@ -42,6 +42,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers
IncludeStateParameterInRedirectUri.Descriptor, IncludeStateParameterInRedirectUri.Descriptor,
AttachAdditionalChallengeParameters.Descriptor) AttachAdditionalChallengeParameters.Descriptor)
.AddRange(Authentication.DefaultHandlers) .AddRange(Authentication.DefaultHandlers)
.AddRange(Device.DefaultHandlers)
.AddRange(Discovery.DefaultHandlers) .AddRange(Discovery.DefaultHandlers)
.AddRange(Exchange.DefaultHandlers) .AddRange(Exchange.DefaultHandlers)
.AddRange(Protection.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"> <Provider Name="GitHub" Documentation="https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps">
<Environment Issuer="https://github.com/"> <Environment Issuer="https://github.com/">
<Configuration AuthorizationEndpoint="https://github.com/login/oauth/authorize" <Configuration AuthorizationEndpoint="https://github.com/login/oauth/authorize"
DeviceAuthorizationEndpoint="https://github.com/login/device/code"
TokenEndpoint="https://github.com/login/oauth/access_token" 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> </Environment>
</Provider> </Provider>

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

@ -50,6 +50,27 @@
</xs:complexType> </xs:complexType>
</xs:element> </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:element name="GrantType" minOccurs="0" maxOccurs="10">
<xs:annotation> <xs:annotation>
<xs:documentation>The grant types supported by the environment.</xs:documentation> <xs:documentation>The grant types supported by the environment.</xs:documentation>
@ -67,6 +88,7 @@
<xs:enumeration value="client_credentials" /> <xs:enumeration value="client_credentials" />
<xs:enumeration value="implicit" /> <xs:enumeration value="implicit" />
<xs:enumeration value="refresh_token" /> <xs:enumeration value="refresh_token" />
<xs:enumeration value="urn:ietf:params:oauth:grant-type:device_code" />
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
</xs:attribute> </xs:attribute>
@ -162,6 +184,12 @@
</xs:annotation> </xs:annotation>
</xs:attribute> </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:attribute name="TokenEndpoint" type="xs:string" use="optional">
<xs:annotation> <xs:annotation>
<xs:documentation>The token endpoint offered by the environment.</xs:documentation> <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.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
@ -916,6 +917,15 @@ public sealed class OpenIddictClientBuilder
public OpenIddictClientBuilder AllowClientCredentialsFlow() public OpenIddictClientBuilder AllowClientCredentialsFlow()
=> Configure(options => options.GrantTypes.Add(GrantTypes.ClientCredentials)); => Configure(options => options.GrantTypes.Add(GrantTypes.ClientCredentials));
/// <summary>
/// Enables 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> /// <summary>
/// Enables hybrid flow support. For more information /// Enables hybrid flow support. For more information
/// about this specific OpenID Connect flow, visit /// 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> /// <summary>
/// Gets or sets the access token resolved from the token response. /// Gets or sets the access token resolved from the token response.
/// </summary> /// </summary>
public string? AccessToken { get; set; } public string? AccessToken
{
get => Response.AccessToken;
set => Response.AccessToken = value;
}
/// <summary> /// <summary>
/// Gets or sets the identity token resolved from the token response. /// Gets or sets the identity token resolved from the token response.
/// </summary> /// </summary>
public string? IdentityToken { get; set; } public string? IdentityToken
{
get => Response.IdToken;
set => Response.IdToken = value;
}
/// <summary> /// <summary>
/// Gets or sets the refresh token resolved from the token response. /// Gets or sets the refresh token resolved from the token response.
/// </summary> /// </summary>
public string? RefreshToken { get; set; } public string? RefreshToken
{
get => Response.RefreshToken;
set => Response.RefreshToken = value;
}
/// <summary> /// <summary>
/// Gets or sets the principal containing the claims resolved from the token response. /// Gets or sets the principal containing the claims resolved from the token response.
/// </summary> /// </summary>
[Obsolete]
public ClaimsPrincipal? Principal { get; set; } public ClaimsPrincipal? Principal { get; set; }
} }
} }

145
src/OpenIddict.Client/OpenIddictClientEvents.cs

@ -676,6 +676,11 @@ public static partial class OpenIddictClientEvents
/// </summary> /// </summary>
public string? BackchannelIdentityToken { get; set; } public string? BackchannelIdentityToken { get; set; }
/// <summary>
/// Gets or sets the device code to validate, if applicable.
/// </summary>
public string? DeviceCode { get; set; }
/// <summary> /// <summary>
/// Gets or sets the frontchannel access token to validate, if applicable. /// Gets or sets the frontchannel access token to validate, if applicable.
/// </summary> /// </summary>
@ -975,6 +980,11 @@ public static partial class OpenIddictClientEvents
/// </summary> /// </summary>
public HashSet<string> Scopes { get; } = new(StringComparer.Ordinal); 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> /// <summary>
/// Gets or sets a boolean indicating whether a state token /// Gets or sets a boolean indicating whether a state token
/// should be generated (and optionally included in the request). /// should be generated (and optionally included in the request).
@ -1000,11 +1010,146 @@ public static partial class OpenIddictClientEvents
/// </summary> /// </summary>
public string? StateToken { get; set; } 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> /// <summary>
/// Gets or sets the principal containing the claims that /// Gets or sets the principal containing the claims that
/// will be used to create the state token, if applicable. /// will be used to create the state token, if applicable.
/// </summary> /// </summary>
public ClaimsPrincipal? StateTokenPrincipal { get; set; } 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> /// <summary>

3
src/OpenIddict.Client/OpenIddictClientExtensions.cs

@ -41,7 +41,10 @@ public static class OpenIddictClientExtensions
builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenNonceValidationEnabled>(); builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenNonceValidationEnabled>();
builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenValidated>(); builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenValidated>();
builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenPrincipal>(); builder.Services.TryAddSingleton<RequireBackchannelIdentityTokenPrincipal>();
builder.Services.TryAddSingleton<RequireChallengeClientAssertionTokenGenerated>();
builder.Services.TryAddSingleton<RequireClientAssertionTokenGenerated>(); builder.Services.TryAddSingleton<RequireClientAssertionTokenGenerated>();
builder.Services.TryAddSingleton<RequireDeviceAuthorizationGrantType>();
builder.Services.TryAddSingleton<RequireDeviceAuthorizationRequest>();
builder.Services.TryAddSingleton<RequireFrontchannelAccessTokenValidated>(); builder.Services.TryAddSingleton<RequireFrontchannelAccessTokenValidated>();
builder.Services.TryAddSingleton<RequireFrontchannelIdentityTokenNonceValidationEnabled>(); builder.Services.TryAddSingleton<RequireFrontchannelIdentityTokenNonceValidationEnabled>();
builder.Services.TryAddSingleton<RequireFrontchannelIdentityTokenValidated>(); 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> /// <summary>
/// Represents a filter that excludes the associated handlers if no client assertion token is generated. /// Represents a filter that excludes the associated handlers if no client assertion token is generated.
/// </summary> /// </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> /// <summary>
/// Represents a filter that excludes the associated handlers if no frontchannel access token is validated. /// Represents a filter that excludes the associated handlers if no frontchannel access token is validated.
/// </summary> /// </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, ValidateIssuer.Descriptor,
ExtractAuthorizationEndpoint.Descriptor, ExtractAuthorizationEndpoint.Descriptor,
ExtractCryptographyEndpoint.Descriptor, ExtractCryptographyEndpoint.Descriptor,
ExtractDeviceAuthorizationEndpoint.Descriptor,
ExtractLogoutEndpoint.Descriptor, ExtractLogoutEndpoint.Descriptor,
ExtractTokenEndpoint.Descriptor, ExtractTokenEndpoint.Descriptor,
ExtractUserinfoEndpoint.Descriptor, ExtractUserinfoEndpoint.Descriptor,
@ -33,6 +34,7 @@ public static partial class OpenIddictClientHandlers
ExtractCodeChallengeMethods.Descriptor, ExtractCodeChallengeMethods.Descriptor,
ExtractScopes.Descriptor, ExtractScopes.Descriptor,
ExtractIssuerParameterRequirement.Descriptor, ExtractIssuerParameterRequirement.Descriptor,
ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor,
ExtractTokenEndpointClientAuthenticationMethods.Descriptor, ExtractTokenEndpointClientAuthenticationMethods.Descriptor,
/* /*
@ -94,20 +96,22 @@ public static partial class OpenIddictClientHandlers
=> ((JsonElement) value).ValueKind is JsonValueKind.String, => ((JsonElement) value).ValueKind is JsonValueKind.String,
// The following parameters MUST be formatted as unique strings: // The following parameters MUST be formatted as unique strings:
Metadata.AuthorizationEndpoint or Metadata.AuthorizationEndpoint or
Metadata.EndSessionEndpoint or Metadata.DeviceAuthorizationEndpoint or
Metadata.Issuer or Metadata.EndSessionEndpoint or
Metadata.JwksUri or Metadata.Issuer or
Metadata.TokenEndpoint or Metadata.JwksUri or
Metadata.TokenEndpoint or
Metadata.UserinfoEndpoint Metadata.UserinfoEndpoint
=> ((JsonElement) value).ValueKind is JsonValueKind.String, => ((JsonElement) value).ValueKind is JsonValueKind.String,
// The following parameters MUST be formatted as arrays of strings: // The following parameters MUST be formatted as arrays of strings:
Metadata.CodeChallengeMethodsSupported or Metadata.CodeChallengeMethodsSupported or
Metadata.GrantTypesSupported or Metadata.DeviceAuthorizationEndpointAuthMethodsSupported or
Metadata.ResponseModesSupported or Metadata.GrantTypesSupported or
Metadata.ResponseTypesSupported or Metadata.ResponseModesSupported or
Metadata.ScopesSupported or Metadata.ResponseTypesSupported or
Metadata.ScopesSupported or
Metadata.TokenEndpointAuthMethodsSupported Metadata.TokenEndpointAuthMethodsSupported
=> ((JsonElement) value) is JsonElement element && => ((JsonElement) value) is JsonElement element &&
element.ValueKind is JsonValueKind.Array && ValidateStringArray(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> /// <summary>
/// Contains the logic responsible for extracting the logout endpoint URI from the discovery document. /// Contains the logic responsible for extracting the logout endpoint URI from the discovery document.
/// </summary> /// </summary>
@ -357,7 +404,7 @@ public static partial class OpenIddictClientHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractLogoutEndpoint>() .UseSingletonHandler<ExtractLogoutEndpoint>()
.SetOrder(ExtractCryptographyEndpoint.Descriptor.Order + 1_000) .SetOrder(ExtractDeviceAuthorizationEndpoint.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn) .SetType(OpenIddictClientHandlerType.BuiltIn)
.Build(); .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> /// <summary>
/// Contains the logic responsible for extracting the authentication methods /// Contains the logic responsible for extracting the authentication methods
/// supported by the token endpoint from the discovery document. /// supported by the token endpoint from the discovery document.
@ -728,7 +821,7 @@ public static partial class OpenIddictClientHandlers
public static OpenIddictClientHandlerDescriptor Descriptor { get; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<HandleConfigurationResponseContext>()
.UseSingletonHandler<ExtractTokenEndpointClientAuthenticationMethods>() .UseSingletonHandler<ExtractTokenEndpointClientAuthenticationMethods>()
.SetOrder(ExtractIssuerParameterRequirement.Descriptor.Order + 1_000) .SetOrder(ExtractDeviceAuthorizationEndpointClientAuthenticationMethods.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn) .SetType(OpenIddictClientHandlerType.BuiltIn)
.Build(); .Build();

9
src/OpenIddict.Client/OpenIddictClientHandlers.Exchange.cs

@ -77,7 +77,8 @@ public static partial class OpenIddictClientHandlers
=> ((JsonElement) value).ValueKind is JsonValueKind.String, => ((JsonElement) value).ValueKind is JsonValueKind.String,
// The following parameters MUST be formatted as numeric dates: // 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. // Parameters that are not in the well-known list can be of any type.
_ => true _ => true
@ -116,10 +117,14 @@ public static partial class OpenIddictClientHandlers
context.Reject( context.Reject(
error: context.Response.Error switch 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.InvalidGrant => Errors.InvalidGrant,
Errors.InvalidScope => Errors.InvalidScope, Errors.InvalidScope => Errors.InvalidScope,
Errors.InvalidRequest => Errors.InvalidRequest, Errors.InvalidRequest => Errors.InvalidRequest,
Errors.SlowDown => Errors.SlowDown,
Errors.UnauthorizedClient => Errors.UnauthorizedClient, Errors.UnauthorizedClient => Errors.UnauthorizedClient,
Errors.UnsupportedGrantType => Errors.UnsupportedGrantType, Errors.UnsupportedGrantType => Errors.UnsupportedGrantType,
_ => Errors.ServerError _ => Errors.ServerError

790
src/OpenIddict.Client/OpenIddictClientHandlers.cs

@ -112,6 +112,18 @@ public static partial class OpenIddictClientHandlers
GenerateLoginStateToken.Descriptor, GenerateLoginStateToken.Descriptor,
AttachChallengeParameters.Descriptor, AttachChallengeParameters.Descriptor,
AttachCustomChallengeParameters.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: * Sign-out processing:
@ -135,6 +147,7 @@ public static partial class OpenIddictClientHandlers
AttachErrorParameters.Descriptor) AttachErrorParameters.Descriptor)
.AddRange(Authentication.DefaultHandlers) .AddRange(Authentication.DefaultHandlers)
.AddRange(Device.DefaultHandlers)
.AddRange(Discovery.DefaultHandlers) .AddRange(Discovery.DefaultHandlers)
.AddRange(Exchange.DefaultHandlers) .AddRange(Exchange.DefaultHandlers)
.AddRange(Protection.DefaultHandlers) .AddRange(Protection.DefaultHandlers)
@ -275,7 +288,8 @@ public static partial class OpenIddictClientHandlers
if (context.GrantType is not ( if (context.GrantType is not (
GrantTypes.AuthorizationCode or GrantTypes.ClientCredentials or 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)); throw new InvalidOperationException(SR.FormatID0310(context.GrantType));
} }
@ -285,6 +299,12 @@ public static partial class OpenIddictClientHandlers
throw new InvalidOperationException(SR.FormatID0359(context.GrantType)); 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 (context.GrantType is GrantTypes.Password)
{ {
if (string.IsNullOrEmpty(context.Username)) if (string.IsNullOrEmpty(context.Username))
@ -329,6 +349,11 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public sealed class ResolveClientRegistrationFromAuthenticationContext : IOpenIddictClientHandler<ProcessAuthenticationContext> public sealed class ResolveClientRegistrationFromAuthenticationContext : IOpenIddictClientHandler<ProcessAuthenticationContext>
{ {
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromAuthenticationContext(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary> /// <summary>
/// Gets the default descriptor definition assigned to this handler. /// Gets the default descriptor definition assigned to this handler.
/// </summary> /// </summary>
@ -359,9 +384,7 @@ public static partial class OpenIddictClientHandlers
// Note: if the static registration cannot be found in the options, this may indicate // 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 // 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. // be used to authenticate users. In this case, throw an exception to abort the flow.
context.Registration ??= context.Options.Registrations.Find( context.Registration ??= await _service.GetClientRegistrationAsync(context.Issuer, context.CancellationToken);
registration => registration.Issuer == context.Issuer) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0292));
// Resolve and attach the server configuration to the context if none has been set already. // Resolve and attach the server configuration to the context if none has been set already.
context.Configuration ??= await context.Registration.ConfigurationManager context.Configuration ??= await context.Registration.ConfigurationManager
@ -901,6 +924,11 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public sealed class ResolveClientRegistrationFromStateToken : IOpenIddictClientHandler<ProcessAuthenticationContext> public sealed class ResolveClientRegistrationFromStateToken : IOpenIddictClientHandler<ProcessAuthenticationContext>
{ {
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromStateToken(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary> /// <summary>
/// Gets the default descriptor definition assigned to this handler. /// Gets the default descriptor definition assigned to this handler.
/// </summary> /// </summary>
@ -943,8 +971,7 @@ public static partial class OpenIddictClientHandlers
// the client was removed after the authorization dance started and thus, can no longer // 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. // be used to authenticate users. In this case, throw an exception to abort the flow.
context.Issuer = issuer; context.Issuer = issuer;
context.Registration = context.Options.Registrations.Find(registration => registration.Issuer == issuer) ?? context.Registration = await _service.GetClientRegistrationAsync(issuer, context.CancellationToken);
throw new InvalidOperationException(SR.GetResourceString(SR.ID0292));
// If an explicit provider name was also added, ensure the two values point to the same issuer. // 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); var provider = context.StateTokenPrincipal.GetClaim(Claims.Private.ProviderName);
@ -2110,9 +2137,10 @@ public static partial class OpenIddictClientHandlers
types.Contains(ResponseTypes.Code) types.Contains(ResponseTypes.Code)
=> true, => true,
// For client credentials, resource owner password credentials // For client credentials, device authorization, resource owner password
// and refresh token requests, always send a token request. // credentials and refresh token requests, always send a token request.
GrantTypes.ClientCredentials or GrantTypes.Password or GrantTypes.RefreshToken => true, GrantTypes.ClientCredentials or GrantTypes.DeviceCode or
GrantTypes.Password or GrantTypes.RefreshToken => true,
_ => false _ => false
}; };
@ -2183,6 +2211,14 @@ public static partial class OpenIddictClientHandlers
context.TokenRequest.RedirectUri = context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri); 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. // 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) else if (context.TokenRequest.GrantType is GrantTypes.Password)
{ {
@ -2502,7 +2538,6 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireTokenRequest>()
.UseSingletonHandler<EvaluateValidatedBackchannelTokens>() .UseSingletonHandler<EvaluateValidatedBackchannelTokens>()
.SetOrder(SendTokenRequest.Descriptor.Order + 1_000) .SetOrder(SendTokenRequest.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn) .SetType(OpenIddictClientHandlerType.BuiltIn)
@ -2520,93 +2555,99 @@ public static partial class OpenIddictClientHandlers
context.RequireBackchannelAccessToken, context.RequireBackchannelAccessToken,
context.ValidateBackchannelAccessToken, context.ValidateBackchannelAccessToken,
context.RejectBackchannelAccessToken) = context.GrantType switch context.RejectBackchannelAccessToken) = context.GrantType switch
{ {
// An access token is always returned as part of token responses, independently of // 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. // 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. // 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 // 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 // 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). // can use custom handlers to validate access tokens that use a readable format (e.g JWT).
GrantTypes.AuthorizationCode or GrantTypes.Implicit when GrantTypes.AuthorizationCode or GrantTypes.Implicit when
context.ResponseType?.Split(Separators.Space) is IList<string> types && context.SendTokenRequest &&
types.Contains(ResponseTypes.Code) context.ResponseType?.Split(Separators.Space) is IList<string> types &&
=> (true, true, false, false), types.Contains(ResponseTypes.Code)
// 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
=> (true, true, false, false), => (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.ExtractBackchannelIdentityToken,
context.RequireBackchannelIdentityToken, context.RequireBackchannelIdentityToken,
context.ValidateBackchannelIdentityToken, context.ValidateBackchannelIdentityToken,
context.RejectBackchannelIdentityToken) = context.GrantType switch context.RejectBackchannelIdentityToken) = context.GrantType switch
{ {
// An identity token is always returned as part of token responses for the code and // 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, // hybrid flows when the authorization server supports OpenID Connect. As such,
// a backchannel identity token is only considered required if the negotiated scopes // a backchannel identity token is only considered required if the negotiated scopes
// include "openid", which indicates the initial request was an OpenID Connect request. // include "openid", which indicates the initial request was an OpenID Connect request.
GrantTypes.AuthorizationCode or GrantTypes.Implicit when GrantTypes.AuthorizationCode or GrantTypes.Implicit when
context.ResponseType?.Split(Separators.Space) is IList<string> types && context.SendTokenRequest &&
types.Contains(ResponseTypes.Code) && context.ResponseType?.Split(Separators.Space) is IList<string> types &&
context.StateTokenPrincipal is ClaimsPrincipal principal && types.Contains(ResponseTypes.Code) &&
principal.HasScope(Scopes.OpenId) => (true, true, true, true), 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 // The client credentials, device code and resource owner password credentials grants
// when using them. However, certain server implementations (like OpenIddict) // don't have an equivalent in OpenID Connect so an identity token is typically never
// allow returning it as a non-standard artifact. As such, the identity token // returned when using them. However, certain server implementations (like OpenIddict)
// is not considered required but will always be validated using the same routine // allow returning it as a non-standard artifact. As such, the identity token is not
// (except nonce validation) if it is present in the token response. // considered required but will always be validated using the same routine
GrantTypes.ClientCredentials or GrantTypes.Password => (true, false, true, false), // (except nonce validation) if it is present in the token response.
GrantTypes.ClientCredentials or GrantTypes.DeviceCode or GrantTypes.Password
// An identity token may or may not be returned as part of refresh token responses => (true, false, true, false),
// 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 // An identity token may or may not be returned as part of refresh token responses
// the same routine (except nonce validation) if it is present in the token response. // depending on the policy adopted by the remote authorization server. As such,
GrantTypes.RefreshToken => (true, false, true, false), // 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.
_ => (false, false, false, false) GrantTypes.RefreshToken => (true, false, true, false),
};
_ => (false, false, false, false)
};
(context.ExtractRefreshToken, (context.ExtractRefreshToken,
context.RequireRefreshToken, context.RequireRefreshToken,
context.ValidateRefreshToken, context.ValidateRefreshToken,
context.RejectRefreshToken) = context.GrantType switch context.RejectRefreshToken) = context.GrantType switch
{ {
// A refresh token may be returned as part of token responses, depending on the // 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" // policy enforced by the remote authorization server (e.g the "offline_access"
// scope may be used). Since the requirements will differ between authorization // scope may be used). Since the requirements will differ between authorization
// servers, a refresh token is never considered required by default. // 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 // 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 // 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). // can use custom handlers to validate access tokens that use a readable format (e.g JWT).
GrantTypes.AuthorizationCode or GrantTypes.Implicit when GrantTypes.AuthorizationCode or GrantTypes.Implicit when
context.ResponseType?.Split(Separators.Space) is IList<string> types && context.SendTokenRequest &&
types.Contains(ResponseTypes.Code) context.ResponseType?.Split(Separators.Space) is IList<string> types &&
=> (true, false, false, false), 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 // A refresh token may or may not be returned as part of client credentials,
// on the policy adopted by the remote authorization server. As such, a // device code, resource owner password credentials and refresh token responses
// refresh token is never considered required for such token responses. // depending on the policy adopted by the remote authorization server. As such,
GrantTypes.ClientCredentials or GrantTypes.Password or GrantTypes.RefreshToken // a refresh token is never considered required for such token responses.
=> (true, false, false, false), GrantTypes.ClientCredentials or GrantTypes.DeviceCode or
GrantTypes.Password or GrantTypes.RefreshToken
_ => (false, false, false, false) => (true, false, false, false),
};
_ => (false, false, false, false)
};
return default; return default;
} }
} }
/// <summary> /// <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> /// </summary>
public sealed class ResolveValidatedBackchannelTokens : IOpenIddictClientHandler<ProcessAuthenticationContext> 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)); Debug.Assert(context.TokenResponse is not null, SR.GetResourceString(SR.ID4007));
context.BackchannelAccessToken = context.ExtractBackchannelAccessToken switch context.BackchannelAccessToken = context.ExtractBackchannelAccessToken ? context.TokenResponse.AccessToken : null;
{ context.BackchannelIdentityToken = context.ExtractBackchannelIdentityToken ? context.TokenResponse.IdToken : null;
true => context.TokenResponse.AccessToken, context.RefreshToken = context.ExtractRefreshToken ? context.TokenResponse.RefreshToken : null;
false => null
};
context.BackchannelIdentityToken = context.ExtractBackchannelIdentityToken switch
{
true => context.TokenResponse.IdToken,
false => null
};
context.RefreshToken = context.ExtractRefreshToken switch
{
true => context.TokenResponse.RefreshToken,
false => null
};
return default; return default;
} }
@ -3396,7 +3423,7 @@ public static partial class OpenIddictClientHandlers
// //
// Note: the userinfo endpoint is an optional endpoint and may not be supported. // Note: the userinfo endpoint is an optional endpoint and may not be supported.
GrantTypes.AuthorizationCode or GrantTypes.Implicit or 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 && when context.UserinfoEndpoint is not null &&
(!string.IsNullOrEmpty(context.BackchannelAccessToken) || (!string.IsNullOrEmpty(context.BackchannelAccessToken) ||
!string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true, !string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true,
@ -3518,7 +3545,6 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireUserinfoRequest>()
.UseSingletonHandler<EvaluateValidatedUserinfoToken>() .UseSingletonHandler<EvaluateValidatedUserinfoToken>()
.SetOrder(SendUserinfoRequest.Descriptor.Order + 1_000) .SetOrder(SendUserinfoRequest.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn) .SetType(OpenIddictClientHandlerType.BuiltIn)
@ -3532,12 +3558,19 @@ public static partial class OpenIddictClientHandlers
throw new ArgumentNullException(nameof(context)); throw new ArgumentNullException(nameof(context));
} }
// By default, OpenIddict doesn't require that userinfo be used but userinfo tokens // By default, OpenIddict doesn't require that userinfo tokens be used but
// or responses will be extracted and validated when a userinfo request was sent. // they are extracted and validated when a userinfo request was sent.
(context.ExtractUserinfoToken, (context.ExtractUserinfoToken,
context.RequireUserinfoToken, context.RequireUserinfoToken,
context.ValidateUserinfoToken, context.ValidateUserinfoToken,
context.RejectUserinfoToken) = (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; return default;
} }
@ -3820,7 +3853,8 @@ public static partial class OpenIddictClientHandlers
// supported by OpenIddict and enabled in the client options. // supported by OpenIddict and enabled in the client options.
if (!string.IsNullOrEmpty(context.GrantType)) 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)); 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. // 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) if (context.Options.SigningCredentials.Count is 0)
{ {
throw new InvalidOperationException(SR.GetResourceString(SR.ID0358)); throw new InvalidOperationException(SR.GetResourceString(SR.ID0358));
}
} }
// If a provider name was specified, resolve the corresponding issuer. // If a provider name was specified, resolve the corresponding issuer.
@ -3877,6 +3914,11 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public sealed class ResolveClientRegistrationFromChallengeContext : IOpenIddictClientHandler<ProcessChallengeContext> public sealed class ResolveClientRegistrationFromChallengeContext : IOpenIddictClientHandler<ProcessChallengeContext>
{ {
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromChallengeContext(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary> /// <summary>
/// Gets the default descriptor definition assigned to this handler. /// Gets the default descriptor definition assigned to this handler.
/// </summary> /// </summary>
@ -3895,12 +3937,12 @@ public static partial class OpenIddictClientHandlers
throw new ArgumentNullException(nameof(context)); 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 // 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 // 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. // be used to authenticate users. In this case, throw an exception to abort the flow.
context.Registration ??= context.Options.Registrations.Find( context.Registration ??= await _service.GetClientRegistrationAsync(context.Issuer, context.CancellationToken);
registration => registration.Issuer == context.Issuer) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0292));
// Resolve and attach the server configuration to the context if none has been set already. // Resolve and attach the server configuration to the context if none has been set already.
context.Configuration ??= await context.Registration.ConfigurationManager context.Configuration ??= await context.Registration.ConfigurationManager
@ -4317,7 +4359,6 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireInteractiveGrantType>()
.UseSingletonHandler<AttachClientId>() .UseSingletonHandler<AttachClientId>()
.SetOrder(AttachResponseMode.Descriptor.Order + 1_000) .SetOrder(AttachResponseMode.Descriptor.Order + 1_000)
.Build(); .Build();
@ -4383,7 +4424,6 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireInteractiveGrantType>()
.UseSingletonHandler<AttachScopes>() .UseSingletonHandler<AttachScopes>()
.SetOrder(AttachRedirectUri.Descriptor.Order + 1_000) .SetOrder(AttachRedirectUri.Descriptor.Order + 1_000)
.Build(); .Build();
@ -4403,11 +4443,13 @@ public static partial class OpenIddictClientHandlers
} }
// If the server configuration indicates the identity provider supports OpenID Connect, // 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 // 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. // 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); context.Scopes.Add(Scopes.OpenId);
} }
@ -4783,6 +4825,7 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; } public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>() = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
.AddFilter<RequireInteractiveGrantType>()
.UseSingletonHandler<AttachChallengeParameters>() .UseSingletonHandler<AttachChallengeParameters>()
.SetOrder(GenerateLoginStateToken.Descriptor.Order + 1_000) .SetOrder(GenerateLoginStateToken.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn) .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> /// <summary>
/// Contains the logic responsible for ensuring that the sign-out demand /// Contains the logic responsible for ensuring that the sign-out demand
/// is compatible with the type of the endpoint that handled the request. /// is compatible with the type of the endpoint that handled the request.
@ -4953,6 +5502,11 @@ public static partial class OpenIddictClientHandlers
/// </summary> /// </summary>
public sealed class ResolveClientRegistrationFromSignOutContext : IOpenIddictClientHandler<ProcessSignOutContext> public sealed class ResolveClientRegistrationFromSignOutContext : IOpenIddictClientHandler<ProcessSignOutContext>
{ {
private readonly OpenIddictClientService _service;
public ResolveClientRegistrationFromSignOutContext(OpenIddictClientService service)
=> _service = service ?? throw new ArgumentNullException(nameof(service));
/// <summary> /// <summary>
/// Gets the default descriptor definition assigned to this handler. /// Gets the default descriptor definition assigned to this handler.
/// </summary> /// </summary>
@ -4971,12 +5525,12 @@ public static partial class OpenIddictClientHandlers
throw new ArgumentNullException(nameof(context)); 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 // 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 // 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. // be used to authenticate users. In this case, throw an exception to abort the flow.
context.Registration ??= context.Options.Registrations.Find( context.Registration ??= await _service.GetClientRegistrationAsync(context.Issuer, context.CancellationToken);
registration => registration.Issuer == context.Issuer) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0292));
// Resolve and attach the server configuration to the context if none has been set already. // Resolve and attach the server configuration to the context if none has been set already.
context.Configuration ??= await context.Registration.ConfigurationManager context.Configuration ??= await context.Registration.ConfigurationManager

3
src/OpenIddict.Client/OpenIddictClientRegistration.cs

@ -148,7 +148,8 @@ public sealed class OpenIddictClientRegistration
}; };
/// <summary> /// <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> /// </summary>
public HashSet<string> Scopes { get; } = new(StringComparer.Ordinal); 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> /// </summary>
public HashSet<string> CodeChallengeMethods { get; } = new(StringComparer.Ordinal); 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> /// <summary>
/// Gets the list of grant types /// Gets the list of grant types
/// supported by the authorization server. /// 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.SubjectTypesSupported] = notification.SubjectTypes.ToArray(),
[Metadata.TokenEndpointAuthMethodsSupported] = notification.TokenEndpointAuthenticationMethods.ToArray(), [Metadata.TokenEndpointAuthMethodsSupported] = notification.TokenEndpointAuthenticationMethods.ToArray(),
[Metadata.IntrospectionEndpointAuthMethodsSupported] = notification.IntrospectionEndpointAuthenticationMethods.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) foreach (var metadata in notification.Metadata)
@ -505,6 +506,14 @@ public static partial class OpenIddictServerHandlers
throw new ArgumentNullException(nameof(context)); 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) if (context.IntrospectionEndpoint is not null)
{ {
context.IntrospectionEndpointAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic); 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 (context.AccessTokenPrincipal is not null)
{ {
// If an expiration date was set on the access token principal, return it to the client application. // If an expiration date was set on the access token principal, return it to the client application.
var date = context.AccessTokenPrincipal.GetExpirationDate(); if (context.AccessTokenPrincipal.GetExpirationDate()
if (date.HasValue && date.Value > DateTimeOffset.UtcNow) 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 // 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. // 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); 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())) !scopes.SetEquals(context.Request.GetScopes()))
{ {
context.Response.Scope = string.Join(" ", scopes); context.Response.Scope = string.Join(" ", scopes);
@ -3104,17 +3105,6 @@ public static partial class OpenIddictServerHandlers
if (context.IncludeDeviceCode) if (context.IncludeDeviceCode)
{ {
context.Response.DeviceCode = context.DeviceCode; 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) if (context.IncludeIdentityToken)
@ -3130,18 +3120,37 @@ public static partial class OpenIddictServerHandlers
if (context.IncludeUserCode) if (context.IncludeUserCode)
{ {
context.Response.UserCode = context.UserCode; context.Response.UserCode = context.UserCode;
}
if (OpenIddictHelpers.CreateAbsoluteUri(context.BaseUri, if (context.EndpointType is OpenIddictServerEndpointType.Device)
context.Options.VerificationEndpointUris.FirstOrDefault()) is Uri uri) {
{ var uri = OpenIddictHelpers.CreateAbsoluteUri(
var builder = new UriBuilder(uri) left : context.BaseUri ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)),
{ right: context.Options.VerificationEndpointUris.First());
Query = string.Concat(Parameters.UserCode, "=", context.UserCode)
}; context.Response.VerificationUri = uri.AbsoluteUri;
context.Response[Parameters.VerificationUri] = uri.AbsoluteUri; if (!string.IsNullOrEmpty(context.UserCode))
context.Response[Parameters.VerificationUriComplete] = builder.Uri.AbsoluteUri; {
// 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; 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: // The following claims MUST be formatted as numeric dates:
Claims.ExpiresAt or Claims.IssuedAt or Claims.NotBefore 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. // Claims that are not in the well-known list can be of any type.
_ => true _ => true

2
src/OpenIddict.Validation/OpenIddictValidationHandlers.cs

@ -61,7 +61,7 @@ public static partial class OpenIddictValidationHandlers
throw new ArgumentNullException(nameof(context)); throw new ArgumentNullException(nameof(context));
} }
context.Configuration = await context.Options.ConfigurationManager context.Configuration ??= await context.Options.ConfigurationManager
.GetConfigurationAsync(context.CancellationToken) .GetConfigurationAsync(context.CancellationToken)
.WaitAsync(context.CancellationToken) ?? .WaitAsync(context.CancellationToken) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); 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, /* name: */ Parameters.UserCode,
/* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") /* 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); 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] [Fact]
public async Task HandleConfigurationRequest_ConfiguredGrantTypesAreReturned() public async Task HandleConfigurationRequest_ConfiguredGrantTypesAreReturned()
{ {

Loading…
Cancel
Save