diff --git a/docs/identity/session-runtime-contract.md b/docs/identity/session-runtime-contract.md new file mode 100644 index 0000000..51b42a8 --- /dev/null +++ b/docs/identity/session-runtime-contract.md @@ -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) diff --git a/docs/identity/token-policy-and-use-cases.md b/docs/identity/token-policy-and-use-cases.md index 0469f0d..e3410b6 100644 --- a/docs/identity/token-policy-and-use-cases.md +++ b/docs/identity/token-policy-and-use-cases.md @@ -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`. diff --git a/src/Thalos.Service.Application/DependencyInjection/ThalosServiceRuntimeServiceCollectionExtensions.cs b/src/Thalos.Service.Application/DependencyInjection/ThalosServiceRuntimeServiceCollectionExtensions.cs index 27d9023..7b29c70 100644 --- a/src/Thalos.Service.Application/DependencyInjection/ThalosServiceRuntimeServiceCollectionExtensions.cs +++ b/src/Thalos.Service.Application/DependencyInjection/ThalosServiceRuntimeServiceCollectionExtensions.cs @@ -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(_ => + new ConfigurationBuilder().AddInMemoryCollection().Build()); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); return services; diff --git a/src/Thalos.Service.Application/Secrets/ConfigurationIdentitySecretMaterialProvider.cs b/src/Thalos.Service.Application/Secrets/ConfigurationIdentitySecretMaterialProvider.cs new file mode 100644 index 0000000..c745029 --- /dev/null +++ b/src/Thalos.Service.Application/Secrets/ConfigurationIdentitySecretMaterialProvider.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; + +namespace Thalos.Service.Application.Secrets; + +/// +/// Configuration-backed secret material provider. +/// +public sealed class ConfigurationIdentitySecretMaterialProvider(IConfiguration configuration) + : IIdentitySecretMaterialProvider +{ + private const string FallbackSecret = "thalos-dev-secret"; + + /// + 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; + } +} diff --git a/src/Thalos.Service.Application/Secrets/IIdentitySecretMaterialProvider.cs b/src/Thalos.Service.Application/Secrets/IIdentitySecretMaterialProvider.cs new file mode 100644 index 0000000..080cf29 --- /dev/null +++ b/src/Thalos.Service.Application/Secrets/IIdentitySecretMaterialProvider.cs @@ -0,0 +1,14 @@ +namespace Thalos.Service.Application.Secrets; + +/// +/// Provider-agnostic boundary for resolving identity secret material. +/// +public interface IIdentitySecretMaterialProvider +{ + /// + /// Resolves secret material for the requested secret key. + /// + /// Logical secret key. + /// Secret material value. + string GetSecret(string secretKey); +} diff --git a/src/Thalos.Service.Application/Sessions/HmacIdentitySessionTokenCodec.cs b/src/Thalos.Service.Application/Sessions/HmacIdentitySessionTokenCodec.cs new file mode 100644 index 0000000..98596c6 --- /dev/null +++ b/src/Thalos.Service.Application/Sessions/HmacIdentitySessionTokenCodec.cs @@ -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; + +/// +/// HMAC-based refresh token codec using provider-agnostic secret material. +/// +public sealed class HmacIdentitySessionTokenCodec( + IIdentitySecretMaterialProvider secretMaterialProvider) + : IIdentitySessionTokenCodec +{ + private const string SigningSecretKey = "SessionSigning"; + + /// + 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)}"; + } + + /// + 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(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); + } +} diff --git a/src/Thalos.Service.Application/Sessions/IIdentitySessionTokenCodec.cs b/src/Thalos.Service.Application/Sessions/IIdentitySessionTokenCodec.cs new file mode 100644 index 0000000..49af0d9 --- /dev/null +++ b/src/Thalos.Service.Application/Sessions/IIdentitySessionTokenCodec.cs @@ -0,0 +1,22 @@ +namespace Thalos.Service.Application.Sessions; + +/// +/// Encodes and decodes refresh token payloads. +/// +public interface IIdentitySessionTokenCodec +{ + /// + /// Encodes refresh token data into a transport-safe token string. + /// + /// Session descriptor payload. + /// Encoded refresh token. + string Encode(IdentitySessionDescriptor descriptor); + + /// + /// Attempts to decode refresh token payload. + /// + /// Encoded refresh token. + /// Decoded session descriptor when valid. + /// True when token is valid and not expired. + bool TryDecode(string token, out IdentitySessionDescriptor descriptor); +} diff --git a/src/Thalos.Service.Application/Sessions/IdentitySessionDescriptor.cs b/src/Thalos.Service.Application/Sessions/IdentitySessionDescriptor.cs new file mode 100644 index 0000000..18676bc --- /dev/null +++ b/src/Thalos.Service.Application/Sessions/IdentitySessionDescriptor.cs @@ -0,0 +1,12 @@ +using BuildingBlock.Identity.Contracts.Conventions; + +namespace Thalos.Service.Application.Sessions; + +/// +/// Internal session descriptor payload used for refresh token encoding/decoding. +/// +public sealed record IdentitySessionDescriptor( + string SubjectId, + string TenantId, + IdentityAuthProvider Provider, + DateTimeOffset ExpiresAtUtc); diff --git a/src/Thalos.Service.Application/Thalos.Service.Application.csproj b/src/Thalos.Service.Application/Thalos.Service.Application.csproj index 70086f7..ca4a7fa 100644 --- a/src/Thalos.Service.Application/Thalos.Service.Application.csproj +++ b/src/Thalos.Service.Application/Thalos.Service.Application.csproj @@ -6,8 +6,13 @@ + + + + + diff --git a/src/Thalos.Service.Application/UseCases/IRefreshIdentitySessionUseCase.cs b/src/Thalos.Service.Application/UseCases/IRefreshIdentitySessionUseCase.cs new file mode 100644 index 0000000..fb71983 --- /dev/null +++ b/src/Thalos.Service.Application/UseCases/IRefreshIdentitySessionUseCase.cs @@ -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; + +/// +/// Defines orchestration boundary for session refresh flows. +/// +public interface IRefreshIdentitySessionUseCase +{ + /// + /// Refreshes an existing identity session. + /// + /// Session refresh request contract. + /// Session refresh response contract. + Task HandleAsync(SessionRefreshRequest request); +} diff --git a/src/Thalos.Service.Application/UseCases/IStartIdentitySessionUseCase.cs b/src/Thalos.Service.Application/UseCases/IStartIdentitySessionUseCase.cs new file mode 100644 index 0000000..cc6f4f8 --- /dev/null +++ b/src/Thalos.Service.Application/UseCases/IStartIdentitySessionUseCase.cs @@ -0,0 +1,16 @@ +using Thalos.Service.Identity.Abstractions.Contracts; + +namespace Thalos.Service.Application.UseCases; + +/// +/// Defines orchestration boundary for session login/start flows. +/// +public interface IStartIdentitySessionUseCase +{ + /// + /// Starts a new identity session. + /// + /// Session start request contract. + /// Session start response contract. + Task HandleAsync(StartIdentitySessionRequest request); +} diff --git a/src/Thalos.Service.Application/UseCases/RefreshIdentitySessionUseCase.cs b/src/Thalos.Service.Application/UseCases/RefreshIdentitySessionUseCase.cs new file mode 100644 index 0000000..e39ed79 --- /dev/null +++ b/src/Thalos.Service.Application/UseCases/RefreshIdentitySessionUseCase.cs @@ -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; + +/// +/// Default orchestration implementation for session refresh. +/// +public sealed class RefreshIdentitySessionUseCase( + IIssueIdentityTokenUseCase issueIdentityTokenUseCase, + IIdentitySessionTokenCodec sessionTokenCodec) + : IRefreshIdentitySessionUseCase +{ + /// + public async Task 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); + } +} diff --git a/src/Thalos.Service.Application/UseCases/StartIdentitySessionUseCase.cs b/src/Thalos.Service.Application/UseCases/StartIdentitySessionUseCase.cs new file mode 100644 index 0000000..e0b41f9 --- /dev/null +++ b/src/Thalos.Service.Application/UseCases/StartIdentitySessionUseCase.cs @@ -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; + +/// +/// Default orchestration implementation for session login/start. +/// +public sealed class StartIdentitySessionUseCase( + IIssueIdentityTokenUseCase issueIdentityTokenUseCase, + IIdentitySessionTokenCodec sessionTokenCodec) + : IStartIdentitySessionUseCase +{ + /// + public async Task 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); + } +} diff --git a/src/Thalos.Service.Grpc/Protos/identity_runtime.proto b/src/Thalos.Service.Grpc/Protos/identity_runtime.proto index 02b620b..ca37a19 100644 --- a/src/Thalos.Service.Grpc/Protos/identity_runtime.proto +++ b/src/Thalos.Service.Grpc/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/src/Thalos.Service.Grpc/Services/IdentityRuntimeGrpcService.cs b/src/Thalos.Service.Grpc/Services/IdentityRuntimeGrpcService.cs index 43677db..5e1fd25 100644 --- a/src/Thalos.Service.Grpc/Services/IdentityRuntimeGrpcService.cs +++ b/src/Thalos.Service.Grpc/Services/IdentityRuntimeGrpcService.cs @@ -11,10 +11,70 @@ namespace Thalos.Service.Grpc.Services; /// Internal gRPC endpoint implementation for identity runtime operations. /// public sealed class IdentityRuntimeGrpcService( + IStartIdentitySessionUseCase startIdentitySessionUseCase, + IRefreshIdentitySessionUseCase refreshIdentitySessionUseCase, IIssueIdentityTokenUseCase issueIdentityTokenUseCase, IEvaluateIdentityPolicyUseCase evaluateIdentityPolicyUseCase, IIdentityPolicyGrpcContractAdapter grpcContractAdapter) : IdentityRuntime.IdentityRuntimeBase { + /// + /// Starts identity session through service use-case orchestration. + /// + /// gRPC session start request. + /// gRPC server call context. + /// gRPC session start response. + public override async Task 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() + }; + } + + /// + /// Refreshes identity session through service use-case orchestration. + /// + /// gRPC session refresh request. + /// gRPC server call context. + /// gRPC session refresh response. + public override async Task 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() + }; + } + /// /// Issues identity token through service use-case orchestration. /// diff --git a/src/Thalos.Service.Identity.Abstractions/Contracts/RefreshIdentitySessionRequest.cs b/src/Thalos.Service.Identity.Abstractions/Contracts/RefreshIdentitySessionRequest.cs new file mode 100644 index 0000000..9663078 --- /dev/null +++ b/src/Thalos.Service.Identity.Abstractions/Contracts/RefreshIdentitySessionRequest.cs @@ -0,0 +1,14 @@ +using BuildingBlock.Identity.Contracts.Conventions; + +namespace Thalos.Service.Identity.Abstractions.Contracts; + +/// +/// Transport-neutral request contract for session refresh. +/// +/// Refresh token value. +/// Correlation identifier. +/// Identity provider for the session. +public sealed record RefreshIdentitySessionRequest( + string RefreshToken, + string CorrelationId, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt); diff --git a/src/Thalos.Service.Identity.Abstractions/Contracts/RefreshIdentitySessionResponse.cs b/src/Thalos.Service.Identity.Abstractions/Contracts/RefreshIdentitySessionResponse.cs new file mode 100644 index 0000000..3cdd7ed --- /dev/null +++ b/src/Thalos.Service.Identity.Abstractions/Contracts/RefreshIdentitySessionResponse.cs @@ -0,0 +1,20 @@ +using BuildingBlock.Identity.Contracts.Conventions; + +namespace Thalos.Service.Identity.Abstractions.Contracts; + +/// +/// Transport-neutral response contract for session refresh. +/// +/// Refreshed access token value. +/// Refreshed refresh token value. +/// Access token expiration in seconds. +/// Identity subject identifier. +/// Tenant scope identifier. +/// Identity provider for the session. +public sealed record RefreshIdentitySessionResponse( + string AccessToken, + string RefreshToken, + int ExpiresInSeconds, + string SubjectId, + string TenantId, + IdentityAuthProvider Provider); diff --git a/src/Thalos.Service.Identity.Abstractions/Contracts/StartIdentitySessionRequest.cs b/src/Thalos.Service.Identity.Abstractions/Contracts/StartIdentitySessionRequest.cs new file mode 100644 index 0000000..9e9c37b --- /dev/null +++ b/src/Thalos.Service.Identity.Abstractions/Contracts/StartIdentitySessionRequest.cs @@ -0,0 +1,18 @@ +using BuildingBlock.Identity.Contracts.Conventions; + +namespace Thalos.Service.Identity.Abstractions.Contracts; + +/// +/// Transport-neutral request contract for session login/start. +/// +/// Identity subject identifier. +/// Tenant scope identifier. +/// Identity provider for the session. +/// External provider token when applicable. +/// Correlation identifier. +public sealed record StartIdentitySessionRequest( + string SubjectId, + string TenantId, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt, + string ExternalToken = "", + string CorrelationId = ""); diff --git a/src/Thalos.Service.Identity.Abstractions/Contracts/StartIdentitySessionResponse.cs b/src/Thalos.Service.Identity.Abstractions/Contracts/StartIdentitySessionResponse.cs new file mode 100644 index 0000000..6146977 --- /dev/null +++ b/src/Thalos.Service.Identity.Abstractions/Contracts/StartIdentitySessionResponse.cs @@ -0,0 +1,20 @@ +using BuildingBlock.Identity.Contracts.Conventions; + +namespace Thalos.Service.Identity.Abstractions.Contracts; + +/// +/// Transport-neutral response contract for session login/start. +/// +/// Issued access token value. +/// Issued refresh token value. +/// Access token expiration in seconds. +/// Identity subject identifier. +/// Tenant scope identifier. +/// Identity provider for the session. +public sealed record StartIdentitySessionResponse( + string AccessToken, + string RefreshToken, + int ExpiresInSeconds, + string SubjectId, + string TenantId, + IdentityAuthProvider Provider); diff --git a/src/Thalos.Service.Identity.Abstractions/Thalos.Service.Identity.Abstractions.csproj b/src/Thalos.Service.Identity.Abstractions/Thalos.Service.Identity.Abstractions.csproj index b1d5d12..2d76b4c 100644 --- a/src/Thalos.Service.Identity.Abstractions/Thalos.Service.Identity.Abstractions.csproj +++ b/src/Thalos.Service.Identity.Abstractions/Thalos.Service.Identity.Abstractions.csproj @@ -6,5 +6,6 @@ + diff --git a/tests/Thalos.Service.Application.UnitTests/HmacIdentitySessionTokenCodecTests.cs b/tests/Thalos.Service.Application.UnitTests/HmacIdentitySessionTokenCodecTests.cs new file mode 100644 index 0000000..1bd45f0 --- /dev/null +++ b/tests/Thalos.Service.Application.UnitTests/HmacIdentitySessionTokenCodecTests.cs @@ -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"; + } +} diff --git a/tests/Thalos.Service.Application.UnitTests/RefreshIdentitySessionUseCaseTests.cs b/tests/Thalos.Service.Application.UnitTests/RefreshIdentitySessionUseCaseTests.cs new file mode 100644 index 0000000..965d138 --- /dev/null +++ b/tests/Thalos.Service.Application.UnitTests/RefreshIdentitySessionUseCaseTests.cs @@ -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 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; + } + } +} diff --git a/tests/Thalos.Service.Application.UnitTests/RuntimeWiringTests.cs b/tests/Thalos.Service.Application.UnitTests/RuntimeWiringTests.cs index 60c2aa3..ae2a364 100644 --- a/tests/Thalos.Service.Application.UnitTests/RuntimeWiringTests.cs +++ b/tests/Thalos.Service.Application.UnitTests/RuntimeWiringTests.cs @@ -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(); + var startSessionUseCase = provider.GetRequiredService(); + var refreshSessionUseCase = provider.GetRequiredService(); var evaluatePolicyUseCase = provider.GetRequiredService(); - 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(); 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(); 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); diff --git a/tests/Thalos.Service.Application.UnitTests/StartIdentitySessionUseCaseTests.cs b/tests/Thalos.Service.Application.UnitTests/StartIdentitySessionUseCaseTests.cs new file mode 100644 index 0000000..f815c49 --- /dev/null +++ b/tests/Thalos.Service.Application.UnitTests/StartIdentitySessionUseCaseTests.cs @@ -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 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; + } + } +}