diff --git a/docs/api/external-api-surface.md b/docs/api/external-api-surface.md index 7dd8ed2..8ef47af 100644 --- a/docs/api/external-api-surface.md +++ b/docs/api/external-api-surface.md @@ -7,6 +7,8 @@ ## Endpoint Baseline - `GET /api/furniture/{furnitureId}/availability` +- Auth required: valid Thalos session (`thalos_session` or `thalos_refresh` cookie, validated via `/api/identity/session/me`) +- Anonymous exceptions: `/health`, `/healthz` ## Edge Responsibilities diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 652360f..2aa7277 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -23,6 +23,8 @@ docker run --rm -p 8080:8080 --name furniture-bff agilewebs/furniture-bff:dev ## Runtime Notes - Requires `FurnitureService__GrpcAddress` to target furniture-service in distributed runs. +- Requires `ThalosAuth__BaseAddress` to target thalos-bff session introspection endpoint. +- For browser usage, configure `FurnitureBff__AllowedOrigins` with explicit origins (not `*`) so cookie credentials are permitted. - gRPC client contract protobuf is vendored at `src/Furniture.Bff.Rest/Protos/furniture_runtime.proto` to keep image builds repo-local. ## Health Endpoint Consistency diff --git a/docs/security/auth-enforcement.md b/docs/security/auth-enforcement.md index f17e486..3b8edf3 100644 --- a/docs/security/auth-enforcement.md +++ b/docs/security/auth-enforcement.md @@ -38,8 +38,14 @@ Standard auth error payload: - `401`: missing or invalid session - `403`: permission denied by identity service +- `503`: identity service unavailable or timeout during session introspection (`identity_unavailable|identity_timeout`) ## Correlation - Incoming/outgoing correlation header: `x-correlation-id` - Correlation ID is forwarded to Thalos session validation call. + +## CORS and Cookie Propagation + +- When `FurnitureBff:AllowedOrigins` is explicit (non-`*`), the BFF enables credentials so browser session cookies are forwarded. +- Wildcard origins remain unsupported for credentialed browser calls by design. diff --git a/src/Furniture.Bff.Rest/Program.cs b/src/Furniture.Bff.Rest/Program.cs index f8a589e..2c998df 100644 --- a/src/Furniture.Bff.Rest/Program.cs +++ b/src/Furniture.Bff.Rest/Program.cs @@ -6,6 +6,7 @@ using Furniture.Bff.Application.Handlers; using Furniture.Bff.Contracts.Api; using Furniture.Bff.Rest.Adapters; using Furniture.Bff.Rest.Endpoints; +using Furniture.Bff.Rest.Security; using Furniture.Service.Grpc; using Microsoft.Extensions.Primitives; @@ -40,7 +41,8 @@ builder.Services.AddCors(options => return; } - policy.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader(); + // Cookie-based session propagation requires explicit allowed origins and credentials. + policy.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader().AllowCredentials(); }); }); builder.Services.AddGrpcClient(options => @@ -132,21 +134,50 @@ async Task EnforceSessionAsync( request.Headers.TryAddWithoutValidation("Cookie", cookieHeader); } - using var response = await httpClientFactory.CreateClient("ThalosAuth").SendAsync(request, ct); - - if (response.StatusCode == HttpStatusCode.Forbidden) + HttpResponseMessage response; + try { - return ErrorResponse(StatusCodes.Status403Forbidden, "forbidden", "Permission denied.", correlationId); + response = await httpClientFactory.CreateClient("ThalosAuth").SendAsync(request, ct); + } + catch (HttpRequestException) + { + return ErrorResponse( + StatusCodes.Status503ServiceUnavailable, + "identity_unavailable", + "Identity service is temporarily unavailable.", + correlationId); + } + catch (TaskCanceledException) + { + return ErrorResponse( + StatusCodes.Status503ServiceUnavailable, + "identity_timeout", + "Identity service did not respond in time.", + correlationId); } - if (response.StatusCode == HttpStatusCode.Unauthorized) + using (response) { - return ErrorResponse(StatusCodes.Status401Unauthorized, "unauthorized", "Unauthorized request.", correlationId); - } + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return ErrorResponse(StatusCodes.Status403Forbidden, "forbidden", "Permission denied.", correlationId); + } - if (!response.IsSuccessStatusCode) - { - return ErrorResponse(StatusCodes.Status401Unauthorized, "session_invalid", "Session validation failed.", 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); + } + + var payload = await response.Content.ReadAsStringAsync(ct); + if (!SessionMePayloadParser.IsAuthenticated(payload)) + { + return ErrorResponse(StatusCodes.Status401Unauthorized, "session_invalid", "Session validation failed.", correlationId); + } } return null; diff --git a/src/Furniture.Bff.Rest/Security/SessionMePayloadParser.cs b/src/Furniture.Bff.Rest/Security/SessionMePayloadParser.cs new file mode 100644 index 0000000..2d7d0b1 --- /dev/null +++ b/src/Furniture.Bff.Rest/Security/SessionMePayloadParser.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace Furniture.Bff.Rest.Security; + +public static class SessionMePayloadParser +{ + public static bool IsAuthenticated(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(payload); + if (!document.RootElement.TryGetProperty("isAuthenticated", out var value)) + { + return false; + } + + return value.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/tests/Furniture.Bff.Application.UnitTests/Furniture.Bff.Application.UnitTests.csproj b/tests/Furniture.Bff.Application.UnitTests/Furniture.Bff.Application.UnitTests.csproj index 5092ef0..7ae51fe 100644 --- a/tests/Furniture.Bff.Application.UnitTests/Furniture.Bff.Application.UnitTests.csproj +++ b/tests/Furniture.Bff.Application.UnitTests/Furniture.Bff.Application.UnitTests.csproj @@ -18,5 +18,6 @@ + diff --git a/tests/Furniture.Bff.Application.UnitTests/SessionMePayloadParserTests.cs b/tests/Furniture.Bff.Application.UnitTests/SessionMePayloadParserTests.cs new file mode 100644 index 0000000..5d45fa3 --- /dev/null +++ b/tests/Furniture.Bff.Application.UnitTests/SessionMePayloadParserTests.cs @@ -0,0 +1,34 @@ +using Furniture.Bff.Rest.Security; + +namespace Furniture.Bff.Application.UnitTests; + +public class SessionMePayloadParserTests +{ + [Fact] + public void IsAuthenticated_WhenPayloadContainsTrueFlag_ReturnsTrue() + { + const string payload = "{\"isAuthenticated\":true,\"subjectId\":\"demo-user\"}"; + + var result = SessionMePayloadParser.IsAuthenticated(payload); + + Assert.True(result); + } + + [Fact] + public void IsAuthenticated_WhenPayloadContainsFalseFlag_ReturnsFalse() + { + const string payload = "{\"isAuthenticated\":false}"; + + var result = SessionMePayloadParser.IsAuthenticated(payload); + + Assert.False(result); + } + + [Fact] + public void IsAuthenticated_WhenPayloadIsInvalid_ReturnsFalse() + { + var result = SessionMePayloadParser.IsAuthenticated("{invalid"); + + Assert.False(result); + } +}