feat(thalos-service): add canonical session flows

Why: provide service-side canonical login/refresh orchestration for session-based web auth.

What: add session contracts, refresh token codec with provider-agnostic secret boundary, grpc session methods, DI wiring, tests, and docs.

Rule: preserve thalos identity ownership and keep transport adapters at service edge.
This commit is contained in:
José René White Enciso 2026-03-08 14:48:35 -06:00
parent fedd26bce6
commit 96c53d9dab
24 changed files with 743 additions and 5 deletions

View File

@ -0,0 +1,31 @@
# Session Runtime Contract
## Canonical Internal gRPC Operations
`IdentityRuntime` now exposes the canonical session operations consumed by `thalos-bff`:
- `StartIdentitySession`
- `RefreshIdentitySession`
- `IssueIdentityToken` (compatibility)
- `EvaluateIdentityPolicy` (policy guardrail)
## Session Flow
1. BFF calls `StartIdentitySession` with subject/tenant/provider/external token.
2. Service issues access token through existing token orchestration.
3. Service generates refresh token through provider-agnostic session token codec.
4. BFF calls `RefreshIdentitySession` with refresh token.
5. Service validates refresh token signature/expiry and reissues session tokens.
## Provider-Agnostic Secret Boundary
Session refresh token signing is bound to `IIdentitySecretMaterialProvider`.
- Contract is provider-neutral.
- Runtime binding is configuration-based by default.
- Vault/cloud/env adapters can be swapped at DI boundaries without changing use-case code.
## Configuration Keys
- `ThalosIdentity:Secrets:SessionSigning`
- `ThalosIdentity:Secrets:Default` (fallback)

View File

@ -18,3 +18,10 @@
- Token issuance and policy evaluation are orchestrated in service use cases.
- Data retrieval and persistence details remain in thalos-dal and identity adapters.
- Protocol adaptation remains outside use-case logic.
## Session Extension
- `IStartIdentitySessionUseCase`: orchestrates canonical session login/start behavior.
- `IRefreshIdentitySessionUseCase`: orchestrates canonical session refresh behavior.
- Refresh token security is implemented via provider-agnostic `IIdentitySecretMaterialProvider`.
- Runtime gRPC session contract details are documented in `docs/identity/session-runtime-contract.md`.

View File

