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
|
||||
- Adapt transport contracts
|
||||
- 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
|
||||
- Owning identity decision policies
|
||||
- Owning persistence decision concerns
|
||||
- Coupling use-cases directly to Vault/cloud provider SDKs
|
||||
|
||||
@ -12,10 +12,11 @@
|
||||
## 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.
|
||||
2. For `Google`, service exchanges and validates external token claims (`sub`, `aud`, `iss`) before issuing session tokens.
|
||||
3. Service issues access token through existing token orchestration.
|
||||
4. Service generates refresh token through provider-agnostic session token codec.
|
||||
5. BFF calls `RefreshIdentitySession` with refresh token.
|
||||
6. Service validates refresh token signature/expiry and reissues session tokens.
|
||||
|
||||
## Provider-Agnostic Secret Boundary
|
||||
|
||||
@ -24,8 +25,11 @@ 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.
|
||||
- OIDC provider material uses the same boundary (no provider SDK coupling in use-case logic).
|
||||
|
||||
## Configuration Keys
|
||||
|
||||
- `ThalosIdentity:Secrets:SessionSigning`
|
||||
- `ThalosIdentity:Secrets:Oidc:Google:ClientId`
|
||||
- `ThalosIdentity:Secrets:Oidc:Google:Issuer` (optional, defaults to `https://accounts.google.com`)
|
||||
- `ThalosIdentity:Secrets:Default` (fallback)
|
||||
|
||||
@ -11,7 +11,11 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --build-arg NUGET
|
||||
## Local Run
|
||||
|
||||
```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
|
||||
@ -23,3 +27,4 @@ docker run --rm -p 8080:8080 --name thalos-service agilewebs/thalos-service:dev
|
||||
## Runtime Notes
|
||||
|
||||
- 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.DAL.DependencyInjection;
|
||||
using Thalos.Service.Application.Adapters;
|
||||
using Thalos.Service.Application.Oidc;
|
||||
using Thalos.Service.Application.Ports;
|
||||
using Thalos.Service.Application.Secrets;
|
||||
using Thalos.Service.Application.Sessions;
|
||||
@ -33,6 +34,7 @@ public static class ThalosServiceRuntimeServiceCollectionExtensions
|
||||
|
||||
services.TryAddSingleton<IIdentityPolicyGrpcContractAdapter, IdentityPolicyGrpcContractAdapter>();
|
||||
services.TryAddSingleton<IIdentitySecretMaterialProvider, ConfigurationIdentitySecretMaterialProvider>();
|
||||
services.TryAddSingleton<IIdentityProviderTokenExchangeService, GoogleIdentityProviderTokenExchangeService>();
|
||||
services.TryAddSingleton<IIdentitySessionTokenCodec, HmacIdentitySessionTokenCodec>();
|
||||
|
||||
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";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetSecret(string secretKey)
|
||||
public bool TryGetSecret(string secretKey, out string secretValue)
|
||||
{
|
||||
var scopedKey = $"ThalosIdentity:Secrets:{secretKey}";
|
||||
var scopedSecret = configuration[scopedKey];
|
||||
if (!string.IsNullOrWhiteSpace(scopedSecret))
|
||||
{
|
||||
return scopedSecret;
|
||||
secretValue = scopedSecret;
|
||||
return true;
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
/// Resolves secret material for the requested secret key.
|
||||
/// </summary>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
using BuildingBlock.Identity.Contracts.Requests;
|
||||
using BuildingBlock.Identity.Contracts.Conventions;
|
||||
using Thalos.Service.Application.Oidc;
|
||||
using Thalos.Service.Application.Sessions;
|
||||
using Thalos.Service.Identity.Abstractions.Contracts;
|
||||
using IdentityIssueRequest = BuildingBlock.Identity.Contracts.Requests.IssueIdentityTokenRequest;
|
||||
@ -10,23 +12,62 @@ namespace Thalos.Service.Application.UseCases;
|
||||
/// </summary>
|
||||
public sealed class StartIdentitySessionUseCase(
|
||||
IIssueIdentityTokenUseCase issueIdentityTokenUseCase,
|
||||
IIdentityProviderTokenExchangeService tokenExchangeService,
|
||||
IIdentitySessionTokenCodec sessionTokenCodec)
|
||||
: IStartIdentitySessionUseCase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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(
|
||||
request.SubjectId,
|
||||
subjectId,
|
||||
request.TenantId,
|
||||
request.Provider,
|
||||
request.ExternalToken);
|
||||
|
||||
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 refreshDescriptor = new IdentitySessionDescriptor(
|
||||
request.SubjectId,
|
||||
subjectId,
|
||||
request.TenantId,
|
||||
request.Provider,
|
||||
DateTimeOffset.UtcNow.AddHours(8));
|
||||
@ -37,6 +78,17 @@ public sealed class StartIdentitySessionUseCase(
|
||||
issueResponse.Token,
|
||||
refreshToken,
|
||||
expiresInSeconds,
|
||||
subjectId,
|
||||
request.TenantId,
|
||||
request.Provider);
|
||||
}
|
||||
|
||||
private static StartIdentitySessionResponse Failed(StartIdentitySessionRequest request)
|
||||
{
|
||||
return new StartIdentitySessionResponse(
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
0,
|
||||
request.SubjectId,
|
||||
request.TenantId,
|
||||
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
|
||||
{
|
||||
public bool TryGetSecret(string secretKey, out string secretValue)
|
||||
{
|
||||
secretValue = "unit-test-secret";
|
||||
return true;
|
||||
}
|
||||
|
||||
public string GetSecret(string secretKey) => "unit-test-secret";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
using BuildingBlock.Identity.Contracts.Conventions;
|
||||
using BuildingBlock.Identity.Contracts.Requests;
|
||||
using BuildingBlock.Identity.Contracts.Responses;
|
||||
using Thalos.Service.Application.Oidc;
|
||||
using Thalos.Service.Application.Sessions;
|
||||
using Thalos.Service.Application.UseCases;
|
||||
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 IdentityIssueResponse = BuildingBlock.Identity.Contracts.Responses.IssueIdentityTokenResponse;
|
||||
|
||||
@ -14,7 +17,11 @@ public class StartIdentitySessionUseCaseTests
|
||||
[Fact]
|
||||
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));
|
||||
|
||||
@ -23,16 +30,98 @@ public class StartIdentitySessionUseCaseTests
|
||||
Assert.Equal("user-1", response.SubjectId);
|
||||
Assert.Equal("tenant-1", response.TenantId);
|
||||
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
|
||||
{
|
||||
public IdentityIssueRequest? LastRequest { get; private set; }
|
||||
|
||||
public Task<IdentityIssueResponse> HandleAsync(IdentityIssueRequest request)
|
||||
{
|
||||
LastRequest = request;
|
||||
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
|
||||
{
|
||||
public string Encode(IdentitySessionDescriptor descriptor)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user