All About Auth in .NET Core

This is a consolidated knowledge base for working with authentication and authorization in .NET Core/.NET 5. This not only covers how wrapt deals with auth, but also touches on everything from defining common jargon around auth and how to set up an authentication server.

The Basics

Difference Between Authentication and Authorization

When you hear 'Auth', it usually means one of two things:

  1. Authentication: are you who you say you are?
  2. Authorization: do you have permission to access something?

Authentication

Figuring out if you are are you say you are is a very complex process performed by a distinct Authentication Server. There are several providers that you can offload this responsibility to including Auth0, Okta, and Fusion Auth to name a few. Microsoft Azure and AWS also have Azure Active Directory and AWS Cognito respectively as well. Unless you have access to Security SMEs, it is highly recommended that you use a provider like one of these whenever possible. With that said, if you do decide to home roll your Authorization Server, Duende (previously Identity Server 4), will give you a good foundation.

Regardless, of your choice, Wrapt Web APIs are built to be authentication provider agnostic. These docs will lay out what you'll want to consider when setting up your Authentication Server using a variety of providers, but be sure to review your providers documentation as well for specific implementation details.

Authorization

Authorization is making sure that you have permissions to do something. In the context of a client application, in order to prove that you have permission to access something, when a user wants to access the protected endpoints of a web API, it needs to present what's called an Access Token as proof that you have the necessary permissions to access that api resource. This Access Token is returned when a user is successfully authenticated by the Authentication Server and will be passed to the Web API to authorize the user for a given set of actions based on the permissions returned in the Access Token.

This Access Token is generally implemented as a JSON Web Token (JWT) and contains, in part, the permissions that can executed using that token. These permissions are expressed as something called scopes. When the Access Token is passed along to the API, the API will inspect the scope in the token to ensure that the appropriate permissions were granted to call that particular endpoint.

It's also important to note that this Access Token does not contain any identity information. This information can be returned in an Identity Token, generally by hitting the user info endpoint (you can check the well-known doc of your authorization server for this).

OAuth2 and OpenID Connect

Some additional terminology that you'll hear thrown around is OAuth2.0 and OpenId Connect.

OAuth2.0 is the industry-standard protocol that standardizes authorization communication between apps and services. OAuth 2.0 defines several different workflows (called flows) to deal with this process.

OpenId Connect is a simple identity layer on top of the OAuth2.0 to convey the concept of a user. A client application can request an identity token (next to an access token) and use it to sign in to the client application. This token can then be used to sign into an API.

Bottom line: OpenId Connect is a protocol on top of OAuth2.0 that prevents the misuse of OAuth2.0 for identity authentication. OAuth2.0 is still what we use for authorization and permissions.

What Are Current Best Practices?

So we have OAuth2.0 to tell us what the best practices are in the industry, this should all be pretty straight forward, right? Well, best practices have changed a lot over the years.

Luckily there have been a lot of lessons learned and the community has consolidated all of the different options in OAuth 2.0 into two authorization flows
that developers should be using in their applications (see the upcoming OAuth 2.1 specification for details). Even better, the flow that we choose depends on the interaction we have.

Client Credentials Flow

If you have Machine to Machine communications with no interaction from a human being, then you should be using the ClientCredentials flow. What does that mean though?

Basically you have a client, an authorization server (Duende, Okta, etc. to make sure you are who you say you are), and you have resources/apis. The authorization server is configured to know about all of the clients, all the apis, and which clients can access which apis.

So using the ClientCredentials flow, the client talks to the authorization server to authenticate itself, generally, using a request using the client id (unique id for the client), client secret (the unique secret value prove who you are), the flow that you are using, and the proper content type header.

curl--location--request POST 'https://localhost:5010/connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data - urlencode 'grant_type=client_credentials' \
--data - urlencode 'client_id=postman' \
--data - urlencode 'client_secret=19451648-6618-4d02-b5b0-14382e24307f'

If the client has the appropriate access, the authorization server will return what's called an access token that can then be used by the client to access the apis.