@ -1,10 +1,13 @@
using Core.Blueprint.Common.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Thalos.Domain.Decisions;
using Thalos.DAL.DependencyInjection;
using Thalos.Service.Application.Adapters;
using Thalos.Service.Application.Ports;
using Thalos.Service.Application.Secrets;
using Thalos.Service.Application.Sessions;
using Thalos.Service.Application.UseCases;
namespace Thalos.Service.Application.DependencyInjection;
@ -23,15 +26,21 @@ public static class ThalosServiceRuntimeServiceCollectionExtensions
{
services.AddBlueprintRuntimeCore();
services.AddThalosDalRuntime();
services.TryAddSingleton<IConfiguration>(_ =>
new ConfigurationBuilder().AddInMemoryCollection().Build());
services.TryAddSingleton<IIdentityPolicyDecisionService, IdentityPolicyDecisionService>();
services.TryAddSingleton<IIdentityTokenDecisionService, IdentityTokenDecisionService>();
services.TryAddSingleton<IIdentityPolicyGrpcContractAdapter, IdentityPolicyGrpcContractAdapter>();
services.TryAddSingleton<IIdentitySecretMaterialProvider, ConfigurationIdentitySecretMaterialProvider>();
services.TryAddSingleton<IIdentitySessionTokenCodec, HmacIdentitySessionTokenCodec>();
services.TryAddSingleton<IIdentityTokenReadPort, IdentityTokenReadPortDalAdapter>();
services.TryAddSingleton<IIdentityPolicyContextReadPort, IdentityPolicyContextReadPortDalAdapter>();
services.TryAddSingleton<IIssueIdentityTokenUseCase, IssueIdentityTokenUseCase>();
services.TryAddSingleton<IStartIdentitySessionUseCase, StartIdentitySessionUseCase>();
services.TryAddSingleton<IRefreshIdentitySessionUseCase, RefreshIdentitySessionUseCase>();
services.TryAddSingleton<IEvaluateIdentityPolicyUseCase, EvaluateIdentityPolicyUseCase>();
return services;

View File

@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
namespace Thalos.Service.Application.Secrets;
/// <summary>
/// Configuration-backed secret material provider.
/// </summary>
public sealed class ConfigurationIdentitySecretMaterialProvider(IConfiguration configuration)
: IIdentitySecretMaterialProvider
{
private const string FallbackSecret = "thalos-dev-secret";
/// <inheritdoc />
public string GetSecret(string secretKey)
{
var scopedKey = $"ThalosIdentity:Secrets:{secretKey}";
var scopedSecret = configuration[scopedKey];
if (!string.IsNullOrWhiteSpace(scopedSecret))
{
return scopedSecret;
}
var defaultSecret = configuration["ThalosIdentity:Secrets:Default"];
return string.IsNullOrWhiteSpace(defaultSecret) ? FallbackSecret : defaultSecret;
}
}

View File

@ -0,0 +1,14 @@
namespace Thalos.Service.Application.Secrets;
/// <summary>
/// Provider-agnostic boundary for resolving identity secret material.
/// </summary>
public interface IIdentitySecretMaterialProvider
{
/// <summary>
/// Resolves secret material for the requested secret key.
/// </summary>
/// <param name="secretKey">Logical secret key.</param>
/// <returns>Secret material value.</returns>
string GetSecret(string secretKey);
}

View File

@ -0,0 +1,123 @@
using System.Security.Cryptography;
using System.Text;
using BuildingBlock.Identity.Contracts.Conventions;
using Thalos.Service.Application.Secrets;
namespace Thalos.Service.Application.Sessions;
/// <summary>
/// HMAC-based refresh token codec using provider-agnostic secret material.
/// </summary>
public sealed class HmacIdentitySessionTokenCodec(
IIdentitySecretMaterialProvider secretMaterialProvider)
: IIdentitySessionTokenCodec
{
private const string SigningSecretKey = "SessionSigning";
/// <inheritdoc />
public string Encode(IdentitySessionDescriptor descriptor)
{
var payload = string.Join('|',
descriptor.SubjectId,
descriptor.TenantId,
descriptor.Provider,
descriptor.ExpiresAtUtc.ToUnixTimeSeconds().ToString());
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var signatureBytes = Sign(payloadBytes);
return $"{Base64UrlEncode(payloadBytes)}.{Base64UrlEncode(signatureBytes)}";
}
/// <inheritdoc />
public bool TryDecode(string token, out IdentitySessionDescriptor descriptor)
{
descriptor = new IdentitySessionDescriptor(string.Empty, string.Empty, IdentityAuthProvider.InternalJwt, DateTimeOffset.MinValue);
if (string.IsNullOrWhiteSpace(token))
{
return false;
}
var parts = token.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
return false;
}
byte[] payloadBytes;
byte[] signatureBytes;
try
{
payloadBytes = Base64UrlDecode(parts[0]);
signatureBytes = Base64UrlDecode(parts[1]);
}
catch (FormatException)
{
return false;
}
var expectedSignature = Sign(payloadBytes);
if (!CryptographicOperations.FixedTimeEquals(signatureBytes, expectedSignature))
{
return false;
}
var payload = Encoding.UTF8.GetString(payloadBytes);
var payloadParts = payload.Split('|', StringSplitOptions.None);
if (payloadParts.Length != 4)
{
return false;
}
if (!Enum.TryParse<IdentityAuthProvider>(payloadParts[2], true, out var provider))
{
provider = IdentityAuthProvider.InternalJwt;
}
if (!long.TryParse(payloadParts[3], out var expiresUnix))
{
return false;
}
var expiresAt = DateTimeOffset.FromUnixTimeSeconds(expiresUnix);
if (expiresAt <= DateTimeOffset.UtcNow)
{
return false;
}
descriptor = new IdentitySessionDescriptor(payloadParts[0], payloadParts[1], provider, expiresAt);
return true;
}
private byte[] Sign(byte[] payloadBytes)
{
var secret = secretMaterialProvider.GetSecret(SigningSecretKey);
var keyBytes = Encoding.UTF8.GetBytes(secret);
using var hmac = new HMACSHA256(keyBytes);
return hmac.ComputeHash(payloadBytes);
}
private static string Base64UrlEncode(byte[] bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string text)
{
var normalized = text
.Replace('-', '+')
.Replace('_', '/');
var padding = normalized.Length % 4;
if (padding > 0)
{
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
}
return Convert.FromBase64String(normalized);
}
}

