Securing multiple Auth0 APIs in ASP.NET Core using OAuth Bearer tokens

This article shows a strategy for security multiple APIs which have different authorization requirements but the tokens are issued by the same authority. Auth0 is used as the identity provider. A user API and a service API are implemented in the ASP.NET Core API project. The access token for the user API data is created using an Open ID Connect Code flow with PKCE authentication and the service API access token is created using the client credentials flow in the trusted backend of the Blazor application. It is important that both access tokens will only work for the intended API.

Code: https://github.com/damienbod/SeparatingApisPerSecurityLevel

Blogs in this series

History

2022-04-17 Updated packages, added nullable support

2021-11-28 Updated to .NET 6

Setup

The projects are setup to use a Blazor WASM application hosted in ASP.NET Core secured using the Open ID Connect code flow with PKCE and the BFF pattern. Cookies are used to persist the session. This application uses two separate APIs, a user data API and a service API. The access token from the OIDC authentication is used to access the user data API and a client credentials flow is used to get an access token for the service API. Auth0 is setup using a regular web application and an API configuration. A scope was added to the API which is requested in the client application and validated in the API project.

Implementing the APIs in ASP.NET Core

OAuth2 JwtBearer auth is used to secure the APIs. As we use the same Authority and the same Audience, a single scheme can be used for both applications. We use the default JwtBearerDefaults.AuthenticationScheme.

services.AddAuthentication(options =>
{
	options.DefaultAuthenticateScheme 
		= JwtBearerDefaults.AuthenticationScheme;
	options.DefaultChallengeScheme 
		= JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
	options.Authority 
		= "https://dev-damienbod.eu.auth0.com/";
	options.Audience 
		= "https://auth0-api1";
});

The AddAuthorization method is used to setup the policies so that each API can authorize that the correct token was used to request the data. Two policies are added, one for the user access token and one for the service access token. The access token created using the client credentials flow with Auth0 can be authorized using the azp claim and the Auth0 gty claim. The API client-id is validated using the token claims. The user access token is validated using an IAuthorizationHandler implementation. A default policy is added to the AddControllers method to require an authenticated user meaning a valid access token.

services.AddSingleton<IAuthorizationHandler, UserApiScopeHandler>();

services.AddAuthorization(policies =>
{
	policies.AddPolicy("p-user-api-auth0", p =>
	{
		p.Requirements.Add(new UserApiScopeHandlerRequirement());
		// Validate id of application for which the token was created
		p.RequireClaim("azp", "AScjLo16UadTQRIt2Zm1xLHVaEaE1feA");
	});

	policies.AddPolicy("p-service-api-auth0", p =>
	{
		// Validate id of application for which the token was created
		p.RequireClaim("azp", "naWWz6gdxtbQ68Hd2oAehABmmGM9m1zJ");
		p.RequireClaim("gty", "client-credentials");
	});
});

services.AddControllers(options =>
{
	var policy = new AuthorizationPolicyBuilder()
		.RequireAuthenticatedUser()
		.Build();
	options.Filters.Add(new AuthorizeFilter(policy));
});

Swagger is added with an OAuth UI so that we can add access tokens manually to test the APIs.

services.AddSwaggerGen(c =>
{
	// add JWT Authentication
	var securityScheme = new OpenApiSecurityScheme
	{
		Name = "JWT Authentication",
		Description = "Enter JWT Bearer token **_only_**",
		In = ParameterLocation.Header,
		Type = SecuritySchemeType.Http,
		Scheme = "bearer", // must be lower case
		BearerFormat = "JWT",
		Reference = new OpenApiReference
		{
			Id = JwtBearerDefaults.AuthenticationScheme,
			Type = ReferenceType.SecurityScheme
		}
	};
	c.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme);
	c.AddSecurityRequirement(new OpenApiSecurityRequirement
	{
		{securityScheme, new string[] { }}
	});

	c.SwaggerDoc("v1", new OpenApiInfo
	{
		Title = "My API",
		Version = "v1",
		Description = "My API",
		Contact = new OpenApiContact
		{
			Name = "damienbod",
			Email = string.Empty,
			Url = new Uri("https://damienbod.com/"),
		},
	});
});

The Configure method is used to add the middleware to implement the API application. It is important to use the UseAuthentication middleware and you should have no reason to implement this yourself. If you find yourself implementing some special authentication middleware for whatever reason, maybe your security architecture might be incorrect.

public void Configure(IApplicationBuilder app)
{
	app.UseSwagger();
	app.UseSwaggerUI(c =>
	{
		c.SwaggerEndpoint("/swagger/v1/swagger.json", "User API");
		c.RoutePrefix = string.Empty;
	});

	// only needed for browser clients
	// app.UseCors("AllowAllOrigins");

	app.UseHttpsRedirection();

	app.UseRouting();

	app.UseAuthentication();
	app.UseAuthorization();

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapControllers();
	});
}

