diff --git a/docs/architecture/service-orchestration-boundary.md b/docs/architecture/service-orchestration-boundary.md index bc5b098..bf5a6c9 100644 --- a/docs/architecture/service-orchestration-boundary.md +++ b/docs/architecture/service-orchestration-boundary.md @@ -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 diff --git a/docs/identity/session-runtime-contract.md b/docs/identity/session-runtime-contract.md index 51b42a8..1a6eb9b 100644 --- a/docs/identity/session-runtime-contract.md +++ b/docs/identity/session-runtime-contract.md @@ -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) diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 4b2c190..9e18c9d 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -11,7 +11,11 @@ docker build --build-arg NUGET_FEED_USERNAME= --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= \ + -e ThalosIdentity__Secrets__Oidc__Google__ClientId= \ + -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`. diff --git a/src/Thalos.Service.Application/DependencyInjection/ThalosServiceRuntimeServiceCollectionExtensions.cs b/src/Thalos.Service.Application/DependencyInjection/ThalosServiceRuntimeServiceCollectionExtensions.cs index 7b29c70..bb541ba 100644 --- a/src/Thalos.Service.Application/DependencyInjection/ThalosServiceRuntimeServiceCollectionExtensions.cs +++ b/src/Thalos.Service.Application/DependencyInjection/ThalosServiceRuntimeServiceCollectionExtensions.cs @@ -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(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Thalos.Service.Application/Oidc/GoogleIdentityProviderTokenExchangeService.cs b/src/Thalos.Service.Application/Oidc/GoogleIdentityProviderTokenExchangeService.cs new file mode 100644 index 0000000..15f6d19 --- /dev/null +++ b/src/Thalos.Service.Application/Oidc/GoogleIdentityProviderTokenExchangeService.cs @@ -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; + +/// +/// Exchanges Google provider tokens into normalized identity claims for service-layer orchestration. +/// +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"; + + /// + public Task 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; + } +} diff --git a/src/Thalos.Service.Application/Oidc/IIdentityProviderTokenExchangeService.cs b/src/Thalos.Service.Application/Oidc/IIdentityProviderTokenExchangeService.cs new file mode 100644 index 0000000..b291e2f --- /dev/null +++ b/src/Thalos.Service.Application/Oidc/IIdentityProviderTokenExchangeService.cs @@ -0,0 +1,20 @@ +using BuildingBlock.Identity.Contracts.Requests; +using BuildingBlock.Identity.Contracts.Responses; + +namespace Thalos.Service.Application.Oidc; + +/// +/// Service boundary for exchanging external provider tokens into normalized identity claims. +/// +public interface IIdentityProviderTokenExchangeService +{ + /// + /// Exchanges an external provider token into a normalized identity response. + /// + /// Provider token exchange request. + /// Cancellation token. + /// Normalized exchange response for downstream use-cases. + Task ExchangeAsync( + ExchangeIdentityProviderTokenRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Thalos.Service.Application/Secrets/ConfigurationIdentitySecretMaterialProvider.cs b/src/Thalos.Service.Application/Secrets/ConfigurationIdentitySecretMaterialProvider.cs index c745029..8ae3dfd 100644 --- a/src/Thalos.Service.Application/Secrets/ConfigurationIdentitySecretMaterialProvider.cs +++ b/src/Thalos.Service.Application/Secrets/ConfigurationIdentitySecretMaterialProvider.cs @@ -11,16 +11,35 @@ public sealed class ConfigurationIdentitySecretMaterialProvider(IConfiguration c private const string FallbackSecret = "thalos-dev-secret"; /// - 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; + } + + /// + public string GetSecret(string secretKey) + { + if (TryGetSecret(secretKey, out var secretValue)) + { + return secretValue; + } + + return FallbackSecret; } } diff --git a/src/Thalos.Service.Application/Secrets/IIdentitySecretMaterialProvider.cs b/src/Thalos.Service.Application/Secrets/IIdentitySecretMaterialProvider.cs index 080cf29..d51ff0d 100644 --- a/src/Thalos.Service.Application/Secrets/IIdentitySecretMaterialProvider.cs +++ b/src/Thalos.Service.Application/Secrets/IIdentitySecretMaterialProvider.cs @@ -5,6 +5,14 @@ namespace Thalos.Service.Application.Secrets; /// public interface IIdentitySecretMaterialProvider { + /// + /// Attempts to resolve secret material for the requested secret key. + /// + /// Logical secret key. + /// Resolved secret value when available. + /// true when the secret is available; otherwise false. + bool TryGetSecret(string secretKey, out string secretValue); + /// /// Resolves secret material for the requested secret key. /// diff --git a/src/Thalos.Service.Application/UseCases/StartIdentitySessionUseCase.cs b/src/Thalos.Service.Application/UseCases/StartIdentitySessionUseCase.cs index e0b41f9..5248817 100644 --- a/src/Thalos.Service.Application/UseCases/StartIdentitySessionUseCase.cs +++ b/src/Thalos.Service.Application/UseCases/StartIdentitySessionUseCase.cs @@ -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; /// public sealed class StartIdentitySessionUseCase( IIssueIdentityTokenUseCase issueIdentityTokenUseCase, + IIdentityProviderTokenExchangeService tokenExchangeService, IIdentitySessionTokenCodec sessionTokenCodec) : IStartIdentitySessionUseCase { /// public async Task 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); diff --git a/tests/Thalos.Service.Application.UnitTests/GoogleIdentityProviderTokenExchangeServiceTests.cs b/tests/Thalos.Service.Application.UnitTests/GoogleIdentityProviderTokenExchangeServiceTests.cs new file mode 100644 index 0000000..a5e38ab --- /dev/null +++ b/tests/Thalos.Service.Application.UnitTests/GoogleIdentityProviderTokenExchangeServiceTests.cs @@ -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(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 + { + ["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(StringComparer.Ordinal) + { + ["Oidc:Google:ClientId"] = "google-client-1" + }); + var service = new GoogleIdentityProviderTokenExchangeService(provider); + var token = BuildUnsignedJwt(new Dictionary + { + ["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(StringComparer.Ordinal)); + var service = new GoogleIdentityProviderTokenExchangeService(provider); + var token = BuildUnsignedJwt(new Dictionary + { + ["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 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 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; + } + } +} diff --git a/tests/Thalos.Service.Application.UnitTests/HmacIdentitySessionTokenCodecTests.cs b/tests/Thalos.Service.Application.UnitTests/HmacIdentitySessionTokenCodecTests.cs index 1bd45f0..654b1be 100644 --- a/tests/Thalos.Service.Application.UnitTests/HmacIdentitySessionTokenCodecTests.cs +++ b/tests/Thalos.Service.Application.UnitTests/HmacIdentitySessionTokenCodecTests.cs @@ -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"; } } diff --git a/tests/Thalos.Service.Application.UnitTests/StartIdentitySessionUseCaseTests.cs b/tests/Thalos.Service.Application.UnitTests/StartIdentitySessionUseCaseTests.cs index f815c49..62c7287 100644 --- a/tests/Thalos.Service.Application.UnitTests/StartIdentitySessionUseCaseTests.cs +++ b/tests/Thalos.Service.Application.UnitTests/StartIdentitySessionUseCaseTests.cs @@ -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 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 ExchangeAsync(ExchangeRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(NextResponse); + } + } + private sealed class FakeSessionTokenCodec : IIdentitySessionTokenCodec { public string Encode(IdentitySessionDescriptor descriptor)