Identity Server 4 is the tool of choice for getting bearer JSON web tokens (JWT) in .NET. The tool comes in a NuGet package that can fit in any ASP.NET project. Identity Server 4 is an implementation of the OAuth 2.0 spec and supports standard flows. The library is extensible to support parts of the spec that are still in draft.
Bearer JWT tokens are preferable to authenticate requests with a backend API. The JWT is stateless and aids in decoupling software modules. The JWT itself is not tied to the user session and works well in a distributed system. This reduces friction between modules since it does not share dependencies like a user session.
In this take, I’ll delve deep into Identity Server 4. This OAuth implementation is fully compatible with the spec. I’ll start from scratch with an ASP.NET Web API project using .Net Core. I’ll stick to the recommended version of .NET Core, which is 3.0.100 at the time of this writing. You can find a working sample of the code here.
To begin, I’ll use CLI tools to keep the focus on the code without visual aids from Visual Studio. To fire up an ASP.NET Web API project, create a new project folder, change directory into it, and do:
dotnet new webapi
The tooling should scaffold a project you can run. Add Identity Server 4 as a dependency:
dotnet add package IdentityServer4 --version 3.0.1
Doing this from Visual Studio works too if that is preferred. With this, I am ready to begin the integration of Identity Server 4 into this project. In the code samples, I’ll ignore using statements unless necessary to put more focus on the integration itself.
OAuth Token Grant Type Flows
Identity Server 4 supports flows such as authorization code with hybrid and implicit grant types. It supports device code for use cases that lack a browser. For this tutorial, I’ll focus on the most useful flows to protect resources:
- Client Credentials: When the client application is acting on its own behalf. Think of it as robots talking to other robots.
- Resource Owner Password Credentials: Exchange user credentials such username and password for an access token. The token uniquely identifies a person requesting access to protected resources. Think of it as an identity card you carry around to gain privileged access.
- Refresh Token: Request a new access token when the current access token becomes invalid or expires. Think of it as a long-lived token, and a way to renew access.
The use case is a person can log in with valid credentials to get tokens. As the access token expires, they can request new tokens with the refresh token. For applications where no one is driving the request, a client credential token can gain access.
Identity Server 4 Client Configuration
To get Identity Server 4 up off the ground, begin with client configuration. In OAuth lingo, a client is the uniquely identifiable app making token requests. Each client can set up allowed grant types and client scopes. These two decide which tokens the client can get from the identity provider. The identity provider is the authentication authority for generating and validating tokens.
Declare a ClientStore
class with the following implementation:
public class ClientStore { public static IEnumerable<ApiResource> GetApiResources() { return new List<ApiResource> { new ApiResource("all", "all") }; } public static IEnumerable<IdentityResource> GetIdentityResources() { return new List<IdentityResource> { new IdentityResources.OpenId() }; } public static IEnumerable<Client> GetClients() { return null; } }
I’ll revisit GetClients
as I flesh out client configuration for each grant type. There are scopes for client credential tokens in ApiResource
. For resource owner tokens, it needs scopes in IdentityResource
. Allowed scopes must appear here first before any one client can use them. Identity Server 4 supports client configuration from a back-end database. It comes with Entity Framework as the data access layer. For this project, I’ll stick to in-memory client configuration. The focus is on generating tokens.
Client registration goes in configuration that adds Identity Server 4 to this project. Open the Startup
class in a code editor and then:
services.AddIdentityServer(options => options.IssuerUri = "localhost") .AddInMemoryApiResources(ClientStore.GetApiResources()) .AddInMemoryIdentityResources(ClientStore.GetIdentityResources()) .AddInMemoryClients(ClientStore.GetClients()) .AddDeveloperSigningCredential(false);
I’m using a temporary signing credential to sign JWTs coming from this identity provider. Passing in false makes it to where the key does not persist on disk. This means it changes every time the app boots up. Identity Server 4 offers asymmetric RSA keys for local development. Asymmetric means there two separate keys. One private key to sign JWTs coming from the identity provider. One public so client apps can validate JWTs and check that they come from the right authority.
Token Request/Response
The Client Credential flow has the following request type:
- grant_type: This must be set to client_credential
- client_id: Uniquely identifies the client requesting tokens
- client_secret: Secret password only known to the client making the request
- scope: List of requested scopes that will go in the JWT to access protected resources
The Resource Owner Password Credential flow has the following request type:
- grant_type: This must be set to password
- username: The person’s username credential
- password: The person’s password credential
- client_id: The target client app they’re login into
- client_secret: The target client’s secret
- scope: Must be set to openid to request the access token. To get a refresh token, add offline_access.
And finally, the Refresh Token flow has the following request type:
- grant_type: This must be set to refresh_token
- client_id: The client app id where the access token came from
- client_secret: The client app secret, which comes from the client app itself
- refresh_token: The original refresh token that comes with the access token
Because all these flows have a lot in common, I’ll reduce this down to a single C# type. This frees me from having to repeat myself with all the different grant types.
For example:
public class TokenRequest { [FromForm(Name = "username")] public string Username { get; set; } [FromForm(Name = "password")] public string Password { get; set; } [FromForm(Name = "grant_type")] public string GrantType { get; set; } [FromForm(Name = "scope")] public string Scope { get; set; } [FromForm(Name = "refresh_token")] public string RefreshToken { get; set; } }
I opted to get request values from a form POST request because Identity Server 4 reads client id/secret data from a form content type. The client id/secret are not part of this TokenRequest
since it reads it off the raw request.
All grant type flows have the following response:
- access_token: A valid JWT. Comes with a sub claim for tokens that identify a person.
- refresh_token: Optional token for renewing the access token
- token_type: Must be set to “Bearer”
- expires_in: Token lifetime set in seconds
- error: Error code in case of a Bad Request response
- error_description: Optional descriptive message of the error code
In plain C#, this looks like this:
public class TokenResponse { [JsonProperty("access_token", DefaultValueHandling = DefaultValueHandling.Ignore)] public string AccessToken { get; set; } [JsonProperty("refresh_token", DefaultValueHandling = DefaultValueHandling.Ignore)] public string RefreshToken { get; set; } [JsonProperty("token_type", DefaultValueHandling = DefaultValueHandling.Ignore)] public string TokenType { get; set; } [JsonProperty("expires_in", DefaultValueHandling = DefaultValueHandling.Ignore)] public int? ExpiresIn { get; set; } [JsonProperty("error", DefaultValueHandling = DefaultValueHandling.Ignore)] public string Error { get; set; } [JsonProperty("error_description", DefaultValueHandling = DefaultValueHandling.Ignore)] public string ErrorDescription { get; set; } }
Use a Newtonsoft
attribute to format the JSON content-type response. Note DefaultValueHandling
is set to ignore to allow as many token responses without clobbering the response.
Token Provider
It’s time to generate tokens in Identity Server 4. I’ll abstract this with an ITokenProvider
interface:
public interface ITokenProvider { Task<TokenResponse> GetToken(TokenRequest request); }
One caveat is to avoid adding too many layers of indirection in front of Identity Server 4. At some point, the code might roll out its own security or reinvent the wheel. Here, I’m only adding this token provider and letting the library do the hard work.
Create a TokenProvider
class that implements ITokenProvider
with the following dependencies:
private readonly ITokenRequestValidator _requestValidator; private readonly IClientSecretValidator _clientValidator; private readonly ITokenResponseGenerator _responseGenerator; private readonly IHttpContextAccessor _httpContextAccessor;
These dependencies have the following using statements:
using IdentityServer4.ResponseHandling; using IdentityServer4.Validation;
Because Identity Server 4 works well with .NET Core, it knows how to get dependencies. Be sure to add them to the constructor and the library will do the rest of the job. This comes from service configuration in Startup
when it calls AddIdentityServer
. This is all thanks to .NET Core by putting dependency injection front and center. Dependencies are mockable, so it’s easy to write unit tests.
To implement the interface, do:
public async Task<TokenResponse> GetToken(TokenRequest request) { var parameters = new NameValueCollection { { "username", request.Username }, { "password", request.Password }, { "grant_type", request.GrantType }, { "scope", request.Scope }, { "refresh_token", request.RefreshToken }, { "response_type", OidcConstants.ResponseTypes.Token } }; var response = await GetIdpToken(parameters); return GetTokenResponse(response); }
Parameters are pass-through values since it already knows about the different grant types. I’m picking values straight from TokenRequest
and placing it in a NameValueCollection
. The response_type
says which kind of token it gets in the response. I’m setting this to Token
because I want an access token.
The private method GetIdpToken
gets a TokenResponse
from Identity Server 4. Because the name clashes with our own token response, set a using alias:
using IdpTokenResponse = IdentityServer4.ResponseHandling.TokenResponse;
Then, declare the private method as:
private async Task<IdpTokenResponse> GetIdpToken(NameValueCollection parameters) { var clientResult = await _clientValidator.ValidateAsync(_httpContextAccessor.HttpContext); if (clientResult.IsError) { return new IdpTokenResponse { Custom = new Dictionary<string, object> { { "Error", "invalid_client" }, { "ErrorDescription", "Invalid client/secret combination" } } }; } var validationResult = await _requestValidator.ValidateRequestAsync(parameters, clientResult); if (validationResult.IsError) { return new IdpTokenResponse { Custom = new Dictionary<string, object> { { "Error", validationResult.Error }, { "ErrorDescription", validationResult.ErrorDescription } } }; } return await _responseGenerator.ProcessAsync(validationResult); }
This does two things, validate client id/secret and token request, and generate tokens. Client id/secret data comes from HttpContext
because it reads it off the raw request. Errors get placed in a custom dictionary for later retrieval.
With the IdpTokenResponse
set, do the mapping to the TokenResponse
:
private static TokenResponse GetTokenResponse(IdpTokenResponse response) { if (response.Custom != null && response.Custom.ContainsKey("Error")) { return new TokenResponse { Error = response.Custom["Error"].ToString(), ErrorDescription = response.Custom["ErrorDescription"]?.ToString() }; } return new TokenResponse { AccessToken = response.AccessToken, RefreshToken = response.RefreshToken, ExpiresIn = response.AccessTokenLifetime, TokenType = "Bearer" }; }
As mentioned, the TokenType
must be set to a bearer type. This lets consuming apps know it’s meant as a bearer token. If there are any errors, set the Error
and ErrorDescription
property when available.
This completes token generation so go ahead and close this file. Note I’m writing pass-through code and letting Identity Server 4 do its work.
Token Endpoint
Add a TokenController
with the following dependency:
private readonly ITokenProvider _tokenProvider;
Because it needs to know how to inject this dependency, put this in the Startup
class:
services.AddTransient<ITokenProvider, TokenProvider>();
For now, the controller class should look like this:
[ApiController] [Route("[controller]")] public class TokenController { private readonly ITokenProvider _tokenProvider; public TokenController(ITokenProvider tokenProvider) { _tokenProvider = tokenProvider; } }
This means the endpoint will be /token
to make token requests. In the TokenController
put in place the form POST request:
[HttpPost] public async Task<ActionResult<TokenResponse>> Post([FromForm] TokenRequest request) { var response = await _tokenProvider.GetToken(request); if (!string.IsNullOrEmpty(response.Error)) { return new BadRequestObjectResult(response); } return response; }
The FromForm
attribute makes it so this grabs the TokenRequest
from a form POST. Note the quick check for an error and the 400 Bad Request response. The spec says it must be set to 400. It may respond with a 401 Unauthorized when client id/secret validation fails. And, it must respond with a 401 when client id/secret fails and is sent via the header. Because I’m sending client data through a form post, I’m keeping this simple.
Since Newtonsoft
formats the JSON response, be sure to add support for this:
dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson --version 3.0.0
services.AddControllers().AddNewtonsoftJson();
Also, I’m going to disable HTTPS redirection that comes with the project. I don’t want to have to deal with local certificates and whatnot. So, find this in Startup
and remove this from the project:
app.UseHttpsRedirection();
This allows local dev tools like curl.exe
which don’t support HTTPS redirection to work. One caveat is the spec does say the token endpoint must require TLS encryption. This is to avoid sending credentials in cleartext. Keep this in mind when the project is ready to ship.
With the token endpoint ready to serve requests. It’s time to start generating tokens. It’d be good to see each grant type sending back tokens.
Client Credential Token
The good news is most of the code is already in place. To finish this, it needs a client configuration that allows client credential tokens. Open up ClientStore
, make sure it’s in the GetClients
method where it returns null and replace it with this:
new Client { ClientName = "Client Credential Flow", ClientId = "client_credential_flow", AllowedGrantTypes = GrantTypes.ClientCredentials, ClientSecrets = { new Secret("client_credential_flow_secret".Sha256()) }, AllowedScopes = { "all" }, AllowOfflineAccess = false, AccessTokenLifetime = 60 }
The client secret itself gets hashed in the client store. Identity Server 4 treats client secrets like a password, so it must be hashed. Storing passwords in plain text will not work, so note the call to Sha256
. The AllowedGrantTypes
is set to the flow it can support. This means this client can only respond with client credential tokens. Each client configuration must have at least one scope. I’m using “all” as a default scope to indicate a catch-all scope. Setting AllowOfflineAccess
to false means this client does not support refresh tokens. Identity Server 4 does not allow refresh tokens in the client credentials flow. Client credential tokens are suitable for one-time use with a short lifetime.
Go ahead and fire up this project with dotnet watch run
. I like running .Net Core projects in watch mode, so it refreshes automatically. This means every time there’s a code change, it rebuilds and runs automatically.
You can use Postman to send requests to the endpoint, which is the tool I recommend. Because I don’t want to hit you with a bunch of fuzzy images from Postman, I’m using curl.exe
. This CLI tool ships with the latest public release of Windows 10.
To get client credential tokens from this endpoint, do:
curl.exe -d "grant_type=client_credentials& scope=all&client_id=client_credential_flow& client_secret=client_credential_flow_secret" http://localhost:5000/token
Note all the required parameters to get client cred tokens are there. This curl command needs to go in a single line. Everything in the double quotes shouldn’t have any spaces.
If you don’t see a response, check that there isn’t any HTTPS redirection. Curl has an
--include
flag that shows response headers. The default project template responds with a 307 Temporary Redirect response which is not supported.
This is what the response looks like:
{ "access_token": "eyJhbG…E60uIFh-LE5Pi-HNdaSslPaZdxyDHVkk5NA", "token_type": "Bearer", "expires_in": 60 }
Resource Owner Token
The resource owner token flow has the following client configuration:
new Client { ClientName = "Resource Owner Flow", ClientId = "resource_owner_flow", AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, ClientSecrets = { new Secret("resource_owner_flow_secret".Sha256()) }, AllowedScopes = { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.OfflineAccess }, AllowOfflineAccess = true, RefreshTokenUsage = TokenUsage.ReUse, AccessTokenLifetime = 60, RefreshTokenExpiration = TokenExpiration.Absolute, AbsoluteRefreshTokenLifetime = 300 }
The AllowedScopes
must be set to OpenId
. If it needs to support refresh tokens, add the OfflineAccess
scope. Token lifetime expirations are set in seconds.
This token flow is not quite ready yet. Requesting tokens throws an error saying it needs IResourceOwnerPasswordValidator
. This interface is straightforward, and it’s what validates user credentials.
Put in place this interface like this:
public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator { public Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { if (context.Request.Raw["password"] != "password" || context.Request.Raw["username"] != "username") { return Task.CompletedTask; } context.Result.IsError = false; context.Result.Subject = GetClaimsPrincipal(); return Task.CompletedTask; } private static ClaimsPrincipal GetClaimsPrincipal() { var issued = DateTimeOffset.Now.ToUnixTimeSeconds(); var claims = new List<Claim> { new Claim(JwtClaimTypes.Subject, Guid.NewGuid().ToString()), new Claim(JwtClaimTypes.AuthenticationTime, issued.ToString()), new Claim(JwtClaimTypes.IdentityProvider, "localhost") }; return new ClaimsPrincipal(new ClaimsIdentity(claims)); } }
Then, add it to Startup
:
services.AddTransient<IResourceOwnerPasswordValidator, ResourceOwnerPasswordValidator>();
The code above needs the following using statement:
using IdentityServer4.Validation;
I’m using a poor man’s credential validator to short-circuiting the logic when it fails. In a real project, be sure to check against a stored hash in a database. If the credentials are valid, set IsError
to false and set the Subject
. The Subject
includes the subject or “sub” claim for this JWT. This is what ties the JWT to a living person. Here I’m sticking a random Guid
, but it can be the user id from the database. Identity Server 4 requires all these three claims in the generated JWT.
Restart the .Net Core watcher, so it picks up the new file. Then run curl to get tokens:
curl.exe -d "username=username&password=password& grant_type=password&scope=openid+offline_access& client_id=resource_owner_flow& client_secret=resource_owner_flow_secret" http://localhost:5000/token
The token response looks like this:
{ "access_token": "eyJhbG…KhGxlF3Rc7VSyoGm3pym-2d2qbASP6sMQ", "refresh_token": "GVP13…Xsy1Vu4M", "token_type": "Bearer", "expires_in": 60 }
Going to jwt.io and pasting the access token shows the following:
{ "nbf": 1570057549, "exp": 1570057609, "iss": "localhost", "client_id": "resource_owner_flow", "sub": "5312e20a-280b-4396-bebc-ae9d2c171d55", "auth_time": 1570057549, "idp": "localhost", "scope": [ "openid", "offline_access" ] }
In this JWT, the exp, client id, and sub are of interest. The exp stands for expiration in Unix epoch time. The client id is the originating app, and sub is the person’s identity.
Refresh Token
To get a refresh token, it needs the refresh token that comes with the resource owner token response. Do this to get a new access token:
curl.exe -d "grant_type=refresh_token& client_id=resource_owner_flow& client_secret=resource_owner_flow_secret& refresh_token=GVP13…Xsy1Vu4M" http://localhost:5000/token
In Identity Server 4 the refresh token can expire. There are options for when the refresh token expires. In this case, the client is set to absolute expiration every five minutes. Once refresh tokens expire, it gets kicked off the store and fails the request validation.
This is what the refresh token response looks like:
{ "access_token": "eyJhbG…V35dNQn Tbq2bUfnjHaBzFQcamfAd_hU3A", "refresh_token": " GVP13…Xsy1Vu4M ", "token_type": "Bearer", "expires_in": 60 }
This refresh token remains the same after each access token renewal. This is because the client configuration is set to ReUse
. The spec says the identity provider can reuse the refresh token. Or, return a brand-new refresh token. It’s up to whatever makes one feel more secure.
Putting It All Together
Now that this identity provider is overflowing with tokens. It is time to secure an endpoint with a bearer token. The scaffold puts in a place a dummy controller that does weather forecasting. Find WeatherForecastController
and open it, I’ll come back to this later.
To enable bearer JWT token authentication, add the following NuGet package:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 3.0.0
In Startup
, add this middleware:
services.AddAuthentication(options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = false, ValidateLifetime = true, LifetimeValidator = (notBefore, expires, securityToken, validationParameter) => expires >= DateTime.UtcNow }; options.RequireHttpsMetadata = false; options.Authority = "http://localhost:5000"; });
This tells the client app where the authentication authority is so it can validate JWTs. The LifetimeValidator
is a lambda expression that rejects expired tokens. I’m disabling HTTPS because I only plan to run this in local.
Client apps need a well-known configuration endpoint that comes from the identity provider. This is how they read the public key and validate tokens.
To include the well-known config endpoint that comes from Identity Server 4:
app.UseIdentityServer();
Feel free to poke around this endpoint. Go to http://localhost:5000/.well-known/openid-configuration in the browser.
With this, add an Authorize
attribute to the Get
method in WeatherForecastController
. Strip out any dependencies from this class such as ILogger
. Project scaffolding can add unnecessary dependencies in this class. Make sure the project is running in watch mode then fire off the following request:
curl -H "Authorization: Bearer eyJhbGciOiJS…7YghdwNxLQ" http://localhost:5000/weatherforecast
Accessing this endpoint without a bearer token returns a 401 response. The request also gets rejected after the token expires.
Below is the typical use case for all these tokens. I’ll begin with resource owner tokens, call the protected endpoint, and refresh the token. Then, use client credential tokens to access the same endpoint.
Conclusion
Identity Server 4 is the best tool for generating bearer tokens. It comes with client credential, resource owner, and refresh tokens. Client configuration dictates which token flows are allowed with grant type and scopes. Startup configuration in .NET Core makes the integration easier. Bearer token authentication can go in any API endpoint by setting the right authority. The well-known config endpoint in the identity provider is how APIs validate tokens.
The post Working with Identity Server 4 appeared first on Simple Talk.
from Simple Talk https://ift.tt/2qNsvIb
via
No comments:
Post a Comment