diff --git a/docs/security/auth-enforcement.md b/docs/security/auth-enforcement.md new file mode 100644 index 0000000..f17e486 --- /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/furniture/{furnitureId}/availability` +- `(GET-only endpoint in this BFF)` + +## 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/Furniture.Bff.Rest/Program.cs b/src/Furniture.Bff.Rest/Program.cs index a975cd1..f8a589e 100644 --- a/src/Furniture.Bff.Rest/Program.cs +++ b/src/Furniture.Bff.Rest/Program.cs @@ -1,3 +1,4 @@ +using System.Net; using Core.Blueprint.Common.DependencyInjection; using Furniture.Bff.Application.Adapters; using Furniture.Bff.Application.DependencyInjection; @@ -9,6 +10,8 @@ using Furniture.Service.Grpc; using Microsoft.Extensions.Primitives; const string CorrelationHeaderName = "x-correlation-id"; +const string SessionAccessCookieName = "thalos_session"; +const string SessionRefreshCookieName = "thalos_refresh"; const string CorsPolicyName = "FurnitureBffCors"; var builder = WebApplication.CreateBuilder(args); @@ -24,6 +27,7 @@ builder.Services.AddHealthChecks(); builder.Services.AddBlueprintRuntimeCore(); builder.Services.AddFurnitureBffApplicationRuntime(); builder.Services.AddScoped(); +builder.Services.AddHttpClient("ThalosAuth"); var allowedOrigins = builder.Configuration.GetSection("FurnitureBff:AllowedOrigins").Get() ?? ["http://localhost:22380", "http://127.0.0.1:22380"]; builder.Services.AddCors(options => @@ -60,8 +64,17 @@ app.Use(async (context, next) => app.MapGet($"{EndpointConventions.ApiPrefix}/{{furnitureId}}/availability", async ( string furnitureId, HttpContext context, - IGetFurnitureAvailabilityHandler handler) => + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IGetFurnitureAvailabilityHandler handler, + CancellationToken ct) => { + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + var request = new GetFurnitureAvailabilityApiRequest( furnitureId, ResolveCorrelationId(context)); @@ -92,3 +105,75 @@ string ResolveCorrelationId(HttpContext context) 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);