The UserApiScopeHandler class implements the abstract AuthorizationHandler class. Logic can be implemented here to fulfil the UserApiScopeHandlerRequirement requirement. This requirement is what we use to authorize a request for the user data API. This handler just validates if the required scope exists in the scope claim.

public class UserApiScopeHandler : 
	AuthorizationHandler<UserApiScopeHandlerRequirement>
{

	protected override Task HandleRequirementAsync(
		AuthorizationHandlerContext context, 
		UserApiScopeHandlerRequirement requirement)
	{
		if (context == null)
			throw new ArgumentNullException(nameof(context));
		if (requirement == null)
			throw new ArgumentNullException(nameof(requirement));

		var scopeClaim = context
			.User
			.Claims
			.FirstOrDefault(t => t.Type == "scope");

		if (scopeClaim != null)
		{
			var scopes = scopeClaim
				.Value
				.Split(" ", StringSplitOptions.RemoveEmptyEntries);
				
			if (scopes.Any(t => t == "auth0-user-api-one"))
			{
				context.Succeed(requirement);
			}
		}

		return Task.CompletedTask;
	}
}

public class UserApiScopeHandlerRequirement : 
	IAuthorizationRequirement{ }

The policies can be applied anywhere within the application and the authorization logic is not tightly coupled anywhere to the business of the application. By separating the authorization implementation with the business implementation of the application, it is easier to maintain and understand the authorization and business of the application. This has worked well for me and I find it easy to test and maintain applications setup like this over long periods of time.

[Authorize(Policy = "p-user-api-auth0")]
[ApiController]
[Route("api/[controller]")]
public class UserOneController : ControllerBase

The p-service-api-auth policy is applied to the Service API.

[Authorize(Policy = "p-service-api-auth0")]
[ApiController]
[Route("api/[controller]")]
public class ServiceTwoController : ControllerBase

When the application is started, the swagger UI is displayed and any access token can be pasted into the swagger UI. Both APIs are displayed in the swagger and both APIs require a different access token.

Calling the clients from ASP.NET Core

A Blazor WASM application hosted in ASP.NET Core is used to access the APIs. The application is secured using a trusted server rendered application and the OIDC data is persisted to a secure cookie. The OnRedirectToIdentityProvider method is used to set the audience of the API to request the access token with the required scope. The scopes are added to the OIDC options.

services.AddAuthentication(options =>
{
	options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
	options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
	options.Cookie.Name = "__Host-BlazorServer";
	options.Cookie.SameSite = SameSiteMode.Lax;
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
	options.Authority = $"https://{Configuration["Auth0:Domain"]}";
	options.ClientId = Configuration["Auth0:ClientId"];
	options.ClientSecret = Configuration["Auth0:ClientSecret"];
	options.ResponseType = OpenIdConnectResponseType.Code;
	options.Scope.Clear();
	options.Scope.Add("openid");
	options.Scope.Add("profile");
	options.Scope.Add("email");
	options.Scope.Add("auth0-user-api-one");
	options.CallbackPath = new PathString("/signin-oidc");
	options.ClaimsIssuer = "Auth0";
	options.SaveTokens = true;
	options.UsePkce = true;
	options.GetClaimsFromUserInfoEndpoint = true;
	options.TokenValidationParameters.NameClaimType = "name";

	options.Events = new OpenIdConnectEvents
	{
		// handle the logout redirection 
		OnRedirectToIdentityProviderForSignOut = (context) =>
		{
			var logoutUri = $"https://{Configuration["Auth0:Domain"]}/v2/logout?client_id={Configuration["Auth0:ClientId"]}";

			var postLogoutUri = context.Properties.RedirectUri;
			if (!string.IsNullOrEmpty(postLogoutUri))
			{
				if (postLogoutUri.StartsWith("/"))
				{
					// transform to absolute
					var request = context.Request;
					postLogoutUri = request.Scheme + "://" + request.Host + request.PathBase + postLogoutUri;
				}
				logoutUri += $"&returnTo={ Uri.EscapeDataString(postLogoutUri)}";
			}

			context.Response.Redirect(logoutUri);
			context.HandleResponse();

			return Task.CompletedTask;
		},
		OnRedirectToIdentityProvider = context =>
		{
			// The context's ProtocolMessage can be used to pass along additional query parameters
			// to Auth0's /authorize endpoint.
			// 
			// Set the audience query parameter to the API identifier to ensure the returned Access Tokens can be used
			// to call protected endpoints on the corresponding API.
			context.ProtocolMessage.SetParameter("audience", "https://auth0-api1");

			return Task.FromResult(0);
		}
	};
});

