Compare commits

...

10 Commits

Author SHA1 Message Date
José René White Enciso
7cec61b959 chore(thalos-service): checkpoint pending development updates 2026-03-09 11:57:46 -06:00
José René White Enciso
cbf38ac9f8 merge(development): integrate session flows 2026-03-08 14:54:59 -06:00
José René White Enciso
96c53d9dab 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.
2026-03-08 14:48:35 -06:00
José René White Enciso
fedd26bce6 chore(thalos-service): add container run assets
Why: align service runtime packaging and health endpoints for container execution.

What: add Docker build assets, container runbook, and dual health mappings with explicit http/grpc ports.

Rule: keep technical intent only and avoid orchestration references.
2026-03-08 14:34:12 -06:00
José René White Enciso
201ef3e599 chore(repo): normalize ignore policy and repository metadata paths 2026-03-06 08:17:47 -06:00
José René White Enciso
2584328f23 Merge branch 'feature/thalos-service-boundary-decoupling' into development 2026-02-25 16:50:54 -06:00
José René White Enciso
56536ae509 Merge branch 'feature/thalos-service-contracts-stable-baseline' into development 2026-02-25 16:50:54 -06:00
José René White Enciso
1fcb52bbf1 refactor(thalos-service): consume blueprint common via package in service contracts 2026-02-25 16:47:38 -06:00
José René White Enciso
9f2dea4df5 refactor(thalos-service): replace cross-repo references with stable packages 2026-02-25 16:42:23 -06:00
José René White Enciso
a336f88f0f chore(thalos-service): stabilize service contract package baseline 2026-02-25 16:07:52 -06:00
31 changed files with 868 additions and 55 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
**/bin/
**/obj/
.vs/
TestResults/
.git/
.repo-tasks/
.repo-context/
.tasks/
.agile/

63
.gitignore vendored
View File

@ -1,53 +1,24 @@
# AgileWebs local orchestration # Repository orchestration folders (local only)
.repo-tasks/
.repo-context/
.tasks/ .tasks/
.agile/ .agile/
# Build artifacts # .NET build outputs
**/[Bb]in/ **/bin/
**/[Oo]bj/ **/obj/
/**/out/
/**/artifacts/
# IDE and editor files
.vs/ .vs/
.idea/ TestResults/
.vscode/
*.suo
*.user
*.userosscache
*.sln.docstates
*.rsuser
*.swp
*.swo
# NuGet
*.nupkg
*.snupkg
**/packages/*
!**/packages/build/
# Test output
**/TestResults/ **/TestResults/
*.trx *.user
*.coverage *.suo
*.coveragexml *.rsuser
# Logs # IDE
*.log .idea/
# Runtime-local artifacts
logs/ logs/
*.log
# Local environment files .env.local
.env .env.*.local
.env.*
!.env.example
# Docker
.docker/
**/.docker/
*.pid
docker-compose.override.yml
docker-compose.*.override.yml
# OS files
.DS_Store
Thumbs.db

10
Directory.Build.props Normal file
View File

@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<Authors>AgileWebs</Authors>
<Company>AgileWebs</Company>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://gitea.dream-views.com/AgileWebs/thalos-service</RepositoryUrl>
<PackageProjectUrl>https://gitea.dream-views.com/AgileWebs/thalos-service</PackageProjectUrl>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
</PropertyGroup>
</Project>

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1.7
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0
FROM ${SDK_IMAGE} AS build
ARG NUGET_FEED_URL=https://gitea.dream-views.com/api/packages/AgileWebs/nuget/index.json
ARG NUGET_FEED_USERNAME=
ARG NUGET_FEED_TOKEN=
WORKDIR /src
COPY . .
RUN if [ -n "$NUGET_FEED_USERNAME" ] && [ -n "$NUGET_FEED_TOKEN" ]; then dotnet nuget add source "$NUGET_FEED_URL" --name gitea-org --username "$NUGET_FEED_USERNAME" --password "$NUGET_FEED_TOKEN" --store-password-in-clear-text --allow-insecure-connections --configfile /root/.nuget/NuGet/NuGet.Config; fi
RUN dotnet restore "src/Thalos.Service.Grpc/Thalos.Service.Grpc.csproj" --configfile /root/.nuget/NuGet/NuGet.Config
RUN dotnet publish "src/Thalos.Service.Grpc/Thalos.Service.Grpc.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
FROM ${RUNTIME_IMAGE} AS runtime
WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
EXPOSE 8081
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Thalos.Service.Grpc.dll"]

