diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs index cbde3e7b..4bda9212 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs @@ -7,10 +7,8 @@ using System.Web; using System.Web.Mvc; using Microsoft.Owin.Security; using Microsoft.Owin.Security.Cookies; -using OpenIddict.Abstractions; using OpenIddict.Client.Owin; using static OpenIddict.Abstractions.OpenIddictConstants; -using static OpenIddict.Client.Owin.OpenIddictClientOwinConstants; namespace OpenIddict.Sandbox.AspNet.Client.Controllers { @@ -55,7 +53,7 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers { // Note: the OWIN host requires appending the #string suffix to indicate // that the "identity_provider" property is a public string parameter. - properties.Dictionary[Parameters.IdentityProvider + PropertyTypes.String] = "github"; + properties.Dictionary[Parameters.IdentityProvider + OpenIddictClientOwinConstants.PropertyTypes.String] = "github"; } // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. @@ -63,11 +61,58 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers return new EmptyResult(); } + [HttpPost, Route("~/logout"), ValidateAntiForgeryToken] + public async Task LogOut(string returnUrl) + { + var context = HttpContext.GetOwinContext(); + + // Retrieve the identity stored in the local authentication cookie. If it's not available, + // this indicate that the user is already logged out locally (or has not logged in yet). + var result = await context.Authentication.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationType); + if (result is not { Identity: ClaimsIdentity identity }) + { + // Only allow local return URLs to prevent open redirect attacks. + return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/"); + } + + // Remove the local authentication cookie before triggering a redirection to the remote server. + context.Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType); + + // Resolve the issuer of the user identifier claim stored in the local authentication cookie. + // If the issuer is known to support remote sign-out, ask OpenIddict to initiate a logout request. + var issuer = identity.Claims.Select(claim => claim.Issuer).First(); + if (issuer is "https://localhost:44349/") + { + var properties = new AuthenticationProperties(new Dictionary + { + // Note: when only one client is registered in the client options, + // setting the issuer property is not required and can be omitted. + [OpenIddictClientOwinConstants.Properties.Issuer] = issuer, + + // While not required, the specification encourages sending an id_token_hint + // parameter containing an identity token returned by the server for this user. + [OpenIddictClientOwinConstants.Properties.IdentityTokenHint] = + result.Properties.Dictionary[OpenIddictClientOwinConstants.Tokens.BackchannelIdentityToken] + }) + { + // Only allow local return URLs to prevent open redirect attacks. + RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/" + }; + + // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. + context.Authentication.SignOut(properties, OpenIddictClientOwinDefaults.AuthenticationType); + return new EmptyResult(); + } + + // Only allow local return URLs to prevent open redirect attacks. + return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/"); + } + // Note: this controller uses the same callback action for all providers // but for users who prefer using a different action per provider, // the following action can be split into separate actions. - [AcceptVerbs("GET", "POST"), Route("~/signin-{provider}")] - public async Task Callback() + [AcceptVerbs("GET", "POST"), Route("~/callback/login/{provider}")] + public async Task LogInCallback() { var context = HttpContext.GetOwinContext(); @@ -123,12 +168,22 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers { Type: Claims.Name } => new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer), + // The antiforgery components require an "identityprovider" claim, which + // is mapped from the authorization server claim returned by OpenIddict. + { Type: Claims.AuthorizationServer } + => new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", + claim.Value, claim.ValueType, claim.Issuer), + _ => claim }) .Where(claim => claim switch { - // Preserve the nameidentifier and name claims. - { Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true, + // Preserve the basic claims that are necessary for the application to work correctly. + { + Type: ClaimTypes.NameIdentifier or + ClaimTypes.Name or + "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider" + } => true, // Applications that use multiple client registrations can filter claims based on the issuer. { Type: "bio", Issuer: "https://github.com/" } => true, @@ -137,11 +192,6 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers _ => false })); - // The antiforgery components require both the ClaimTypes.NameIdentifier and identityprovider claims - // so the latter is manually added using the issuer identity resolved from the remote server. - claims.Add(new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", - result.Identity.GetClaim(Claims.AuthorizationServer))); - var identity = new ClaimsIdentity(claims, authenticationType: CookieAuthenticationDefaults.AuthenticationType, nameType: ClaimTypes.Name, @@ -155,7 +205,11 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers { Key: ".redirect" } => true, // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. - { Key: Tokens.BackchannelAccessToken or Tokens.RefreshToken } => true, + { + Key: OpenIddictClientOwinConstants.Tokens.BackchannelAccessToken or + OpenIddictClientOwinConstants.Tokens.BackchannelIdentityToken or + OpenIddictClientOwinConstants.Tokens.RefreshToken + } => true, // Don't add the other properties to the external cookie. _ => false @@ -166,20 +220,22 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers return Redirect(properties.RedirectUri); } - [AcceptVerbs("GET", "POST"), Route("~/logout")] - public ActionResult LogOut() + // Note: this controller uses the same callback action for all providers + // but for users who prefer using a different action per provider, + // the following action can be split into separate actions. + [AcceptVerbs("GET", "POST"), Route("~/callback/logout/{provider}")] + public async Task LogOutCallback() { var context = HttpContext.GetOwinContext(); - // Ask the cookies middleware to delete the local cookie created when the user agent - // is redirected from the identity provider after a successful authorization flow. - var properties = new AuthenticationProperties - { - RedirectUri = "/" - }; + // Retrieve the data stored by OpenIddict in the state token created when the logout was triggered. + var result = await context.Authentication.AuthenticateAsync(OpenIddictClientOwinDefaults.AuthenticationType); - context.Authentication.SignOut(properties, CookieAuthenticationDefaults.AuthenticationType); - return Redirect(properties.RedirectUri); + // In this sample, the local authentication cookie is always removed before the user agent is redirected + // to the authorization server. Applications that prefer delaying the removal of the local cookie can + // remove the corresponding code from the logout action and remove the authentication cookie in this action. + + return Redirect(result.Properties.RedirectUri); } } } diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs index 9212a7e3..85136371 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs @@ -73,10 +73,14 @@ namespace OpenIddict.Sandbox.AspNet.Client // parameter containing their URL as part of authorization responses. For more information, // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.4. options.SetRedirectionEndpointUris( - "/signin-local", - "/signin-github", - "/signin-google", - "/signin-twitter"); + "/callback/login/local", + "/callback/login/github", + "/callback/login/google", + "/callback/login/twitter"); + + // Enable the post-logout redirection endpoints needed to handle the callback stage. + options.SetPostLogoutRedirectionEndpointUris( + "/callback/logout/local"); // Register the signing and encryption credentials used to protect // sensitive data like the state tokens produced by OpenIddict. @@ -85,7 +89,8 @@ namespace OpenIddict.Sandbox.AspNet.Client // Register the OWIN host and configure the OWIN-specific options. options.UseOwin() - .EnableRedirectionEndpointPassthrough(); + .EnableRedirectionEndpointPassthrough() + .EnablePostLogoutRedirectionEndpointPassthrough(); // Register the System.Net.Http integration. options.UseSystemNetHttp(); @@ -97,8 +102,10 @@ namespace OpenIddict.Sandbox.AspNet.Client ClientId = "mvc", ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", - RedirectUri = new Uri("https://localhost:44378/signin-local", UriKind.Absolute), - Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" } + Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" }, + + RedirectUri = new Uri("https://localhost:44378/callback/login/local", UriKind.Absolute), + PostLogoutRedirectUri = new Uri("https://localhost:44378/callback/logout/local", UriKind.Absolute) }); // Register the Web providers integrations. @@ -107,20 +114,20 @@ namespace OpenIddict.Sandbox.AspNet.Client { ClientId = "c4ade52327b01ddacff3", ClientSecret = "da6bed851b75e317bf6b2cb67013679d9467c122", - RedirectUri = new Uri("https://localhost:44378/signin-github", UriKind.Absolute) + RedirectUri = new Uri("https://localhost:44378/callback/login/github", UriKind.Absolute) }) .AddGoogle(new() { ClientId = "1016114395689-kgtgq2p6dj27d7v6e2kjkoj54dgrrckh.apps.googleusercontent.com", ClientSecret = "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", - RedirectUri = new Uri("https://localhost:44378/signin-google", UriKind.Absolute), + RedirectUri = new Uri("https://localhost:44378/callback/login/google", UriKind.Absolute), Scopes = { Scopes.Profile } }) .AddTwitter(new() { ClientId = "bXgwc0U3N3A3YWNuaWVsdlRmRWE6MTpjaQ", ClientSecret = "VcohOgBp-6yQCurngo4GAyKeZh0D6SUCCSjJgEo1uRzJarjIUS", - RedirectUri = new Uri("https://localhost:44378/signin-twitter", UriKind.Absolute) + RedirectUri = new Uri("https://localhost:44378/callback/login/twitter", UriKind.Absolute) }); }); diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Views/Home/Index.cshtml b/sandbox/OpenIddict.Sandbox.AspNet.Client/Views/Home/Index.cshtml index 6193c6bb..6f35bc29 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Views/Home/Index.cshtml +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Views/Home/Index.cshtml @@ -21,14 +21,18 @@ if (User is ClaimsPrincipal principal && principal.FindFirst(ClaimTypes.NameIdentifier)?.Issuer is "https://localhost:44349/") { -
+ @Html.AntiForgeryToken()
} - Sign out +
+ @Html.AntiForgeryToken() + + +
} else diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs index 96708de9..8800406b 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs @@ -7,10 +7,8 @@ using System.Web; using System.Web.Mvc; using Microsoft.AspNet.Identity; using Microsoft.Owin.Security; -using OpenIddict.Abstractions; using OpenIddict.Client.Owin; using static OpenIddict.Abstractions.OpenIddictConstants; -using static OpenIddict.Client.Owin.OpenIddictClientOwinConstants; namespace OpenIddict.Sandbox.AspNet.Server.Controllers { @@ -19,8 +17,8 @@ namespace OpenIddict.Sandbox.AspNet.Server.Controllers // Note: this controller uses the same callback action for all providers // but for users who prefer using a different action per provider, // the following action can be split into separate actions. - [AcceptVerbs("GET", "POST"), Route("~/signin-{provider}")] - public async Task Callback() + [AcceptVerbs("GET", "POST"), Route("~/callback/login/{provider}")] + public async Task LogInCallback() { var context = HttpContext.GetOwinContext(); @@ -76,12 +74,22 @@ namespace OpenIddict.Sandbox.AspNet.Server.Controllers { Type: Claims.Name } => new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer), + // The antiforgery components require an "identityprovider" claim, which + // is mapped from the authorization server claim returned by OpenIddict. + { Type: Claims.AuthorizationServer } + => new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", + claim.Value, claim.ValueType, claim.Issuer), + _ => claim }) .Where(claim => claim switch { - // Preserve the nameidentifier and name claims. - { Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true, + // Preserve the basic claims that are necessary for the application to work correctly. + { + Type: ClaimTypes.NameIdentifier or + ClaimTypes.Name or + "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider" + } => true, // Applications that use multiple client registrations can filter claims based on the issuer. { Type: "bio", Issuer: "https://github.com/" } => true, @@ -90,11 +98,6 @@ namespace OpenIddict.Sandbox.AspNet.Server.Controllers _ => false })); - // The antiforgery components require both the ClaimTypes.NameIdentifier and identityprovider claims - // so the latter is manually added using the issuer identity resolved from the remote server. - claims.Add(new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", - result.Identity.GetClaim(Claims.AuthorizationServer))); - // Note: when using external authentication providers with ASP.NET Identity, // the user identity MUST be added to the external authentication cookie scheme. var identity = new ClaimsIdentity(claims, @@ -110,7 +113,10 @@ namespace OpenIddict.Sandbox.AspNet.Server.Controllers { Key: ".redirect" } => true, // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. - { Key: Tokens.BackchannelAccessToken or Tokens.RefreshToken } => true, + { + Key: OpenIddictClientOwinConstants.Tokens.BackchannelAccessToken or + OpenIddictClientOwinConstants.Tokens.RefreshToken + } => true, // Don't add the other properties to the external cookie. _ => false diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs index 30f5517c..3cc2078e 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs @@ -72,7 +72,11 @@ namespace OpenIddict.Sandbox.AspNet.Server DisplayName = "MVC client application", RedirectUris = { - new Uri("https://localhost:44378/signin-local") + new Uri("https://localhost:44378/callback/login/local") + }, + PostLogoutRedirectUris = + { + new Uri("https://localhost:44378/callback/logout/local") }, Permissions = { @@ -87,10 +91,6 @@ namespace OpenIddict.Sandbox.AspNet.Server Permissions.Scopes.Roles, Permissions.Prefixes.Scope + "demo_api" }, - PostLogoutRedirectUris = - { - new Uri("https://localhost:44378/Account/SignOutCallback") - }, Requirements = { Requirements.Features.ProofKeyForCodeExchange @@ -129,7 +129,7 @@ namespace OpenIddict.Sandbox.AspNet.Server // address per provider, unless all the registered providers support returning an "iss" // parameter containing their URL as part of authorization responses. For more information, // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.4. - options.SetRedirectionEndpointUris("/signin-github"); + options.SetRedirectionEndpointUris("/callback/login/github"); // Register the signing and encryption credentials used to protect // sensitive data like the state tokens produced by OpenIddict. @@ -149,7 +149,7 @@ namespace OpenIddict.Sandbox.AspNet.Server { ClientId = "c4ade52327b01ddacff3", ClientSecret = "da6bed851b75e317bf6b2cb67013679d9467c122", - RedirectUri = new Uri("https://localhost:44349/signin-github", UriKind.Absolute) + RedirectUri = new Uri("https://localhost:44349/callback/login/github", UriKind.Absolute) }); }) diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs index 4925a883..53403da1 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs @@ -52,11 +52,55 @@ public class AuthenticationController : Controller return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); } + [HttpPost("~/logout"), ValidateAntiForgeryToken] + public async Task LogOut(string returnUrl) + { + // Retrieve the identity stored in the local authentication cookie. If it's not available, + // this indicate that the user is already logged out locally (or has not logged in yet). + var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (result is not { Principal.Identity: ClaimsIdentity identity }) + { + // Only allow local return URLs to prevent open redirect attacks. + return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/"); + } + + // Remove the local authentication cookie before triggering a redirection to the remote server. + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + // Resolve the issuer of the user identifier claim stored in the local authentication cookie. + // If the issuer is known to support remote sign-out, ask OpenIddict to initiate a logout request. + var issuer = identity.Claims.Select(claim => claim.Issuer).First(); + if (issuer is "https://localhost:44395/") + { + var properties = new AuthenticationProperties(new Dictionary + { + // Note: when only one client is registered in the client options, + // setting the issuer property is not required and can be omitted. + [OpenIddictClientAspNetCoreConstants.Properties.Issuer] = issuer, + + // While not required, the specification encourages sending an id_token_hint + // parameter containing an identity token returned by the server for this user. + [OpenIddictClientAspNetCoreConstants.Properties.IdentityTokenHint] = + result.Properties.GetTokenValue(OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken) + }) + { + // Only allow local return URLs to prevent open redirect attacks. + RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/" + }; + + // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. + return SignOut(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + } + + // Only allow local return URLs to prevent open redirect attacks. + return Redirect(Url.IsLocalUrl(returnUrl) ? returnUrl : "/"); + } + // Note: this controller uses the same callback action for all providers // but for users who prefer using a different action per provider, // the following action can be split into separate actions. - [HttpGet("~/signin-{provider}"), HttpPost("~/signin-{provider}")] - public async Task Callback() + [HttpGet("~/callback/login/{provider}"), HttpPost("~/callback/login/{provider}"), IgnoreAntiforgeryToken] + public async Task LogInCallback() { // Retrieve the authorization data validated by OpenIddict as part of the callback handling. var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); @@ -114,7 +158,7 @@ public class AuthenticationController : Controller }) .Where(claim => claim switch { - // Preserve the nameidentifier and name claims. + // Preserve the basic claims that are necessary for the application to work correctly. { Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true, // Applications that use multiple client registrations can filter claims based on the issuer. @@ -136,9 +180,10 @@ public class AuthenticationController : Controller // To make cookies less heavy, tokens that are not used are filtered out before creating the cookie. properties.StoreTokens(result.Properties.GetTokens().Where(token => token switch { - // Preserve the access and refresh tokens returned in the token response, if available. + // Preserve the access, identity and refresh tokens returned in the token response, if available. { - Name: OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or + Name: OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or + OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken or OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken } => true, @@ -154,16 +199,19 @@ public class AuthenticationController : Controller return Redirect(properties.RedirectUri); } - [HttpGet("~/logout"), HttpPost("~/logout")] - public ActionResult LogOut() + // Note: this controller uses the same callback action for all providers + // but for users who prefer using a different action per provider, + // the following action can be split into separate actions. + [HttpGet("~/callback/logout/{provider}"), HttpPost("~/callback/logout/{provider}"), IgnoreAntiforgeryToken] + public async Task LogOutCallback() { - // Ask the cookies middleware to delete the local cookie created when the user agent - // is redirected from the identity provider after a successful authorization flow. - var properties = new AuthenticationProperties - { - RedirectUri = "/" - }; + // Retrieve the data stored by OpenIddict in the state token created when the logout was triggered. + var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + + // In this sample, the local authentication cookie is always removed before the user agent is redirected + // to the authorization server. Applications that prefer delaying the removal of the local cookie can + // remove the corresponding code from the logout action and remove the authentication cookie in this action. - return SignOut(properties, CookieAuthenticationDefaults.AuthenticationScheme); + return Redirect(result!.Properties!.RedirectUri); } } diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/HomeController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/HomeController.cs index e5b8bc5d..aea5f97c 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/HomeController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/HomeController.cs @@ -17,7 +17,7 @@ public class HomeController : Controller [HttpGet("~/")] public ActionResult Index() => View(); - [Authorize, HttpPost("~/")] + [Authorize, HttpPost("~/"), ValidateAntiForgeryToken] public async Task Index(CancellationToken cancellationToken) { var token = await HttpContext.GetTokenAsync(CookieAuthenticationDefaults.AuthenticationScheme, diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs index 3ed21acf..889d1204 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs @@ -81,11 +81,15 @@ public class Startup // parameter containing their URL as part of authorization responses. For more information, // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.4. options.SetRedirectionEndpointUris( - "/signin-local", - "/signin-github", - "/signin-google", - "/signin-reddit", - "/signin-twitter"); + "/callback/login/local", + "/callback/login/github", + "/callback/login/google", + "/callback/login/reddit", + "/callback/login/twitter"); + + // Enable the post-logout redirection endpoints needed to handle the callback stage. + options.SetPostLogoutRedirectionEndpointUris( + "/callback/logout/local"); // Register the signing and encryption credentials used to protect // sensitive data like the state tokens produced by OpenIddict. @@ -95,7 +99,8 @@ public class Startup // Register the ASP.NET Core host and configure the ASP.NET Core-specific options. options.UseAspNetCore() .EnableStatusCodePagesIntegration() - .EnableRedirectionEndpointPassthrough(); + .EnableRedirectionEndpointPassthrough() + .EnablePostLogoutRedirectionEndpointPassthrough(); // Register the System.Net.Http integration. options.UseSystemNetHttp(); @@ -107,8 +112,10 @@ public class Startup ClientId = "mvc", ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", - RedirectUri = new Uri("https://localhost:44381/signin-local", UriKind.Absolute), - Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" } + Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" }, + + RedirectUri = new Uri("https://localhost:44381/callback/login/local", UriKind.Absolute), + PostLogoutRedirectUri = new Uri("https://localhost:44381/callback/logout/local", UriKind.Absolute), }); // Register the Web providers integrations. @@ -117,20 +124,20 @@ public class Startup { ClientId = "c4ade52327b01ddacff3", ClientSecret = "da6bed851b75e317bf6b2cb67013679d9467c122", - RedirectUri = new Uri("https://localhost:44381/signin-github", UriKind.Absolute) + RedirectUri = new Uri("https://localhost:44381/callback/login/github", UriKind.Absolute) }) .AddGoogle(new() { ClientId = "1016114395689-kgtgq2p6dj27d7v6e2kjkoj54dgrrckh.apps.googleusercontent.com", ClientSecret = "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", - RedirectUri = new Uri("https://localhost:44381/signin-google", UriKind.Absolute), + RedirectUri = new Uri("https://localhost:44381/callback/login/google", UriKind.Absolute), Scopes = { Scopes.Profile } }) .AddReddit(new() { ClientId = "vDLNqhrkwrvqHgnoBWF3og", ClientSecret = "Tpab28Dz0upyZLqn7AN3GFD1O-zaAw", - RedirectUri = new Uri("https://localhost:44381/signin-reddit", UriKind.Absolute), + RedirectUri = new Uri("https://localhost:44381/callback/login/reddit", UriKind.Absolute), ProductName = "DemoApp", ProductVersion = "1.0.0" }) @@ -138,7 +145,7 @@ public class Startup { ClientId = "bXgwc0U3N3A3YWNuaWVsdlRmRWE6MTpjaQ", ClientSecret = "VcohOgBp-6yQCurngo4GAyKeZh0D6SUCCSjJgEo1uRzJarjIUS", - RedirectUri = new Uri("https://localhost:44381/signin-twitter", UriKind.Absolute) + RedirectUri = new Uri("https://localhost:44381/callback/login/twitter", UriKind.Absolute) }); }); diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Views/Home/Index.cshtml b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Views/Home/Index.cshtml index efed984b..d511c1a0 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Views/Home/Index.cshtml +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Views/Home/Index.cshtml @@ -20,12 +20,14 @@ if (User.FindFirst(ClaimTypes.NameIdentifier)?.Issuer is "https://localhost:44395/") { -
+
} - Sign out +
+ +
} else diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthenticationController.cs index ac9fd04e..21d4ef21 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthenticationController.cs @@ -12,8 +12,8 @@ public class AuthenticationController : Controller // Note: this controller uses the same callback action for all providers // but for users who prefer using a different action per provider, // the following action can be split into separate actions. - [HttpGet("~/signin-{provider}"), HttpPost("~/signin-{provider}")] - public async Task Callback() + [HttpGet("~/callback/login/{provider}"), HttpPost("~/callback/login/{provider}"), IgnoreAntiforgeryToken] + public async Task LogInCallback() { // Retrieve the authorization data validated by OpenIddict as part of the callback handling. var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); @@ -71,7 +71,7 @@ public class AuthenticationController : Controller }) .Where(claim => claim switch { - // Preserve the nameidentifier and name claims. + // Preserve the basic claims that are necessary for the application to work correctly. { Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true, // Applications that use multiple client registrations can filter claims based on the issuer. diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs index 520b1155..1aacc6d1 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs @@ -74,7 +74,7 @@ public class Startup // address per provider, unless all the registered providers support returning an "iss" // parameter containing their URL as part of authorization responses. For more information, // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.4. - options.SetRedirectionEndpointUris("/signin-github"); + options.SetRedirectionEndpointUris("/callback/login/github"); // Register the signing and encryption credentials used to protect // sensitive data like the state tokens produced by OpenIddict. @@ -95,7 +95,7 @@ public class Startup { ClientId = "c4ade52327b01ddacff3", ClientSecret = "da6bed851b75e317bf6b2cb67013679d9467c122", - RedirectUri = new Uri("https://localhost:44395/signin-github", UriKind.Absolute) + RedirectUri = new Uri("https://localhost:44395/callback/login/github", UriKind.Absolute) }); }) diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs index 34c2bbee..8feb3afa 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs @@ -40,7 +40,11 @@ public class Worker : IHostedService }, RedirectUris = { - new Uri("https://localhost:44381/signin-local") + new Uri("https://localhost:44381/callback/login/local") + }, + PostLogoutRedirectUris = + { + new Uri("https://localhost:44381/callback/logout/local") }, Permissions = { diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 65ffb77e..b2ef4250 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -125,12 +125,14 @@ public static class OpenIddictConstants public const string CreationDate = "oi_crt_dt"; public const string DeviceCodeId = "oi_dvc_id"; public const string DeviceCodeLifetime = "oi_dvc_lft"; + public const string EndpointType = "oi_ept_typ"; public const string ExpirationDate = "oi_exp_dt"; public const string GrantType = "oi_grt_typ"; public const string HostProperties = "oi_hst_props"; public const string IdentityTokenLifetime = "oi_idt_lft"; public const string Issuer = "oi_iss"; public const string Nonce = "oi_nce"; + public const string PostLogoutRedirectUri = "oi_pstlgt_reduri"; public const string Presenter = "oi_prst"; public const string RedirectUri = "oi_reduri"; public const string RefreshTokenLifetime = "oi_reft_lft"; diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index e285d247..879e7cd1 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1316,6 +1316,9 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad The request forgery protection claim cannot be resolved from the state token. + + The endpoint type associated with the state token cannot be resolved. + The security token is missing. @@ -1739,6 +1742,9 @@ Alternatively, you can disable the token storage feature by calling 'services.Ad The client application is not allowed to use the specified identity token hint. + + The specified state token is not suitable for the requested operation. + The '{0}' parameter shouldn't be null or empty at this point. @@ -2344,6 +2350,15 @@ This may indicate that the hashed entry is corrupted or malformed. The logout request was rejected because the identity token used as a hint was issued to a different client. + + The post-logout redirection request was successfully extracted: {Request}. + + + The post-logout redirection request was successfully validated. + + + The post-logout redirection request was successfully validated. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs index 178184e6..2f2a446c 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictConfiguration.cs @@ -31,6 +31,11 @@ public class OpenIddictConfiguration /// public HashSet CodeChallengeMethodsSupported { get; } = new(StringComparer.Ordinal); + /// + /// Gets or sets the address of the end session endpoint. + /// + public Uri? EndSessionEndpoint { get; set; } + /// /// Gets the grant types supported by the server. /// diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreBuilder.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreBuilder.cs index f2453edd..0c01b124 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreBuilder.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreBuilder.cs @@ -47,6 +47,16 @@ public class OpenIddictClientAspNetCoreBuilder return this; } + /// + /// Enables the pass-through mode for the OpenID Connect post-logout redirection endpoint. + /// When the pass-through mode is used, OpenID Connect requests are initially handled by OpenIddict. + /// Once validated, the rest of the request processing pipeline is invoked, so that OpenID Connect requests + /// can be handled at a later stage (in a custom middleware or in a MVC controller, for instance). + /// + /// The . + public OpenIddictClientAspNetCoreBuilder EnablePostLogoutRedirectionEndpointPassthrough() + => Configure(options => options.EnablePostLogoutRedirectionEndpointPassthrough = true); + /// /// Enables the pass-through mode for the OpenID Connect redirection endpoint. /// When the pass-through mode is used, OpenID Connect requests are initially handled by OpenIddict. diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConstants.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConstants.cs index 6443512e..6451a776 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConstants.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreConstants.cs @@ -15,9 +15,11 @@ public static class OpenIddictClientAspNetCoreConstants { public const string AuthorizationCodePrincipal = ".authorization_code_principal"; public const string BackchannelAccessTokenPrincipal = ".backchannel_access_token_principal"; - public const string BackchannelIdentityTokenPrincipal = ".backchannel_id_token_principal"; + public const string BackchannelIdentityTokenPrincipal = ".backchannel_identity_token_principal"; public const string FrontchannelAccessTokenPrincipal = ".frontchannel_access_token_principal"; - public const string FrontchannelIdentityTokenPrincipal = ".frontchannel_id_token_principal"; + public const string FrontchannelIdentityTokenPrincipal = ".frontchannel_identity_token_principal"; + public const string IdentityTokenHint = ".identity_token_hint"; + public const string LoginHint = ".login_hint"; public const string Issuer = ".issuer"; public const string Error = ".error"; public const string ErrorDescription = ".error_description"; diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreExtensions.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreExtensions.cs index d9f7e705..8d2a93dd 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreExtensions.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreExtensions.cs @@ -40,6 +40,7 @@ public static class OpenIddictClientAspNetCoreExtensions // Register the built-in filters used by the default OpenIddict ASP.NET Core client event handlers. builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs index e4c72ccc..0a243351 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandler.cs @@ -19,7 +19,8 @@ namespace OpenIddict.Client.AspNetCore; /// Provides the logic necessary to extract, validate and handle OpenID Connect requests. /// public class OpenIddictClientAspNetCoreHandler : AuthenticationHandler, - IAuthenticationRequestHandler + IAuthenticationRequestHandler, + IAuthenticationSignOutHandler { private readonly IOpenIddictClientDispatcher _dispatcher; private readonly IOpenIddictClientFactory _factory; @@ -150,6 +151,8 @@ public class OpenIddictClientAspNetCoreHandler : AuthenticationHandler context.StateTokenPrincipal, + _ => null }; @@ -374,4 +377,46 @@ public class OpenIddictClientAspNetCoreHandler : AuthenticationHandler protected override Task HandleForbiddenAsync(AuthenticationProperties? properties) => HandleChallengeAsync(properties); + + /// + public async Task SignOutAsync(AuthenticationProperties? properties) + { + var transaction = Context.Features.Get()?.Transaction ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0112)); + + var context = new ProcessSignOutContext(transaction) + { + Principal = new ClaimsPrincipal(new ClaimsIdentity()), + Request = new OpenIddictRequest() + }; + + transaction.Properties[typeof(AuthenticationProperties).FullName!] = properties ?? new AuthenticationProperties(); + + await _dispatcher.DispatchAsync(context); + + if (context.IsRequestHandled || context.IsRequestSkipped) + { + return; + } + + else if (context.IsRejected) + { + var notification = new ProcessErrorContext(transaction) + { + Error = context.Error ?? Errors.InvalidRequest, + ErrorDescription = context.ErrorDescription, + ErrorUri = context.ErrorUri, + Response = new OpenIddictResponse() + }; + + await _dispatcher.DispatchAsync(notification); + + if (notification.IsRequestHandled || context.IsRequestSkipped) + { + return; + } + + throw new InvalidOperationException(SR.GetResourceString(SR.ID0111)); + } + } } diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlerFilters.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlerFilters.cs index 6e7e87c6..6e374d25 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlerFilters.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlerFilters.cs @@ -17,14 +17,13 @@ namespace OpenIddict.Client.AspNetCore; public static class OpenIddictClientAspNetCoreHandlerFilters { /// - /// Represents a filter that excludes the associated handlers if the - /// pass-through mode was not enabled for the authorization endpoint. + /// Represents a filter that excludes the associated handlers if error pass-through was not enabled. /// - public class RequireRedirectionEndpointPassthroughEnabled : IOpenIddictClientHandlerFilter + public class RequireErrorPassthroughEnabled : IOpenIddictClientHandlerFilter { private readonly IOptionsMonitor _options; - public RequireRedirectionEndpointPassthroughEnabled(IOptionsMonitor options) + public RequireErrorPassthroughEnabled(IOptionsMonitor options) => _options = options ?? throw new ArgumentNullException(nameof(options)); public ValueTask IsActiveAsync(BaseContext context) @@ -34,18 +33,35 @@ public static class OpenIddictClientAspNetCoreHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new(_options.CurrentValue.EnableRedirectionEndpointPassthrough); + return new(_options.CurrentValue.EnableErrorPassthrough); } } /// - /// Represents a filter that excludes the associated handlers if error pass-through was not enabled. + /// Represents a filter that excludes the associated handlers if no ASP.NET Core request can be found. /// - public class RequireErrorPassthroughEnabled : IOpenIddictClientHandlerFilter + public class RequireHttpRequest : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(BaseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.Transaction.GetHttpRequest() is not null); + } + } + + /// + /// Represents a filter that excludes the associated handlers if the + /// pass-through mode was not enabled for the post-logout redirection endpoint. + /// + public class RequirePostLogoutRedirectionEndpointPassthroughEnabled : IOpenIddictClientHandlerFilter { private readonly IOptionsMonitor _options; - public RequireErrorPassthroughEnabled(IOptionsMonitor options) + public RequirePostLogoutRedirectionEndpointPassthroughEnabled(IOptionsMonitor options) => _options = options ?? throw new ArgumentNullException(nameof(options)); public ValueTask IsActiveAsync(BaseContext context) @@ -55,15 +71,21 @@ public static class OpenIddictClientAspNetCoreHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new(_options.CurrentValue.EnableErrorPassthrough); + return new(_options.CurrentValue.EnablePostLogoutRedirectionEndpointPassthrough); } } /// - /// Represents a filter that excludes the associated handlers if no ASP.NET Core request can be found. + /// Represents a filter that excludes the associated handlers if the + /// pass-through mode was not enabled for the redirection endpoint. /// - public class RequireHttpRequest : IOpenIddictClientHandlerFilter + public class RequireRedirectionEndpointPassthroughEnabled : IOpenIddictClientHandlerFilter { + private readonly IOptionsMonitor _options; + + public RequireRedirectionEndpointPassthroughEnabled(IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + public ValueTask IsActiveAsync(BaseContext context) { if (context is null) @@ -71,7 +93,7 @@ public static class OpenIddictClientAspNetCoreHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new(context.Transaction.GetHttpRequest() is not null); + return new(_options.CurrentValue.EnableRedirectionEndpointPassthrough); } } diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.Session.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.Session.cs new file mode 100644 index 00000000..d8f7dc1e --- /dev/null +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.Session.cs @@ -0,0 +1,105 @@ +/* + * 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 Microsoft.AspNetCore; +using Microsoft.AspNetCore.WebUtilities; + +namespace OpenIddict.Client.AspNetCore; + +public static partial class OpenIddictClientAspNetCoreHandlers +{ + public static class Session + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Session request processing: + */ + ProcessQueryRequest.Descriptor, + + /* + * Post-logout redirection request extraction: + */ + ExtractGetOrPostRequest.Descriptor, + + /* + * Post-logout redirection request handling: + */ + EnablePassthroughMode.Descriptor, + + /* + * Post-logout redirection response handling: + */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + ProcessPassthroughErrorResponse.Descriptor, + ProcessStatusCodePagesErrorResponse.Descriptor, + ProcessLocalErrorResponse.Descriptor); + + /// + /// Contains the logic responsible for processing authorization requests using 302 redirects. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class ProcessQueryRequest : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(50_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ApplyLogoutRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); + + // Note: while initially not allowed by the core OAuth 2.0 specification, multiple parameters + // with the same name are used by derived drafts like the OAuth 2.0 token exchange specification. + // For consistency, multiple parameters with the same name are also supported by this endpoint. + +#if SUPPORTS_MULTIPLE_VALUES_IN_QUERYHELPERS + var location = QueryHelpers.AddQueryString(context.EndSessionEndpoint, + from parameter in context.Request.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select KeyValuePair.Create(parameter.Key, value)); +#else + var location = context.EndSessionEndpoint; + + foreach (var (key, value) in + from parameter in context.Request.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select (parameter.Key, Value: value)) + { + location = QueryHelpers.AddQueryString(location, key, value); + } +#endif + response.Redirect(location); + context.HandleRequest(); + + return default; + } + } + } +} diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs index b9ea1d04..b3171bc4 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreHandlers.cs @@ -37,14 +37,21 @@ public static partial class OpenIddictClientAspNetCoreHandlers * Authentication processing: */ ValidateCorrelationCookie.Descriptor, - ValidateRedirectUri.Descriptor, + ValidateEndpointUri.Descriptor, /* * Challenge processing: */ - GenerateCorrelationCookie.Descriptor, - ResolveHostChallengeParameters.Descriptor) - .AddRange(Authentication.DefaultHandlers); + ResolveHostChallengeParameters.Descriptor, + GenerateLoginCorrelationCookie.Descriptor, + + /* + * Sign-out processing: + */ + ResolveHostSignOutParameters.Descriptor, + GenerateLogoutCorrelationCookie.Descriptor) + .AddRange(Authentication.DefaultHandlers) + .AddRange(Session.DefaultHandlers); /// /// Contains the logic responsible for inferring the endpoint type from the request address. @@ -76,8 +83,9 @@ public static partial class OpenIddictClientAspNetCoreHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); context.EndpointType = - Matches(request, context.Options.RedirectionEndpointUris) ? OpenIddictClientEndpointType.Redirection : - OpenIddictClientEndpointType.Unknown; + Matches(request, context.Options.PostLogoutRedirectionEndpointUris) ? OpenIddictClientEndpointType.PostLogoutRedirection : + Matches(request, context.Options.RedirectionEndpointUris) ? OpenIddictClientEndpointType.Redirection : + OpenIddictClientEndpointType.Unknown; return default; @@ -215,8 +223,8 @@ public static partial class OpenIddictClientAspNetCoreHandlers } /// - /// Contains the logic responsible for validating the correlation cookie that serves as a - /// protection against state token injection, forged requests and session fixation attacks. + /// Contains the logic responsible for validating the correlation cookie that serves as a protection + /// against state token injection, forged requests, denial of service and session fixation attacks. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// public class ValidateCorrelationCookie : IOpenIddictClientHandler @@ -301,10 +309,10 @@ public static partial class OpenIddictClientAspNetCoreHandlers } /// - /// Contains the logic responsible for comparing the current request URL to the redirect_uri stored in the state token. + /// Contains the logic responsible for comparing the current request URL to the expected URL stored in the state token. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// - public class ValidateRedirectUri : IOpenIddictClientHandler + public class ValidateEndpointUri : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. @@ -313,7 +321,7 @@ public static partial class OpenIddictClientAspNetCoreHandlers = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() .AddFilter() - .UseSingletonHandler() + .UseSingletonHandler() .SetOrder(ValidateCorrelationCookie.Descriptor.Order + 500) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -333,10 +341,29 @@ public static partial class OpenIddictClientAspNetCoreHandlers var request = context.Transaction.GetHttpRequest() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); - // Try to resolve the original redirect_uri from the state token. If it cannot be resolved, - // this likely means the authorization request was sent without a redirect_uri attached. - if (!Uri.TryCreate(context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri), - UriKind.Absolute, out Uri? address)) + // Resolve the endpoint type allowed to be used with the state token. + if (!Enum.TryParse(context.StateTokenPrincipal.GetClaim(Claims.Private.EndpointType), + ignoreCase: true, out OpenIddictClientEndpointType type)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0340)); + } + + // Resolve the endpoint URI from either the redirect_uri or post_logout_redirect_uri + // depending on the type of endpoint meant to be used with the specified state token. + var value = type switch + { + OpenIddictClientEndpointType.PostLogoutRedirection => + context.StateTokenPrincipal.GetClaim(Claims.Private.PostLogoutRedirectUri), + + OpenIddictClientEndpointType.Redirection => + context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri), + + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0340)) + }; + + // If the endpoint URI cannot be resolved, this likely means the authorization or + // logout request was sent without a redirect_uri/post_logout_redirect_uri attached. + if (string.IsNullOrEmpty(value)) { return default; } @@ -345,17 +372,18 @@ public static partial class OpenIddictClientAspNetCoreHandlers var uri = new Uri(request.Scheme + Uri.SchemeDelimiter + request.Host + request.PathBase + request.Path, UriKind.Absolute); - // Compare the current HTTP request address to the original redirect_uri. If the two don't + // Compare the current HTTP request address to the original endpoint URI. If the two don't // match, this may indicate a mix-up attack. While the authorization server is expected to // abort the authorization flow by rejecting the token request that may be eventually sent - // with the original redirect_uri, many servers are known to incorrectly implement this - // redirect_uri validation logic. This check also offers limited protection as it cannot + // with the original endpoint URI, many servers are known to incorrectly implement this + // endpoint URI validation logic. This check also offers limited protection as it cannot // prevent the authorization code from being leaked to a malicious authorization server. - // By comparing the redirect_uri directly in the client, a first layer of protection is + // By comparing the endpoint URI directly in the client, a first layer of protection is // provided independently of whether the authorization server will enforce this check. // // See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-19#section-4.4.2.2 // for more information. + var address = new Uri(value, UriKind.Absolute); if (uri != new UriBuilder(address) { Query = null }.Uri) { context.Reject( @@ -366,9 +394,9 @@ public static partial class OpenIddictClientAspNetCoreHandlers return default; } - // Ensure all the query string parameters that were part of the original redirect_uri + // Ensure all the query string parameters that were part of the original endpoint URI // are present in the current request (parameters that were not part of the original - // redirect_uri are assumed to be authorization response parameters and are ignored). + // endpoint URI are assumed to be authorization response parameters and are ignored). if (!string.IsNullOrEmpty(address.Query) && QueryHelpers.ParseQuery(address.Query) .Any(parameter => request.Query[parameter.Key] != parameter.Value)) { @@ -436,6 +464,20 @@ public static partial class OpenIddictClientAspNetCoreHandlers context.TargetLinkUri = properties.RedirectUri; } + // If an identity token hint was specified, attach it to the context. + if (properties.Items.TryGetValue(Properties.IdentityTokenHint, out string? token) && + !string.IsNullOrEmpty(token)) + { + context.IdentityTokenHint = token; + } + + // If a login hint was specified, attach it to the context. + if (properties.Items.TryGetValue(Properties.LoginHint, out string? hint) && + !string.IsNullOrEmpty(hint)) + { + context.LoginHint = hint; + } + // Preserve the host properties in the principal. if (properties.Items.Count is not 0) { @@ -470,11 +512,11 @@ public static partial class OpenIddictClientAspNetCoreHandlers /// protection against state token injection, forged requests and session fixation attacks. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// - public class GenerateCorrelationCookie : IOpenIddictClientHandler + public class GenerateLoginCorrelationCookie : IOpenIddictClientHandler { private readonly IOptionsMonitor _options; - public GenerateCorrelationCookie(IOptionsMonitor options) + public GenerateLoginCorrelationCookie(IOptionsMonitor options) => _options = options ?? throw new ArgumentNullException(nameof(options)); /// @@ -483,8 +525,8 @@ public static partial class OpenIddictClientAspNetCoreHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() - .AddFilter() - .UseSingletonHandler() + .AddFilter() + .UseSingletonHandler() .SetOrder(AttachChallengeParameters.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -538,6 +580,174 @@ public static partial class OpenIddictClientAspNetCoreHandlers } } + /// + /// Contains the logic responsible for resolving the additional sign-out parameters stored in the ASP.NET + /// Core authentication properties specified by the application that triggered the sign-out operation. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class ResolveHostSignOutParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateSignOutDemand.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName!); + if (properties is null) + { + return default; + } + + // If an issuer was explicitly set, update the sign-out context to use it. + if (properties.Items.TryGetValue(Properties.Issuer, out string? issuer) && !string.IsNullOrEmpty(issuer)) + { + // Ensure the issuer set by the application is a valid absolute URI. + if (!Uri.TryCreate(issuer, UriKind.Absolute, out Uri? uri) || !uri.IsWellFormedOriginalString()) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0306)); + } + + context.Issuer = uri; + } + + // If a return URL was specified, use it as the target_link_uri claim. + if (!string.IsNullOrEmpty(properties.RedirectUri)) + { + context.TargetLinkUri = properties.RedirectUri; + } + + // If an identity token hint was specified, attach it to the context. + if (properties.Items.TryGetValue(Properties.IdentityTokenHint, out string? token) && + !string.IsNullOrEmpty(token)) + { + context.IdentityTokenHint = token; + } + + // If a login hint was specified, attach it to the context. + if (properties.Items.TryGetValue(Properties.LoginHint, out string? hint) && + !string.IsNullOrEmpty(hint)) + { + context.LoginHint = hint; + } + + // Preserve the host properties in the principal. + if (properties.Items.Count is not 0) + { + context.Principal.SetClaim(Claims.Private.HostProperties, properties.Items); + } + + foreach (var parameter in properties.Parameters) + { + context.Parameters[parameter.Key] = parameter.Value switch + { + OpenIddictParameter value => value, + JsonElement value => new OpenIddictParameter(value), + bool value => new OpenIddictParameter(value), + int value => new OpenIddictParameter(value), + long value => new OpenIddictParameter(value), + string value => new OpenIddictParameter(value), + string[] value => new OpenIddictParameter(value), + +#if SUPPORTS_JSON_NODES + JsonNode value => new OpenIddictParameter(value), +#endif + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0115)) + }; + } + + return default; + } + } + + /// + /// Contains the logic responsible for creating a correlation cookie that serves as a + /// protection against state token injection, forged requests and denial of service attacks. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. + /// + public class GenerateLogoutCorrelationCookie : IOpenIddictClientHandler + { + private readonly IOptionsMonitor _options; + + public GenerateLogoutCorrelationCookie(IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachChallengeParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: using a correlation cookie serves as an injection/antiforgery protection as the request + // will always be rejected if a cookie corresponding to the request forgery protection claim + // persisted in the state token cannot be found. This protection is considered essential + // in OpenIddict and cannot be disabled via the options. Applications that prefer implementing + // a different protection strategy can set the request forgery protection claim to null or + // remove this handler from the handlers list and add a custom one using a different approach. + + if (string.IsNullOrEmpty(context.RequestForgeryProtection)) + { + return default; + } + + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // This handler only applies to ASP.NET Core requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetHttpRequest()?.HttpContext.Response ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0114)); + + // Resolve the cookie builder from the OWIN integration options. + var builder = _options.CurrentValue.CookieBuilder; + + // Unless a value was explicitly set in the options, use the expiration date + // of the state token principal as the expiration date of the correlation cookie. + var options = builder.Build(response.HttpContext); + options.Expires ??= context.StateTokenPrincipal.GetExpirationDate(); + + // Compute a collision-resistant and hard-to-guess cookie name based on the prefix set + // in the options and the random request forgery protection claim generated earlier. + var name = new StringBuilder(builder.Name) + .Append(Separators.Dot) + .Append(context.RequestForgeryProtection) + .ToString(); + + // Add the correlation cookie to the response headers. + response.Cookies.Append(name, "v1", options); + + return default; + } + } + /// /// Contains the logic responsible for enabling the pass-through mode for the received request. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. diff --git a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreOptions.cs b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreOptions.cs index f11ec658..d84fcb13 100644 --- a/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreOptions.cs +++ b/src/OpenIddict.Client.AspNetCore/OpenIddictClientAspNetCoreOptions.cs @@ -13,6 +13,14 @@ namespace OpenIddict.Client.AspNetCore; /// public class OpenIddictClientAspNetCoreOptions : AuthenticationSchemeOptions { + /// + /// Gets or sets a boolean indicating whether the pass-through mode is enabled for the post-logout redirection endpoint. + /// When the pass-through mode is used, OpenID Connect requests are initially handled by OpenIddict. + /// Once validated, the rest of the request processing pipeline is invoked, so that OpenID Connect requests + /// can be handled at a later stage (in a custom middleware or in a MVC controller, for instance). + /// + public bool EnablePostLogoutRedirectionEndpointPassthrough { get; set; } + /// /// Gets or sets a boolean indicating whether the pass-through mode is enabled for the redirection endpoint. /// When the pass-through mode is used, OpenID Connect requests are initially handled by OpenIddict. diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinBuilder.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinBuilder.cs index 974cd502..2ede1cd2 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinBuilder.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinBuilder.cs @@ -47,6 +47,16 @@ public class OpenIddictClientOwinBuilder return this; } + /// + /// Enables the pass-through mode for the OpenID Connect post-logout redirection endpoint. + /// When the pass-through mode is used, OpenID Connect requests are initially handled by OpenIddict. + /// Once validated, the rest of the request processing pipeline is invoked, so that OpenID Connect requests + /// can be handled at a later stage (in a custom middleware or in a MVC controller, for instance). + /// + /// The . + public OpenIddictClientOwinBuilder EnablePostLogoutRedirectionEndpointPassthrough() + => Configure(options => options.EnablePostLogoutRedirectionEndpointPassthrough = true); + /// /// Enables the pass-through mode for the OpenID Connect redirection endpoint. /// When the pass-through mode is used, OpenID Connect requests are initially handled by OpenIddict. diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinConstants.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinConstants.cs index 7986c47c..808893b8 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinConstants.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinConstants.cs @@ -23,9 +23,11 @@ public static class OpenIddictClientOwinConstants { public const string AuthorizationCodePrincipal = ".authorization_code_principal"; public const string BackchannelAccessTokenPrincipal = ".backchannel_access_token_principal"; - public const string BackchannelIdentityTokenPrincipal = ".backchannel_id_token_principal"; + public const string BackchannelIdentityTokenPrincipal = ".backchannel_identity_token_principal"; public const string FrontchannelAccessTokenPrincipal = ".frontchannel_access_token_principal"; - public const string FrontchannelIdentityTokenPrincipal = ".frontchannel_id_token_principal"; + public const string FrontchannelIdentityTokenPrincipal = ".frontchannel_identity_token_principal"; + public const string IdentityTokenHint = ".identity_token_hint"; + public const string LoginHint = ".login_hint"; public const string Issuer = ".issuer"; public const string Error = ".error"; public const string ErrorDescription = ".error_description"; diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinExtensions.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinExtensions.cs index d0927dd4..24a4706f 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinExtensions.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinExtensions.cs @@ -43,6 +43,7 @@ public static class OpenIddictClientOwinExtensions // Register the built-in filters used by the default OpenIddict OWIN client event handlers. builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); // Register the option initializer used by the OpenIddict OWIN client integration services. diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs index 2d01fd37..63bb2c15 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandler.cs @@ -166,6 +166,8 @@ public class OpenIddictClientOwinHandler : AuthenticationHandler context.StateTokenPrincipal, + _ => null }; @@ -316,5 +318,47 @@ public class OpenIddictClientOwinHandler : AuthenticationHandler(typeof(OpenIddictClientTransaction).FullName) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0315)); + + transaction.Properties[typeof(AuthenticationProperties).FullName!] = signout.Properties ?? new AuthenticationProperties(); + + var context = new ProcessSignOutContext(transaction) + { + Principal = new ClaimsPrincipal(new ClaimsIdentity()), + Request = new OpenIddictRequest() + }; + + await _dispatcher.DispatchAsync(context); + + if (context.IsRequestHandled || context.IsRequestSkipped) + { + return; + } + + else if (context.IsRejected) + { + var notification = new ProcessErrorContext(transaction) + { + Error = context.Error ?? Errors.InvalidRequest, + ErrorDescription = context.ErrorDescription, + ErrorUri = context.ErrorUri, + Response = new OpenIddictResponse() + }; + + await _dispatcher.DispatchAsync(notification); + + if (notification.IsRequestHandled || context.IsRequestSkipped) + { + return; + } + + throw new InvalidOperationException(SR.GetResourceString(SR.ID0111)); + } + } } } diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlerFilters.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlerFilters.cs index d6c42974..151c5792 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlerFilters.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlerFilters.cs @@ -18,7 +18,29 @@ public static class OpenIddictClientOwinHandlerFilters { /// /// Represents a filter that excludes the associated handlers if the - /// pass-through mode was not enabled for the authorization endpoint. + /// pass-through mode was not enabled for the post-logout redirection endpoint. + /// + public class RequirePostLogoutRedirectionEndpointPassthroughEnabled : IOpenIddictClientHandlerFilter + { + private readonly IOptionsMonitor _options; + + public RequirePostLogoutRedirectionEndpointPassthroughEnabled(IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + + public ValueTask IsActiveAsync(BaseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(_options.CurrentValue.EnablePostLogoutRedirectionEndpointPassthrough); + } + } + + /// + /// Represents a filter that excludes the associated handlers if the + /// pass-through mode was not enabled for the redirection endpoint. /// public class RequireRedirectionEndpointPassthroughEnabled : IOpenIddictClientHandlerFilter { diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.Session.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.Session.cs new file mode 100644 index 00000000..f34c60a7 --- /dev/null +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.Session.cs @@ -0,0 +1,93 @@ +/* + * 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 Owin; + +namespace OpenIddict.Client.Owin; + +public static partial class OpenIddictClientOwinHandlers +{ + public static class Session + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Session request processing: + */ + ProcessQueryRequest.Descriptor, + + /* + * Post-logout redirection request extraction: + */ + ExtractGetOrPostRequest.Descriptor, + + /* + * Post-logout redirection request handling: + */ + EnablePassthroughMode.Descriptor, + + /* + * Post-logout redirection response handling: + */ + AttachHttpResponseCode.Descriptor, + AttachCacheControlHeader.Descriptor, + ProcessPassthroughErrorResponse.Descriptor, + ProcessLocalErrorResponse.Descriptor); + + /// + /// Contains the logic responsible for processing authorization requests using 302 redirects. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class ProcessQueryRequest : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(50_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ApplyLogoutRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // This handler only applies to OWIN requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetOwinRequest()?.Context.Response ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); + + var location = context.EndSessionEndpoint; + + // Note: while initially not allowed by the core OAuth 2.0 specification, multiple parameters + // with the same name are used by derived drafts like the OAuth 2.0 token exchange specification. + // For consistency, multiple parameters with the same name are also supported by this endpoint. + foreach (var (key, value) in + from parameter in context.Request.GetParameters() + let values = (string?[]?) parameter.Value + where values is not null + from value in values + where !string.IsNullOrEmpty(value) + select (parameter.Key, Value: value)) + { + location = WebUtilities.AddQueryString(location, key, value); + } + + response.Redirect(location); + context.HandleRequest(); + + return default; + } + } + } +} diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs index b3d97411..e3662273 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs @@ -33,14 +33,21 @@ public static partial class OpenIddictClientOwinHandlers * Authentication processing: */ ValidateCorrelationCookie.Descriptor, - ValidateRedirectUri.Descriptor, + ValidateEndpointUri.Descriptor, /* * Challenge processing: */ - GenerateCorrelationCookie.Descriptor, - ResolveHostChallengeParameters.Descriptor) - .AddRange(Authentication.DefaultHandlers); + ResolveHostChallengeParameters.Descriptor, + GenerateLoginCorrelationCookie.Descriptor, + + /* + * Sign-out processing: + */ + ResolveHostSignOutParameters.Descriptor, + GenerateLogoutCorrelationCookie.Descriptor) + .AddRange(Authentication.DefaultHandlers) + .AddRange(Session.DefaultHandlers); /// /// Contains the logic responsible for inferring the endpoint type from the request address. @@ -75,8 +82,9 @@ public static partial class OpenIddictClientOwinHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); context.EndpointType = - Matches(request, context.Options.RedirectionEndpointUris) ? OpenIddictClientEndpointType.Redirection : - OpenIddictClientEndpointType.Unknown; + Matches(request, context.Options.PostLogoutRedirectionEndpointUris) ? OpenIddictClientEndpointType.PostLogoutRedirection : + Matches(request, context.Options.RedirectionEndpointUris) ? OpenIddictClientEndpointType.Redirection : + OpenIddictClientEndpointType.Unknown; return default; @@ -309,10 +317,10 @@ public static partial class OpenIddictClientOwinHandlers } /// - /// Contains the logic responsible for comparing the current request URL to the redirect_uri stored in the state token. + /// Contains the logic responsible for comparing the current request URL to the expected URL stored in the state token. /// Note: this handler is not used when the OpenID Connect request is not initially handled by ASP.NET Core. /// - public class ValidateRedirectUri : IOpenIddictClientHandler + public class ValidateEndpointUri : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. @@ -321,7 +329,7 @@ public static partial class OpenIddictClientOwinHandlers = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() .AddFilter() - .UseSingletonHandler() + .UseSingletonHandler() .SetOrder(ValidateCorrelationCookie.Descriptor.Order + 500) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -341,10 +349,29 @@ public static partial class OpenIddictClientOwinHandlers var request = context.Transaction.GetOwinRequest() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); - // Try to resolve the original redirect_uri from the state token. If it cannot be resolved, - // this likely means the authorization request was sent without a redirect_uri attached. - if (!Uri.TryCreate(context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri), - UriKind.Absolute, out Uri? address)) + // Resolve the endpoint type allowed to be used with the state token. + if (!Enum.TryParse(context.StateTokenPrincipal.GetClaim(Claims.Private.EndpointType), + ignoreCase: true, out OpenIddictClientEndpointType type)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0340)); + } + + // Resolve the endpoint address from either the redirect_uri or post_logout_redirect_uri + // depending on the type of endpoint allowed to receive the specified state token. + var value = type switch + { + OpenIddictClientEndpointType.PostLogoutRedirection => + context.StateTokenPrincipal.GetClaim(Claims.Private.PostLogoutRedirectUri), + + OpenIddictClientEndpointType.Redirection => + context.StateTokenPrincipal.GetClaim(Claims.Private.RedirectUri), + + _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0340)) + }; + + // If the endpoint URI cannot be resolved, this likely means the authorization or + // logout request was sent without a redirect_uri/post_logout_redirect_uri attached. + if (string.IsNullOrEmpty(value)) { return default; } @@ -353,17 +380,18 @@ public static partial class OpenIddictClientOwinHandlers var uri = new Uri(request.Scheme + Uri.SchemeDelimiter + request.Host + request.PathBase + request.Path, UriKind.Absolute); - // Compare the current HTTP request address to the original redirect_uri. If the two don't + // Compare the current HTTP request address to the original endpoint URI. If the two don't // match, this may indicate a mix-up attack. While the authorization server is expected to // abort the authorization flow by rejecting the token request that may be eventually sent - // with the original redirect_uri, many servers are known to incorrectly implement this - // redirect_uri validation logic. This check also offers limited protection as it cannot + // with the original endpoint URI, many servers are known to incorrectly implement this + // endpoint URI validation logic. This check also offers limited protection as it cannot // prevent the authorization code from being leaked to a malicious authorization server. - // By comparing the redirect_uri directly in the client, a first layer of protection is + // By comparing the endpoint URI directly in the client, a first layer of protection is // provided independently of whether the authorization server will enforce this check. // // See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-19#section-4.4.2.2 // for more information. + var address = new Uri(value, UriKind.Absolute); if (uri != new UriBuilder(address) { Query = null }.Uri) { context.Reject( @@ -374,9 +402,9 @@ public static partial class OpenIddictClientOwinHandlers return default; } - // Ensure all the query string parameters that were part of the original redirect_uri + // Ensure all the query string parameters that were part of the original endpoint URI // are present in the current request (parameters that were not part of the original - // redirect_uri are assumed to be authorization response parameters and are ignored). + // endpoint URI are assumed to be authorization response parameters and are ignored). if (!string.IsNullOrEmpty(address.Query) && OpenIddictHelpers.ParseQuery(address.Query) // Note: ignore parameters that only include empty values // to match the logic used by OWIN for IOwinRequest.Query. @@ -447,6 +475,20 @@ public static partial class OpenIddictClientOwinHandlers context.TargetLinkUri = properties.RedirectUri; } + // If an identity token hint was specified, attach it to the context. + if (properties.Dictionary.TryGetValue(Properties.IdentityTokenHint, out string? token) && + !string.IsNullOrEmpty(token)) + { + context.IdentityTokenHint = token; + } + + // If a login hint was specified, attach it to the context. + if (properties.Dictionary.TryGetValue(Properties.LoginHint, out string? hint) && + !string.IsNullOrEmpty(hint)) + { + context.LoginHint = hint; + } + // Preserve the host properties in the principal. if (properties.Dictionary.Count is not 0) { @@ -500,11 +542,11 @@ public static partial class OpenIddictClientOwinHandlers /// protection against state token injection, forged requests and session fixation attacks. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. /// - public class GenerateCorrelationCookie : IOpenIddictClientHandler + public class GenerateLoginCorrelationCookie : IOpenIddictClientHandler { private readonly IOptionsMonitor _options; - public GenerateCorrelationCookie(IOptionsMonitor options) + public GenerateLoginCorrelationCookie(IOptionsMonitor options) => _options = options ?? throw new ArgumentNullException(nameof(options)); /// @@ -513,8 +555,8 @@ public static partial class OpenIddictClientOwinHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() - .AddFilter() - .UseSingletonHandler() + .AddFilter() + .UseSingletonHandler() .SetOrder(AttachChallengeParameters.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -576,6 +618,201 @@ public static partial class OpenIddictClientOwinHandlers } } + /// + /// Contains the logic responsible for resolving the additional sign-out parameters stored in the ASP.NET + /// Core authentication properties specified by the application that triggered the sign-out operation. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class ResolveHostSignOutParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(ValidateSignOutDemand.Descriptor.Order - 500) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName!); + if (properties is null) + { + return default; + } + + // If an issuer was explicitly set, update the challenge context to use it. + if (properties.Dictionary.TryGetValue(Properties.Issuer, out string? issuer) && !string.IsNullOrEmpty(issuer)) + { + // Ensure the issuer set by the application is a valid absolute URI. + if (!Uri.TryCreate(issuer, UriKind.Absolute, out Uri? uri) || !uri.IsWellFormedOriginalString()) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0306)); + } + + context.Issuer = uri; + } + + // If a return URL was specified, use it as the target_link_uri claim. + if (!string.IsNullOrEmpty(properties.RedirectUri)) + { + context.TargetLinkUri = properties.RedirectUri; + } + + // If an identity token hint was specified, attach it to the context. + if (properties.Dictionary.TryGetValue(Properties.IdentityTokenHint, out string? token) && + !string.IsNullOrEmpty(token)) + { + context.IdentityTokenHint = token; + } + + // If a login hint was specified, attach it to the context. + if (properties.Dictionary.TryGetValue(Properties.LoginHint, out string? hint) && + !string.IsNullOrEmpty(hint)) + { + context.LoginHint = hint; + } + + // Preserve the host properties in the principal. + if (properties.Dictionary.Count is not 0) + { + context.Principal.SetClaim(Claims.Private.HostProperties, properties.Dictionary); + } + + // Note: unlike ASP.NET Core, Owin's AuthenticationProperties doesn't offer a strongly-typed + // dictionary that allows flowing parameters while preserving their original types. To allow + // returning custom parameters, the OWIN host allows using AuthenticationProperties.Dictionary + // but requires suffixing the properties that are meant to be used as parameters using a special + // suffix that indicates that the property is public and determines its actual representation. + foreach (var property in properties.Dictionary) + { + var (name, value) = property.Key switch + { + // If the property ends with #string, represent it as a string parameter. + string key when key.EndsWith(PropertyTypes.String, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.String.Length), + Value: new OpenIddictParameter(property.Value)), + + // If the property ends with #boolean, return it as a boolean parameter. + string key when key.EndsWith(PropertyTypes.Boolean, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Boolean.Length), + Value: new OpenIddictParameter(bool.Parse(property.Value))), + + // If the property ends with #integer, return it as an integer parameter. + string key when key.EndsWith(PropertyTypes.Integer, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Integer.Length), + Value: new OpenIddictParameter(long.Parse(property.Value, CultureInfo.InvariantCulture))), + + // If the property ends with #json, return it as a JSON parameter. + string key when key.EndsWith(PropertyTypes.Json, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Json.Length), + Value: new OpenIddictParameter(JsonSerializer.Deserialize(property.Value))), + + _ => default + }; + + if (!string.IsNullOrEmpty(name)) + { + context.Parameters[name] = value; + } + } + + return default; + } + } + + /// + /// Contains the logic responsible for creating a correlation cookie that serves as a + /// protection against state token injection, forged requests and denial of service attacks. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class GenerateLogoutCorrelationCookie : IOpenIddictClientHandler + { + private readonly IOptionsMonitor _options; + + public GenerateLogoutCorrelationCookie(IOptionsMonitor options) + => _options = options ?? throw new ArgumentNullException(nameof(options)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachChallengeParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: using a correlation cookie serves as an injection/antiforgery protection as the request + // will always be rejected if a cookie corresponding to the request forgery protection claim + // persisted in the state token cannot be found. This protection is considered essential + // in OpenIddict and cannot be disabled via the options. Applications that prefer implementing + // a different protection strategy can set the request forgery protection claim to null or + // remove this handler from the handlers list and add a custom one using a different approach. + + if (string.IsNullOrEmpty(context.RequestForgeryProtection)) + { + return default; + } + + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // This handler only applies to OWIN requests. If the HTTP context cannot be resolved, + // this may indicate that the request was incorrectly processed by another server stack. + var response = context.Transaction.GetOwinRequest()?.Context.Response ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0120)); + + // Compute a collision-resistant and hard-to-guess cookie name based on the prefix set + // in the options and the random request forgery protection claim generated earlier. + var name = new StringBuilder(_options.CurrentValue.CookieName) + .Append(Separators.Dot) + .Append(context.RequestForgeryProtection) + .ToString(); + + // Resolve the cookie manager and the cookie options from the OWIN integration options. + var (manager, options) = ( + _options.CurrentValue.CookieManager, + _options.CurrentValue.CookieOptions); + + // Add the correlation cookie to the response headers. + manager.AppendResponseCookie(response.Context, name, "v1", new CookieOptions + { + Domain = options.Domain, + HttpOnly = options.HttpOnly, + Path = options.Path, + SameSite = options.SameSite, + Secure = options.Secure, + + // Use the expiration date of the state token principal + // as the expiration date of the correlation cookie. + Expires = context.StateTokenPrincipal.GetExpirationDate()?.UtcDateTime + }); + + return default; + } + } + /// /// Contains the logic responsible for enabling the pass-through mode for the received request. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinOptions.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinOptions.cs index 2fc93527..902f9eb5 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinOptions.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinOptions.cs @@ -20,6 +20,14 @@ public class OpenIddictClientOwinOptions : AuthenticationOptions : base(OpenIddictClientOwinDefaults.AuthenticationType) => AuthenticationMode = AuthenticationMode.Passive; + /// + /// Gets or sets a boolean indicating whether the pass-through mode is enabled for the post-logout redirection endpoint. + /// When the pass-through mode is used, OpenID Connect requests are initially handled by OpenIddict. + /// Once validated, the rest of the request processing pipeline is invoked, so that OpenID Connect requests + /// can be handled at a later stage (in a custom middleware or in a MVC controller, for instance). + /// + public bool EnablePostLogoutRedirectionEndpointPassthrough { get; set; } + /// /// Gets or sets a boolean indicating whether the pass-through mode is enabled for the redirection endpoint. /// When the pass-through mode is used, OpenID Connect requests are initially handled by OpenIddict. diff --git a/src/OpenIddict.Client/OpenIddictClientBuilder.cs b/src/OpenIddict.Client/OpenIddictClientBuilder.cs index 9befa13e..3253ff4f 100644 --- a/src/OpenIddict.Client/OpenIddictClientBuilder.cs +++ b/src/OpenIddict.Client/OpenIddictClientBuilder.cs @@ -1028,6 +1028,52 @@ public class OpenIddictClientBuilder }); } + /// + /// Sets the relative or absolute URLs associated to the post-logout redirection endpoint. + /// If an empty array is specified, the endpoint will be considered disabled. + /// + /// The addresses associated to the endpoint. + /// The . + public OpenIddictClientBuilder SetPostLogoutRedirectionEndpointUris(params string[] addresses) + { + if (addresses is null) + { + throw new ArgumentNullException(nameof(addresses)); + } + + return SetPostLogoutRedirectionEndpointUris(addresses.Select(address => new Uri(address, UriKind.RelativeOrAbsolute)).ToArray()); + } + + /// + /// Sets the relative or absolute URLs associated to the post-logout redirection endpoint. + /// If an empty array is specified, the endpoint will be considered disabled. + /// + /// The addresses associated to the endpoint. + /// The . + public OpenIddictClientBuilder SetPostLogoutRedirectionEndpointUris(params Uri[] addresses) + { + if (addresses is null) + { + throw new ArgumentNullException(nameof(addresses)); + } + + if (addresses.Any(address => !address.IsWellFormedOriginalString())) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0072), nameof(addresses)); + } + + if (addresses.Any(address => address.OriginalString.StartsWith("~", StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException(SR.FormatID0081("~"), nameof(addresses)); + } + + return Configure(options => + { + options.PostLogoutRedirectionEndpointUris.Clear(); + options.PostLogoutRedirectionEndpointUris.AddRange(addresses); + }); + } + /// /// Sets the client assertion token lifetime, after which backchannel requests /// using an expired state token should be automatically rejected by the server. diff --git a/src/OpenIddict.Client/OpenIddictClientEndpointType.cs b/src/OpenIddict.Client/OpenIddictClientEndpointType.cs index 23bddf08..c784f3d2 100644 --- a/src/OpenIddict.Client/OpenIddictClientEndpointType.cs +++ b/src/OpenIddict.Client/OpenIddictClientEndpointType.cs @@ -19,5 +19,10 @@ public enum OpenIddictClientEndpointType /// /// Redirection endpoint. /// - Redirection = 1 + Redirection = 1, + + /// + /// Post-logout redirection endpoint. + /// + PostLogoutRedirection = 2 } diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.Session.cs b/src/OpenIddict.Client/OpenIddictClientEvents.Session.cs new file mode 100644 index 00000000..cc5fbfda --- /dev/null +++ b/src/OpenIddict.Client/OpenIddictClientEvents.Session.cs @@ -0,0 +1,190 @@ +/* + * 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.Security.Claims; + +namespace OpenIddict.Client; + +public static partial class OpenIddictClientEvents +{ + /// + /// Represents an event called for each request to the logout endpoint to give the user code + /// a chance to manually update the logout request before it is sent to the identity provider. + /// + public class PrepareLogoutRequestContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public PrepareLogoutRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the principal containing the claims stored in the state object. + /// + public ClaimsPrincipal StatePrincipal { get; set; } = new ClaimsPrincipal(new ClaimsIdentity()); + } + + /// + /// Represents an event called for each request to the logout endpoint + /// to give the user code a chance to manually send the logout request. + /// + public class ApplyLogoutRequestContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public ApplyLogoutRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + public string EndSessionEndpoint { get; set; } = null!; + } + + /// + /// Represents an event called for each request to the post-logout redirection endpoint to give the user code + /// a chance to manually extract the redirection request from the ambient HTTP context. + /// + public class ExtractPostLogoutRedirectionRequestContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public ExtractPostLogoutRedirectionRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request or if it was extracted yet. + /// + public OpenIddictRequest? Request + { + get => Transaction.Request; + set => Transaction.Request = value; + } + } + + /// + /// Represents an event called for each request to the post-logout redirection endpoint + /// to determine if the request is valid and should continue to be processed. + /// + public class ValidatePostLogoutRedirectionRequestContext : BaseValidatingContext + { + /// + /// Creates a new instance of the class. + /// + public ValidatePostLogoutRedirectionRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the security principal extracted from the identity token, + /// if applicable to the current redirection request. If no identity token + /// is available at the validation stage, a token request will typically be + /// sent to retrieve a complete set of tokens (e.g logout code flow). + /// + public ClaimsPrincipal? Principal { get; set; } + + /// + /// Gets or sets the security principal extracted from the state token. + /// + public ClaimsPrincipal? StateTokenPrincipal { get; set; } + } + + /// + /// Represents an event called for each validated redirection request + /// to allow the user code to decide how the request should be handled. + /// + public class HandlePostLogoutRedirectionRequestContext : BaseValidatingTicketContext + { + /// + /// Creates a new instance of the class. + /// + public HandlePostLogoutRedirectionRequestContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets the additional parameters returned to the client application. + /// + public Dictionary Parameters { get; private set; } + = new(StringComparer.Ordinal); + } + + /// + /// Represents an event called before the redirection response is returned to the caller. + /// + public class ApplyPostLogoutRedirectionResponseContext : BaseRequestContext + { + /// + /// Creates a new instance of the class. + /// + public ApplyPostLogoutRedirectionResponseContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request, or if it couldn't be extracted. + /// + public OpenIddictRequest? Request + { + get => Transaction.Request; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the response. + /// + public OpenIddictResponse Response + { + get => Transaction.Response!; + set => Transaction.Response = value; + } + } +} diff --git a/src/OpenIddict.Client/OpenIddictClientEvents.cs b/src/OpenIddict.Client/OpenIddictClientEvents.cs index b5532fb8..c8428500 100644 --- a/src/OpenIddict.Client/OpenIddictClientEvents.cs +++ b/src/OpenIddict.Client/OpenIddictClientEvents.cs @@ -760,6 +760,17 @@ public static partial class OpenIddictClientEvents /// public string? TargetLinkUri { get; set; } + /// + /// Gets or sets the optional identity token hint that will + /// be sent to the authorization server, if applicable. + /// + public string? IdentityTokenHint { get; set; } + + /// + /// Gets or sets the optional login hint that will be sent to the authorization server, if applicable. + /// + public string? LoginHint { get; set; } + /// /// Gets the set of scopes that will be requested to the authorization server. /// @@ -794,4 +805,104 @@ public static partial class OpenIddictClientEvents /// public ClaimsPrincipal? StateTokenPrincipal { get; set; } } + + /// + /// Represents an event called when processing a sign-out response. + /// + public class ProcessSignOutContext : BaseValidatingTicketContext + { + /// + /// Creates a new instance of the class. + /// + public ProcessSignOutContext(OpenIddictClientTransaction transaction) + : base(transaction) + { + } + + /// + /// Gets or sets the request. + /// + public OpenIddictRequest Request + { + get => Transaction.Request!; + set => Transaction.Request = value; + } + + /// + /// Gets or sets the response. + /// + public OpenIddictResponse Response + { + get => Transaction.Response!; + set => Transaction.Response = value; + } + + /// + /// Gets or sets the client identifier that will be used for the sign-out demand. + /// + public string? ClientId { get; set; } + + /// + /// Gets or sets the post-logout redirection endpoint that + /// will be used for the sign-out demand, if applicable. + /// + public string? PostLogoutRedirectUri { get; set; } + + /// + /// Gets or sets the optional identity token hint that will + /// be sent to the authorization server, if applicable. + /// + public string? IdentityTokenHint { get; set; } + + /// + /// Gets or sets the optional login hint that will be sent to the authorization server, if applicable. + /// + public string? LoginHint { get; set; } + + /// + /// Gets or sets the optional return URL that will be stored in the state token, if applicable. + /// + public string? TargetLinkUri { get; set; } + + /// + /// Gets or sets the request forgery protection that will be stored in the state token, if applicable. + /// Note: this value MUST NOT be user-defined or extracted from any request and MUST be random + /// (generated by a random number generator suitable for cryptographic operations). + /// + public string? RequestForgeryProtection { get; set; } + + /// + /// Gets the additional parameters returned to caller. + /// + public Dictionary Parameters { get; } = new(StringComparer.Ordinal); + + /// + /// Gets or sets a boolean indicating whether a state token + /// should be generated (and optionally included in the request). + /// Note: overriding the value of this property is generally not + /// recommended, except when dealing with non-standard clients. + /// + public bool GenerateStateToken { get; set; } + + /// + /// Gets or sets a boolean indicating whether the generated + /// state token should be included as part of the request. + /// Note: overriding the value of this property is generally not + /// recommended, except when dealing with non-standard clients. + /// + public bool IncludeStateToken { get; set; } + + /// + /// Gets or sets the generated state token, if applicable. + /// The state token will only be returned if + /// is set to . + /// + public string? StateToken { get; set; } + + /// + /// Gets or sets the principal containing the claims that + /// will be used to create the state token, if applicable. + /// + public ClaimsPrincipal? StateTokenPrincipal { get; set; } + } } diff --git a/src/OpenIddict.Client/OpenIddictClientExtensions.cs b/src/OpenIddict.Client/OpenIddictClientExtensions.cs index 5425427a..f071f4fb 100644 --- a/src/OpenIddict.Client/OpenIddictClientExtensions.cs +++ b/src/OpenIddict.Client/OpenIddictClientExtensions.cs @@ -45,10 +45,12 @@ public static class OpenIddictClientExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs index 870ebbb4..0c815b53 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlerFilters.cs @@ -173,9 +173,41 @@ public static class OpenIddictClientHandlerFilters } /// - /// Represents a filter that excludes the associated handlers if the request is not a redirection request. + /// Represents a filter that excludes the associated handlers if no login state token is generated. /// - public class RequireRedirectionRequest : IOpenIddictClientHandlerFilter + public class RequireLoginStateTokenGenerated : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.GenerateStateToken); + } + } + + /// + /// Represents a filter that excludes the associated handlers if no logout state token is generated. + /// + public class RequireLogoutStateTokenGenerated : IOpenIddictClientHandlerFilter + { + public ValueTask IsActiveAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + return new(context.GenerateStateToken); + } + } + + /// + /// Represents a filter that excludes the associated handlers if the request is not a post-logout redirection request. + /// + public class RequirePostLogoutRedirectionRequest : IOpenIddictClientHandlerFilter { public ValueTask IsActiveAsync(BaseContext context) { @@ -184,39 +216,39 @@ public static class OpenIddictClientHandlerFilters throw new ArgumentNullException(nameof(context)); } - return new(context.EndpointType is OpenIddictClientEndpointType.Redirection); + return new(context.EndpointType is OpenIddictClientEndpointType.PostLogoutRedirection); } } /// - /// Represents a filter that excludes the associated handlers if no refresh token is validated. + /// Represents a filter that excludes the associated handlers if the request is not a redirection request. /// - public class RequireRefreshTokenValidated : IOpenIddictClientHandlerFilter + public class RequireRedirectionRequest : IOpenIddictClientHandlerFilter { - public ValueTask IsActiveAsync(ProcessAuthenticationContext context) + public ValueTask IsActiveAsync(BaseContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } - return new(context.ValidateRefreshToken); + return new(context.EndpointType is OpenIddictClientEndpointType.Redirection); } } /// - /// Represents a filter that excludes the associated handlers if no state token is generated. + /// Represents a filter that excludes the associated handlers if no refresh token is validated. /// - public class RequireStateTokenGenerated : IOpenIddictClientHandlerFilter + public class RequireRefreshTokenValidated : IOpenIddictClientHandlerFilter { - public ValueTask IsActiveAsync(ProcessChallengeContext context) + public ValueTask IsActiveAsync(ProcessAuthenticationContext context) { if (context is null) { throw new ArgumentNullException(nameof(context)); } - return new(context.GenerateStateToken); + return new(context.ValidateRefreshToken); } } diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs index c0f0a15a..27db3d28 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Discovery.cs @@ -23,6 +23,7 @@ public static partial class OpenIddictClientHandlers ValidateIssuer.Descriptor, ExtractAuthorizationEndpoint.Descriptor, ExtractCryptographyEndpoint.Descriptor, + ExtractLogoutEndpoint.Descriptor, ExtractTokenEndpoint.Descriptor, ExtractUserinfoEndpoint.Descriptor, ExtractGrantTypes.Descriptor, @@ -89,6 +90,7 @@ public static partial class OpenIddictClientHandlers { // The following parameters MUST be formatted as unique strings: Metadata.AuthorizationEndpoint or + Metadata.EndSessionEndpoint or Metadata.Issuer or Metadata.JwksUri or Metadata.TokenEndpoint or @@ -295,6 +297,49 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for extracting the logout endpoint address from the discovery document. + /// + public class ExtractLogoutEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ExtractCryptographyEndpoint.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(HandleConfigurationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var address = (string?) context.Response[Metadata.EndSessionEndpoint]; + if (!string.IsNullOrEmpty(address)) + { + if (!Uri.TryCreate(address, UriKind.Absolute, out Uri? uri) || !uri.IsWellFormedOriginalString()) + { + context.Reject( + error: Errors.ServerError, + description: SR.FormatID2100(Metadata.EndSessionEndpoint), + uri: SR.FormatID8000(SR.ID2100)); + + return default; + } + + context.Configuration.EndSessionEndpoint = uri; + } + + return default; + } + } + /// /// Contains the logic responsible for extracting the token endpoint address from the discovery document. /// @@ -306,7 +351,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ExtractCryptographyEndpoint.Descriptor.Order + 1_000) + .SetOrder(ExtractLogoutEndpoint.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Session.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Session.cs new file mode 100644 index 00000000..b0eeb19f --- /dev/null +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Session.cs @@ -0,0 +1,450 @@ +/* + * 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 Microsoft.Extensions.Logging; + +namespace OpenIddict.Client; + +public static partial class OpenIddictClientHandlers +{ + public static class Session + { + public static ImmutableArray DefaultHandlers { get; } = ImmutableArray.Create( + /* + * Logout request top-level processing: + */ + PrepareLogoutRequest.Descriptor, + ApplyLogoutRequest.Descriptor, + + /* + * Logout request processing: + */ + AttachLogoutEndpoint.Descriptor, + + /* + * Post-logout redirection request top-level processing: + */ + ExtractPostLogoutRedirectionRequest.Descriptor, + ValidatePostLogoutRedirectionRequest.Descriptor, + HandlePostLogoutRedirectionRequest.Descriptor, + ApplyPostLogoutRedirectionResponse.Descriptor, + ApplyPostLogoutRedirectionResponse.Descriptor, + + /* + * Post-logout redirection request validation: + */ + ValidateTokens.Descriptor); + + /// + /// Contains the logic responsible for preparing authorization requests and invoking the corresponding event handlers. + /// + public class PrepareLogoutRequest : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public PrepareLogoutRequest(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseScopedHandler() + .SetOrder(int.MaxValue - 100_000) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new PrepareLogoutRequestContext(context.Transaction); + await _dispatcher.DispatchAsync(notification); + + if (notification.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (notification.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + } + } + + /// + /// Contains the logic responsible for applying authorization requests and invoking the corresponding event handlers. + /// + public class ApplyLogoutRequest : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public ApplyLogoutRequest(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseScopedHandler() + .SetOrder(PrepareLogoutRequest.Descriptor.Order + 1_000) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ApplyLogoutRequestContext(context.Transaction); + await _dispatcher.DispatchAsync(notification); + + if (notification.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (notification.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + } + } + + /// + /// Contains the logic responsible for attaching the address of the authorization request to the request. + /// + public class AttachLogoutEndpoint : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .Build(); + + /// + public ValueTask HandleAsync(ApplyLogoutRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Ensure the end session endpoint is present and is a valid absolute URL. + if (context.Configuration.EndSessionEndpoint is not { IsAbsoluteUri: true } || + !context.Configuration.EndSessionEndpoint.IsWellFormedOriginalString()) + { + throw new InvalidOperationException(SR.FormatID0301(Metadata.EndSessionEndpoint)); + } + + context.EndSessionEndpoint = context.Configuration.EndSessionEndpoint.AbsoluteUri; + + return default; + } + } + + /// + /// Contains the logic responsible for extracting redirection requests and invoking the corresponding event handlers. + /// + public class ExtractPostLogoutRedirectionRequest : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public ExtractPostLogoutRedirectionRequest(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(100_000) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ExtractPostLogoutRedirectionRequestContext(context.Transaction); + 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; + } + + if (notification.Request is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0302)); + } + + context.Logger.LogInformation(SR.GetResourceString(SR.ID6199), notification.Request); + } + } + + /// + /// Contains the logic responsible for validating redirection requests and invoking the corresponding event handlers. + /// + public class ValidatePostLogoutRedirectionRequest : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public ValidatePostLogoutRedirectionRequest(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ExtractPostLogoutRedirectionRequest.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ValidatePostLogoutRedirectionRequestContext(context.Transaction); + 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.Logger.LogInformation(SR.GetResourceString(SR.ID6200)); + } + } + + /// + /// Contains the logic responsible for handling redirection requests and invoking the corresponding event handlers. + /// + public class HandlePostLogoutRedirectionRequest : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public HandlePostLogoutRedirectionRequest(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidatePostLogoutRedirectionRequest.Descriptor.Order + 1_000) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new HandlePostLogoutRedirectionRequestContext(context.Transaction); + 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.Logger.LogInformation(SR.GetResourceString(SR.ID6201)); + } + } + + /// + /// Contains the logic responsible for processing redirection responses and invoking the corresponding event handlers. + /// + public class ApplyPostLogoutRedirectionResponse : IOpenIddictClientHandler where TContext : BaseRequestContext + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public ApplyPostLogoutRedirectionResponse(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler>() + .SetOrder(int.MaxValue - 100_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(TContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ApplyPostLogoutRedirectionResponseContext(context.Transaction); + await _dispatcher.DispatchAsync(notification); + + if (notification.IsRequestHandled) + { + context.HandleRequest(); + return; + } + + else if (notification.IsRequestSkipped) + { + context.SkipRequest(); + return; + } + + throw new InvalidOperationException(SR.GetResourceString(SR.ID0303)); + } + } + + /// + /// Contains the logic responsible for rejecting redirection requests that don't + /// specify a valid access token, authorization code, identity token or state token. + /// + public class ValidateTokens : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public ValidateTokens(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseScopedHandler() + .SetOrder(int.MinValue + 100_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidatePostLogoutRedirectionRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new ProcessAuthenticationContext(context.Transaction); + await _dispatcher.DispatchAsync(notification); + + // Store the context object in the transaction so it can be later retrieved by handlers + // that want to access the authentication result without triggering a new authentication flow. + context.Transaction.SetProperty(typeof(ProcessAuthenticationContext).FullName!, 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; + } + + // Attach the security principals extracted from the tokens to the validation context. + context.Principal = notification.FrontchannelIdentityTokenPrincipal; + context.StateTokenPrincipal = notification.StateTokenPrincipal; + } + } + } +} diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index 898a431f..0684cfb7 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -31,6 +31,7 @@ public static partial class OpenIddictClientHandlers ValidateRequiredStateToken.Descriptor, ValidateStateToken.Descriptor, RedeemStateTokenEntry.Descriptor, + ValidateStateTokenEndpointType.Descriptor, ResolveClientRegistrationFromStateToken.Descriptor, ValidateIssuerParameter.Descriptor, ValidateFrontchannelErrorParameters.Descriptor, @@ -90,7 +91,7 @@ public static partial class OpenIddictClientHandlers * Challenge processing: */ ValidateChallengeDemand.Descriptor, - ResolveClientRegistration.Descriptor, + ResolveClientRegistrationFromChallengeContext.Descriptor, AttachGrantType.Descriptor, EvaluateGeneratedChallengeTokens.Descriptor, AttachResponseType.Descriptor, @@ -105,6 +106,21 @@ public static partial class OpenIddictClientHandlers ValidateRedirectUriParameter.Descriptor, GenerateStateToken.Descriptor, AttachChallengeParameters.Descriptor, + AttachCustomChallengeParameters.Descriptor, + + /* + * Sign-out processing: + */ + ValidateSignOutDemand.Descriptor, + ResolveClientRegistrationFromSignOutContext.Descriptor, + AttachOptionalClientId.Descriptor, + AttachPostLogoutRedirectUri.Descriptor, + EvaluateGeneratedLogoutTokens.Descriptor, + AttachLogoutRequestForgeryProtection.Descriptor, + PrepareLogoutStateTokenPrincipal.Descriptor, + GenerateLogoutStateToken.Descriptor, + AttachSignOutParameters.Descriptor, + AttachCustomSignOutParameters.Descriptor, /* * Error processing: @@ -115,6 +131,7 @@ public static partial class OpenIddictClientHandlers .AddRange(Discovery.DefaultHandlers) .AddRange(Exchange.DefaultHandlers) .AddRange(Protection.DefaultHandlers) + .AddRange(Session.DefaultHandlers) .AddRange(Userinfo.DefaultHandlers); /// @@ -140,13 +157,14 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } - // Authentication demands can be triggered from the redirection endpoint - // to handle authorization callbacks but also from unknown endpoints + // Authentication demands can be triggered from the redirection endpoints + // to handle authorization/logout callbacks but also from unknown endpoints // when using the refresh token grant, to perform a token refresh dance. switch (context.EndpointType) { case OpenIddictClientEndpointType.Redirection: + case OpenIddictClientEndpointType.PostLogoutRedirection: break; case OpenIddictClientEndpointType.Unknown: @@ -224,6 +242,13 @@ public static partial class OpenIddictClientHandlers // chosen authorization server), the state is always considered required at this point. OpenIddictClientEndpointType.Redirection => (true, true, true), + // While the OpenID Connect RP-initiated logout specification doesn't require sending + // a state as part of logout requests, the identity provider MUST return the state + // if one was initially specified. Since OpenIddict always sends a state (used as a + // way to mitigate CSRF attacks and store per-logout values like the identity of the + // chosen authorization server), the state is always considered required at this point. + OpenIddictClientEndpointType.PostLogoutRedirection => (true, true, true), + _ => (false, false, false) }; @@ -256,8 +281,8 @@ public static partial class OpenIddictClientHandlers context.StateToken = context.EndpointType switch { - OpenIddictClientEndpointType.Redirection when context.ExtractStateToken - => context.Request.State, + OpenIddictClientEndpointType.Redirection or OpenIddictClientEndpointType.PostLogoutRedirection + when context.ExtractStateToken => context.Request.State, _ => null }; @@ -433,6 +458,56 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for ensuring the resolved state + /// token is suitable for the requested authentication demand. + /// + public class ValidateStateTokenEndpointType : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(RedeemStateTokenEntry.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // Resolve the endpoint type allowed to be used with the state token. + if (!Enum.TryParse(context.StateTokenPrincipal.GetClaim(Claims.Private.EndpointType), + ignoreCase: true, out OpenIddictClientEndpointType type)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0340)); + } + + // Reject the authentication demand if the expected endpoint type doesn't + // match the current endpoint type as it may indicate a mix-up attack (e.g a + // state token created for a logout operation was used for a login operation). + if (type != context.EndpointType) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.GetResourceString(SR.ID2142), + uri: SR.FormatID8000(SR.ID2142)); + + return default; + } + + return default; + } + } + /// /// Contains the logic responsible for resolving the client registration /// based on the authorization server identity stored in the state token. @@ -446,7 +521,7 @@ public static partial class OpenIddictClientHandlers = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(RedeemStateTokenEntry.Descriptor.Order + 1_000) + .SetOrder(ValidateStateTokenEndpointType.Descriptor.Order + 1_000) .Build(); /// @@ -636,6 +711,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .AddFilter() .UseSingletonHandler() .SetOrder(ValidateFrontchannelErrorParameters.Descriptor.Order + 1_000) @@ -689,6 +765,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() .AddFilter() .UseSingletonHandler() .SetOrder(ResolveGrantTypeFromStateToken.Descriptor.Order + 1_000) @@ -3276,6 +3353,11 @@ public static partial class OpenIddictClientHandlers throw new ArgumentNullException(nameof(context)); } + if (context.EndpointType is not OpenIddictClientEndpointType.Unknown) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0006)); + } + // If an explicit grant type was specified, ensure it is supported by OpenIddict. if (!string.IsNullOrEmpty(context.GrantType) && context.GrantType is not (GrantTypes.AuthorizationCode or GrantTypes.Implicit)) @@ -3300,14 +3382,14 @@ public static partial class OpenIddictClientHandlers /// /// Contains the logic responsible for resolving the client registration applicable to the challenge demand. /// - public class ResolveClientRegistration : IOpenIddictClientHandler + public class ResolveClientRegistrationFromChallengeContext : IOpenIddictClientHandler { /// /// Gets the default descriptor definition assigned to this handler. /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() + .UseSingletonHandler() .SetOrder(ValidateChallengeDemand.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -3353,7 +3435,7 @@ public static partial class OpenIddictClientHandlers public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() .UseSingletonHandler() - .SetOrder(ResolveClientRegistration.Descriptor.Order + 1_000) + .SetOrder(ResolveClientRegistrationFromChallengeContext.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -4062,7 +4144,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseSingletonHandler() .SetOrder(AttachCodeChallengeParameters.Descriptor.Order + 1_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -4136,6 +4218,11 @@ public static partial class OpenIddictClientHandlers // help mitigate downgrade attacks (e.g authorization code flow -> implicit flow). principal.SetClaim(Claims.Private.ResponseType, context.ResponseType); + // Store the type of endpoint allowed to receive the generated state token. + principal.SetClaim(Claims.Private.EndpointType, Enum.GetName( + typeof(OpenIddictClientEndpointType), + OpenIddictClientEndpointType.Redirection)!.ToLowerInvariant()); + // Store the optional redirect_uri to allow sending it as part of the token request. principal.SetClaim(Claims.Private.RedirectUri, context.RedirectUri); @@ -4174,7 +4261,7 @@ public static partial class OpenIddictClientHandlers /// public static OpenIddictClientHandlerDescriptor Descriptor { get; } = OpenIddictClientHandlerDescriptor.CreateBuilder() - .AddFilter() + .AddFilter() .UseScopedHandler() .SetOrder(100_000) .SetType(OpenIddictClientHandlerType.BuiltIn) @@ -4304,11 +4391,42 @@ public static partial class OpenIddictClientHandlers context.Request.CodeChallenge = context.CodeChallenge; context.Request.CodeChallengeMethod = context.CodeChallengeMethod; + context.Request.IdTokenHint = context.IdentityTokenHint; + context.Request.LoginHint = context.LoginHint; + if (context.IncludeStateToken) { context.Request.State = context.StateToken; } + return default; + } + } + + /// + /// Contains the logic responsible for attaching the parameters + /// populated from user-defined handlers to the challenge response. + /// + public class AttachCustomChallengeParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachChallengeParameters.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (context.Parameters.Count > 0) { foreach (var parameter in context.Parameters) @@ -4321,6 +4439,440 @@ public static partial class OpenIddictClientHandlers } } + /// + /// Contains the logic responsible for ensuring that the sign-out demand + /// is compatible with the type of the endpoint that handled the request. + /// + public class ValidateSignOutDemand : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(int.MinValue + 100_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.EndpointType is not OpenIddictClientEndpointType.Unknown) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0024)); + } + + return default; + } + } + + /// + /// Contains the logic responsible for resolving the client registration applicable to the sign-out demand. + /// + public class ResolveClientRegistrationFromSignOutContext : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateSignOutDemand.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: if the static registration cannot be found in the options, this may indicate + // the client was removed after the authorization dance started and thus, can no longer + // be used to authenticate users. In this case, throw an exception to abort the flow. + context.Registration = context.Options.Registrations.Find( + registration => registration.Issuer == context.Issuer) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0292)); + + // Resolve and attach the server configuration to the context. + var configuration = await context.Registration.ConfigurationManager.GetConfigurationAsync(default) ?? + throw new InvalidOperationException(SR.GetResourceString(SR.ID0140)); + + // Ensure the issuer resolved from the configuration matches the expected value. + if (configuration.Issuer != context.Issuer) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0307)); + } + + context.Configuration = configuration; + } + } + + /// + /// Contains the logic responsible for attaching the client identifier to the sign-out request. + /// + public class AttachOptionalClientId : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ResolveClientRegistrationFromSignOutContext.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: the client_id parameter is optional. + context.ClientId ??= context.Registration.ClientId; + + return default; + } + } + + /// + /// Contains the logic responsible for attaching the post_logout_redirect_uri to the sign-out request. + /// + public class AttachPostLogoutRedirectUri : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachOptionalClientId.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: the post_logout_redirect_uri parameter is optional. + context.PostLogoutRedirectUri ??= context.Registration.PostLogoutRedirectUri?.AbsoluteUri; + + return default; + } + } + + /// + /// Contains the logic responsible for selecting the token types that + /// should be generated and optionally returned in the response. + /// + public class EvaluateGeneratedLogoutTokens : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachPostLogoutRedirectUri.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + (context.GenerateStateToken, context.IncludeStateToken) = (true, true); + + return default; + } + } + + /// + /// Contains the logic responsible for attaching a request forgery protection to the authorization request. + /// + public class AttachLogoutRequestForgeryProtection : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(EvaluateGeneratedLogoutTokens.Descriptor.Order + 1_000) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Generate a new crypto-secure random identifier that will + // be used as the non-guessable part of the state token. + var data = new byte[256 / 8]; +#if SUPPORTS_STATIC_RANDOM_NUMBER_GENERATOR_METHODS + RandomNumberGenerator.Fill(data); +#else + using var generator = RandomNumberGenerator.Create(); + generator.GetBytes(data); +#endif + context.RequestForgeryProtection = Base64UrlEncoder.Encode(data); + + return default; + } + } + + /// + /// Contains the logic responsible for preparing and attaching the claims principal + /// used to generate the logout state token, if one is going to be returned. + /// + public class PrepareLogoutStateTokenPrincipal : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachLogoutRequestForgeryProtection.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013)); + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + // Create a new principal containing only the filtered claims. + // Actors identities are also filtered (delegation scenarios). + var principal = context.Principal.Clone(claim => + { + // Never include the public or internal token identifiers to ensure the identifiers + // that are automatically inherited from the parent token are not reused for the new token. + if (string.Equals(claim.Type, Claims.JwtId, StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, Claims.Private.TokenId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Never include the creation and expiration dates that are automatically + // inherited from the parent token are not reused for the new token. + if (string.Equals(claim.Type, Claims.ExpiresAt, StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, Claims.IssuedAt, StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, Claims.NotBefore, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Other claims are always included in the state token, even private claims. + return true; + }); + + principal.SetCreationDate(DateTimeOffset.UtcNow); + + var lifetime = context.Principal.GetStateTokenLifetime() ?? context.Options.StateTokenLifetime; + if (lifetime.HasValue) + { + principal.SetExpirationDate(principal.GetCreationDate() + lifetime.Value); + } + + // Store the identity of the authorization server in the state token + // principal to allow resolving it when handling the post-logout callback. + // + // See https://datatracker.ietf.org/doc/html/draft-bradley-oauth-jwt-encoded-state-09 + // for more information about this special claim. + principal.SetClaim(Claims.AuthorizationServer, context.Issuer.AbsoluteUri); + + // Store the request forgery protection in the state token so it can be later used to + // ensure the authorization response sent to the redirection endpoint is not forged. + principal.SetClaim(Claims.RequestForgeryProtection, context.RequestForgeryProtection); + + // Store the optional return URL in the state token. + principal.SetClaim(Claims.TargetLinkUri, context.TargetLinkUri); + + // Store the type of endpoint allowed to receive the generated state token. + principal.SetClaim(Claims.Private.EndpointType, Enum.GetName( + typeof(OpenIddictClientEndpointType), + OpenIddictClientEndpointType.PostLogoutRedirection)!.ToLowerInvariant()); + + // Store the post_logout_redirect_uri to allow comparing to the actual redirection URL. + principal.SetClaim(Claims.Private.PostLogoutRedirectUri, context.PostLogoutRedirectUri); + + context.StateTokenPrincipal = principal; + + return default; + } + } + + /// + /// Contains the logic responsible for generating a logout state token for the current sign-out operation. + /// + public class GenerateLogoutStateToken : IOpenIddictClientHandler + { + private readonly IOpenIddictClientDispatcher _dispatcher; + + public GenerateLogoutStateToken(IOpenIddictClientDispatcher dispatcher) + => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseScopedHandler() + .SetOrder(100_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var notification = new GenerateTokenContext(context.Transaction) + { + CreateTokenEntry = !context.Options.DisableTokenStorage, + PersistTokenPayload = !context.Options.DisableTokenStorage, + Principal = context.StateTokenPrincipal!, + TokenFormat = TokenFormats.Jwt, + TokenType = TokenTypeHints.StateToken + }; + + 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.StateToken = notification.Token; + } + } + + /// + /// Contains the logic responsible for attaching the appropriate parameters to the sign-out response. + /// + public class AttachSignOutParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(GenerateLogoutStateToken.Descriptor.Order + 1_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Note: while the exact order of the parameters has typically no effect on how requests + // are handled by an authorization server, client_id and post_logout_redirect_uri are + // set first so that they appear early in the URL (when GET requests are used), making + // mistyped values easier to spot when an error is returned by the identity provider. + context.Request.ClientId = context.ClientId; + context.Request.PostLogoutRedirectUri = context.PostLogoutRedirectUri; + + context.Request.IdTokenHint = context.IdentityTokenHint; + context.Request.LoginHint = context.LoginHint; + + if (context.IncludeStateToken) + { + context.Request.State = context.StateToken; + } + + return default; + } + } + + /// + /// Contains the logic responsible for attaching the parameters + /// populated from user-defined handlers to the sign-out response. + /// + public class AttachCustomSignOutParameters : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(100_000) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context.Parameters.Count > 0) + { + foreach (var parameter in context.Parameters) + { + context.Response.SetParameter(parameter.Key, parameter.Value); + } + } + + return default; + } + } + /// /// Contains the logic responsible for extracting potential errors from the response. /// diff --git a/src/OpenIddict.Client/OpenIddictClientOptions.cs b/src/OpenIddict.Client/OpenIddictClientOptions.cs index 8c0ffe98..e406e6f7 100644 --- a/src/OpenIddict.Client/OpenIddictClientOptions.cs +++ b/src/OpenIddict.Client/OpenIddictClientOptions.cs @@ -82,6 +82,11 @@ public class OpenIddictClientOptions /// public List RedirectionEndpointUris { get; } = new(); + /// + /// Gets the absolute and relative URIs associated to the post-logout redirection endpoint. + /// + public List PostLogoutRedirectionEndpointUris { get; } = new(); + /// /// Gets the static client registrations used by the OpenIddict client services. /// diff --git a/src/OpenIddict.Client/OpenIddictClientRegistration.cs b/src/OpenIddict.Client/OpenIddictClientRegistration.cs index fd782f95..6a5b6c36 100644 --- a/src/OpenIddict.Client/OpenIddictClientRegistration.cs +++ b/src/OpenIddict.Client/OpenIddictClientRegistration.cs @@ -29,6 +29,11 @@ public class OpenIddictClientRegistration /// public Uri? RedirectUri { get; set; } + /// + /// Gets or sets the address of the post-logout redirection endpoint that will handle the callback. + /// + public Uri? PostLogoutRedirectUri { get; set; } + /// /// Gets the list of encryption credentials used to create tokens for this client. /// Multiple credentials can be added to support key rollover, but if X.509 keys diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs index 3513360f..364c8c84 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs @@ -34,7 +34,7 @@ public static class OpenIddictServerAspNetCoreConstants public const string Error = ".error"; public const string ErrorDescription = ".error_description"; public const string ErrorUri = ".error_uri"; - public const string IdentityTokenPrincipal = ".id_token_principal"; + public const string IdentityTokenPrincipal = ".identity_token_principal"; public const string RefreshTokenPrincipal = ".refresh_token_principal"; public const string Scope = ".scope"; public const string UserCodePrincipal = ".user_code_principal"; diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs index c2f18322..cad74579 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs @@ -10,7 +10,6 @@ using System.Security.Claims; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using static OpenIddict.Server.OpenIddictServerHandlers.Authentication; namespace OpenIddict.Server;