It is worth noting that this example uses a client secret which, while acceptable, can be hardened using asymmetric keys. For further discussion see the M2M portion of Dominick Baier's NDC 2020 talk and this page of the Duende docs.

Code Flow with PKCE

For anything else, you have some kind of a user interacting with a client. In these situations, you want to use a code flow with something called PKCE, also known as Proof Key for Code Exchange.

So how do we use this code flow?

  1. We start as a user providing some input to our browser or native application, like logging in for instance. This would generally start by hitting an /authorize endpoint and submit a request to the authorization server like this:

      /authorize?
      client_id=orderingapp
      &redirect_uri=https://ordering.com/
      &response_type=code
      &scope=order:write
      &state=someId
      &code_challenge=hash(verifier)
    

    Where:

    • client_id=orderingapp is the id of the client application as designated on the authorization server
    • redirect_uri=https://ordering.com is the uri that we are redirected to after authorization is complete
    • response_type=code is designating that this request is a code flow
    • scope=order:write is a space delimited list of the scopes (permissions) that we are requesting
    • state=someId is an extra piece of info that helps bind the request to a user agent. This helps protect against XSRF attacks by essentially making sure that this info persists across the flow.
    • code_challenge=hash(verifier) is used for the PKCE flow as what is essentially a per request secret
  2. Once we've made that request, the client app hands over control to the authorization server to show whatever UI it wants, i.e. some kind of log in page to enter your user credentials.

  3. Next, the use can optionally be asked for consent for certain access. This depends on what you chose as your implementation details. For example, you login with Google and you consent to give the client access to your name and email address.

  4. Next, the authorization server will send a authorization code. The client (on the server) will then make a request to the authorization server that includes the authorization code.

  5. When the authorization server gets this request, it will check that all the info matches what it just sent out to the browser and, if it does, it with issue an access token to the client that can be used to make authorized requests to your API. This access token is generally a JWT that includes the access token as well as a refresh token that we can use to refresh our token after it expires. Depending on the scopes you requested, you could also get an identity token as well to designate user information.

When does this PKCE thing come into play though? Well, the PKCE part of this essentially just means that, in addition to providing client secret with your /authorize request that is managed between the authorization server and the client, a secret hash will be dynamically generated per request.

This video by Dominick Baier has a great walkthrough of recent best practices, specifically for the code flow at this timestamp.

How Does Wrapt Set Up Auth?

Wrapt lets you bring whatever Authentication Server you’d like to the table and sets up several areas of authorization in your web APIs.

  1. JWT Service Registration
    • Registers the AddAuthorization service and configures it to expect JWT tokens from your given identity server.
    • Adds policies to register the permissions (scopes) used in your API.
  2. Controller Setup
    • Adds the appropriate Authorization and ProducesResponse attributes as well as xml comments to your controller endpoints.
  3. Swagger Integration
    • Adds a SecurityRequirement to describe how the token is being returned to swagger. In this case, it's using an OAuth2 scheme and designating the location of the API key.
    • Adds SecurityDefinitions to tell swagger the how your api is protected. This includes the authorization and token urls (the endpoints that should be hit to authorize the request and retrieve a refresh token respectively). It also includes all the scopes (permissions) that the user can select in swagger.
    • Adds additional SwaggerUI configuration. This will populate the clientid and client secret for your users automatically when using swagger. There is also a config setting to ensure that PKCE security is used
  4. Integration Testing
    • Adds auth into your WebHostFactory.
    • Updates existing integration test to keep them passing.
    • Adds additional tests to confirm Unauthorized and Forbidden responses given improper scopes and auth requests (if applicable).

Example Authorization Server Setup

Identity Server 4 is a staple of the .NET community that has recently transitioned to Duende. I'll be using this in the Auth setup example to make sure it is as reproducible as possible, but this should have a comparable setup via other provider UI's like Auth0, Okta, etc.