View File

@ -0,0 +1,22 @@
namespace Thalos.Service.Application.Sessions;
/// <summary>
/// Encodes and decodes refresh token payloads.
/// </summary>
public interface IIdentitySessionTokenCodec
{
/// <summary>
/// Encodes refresh token data into a transport-safe token string.
/// </summary>
/// <param name="descriptor">Session descriptor payload.</param>
/// <returns>Encoded refresh token.</returns>
string Encode(IdentitySessionDescriptor descriptor);
/// <summary>
/// Attempts to decode refresh token payload.
/// </summary>
/// <param name="token">Encoded refresh token.</param>
/// <param name="descriptor">Decoded session descriptor when valid.</param>
/// <returns>True when token is valid and not expired.</returns>
bool TryDecode(string token, out IdentitySessionDescriptor descriptor);
}

View File

@ -0,0 +1,12 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.Service.Application.Sessions;
/// <summary>
/// Internal session descriptor payload used for refresh token encoding/decoding.
/// </summary>
public sealed record IdentitySessionDescriptor(
string SubjectId,
string TenantId,
IdentityAuthProvider Provider,
DateTimeOffset ExpiresAtUtc);

View File

@ -6,8 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="BuildingBlock.Identity.Contracts" Version="0.2.0" />
<PackageReference Include="Thalos.Domain" Version="0.2.0" />
<PackageReference Include="Thalos.DAL" Version="0.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\Thalos.Service.Identity.Abstractions\\Thalos.Service.Identity.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,18 @@
using Thalos.Service.Identity.Abstractions.Contracts;
using SessionRefreshRequest = Thalos.Service.Identity.Abstractions.Contracts.RefreshIdentitySessionRequest;
using SessionRefreshResponse = Thalos.Service.Identity.Abstractions.Contracts.RefreshIdentitySessionResponse;
namespace Thalos.Service.Application.UseCases;
/// <summary>
/// Defines orchestration boundary for session refresh flows.
/// </summary>
public interface IRefreshIdentitySessionUseCase
{
/// <summary>
/// Refreshes an existing identity session.
/// </summary>
/// <param name="request">Session refresh request contract.</param>
/// <returns>Session refresh response contract.</returns>
Task<SessionRefreshResponse> HandleAsync(SessionRefreshRequest request);
}

View File

@ -0,0 +1,16 @@
using Thalos.Service.Identity.Abstractions.Contracts;
namespace Thalos.Service.Application.UseCases;
/// <summary>
/// Defines orchestration boundary for session login/start flows.
/// </summary>
public interface IStartIdentitySessionUseCase
{
/// <summary>
/// Starts a new identity session.
/// </summary>
/// <param name="request">Session start request contract.</param>
/// <returns>Session start response contract.</returns>
Task<StartIdentitySessionResponse> HandleAsync(StartIdentitySessionRequest request);
}

View File

