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.
When you hear 'Auth', it usually means one of two things:
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 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).
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.
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.
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.
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?
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 serverredirect_uri=https://ordering.com
is the uri that we are redirected to after authorization is completeresponse_type=code
is designating that this request is a code
flowscope=order:write
is a space delimited list of the scopes (permissions) that we are requestingstate=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 secretOnce 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.
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.
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.
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.
Wrapt lets you bring whatever Authentication Server you’d like to the table and sets up several areas of authorization in your web APIs.
AddAuthorization
service and configures it to expect JWT tokens from your given identity server.Authorization
and ProducesResponse
attributes as well as xml comments to your controller endpoints.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.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.WebHostFactory
.Unauthorized
and Forbidden
responses given improper scopes and auth requests (if applicable).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:
Install the appropriate dotnet
templates
dotnet new --install Duende.IdentityServer.Templates
#OR
dotnet new --install IdentityServer4.Templates
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
wherelocalhost:5010/
is your domain.
Open the project and go to Config.cs
. This is where your authorization server configuration lives.
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
};
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.
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.")
};
}
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" },
}
};
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" },
}
};
}
}
POST
request.Authorization
tab, select OAuth 2.0
for type, and fill in the appropriate information. Note that the scopes are space delimited.GetNewAccessToken
to get your token. This token can now be added to your Postman requests when working with your API.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.