diff --git a/docs/api/identity-edge-api.md b/docs/api/identity-edge-api.md
index 0423fa2..822b38a 100644
--- a/docs/api/identity-edge-api.md
+++ b/docs/api/identity-edge-api.md
@@ -7,13 +7,22 @@
## Entrypoints
-- `POST /api/identity/token`
-- `POST /api/identity/session/refresh`
+- Canonical session endpoints:
+ - `POST /api/identity/session/login`
+ - `POST /api/identity/session/refresh`
+ - `POST /api/identity/session/logout`
+ - `GET /api/identity/session/me`
+- Compatibility endpoint:
+ - `POST /api/identity/token`
+ - `POST /api/identity/login`
+ - `POST /api/identity/token/refresh`
+ - `POST /api/identity/logout`
## Boundary Notes
- Endpoint handlers perform edge validation and permission checks.
-- Token issuance and policy evaluation requests are mapped to thalos-service identity contracts.
-- Session refresh requests are mapped through edge contract adapters before downstream calls.
+- Session login and refresh call canonical thalos-service session gRPC operations.
+- Session cookies are managed at the BFF edge (`thalos_session`, `thalos_refresh`) with env-driven secure flag.
+- Token issuance and policy evaluation contracts remain available for compatibility calls.
- Business orchestration remains in thalos-service.
- Identity abstractions remain owned by Thalos repositories.
diff --git a/docs/security/permission-enforcement-map.md b/docs/security/permission-enforcement-map.md
index d72d296..a0ca4d2 100644
--- a/docs/security/permission-enforcement-map.md
+++ b/docs/security/permission-enforcement-map.md
@@ -9,3 +9,7 @@
- Permission checks happen at BFF entrypoints using thalos-service policy responses.
- Authorization decisions are explicit and traceable at edge boundaries.
+- Auth failure payload shape is standardized as `{ code, message, correlationId }`.
+- HTTP semantics:
+ - `401`: no valid session or failed session issuance/refresh.
+ - `403`: authenticated but denied by permission policy.
diff --git a/src/Thalos.Bff.Application/Adapters/IThalosServiceClient.cs b/src/Thalos.Bff.Application/Adapters/IThalosServiceClient.cs
index 2e33aa6..bae766f 100644
--- a/src/Thalos.Bff.Application/Adapters/IThalosServiceClient.cs
+++ b/src/Thalos.Bff.Application/Adapters/IThalosServiceClient.cs
@@ -1,5 +1,6 @@
using BuildingBlock.Identity.Contracts.Requests;
using BuildingBlock.Identity.Contracts.Responses;
+using Thalos.Bff.Application.Sessions;
namespace Thalos.Bff.Application.Adapters;
@@ -8,6 +9,14 @@ namespace Thalos.Bff.Application.Adapters;
///
public interface IThalosServiceClient
{
+ ///
+ /// Starts canonical session flow in thalos-service.
+ ///
+ /// Identity token issuance request.
+ /// Request correlation identifier.
+ /// Session token bundle.
+ Task StartSessionAsync(IssueIdentityTokenRequest request, string correlationId);
+
///
/// Requests token issuance from thalos-service.
///
@@ -28,4 +37,11 @@ public interface IThalosServiceClient
/// Session refresh request.
/// Session refresh response.
Task RefreshSessionAsync(RefreshIdentitySessionRequest request);
+
+ ///
+ /// Refreshes canonical session flow in thalos-service.
+ ///
+ /// Session refresh request.
+ /// Session token bundle.
+ Task RefreshSessionTokensAsync(RefreshIdentitySessionRequest request);
}
diff --git a/src/Thalos.Bff.Application/Sessions/SessionTokensResult.cs b/src/Thalos.Bff.Application/Sessions/SessionTokensResult.cs
new file mode 100644
index 0000000..8dc4a4c
--- /dev/null
+++ b/src/Thalos.Bff.Application/Sessions/SessionTokensResult.cs
@@ -0,0 +1,14 @@
+using BuildingBlock.Identity.Contracts.Conventions;
+
+namespace Thalos.Bff.Application.Sessions;
+
+///
+/// Session token payload returned by thalos-service session operations.
+///
+public sealed record SessionTokensResult(
+ string AccessToken,
+ string RefreshToken,
+ int ExpiresInSeconds,
+ string SubjectId,
+ string TenantId,
+ IdentityAuthProvider Provider);
diff --git a/src/Thalos.Bff.Contracts/Api/ApiErrorResponse.cs b/src/Thalos.Bff.Contracts/Api/ApiErrorResponse.cs
new file mode 100644
index 0000000..810c29f
--- /dev/null
+++ b/src/Thalos.Bff.Contracts/Api/ApiErrorResponse.cs
@@ -0,0 +1,9 @@
+namespace Thalos.Bff.Contracts.Api;
+
+///
+/// Standardized API error payload.
+///
+/// Stable machine-readable error code.
+/// Human-readable error message.
+/// Request correlation identifier.
+public sealed record ApiErrorResponse(string Code, string Message, string CorrelationId);
diff --git a/src/Thalos.Bff.Contracts/Api/SessionLoginApiRequest.cs b/src/Thalos.Bff.Contracts/Api/SessionLoginApiRequest.cs
new file mode 100644
index 0000000..3aaace1
--- /dev/null
+++ b/src/Thalos.Bff.Contracts/Api/SessionLoginApiRequest.cs
@@ -0,0 +1,18 @@
+using BuildingBlock.Identity.Contracts.Conventions;
+
+namespace Thalos.Bff.Contracts.Api;
+
+///
+/// Canonical API request for session login.
+///
+/// Identity subject identifier.
+/// Tenant identifier.
+/// Request correlation identifier.
+/// Identity auth provider.
+/// External provider token when applicable.
+public sealed record SessionLoginApiRequest(
+ string SubjectId,
+ string TenantId,
+ string CorrelationId,
+ IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt,
+ string ExternalToken = "");
diff --git a/src/Thalos.Bff.Contracts/Api/SessionLoginApiResponse.cs b/src/Thalos.Bff.Contracts/Api/SessionLoginApiResponse.cs
new file mode 100644
index 0000000..368380e
--- /dev/null
+++ b/src/Thalos.Bff.Contracts/Api/SessionLoginApiResponse.cs
@@ -0,0 +1,16 @@
+using BuildingBlock.Identity.Contracts.Conventions;
+
+namespace Thalos.Bff.Contracts.Api;
+
+///
+/// Canonical API response for session login and refresh.
+///
+/// Identity subject identifier.
+/// Tenant identifier.
+/// Identity auth provider.
+/// Access token expiration in seconds.
+public sealed record SessionLoginApiResponse(
+ string SubjectId,
+ string TenantId,
+ IdentityAuthProvider Provider,
+ int ExpiresInSeconds);
diff --git a/src/Thalos.Bff.Contracts/Api/SessionMeApiResponse.cs b/src/Thalos.Bff.Contracts/Api/SessionMeApiResponse.cs
new file mode 100644
index 0000000..833a7ab
--- /dev/null
+++ b/src/Thalos.Bff.Contracts/Api/SessionMeApiResponse.cs
@@ -0,0 +1,16 @@
+using BuildingBlock.Identity.Contracts.Conventions;
+
+namespace Thalos.Bff.Contracts.Api;
+
+///
+/// API response contract for current authenticated session details.
+///
+/// Indicates whether the caller has an authenticated session.
+/// Identity subject identifier.
+/// Tenant identifier.
+/// Identity auth provider.
+public sealed record SessionMeApiResponse(
+ bool IsAuthenticated,
+ string SubjectId,
+ string TenantId,
+ IdentityAuthProvider Provider);
diff --git a/src/Thalos.Bff.Rest/Adapters/ThalosServiceGrpcClientAdapter.cs b/src/Thalos.Bff.Rest/Adapters/ThalosServiceGrpcClientAdapter.cs
index 01f3724..5fa5d99 100644
--- a/src/Thalos.Bff.Rest/Adapters/ThalosServiceGrpcClientAdapter.cs
+++ b/src/Thalos.Bff.Rest/Adapters/ThalosServiceGrpcClientAdapter.cs
@@ -1,6 +1,7 @@
using Grpc.Core;
using Microsoft.Extensions.Primitives;
using Thalos.Bff.Application.Adapters;
+using Thalos.Bff.Application.Sessions;
using Thalos.Service.Grpc;
using BuildingBlock.Identity.Contracts.Requests;
using BuildingBlock.Identity.Contracts.Responses;
@@ -12,11 +13,35 @@ namespace Thalos.Bff.Rest.Adapters;
///
public sealed class ThalosServiceGrpcClientAdapter(
IdentityRuntime.IdentityRuntimeClient grpcClient,
- IHttpContextAccessor httpContextAccessor,
- IConfiguration configuration) : IThalosServiceClient
+ IHttpContextAccessor httpContextAccessor) : IThalosServiceClient
{
private const string CorrelationHeaderName = "x-correlation-id";
- private readonly string refreshTenantId = configuration["ThalosService:RefreshTenantId"] ?? "refresh";
+
+ ///
+ public async Task StartSessionAsync(IssueIdentityTokenRequest request, string correlationId)
+ {
+ var resolvedCorrelationId = ResolveCorrelationId(correlationId);
+ var grpcRequest = new StartIdentitySessionGrpcRequest
+ {
+ SubjectId = request.SubjectId,
+ TenantId = request.TenantId,
+ Provider = request.Provider.ToString(),
+ ExternalToken = request.ExternalToken,
+ CorrelationId = resolvedCorrelationId
+ };
+
+ var grpcResponse = await grpcClient.StartIdentitySessionAsync(
+ grpcRequest,
+ headers: CreateHeaders(resolvedCorrelationId));
+
+ return new SessionTokensResult(
+ grpcResponse.AccessToken,
+ grpcResponse.RefreshToken,
+ grpcResponse.ExpiresInSeconds,
+ grpcResponse.SubjectId,
+ grpcResponse.TenantId,
+ ParseProvider(grpcResponse.Provider));
+ }
///
public async Task IssueTokenAsync(IssueIdentityTokenRequest request)
@@ -61,20 +86,33 @@ public sealed class ThalosServiceGrpcClientAdapter(
///
public async Task RefreshSessionAsync(RefreshIdentitySessionRequest request)
+ {
+ var sessionTokens = await RefreshSessionTokensAsync(request);
+ return new RefreshIdentitySessionResponse(sessionTokens.AccessToken, sessionTokens.ExpiresInSeconds);
+ }
+
+ ///
+ public async Task RefreshSessionTokensAsync(RefreshIdentitySessionRequest request)
{
var correlationId = ResolveCorrelationId(request.CorrelationId);
- var grpcRequest = new IssueIdentityTokenGrpcRequest
+ var grpcRequest = new RefreshIdentitySessionGrpcRequest
{
- SubjectId = request.RefreshToken,
- TenantId = refreshTenantId,
+ RefreshToken = request.RefreshToken,
+ CorrelationId = correlationId,
Provider = request.Provider.ToString()
};
- var grpcResponse = await grpcClient.IssueIdentityTokenAsync(
+ var grpcResponse = await grpcClient.RefreshIdentitySessionAsync(
grpcRequest,
headers: CreateHeaders(correlationId));
- return new RefreshIdentitySessionResponse(grpcResponse.Token, grpcResponse.ExpiresInSeconds);
+ return new SessionTokensResult(
+ grpcResponse.AccessToken,
+ string.IsNullOrWhiteSpace(grpcResponse.RefreshToken) ? request.RefreshToken : grpcResponse.RefreshToken,
+ grpcResponse.ExpiresInSeconds,
+ grpcResponse.SubjectId,
+ grpcResponse.TenantId,
+ ParseProvider(grpcResponse.Provider));
}
private string ResolveCorrelationId(string? preferred = null)
@@ -108,4 +146,11 @@ public sealed class ThalosServiceGrpcClientAdapter(
{ CorrelationHeaderName, correlationId }
};
}
+
+ private static BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider ParseProvider(string provider)
+ {
+ return Enum.TryParse(provider, true, out var parsedProvider)
+ ? parsedProvider
+ : BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider.InternalJwt;
+ }
}
diff --git a/src/Thalos.Bff.Rest/Program.cs b/src/Thalos.Bff.Rest/Program.cs
index b26c0ec..e014496 100644
--- a/src/Thalos.Bff.Rest/Program.cs
+++ b/src/Thalos.Bff.Rest/Program.cs
@@ -1,14 +1,20 @@
+using BuildingBlock.Identity.Contracts.Conventions;
+using BuildingBlock.Identity.Contracts.Requests;
using Core.Blueprint.Common.DependencyInjection;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Thalos.Bff.Application.Adapters;
using Thalos.Bff.Application.DependencyInjection;
using Thalos.Bff.Application.Handlers;
+using Thalos.Bff.Application.Security;
using Thalos.Bff.Contracts.Api;
using Thalos.Bff.Rest.Adapters;
using Thalos.Bff.Rest.Endpoints;
using Thalos.Service.Grpc;
const string CorrelationHeaderName = "x-correlation-id";
+const string SessionAccessCookieName = "thalos_session";
+const string SessionRefreshCookieName = "thalos_refresh";
var builder = WebApplication.CreateBuilder(args);
var edgeProtocol = builder.Configuration["ThalosBff:EdgeProtocol"] ?? "rest";
@@ -40,6 +46,90 @@ app.Use(async (context, next) =>
await next();
});
+app.MapPost($"{EndpointConventions.ApiPrefix}/session/login", async (
+ SessionLoginApiRequest request,
+ HttpContext context,
+ IThalosServiceClient serviceClient,
+ IIdentityEdgeContractAdapter contractAdapter,
+ IPermissionGuard permissionGuard) =>
+{
+ var correlationId = ResolveCorrelationId(context, request.CorrelationId);
+ var issueRequest = new IssueTokenApiRequest(
+ request.SubjectId,
+ request.TenantId,
+ correlationId,
+ request.Provider,
+ request.ExternalToken);
+
+ var policyRequest = contractAdapter.ToPolicyRequest(issueRequest, "identity.token.issue");
+ var policyResponse = await serviceClient.EvaluatePolicyAsync(policyRequest);
+ if (!permissionGuard.CanAccess(policyResponse))
+ {
+ return ErrorResult(StatusCodes.Status403Forbidden, "forbidden", "Permission denied.", correlationId);
+ }
+
+ var serviceRequest = contractAdapter.ToIssueTokenRequest(issueRequest);
+ var sessionTokens = await serviceClient.StartSessionAsync(serviceRequest, correlationId);
+
+ if (string.IsNullOrWhiteSpace(sessionTokens.AccessToken) || string.IsNullOrWhiteSpace(sessionTokens.RefreshToken))
+ {
+ return ErrorResult(StatusCodes.Status401Unauthorized, "session_login_failed", "Unable to issue session.", correlationId);
+ }
+
+ WriteSessionCookies(context, sessionTokens, builder.Configuration);
+
+ var response = new SessionLoginApiResponse(
+ sessionTokens.SubjectId,
+ sessionTokens.TenantId,
+ sessionTokens.Provider,
+ sessionTokens.ExpiresInSeconds);
+
+ return Results.Ok(response);
+});
+
+// Compatibility alias kept for existing token-based callers.
+app.MapPost($"{EndpointConventions.ApiPrefix}/login", async (
+ SessionLoginApiRequest request,
+ HttpContext context,
+ IThalosServiceClient serviceClient,
+ IIdentityEdgeContractAdapter contractAdapter,
+ IPermissionGuard permissionGuard) =>
+{
+ var correlationId = ResolveCorrelationId(context, request.CorrelationId);
+ var issueRequest = new IssueTokenApiRequest(
+ request.SubjectId,
+ request.TenantId,
+ correlationId,
+ request.Provider,
+ request.ExternalToken);
+
+ var policyRequest = contractAdapter.ToPolicyRequest(issueRequest, "identity.token.issue");
+ var policyResponse = await serviceClient.EvaluatePolicyAsync(policyRequest);
+ if (!permissionGuard.CanAccess(policyResponse))
+ {
+ return ErrorResult(StatusCodes.Status403Forbidden, "forbidden", "Permission denied.", correlationId);
+ }
+
+ var serviceRequest = contractAdapter.ToIssueTokenRequest(issueRequest);
+ var sessionTokens = await serviceClient.StartSessionAsync(serviceRequest, correlationId);
+
+ if (string.IsNullOrWhiteSpace(sessionTokens.AccessToken) || string.IsNullOrWhiteSpace(sessionTokens.RefreshToken))
+ {
+ return ErrorResult(StatusCodes.Status401Unauthorized, "session_login_failed", "Unable to issue session.", correlationId);
+ }
+
+ WriteSessionCookies(context, sessionTokens, builder.Configuration);
+
+ var response = new SessionLoginApiResponse(
+ sessionTokens.SubjectId,
+ sessionTokens.TenantId,
+ sessionTokens.Provider,
+ sessionTokens.ExpiresInSeconds);
+
+ return Results.Ok(response);
+});
+
+// Compatibility alias kept for existing token-based callers.
app.MapPost($"{EndpointConventions.ApiPrefix}/token", async (
IssueTokenApiRequest request,
HttpContext context,
@@ -54,18 +144,105 @@ app.MapPost($"{EndpointConventions.ApiPrefix}/token", async (
}
catch (UnauthorizedAccessException)
{
- return Results.Unauthorized();
+ var correlationId = ResolveCorrelationId(context, normalizedRequest.CorrelationId);
+ return ErrorResult(StatusCodes.Status401Unauthorized, "unauthorized", "Unauthorized request.", correlationId);
}
});
app.MapPost($"{EndpointConventions.ApiPrefix}/session/refresh", async (
- RefreshSessionApiRequest request,
+ RefreshSessionApiRequest? request,
HttpContext context,
- IRefreshSessionHandler handler) =>
+ IThalosServiceClient serviceClient) =>
{
- var normalizedRequest = request with { CorrelationId = ResolveCorrelationId(context, request.CorrelationId) };
- var response = await handler.HandleAsync(normalizedRequest);
- return Results.Ok(response);
+ var correlationId = ResolveCorrelationId(context, request?.CorrelationId);
+ var refreshToken = request?.RefreshToken;
+ if (string.IsNullOrWhiteSpace(refreshToken))
+ {
+ context.Request.Cookies.TryGetValue(SessionRefreshCookieName, out refreshToken);
+ }
+
+ if (string.IsNullOrWhiteSpace(refreshToken))
+ {
+ return ErrorResult(StatusCodes.Status401Unauthorized, "session_missing", "Session refresh token is required.", correlationId);
+ }
+
+ var provider = request?.Provider ?? IdentityAuthProvider.InternalJwt;
+ var refreshResponse = await serviceClient.RefreshSessionTokensAsync(
+ new RefreshIdentitySessionRequest(refreshToken, correlationId, provider));
+
+ if (string.IsNullOrWhiteSpace(refreshResponse.AccessToken))
+ {
+ return ErrorResult(StatusCodes.Status401Unauthorized, "session_refresh_failed", "Session refresh failed.", correlationId);
+ }
+
+ WriteSessionCookies(context, refreshResponse, builder.Configuration);
+ return Results.Ok(new SessionLoginApiResponse(
+ refreshResponse.SubjectId,
+ refreshResponse.TenantId,
+ refreshResponse.Provider,
+ refreshResponse.ExpiresInSeconds));
+});
+
+// Compatibility alias kept for token-first refresh callers.
+app.MapPost($"{EndpointConventions.ApiPrefix}/token/refresh", async (
+ RefreshSessionApiRequest? request,
+ HttpContext context,
+ IThalosServiceClient serviceClient) =>
+{
+ var correlationId = ResolveCorrelationId(context, request?.CorrelationId);
+ var refreshToken = request?.RefreshToken;
+ if (string.IsNullOrWhiteSpace(refreshToken))
+ {
+ context.Request.Cookies.TryGetValue(SessionRefreshCookieName, out refreshToken);
+ }
+
+ if (string.IsNullOrWhiteSpace(refreshToken))
+ {
+ return ErrorResult(StatusCodes.Status401Unauthorized, "session_missing", "Session refresh token is required.", correlationId);
+ }
+
+ var provider = request?.Provider ?? IdentityAuthProvider.InternalJwt;
+ var refreshResponse = await serviceClient.RefreshSessionTokensAsync(
+ new RefreshIdentitySessionRequest(refreshToken, correlationId, provider));
+
+ if (string.IsNullOrWhiteSpace(refreshResponse.AccessToken))
+ {
+ return ErrorResult(StatusCodes.Status401Unauthorized, "session_refresh_failed", "Session refresh failed.", correlationId);
+ }
+
+ WriteSessionCookies(context, refreshResponse, builder.Configuration);
+ return Results.Ok(new RefreshSessionApiResponse(refreshResponse.AccessToken, refreshResponse.ExpiresInSeconds));
+});
+
+app.MapPost($"{EndpointConventions.ApiPrefix}/session/logout", (HttpContext context) =>
+{
+ DeleteSessionCookies(context, builder.Configuration);
+ return Results.NoContent();
+});
+
+// Compatibility alias for logout callers.
+app.MapPost($"{EndpointConventions.ApiPrefix}/logout", (HttpContext context) =>
+{
+ DeleteSessionCookies(context, builder.Configuration);
+ return Results.NoContent();
+});
+
+app.MapGet($"{EndpointConventions.ApiPrefix}/session/me", (HttpContext context) =>
+{
+ var correlationId = ResolveCorrelationId(context);
+
+ if (!context.Request.Cookies.TryGetValue(SessionAccessCookieName, out var accessToken) ||
+ string.IsNullOrWhiteSpace(accessToken))
+ {
+ return ErrorResult(StatusCodes.Status401Unauthorized, "session_missing", "No active session.", correlationId);
+ }
+
+ if (!TryParseSessionProfile(accessToken, out var meResponse))
+ {
+ return ErrorResult(StatusCodes.Status401Unauthorized, "session_invalid", "Invalid session token.", correlationId);
+ }
+
+ return Results.Ok(meResponse);
});
app.MapHealthChecks("/healthz");
@@ -73,6 +250,11 @@ app.MapHealthChecks("/health");
app.Run();
+IResult ErrorResult(int statusCode, string code, string message, string correlationId)
+{
+ return Results.Json(new ApiErrorResponse(code, message, correlationId), statusCode: statusCode);
+}
+
string ResolveCorrelationId(HttpContext context, string? preferred = null)
{
if (!string.IsNullOrWhiteSpace(preferred))
@@ -98,3 +280,79 @@ string ResolveCorrelationId(HttpContext context, string? preferred = null)
return context.TraceIdentifier;
}
+
+void WriteSessionCookies(HttpContext context, Thalos.Bff.Application.Sessions.SessionTokensResult tokens, IConfiguration configuration)
+{
+ var secureCookie = configuration.GetValue("ThalosBff:SessionCookieSecure", false);
+ var cookieOptions = CreateCookieOptions(secureCookie, tokens.ExpiresInSeconds);
+
+ context.Response.Cookies.Append(SessionAccessCookieName, tokens.AccessToken, cookieOptions);
+
+ var refreshCookieSeconds = Math.Max(tokens.ExpiresInSeconds, 8 * 60 * 60);
+ context.Response.Cookies.Append(
+ SessionRefreshCookieName,
+ tokens.RefreshToken,
+ CreateCookieOptions(secureCookie, refreshCookieSeconds));
+}
+
+void DeleteSessionCookies(HttpContext context, IConfiguration configuration)
+{
+ var secureCookie = configuration.GetValue("ThalosBff:SessionCookieSecure", false);
+ var options = CreateCookieOptions(secureCookie, 0);
+ context.Response.Cookies.Delete(SessionAccessCookieName, options);
+ context.Response.Cookies.Delete(SessionRefreshCookieName, options);
+}
+
+static CookieOptions CreateCookieOptions(bool secure, int expiresInSeconds)
+{
+ return new CookieOptions
+ {
+ HttpOnly = true,
+ Secure = secure,
+ SameSite = SameSiteMode.Lax,
+ Path = "/",
+ MaxAge = TimeSpan.FromSeconds(Math.Max(0, expiresInSeconds))
+ };
+}
+
+static bool TryParseSessionProfile(string accessToken, out SessionMeApiResponse response)
+{
+ response = new SessionMeApiResponse(false, string.Empty, string.Empty, IdentityAuthProvider.InternalJwt);
+
+ if (string.IsNullOrWhiteSpace(accessToken))
+ {
+ return false;
+ }
+
+ var parts = accessToken.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length < 3)
+ {
+ return false;
+ }
+
+ IdentityAuthProvider provider;
+ string subjectId;
+ string tenantId;
+
+ if (parts[0].Equals("azure", StringComparison.OrdinalIgnoreCase) && parts.Length >= 4)
+ {
+ provider = IdentityAuthProvider.AzureAd;
+ subjectId = parts[1];
+ tenantId = parts[2];
+ }
+ else if (parts[0].Equals("google", StringComparison.OrdinalIgnoreCase) && parts.Length >= 4)
+ {
+ provider = IdentityAuthProvider.Google;
+ subjectId = parts[1];
+ tenantId = parts[2];
+ }
+ else
+ {
+ provider = IdentityAuthProvider.InternalJwt;
+ subjectId = parts[0];
+ tenantId = parts[1];
+ }
+
+ response = new SessionMeApiResponse(true, subjectId, tenantId, provider);
+ return true;
+}
diff --git a/src/Thalos.Bff.Rest/Protos/identity_runtime.proto b/src/Thalos.Bff.Rest/Protos/identity_runtime.proto
index 02b620b..ca37a19 100644
--- a/src/Thalos.Bff.Rest/Protos/identity_runtime.proto
+++ b/src/Thalos.Bff.Rest/Protos/identity_runtime.proto
@@ -5,10 +5,44 @@ option csharp_namespace = "Thalos.Service.Grpc";
package thalos.service.grpc;
service IdentityRuntime {
+ rpc StartIdentitySession (StartIdentitySessionGrpcRequest) returns (StartIdentitySessionGrpcResponse);
+ rpc RefreshIdentitySession (RefreshIdentitySessionGrpcRequest) returns (RefreshIdentitySessionGrpcResponse);
rpc IssueIdentityToken (IssueIdentityTokenGrpcRequest) returns (IssueIdentityTokenGrpcResponse);
rpc EvaluateIdentityPolicy (EvaluateIdentityPolicyGrpcRequest) returns (EvaluateIdentityPolicyGrpcResponse);
}
+message StartIdentitySessionGrpcRequest {
+ string subject_id = 1;
+ string tenant_id = 2;
+ string provider = 3;
+ string external_token = 4;
+ string correlation_id = 5;
+}
+
+message StartIdentitySessionGrpcResponse {
+ string access_token = 1;
+ string refresh_token = 2;
+ int32 expires_in_seconds = 3;
+ string subject_id = 4;
+ string tenant_id = 5;
+ string provider = 6;
+}
+
+message RefreshIdentitySessionGrpcRequest {
+ string refresh_token = 1;
+ string correlation_id = 2;
+ string provider = 3;
+}
+
+message RefreshIdentitySessionGrpcResponse {
+ string access_token = 1;
+ string refresh_token = 2;
+ int32 expires_in_seconds = 3;
+ string subject_id = 4;
+ string tenant_id = 5;
+ string provider = 6;
+}
+
message IssueIdentityTokenGrpcRequest {
string subject_id = 1;
string tenant_id = 2;
diff --git a/tests/Thalos.Bff.Application.UnitTests/ContractShapeTests.cs b/tests/Thalos.Bff.Application.UnitTests/ContractShapeTests.cs
index 99f4d1c..0003480 100644
--- a/tests/Thalos.Bff.Application.UnitTests/ContractShapeTests.cs
+++ b/tests/Thalos.Bff.Application.UnitTests/ContractShapeTests.cs
@@ -18,6 +18,17 @@ public class ContractShapeTests
Assert.Equal(IdentityAuthProvider.InternalJwt, request.Provider);
}
+ [Fact]
+ public void SessionLoginApiRequest_WhenCreated_UsesProviderDefault()
+ {
+ var request = new SessionLoginApiRequest("user-2", "tenant-2", "corr-456");
+
+ Assert.Equal("user-2", request.SubjectId);
+ Assert.Equal("tenant-2", request.TenantId);
+ Assert.Equal("corr-456", request.CorrelationId);
+ Assert.Equal(IdentityAuthProvider.InternalJwt, request.Provider);
+ }
+
[Fact]
public void ThalosBffPackageContract_WhenCreated_UsesBlueprintDescriptorContract()
{
diff --git a/tests/Thalos.Bff.Application.UnitTests/IssueTokenHandlerTests.cs b/tests/Thalos.Bff.Application.UnitTests/IssueTokenHandlerTests.cs
index c14f71f..bc82da3 100644
--- a/tests/Thalos.Bff.Application.UnitTests/IssueTokenHandlerTests.cs
+++ b/tests/Thalos.Bff.Application.UnitTests/IssueTokenHandlerTests.cs
@@ -1,5 +1,6 @@
using Thalos.Bff.Application.Adapters;
using Thalos.Bff.Application.Handlers;
+using Thalos.Bff.Application.Sessions;
using Thalos.Bff.Application.Security;
using Thalos.Bff.Contracts.Api;
using BuildingBlock.Identity.Contracts.Requests;
@@ -25,6 +26,17 @@ public class IssueTokenHandlerTests
private sealed class FakeThalosServiceClient : IThalosServiceClient
{
+ public Task StartSessionAsync(IssueIdentityTokenRequest request, string correlationId)
+ {
+ return Task.FromResult(new SessionTokensResult(
+ "token-xyz",
+ "refresh-xyz",
+ 1800,
+ request.SubjectId,
+ request.TenantId,
+ request.Provider));
+ }
+
public Task IssueTokenAsync(IssueIdentityTokenRequest request)
{
return Task.FromResult(new IssueIdentityTokenResponse("token-xyz", 1800));
@@ -39,6 +51,17 @@ public class IssueTokenHandlerTests
{
return Task.FromResult(new RefreshIdentitySessionResponse("token-refreshed", 1800));
}
+
+ public Task RefreshSessionTokensAsync(RefreshIdentitySessionRequest request)
+ {
+ return Task.FromResult(new SessionTokensResult(
+ "token-refreshed",
+ request.RefreshToken,
+ 1800,
+ "user-1",
+ "tenant-1",
+ request.Provider));
+ }
}
private sealed class FakeIdentityEdgeContractAdapter : IIdentityEdgeContractAdapter
diff --git a/tests/Thalos.Bff.Application.UnitTests/RefreshSessionHandlerTests.cs b/tests/Thalos.Bff.Application.UnitTests/RefreshSessionHandlerTests.cs
index b805764..cb11066 100644
--- a/tests/Thalos.Bff.Application.UnitTests/RefreshSessionHandlerTests.cs
+++ b/tests/Thalos.Bff.Application.UnitTests/RefreshSessionHandlerTests.cs
@@ -1,5 +1,6 @@
using Thalos.Bff.Application.Adapters;
using Thalos.Bff.Application.Handlers;
+using Thalos.Bff.Application.Sessions;
using Thalos.Bff.Contracts.Api;
using BuildingBlock.Identity.Contracts.Requests;
using BuildingBlock.Identity.Contracts.Responses;
@@ -21,6 +22,17 @@ public class RefreshSessionHandlerTests
private sealed class FakeThalosServiceClient : IThalosServiceClient
{
+ public Task StartSessionAsync(IssueIdentityTokenRequest request, string correlationId)
+ {
+ return Task.FromResult(new SessionTokensResult(
+ "token-xyz",
+ "refresh-xyz",
+ 1800,
+ request.SubjectId,
+ request.TenantId,
+ request.Provider));
+ }
+
public Task IssueTokenAsync(IssueIdentityTokenRequest request)
{
return Task.FromResult(new IssueIdentityTokenResponse("token-xyz", 1800));
@@ -35,6 +47,17 @@ public class RefreshSessionHandlerTests
{
return Task.FromResult(new RefreshIdentitySessionResponse("token-refreshed", 1800));
}
+
+ public Task RefreshSessionTokensAsync(RefreshIdentitySessionRequest request)
+ {
+ return Task.FromResult(new SessionTokensResult(
+ "token-refreshed",
+ request.RefreshToken,
+ 1800,
+ "user-1",
+ "tenant-1",
+ request.Provider));
+ }
}
private sealed class FakeIdentityEdgeContractAdapter : IIdentityEdgeContractAdapter