@ -0,0 +1,52 @@
using BuildingBlock.Identity.Contracts.Requests;
using Thalos.Service.Application.Sessions;
using Thalos.Service.Identity.Abstractions.Contracts;
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
using SessionRefreshRequest = Thalos.Service.Identity.Abstractions.Contracts.RefreshIdentitySessionRequest;
using SessionRefreshResponse = Thalos.Service.Identity.Abstractions.Contracts.RefreshIdentitySessionResponse;
namespace Thalos.Service.Application.UseCases;
/// <summary>
/// Default orchestration implementation for session refresh.
/// </summary>
public sealed class RefreshIdentitySessionUseCase(
IIssueIdentityTokenUseCase issueIdentityTokenUseCase,
IIdentitySessionTokenCodec sessionTokenCodec)
: IRefreshIdentitySessionUseCase
{
/// <inheritdoc />
public async Task<SessionRefreshResponse> HandleAsync(SessionRefreshRequest request)
{
if (!sessionTokenCodec.TryDecode(request.RefreshToken, out var descriptor))
{
return new SessionRefreshResponse(
string.Empty,
string.Empty,
0,
string.Empty,
string.Empty,
request.Provider);
}
var issueRequest = new IdentityIssueRequest(
descriptor.SubjectId,
descriptor.TenantId,
descriptor.Provider);
var issueResponse = await issueIdentityTokenUseCase.HandleAsync(issueRequest);
var refreshToken = sessionTokenCodec.Encode(new IdentitySessionDescriptor(
descriptor.SubjectId,
descriptor.TenantId,
descriptor.Provider,
DateTimeOffset.UtcNow.AddHours(8)));
return new SessionRefreshResponse(
issueResponse.Token,
refreshToken,
Math.Max(0, issueResponse.ExpiresInSeconds),
descriptor.SubjectId,
descriptor.TenantId,
descriptor.Provider);
}
}

View File

@ -0,0 +1,44 @@
using BuildingBlock.Identity.Contracts.Requests;
using Thalos.Service.Application.Sessions;
using Thalos.Service.Identity.Abstractions.Contracts;
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
namespace Thalos.Service.Application.UseCases;
/// <summary>
/// Default orchestration implementation for session login/start.
/// </summary>
public sealed class StartIdentitySessionUseCase(
IIssueIdentityTokenUseCase issueIdentityTokenUseCase,
IIdentitySessionTokenCodec sessionTokenCodec)
: IStartIdentitySessionUseCase
{
/// <inheritdoc />
public async Task<StartIdentitySessionResponse> HandleAsync(StartIdentitySessionRequest request)
{
var issueRequest = new IdentityIssueRequest(
request.SubjectId,
request.TenantId,
request.Provider,
request.ExternalToken);
var issueResponse = await issueIdentityTokenUseCase.HandleAsync(issueRequest);
var expiresInSeconds = Math.Max(0, issueResponse.ExpiresInSeconds);
var refreshDescriptor = new IdentitySessionDescriptor(
request.SubjectId,
request.TenantId,
request.Provider,
DateTimeOffset.UtcNow.AddHours(8));
var refreshToken = sessionTokenCodec.Encode(refreshDescriptor);
return new StartIdentitySessionResponse(
issueResponse.Token,
refreshToken,
expiresInSeconds,
request.SubjectId,
request.TenantId,
request.Provider);
}
}

View File

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

View File