View File

@ -0,0 +1,27 @@
# Thalos Service Contract Package Baseline
## Feed
- Source: `https://gitea.dream-views.com/api/packages/AgileWebs/nuget/index.json`
- Authentication: Gitea login + token
- HTTP requirement: `allowInsecureConnections="true"` in `nuget.config`
## Published Baseline
| Package | Version | Published On |
| --- | --- | --- |
| Thalos.Service.Identity.Abstractions | 0.2.0 | 2026-02-25 |
## Dependency Note
`Thalos.Service.Identity.Abstractions` depends on:
- `Core.Blueprint.Common` version `0.2.0`
## Consumer Validation
Restore validation passed using:
- `TargetFramework`: `net10.0`
- `PackageReference`: `Thalos.Service.Identity.Abstractions` `0.2.0`
- Restore flags: `--no-cache --force`

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. - Token issuance and policy evaluation are orchestrated in service use cases.
- Data retrieval and persistence details remain in thalos-dal and identity adapters. - Data retrieval and persistence details remain in thalos-dal and identity adapters.
- Protocol adaptation remains outside use-case logic. - 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

@ -0,0 +1,25 @@
# Containerization Runbook
## Image Build
If the repo consumes internal packages from Gitea, pass feed credentials as build args.
```bash
docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --build-arg NUGET_FEED_TOKEN=<gitea-token> -t agilewebs/thalos-service:dev .
```
## Local Run
```bash
docker run --rm -p 8080:8080 --name thalos-service agilewebs/thalos-service:dev
```
## Health Probe
- Path: `/health`
- Fallback path: `/healthz`
- Port: `8080`
## Runtime Notes
- Exposes internal identity runtime endpoint set and gRPC service.

View File

