AspNet.Security.OAuth.Providers icon indicating copy to clipboard operation
AspNet.Security.OAuth.Providers copied to clipboard

Introducing the OpenIddict-powered providers

Open kevinchalet opened this issue 2 years ago • 0 comments

Earlier today, the first OpenIddict 4.0 preview was pushed to NuGet.org.

As part of this release, a new client stack was introduced alongside an OpenIddict.Client.WebIntegration package that aims at offering an alternative to the aspnet-contrib providers offered in this repository (that will still be developed and maintained).

As I suspect many users will wonder whether these new providers could be a nice fit for their applications, here's a list of things that differ between the aspnet-contrib providers and their equivalent in the OpenIddict world:

  • Instead of being built on top of the ASP.NET Core OAuth 2.0 base handler, these providers are based on the new OpenIddict client, which is a modern dual-protocol client stack that supports both OAuth 2.0 and OpenID Connect and thus is able to adapt its security checks to the protocol(s) supported by the provider (while we've accepted OpenID Connect providers in aspnet-contrib, not all the security checks normally required by the standard have been implemented).

  • Unlike the aspnet-contrib providers, most of the code behind the OpenIddict providers is generated using Roslyn Source Generators (e.g the settings, the builder methods, the environments, etc.), which makes them much easier to maintain and will eventually allow supporting more providers while greatly reducing the maintainance burden.

  • Unlike the ASP.NET Core OAuth 2.0 base handler, the OpenIddict client fully supports OpenID Connect discovery/OAuth 2.0 authorization server metadata, which allows discovering endpoint URLs dynamically, making the OpenIddict-based providers that support discovery more resilient to arbitrary endpoint changes.

  • The OpenIddict client is - by default - a stateful client that requires configuring a database for two reasons (note: if you already use the server feature, you can share the same DB):

    • By storing the status of state tokens in a database, the OpenIddict client is able to detect when they are used multiple times and protect against replay attacks, which is not something the ASP.NET Core OAuth 2.0 or OpenID Connect handlers offer by default.
    • By storing the content of state tokens in a database (what we often call "reference tokens"), the OpenIddict client is not impacted by the state size limits enforced by some services (like Twitter).
  • The OpenIddict providers use a System.Net.Http integration that relies on IHttpClientFactory and integrates Polly by default to automatically retry failed HTTP requests based on a built-in policy and thus be less prone to transient network errors.

  • The aspnet-contrib providers use an authentication scheme per provider, which means you can do [Authorize(AuthenticationSchemes = "Facebook")] to trigger an authentication dance. In contrast, the OpenIddict client uses a single authentication scheme and requires setting the issuer as an AuthenticationProperties item if multiple providers are registered:

[HttpGet("~/login")]
public ActionResult LogIn(string provider, string returnUrl)
{
    var issuer = provider switch
    {
        "github"  => "https://github.com/",
        "google"  => "https://accounts.google.com/",
        "reddit"  => "https://www.reddit.com/",
        "twitter" => "https://twitter.com/",

        _ => null
    };

    if (string.IsNullOrEmpty(issuer))
    {
        return BadRequest();
    }

    var properties = new AuthenticationProperties(new Dictionary<string, string>
    {
        // Note: when only one client is registered in the client options,
        // setting the issuer property is not required and can be omitted.
        [OpenIddictClientAspNetCoreConstants.Properties.Issuer] = issuer
    })
    {
        // 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 Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
}
  • For the same reason, the providers registered via the OpenIddict client are not listed by Identity's SignInManager.GetExternalAuthenticationSchemesAsync() and so don't appear in the "external providers" list returned by the default Identity UI. In practice, many users will prefer customizing this part to be more user-friendly, for instance by using localized provider names or logos, which is not something you can natively do with SignInManager.GetExternalAuthenticationSchemesAsync() anyway.

  • The OpenIddict client doesn't have the "delegate that ClaimsPrincipal instance to the cookie handler so it can create an authentication cookie based on it" logic you have in the aspnet-contrib handlers. Instead, you're encouraged to handle the external authentication data -> local authentication cookie creation in your own code, which gives you full control over what's stored exactly in the final authentication cookie:

// 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<ActionResult> 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<Claim>(result.Principal.Claims
        .Select(claim => claim switch
        {
            // Map the standard "sub" and custom "id" claims to ClaimTypes.NameIdentifier, which is
            // the default claim type used by .NET and is required by the antiforgery components.
            { Type: Claims.Subject } or
            { Type: "id", Issuer: "https://github.com/" or "https://twitter.com/" }
                => new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer),

            // Map the standard "name" claim to ClaimTypes.Name.
            { Type: Claims.Name }
                => new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer),

            _ => claim
        })
        .Where(claim => claim switch
        {
            // Preserve the nameidentifier and name claims.
            { Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => 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
        }));

    var identity = new ClaimsIdentity(claims,
        authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
        nameType: ClaimTypes.Name,
        roleType: ClaimTypes.Role);

    // Build the authentication properties based on the properties that were added when the challenge was triggered.
    var properties = new AuthenticationProperties(result.Properties.Items);

    // 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 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.
        {
            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(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), properties);

    return Redirect(properties.RedirectUri);
}
  • The OpenIddict client doesn't do any claims mapping: all the claims resolved from the identity token/userinfo response are flowed exactly as they were returned and it's up to the user to implement a custom mapping if necessary.

  • The OpenIddict providers are compatible with more .NET environments than the aspnet-contrib providers: they don't just work on ASP.NET Core (2.1 on .NET Framework, 3.1 on .NET Core, 6.0 and 7.0 on .NET) but they are also natively compatible with OWIN/Katana so they can be used in legacy ASP.NET >= 4.6.1 applications.

If you're interested in giving the OpenIddict providers a try, feel free to take a look at the sample in the OpenIddict repository.

The following providers are already part of OpenIddict 4.0 preview1, but if you'd like to see additional providers supported, please don't hesitate to contribute to the effort :smile:

Provider name
Apple
GitHub
Google
Microsoft
Reddit
Twitter

Cheers!

kevinchalet avatar Jun 22 '22 18:06 kevinchalet