diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs index 955fe5af..416e036e 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Controllers/AuthenticationController.cs @@ -22,9 +22,9 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers var issuer = provider switch { - "local" => "https://localhost:44349/", - "github" => "https://github.com/", - "google" => "https://accounts.google.com/", + "local" or "local-github" => "https://localhost:44349/", + "github" => "https://github.com/", + "google" => "https://accounts.google.com/", _ => null }; @@ -45,6 +45,17 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/" }; + // The local authorization server sample allows the client to select the external + // identity provider that will be used to eventually authenticate the user. For that, + // a custom "identity_provider" parameter is sent to the authorization server so that + // the user is directly redirected to GitHub (in this case, no login page is shown). + if (provider is "local-github") + { + // Note: the OWIN host requires appending the #string suffix to indicate + // that the "identity_provider" property is a public string parameter. + properties.Dictionary["identity_provider#string"] = "github"; + } + // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. context.Authentication.Challenge(properties, OpenIddictClientOwinDefaults.AuthenticationType); return new EmptyResult(); @@ -69,7 +80,7 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers // It is also suitable for applications that don't need to authenticate users but only need to perform // action(s) on their behalf by making API calls using the access token returned by the remote server. // - // * Storing the external claims/tokens in a database (and optionally keeping the essentials claims in an + // * Storing the external claims/tokens in a database (and optionally keeping the essential claims in an // authentication cookie so that cookie size limits are not hit). For the applications that use ASP.NET // Core Identity, the UserManager.SetAuthenticationTokenAsync() API can be used to store external tokens. // @@ -136,16 +147,15 @@ namespace OpenIddict.Sandbox.AspNet.Client.Controllers nameType: ClaimTypes.Name, roleType: ClaimTypes.Role); - // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. - var properties = new AuthenticationProperties(new Dictionary - { - [Tokens.BackchannelAccessToken] = GetProperty(result.Properties, Tokens.BackchannelAccessToken), - [Tokens.RefreshToken] = GetProperty(result.Properties, Tokens.RefreshToken) - }) + var properties = new AuthenticationProperties { RedirectUri = result.Properties.RedirectUri }; + // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. + properties.Dictionary[Tokens.BackchannelAccessToken] = GetProperty(result.Properties, Tokens.BackchannelAccessToken); + properties.Dictionary[Tokens.RefreshToken] = GetProperty(result.Properties, Tokens.RefreshToken); + context.Authentication.SignIn(properties, identity); return Redirect(properties.RedirectUri); diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/OpenIddict.Sandbox.AspNet.Client.csproj b/sandbox/OpenIddict.Sandbox.AspNet.Client/OpenIddict.Sandbox.AspNet.Client.csproj index a2401c2c..47d1c57f 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/OpenIddict.Sandbox.AspNet.Client.csproj +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/OpenIddict.Sandbox.AspNet.Client.csproj @@ -15,11 +15,9 @@ - - - + diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs index fb8d6054..c18fcd9c 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Startup.cs @@ -29,7 +29,7 @@ namespace OpenIddict.Sandbox.AspNet.Client SlidingExpiration = false }); - // Register the OpenIddict client middleware. + // Register the OpenIddict middleware. app.UseMiddlewareFromContainer(); // Configure ASP.NET MVC 5.2 to use Autofac when activating controller instances. @@ -68,7 +68,7 @@ namespace OpenIddict.Sandbox.AspNet.Client { // Enable the redirection endpoint needed to handle the callback stage. // - // Note: to prevent mix-up attacks, it's recommended to use a unique redirection endpoint + // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint // 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. diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Client/Views/Home/Index.cshtml b/sandbox/OpenIddict.Sandbox.AspNet.Client/Views/Home/Index.cshtml index cdacc33e..2d9c7ff7 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Client/Views/Home/Index.cshtml +++ b/sandbox/OpenIddict.Sandbox.AspNet.Client/Views/Home/Index.cshtml @@ -37,6 +37,8 @@

Welcome, anonymous

