diff --git a/docs/security/auth-enforcement.md b/docs/security/auth-enforcement.md new file mode 100644 index 0000000..676340d --- /dev/null +++ b/docs/security/auth-enforcement.md @@ -0,0 +1,45 @@ +# Auth Enforcement + +## Scope + +This BFF enforces authenticated access on business endpoints using Thalos session validation. + +## Protected Endpoints + +- `/api/customer/orders/status` +- `/api/customer/orders` + +## Anonymous Endpoints + +- `/health` +- `/healthz` + +## Session Validation Contract + +- BFF requires at least one session cookie: + - `thalos_session` + - `thalos_refresh` +- BFF calls Thalos session introspection endpoint: + - `GET /api/identity/session/me` +- Base address configured by: + - `ThalosAuth:BaseAddress` + +## Error Semantics + +Standard auth error payload: + +```json +{ + "code": "unauthorized|forbidden|session_missing|session_invalid", + "message": "human-readable message", + "correlationId": "request correlation id" +} +``` + +- `401`: missing or invalid session +- `403`: permission denied by identity service + +## Correlation + +- Incoming/outgoing correlation header: `x-correlation-id` +- Correlation ID is forwarded to Thalos session validation call. diff --git a/src/Customer.Orders.Bff.Rest/Program.cs b/src/Customer.Orders.Bff.Rest/Program.cs index cbd8846..cad02c3 100644 --- a/src/Customer.Orders.Bff.Rest/Program.cs +++ b/src/Customer.Orders.Bff.Rest/Program.cs @@ -1,22 +1,62 @@ +using System.Net; using Customer.Orders.Bff.Application.Adapters; using Customer.Orders.Bff.Application.Handlers; using Customer.Orders.Bff.Contracts.Requests; +using Microsoft.Extensions.Primitives; + +const string CorrelationHeaderName = "x-correlation-id"; +const string SessionAccessCookieName = "thalos_session"; +const string SessionRefreshCookieName = "thalos_refresh"; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHttpClient("ThalosAuth"); var app = builder.Build(); -app.MapGet("/api/customer/orders/status", async (string contextId, IGetCustomerOrderStatusHandler handler, CancellationToken ct) => +app.Use(async (context, next) => { + var correlationId = ResolveCorrelationId(context); + context.Items[CorrelationHeaderName] = correlationId; + context.Request.Headers[CorrelationHeaderName] = correlationId; + context.Response.Headers[CorrelationHeaderName] = correlationId; + await next(); +}); + +app.MapGet("/api/customer/orders/status", async ( + string contextId, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IGetCustomerOrderStatusHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + var request = new GetCustomerOrderStatusRequest(contextId); return Results.Ok(await handler.HandleAsync(request, ct)); }); -app.MapPost("/api/customer/orders", async (SubmitCustomerOrderRequest request, ISubmitCustomerOrderHandler handler, CancellationToken ct) => +app.MapPost("/api/customer/orders", async ( + SubmitCustomerOrderRequest request, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ISubmitCustomerOrderHandler handler, + CancellationToken ct) => { + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + return Results.Ok(await handler.HandleAsync(request, ct)); }); @@ -24,3 +64,93 @@ app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "customer- app.MapGet("/healthz", () => Results.Ok(new { status = "ok", service = "customer-orders-bff" })); app.Run(); + +string ResolveCorrelationId(HttpContext context) +{ + if (context.Items.TryGetValue(CorrelationHeaderName, out var itemValue) && + itemValue is string itemCorrelationId && + !string.IsNullOrWhiteSpace(itemCorrelationId)) + { + return itemCorrelationId; + } + + if (context.Request.Headers.TryGetValue(CorrelationHeaderName, out var headerValue) && + !StringValues.IsNullOrEmpty(headerValue)) + { + return headerValue.ToString(); + } + + return context.TraceIdentifier; +} + +async Task EnforceSessionAsync( + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + CancellationToken ct) +{ + var correlationId = ResolveCorrelationId(context); + + if (!context.Request.Cookies.ContainsKey(SessionAccessCookieName) && + !context.Request.Cookies.ContainsKey(SessionRefreshCookieName)) + { + return ErrorResponse(StatusCodes.Status401Unauthorized, "session_missing", "No active session.", correlationId); + } + + var thalosBaseAddress = configuration["ThalosAuth:BaseAddress"] ?? "http://thalos-bff:8080"; + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{thalosBaseAddress.TrimEnd('/')}/api/identity/session/me"); + + request.Headers.TryAddWithoutValidation(CorrelationHeaderName, correlationId); + var cookieHeader = BuildForwardCookieHeader(context); + if (!string.IsNullOrWhiteSpace(cookieHeader)) + { + request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); + } + + using var response = await httpClientFactory.CreateClient("ThalosAuth").SendAsync(request, ct); + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return ErrorResponse(StatusCodes.Status403Forbidden, "forbidden", "Permission denied.", correlationId); + } + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return ErrorResponse(StatusCodes.Status401Unauthorized, "unauthorized", "Unauthorized request.", correlationId); + } + + if (!response.IsSuccessStatusCode) + { + return ErrorResponse(StatusCodes.Status401Unauthorized, "session_invalid", "Session validation failed.", correlationId); + } + + return null; +} + +static string BuildForwardCookieHeader(HttpContext context) +{ + var cookies = new List(); + + if (context.Request.Cookies.TryGetValue(SessionAccessCookieName, out var accessCookie) && + !string.IsNullOrWhiteSpace(accessCookie)) + { + cookies.Add($"{SessionAccessCookieName}={accessCookie}"); + } + + if (context.Request.Cookies.TryGetValue(SessionRefreshCookieName, out var refreshCookie) && + !string.IsNullOrWhiteSpace(refreshCookie)) + { + cookies.Add($"{SessionRefreshCookieName}={refreshCookie}"); + } + + return string.Join("; ", cookies); +} + +static IResult ErrorResponse(int statusCode, string code, string message, string correlationId) +{ + return Results.Json(new AuthErrorResponse(code, message, correlationId), statusCode: statusCode); +} + +sealed record AuthErrorResponse(string Code, string Message, string CorrelationId);