This article shows how to implement an Angular single page application with an ASP.NET Core API and secured using the Open ID Connect code flow with PKCE and OAuth JWT Bearer tokens to protect the API. The identity provider is implemented using Auth0. The flow uses refresh tokens to renew the SPA session and the revocation endpoint is used to clean up the refresh tokens on logout.
Setup
The solutions consists of three parts, an ASP.NET Core API which would provide the data in a secure way, an Angular application which would use the data and the Auth0 service which is used as the identity provider. Both applications are registered in Auth0 and the refresh tokens are configured for the SPA. The API can be used from the SPA application.
Angular SPA Code flow PKCE with refresh tokens
The Angular Open ID Connect client is implemented using the npm package angular-auth-oidc-client. The Auth0 client requires two special configurations to use an API. The audience is added as a custom parameter in the authorize request so that the required API can be used. The customParamsRefreshToken is used to add the scope parameter to the refresh request which is required by Auth0. The rest is standard Open ID Connect settings used for code flow using PKCE and refresh tokens.
import { APP_INITIALIZER, NgModule } from '@angular/core'; import { AuthModule, LogLevel, OidcConfigService } from 'angular-auth-oidc-client'; export function configureAuth(oidcConfigService: OidcConfigService) { return () => oidcConfigService.withConfig({ stsServer: 'https://dev-damienbod.eu.auth0.com', redirectUrl: window.location.origin, postLogoutRedirectUri: window.location.origin, clientId: 'Ujh5oSBAFr1BuilgkZPcMWEgnuREgrwU', scope: 'openid profile offline_access auth0-user-api-spa', responseType: 'code', silentRenew: true, useRefreshToken: true, logLevel: LogLevel.Debug, customParams: { audience: 'https://auth0-api-spa', // API app in Auth0 }, customParamsRefreshToken: { scope: 'openid profile offline_access auth0-user-api-spa', }, }); } @NgModule({ imports: [AuthModule.forRoot()], providers: [ OidcConfigService, { provide: APP_INITIALIZER, useFactory: configureAuth, deps: [OidcConfigService], multi: true, }, ], exports: [AuthModule], }) export class AuthConfigModule {}
An AuthInterceptor class is used to add the access token to the API requests to the secure APIs which use the access token. It is important that the access token is only sent to the intended API and not every outgoing HTTP request.
import { HttpInterceptor, HttpRequest, HttpHandler } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { AuthService } from './auth.service'; @Injectable() export class AuthInterceptor implements HttpInterceptor { private secureRoutes = ['https://localhost:44390']; constructor(private authService: AuthService) {} intercept( request: HttpRequest<any>, next: HttpHandler ) { if (!this.secureRoutes.find((x) => request.url.startsWith(x))) { return next.handle(request); } const token = this.authService.token; if (!token) { return next.handle(request); } request = request.clone({ headers: request.headers.set('Authorization', 'Bearer ' + token), }); return next.handle(request); } }
ASP.NET Core API OAuth
The ASP.NET Core API allows requests from the calling SPA application. CORS is enabled for the application. The AddAuthentication method is used to add JWT bearer token security and the policies are added to verify the access token. The UseAuthentication method is used to add the security middleware.
public void ConfigureServices(IServiceCollection services) { // ... JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); // IdentityModelEventSource.ShowPII = true; // only needed for browser clients services.AddCors(options => { options.AddPolicy("AllowAllOrigins", builder => { builder .AllowCredentials() .WithOrigins( "https://localhost:4204") .SetIsOriginAllowedToAllowWildcardSubdomains() .AllowAnyHeader() .AllowAnyMethod(); }); }); 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-api-spa"; }); 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", "Ujh5oSBAFr1BuilgkZPcMWEgnuREgrwU"); }); }); services.AddControllers(options => { var policy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); options.Filters.Add(new AuthorizeFilter(policy)); }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { // ... app.UseCors("AllowAllOrigins"); app.UseHttpsRedirection(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }
The UserApiScopeHandler class implements the AuthorizationHandler to require the UserApiScopeHandlerRequirement requirement which is used as the policy.
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-spa")) { context.Succeed(requirement); } } return Task.CompletedTask; } }
The UserOneController class uses the policy which validates the access token and the claims from the token.
[SwaggerTag("User access token protected using Auth0")] [Authorize(Policy = "p-user-api-auth0")] [ApiController] [Route("api/[controller]")] public class UserOneController : ControllerBase { /// <summary> /// returns data id the correct Auth0 access token is used. /// </summary> /// <returns>protected data</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public IEnumerable<string> Get() { return new List<string> { "user one data" }; } }
Problems, notes, Improvements
Auth0 supports the revocation endpoint which is really good and so the refresh token can be revoked when the Angular application is logged out. This is really a MUST I think if using refresh tokens in the browser. It is not possible to revoke the access tokens so these remain valid after the SPA app logs out. You could reduce the lifespan of the access tokens which would improve this a bit. Auth0 does not support reference tokens and introspection which I would always use if using SPA authentication. Introspection could be supported by using a different identity provider. Using refresh token rotation is really important when using refresh tokens in the browser, this should also be configured.
Using Auth0 with an SPA means you cannot fully logout. The tokens are also stored somewhere in the browser, but at least the refresh token can be revoked which is really important. To improve security, you could switch to a BFF architecture and remove the tokens from the browser. Then it would also be possible to fully logout. The BFF also allows for client authentication and other security features which are not possible with an SPA.
Javier is Content Specialist and also .NET developer. He writes helpful guides and articles, assist with other marketing and .NET community work