feat(auth): add google oidc token exchange service integration

This commit is contained in:
José René White Enciso 2026-03-11 12:07:03 -06:00
commit 85796336c6
12 changed files with 528 additions and 11 deletions

View File

@ -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

View File

@ -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)

View File

@ -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`.

View File

@ -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>();

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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);

View File

@ -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;
}
}
}

View File

@ -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";
}
}

View File

@ -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)