Skip to content

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.

tokens

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 configurations

Hooking 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.