@ -1,10 +1,13 @@
using Core.Blueprint.Common.DependencyInjection; using Core.Blueprint.Common.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Thalos.Domain.Decisions; using Thalos.Domain.Decisions;
using Thalos.DAL.DependencyInjection; using Thalos.DAL.DependencyInjection;
using Thalos.Service.Application.Adapters; using Thalos.Service.Application.Adapters;
using Thalos.Service.Application.Ports; using Thalos.Service.Application.Ports;
using Thalos.Service.Application.Secrets;
using Thalos.Service.Application.Sessions;
using Thalos.Service.Application.UseCases; using Thalos.Service.Application.UseCases;
namespace Thalos.Service.Application.DependencyInjection; namespace Thalos.Service.Application.DependencyInjection;
@ -23,15 +26,21 @@ public static class ThalosServiceRuntimeServiceCollectionExtensions
{ {
services.AddBlueprintRuntimeCore(); services.AddBlueprintRuntimeCore();
services.AddThalosDalRuntime(); services.AddThalosDalRuntime();
services.TryAddSingleton<IConfiguration>(_ =>
new ConfigurationBuilder().AddInMemoryCollection().Build());
services.TryAddSingleton<IIdentityPolicyDecisionService, IdentityPolicyDecisionService>(); services.TryAddSingleton<IIdentityPolicyDecisionService, IdentityPolicyDecisionService>();
services.TryAddSingleton<IIdentityTokenDecisionService, IdentityTokenDecisionService>(); services.TryAddSingleton<IIdentityTokenDecisionService, IdentityTokenDecisionService>();
services.TryAddSingleton<IIdentityPolicyGrpcContractAdapter, IdentityPolicyGrpcContractAdapter>(); services.TryAddSingleton<IIdentityPolicyGrpcContractAdapter, IdentityPolicyGrpcContractAdapter>();
services.TryAddSingleton<IIdentitySecretMaterialProvider, ConfigurationIdentitySecretMaterialProvider>();
services.TryAddSingleton<IIdentitySessionTokenCodec, HmacIdentitySessionTokenCodec>();
services.TryAddSingleton<IIdentityTokenReadPort, IdentityTokenReadPortDalAdapter>(); services.TryAddSingleton<IIdentityTokenReadPort, IdentityTokenReadPortDalAdapter>();
services.TryAddSingleton<IIdentityPolicyContextReadPort, IdentityPolicyContextReadPortDalAdapter>(); services.TryAddSingleton<IIdentityPolicyContextReadPort, IdentityPolicyContextReadPortDalAdapter>();
services.TryAddSingleton<IIssueIdentityTokenUseCase, IssueIdentityTokenUseCase>(); services.TryAddSingleton<IIssueIdentityTokenUseCase, IssueIdentityTokenUseCase>();
services.TryAddSingleton<IStartIdentitySessionUseCase, StartIdentitySessionUseCase>();
services.TryAddSingleton<IRefreshIdentitySessionUseCase, RefreshIdentitySessionUseCase>();
services.TryAddSingleton<IEvaluateIdentityPolicyUseCase, EvaluateIdentityPolicyUseCase>(); services.TryAddSingleton<IEvaluateIdentityPolicyUseCase, EvaluateIdentityPolicyUseCase>();
return services; 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> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\..\..\building-block-identity\src\BuildingBlock.Identity.Contracts\BuildingBlock.Identity.Contracts.csproj" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<ProjectReference Include="..\..\..\thalos-domain\src\Thalos.Domain\Thalos.Domain.csproj" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\..\..\thalos-dal\src\Thalos.DAL\Thalos.DAL.csproj" /> <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> </ItemGroup>
</Project> </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

@ -1,7 +1,16 @@
using Thalos.Service.Application.DependencyInjection; using Thalos.Service.Application.DependencyInjection;
using Thalos.Service.Grpc.Services; using Thalos.Service.Grpc.Services;
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var httpPort = builder.Configuration.GetValue("ThalosService:HttpPort", 8080);
var grpcPort = builder.Configuration.GetValue("ThalosService:GrpcPort", 8081);
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(httpPort, listenOptions => listenOptions.Protocols = HttpProtocols.Http1);
options.ListenAnyIP(grpcPort, listenOptions => listenOptions.Protocols = HttpProtocols.Http2);
});
builder.Services.AddGrpc(); builder.Services.AddGrpc();
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
@ -11,5 +20,6 @@ var app = builder.Build();
app.MapGrpcService<IdentityRuntimeGrpcService>(); app.MapGrpcService<IdentityRuntimeGrpcService>();
app.MapHealthChecks("/healthz"); app.MapHealthChecks("/healthz");
app.MapHealthChecks("/health");
app.Run(); app.Run();

View File

@ -5,10 +5,44 @@ option csharp_namespace = "Thalos.Service.Grpc";
package thalos.service.grpc; package thalos.service.grpc;
service IdentityRuntime { service IdentityRuntime {
rpc StartIdentitySession (StartIdentitySessionGrpcRequest) returns (StartIdentitySessionGrpcResponse);
rpc RefreshIdentitySession (RefreshIdentitySessionGrpcRequest) returns (RefreshIdentitySessionGrpcResponse);
rpc IssueIdentityToken (IssueIdentityTokenGrpcRequest) returns (IssueIdentityTokenGrpcResponse); rpc IssueIdentityToken (IssueIdentityTokenGrpcRequest) returns (IssueIdentityTokenGrpcResponse);
rpc EvaluateIdentityPolicy (EvaluateIdentityPolicyGrpcRequest) returns (EvaluateIdentityPolicyGrpcResponse); 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 { message IssueIdentityTokenGrpcRequest {
string subject_id = 1; string subject_id = 1;
string tenant_id = 2; string tenant_id = 2;

View File

@ -11,10 +11,70 @@ namespace Thalos.Service.Grpc.Services;
/// Internal gRPC endpoint implementation for identity runtime operations. /// Internal gRPC endpoint implementation for identity runtime operations.
/// </summary> /// </summary>
public sealed class IdentityRuntimeGrpcService( public sealed class IdentityRuntimeGrpcService(
IStartIdentitySessionUseCase startIdentitySessionUseCase,
IRefreshIdentitySessionUseCase refreshIdentitySessionUseCase,
IIssueIdentityTokenUseCase issueIdentityTokenUseCase, IIssueIdentityTokenUseCase issueIdentityTokenUseCase,
IEvaluateIdentityPolicyUseCase evaluateIdentityPolicyUseCase, IEvaluateIdentityPolicyUseCase evaluateIdentityPolicyUseCase,
IIdentityPolicyGrpcContractAdapter grpcContractAdapter) : IdentityRuntime.IdentityRuntimeBase 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> /// <summary>
/// Issues identity token through service use-case orchestration. /// Issues identity token through service use-case orchestration.
/// </summary> /// </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

@ -5,6 +5,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\blueprint-platform\src\Core.Blueprint.Common\Core.Blueprint.Common.csproj" /> <PackageReference Include="Core.Blueprint.Common" Version="0.2.0" />
<PackageReference Include="BuildingBlock.Identity.Contracts" Version="0.2.0" />
</ItemGroup> </ItemGroup>
</Project> </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.DependencyInjection;
using Thalos.Service.Application.Grpc; using Thalos.Service.Application.Grpc;
using Thalos.Service.Application.UseCases; 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; namespace Thalos.Service.Application.UnitTests;
@ -18,15 +22,27 @@ public class RuntimeWiringTests
using var provider = services.BuildServiceProvider(); using var provider = services.BuildServiceProvider();
var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>(); var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>();
var startSessionUseCase = provider.GetRequiredService<IStartIdentitySessionUseCase>();
var refreshSessionUseCase = provider.GetRequiredService<IRefreshIdentitySessionUseCase>();
var evaluatePolicyUseCase = provider.GetRequiredService<IEvaluateIdentityPolicyUseCase>(); 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( 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("user-1:tenant-1:token", tokenResponse.Token);
Assert.Equal(1800, tokenResponse.ExpiresInSeconds); Assert.Equal(1800, tokenResponse.ExpiresInSeconds);
Assert.True(policyResponse.IsAllowed); 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] [Fact]
@ -39,7 +55,7 @@ public class RuntimeWiringTests
var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>(); var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>();
var tokenResponse = await issueTokenUseCase.HandleAsync( 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(string.Empty, tokenResponse.Token);
Assert.Equal(0, tokenResponse.ExpiresInSeconds); Assert.Equal(0, tokenResponse.ExpiresInSeconds);
@ -55,7 +71,7 @@ public class RuntimeWiringTests
var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>(); var issueTokenUseCase = provider.GetRequiredService<IIssueIdentityTokenUseCase>();
var tokenResponse = await issueTokenUseCase.HandleAsync( var tokenResponse = await issueTokenUseCase.HandleAsync(
new IssueIdentityTokenRequest( new BuildingIssueRequest(
string.Empty, string.Empty,
"tenant-2", "tenant-2",
IdentityAuthProvider.AzureAd, IdentityAuthProvider.AzureAd,
@ -69,7 +85,7 @@ public class RuntimeWiringTests
public void IdentityPolicyGrpcContractAdapter_WhenMapped_PreservesValues() public void IdentityPolicyGrpcContractAdapter_WhenMapped_PreservesValues()
{ {
var adapter = new IdentityPolicyGrpcContractAdapter(); 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 grpcContract = adapter.ToGrpc(useCaseRequest);
var roundtrip = adapter.FromGrpc(grpcContract); 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;
}
}
}