@Html.ActionLink("Sign in using the local OIDC server", "Login", "Authentication", new { provider = "local" }, new { @class = "btn btn-lg btn-success" }) + @Html.ActionLink("Sign in using the local OIDC server (using GitHub delegation)", "Login", "Authentication", + new { provider = "local-github" }, new { @class = "btn btn-lg btn-success" }) @Html.ActionLink("Sign in using GitHub", "Login", "Authentication", new { provider = "github" }, new { @class = "btn btn-lg btn-success" }) @Html.ActionLink("Sign in using Google", "Login", "Authentication", diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs new file mode 100644 index 00000000..499a1e61 --- /dev/null +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthenticationController.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using System.Web; +using System.Web.Mvc; +using Microsoft.AspNet.Identity; +using Microsoft.Owin.Security; +using OpenIddict.Client.Owin; +using static OpenIddict.Client.Owin.OpenIddictClientOwinConstants; + +namespace OpenIddict.Sandbox.AspNet.Server.Controllers +{ + 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. + [AcceptVerbs("GET", "POST"), Route("~/signin-{provider}")] + public async Task Callback() + { + var context = HttpContext.GetOwinContext(); + + // Retrieve the authorization data validated by OpenIddict as part of the callback handling. + var result = await context.Authentication.AuthenticateAsync(OpenIddictClientOwinDefaults.AuthenticationType); + + // Multiple strategies exist to handle OAuth 2.0/OpenID Connect callbacks, each with their pros and cons: + // + // * Directly using the tokens to perform the necessary action(s) on behalf of the user, which is suitable + // for applications that don't need a long-term access to the user's resources or don't want to store + // access/refresh tokens in a database or in an authentication cookie (which has security implications). + // It is also suitable for applications that don't need to authenticate users but only need to perform + // action(s) on their behalf by making API calls using the access token returned by the remote server. + // + // * Storing the external claims/tokens in a database (and optionally keeping the essential claims in an + // authentication cookie so that cookie size limits are not hit). For the applications that use ASP.NET + // Core Identity, the UserManager.SetAuthenticationTokenAsync() API can be used to store external tokens. + // + // Note: in this case, it's recommended to use column encryption to protect the tokens in the database. + // + // * Storing the external claims/tokens in an authentication cookie, which doesn't require having + // a user database but may be affected by the cookie size limits enforced by most browser vendors + // (e.g Safari for macOS and Safari for iOS/iPadOS enforce a per-domain 4KB limit for all cookies). + // + // Note: this is the approach used here, but the external claims are first filtered to only persist + // a few claims like the user identifier. The same approach is used to store the access/refresh tokens. + + // Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint, + // result.Principal.Identity will represent an unauthenticated identity and won't contain any claim. + // + // Such identities cannot be used as-is to build an authentication cookie in ASP.NET (as the + // antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity) but + // the access/refresh tokens can be retrieved using result.Properties.GetTokens() to make API calls. + if (result.Identity is not ClaimsIdentity { IsAuthenticated: true }) + { + throw new InvalidOperationException("The external authorization data cannot be used for authentication."); + } + + // Build an identity based on the external claims and that will be used to create the authentication cookie. + // + // By default, all claims extracted during the authorization dance are available. The claims collection stored + // in the cookie can be filtered out or mapped to different names depending the claim name or its issuer. + var claims = new List(result.Identity.Claims + .Select(claim => claim switch + { + // Note: when using external authentication providers with ASP.NET Core Identity, + // the "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" claim + // - which is not configurable in Identity - MUST be used to store the user identifier. + { Type: "id", Issuer: "https://github.com/" } + => new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer), + + _ => claim + }) + .Where(claim => claim switch + { + // Preserve the "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" claim. + { Type: ClaimTypes.NameIdentifier } => true, + + // Applications that use multiple client registrations can filter claims based on the issuer. + { Type: "bio", Issuer: "https://github.com/" } => true, + + // Don't preserve the other claims. + _ => false + })); + + // The antiforgery components require both the 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", claims[0].Issuer)); + + var identity = new ClaimsIdentity(claims, + authenticationType: DefaultAuthenticationTypes.ExternalCookie, + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role); + + var properties = new AuthenticationProperties + { + RedirectUri = result.Properties.RedirectUri + }; + + // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. + properties.Dictionary[Tokens.BackchannelAccessToken] = GetProperty(result.Properties, Tokens.BackchannelAccessToken); + properties.Dictionary[Tokens.RefreshToken] = GetProperty(result.Properties, Tokens.RefreshToken); + + context.Authentication.SignIn(properties, identity); + return Redirect(properties.RedirectUri); + + static string GetProperty(AuthenticationProperties properties, string name) + => properties.Dictionary.TryGetValue(name, out var value) ? value : string.Empty; + } + } +} diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs index ae359848..dec74133 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Controllers/AuthorizationController.cs @@ -14,9 +14,10 @@ using System.Web.Mvc; using Microsoft.AspNet.Identity; using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin.Security; +using OpenIddict.Abstractions; +using OpenIddict.Client.Owin; using OpenIddict.Sandbox.AspNet.Server.Helpers; using OpenIddict.Sandbox.AspNet.Server.ViewModels.Authorization; -using OpenIddict.Abstractions; using OpenIddict.Server.Owin; using Owin; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -53,8 +54,52 @@ namespace OpenIddict.Sandbox.AspNet.Server.Controllers if (result?.Identity == null || (request.MaxAge != null && result.Properties?.IssuedUtc != null && DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) { - context.Authentication.Challenge(DefaultAuthenticationTypes.ApplicationCookie); + // For applications that want to allow the client to select the external authentication provider + // that will be used to authenticate the user, the identity_provider parameter can be used for that. + if (!string.IsNullOrEmpty(request.IdentityProvider)) + { + var issuer = request.IdentityProvider switch + { + "github" => "https://github.com/", + + _ => null + }; + + if (string.IsNullOrEmpty(issuer)) + { + context.Authentication.Challenge( + authenticationTypes: OpenIddictServerOwinDefaults.AuthenticationType, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerOwinConstants.Properties.Error] = Errors.InvalidRequest, + [OpenIddictServerOwinConstants.Properties.ErrorDescription] = + "The specified identity provider is not valid." + })); + + return new EmptyResult(); + } + 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 + }) + { + // Once the callback is handled, redirect the user agent to the ASP.NET Identity + // page responsible for showing the external login confirmation form if necessary. + RedirectUri = Url.Action("ExternalLoginCallback", "Account", new + { + ReturnUrl = Request.RawUrl + }) + }; + + // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. + context.Authentication.Challenge(properties, OpenIddictClientOwinDefaults.AuthenticationType); + return new EmptyResult(); + } + + context.Authentication.Challenge(DefaultAuthenticationTypes.ApplicationCookie); return new EmptyResult(); } diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/OpenIddict.Sandbox.AspNet.Server.csproj b/sandbox/OpenIddict.Sandbox.AspNet.Server/OpenIddict.Sandbox.AspNet.Server.csproj index 7366731a..8999f06c 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/OpenIddict.Sandbox.AspNet.Server.csproj +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/OpenIddict.Sandbox.AspNet.Server.csproj @@ -15,11 +15,9 @@
+ - - - diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs index d507ecf1..30f5517c 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs @@ -9,6 +9,7 @@ using Autofac.Integration.WebApi; using Microsoft.Extensions.DependencyInjection; using Microsoft.Owin; using OpenIddict.Abstractions; +using OpenIddict.Client.Owin; using OpenIddict.Sandbox.AspNet.Server.Models; using OpenIddict.Server.Owin; using OpenIddict.Validation.Owin; @@ -29,7 +30,8 @@ namespace OpenIddict.Sandbox.AspNet.Server // Register the Autofac scope injector middleware. app.UseAutofacLifetimeScopeInjector(container); - // Register the two OpenIddict server/validation middleware. + // Register the OpenIddict middleware. + app.UseMiddlewareFromContainer(); app.UseMiddlewareFromContainer(); app.UseMiddlewareFromContainer(); @@ -118,6 +120,39 @@ namespace OpenIddict.Sandbox.AspNet.Server // .UseDatabase(new MongoClient().GetDatabase("openiddict")); }) + // Register the OpenIddict client components. + .AddClient(options => + { + // Enable the redirection endpoint needed to handle the callback stage. + // + // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint + // 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"); + + // Register the signing and encryption credentials used to protect + // sensitive data like the state tokens produced by OpenIddict. + options.AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + // Register the OWIN host and configure the OWIN-specific options. + options.UseOwin() + .EnableRedirectionEndpointPassthrough(); + + // Register the System.Net.Http integration. + options.UseSystemNetHttp(); + + // Register the Web providers integrations. + options.UseWebProviders() + .AddGitHub(new() + { + ClientId = "c4ade52327b01ddacff3", + ClientSecret = "da6bed851b75e317bf6b2cb67013679d9467c122", + RedirectUri = new Uri("https://localhost:44349/signin-github", UriKind.Absolute) + }); + }) + // Register the OpenIddict server components. .AddServer(options => { diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs index 08634d8d..e5386622 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/AuthenticationController.cs @@ -14,10 +14,10 @@ public class AuthenticationController : Controller { var issuer = provider switch { - "local" => "https://localhost:44395/", - "github" => "https://github.com/", - "google" => "https://accounts.google.com/", - "reddit" => "https://www.reddit.com/", + "local" or "local-github" => "https://localhost:44395/", + "github" => "https://github.com/", + "google" => "https://accounts.google.com/", + "reddit" => "https://www.reddit.com/", _ => null }; @@ -38,6 +38,15 @@ public class AuthenticationController : Controller RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/" }; + // The local authorization server sample allows the client to select the external + // identity provider that will be used to eventually authenticate the user. For that, + // a custom "identity_provider" parameter is sent to the authorization server so that + // the user is directly redirected to GitHub (in this case, no login page is shown). + if (provider is "local-github") + { + properties.Parameters["identity_provider"] = "github"; + } + // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); } @@ -59,7 +68,7 @@ public class AuthenticationController : Controller // It is also suitable for applications that don't need to authenticate users but only need to perform // action(s) on their behalf by making API calls using the access token returned by the remote server. // - // * Storing the external claims/tokens in a database (and optionally keeping the essentials claims in an + // * Storing the external claims/tokens in a database (and optionally keeping the essential claims in an // authentication cookie so that cookie size limits are not hit). For the applications that use ASP.NET // Core Identity, the UserManager.SetAuthenticationTokenAsync() API can be used to store external tokens. // @@ -87,7 +96,7 @@ public class AuthenticationController : Controller // // By default, all claims extracted during the authorization dance are available. The claims collection stored // in the cookie can be filtered out or mapped to different names depending the claim name or its issuer. - var claims = result.Principal.Claims + var claims = new List(result.Principal.Claims .Select(claim => claim switch { // Applications can map non-standard claims issued by specific issuers to a standard equivalent. @@ -106,16 +115,21 @@ public class AuthenticationController : Controller // Don't preserve the other claims. _ => false - }); + })); var identity = new ClaimsIdentity(claims, authenticationType: CookieAuthenticationDefaults.AuthenticationScheme, nameType: Claims.Name, roleType: Claims.Role); + var properties = new AuthenticationProperties + { + RedirectUri = result.Properties.RedirectUri + }; + // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. // To make cookies less heavy, tokens that are not used can be filtered out before creating the cookie. - var tokens = result.Properties.GetTokens().Where(token => token switch + properties.StoreTokens(result.Properties.GetTokens().Where(token => token switch { // Preserve the access and refresh tokens returned in the token response, if available. { @@ -125,14 +139,7 @@ public class AuthenticationController : Controller // Ignore the other tokens. _ => false - }); - - var properties = new AuthenticationProperties - { - RedirectUri = result.Properties.RedirectUri - }; - - properties.StoreTokens(tokens); + })); // Note: "return SignIn(...)" cannot be directly used in this case, as the cookies handler doesn't allow // redirecting from an endpoint that doesn't match the path set in CookieAuthenticationOptions.LoginPath. diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/OpenIddict.Sandbox.AspNetCore.Client.csproj b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/OpenIddict.Sandbox.AspNetCore.Client.csproj index 5a4463ab..4423f9ad 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/OpenIddict.Sandbox.AspNetCore.Client.csproj +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/OpenIddict.Sandbox.AspNetCore.Client.csproj @@ -7,10 +7,7 @@ - - - - + diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs index 5951857c..adcd8cea 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs @@ -76,7 +76,7 @@ public class Startup { // Enable the redirection endpoint needed to handle the callback stage. // - // Note: to prevent mix-up attacks, it's recommended to use a unique redirection endpoint + // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint // 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. diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Views/Home/Index.cshtml b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Views/Home/Index.cshtml index 57f66293..dd2445a5 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Views/Home/Index.cshtml +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Views/Home/Index.cshtml @@ -33,6 +33,8 @@

Welcome, anonymous

Sign in using the local OIDC server + Sign in using the local OIDC server (using GitHub delegation) Sign in using GitHub Callback() + { + // Retrieve the authorization data validated by OpenIddict as part of the callback handling. + var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + + // Multiple strategies exist to handle OAuth 2.0/OpenID Connect callbacks, each with their pros and cons: + // + // * Directly using the tokens to perform the necessary action(s) on behalf of the user, which is suitable + // for applications that don't need a long-term access to the user's resources or don't want to store + // access/refresh tokens in a database or in an authentication cookie (which has security implications). + // It is also suitable for applications that don't need to authenticate users but only need to perform + // action(s) on their behalf by making API calls using the access token returned by the remote server. + // + // * Storing the external claims/tokens in a database (and optionally keeping the essential claims in an + // authentication cookie so that cookie size limits are not hit). For the applications that use ASP.NET + // Core Identity, the UserManager.SetAuthenticationTokenAsync() API can be used to store external tokens. + // + // Note: in this case, it's recommended to use column encryption to protect the tokens in the database. + // + // * Storing the external claims/tokens in an authentication cookie, which doesn't require having + // a user database but may be affected by the cookie size limits enforced by most browser vendors + // (e.g Safari for macOS and Safari for iOS/iPadOS enforce a per-domain 4KB limit for all cookies). + // + // Note: this is the approach used here, but the external claims are first filtered to only persist + // a few claims like the user identifier. The same approach is used to store the access/refresh tokens. + + // Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint, + // result.Principal.Identity will represent an unauthenticated identity and won't contain any claim. + // + // Such identities cannot be used as-is to build an authentication cookie in ASP.NET Core (as the + // antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity) but + // the access/refresh tokens can be retrieved using result.Properties.GetTokens() to make API calls. + if (result.Principal.Identity is not ClaimsIdentity { IsAuthenticated: true }) + { + throw new InvalidOperationException("The external authorization data cannot be used for authentication."); + } + + // Build an identity based on the external claims and that will be used to create the authentication cookie. + // + // By default, all claims extracted during the authorization dance are available. The claims collection stored + // in the cookie can be filtered out or mapped to different names depending the claim name or its issuer. + var claims = new List(result.Principal.Claims + .Select(claim => claim switch + { + // Note: when using external authentication providers with ASP.NET Core Identity, + // the "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" claim + // - which is not configurable in Identity - MUST be used to store the user identifier. + { Type: "id", Issuer: "https://github.com/" } + => new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer), + + _ => claim + }) + .Where(claim => claim switch + { + // Preserve the "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" claim. + { Type: ClaimTypes.NameIdentifier } => true, + + // Applications that use multiple client registrations can filter claims based on the issuer. + { Type: "bio", Issuer: "https://github.com/" } => true, + + // Don't preserve the other claims. + _ => false + })); + + // Note: when using external authentication providers with ASP.NET Core Identity, + // the "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" claim + // - which is not configurable in Identity - MUST be used to store the user identifier. + var identity = new ClaimsIdentity(claims, + authenticationType: IdentityConstants.ExternalScheme, + nameType: ClaimTypes.NameIdentifier, + roleType: ClaimTypes.Role); + + var properties = new AuthenticationProperties + { + RedirectUri = result.Properties.RedirectUri + }; + + // Store the identity of the external provider in the authentication properties to allow + // ASP.NET Core Identity to resolve it when returning the external login confirmation form. + properties.Items["LoginProvider"] = claims[0].Issuer; + + // If needed, the tokens returned by the authorization server can be stored in the authentication cookie. + // To make cookies less heavy, tokens that are not used can be 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. + { + Name: OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or + OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken + } => true, + + // Ignore the other tokens. + _ => false + })); + + // Note: "return SignIn(...)" cannot be directly used in this case, as the cookies handler doesn't allow + // redirecting from an endpoint that doesn't match the path set in CookieAuthenticationOptions.LoginPath. + // For more information about this restriction, visit https://github.com/dotnet/aspnetcore/issues/36934. + await HttpContext.SignInAsync(IdentityConstants.ExternalScheme, new ClaimsPrincipal(identity), properties); + + return Redirect(properties.RedirectUri); + } +} diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs index 6d7f9dcc..e217f0c0 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Controllers/AuthorizationController.cs @@ -11,10 +11,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; +using OpenIddict.Abstractions; +using OpenIddict.Client.AspNetCore; using OpenIddict.Sandbox.AspNetCore.Server.Helpers; using OpenIddict.Sandbox.AspNetCore.Server.Models; using OpenIddict.Sandbox.AspNetCore.Server.ViewModels.Authorization; -using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -54,11 +55,15 @@ public class AuthorizationController : Controller var request = HttpContext.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); - // Retrieve the user principal stored in the authentication cookie. - // If a max_age parameter was provided, ensure that the cookie is not too old. - // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. + // Try to retrieve the user principal stored in the authentication cookie and redirect + // the user agent to the login page (or to an external provider) in the following cases: + // + // - If the user principal can't be extracted or the cookie is too old. + // - If prompt=login was specified by the client application. + // - If a max_age parameter was provided and the authentication cookie is not considered "fresh" enough. var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); - if (result == null || !result.Succeeded || (request.MaxAge != null && result.Properties?.IssuedUtc != null && + if (result == null || !result.Succeeded || request.HasPrompt(Prompts.Login) || + (request.MaxAge != null && result.Properties?.IssuedUtc != null && DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) { // If the client application requested promptless authentication, @@ -74,19 +79,6 @@ public class AuthorizationController : Controller })); } - return Challenge( - authenticationSchemes: IdentityConstants.ApplicationScheme, - properties: new AuthenticationProperties - { - RedirectUri = Request.PathBase + Request.Path + QueryString.Create( - Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) - }); - } - - // If prompt=login was specified by the client application, - // immediately return the user agent to the login page. - if (request.HasPrompt(Prompts.Login)) - { // To avoid endless login -> authorization redirects, the prompt=login flag // is removed from the authorization request payload before redirecting the user. var prompt = string.Join(" ", request.GetPrompts().Remove(Prompts.Login)); @@ -97,6 +89,48 @@ public class AuthorizationController : Controller parameters.Add(KeyValuePair.Create(Parameters.Prompt, new StringValues(prompt))); + // For applications that want to allow the client to select the external authentication provider + // that will be used to authenticate the user, the identity_provider parameter can be used for that. + if (!string.IsNullOrEmpty(request.IdentityProvider)) + { + var issuer = request.IdentityProvider switch + { + "github" => "https://github.com/", + + _ => null + }; + + if (string.IsNullOrEmpty(issuer)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidRequest, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The specified identity provider is not valid." + })); + } + + 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 + }) + { + // Once the callback is handled, redirect the user agent to the ASP.NET Identity + // page responsible for showing the external login confirmation form if necessary. + RedirectUri = Url.Action("ExternalLoginCallback", "Account", new + { + ReturnUrl = Request.PathBase + Request.Path + QueryString.Create(parameters) + }) + }; + + // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. + return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + } + return Challenge( authenticationSchemes: IdentityConstants.ApplicationScheme, properties: new AuthenticationProperties diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/OpenIddict.Sandbox.AspNetCore.Server.csproj b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/OpenIddict.Sandbox.AspNetCore.Server.csproj index 5f291bde..5ab1bf0f 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/OpenIddict.Sandbox.AspNetCore.Server.csproj +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/OpenIddict.Sandbox.AspNetCore.Server.csproj @@ -9,13 +9,10 @@ + - - - - diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs index 57b0832e..924fd2eb 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs @@ -76,6 +76,40 @@ public class Startup options.UseQuartz(); }) + // Register the OpenIddict client components. + .AddClient(options => + { + // Enable the redirection endpoint needed to handle the callback stage. + // + // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint + // 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"); + + // Register the signing and encryption credentials used to protect + // sensitive data like the state tokens produced by OpenIddict. + options.AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + // Register the ASP.NET Core host and configure the ASP.NET Core-specific options. + options.UseAspNetCore() + .EnableStatusCodePagesIntegration() + .EnableRedirectionEndpointPassthrough(); + + // Register the System.Net.Http integration. + options.UseSystemNetHttp(); + + // Register the Web providers integrations. + options.UseWebProviders() + .AddGitHub(new() + { + ClientId = "c4ade52327b01ddacff3", + ClientSecret = "da6bed851b75e317bf6b2cb67013679d9467c122", + RedirectUri = new Uri("https://localhost:44395/signin-github", UriKind.Absolute) + }); + }) + // Register the OpenIddict server components. .AddServer(options => { diff --git a/src/OpenIddict.Client/OpenIddictClientBuilder.cs b/src/OpenIddict.Client/OpenIddictClientBuilder.cs index 46a0c202..9befa13e 100644 --- a/src/OpenIddict.Client/OpenIddictClientBuilder.cs +++ b/src/OpenIddict.Client/OpenIddictClientBuilder.cs @@ -975,7 +975,7 @@ public class OpenIddictClientBuilder /// If an empty array is specified, the endpoint will be considered disabled. /// /// - /// Note: to prevent mix-up attacks, it's recommended to use a unique redirection endpoint + /// Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint /// 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. @@ -997,7 +997,7 @@ public class OpenIddictClientBuilder /// If an empty array is specified, the endpoint will be considered disabled. /// /// - /// Note: to prevent mix-up attacks, it's recommended to use a unique redirection endpoint + /// Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint /// 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.