merge: integrate furniture-bff auth and web updates

This commit is contained in:
José René White Enciso 2026-03-11 12:39:09 -06:00
commit c2c6c41965
7 changed files with 116 additions and 11 deletions

View File

@ -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

View File

@ -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

View File

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

View File

@ -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<FurnitureRuntime.FurnitureRuntimeClient>(options =>
@ -132,21 +134,50 @@ async Task<IResult?> 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;

View File

@ -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;
}
}
}

View File

@ -18,5 +18,6 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Furniture.Bff.Application\Furniture.Bff.Application.csproj" />
<ProjectReference Include="..\..\src\Furniture.Bff.Contracts\Furniture.Bff.Contracts.csproj" />
<ProjectReference Include="..\..\src\Furniture.Bff.Rest\Furniture.Bff.Rest.csproj" />
</ItemGroup>
</Project>

View File

@ -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);
}
}