@ -11,10 +11,70 @@ namespace Thalos.Service.Grpc.Services;
/// Internal gRPC endpoint implementation for identity runtime operations.
/// </summary>
public sealed class IdentityRuntimeGrpcService(
IStartIdentitySessionUseCase startIdentitySessionUseCase,
IRefreshIdentitySessionUseCase refreshIdentitySessionUseCase,
IIssueIdentityTokenUseCase issueIdentityTokenUseCase,
IEvaluateIdentityPolicyUseCase evaluateIdentityPolicyUseCase,
IIdentityPolicyGrpcContractAdapter grpcContractAdapter) : IdentityRuntime.IdentityRuntimeBase
{
/// <summary>
/// Starts identity session through service use-case orchestration.
/// </summary>
/// <param name="request">gRPC session start request.</param>
/// <param name="context">gRPC server call context.</param>
/// <returns>gRPC session start response.</returns>
public override async Task<StartIdentitySessionGrpcResponse> StartIdentitySession(
StartIdentitySessionGrpcRequest request,
ServerCallContext context)
{
var useCaseRequest = new Thalos.Service.Identity.Abstractions.Contracts.StartIdentitySessionRequest(
request.SubjectId,
request.TenantId,
ParseProvider(request.Provider),
request.ExternalToken,
request.CorrelationId);
var useCaseResponse = await startIdentitySessionUseCase.HandleAsync(useCaseRequest);
return new StartIdentitySessionGrpcResponse
{
AccessToken = useCaseResponse.AccessToken,
RefreshToken = useCaseResponse.RefreshToken,
ExpiresInSeconds = useCaseResponse.ExpiresInSeconds,
SubjectId = useCaseResponse.SubjectId,
TenantId = useCaseResponse.TenantId,
Provider = useCaseResponse.Provider.ToString()
};
}
/// <summary>
/// Refreshes identity session through service use-case orchestration.
/// </summary>
/// <param name="request">gRPC session refresh request.</param>
/// <param name="context">gRPC server call context.</param>
/// <returns>gRPC session refresh response.</returns>
public override async Task<RefreshIdentitySessionGrpcResponse> RefreshIdentitySession(
RefreshIdentitySessionGrpcRequest request,
ServerCallContext context)
{
var useCaseRequest = new Thalos.Service.Identity.Abstractions.Contracts.RefreshIdentitySessionRequest(
request.RefreshToken,
request.CorrelationId,
ParseProvider(request.Provider));
var useCaseResponse = await refreshIdentitySessionUseCase.HandleAsync(useCaseRequest);
return new RefreshIdentitySessionGrpcResponse
{
AccessToken = useCaseResponse.AccessToken,
RefreshToken = useCaseResponse.RefreshToken,
ExpiresInSeconds = useCaseResponse.ExpiresInSeconds,
SubjectId = useCaseResponse.SubjectId,
TenantId = useCaseResponse.TenantId,
Provider = useCaseResponse.Provider.ToString()
};
}
/// <summary>
/// Issues identity token through service use-case orchestration.
/// </summary>

View File

@ -0,0 +1,14 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.Service.Identity.Abstractions.Contracts;
/// <summary>
/// Transport-neutral request contract for session refresh.
/// </summary>
/// <param name="RefreshToken">Refresh token value.</param>
/// <param name="CorrelationId">Correlation identifier.</param>
/// <param name="Provider">Identity provider for the session.</param>
public sealed record RefreshIdentitySessionRequest(
string RefreshToken,
string CorrelationId,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt);

View File

@ -0,0 +1,20 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.Service.Identity.Abstractions.Contracts;
/// <summary>
/// Transport-neutral response contract for session refresh.
/// </summary>
/// <param name="AccessToken">Refreshed access token value.</param>
/// <param name="RefreshToken">Refreshed refresh token value.</param>
/// <param name="ExpiresInSeconds">Access token expiration in seconds.</param>
/// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="TenantId">Tenant scope identifier.</param>
/// <param name="Provider">Identity provider for the session.</param>
public sealed record RefreshIdentitySessionResponse(
string AccessToken,
string RefreshToken,
int ExpiresInSeconds,
string SubjectId,
string TenantId,
IdentityAuthProvider Provider);

View File

@ -0,0 +1,18 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.Service.Identity.Abstractions.Contracts;
/// <summary>
/// Transport-neutral request contract for session login/start.
/// </summary>
/// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="TenantId">Tenant scope identifier.</param>
/// <param name="Provider">Identity provider for the session.</param>
/// <param name="ExternalToken">External provider token when applicable.</param>
/// <param name="CorrelationId">Correlation identifier.</param>
public sealed record StartIdentitySessionRequest(
string SubjectId,
string TenantId,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt,
string ExternalToken = "",
string CorrelationId = "");

View File

@ -0,0 +1,20 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.Service.Identity.Abstractions.Contracts;
/// <summary>
/// Transport-neutral response contract for session login/start.
/// </summary>
/// <param name="AccessToken">Issued access token value.</param>
/// <param name="RefreshToken">Issued refresh token value.</param>
/// <param name="ExpiresInSeconds">Access token expiration in seconds.</param>
/// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="TenantId">Tenant scope identifier.</param>
/// <param name="Provider">Identity provider for the session.</param>
public sealed record StartIdentitySessionResponse(
string AccessToken,
string RefreshToken,
int ExpiresInSeconds,
string SubjectId,
string TenantId,
IdentityAuthProvider Provider);

View File

@ -6,5 +6,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Core.Blueprint.Common" Version="0.2.0" />
<PackageReference Include="BuildingBlock.Identity.Contracts" Version="0.2.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,49 @@
using BuildingBlock.Identity.Contracts.Conventions;
using Thalos.Service.Application.Secrets;
using Thalos.Service.Application.Sessions;
namespace Thalos.Service.Application.UnitTests;
public class HmacIdentitySessionTokenCodecTests
{
[Fact]
public void EncodeAndTryDecode_WhenTokenValid_RoundTripsDescriptor()
{
var codec = new HmacIdentitySessionTokenCodec(new FakeSecretMaterialProvider());
var descriptor = new IdentitySessionDescriptor(
"user-9",
"tenant-9",
IdentityAuthProvider.AzureAd,
DateTimeOffset.UtcNow.AddMinutes(5));
var token = codec.Encode(descriptor);
var ok = codec.TryDecode(token, out var decoded);
Assert.True(ok);
Assert.Equal("user-9", decoded.SubjectId);
Assert.Equal("tenant-9", decoded.TenantId);
Assert.Equal(IdentityAuthProvider.AzureAd, decoded.Provider);
}
[Fact]
public void TryDecode_WhenTokenTampered_ReturnsFalse()
{
var codec = new HmacIdentitySessionTokenCodec(new FakeSecretMaterialProvider());
var descriptor = new IdentitySessionDescriptor(
"user-9",
"tenant-9",
IdentityAuthProvider.InternalJwt,
DateTimeOffset.UtcNow.AddMinutes(5));
var token = codec.Encode(descriptor) + "tamper";
var ok = codec.TryDecode(token, out _);
Assert.False(ok);
}
private sealed class FakeSecretMaterialProvider : IIdentitySecretMaterialProvider
{
public string GetSecret(string secretKey) => "unit-test-secret";
}
}

View File

@ -0,0 +1,78 @@
using BuildingBlock.Identity.Contracts.Conventions;
using BuildingBlock.Identity.Contracts.Requests;
using BuildingBlock.Identity.Contracts.Responses;
using Thalos.Service.Application.Sessions;
using Thalos.Service.Application.UseCases;
using Thalos.Service.Identity.Abstractions.Contracts;
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
using IdentityIssueResponse = BuildingBlock.Identity.Contracts.Responses.IssueIdentityTokenResponse;
using SessionRefreshRequest = Thalos.Service.Identity.Abstractions.Contracts.RefreshIdentitySessionRequest;
namespace Thalos.Service.Application.UnitTests;
public class RefreshIdentitySessionUseCaseTests
{
[Fact]
public async Task HandleAsync_WhenRefreshTokenValid_ReissuesSessionTokens()
{
var useCase = new RefreshIdentitySessionUseCase(new FakeIssueUseCase(), new FakeSessionTokenCodec());
var response = await useCase.HandleAsync(new SessionRefreshRequest("refresh-token", "corr-1", IdentityAuthProvider.Google));
Assert.Equal("token-new", response.AccessToken);
Assert.Equal(3000, response.ExpiresInSeconds);
Assert.Equal("google-sub-1", response.SubjectId);
Assert.Equal("tenant-2", response.TenantId);
Assert.Equal("refresh-google-sub-1-tenant-2", response.RefreshToken);
}
[Fact]
public async Task HandleAsync_WhenRefreshTokenInvalid_ReturnsEmptyPayload()
{
var useCase = new RefreshIdentitySessionUseCase(new FakeIssueUseCase(), new InvalidSessionTokenCodec());
var response = await useCase.HandleAsync(new SessionRefreshRequest("bad-token", "corr-2"));
Assert.Equal(string.Empty, response.AccessToken);
Assert.Equal(0, response.ExpiresInSeconds);
Assert.Equal(string.Empty, response.RefreshToken);
}
private sealed class FakeIssueUseCase : IIssueIdentityTokenUseCase
{
public Task<IdentityIssueResponse> HandleAsync(IdentityIssueRequest request)
{
return Task.FromResult(new IdentityIssueResponse("token-new", 3000));
}
}
private sealed class FakeSessionTokenCodec : IIdentitySessionTokenCodec
{
public string Encode(IdentitySessionDescriptor descriptor)
{
return $"refresh-{descriptor.SubjectId}-{descriptor.TenantId}";
}
public bool TryDecode(string token, out IdentitySessionDescriptor descriptor)
{
descriptor = new IdentitySessionDescriptor(
"google-sub-1",
"tenant-2",
IdentityAuthProvider.Google,
DateTimeOffset.UtcNow.AddHours(1));
return true;
}
}
private sealed class InvalidSessionTokenCodec : IIdentitySessionTokenCodec
{
public string Encode(IdentitySessionDescriptor descriptor) => string.Empty;
public bool TryDecode(string token, out IdentitySessionDescriptor descriptor)
{
descriptor = new IdentitySessionDescriptor(string.Empty, string.Empty, IdentityAuthProvider.InternalJwt, DateTimeOffset.MinValue);
return false;
}
}
}

View File

@ -5,6 +5,10 @@ using Thalos.Service.Application.Adapters;
using Thalos.Service.Application.DependencyInjection;
using Thalos.Service.Application.Grpc;
using Thalos.Service.Application.UseCases;
using BuildingIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
using BuildingPolicyRequest = BuildingBlock.Identity.Contracts.Requests.EvaluateIdentityPolicyRequest;
using StartSessionRequest = Thalos.Service.Identity.Abstractions.Contracts.StartIdentitySessionRequest;
using SessionRefreshRequest = Thalos.Service.Identity.Abstractions.Contracts.RefreshIdentitySessionRequest;
namespace Thalos.Service.Application.UnitTests;
@ -18,15 +22,27 @@ public class RuntimeWiringTests
using var provider = services.BuildServiceProvider();
var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>();
var startSessionUseCase = provider.GetRequiredService<IStartIdentitySessionUseCase>();
var refreshSessionUseCase = provider.GetRequiredService<IRefreshIdentitySessionUseCase>();
var evaluatePolicyUseCase = provider.GetRequiredService<IEvaluateIdentityPolicyUseCase>();
var tokenResponse = await issueTokenUseCase.HandleAsync(new IssueIdentityTokenRequest("user-1", "tenant-1"));
var tokenResponse = await issueTokenUseCase.HandleAsync(new BuildingIssueRequest("user-1", "tenant-1"));
var policyResponse = await evaluatePolicyUseCase.HandleAsync(
new EvaluateIdentityPolicyRequest("user-1", "tenant-1", "identity.token.issue"));
new BuildingPolicyRequest("user-1", "tenant-1", "identity.token.issue"));
var startSessionResponse = await startSessionUseCase.HandleAsync(
new StartSessionRequest("user-1", "tenant-1"));
var refreshSessionResponse = await refreshSessionUseCase.HandleAsync(
new SessionRefreshRequest(startSessionResponse.RefreshToken, "corr-rt-1"));
Assert.Equal("user-1:tenant-1:token", tokenResponse.Token);
Assert.Equal(1800, tokenResponse.ExpiresInSeconds);
Assert.True(policyResponse.IsAllowed);
Assert.Equal("user-1", startSessionResponse.SubjectId);
Assert.Equal("tenant-1", startSessionResponse.TenantId);
Assert.NotEmpty(startSessionResponse.RefreshToken);
Assert.Equal("user-1", refreshSessionResponse.SubjectId);
Assert.Equal("tenant-1", refreshSessionResponse.TenantId);
Assert.NotEmpty(refreshSessionResponse.RefreshToken);
}
[Fact]
@ -39,7 +55,7 @@ public class RuntimeWiringTests
var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>();
var tokenResponse = await issueTokenUseCase.HandleAsync(
new IssueIdentityTokenRequest("missing-user", "tenant-1"));
new BuildingIssueRequest("missing-user", "tenant-1"));
Assert.Equal(string.Empty, tokenResponse.Token);
Assert.Equal(0, tokenResponse.ExpiresInSeconds);
@ -55,7 +71,7 @@ public class RuntimeWiringTests
var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>();
var tokenResponse = await issueTokenUseCase.HandleAsync(
new IssueIdentityTokenRequest(
new BuildingIssueRequest(
string.Empty,
"tenant-2",
IdentityAuthProvider.AzureAd,
@ -69,7 +85,7 @@ public class RuntimeWiringTests
public void IdentityPolicyGrpcContractAdapter_WhenMapped_PreservesValues()
{
var adapter = new IdentityPolicyGrpcContractAdapter();
var useCaseRequest = new EvaluateIdentityPolicyRequest("user-2", "tenant-2", "identity.policy.evaluate");
var useCaseRequest = new BuildingPolicyRequest("user-2", "tenant-2", "identity.policy.evaluate");
var grpcContract = adapter.ToGrpc(useCaseRequest);
var roundtrip = adapter.FromGrpc(grpcContract);

View File

@ -0,0 +1,49 @@
using BuildingBlock.Identity.Contracts.Conventions;
using BuildingBlock.Identity.Contracts.Requests;
using BuildingBlock.Identity.Contracts.Responses;
using Thalos.Service.Application.Sessions;
using Thalos.Service.Application.UseCases;
using Thalos.Service.Identity.Abstractions.Contracts;
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
using IdentityIssueResponse = BuildingBlock.Identity.Contracts.Responses.IssueIdentityTokenResponse;
namespace Thalos.Service.Application.UnitTests;
public class StartIdentitySessionUseCaseTests
{
[Fact]
public async Task HandleAsync_WhenCalled_IssuesTokenAndRefreshToken()
{
var useCase = new StartIdentitySessionUseCase(new FakeIssueUseCase(), new FakeSessionTokenCodec());
var response = await useCase.HandleAsync(new StartIdentitySessionRequest("user-1", "tenant-1", IdentityAuthProvider.InternalJwt));
Assert.Equal("token-abc", response.AccessToken);
Assert.Equal(1800, response.ExpiresInSeconds);
Assert.Equal("user-1", response.SubjectId);
Assert.Equal("tenant-1", response.TenantId);
Assert.Equal("refresh-user-1-tenant-1", response.RefreshToken);
}
private sealed class FakeIssueUseCase : IIssueIdentityTokenUseCase
{
public Task<IdentityIssueResponse> HandleAsync(IdentityIssueRequest request)
{
return Task.FromResult(new IdentityIssueResponse("token-abc", 1800));
}
}
private sealed class FakeSessionTokenCodec : IIdentitySessionTokenCodec
{
public string Encode(IdentitySessionDescriptor descriptor)
{
return $"refresh-{descriptor.SubjectId}-{descriptor.TenantId}";
}
public bool TryDecode(string token, out IdentitySessionDescriptor descriptor)
{
descriptor = new IdentitySessionDescriptor(string.Empty, string.Empty, IdentityAuthProvider.InternalJwt, DateTimeOffset.UtcNow);
return false;
}
}
}