dotnet multiple jwt bearer
From time to time auth topic appears and is always painfull
In this note going to collect some observations and samples
We are going to figure out how to have multiple bearer jwt auth plus api key auth combined in single app
Movies App
Let's pretend we are build an movies service, where users adding and liking movies, aka as simple as:
mkdir movies
cd movies
dotnet new web --exclude-launch-settings
using System.Security.Claims;
var movies = new List<Movie> {
new("die-hard", "Die Hard", 1988, "user1"),
new("alien", "Alien", 1979, "user2"),
new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
new("rambo", "Rambo", 2008, "user2"),
};
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", (ClaimsPrincipal user) => new {
authenticated = user.Identity?.IsAuthenticated,
scheme = user.Identity?.AuthenticationType,
user = user.Identity?.Name,
claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});
app.MapGet("/movies", () => movies);
app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));
app.MapPost("/movies", (Movie movie) => {
movies.Add(movie);
return movie;
}).RequireAuthorization();
app.MapPut("/movies/{id}", (string id, Movie movie) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index] = movie;
return movie;
}).RequireAuthorization();
app.MapDelete("/movies/{id}", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies.RemoveAt(index);
}).RequireAuthorization();
app.MapPatch("/movies/{id}/likes", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index].Likes++;
return movies[index];
});
app.Run();
record Movie (string Id, string Title, int Year, string Owner) {
public int Likes { get; set; } = 0;
}
Notes:
- as you can see we are not bothering with validations at all and auth yet, this is by intent, just to have something to start from, key goal here is to have some sample app
- there is no dependencies, so should be repeatable from scratch
As you can imagine usage is:
curl -s http://localhost:5000/movies | jq
curl -s http://localhost:5000/movies/die-hard | jq
curl -s -X PATCH http://localhost:5000/movies/die-hard/likes | jq
Also note how we are using default endpoint to retrieve current user info even so we did not touched auth at all
The key idea here is that it does not matter what and which auth approach we are going to use at the end it should fill current user ClaimsPrincipal
For anonymous request it will be:
{
"authenticated": false,
"scheme": null,
"user": null,
"claims": {}
}
All examples below will be kind of copy pasta of this demo app
Authentication vs Authorization
Refresher:
- authentication - are you logged int?
- authorization - do you have permissions to do that?
JWT token
Before proceeding to JWT Authentication we need somehow create JWT tokens.
Let's pretend we have an auth service that produces them.
For simplicity and local tests here is an console app to do it.
mkdir jwtcli
cd jwtcli
dotnet new console
dotnet add package System.IdentityModel.Tokens.Jwt
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
var user = "user1";
var handler = new JwtSecurityTokenHandler();
var token = handler.CreateEncodedJwt(new SecurityTokenDescriptor {
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS")), SecurityAlgorithms.HmacSha256Signature),
Audience = "http://localhost:5000", // is important if target server checks audience
Expires = DateTime.UtcNow.AddMinutes(10000),
Issuer = "https://contoso.com", // is important if target server checks issuer
Subject = new ClaimsIdentity(new[] {
new Claim(JwtRegisteredClaimNames.Sub, user), // "sub" is a well known subject identifier type - aka user identifier
new Claim(ClaimTypes.Name, user) // "unique_name" is a claim dotnet will use for current user identity name
}),
});
Console.WriteLine(token);
It will give us token like this one:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsInVuaXF1ZV9uYW1lIjoidXNlcjEiLCJuYmYiOjE3MDM5MzE4OTcsImV4cCI6MTcwNDUzMTg5NywiaWF0IjoxNzAzOTMxODk3LCJpc3MiOiJodHRwczovL2NvbnRvc28uY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.R5IDO3_YSg0pfp7gmhMLl9rQW4xHQzlA-F9YA3luj5I
We can see whats inside and verify signature here:
{
"sub": "user1",
"unique_name": "user1",
"nbf": 1703931897,
"exp": 1704531897,
"iat": 1703931897,
"iss": "https://contoso.com",
"aud": "http://localhost:5000"
}
I will save it to $token
environment variable and use it in bash script samples to keep them small and readable, aka:
export token=$(dotnet run --project jwtcli)
echo $token
Notes:
sub
is a well known claim for subject, aka user identifier, but later we will see how dot net wants dedicated claim for user name and will "alias" it- as a subject we may use whatever we want, from email to phone or like in our case dedicated string identifier
JWT Authentication
Here is the simples ever way to add authentication to your app
mkdir jwtauth
cd jwtauth
dotnet new web --exclude-launch-settings
dotnet add package Microsoft.Identity.Web
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var movies = new List<Movie> {
new("die-hard", "Die Hard", 1988, "user1"),
new("alien", "Alien", 1979, "user2"),
new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
new("rambo", "Rambo", 2008, "user2"),
};
var builder = WebApplication.CreateBuilder(args);
// ADD JWT AUTH
builder.Services.AddAuthorization().AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://contoso.com",
ValidAudience = "http://localhost:5000",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
};
});
var app = builder.Build();
// USE JWT AUTH
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (ClaimsPrincipal user) => new {
authenticated = user.Identity?.IsAuthenticated,
scheme = user.Identity?.AuthenticationType,
user = user.Identity?.Name,
claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});
app.MapGet("/movies", () => movies);
app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));
app.MapPost("/movies", (Movie movie) => {
movies.Add(movie);
return movie;
}).RequireAuthorization(); // <- REQUIRES AUTH
app.MapPut("/movies/{id}", (string id, Movie movie) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index] = movie;
return movie;
}).RequireAuthorization();
app.MapDelete("/movies/{id}", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies.RemoveAt(index);
}).RequireAuthorization();
app.MapPatch("/movies/{id}/likes", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index].Likes++;
return movies[index];
});
app.Run();
record Movie (string Id, string Title, int Year, string Owner) {
public int Likes { get; set; } = 0;
}
Now here are few tests:
first lets check that we are still returnin nothing for anonymous user
curl -s http://localhost:5000 | jq
{
"authenticated": false,
"scheme": null,
"user": null,
"claims": {}
}
And now for authenticated (we have saved token to $token
)
curl -s http://localhost:5000 -H "Authorization: Bearer $token" | jq
{
"authenticated": true,
"scheme": "AuthenticationTypes.Federation",
"user": "user1",
"claims": {
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "user1",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "user1",
"nbf": "1703931897",
"exp": "1704531897",
"iat": "1703931897",
"iss": "https://contoso.com",
"aud": "http://localhost:5000"
}
}
Notes:
- scheme is being set to
AuthenticationTypes.Federation
rather thanBearer
but we do not care at moment nameidentifier
claim comes fromsub
of our tokenname
claim comes fromunique_name
of our token and is used as current username- both
sub
andunique_name
were removed/replaced deep inside auth library
And now lets check that we wired up everything:
# should respond with 401 unauthorized
curl -i -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}'
# should work
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}' -H "Authorization: Bearer $token" | jq
ApiKey auth
Ok, now we are going to do something similar but for api key
Idea behind this:
- we allowing access from sibling services which know secret api key only
- we want to add it alone for a next steps when we will combine different auth methods
mkdir apikeyauth
cd apikeyauth
dotnet new web --exclude-launch-settings
Here is the code:
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
var movies = new List<Movie> {
new("die-hard", "Die Hard", 1988, "user1"),
new("alien", "Alien", 1979, "user2"),
new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
new("rambo", "Rambo", 2008, "user2"),
};
var builder = WebApplication.CreateBuilder(args);
// ADD API KEY AUTH
builder.Services.AddAuthorization().AddAuthentication().AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationOptions.Scheme, options => {});
var app = builder.Build();
// USE API KEY AUTH
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (ClaimsPrincipal user) => new {
authenticated = user.Identity?.IsAuthenticated,
scheme = user.Identity?.AuthenticationType,
user = user.Identity?.Name,
claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});
app.MapGet("/movies", () => movies);
app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));
app.MapPost("/movies", (Movie movie) => {
movies.Add(movie);
return movie;
}).RequireAuthorization();
app.MapPut("/movies/{id}", (string id, Movie movie) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index] = movie;
return movie;
}).RequireAuthorization();
app.MapDelete("/movies/{id}", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies.RemoveAt(index);
}).RequireAuthorization();
app.MapPatch("/movies/{id}/likes", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index].Likes++;
return movies[index];
});
app.Run();
record Movie (string Id, string Title, int Year, string Owner) {
public int Likes { get; set; } = 0;
}
// API KEY AUTH IMPLEMENTATION DETAILS
class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions {
public static readonly string Scheme = "ApiKey";
public string Secret { get; set; } = "SecretApiKey";
}
class ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder) {
protected override Task<AuthenticateResult> HandleAuthenticateAsync() {
if (!AuthenticationHeaderValue.TryParse(Request.Headers.Authorization, out var authorization)) {
return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header"));
}
if (authorization.Scheme != ApiKeyAuthenticationOptions.Scheme) {
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Scheme"));
}
if (authorization.Parameter != options.CurrentValue.Secret) {
return Task.FromResult(AuthenticateResult.Fail("Invalid ApiKey"));
}
var claims = new[] {
new Claim(ClaimTypes.Name, "service2service") // the same way as with JWT, we are going to add "unique_name" claim which will be recognized and used by dotnet whenever we want current username
};
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationOptions.Scheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationOptions.Scheme);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
The same way as before we are going to check that neither anonymour nor jwt request are considered authenticated:
curl -s http://localhost:5000/ | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer $token" | jq
in both cases response is:
{
"authenticated": false,
"scheme": null,
"user": null,
"claims": {}
}
But for api key auth we got our suer
curl -s http://localhost:5000/ -H "Authorization: ApiKey SecretApiKey" | jq
{
"authenticated": true,
"scheme": "ApiKey",
"user": "service2service",
"claims": {
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "service2service"
}
}
Notes:
- note that we have only one
name
claim - which is used by dotnet to access current username
- also now we are in charge of current auth schema
- just for example we are adding
service2service
username so it is definitely clear whats going on, but any other claims can be used and we will play with all that little bit later
Combine JWT and ApiKet auth
So here is the question how should we combine both jwt and api key authentication in dotnet
mkdir combinejwtapikey
cd combinejwtapikey
dotnet new web --exclude-launch-settings
dotnet add package Microsoft.Identity.Web
Here is the catch, our first attempt may be something like this:
// ADD BOTH JWT AND API KEY AUTH
builder.Services.AddAuthorization().AddAuthentication()
// JWT
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://contoso.com",
ValidAudience = "http://localhost:5000",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
};
})
// API KEY
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationOptions.Scheme, options => {});
Everything will compile and start, but not matter what we will send - we will be treated as anonymous user
Thats because it is now not clear what authentication to use and what to do if both present etc
From one side we can set "default" authentication scheme like so .AddAuthentication("ApiKey")
or like so .AddAuthentication("Bearer")
but will it actualy solve something?
It will add following behaviour to our application - everywhere in our app whenever we are injecting current user, if api key auth is passent it will be filled, but if we are going to pass jwt - user will still be anonymous, which will be our first WTF
One of workarounds may be to create authorization policy allowing any of schemes, but how about endpoins that supposed to be called for both anonymous and authenticated users?
Found really good article with explanations of how it should be done:
https://damienbod.com/2022/09/19/asp-net-core-api-auth-with-multiple-identity-providers/
In short, we are going to add .AddAuthentication("Unknown")
, then .AddJwtBearer()
, then .AddApiKey()
and then we will add an policy which job is to return scheme name for incoming request, aka:
// ADD BOTH JWT AND API KEY AUTH
builder.Services.AddAuthorization().AddAuthentication("Unknown")
// JWT
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://contoso.com",
ValidAudience = "http://localhost:5000",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
};
})
// API KEY
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationOptions.Scheme, options => {})
// WIREUP BOTH AUTHENTICATIONS
.AddPolicyScheme("Unknown", "figure out scheme for incomming request", options => {
options.ForwardDefault = JwtBearerDefaults.AuthenticationScheme;
options.ForwardDefaultSelector = context => {
if (!AuthenticationHeaderValue.TryParse(context.Request.Headers.Authorization, out var authorization)) {
return null;
}
if (authorization.Scheme == JwtBearerDefaults.AuthenticationScheme) {
return JwtBearerDefaults.AuthenticationScheme;
}
if (authorization.Scheme == ApiKeyAuthenticationOptions.Scheme) {
return ApiKeyAuthenticationOptions.Scheme;
}
return null;
};
});
Note that we still need some schema that will mark request as authenticated or anonymous, thats why we must set ForwardDefault
to something, otherwise you will receive The method or operation is not implemented
at Microsoft.AspNetCore.Authentication.PolicySchemeHandler.HandleAuthenticateAsync
Here is what I have ended up with:
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var movies = new List<Movie> {
new("die-hard", "Die Hard", 1988, "user1"),
new("alien", "Alien", 1979, "user2"),
new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
new("rambo", "Rambo", 2008, "user2"),
};
var builder = WebApplication.CreateBuilder(args);
// ADD JWT AUTH
builder.Services.AddAuthorization().AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://contoso.com",
ValidAudience = "http://localhost:5000",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
};
});
var app = builder.Build();
// USE JWT AUTH
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (ClaimsPrincipal user) => new {
authenticated = user.Identity?.IsAuthenticated,
scheme = user.Identity?.AuthenticationType,
user = user.Identity?.Name,
claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});
app.MapGet("/movies", () => movies);
app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));
app.MapPost("/movies", (Movie movie) => {
movies.Add(movie);
return movie;
}).RequireAuthorization(); // <- REQUIRES AUTH
app.MapPut("/movies/{id}", (string id, Movie movie) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index] = movie;
return movie;
}).RequireAuthorization();
app.MapDelete("/movies/{id}", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies.RemoveAt(index);
}).RequireAuthorization();
app.MapPatch("/movies/{id}/likes", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index].Likes++;
return movies[index];
});
app.Run();
record Movie (string Id, string Title, int Year, string Owner) {
public int Likes { get; set; } = 0;
}
And samples for default endpoint:
curl -s http://localhost:5000/ | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer $token" | jq
curl -s http://localhost:5000/ -H "Authorization: ApiKey SecretApiKey" | jq
Note that we are not adding [Athorization]
nor .RequireAuthorization()
, endpoint is accessible by both anonymous and authenticated users and based on request correct handler is choosen, which then fills current user, profit.
And as you can guess the same is true for endpoints requiring authentication
# should respond with 401 unauthorized
curl -i -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}'
# jwt should work
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}' -H "Authorization: Bearer $token" | jq
# api key should workd
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "predator", "title": "Predator", "year": 1987, "owner": "user1"}' -H "Authorization: ApiKey SecretApiKey" | jq
Combining multiple JWT authorities
Previous step was crusial for this example
What if we have multiple JWT Bearer authentications, or may be we are in the middle of migration, or may be we want to combine our auth service with Azure Active Directory tokens generated for service account to accomplish service to service communications
In all that cases incomming requests will have Authorization: Bearer xxxx
with tokens signed by different authorities
By different authorities we may think of Google, Microsoft, Okta, Identity Server and so on, especially OpenId Connect providers will work nicely out of the box
For now, to keep things simple, lets reuse our console app, by changing authority and signing secret, aka:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsInVuaXF1ZV9uYW1lIjoidXNlcjIiLCJuYmYiOjE3MDM5NDQ4MzMsImV4cCI6MTcwNDU0NDgzMywiaWF0IjoxNzAzOTQ0ODMzLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.EboZv59hfZ0HadaxswedxRi2nfz2UUEYnOgEi3xvClk
{
"sub": "user2",
"unique_name": "user2",
"nbf": 1703944833,
"exp": 1704544833,
"iat": 1703944833,
"iss": "https://example.com",
"aud": "http://localhost:5000"
}
signed by SECOND_SECRET_FOR_JWT_TOKEN_SIGN
because it has only issued at iat
claim and has no expirity exp
we will be validating it little bit different
mkdir multiplejwt
cd multiplejwt
dotnet new web --exclude-launch-settings
dotnet add package Microsoft.Identity.Web
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
var movies = new List<Movie> {
new("die-hard", "Die Hard", 1988, "user1"),
new("alien", "Alien", 1979, "user2"),
new("terminator-2", "Terminator 2: Judgment Day", 1991, "user1"),
new("rambo", "Rambo", 2008, "user2"),
};
var builder = WebApplication.CreateBuilder(args);
// ADD MULTIPLE JWT
builder.Services.AddAuthorization().AddAuthentication("Unknown")
// FIRST JWT
.AddJwtBearer("FirstJwt", options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://contoso.com",
ValidAudience = "http://localhost:5000",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
};
})
// SECOND JWT
.AddJwtBearer("SecondJwt", options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://example.com",
ValidAudience = "http://localhost:5000",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECOND_SECRET_FOR_JWT_TOKEN_SIGN"))
};
})
// WIREUP BOTH AUTHENTICATIONS
.AddPolicyScheme("Unknown", "Dynamically depetermine which JWT to use", options => {
options.ForwardDefault = "FirstJwt"; // fallback
options.ForwardDefaultSelector = context => {
if (!AuthenticationHeaderValue.TryParse(context.Request.Headers.Authorization, out var authorization)) {
return null;
}
if (authorization.Scheme != "Bearer") {
return null;
}
var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(authorization.Parameter)) {
return null;
}
var issuer = handler.ReadJwtToken(authorization.Parameter).Issuer;
if (issuer == "https://contoso.com") {
return "FirstJwt";
}
if (issuer == "https://example.com") {
return "SecondJwt";
}
return null;
};
});
var app = builder.Build();
// USE BOTH JWT AND API KEY AUTH
// not how it always stays the same
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", (ClaimsPrincipal user) => new {
authenticated = user.Identity?.IsAuthenticated,
scheme = user.Identity?.AuthenticationType,
user = user.Identity?.Name,
claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
});
app.MapGet("/movies", () => movies);
app.MapGet("/movies/{id}", (string id) => movies.FirstOrDefault(m => m.Id == id));
app.MapPost("/movies", (Movie movie) => {
movies.Add(movie);
return movie;
}).RequireAuthorization(); // <- REQUIRES AUTH
app.MapPut("/movies/{id}", (string id, Movie movie) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index] = movie;
return movie;
}).RequireAuthorization();
app.MapDelete("/movies/{id}", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies.RemoveAt(index);
}).RequireAuthorization();
app.MapPatch("/movies/{id}/likes", (string id) => {
var index = movies.FindIndex(m => m.Id == id);
movies[index].Likes++;
return movies[index];
});
app.Run();
record Movie (string Id, string Title, int Year, string Owner) {
public int Likes { get; set; } = 0;
}
# check main endpoint
curl -s http://localhost:5000/ | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer $token" | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsInVuaXF1ZV9uYW1lIjoidXNlcjIiLCJuYmYiOjE3MDM5NDQ4MzMsImV4cCI6MTcwNDU0NDgzMywiaWF0IjoxNzAzOTQ0ODMzLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.EboZv59hfZ0HadaxswedxRi2nfz2UUEYnOgEi3xvClk" | jq
# should respond with 401 unauthorized
curl -i -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}'
# jwt should work
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "contact", "title": "Contact", "year": 1997, "owner": "user1"}' -H "Authorization: Bearer $token" | jq
# api key should workd
curl -s -X POST http://localhost:5000/movies -H 'Content-Type: application/json' -d '{"id": "predator", "title": "Predator", "year": 1987, "owner": "user1"}' -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsInVuaXF1ZV9uYW1lIjoidXNlcjIiLCJuYmYiOjE3MDM5NDQ4MzMsImV4cCI6MTcwNDU0NDgzMywiaWF0IjoxNzAzOTQ0ODMzLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.EboZv59hfZ0HadaxswedxRi2nfz2UUEYnOgEi3xvClk" | jq
As you can guess you can combine many jwt and api keys together with this approach.
Authorization
On top of that you may want to add some additional policies that will tune access to your service
Aka require api key authentication for one endpoints and any jwt for another
Or having certain roles or any other claims in token
mkdir policies
cd policies
dotnet new web --exclude-launch-settings
dotnet add package Microsoft.Identity.Web
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// ADD MULTIPLE JWT
builder.Services.AddAuthentication("Unknown")
// FIRST JWT
.AddJwtBearer("FirstJwt", options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://contoso.com",
ValidAudience = "http://localhost:5000",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECRET_USED_TO_SIGN_OUR_CUSTOM_JWT_TOKENS"))
};
})
// SECOND JWT
.AddJwtBearer("SecondJwt", options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "https://example.com",
ValidAudience = "http://localhost:5000",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SECOND_SECRET_FOR_JWT_TOKEN_SIGN"))
};
})
// API KEY
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>("ApiKey", options => {})
// WIREUP BOTH AUTHENTICATIONS
.AddPolicyScheme("Unknown", "Dynamically depetermine which auth to use", options => {
options.ForwardDefault = "ApiKey"; // fallback
options.ForwardDefaultSelector = context => {
if (!AuthenticationHeaderValue.TryParse(context.Request.Headers.Authorization, out var authorization)) {
return null;
}
if (authorization.Scheme == "ApiKey") {
return "ApiKey";
}
if (authorization.Scheme != "Bearer") {
return null;
}
var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(authorization.Parameter)) {
return null;
}
var issuer = handler.ReadJwtToken(authorization.Parameter).Issuer;
if (issuer == "https://contoso.com") {
return "FirstJwt";
}
if (issuer == "https://example.com") {
return "SecondJwt";
}
return null;
};
});
// AUTHORIZATION POLICIES
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AuthenticatedWithAnyScheme", policy => policy.AddAuthenticationSchemes("FirstJwt", "SecondJwt", "ApiKey").RequireAuthenticatedUser())
.AddPolicy("AuthenticatedWithJwt", policy => policy.AddAuthenticationSchemes("FirstJwt", "SecondJwt").RequireAuthenticatedUser())
.AddPolicy("AuthenticatedWithApiKey", policy => policy.AddAuthenticationSchemes("ApiKey").RequireAuthenticatedUser());
var app = builder.Build();
// USE BOTH JWT AND API KEY AUTH
app.UseAuthentication();
app.UseAuthorization();
var demo = (ClaimsPrincipal user) => new {
authenticated = user.Identity?.IsAuthenticated,
scheme = user.Identity?.AuthenticationType,
user = user.Identity?.Name,
claims = user.Claims.ToDictionary(c => c.Type, c => c.Value)
};
app.MapGet("/", demo);
app.MapGet("/any", demo).RequireAuthorization("AuthenticatedWithAnyScheme");
app.MapGet("/jwt", demo).RequireAuthorization("AuthenticatedWithJwt");
app.MapGet("/apikey", demo).RequireAuthorization("AuthenticatedWithApiKey");
app.Run();
// API KEY IMPLEMENTATION DETAILS
class ApiKeyAuthenticationOptions: AuthenticationSchemeOptions {
public static readonly string Scheme = "ApiKey";
public string Secret { get; set; } = "SecretApiKey";
}
class ApiKeyAuthenticationHandler(IOptionsMonitor<ApiKeyAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder) {
protected override Task<AuthenticateResult> HandleAuthenticateAsync() {
if (!AuthenticationHeaderValue.TryParse(Request.Headers.Authorization, out var authorization)) {
return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header"));
}
if (authorization.Scheme != ApiKeyAuthenticationOptions.Scheme) {
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Scheme"));
}
if (authorization.Parameter != options.CurrentValue.Secret) {
return Task.FromResult(AuthenticateResult.Fail("Invalid ApiKey"));
}
var claims = new[] {
new Claim(ClaimTypes.Name, "service2service")
};
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationOptions.Scheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationOptions.Scheme);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
As you can guess endpoints will allow requests only via certain authentication
# main endpoint allow everything
curl -s http://localhost:5000/ | jq
curl -s http://localhost:5000/ -H "Authorization: ApiKey SecretApiKey" | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer $token" | jq
curl -s http://localhost:5000/ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsInVuaXF1ZV9uYW1lIjoidXNlcjIiLCJuYmYiOjE3MDM5NDQ4MzMsImV4cCI6MTcwNDU0NDgzMywiaWF0IjoxNzAzOTQ0ODMzLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIn0.EboZv59hfZ0HadaxswedxRi2nfz2UUEYnOgEi3xvClk" | jq
# /any - works the same way, except it does require auth, so anymous request will receive 401
curl -s http://localhost:5000/any -i
# but rest will work
curl -s http://localhost:5000/any -H "Authorization: ApiKey SecretApiKey" | jq
curl -s http://localhost:5000/any -H "Authorization: Bearer $token" | jq
# /jwt - requiers jwt auth
# so this one wont work
curl -s http://localhost:5000/jwt -i
curl -s http://localhost:5000/jwt -H "Authorization: ApiKey SecretApiKey" -i
# and this will
curl -s http://localhost:5000/jwt -H "Authorization: Bearer $token" | jq
# /apikey - wise versa, for apikey
# non api key requests will receive 401
curl -s http://localhost:5000/apikey -i
curl -s http://localhost:5000/apikey -H "Authorization: Bearer $token" -i
# api key will work
curl -s http://localhost:5000/apikey -H "Authorization: ApiKey SecretApiKey" | jq
In token you may want to pass role claim and add it to requirement or do some other fancy things, but in general thats it.
Here are few related notes: