Compare commits
2 Commits
7cec61b959
...
85796336c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85796336c6 | ||
|
|
dec12f912f |
@ -8,7 +8,9 @@ Constrain thalos-service to orchestration responsibilities after thalos-domain e
|
|||||||
- Delegate policy/token decisions to thalos-domain abstractions
|
- Delegate policy/token decisions to thalos-domain abstractions
|
||||||
- Adapt transport contracts
|
- Adapt transport contracts
|
||||||
- Route provider metadata (`InternalJwt`, `AzureAd`, `Google`) between edge/service/dal boundaries
|
- Route provider metadata (`InternalJwt`, `AzureAd`, `Google`) between edge/service/dal boundaries
|
||||||
|
- Orchestrate Google external-token claim validation through provider-agnostic secret/material boundaries
|
||||||
|
|
||||||
## Prohibited Responsibilities
|
## Prohibited Responsibilities
|
||||||
- Owning identity decision policies
|
- Owning identity decision policies
|
||||||
- Owning persistence decision concerns
|
- Owning persistence decision concerns
|
||||||
|
- Coupling use-cases directly to Vault/cloud provider SDKs
|
||||||
|
|||||||
@ -12,10 +12,11 @@
|
|||||||
## Session Flow
|
## Session Flow
|
||||||
|
|
||||||
1. BFF calls `StartIdentitySession` with subject/tenant/provider/external token.
|
1. BFF calls `StartIdentitySession` with subject/tenant/provider/external token.
|
||||||
2. Service issues access token through existing token orchestration.
|
2. For `Google`, service exchanges and validates external token claims (`sub`, `aud`, `iss`) before issuing session tokens.
|
||||||
3. Service generates refresh token through provider-agnostic session token codec.
|
3. Service issues access token through existing token orchestration.
|
||||||
4. BFF calls `RefreshIdentitySession` with refresh token.
|
4. Service generates refresh token through provider-agnostic session token codec.
|
||||||
5. Service validates refresh token signature/expiry and reissues session tokens.
|
5. BFF calls `RefreshIdentitySession` with refresh token.
|
||||||
|
6. Service validates refresh token signature/expiry and reissues session tokens.
|
||||||
|
|
||||||
## Provider-Agnostic Secret Boundary
|
## Provider-Agnostic Secret Boundary
|
||||||
|
|
||||||
@ -24,8 +25,11 @@ Session refresh token signing is bound to `IIdentitySecretMaterialProvider`.
|
|||||||
- Contract is provider-neutral.
|
- Contract is provider-neutral.
|
||||||
- Runtime binding is configuration-based by default.
|
- Runtime binding is configuration-based by default.
|
||||||
- Vault/cloud/env adapters can be swapped at DI boundaries without changing use-case code.
|
- Vault/cloud/env adapters can be swapped at DI boundaries without changing use-case code.
|
||||||
|
- OIDC provider material uses the same boundary (no provider SDK coupling in use-case logic).
|
||||||
|
|
||||||
## Configuration Keys
|
## Configuration Keys
|
||||||
|
|
||||||
- `ThalosIdentity:Secrets:SessionSigning`
|
- `ThalosIdentity:Secrets:SessionSigning`
|
||||||
|
- `ThalosIdentity:Secrets:Oidc:Google:ClientId`
|
||||||
|
- `ThalosIdentity:Secrets:Oidc:Google:Issuer` (optional, defaults to `https://accounts.google.com`)
|
||||||
- `ThalosIdentity:Secrets:Default` (fallback)
|
- `ThalosIdentity:Secrets:Default` (fallback)
|
||||||
|
|||||||
@ -11,7 +11,11 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --build-arg NUGET
|
|||||||
## Local Run
|
## Local Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -p 8080:8080 --name thalos-service agilewebs/thalos-service:dev
|
docker run --rm -p 8080:8080 \
|
||||||
|
-e ThalosIdentity__Secrets__SessionSigning=<session-signing-secret> \
|
||||||
|
-e ThalosIdentity__Secrets__Oidc__Google__ClientId=<google-client-id> \
|
||||||
|
-e ThalosIdentity__Secrets__Oidc__Google__Issuer=https://accounts.google.com \
|
||||||
|
--name thalos-service agilewebs/thalos-service:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Health Probe
|
## Health Probe
|
||||||
@ -23,3 +27,4 @@ docker run --rm -p 8080:8080 --name thalos-service agilewebs/thalos-service:dev
|
|||||||
## Runtime Notes
|
## Runtime Notes
|
||||||
|
|
||||||
- Exposes internal identity runtime endpoint set and gRPC service.
|
- Exposes internal identity runtime endpoint set and gRPC service.
|
||||||
|
- Google OIDC claim validation requires `ThalosIdentity:Secrets:Oidc:Google:ClientId`.
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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.Oidc;
|
||||||
using Thalos.Service.Application.Ports;
|
using Thalos.Service.Application.Ports;
|
||||||
using Thalos.Service.Application.Secrets;
|
using Thalos.Service.Application.Secrets;
|
||||||
using Thalos.Service.Application.Sessions;
|
using Thalos.Service.Application.Sessions;
|
||||||
@ -33,6 +34,7 @@ public static class ThalosServiceRuntimeServiceCollectionExtensions
|
|||||||
|
|
||||||
services.TryAddSingleton<IIdentityPolicyGrpcContractAdapter, IdentityPolicyGrpcContractAdapter>();
|
services.TryAddSingleton<IIdentityPolicyGrpcContractAdapter, IdentityPolicyGrpcContractAdapter>();
|
||||||
services.TryAddSingleton<IIdentitySecretMaterialProvider, ConfigurationIdentitySecretMaterialProvider>();
|
services.TryAddSingleton<IIdentitySecretMaterialProvider, ConfigurationIdentitySecretMaterialProvider>();
|
||||||
|
services.TryAddSingleton<IIdentityProviderTokenExchangeService, GoogleIdentityProviderTokenExchangeService>();
|
||||||
services.TryAddSingleton<IIdentitySessionTokenCodec, HmacIdentitySessionTokenCodec>();
|
services.TryAddSingleton<IIdentitySessionTokenCodec, HmacIdentitySessionTokenCodec>();
|
||||||
|
|
||||||
services.TryAddSingleton<IIdentityTokenReadPort, IdentityTokenReadPortDalAdapter>();
|
services.TryAddSingleton<IIdentityTokenReadPort, IdentityTokenReadPortDalAdapter>();
|
||||||
|
|||||||
@ -0,0 +1,188 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using BuildingBlock.Identity.Contracts.Conventions;
|
||||||
|
using BuildingBlock.Identity.Contracts.Requests;
|
||||||
|
using BuildingBlock.Identity.Contracts.Responses;
|
||||||
|
using Thalos.Service.Application.Secrets;
|
||||||
|
|
||||||
|
namespace Thalos.Service.Application.Oidc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges Google provider tokens into normalized identity claims for service-layer orchestration.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GoogleIdentityProviderTokenExchangeService(
|
||||||
|
IIdentitySecretMaterialProvider secretMaterialProvider)
|
||||||
|
: IIdentityProviderTokenExchangeService
|
||||||
|
{
|
||||||
|
private const string GoogleClientIdSecretKey = "Oidc:Google:ClientId";
|
||||||
|
private const string GoogleIssuerSecretKey = "Oidc:Google:Issuer";
|
||||||
|
private const string DefaultGoogleIssuer = "https://accounts.google.com";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ExchangeIdentityProviderTokenResponse> ExchangeAsync(
|
||||||
|
ExchangeIdentityProviderTokenRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (request.Provider != IdentityAuthProvider.Google)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Failed(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ExternalToken))
|
||||||
|
{
|
||||||
|
return Task.FromResult(Failed(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secretMaterialProvider.TryGetSecret(GoogleClientIdSecretKey, out var expectedAudience))
|
||||||
|
{
|
||||||
|
return Task.FromResult(Failed(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedIssuer = secretMaterialProvider.TryGetSecret(GoogleIssuerSecretKey, out var configuredIssuer)
|
||||||
|
? configuredIssuer
|
||||||
|
: DefaultGoogleIssuer;
|
||||||
|
|
||||||
|
if (!TryParseJwtPayload(request.ExternalToken, out var payload))
|
||||||
|
{
|
||||||
|
return Task.FromResult(Failed(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
var subject = ReadStringClaim(payload, "sub");
|
||||||
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
|
{
|
||||||
|
return Task.FromResult(Failed(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AudienceMatches(payload, expectedAudience))
|
||||||
|
{
|
||||||
|
return Task.FromResult(Failed(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IssuerMatches(payload, expectedIssuer))
|
||||||
|
{
|
||||||
|
return Task.FromResult(Failed(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = new ExchangeIdentityProviderTokenResponse(
|
||||||
|
subject,
|
||||||
|
request.TenantId,
|
||||||
|
request.Provider,
|
||||||
|
true);
|
||||||
|
|
||||||
|
return Task.FromResult(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExchangeIdentityProviderTokenResponse Failed(ExchangeIdentityProviderTokenRequest request)
|
||||||
|
{
|
||||||
|
return new ExchangeIdentityProviderTokenResponse(
|
||||||
|
string.Empty,
|
||||||
|
request.TenantId,
|
||||||
|
request.Provider,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseJwtPayload(string jwt, out JsonElement payload)
|
||||||
|
{
|
||||||
|
payload = default;
|
||||||
|
var segments = jwt.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (segments.Length < 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payloadBytes = DecodeBase64Url(segments[1]);
|
||||||
|
using var document = JsonDocument.Parse(payloadBytes);
|
||||||
|
payload = document.RootElement.Clone();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DecodeBase64Url(string input)
|
||||||
|
{
|
||||||
|
var normalized = input.Replace('-', '+').Replace('_', '/');
|
||||||
|
var remainder = normalized.Length % 4;
|
||||||
|
if (remainder > 0)
|
||||||
|
{
|
||||||
|
normalized = normalized.PadRight(normalized.Length + (4 - remainder), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.FromBase64String(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool AudienceMatches(JsonElement payload, string expectedAudience)
|
||||||
|
{
|
||||||
|
if (!payload.TryGetProperty("aud", out var audienceElement))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audienceElement.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return string.Equals(
|
||||||
|
audienceElement.GetString(),
|
||||||
|
expectedAudience,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audienceElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var candidate in audienceElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (candidate.ValueKind != JsonValueKind.String)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(candidate.GetString(), expectedAudience, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IssuerMatches(JsonElement payload, string expectedIssuer)
|
||||||
|
{
|
||||||
|
var tokenIssuer = ReadStringClaim(payload, "iss");
|
||||||
|
if (string.IsNullOrWhiteSpace(tokenIssuer))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedTokenIssuer = NormalizeIssuer(tokenIssuer);
|
||||||
|
var normalizedExpectedIssuer = NormalizeIssuer(expectedIssuer);
|
||||||
|
return string.Equals(normalizedTokenIssuer, normalizedExpectedIssuer, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeIssuer(string issuer)
|
||||||
|
{
|
||||||
|
return issuer.Trim().TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadStringClaim(JsonElement payload, string claimName)
|
||||||
|
{
|
||||||
|
if (!payload.TryGetProperty(claimName, out var claimElement))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return claimElement.ValueKind == JsonValueKind.String
|
||||||
|
? claimElement.GetString() ?? string.Empty
|
||||||
|
: string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
using BuildingBlock.Identity.Contracts.Requests;
|
||||||
|
using BuildingBlock.Identity.Contracts.Responses;
|
||||||
|
|
||||||
|
namespace Thalos.Service.Application.Oidc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service boundary for exchanging external provider tokens into normalized identity claims.
|
||||||
|
/// </summary>
|
||||||
|
public interface IIdentityProviderTokenExchangeService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges an external provider token into a normalized identity response.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Provider token exchange request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Normalized exchange response for downstream use-cases.</returns>
|
||||||
|
Task<ExchangeIdentityProviderTokenResponse> ExchangeAsync(
|
||||||
|
ExchangeIdentityProviderTokenRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -11,16 +11,35 @@ public sealed class ConfigurationIdentitySecretMaterialProvider(IConfiguration c
|
|||||||
private const string FallbackSecret = "thalos-dev-secret";
|
private const string FallbackSecret = "thalos-dev-secret";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GetSecret(string secretKey)
|
public bool TryGetSecret(string secretKey, out string secretValue)
|
||||||
{
|
{
|
||||||
var scopedKey = $"ThalosIdentity:Secrets:{secretKey}";
|
var scopedKey = $"ThalosIdentity:Secrets:{secretKey}";
|
||||||
var scopedSecret = configuration[scopedKey];
|
var scopedSecret = configuration[scopedKey];
|
||||||
if (!string.IsNullOrWhiteSpace(scopedSecret))
|
if (!string.IsNullOrWhiteSpace(scopedSecret))
|
||||||
{
|
{
|
||||||
return scopedSecret;
|
secretValue = scopedSecret;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultSecret = configuration["ThalosIdentity:Secrets:Default"];
|
var defaultSecret = configuration["ThalosIdentity:Secrets:Default"];
|
||||||
return string.IsNullOrWhiteSpace(defaultSecret) ? FallbackSecret : defaultSecret;
|
if (!string.IsNullOrWhiteSpace(defaultSecret))
|
||||||
|
{
|
||||||
|
secretValue = defaultSecret;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
secretValue = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetSecret(string secretKey)
|
||||||
|
{
|
||||||
|
if (TryGetSecret(secretKey, out var secretValue))
|
||||||
|
{
|
||||||
|
return secretValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FallbackSecret;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,14 @@ namespace Thalos.Service.Application.Secrets;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IIdentitySecretMaterialProvider
|
public interface IIdentitySecretMaterialProvider
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to resolve secret material for the requested secret key.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secretKey">Logical secret key.</param>
|
||||||
|
/// <param name="secretValue">Resolved secret value when available.</param>
|
||||||
|
/// <returns><c>true</c> when the secret is available; otherwise <c>false</c>.</returns>
|
||||||
|
bool TryGetSecret(string secretKey, out string secretValue);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves secret material for the requested secret key.
|
/// Resolves secret material for the requested secret key.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
using BuildingBlock.Identity.Contracts.Requests;
|
using BuildingBlock.Identity.Contracts.Requests;
|
||||||
|
using BuildingBlock.Identity.Contracts.Conventions;
|
||||||
|
using Thalos.Service.Application.Oidc;
|
||||||
using Thalos.Service.Application.Sessions;
|
using Thalos.Service.Application.Sessions;
|
||||||
using Thalos.Service.Identity.Abstractions.Contracts;
|
using Thalos.Service.Identity.Abstractions.Contracts;
|
||||||
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
|
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
|
||||||
@ -10,23 +12,62 @@ namespace Thalos.Service.Application.UseCases;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StartIdentitySessionUseCase(
|
public sealed class StartIdentitySessionUseCase(
|
||||||
IIssueIdentityTokenUseCase issueIdentityTokenUseCase,
|
IIssueIdentityTokenUseCase issueIdentityTokenUseCase,
|
||||||
|
IIdentityProviderTokenExchangeService tokenExchangeService,
|
||||||
IIdentitySessionTokenCodec sessionTokenCodec)
|
IIdentitySessionTokenCodec sessionTokenCodec)
|
||||||
: IStartIdentitySessionUseCase
|
: IStartIdentitySessionUseCase
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<StartIdentitySessionResponse> HandleAsync(StartIdentitySessionRequest request)
|
public async Task<StartIdentitySessionResponse> HandleAsync(StartIdentitySessionRequest request)
|
||||||
{
|
{
|
||||||
|
var subjectId = request.SubjectId;
|
||||||
|
if (request.Provider == IdentityAuthProvider.Google)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ExternalToken))
|
||||||
|
{
|
||||||
|
return Failed(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
var exchangeResponse = await tokenExchangeService.ExchangeAsync(new ExchangeIdentityProviderTokenRequest(
|
||||||
|
request.TenantId,
|
||||||
|
request.Provider,
|
||||||
|
request.ExternalToken,
|
||||||
|
request.CorrelationId));
|
||||||
|
|
||||||
|
if (!exchangeResponse.IsAuthenticated || string.IsNullOrWhiteSpace(exchangeResponse.SubjectId))
|
||||||
|
{
|
||||||
|
return Failed(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.SubjectId) &&
|
||||||
|
!string.Equals(request.SubjectId, exchangeResponse.SubjectId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Failed(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectId = exchangeResponse.SubjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(subjectId))
|
||||||
|
{
|
||||||
|
return Failed(request);
|
||||||
|
}
|
||||||
|
|
||||||
var issueRequest = new IdentityIssueRequest(
|
var issueRequest = new IdentityIssueRequest(
|
||||||
request.SubjectId,
|
subjectId,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
request.Provider,
|
request.Provider,
|
||||||
request.ExternalToken);
|
request.ExternalToken);
|
||||||
|
|
||||||
var issueResponse = await issueIdentityTokenUseCase.HandleAsync(issueRequest);
|
var issueResponse = await issueIdentityTokenUseCase.HandleAsync(issueRequest);
|
||||||
|
if (string.IsNullOrWhiteSpace(issueResponse.Token) || issueResponse.ExpiresInSeconds <= 0)
|
||||||
|
{
|
||||||
|
return Failed(request);
|
||||||
|
}
|
||||||
|
|
||||||
var expiresInSeconds = Math.Max(0, issueResponse.ExpiresInSeconds);
|
var expiresInSeconds = Math.Max(0, issueResponse.ExpiresInSeconds);
|
||||||
|
|
||||||
var refreshDescriptor = new IdentitySessionDescriptor(
|
var refreshDescriptor = new IdentitySessionDescriptor(
|
||||||
request.SubjectId,
|
subjectId,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
request.Provider,
|
request.Provider,
|
||||||
DateTimeOffset.UtcNow.AddHours(8));
|
DateTimeOffset.UtcNow.AddHours(8));
|
||||||
@ -37,6 +78,17 @@ public sealed class StartIdentitySessionUseCase(
|
|||||||
issueResponse.Token,
|
issueResponse.Token,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
expiresInSeconds,
|
expiresInSeconds,
|
||||||
|
subjectId,
|
||||||
|
request.TenantId,
|
||||||
|
request.Provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StartIdentitySessionResponse Failed(StartIdentitySessionRequest request)
|
||||||
|
{
|
||||||
|
return new StartIdentitySessionResponse(
|
||||||
|
string.Empty,
|
||||||
|
string.Empty,
|
||||||
|
0,
|
||||||
request.SubjectId,
|
request.SubjectId,
|
||||||
request.TenantId,
|
request.TenantId,
|
||||||
request.Provider);
|
request.Provider);
|
||||||
|
|||||||
@ -0,0 +1,122 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using BuildingBlock.Identity.Contracts.Conventions;
|
||||||
|
using BuildingBlock.Identity.Contracts.Requests;
|
||||||
|
using Thalos.Service.Application.Oidc;
|
||||||
|
using Thalos.Service.Application.Secrets;
|
||||||
|
|
||||||
|
namespace Thalos.Service.Application.UnitTests;
|
||||||
|
|
||||||
|
public class GoogleIdentityProviderTokenExchangeServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ExchangeAsync_WhenTokenClaimsMatch_ReturnsAuthenticatedSubject()
|
||||||
|
{
|
||||||
|
var provider = new FakeSecretMaterialProvider(
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["Oidc:Google:ClientId"] = "google-client-1",
|
||||||
|
["Oidc:Google:Issuer"] = "https://accounts.google.com"
|
||||||
|
});
|
||||||
|
var service = new GoogleIdentityProviderTokenExchangeService(provider);
|
||||||
|
var token = BuildUnsignedJwt(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["sub"] = "google-sub-1",
|
||||||
|
["aud"] = "google-client-1",
|
||||||
|
["iss"] = "https://accounts.google.com"
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await service.ExchangeAsync(new ExchangeIdentityProviderTokenRequest(
|
||||||
|
"tenant-1",
|
||||||
|
IdentityAuthProvider.Google,
|
||||||
|
token,
|
||||||
|
"corr-1"));
|
||||||
|
|
||||||
|
Assert.True(response.IsAuthenticated);
|
||||||
|
Assert.Equal("google-sub-1", response.SubjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExchangeAsync_WhenAudienceMismatches_ReturnsUnauthenticated()
|
||||||
|
{
|
||||||
|
var provider = new FakeSecretMaterialProvider(
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["Oidc:Google:ClientId"] = "google-client-1"
|
||||||
|
});
|
||||||
|
var service = new GoogleIdentityProviderTokenExchangeService(provider);
|
||||||
|
var token = BuildUnsignedJwt(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["sub"] = "google-sub-2",
|
||||||
|
["aud"] = "google-client-2",
|
||||||
|
["iss"] = "https://accounts.google.com"
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await service.ExchangeAsync(new ExchangeIdentityProviderTokenRequest(
|
||||||
|
"tenant-2",
|
||||||
|
IdentityAuthProvider.Google,
|
||||||
|
token,
|
||||||
|
"corr-2"));
|
||||||
|
|
||||||
|
Assert.False(response.IsAuthenticated);
|
||||||
|
Assert.Equal(string.Empty, response.SubjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExchangeAsync_WhenGoogleClientIdSecretMissing_ReturnsUnauthenticated()
|
||||||
|
{
|
||||||
|
var provider = new FakeSecretMaterialProvider(new Dictionary<string, string>(StringComparer.Ordinal));
|
||||||
|
var service = new GoogleIdentityProviderTokenExchangeService(provider);
|
||||||
|
var token = BuildUnsignedJwt(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["sub"] = "google-sub-3",
|
||||||
|
["aud"] = "google-client-1",
|
||||||
|
["iss"] = "https://accounts.google.com"
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await service.ExchangeAsync(new ExchangeIdentityProviderTokenRequest(
|
||||||
|
"tenant-3",
|
||||||
|
IdentityAuthProvider.Google,
|
||||||
|
token,
|
||||||
|
"corr-3"));
|
||||||
|
|
||||||
|
Assert.False(response.IsAuthenticated);
|
||||||
|
Assert.Equal(string.Empty, response.SubjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildUnsignedJwt(Dictionary<string, object?> payload)
|
||||||
|
{
|
||||||
|
var header = Base64UrlEncode("""{"alg":"none","typ":"JWT"}""");
|
||||||
|
var payloadJson = JsonSerializer.Serialize(payload);
|
||||||
|
var payloadEncoded = Base64UrlEncode(payloadJson);
|
||||||
|
return $"{header}.{payloadEncoded}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Base64UrlEncode(string value)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(value);
|
||||||
|
var encoded = Convert.ToBase64String(bytes);
|
||||||
|
return encoded.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSecretMaterialProvider(
|
||||||
|
IReadOnlyDictionary<string, string> secrets) : IIdentitySecretMaterialProvider
|
||||||
|
{
|
||||||
|
public bool TryGetSecret(string secretKey, out string secretValue)
|
||||||
|
{
|
||||||
|
if (secrets.TryGetValue(secretKey, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
secretValue = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
secretValue = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetSecret(string secretKey)
|
||||||
|
{
|
||||||
|
return TryGetSecret(secretKey, out var secretValue) ? secretValue : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,6 +44,12 @@ public class HmacIdentitySessionTokenCodecTests
|
|||||||
|
|
||||||
private sealed class FakeSecretMaterialProvider : IIdentitySecretMaterialProvider
|
private sealed class FakeSecretMaterialProvider : IIdentitySecretMaterialProvider
|
||||||
{
|
{
|
||||||
|
public bool TryGetSecret(string secretKey, out string secretValue)
|
||||||
|
{
|
||||||
|
secretValue = "unit-test-secret";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public string GetSecret(string secretKey) => "unit-test-secret";
|
public string GetSecret(string secretKey) => "unit-test-secret";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
using BuildingBlock.Identity.Contracts.Conventions;
|
using BuildingBlock.Identity.Contracts.Conventions;
|
||||||
using BuildingBlock.Identity.Contracts.Requests;
|
using BuildingBlock.Identity.Contracts.Requests;
|
||||||
using BuildingBlock.Identity.Contracts.Responses;
|
using BuildingBlock.Identity.Contracts.Responses;
|
||||||
|
using Thalos.Service.Application.Oidc;
|
||||||
using Thalos.Service.Application.Sessions;
|
using Thalos.Service.Application.Sessions;
|
||||||
using Thalos.Service.Application.UseCases;
|
using Thalos.Service.Application.UseCases;
|
||||||
using Thalos.Service.Identity.Abstractions.Contracts;
|
using Thalos.Service.Identity.Abstractions.Contracts;
|
||||||
|
using ExchangeRequest = BuildingBlock.Identity.Contracts.Requests.ExchangeIdentityProviderTokenRequest;
|
||||||
|
using ExchangeResponse = BuildingBlock.Identity.Contracts.Responses.ExchangeIdentityProviderTokenResponse;
|
||||||
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
|
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
|
||||||
using IdentityIssueResponse = BuildingBlock.Identity.Contracts.Responses.IssueIdentityTokenResponse;
|
using IdentityIssueResponse = BuildingBlock.Identity.Contracts.Responses.IssueIdentityTokenResponse;
|
||||||
|
|
||||||
@ -14,7 +17,11 @@ public class StartIdentitySessionUseCaseTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task HandleAsync_WhenCalled_IssuesTokenAndRefreshToken()
|
public async Task HandleAsync_WhenCalled_IssuesTokenAndRefreshToken()
|
||||||
{
|
{
|
||||||
var useCase = new StartIdentitySessionUseCase(new FakeIssueUseCase(), new FakeSessionTokenCodec());
|
var issueUseCase = new FakeIssueUseCase();
|
||||||
|
var useCase = new StartIdentitySessionUseCase(
|
||||||
|
issueUseCase,
|
||||||
|
new FakeExchangeService(),
|
||||||
|
new FakeSessionTokenCodec());
|
||||||
|
|
||||||
var response = await useCase.HandleAsync(new StartIdentitySessionRequest("user-1", "tenant-1", IdentityAuthProvider.InternalJwt));
|
var response = await useCase.HandleAsync(new StartIdentitySessionRequest("user-1", "tenant-1", IdentityAuthProvider.InternalJwt));
|
||||||
|
|
||||||
@ -23,16 +30,98 @@ public class StartIdentitySessionUseCaseTests
|
|||||||
Assert.Equal("user-1", response.SubjectId);
|
Assert.Equal("user-1", response.SubjectId);
|
||||||
Assert.Equal("tenant-1", response.TenantId);
|
Assert.Equal("tenant-1", response.TenantId);
|
||||||
Assert.Equal("refresh-user-1-tenant-1", response.RefreshToken);
|
Assert.Equal("refresh-user-1-tenant-1", response.RefreshToken);
|
||||||
|
Assert.NotNull(issueUseCase.LastRequest);
|
||||||
|
Assert.Equal("user-1", issueUseCase.LastRequest!.SubjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_WhenGoogleProviderAndExchangeSucceeds_UsesExchangedSubject()
|
||||||
|
{
|
||||||
|
var issueUseCase = new FakeIssueUseCase();
|
||||||
|
var exchangeService = new FakeExchangeService
|
||||||
|
{
|
||||||
|
NextResponse = new ExchangeResponse(
|
||||||
|
"google-sub-123",
|
||||||
|
"tenant-1",
|
||||||
|
IdentityAuthProvider.Google,
|
||||||
|
true)
|
||||||
|
};
|
||||||
|
|
||||||
|
var useCase = new StartIdentitySessionUseCase(
|
||||||
|
issueUseCase,
|
||||||
|
exchangeService,
|
||||||
|
new FakeSessionTokenCodec());
|
||||||
|
|
||||||
|
var response = await useCase.HandleAsync(new StartIdentitySessionRequest(
|
||||||
|
string.Empty,
|
||||||
|
"tenant-1",
|
||||||
|
IdentityAuthProvider.Google,
|
||||||
|
"google-id-token",
|
||||||
|
"corr-1"));
|
||||||
|
|
||||||
|
Assert.Equal("token-abc", response.AccessToken);
|
||||||
|
Assert.Equal("google-sub-123", response.SubjectId);
|
||||||
|
Assert.Equal(IdentityAuthProvider.Google, response.Provider);
|
||||||
|
Assert.NotNull(issueUseCase.LastRequest);
|
||||||
|
Assert.Equal("google-sub-123", issueUseCase.LastRequest!.SubjectId);
|
||||||
|
Assert.Equal("google-id-token", issueUseCase.LastRequest.ExternalToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_WhenGoogleExchangeFails_ReturnsFailedSession()
|
||||||
|
{
|
||||||
|
var issueUseCase = new FakeIssueUseCase();
|
||||||
|
var exchangeService = new FakeExchangeService
|
||||||
|
{
|
||||||
|
NextResponse = new ExchangeResponse(
|
||||||
|
string.Empty,
|
||||||
|
"tenant-1",
|
||||||
|
IdentityAuthProvider.Google,
|
||||||
|
false)
|
||||||
|
};
|
||||||
|
var useCase = new StartIdentitySessionUseCase(
|
||||||
|
issueUseCase,
|
||||||
|
exchangeService,
|
||||||
|
new FakeSessionTokenCodec());
|
||||||
|
|
||||||
|
var response = await useCase.HandleAsync(new StartIdentitySessionRequest(
|
||||||
|
string.Empty,
|
||||||
|
"tenant-1",
|
||||||
|
IdentityAuthProvider.Google,
|
||||||
|
"invalid-token",
|
||||||
|
"corr-2"));
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, response.AccessToken);
|
||||||
|
Assert.Equal(string.Empty, response.RefreshToken);
|
||||||
|
Assert.Equal(0, response.ExpiresInSeconds);
|
||||||
|
Assert.Null(issueUseCase.LastRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeIssueUseCase : IIssueIdentityTokenUseCase
|
private sealed class FakeIssueUseCase : IIssueIdentityTokenUseCase
|
||||||
{
|
{
|
||||||
|
public IdentityIssueRequest? LastRequest { get; private set; }
|
||||||
|
|
||||||
public Task<IdentityIssueResponse> HandleAsync(IdentityIssueRequest request)
|
public Task<IdentityIssueResponse> HandleAsync(IdentityIssueRequest request)
|
||||||
{
|
{
|
||||||
|
LastRequest = request;
|
||||||
return Task.FromResult(new IdentityIssueResponse("token-abc", 1800));
|
return Task.FromResult(new IdentityIssueResponse("token-abc", 1800));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeExchangeService : IIdentityProviderTokenExchangeService
|
||||||
|
{
|
||||||
|
public ExchangeResponse NextResponse { get; set; } = new(
|
||||||
|
string.Empty,
|
||||||
|
string.Empty,
|
||||||
|
IdentityAuthProvider.Google,
|
||||||
|
false);
|
||||||
|
|
||||||
|
public Task<ExchangeResponse> ExchangeAsync(ExchangeRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(NextResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeSessionTokenCodec : IIdentitySessionTokenCodec
|
private sealed class FakeSessionTokenCodec : IIdentitySessionTokenCodec
|
||||||
{
|
{
|
||||||
public string Encode(IdentitySessionDescriptor descriptor)
|
public string Encode(IdentitySessionDescriptor descriptor)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user