This is not an exhaustive walkthrough as there are too many business specific needs that come into play, but this will give you a base to work from. Let's get an initial setup going:

  1. Install the appropriate dotnet templates

    dotnet new --install Duende.IdentityServer.Templates
    #OR
    dotnet new --install IdentityServer4.Templates
  2. Create the authorization server

    dotnet new isinmem -n MyAuthServerProjectName
    #OR
    dotnet new is4inmem -n MyAuthServerProjectName

    Note, you can check your authorization server config at https://localhost:5010/.well-known/openid-configuration where localhost:5010/ is your domain.

  3. Open the project and go to Config.cs. This is where your authorization server configuration lives.

  4. Make sure you have Identity Resources available. These are a list of claims that can be associated to the user using the scope parameter:

    public static IEnumerable<IdentityResource> IdentityResources =>
    	new IdentityResource[]
    	{
    	    new IdentityResources.OpenId(), // when openid is requested the subjectid/userid will be returned
    	    new IdentityResources.Profile(), // when profile is requested the given name and family name will be returned
    	};
  5. Add Api Resources. These define API in whatever organization your business has set up. This could be a single monolithic API, or a suite of smaller APIs. This Resource should include the scopes allowed by this resource. In relation to Wrapt projects. This can generally map 1:1 to the WebAPI projects generated with Craftsman.

    public static IEnumerable<ApiResource> ApiResources =>
        new List<ApiResource>
        {
            new ApiResource("invoice", "Invoice API")
            {
                Scopes = { "invoice.read", "invoice.pay", "manage", "enumerate" },
                ApiSecrets = { new Secret("secret".Sha256()) },
            },
    
            new ApiResource("customer", "Customer API")
            {
                Scopes = { "customer.read", "customer.contact", "manage", "enumerate" }
            }
        };

    Note that the secret included in the ApiResource should include any client secrets using it below

    You'll also need to register the ApiResource service in your Startup.cs file. This should make your in-memory block look like this:

    builder.AddInMemoryIdentityResources(Config.IdentityResources);
    builder.AddInMemoryApiScopes(Config.ApiScopes);
    builder.AddInMemoryApiResources(Config.ApiResources); // this is the new api resource registration
    builder.AddInMemoryClients(Config.Clients);

    While you're in Startup.cs, you can optionally remove the authentication service registration that includes google options or add additional configuration for that as well.

  6. Add Api Scopes. These scopes are the permissions that the client can request.

    public static IEnumerable<ApiScope> GetApiScopes()
    {
        return new List<ApiScope>
        {
            // invoice API specific scopes
            new ApiScope(name: "invoice.read",   displayName: "Reads your invoices."),
            new ApiScope(name: "invoice.pay",    displayName: "Pays your invoices."),
    
            // customer API specific scopes
            new ApiScope(name: "customer.read",    displayName: "Reads you customers information."),
            new ApiScope(name: "customer.contact", displayName: "Allows contacting one of your customers."),
    
            // shared scopes
            new ApiScope(name: "manage",    displayName: "Provides administrative access.")
            new ApiScope(name: "enumerate", displayName: "Allows enumerating data.")
        };
    }
  7. Set up the client(s) that can request access tokens from your auth server. As far as each property of the client:

    public static IEnumerable<Client> Clients =>
      new Client
          {
              ClientId = "Swagger",
              ClientName = "Swagger UI",
              ClientSecrets = { new Secret("secret".Sha256()) },
    
              AllowedGrantTypes = GrantTypes.Code,
              AllowOfflineAccess = true,
              RequirePkce = true,
              RequireClientSecret = false,
    
              RedirectUris = {"https://localhost:5000/swagger/oauth2-redirect.html"}, // If you have a base path for your Swagger UI, then also include it in your redirect URI (i.e. if you have the Swagger UI on /swagger, your redirect URI should be /swagger/oauth2-redirect.html). 
              PostLogoutRedirectUris = { "http://localhost:5000/" },
              FrontChannelLogoutUri =    "http://localhost:5000/signout-oidc",
              AllowedCorsOrigins = {"https://localhost:5000"},
              AllowedScopes = { "recipes.read", "recipes.add", "recipes.update", "recipes.delete", "openid", "profile" },
          },
          new Client
          {
              ClientId = "Postman",
              ClientName = "Postman UI",
              ClientSecrets = { new Secret("secret".Sha256()) },
    
              AllowedGrantTypes = GrantTypes.Code,
              AllowOfflineAccess = true,
              RequirePkce = true,
              RequireClientSecret = false,
    
              RedirectUris = { "https://oauth.pstmn.io/v1/callback" },
              AllowedScopes = { "recipes.read", "recipes.add", "recipes.update", "recipes.delete", "openid", "profile" },
          }
      };
  8. So something like this could be a complete Config.cs file:

// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.


using Duende.IdentityServer.Models;
using System.Collections.Generic;
{
    public static class Config
    {
        public static IEnumerable<IdentityResource> IdentityResources =>
            new IdentityResource[]
            {
                new IdentityResources.OpenId(), // when openid is requested the subjectid/userid will be returned
                new IdentityResources.Profile(), // when profile is requested the given name and family name will be returned
            };

        public static IEnumerable<ApiResource> ApiResources =>
            new ApiResource[]
            {
                new ApiResource("patients", "Patients API")
                {
                    Scopes = { "patients.read", "patients.add", "patients.update", "patients.delete" },
                    ApiSecrets = { new Secret("secret".Sha256()) },
                }
            };

        // allow access to identity information. client level rules of who can access what (e.g. read:sample, read:order, create:order, read:report)
        // this will be in the audience claim and will be checked by the jwt middleware to grant access or not
        public static IEnumerable<ApiScope> ApiScopes =>
            new ApiScope[]
            {
                new ApiScope("patients.read", "CanReadPatients"),
                new ApiScope("patients.add", "CanAddPatients"),
                new ApiScope("patients.update", "CanUpdatePatients"),
                new ApiScope("patients.delete", "CanDeletePatients"),
            };

        // clients that have access to each scope
        public static IEnumerable<Client> Clients =>
            new Client[]
            {
                new Client
                {
                    ClientId = "Swagger",
                    ClientName = "Swagger UI",
                    ClientSecrets = { new Secret("secret".Sha256()) },

                    AllowedGrantTypes = GrantTypes.Code,
                    AllowOfflineAccess = true,
                    RequirePkce = true,
                    RequireClientSecret = true,

                    RedirectUris = {"https://localhost:5000/swagger/oauth2-redirect.html"},
                    PostLogoutRedirectUris = { "http://localhost:5000/" },
                    FrontChannelLogoutUri =    "http://localhost:5000/signout-oidc",
                    AllowedCorsOrigins = {"https://localhost:5000"},
                    AllowedScopes = { "patients.read", "patients.add", "patients.update", "patients.delete", "openid", "profile" },
                },
                new Client
                {
                    ClientId = "Postman",
                    ClientName = "Postman UI",
                    ClientSecrets = { new Secret("secret".Sha256()) },

                    AllowedGrantTypes = GrantTypes.Code,
                    AllowOfflineAccess = true,
                    RequirePkce = true,
                    RequireClientSecret = true,

                    RedirectUris = { "https://oauth.pstmn.io/v1/callback" },
                    AllowedScopes = { "patients.read", "patients.add", "patients.update", "patients.delete", "openid", "profile" },
                }
            };
    }
}

Example Client Authorization Flows

Example OAuth2 Flow in Postman

  1. Make sure your Authentication Server is properly configured for this client (see the Duende example above for a sample).
  2. Open Postman and create a new POST request.
  3. Select the Authorization tab, select OAuth 2.0 for type, and fill in the appropriate information. Note that the scopes are space delimited.example postman client for auth
  4. Select GetNewAccessToken to get your token. This token can now be added to your Postman requests when working with your API.

Example OAuth2 Flow in Swagger

Wrapt APIs will automatically scaffold out the appropriate configuration for handling Authorization in your Swagger setup. Just run your API, go to your swagger page, click the Authorize button, select your scopes, and log in via OAuth.