Calling the User API

A user API client service is used to request the data from the ASP.NET Core API. The access token is passed as a parameter and the IHttpClientFactory is used to create the HttpClient.

/// <summary>
/// setup to oidc client in the startup correctly
/// https://auth0.com/docs/quickstart/webapp/aspnet-core#enterprise-saml-and-others-
/// </summary>
public class MyApiUserOneClient
{
	private readonly IConfiguration _configurations;
	private readonly IHttpClientFactory _clientFactory;

	public MyApiUserOneClient(
		IConfiguration configurations,
		IHttpClientFactory clientFactory)
	{
		_configurations = configurations;
		_clientFactory = clientFactory;
	}

	public async Task<List<string>> GetUserOneApiData(string accessToken)
	{
		try
		{
			var client = _clientFactory.CreateClient();

			client.BaseAddress = new Uri(_configurations["MyApiUrl"]);

			client.SetBearerToken(accessToken);

			var response = await client.GetAsync("api/UserOne");
			if (response.IsSuccessStatusCode)
			{
				var data = await JsonSerializer.DeserializeAsync<List<string>>(
				await response.Content.ReadAsStreamAsync());

				return data;
			}

			throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
		}
		catch (Exception e)
		{
			throw new ApplicationException($"Exception {e}");
		}
	}
}

The user access token is saved to the HttpContext after a successful sign-in and the GetTokenAsync method with the “access_token” parameter is used to retrieve the user access token.

private readonly MyApiUserOneClient _myApiUserOneClient;

public CallUserApiController(
	MyApiUserOneClient myApiUserOneClient)
{
	_myApiUserOneClient = myApiUserOneClient;
}

[HttpGet]
public async Task<IActionResult> GetAsync()
{
	// call user API
	string accessToken = 
		await HttpContext.GetTokenAsync("access_token");
	var userData = 
		await _myApiUserOneClient
			.GetUserOneApiData(accessToken);

	return Ok(userData);
}

Calling the Service API

Using a service API requires requesting an access token using the OAuth client credentials flow. This flow can only be used in a trusted backend and a secret is required to request an access token. No user is involved. This is a machine to machine request. The access token is persisted to a distributed cache.

public class Auth0CCTokenApiService
{
	private readonly ILogger<Auth0CCTokenApiService> _logger;
	private readonly Auth0ApiConfiguration _auth0ApiConfiguration;

	private static readonly Object _lock = new Object();
	private IDistributedCache _cache;

	private const int cacheExpirationInDays = 1;

	private class AccessTokenResult
	{
		public string AcessToken { get; set; } = string.Empty;
		public DateTime ExpiresIn { get; set; }
	}

	private class AccessTokenItem
	{
		public string access_token { get; set; } = string.Empty;
		public int expires_in { get; set; }
		public string token_type { get; set; }
		public string scope { get; set; }
	}

	public Auth0CCTokenApiService(
			IOptions<Auth0ApiConfiguration> auth0ApiConfiguration,
			IHttpClientFactory httpClientFactory,
			ILoggerFactory loggerFactory,
			IDistributedCache cache)
	{
		_auth0ApiConfiguration = auth0ApiConfiguration.Value;
		_logger = loggerFactory.CreateLogger<Auth0CCTokenApiService>();
		_cache = cache;
	}

	public async Task<string> GetApiToken(HttpClient client, string api_name)
	{
		var accessToken = GetFromCache(api_name);

		if (accessToken != null)
		{
			if (accessToken.ExpiresIn > DateTime.UtcNow)
			{
				return accessToken.AcessToken;
			}
			else
			{
				// remove  => NOT Needed for this cache type
			}
		}

		_logger.LogDebug($"GetApiToken new from oauth server for {api_name}");

		// add
		var newAccessToken = await GetApiTokenClient(client);
		AddToCache(api_name, newAccessToken);

		return newAccessToken.AcessToken;
	}

