Handling Authentication and Authorization
As previously mentioned, we’ll be setting up a RSA encyrption which uses a public and private key to encrypt and decrypt. Our auth service will sign the JWT token with the private key and the other services will verify the token with the public key. The body of JWT tokens will contain the user’s claims and which are unencrypted, then each service simply verifies that the token hasn’t been tampered with.
Currently there isn’t a dedicated auth solution by the .NET team for Aspire. Because Aspire can be used with any .NET application auth can be implemented in any way you see fit. Although, there have been requests from the .NET community to add a dedicated way of handling auth in Aspire.
.NET Aspire has an integration for adding Keycloak you can check. out the tutuorials and docs: Youtube, Docs
Auth Service
The services in 100 Days follow a project setup that includes:
DirectoryService.Api/ - The main API for the service
- …
DirectoryService.Data/ - Data entities and database context
- …
DirectoryService.DataMigrationService/ - A service to migrate data
- …
In the case of the auth service data, we are simply using the
Identity APIs to create a application user.
public class AppUser : IdentityUser{ [MaxLength(1024)] public string? ImageUrl { get; set; }}public class AuthContext : IdentityDbContext<AppUser>{ public AuthContext(DbContextOptions<AuthContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder);
builder.HasDefaultSchema("identity"); }}The Auth.Api project exposes the following endpoints:
public static void AddAuthEndpoints(this WebApplication app){ var authEndoints = app.MapGroup("/api/auth");
authEndoints .MapPost("/login", LoginUserAsync) .Produces<UserDto>() .Produces(401) .Produces(404) .Produces(500) .Accepts<LoginRequest>("application/json");
authEndoints .MapPost("/register", RegisterUserAsync) .Produces<UserDto>() .Produces(400) .Produces(401) .Accepts<Microsoft.AspNetCore.Identity.Data.RegisterRequest>("application/json");
authEndoints .MapPost("/logout", LogoutUser) .Produces(200) .Produces(401) .RequireAuthorization();
authEndoints .MapGet("/me", GetCurrentUser) .Produces<UserDto>() .Produces(401) .RequireAuthorization();}Jwt Service
In order to generate and validate JWT tokens we have a JwtService
that uses the private key which is retrieved from the Azure
Key Vault integration we setup previously.
public interface IJwtTokenService{ string GenerateJwtTokenAsync(AppUser user);}
public class JwtTokenService : IJwtTokenService{ private readonly UserManager<AppUser> _userManager; private readonly IConfiguration _configuration; private readonly ILogger<JwtTokenService> _logger;
public JwtTokenService( UserManager<AppUser> userManager, IConfiguration configuration, ILogger<JwtTokenService> logger ) { _userManager = userManager; _configuration = configuration; _logger = logger; }
public string GenerateJwtTokenAsync(AppUser user) { string? privateKey = _configuration["private-key"]; ArgumentException.ThrowIfNullOrEmpty(privateKey, nameof(privateKey));
if (string.IsNullOrEmpty(privateKey)) { throw new Exception("Private key is empty"); }
var rsa = RSA.Create(); rsa.ImportFromPem(privateKey.ToCharArray());
// Create credentials var credentials = new SigningCredentials( new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256 );
// Create claims for JWT var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.Id), new(ClaimTypes.Name, user.UserName ?? ""), };
// Generate JWT var tokenHandler = new JwtSecurityTokenHandler(); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(claims), Expires = DateTime.UtcNow.AddDays(1), SigningCredentials = credentials, };
var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); }}Because our react app is a web application, it is generally not recommended to return the JWT token in the response body or store it in local storage. Allowing credentials to be accessible by javascript or stored in local storage can lead to XSS attacks. This could be a reason to use the backend for frontend pattern, since if there were mobile apps we were supporting we would want to return the JWT token in the response body.
So an additional service is added to handle creating cookies which are HttpOnly and Secure.
interface IJwtCookieService{ void SetJwtCookie(HttpContext httpContext, string jwt); void RemoveJwtCookie(HttpContext httpContext);}
public class JwtCookieService : IJwtCookieService{ public void SetJwtCookie(HttpContext httpContext, string jwt) { var isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development";
CookieOptions cookieOptions; if (isDevelopment) { cookieOptions = new CookieOptions { HttpOnly = true, // Prevent JavaScript access (helps protect against XSS) Secure = false, // Only send the cookie over HTTPS SameSite = SameSiteMode.Lax, // Only send the cookie in first-party contexts Expires = DateTime.UtcNow.AddDays(1), Path = "/", Domain = "localhost", IsEssential = true }; } else { cookieOptions = new() { HttpOnly = true, // Prevent JavaScript access (helps protect against XSS) Secure = true, // Only send the cookie over HTTPS SameSite = SameSiteMode.Strict, // Only send the cookie in first-party contexts Expires = DateTime.UtcNow.AddDays(1) }; }
httpContext.Response.Cookies.Append("jwt", jwt, cookieOptions); }
public void RemoveJwtCookie(HttpContext httpContext) { httpContext.Response.Cookies.Delete("jwt"); }}Now we can use our service in our method handlers:
internal static async Task<IResult> LoginUserAsync( HttpContext httpContext, [FromBody] LoginRequest request, [FromServices] UserManager<AppUser> userManager, [FromServices] IJwtTokenService jwtTokenService, [FromServices] IJwtCookieService jwtCookieService){ AppUser? user = await userManager.FindByEmailAsync(request.Email); if (user is null) { return Results.Json(new { message = "User not found" }, statusCode: 404); }
bool result = await userManager.CheckPasswordAsync(user, request.Password); if (!result) { return Results.Json(new { message = "Invalid password" }, statusCode: 401); }
string token = jwtTokenService.GenerateJwtTokenAsync(user); jwtCookieService.SetJwtCookie(httpContext, token); UserDto userDto = new() { UserId = user.Id, UserName = user.UserName, Email = user.Email, };
return Results.Ok(userDto);}Authenticate between services
We have our JWT token being generated and stored in a cookie. Next lets create a extension method that will configure our authentication and authorization middleware.
public class RsaKeyService{ public RsaSecurityKey SecurityKey { get; set; }
public RsaKeyService(IConfiguration configuration) { string? publicKey = configuration["public-key"]; ArgumentException.ThrowIfNullOrEmpty(publicKey);
var rsa = RSA.Create(); rsa.ImportFromPem(publicKey.ToCharArray()); SecurityKey = new RsaSecurityKey(rsa); }}
public static class AuthenticationExtensions{ public static void AddJwtAuthentication(this WebApplicationBuilder builder) { builder.Services.AddSingleton<RsaKeyService>(); string? publicKey = builder.Configuration["public-key"]; ArgumentException.ThrowIfNullOrEmpty(publicKey);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKey.ToCharArray()); builder .Services.AddAuthentication(opts => { opts.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; opts.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(opts => { opts.Events = new JwtBearerEvents { OnMessageReceived = context => { var token = context.Request.Cookies["jwt"]; if (!string.IsNullOrEmpty(token)) { Console.WriteLine($"Token found in cookie \n\n{token}"); context.Token = token; } else { Console.WriteLine("No token found in cookie"); }
return Task.CompletedTask; }, OnAuthenticationFailed = context => { Console.WriteLine($"Authentication failed: {context.Exception.Message}"); return Task.CompletedTask; }, OnChallenge = context => { Console.WriteLine($"Authentication challenge: {context.Error}"); return Task.CompletedTask; } }; var rsaKeyService = builder.Services.BuildServiceProvider().GetRequiredService<RsaKeyService>();
opts.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = rsaKeyService.SecurityKey, ClockSkew = TimeSpan.Zero }; }); }}For each service we can add the extension call
along with the AddAzureKeyVaultSecrets.
builder.AddAzureKeyVaultSecrets();builder.AddJwtAuthentication();builder.Services.AddAuthorization();
//...other service configurationsHooking up to the React App
Since I am not using a strict gateway pattern
with this application, I am using the vite config
to proxy requests to the services, and nginx
to do the same in production. The services URLs
are stored in environment variables that use
the services__ prefix and in the case of
http & https the __http__ & __https__
sufix.
export default defineConfig({ plugins: [plugin(), svgr(), TanStackRouterVite()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), }, }, server: { host: true, port: parseInt(process.env.PORT ?? "5173"), proxy: { "/api/auth": { target: process.env.services__authapi__https__0 || process.env.services__authapi__http__0, changeOrigin: true, secure: false, }, "/api/goal": { target: process.env.services__goalapi__https__0 || process.env.services__goalapi__http__0, changeOrigin: true, secure: false, }, "/api/entries": { target: process.env.services__entryapi__https__0 || process.env.services__entryapi__http__0, changeOrigin: true, secure: false, }, }, },});server { listen ${PORT}; listen [::]:${PORT}; server_name localhost;
access_log /var/log/nginx/server.access.log main;
location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }
location /api/auth { proxy_pass ${services__authapi__https__0}; proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /api/goal { proxy_pass ${services__goalapi__https__0}; proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /api/entry { proxy_pass ${services__entryapi__https__0}; proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }}As long as the JWT token is in the cookie, the react application does not need to do anything special to authenticate with the services.