	private async Task<AccessTokenResult> GetApiTokenClient(HttpClient client)
	{
		try
		{
			var payload = new Auth0ClientCrendentials
			{
				client_id = _auth0ApiConfiguration.ClientId,
				client_secret = _auth0ApiConfiguration.ClientSecret,
				audience = _auth0ApiConfiguration.Audience
			};

			var authUrl = _auth0ApiConfiguration.Url;
			var tokenResponse = await client.PostAsJsonAsync(authUrl, payload);

			if (tokenResponse.StatusCode == System.Net.HttpStatusCode.OK)
			{
				var result = await tokenResponse.Content.ReadFromJsonAsync<AccessTokenItem>();
				DateTime expirationTime = DateTimeOffset.FromUnixTimeSeconds(result.expires_in).DateTime;
				return new AccessTokenResult
				{
					AcessToken = result.access_token,
					ExpiresIn = expirationTime
				};
			}

			_logger.LogError($"tokenResponse.IsError Status code: {tokenResponse.StatusCode}, Error: {tokenResponse.ReasonPhrase}");
			throw new ApplicationException($"Status code: {tokenResponse.StatusCode}, Error: {tokenResponse.ReasonPhrase}");
		}
		catch (Exception e)
		{
			_logger.LogError($"Exception {e}");
			throw new ApplicationException($"Exception {e}");
		}
	}

	private void AddToCache(string key, AccessTokenResult accessTokenItem)
	{
		var options = new DistributedCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromDays(cacheExpirationInDays));

		lock (_lock)
		{
			_cache.SetString(key, System.Text.Json.JsonSerializer.Serialize(accessTokenItem), options);
		}
	}

	private AccessTokenResult GetFromCache(string key)
	{
		var item = _cache.GetString(key);
		if (item != null)
		{
			return System.Text.Json.JsonSerializer.Deserialize<AccessTokenResult>(item);
		}

		return null;
	}
}

The MyApiServiceTwoClient service uses the client credentials token client to get the access token and request data from the service API.

public class MyApiServiceTwoClient
{
	private readonly IConfiguration _configurations;
	private readonly IHttpClientFactory _clientFactory;
	private readonly Auth0CCTokenApiService _auth0TokenApiService;

	public MyApiServiceTwoClient(
		IConfiguration configurations,
		IHttpClientFactory clientFactory,
		Auth0CCTokenApiService auth0TokenApiService)
	{
		_configurations = configurations;
		_clientFactory = clientFactory;
		_auth0TokenApiService = auth0TokenApiService;
	}

	public async Task<List<string>> GetServiceTwoApiData()
	{
		try
		{
			var client = _clientFactory.CreateClient();

			client.BaseAddress = new Uri(_configurations["MyApiUrl"]);

			var access_token = await _auth0TokenApiService.GetApiToken(client, "ServiceTwoApi");

			client.SetBearerToken(access_token);

			var response = await client.GetAsync("api/ServiceTwo");
			if (response.IsSuccessStatusCode)
			{
				var data = await JsonSerializer.DeserializeAsync<List<string>>(
				await response.Content.ReadAsStreamAsync());

				return data;
			}

			throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
		}
		catch (Exception e)
		{
			throw new ApplicationException($"Exception {e}");
		}
	}
}


The services are added to the default IoC in ASP.NET Core so that construction injection can be used.

services.AddHttpClient();
services.AddOptions();

services.Configure<Auth0ApiConfiguration(Configuration.GetSection("Auth0ApiConfiguration");
services.AddScoped<Auth0CCTokenApiService>();
services.AddScoped<MyApiServiceTwoClient>();
services.AddScoped<MyApiUserOneClient>();

The service can be used anywhere in the code as required.

private readonly MyApiServiceTwoClient _myApiClientService;

public CallServiceApiController(
	MyApiServiceTwoClient myApiClientService)
{
	_myApiClientService = myApiClientService;
}

[HttpGet]
public async Task<IActionResult> GetAsync()
{
	// call service API
	var serviceData = await 
		_myApiClientService.GetServiceTwoApiData();

	return Ok(serviceData);
}

You can test the APIs in the swagger UI. I added a breakpoint to my application and copied the access token. I added the token to the swagger UI.

If you send a HTTP request using the wrong token for the intended API, the request will be rejected and a 401or 403 is returned. Without the extra authorization logic implemented with the policies, this request would not have failed.

Notes

It is really important to validate that only access tokens created for the specific APIs will work. There are different ways of implementing this. If using service APIs which are probably solution internal, you could possibly use network security as well to separate these into different security zones. It is really important to validate the no access non-functional use case where using the same identity provider to create the access token for different APIs or if the identity provider produces access tokens for different applications which will probably have different security requirements. For high security requirements, you could use sender constrained tokens.

Links

https://auth0.com/docs/quickstart/webapp/aspnet-core

https://docs.microsoft.com/en-us/aspnet/core/security/authorization/introduction

Open ID Connect

Securing Blazor Web assembly using Cookies and Auth0

2 comments

  1. […] Securing multiple Auth0 APIs in ASP.NET Core using OAuth Bearer tokens (Damien Bowden) […]

  2. […] Securing multiple Auth0 APIs in ASP.NET Core using OAuth Bearer tokens – Damien Bowden […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.