From 730abb95ec520a488310bda067f444201a8c6a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Wed, 11 Mar 2026 10:13:21 -0600 Subject: [PATCH] feat(thalos-bff): add google oidc start and callback flow --- docs/api/identity-edge-api.md | 6 +- docs/architecture/bff-identity-boundary.md | 3 + docs/runbooks/containerization.md | 12 +- .../Security/Oidc/GoogleOidcFlowService.cs | 296 ++++++++++++++++++ .../Security/Oidc/GoogleOidcOptions.cs | 16 + src/Thalos.Bff.Rest/Program.cs | 172 +++++++++- .../GoogleOidcFlowServiceTests.cs | 69 ++++ 7 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 src/Thalos.Bff.Application/Security/Oidc/GoogleOidcFlowService.cs create mode 100644 src/Thalos.Bff.Application/Security/Oidc/GoogleOidcOptions.cs create mode 100644 tests/Thalos.Bff.Application.UnitTests/GoogleOidcFlowServiceTests.cs diff --git a/docs/api/identity-edge-api.md b/docs/api/identity-edge-api.md index 822b38a..1a503e1 100644 --- a/docs/api/identity-edge-api.md +++ b/docs/api/identity-edge-api.md @@ -12,6 +12,9 @@ - `POST /api/identity/session/refresh` - `POST /api/identity/session/logout` - `GET /api/identity/session/me` +- Canonical OIDC endpoints: + - `GET /api/identity/oidc/google/start` + - `GET /api/identity/oidc/google/callback` - Compatibility endpoint: - `POST /api/identity/token` - `POST /api/identity/login` @@ -22,7 +25,8 @@ - Endpoint handlers perform edge validation and permission checks. - Session login and refresh call canonical thalos-service session gRPC operations. -- Session cookies are managed at the BFF edge (`thalos_session`, `thalos_refresh`) with env-driven secure flag. +- OIDC start/callback handlers generate and validate PKCE/state/nonce payloads. +- Session cookies are managed at the BFF edge (`thalos_session`, `thalos_refresh`) with env-driven secure/domain policy. - Token issuance and policy evaluation contracts remain available for compatibility calls. - Business orchestration remains in thalos-service. - Identity abstractions remain owned by Thalos repositories. diff --git a/docs/architecture/bff-identity-boundary.md b/docs/architecture/bff-identity-boundary.md index a778e74..c8d9e10 100644 --- a/docs/architecture/bff-identity-boundary.md +++ b/docs/architecture/bff-identity-boundary.md @@ -9,8 +9,11 @@ Keep thalos-bff as an edge adapter layer that consumes thalos-service and adopte - Correlation/tracing propagation - Single active edge protocol policy enforcement (`rest`) - Provider metadata propagation (`InternalJwt`, `AzureAd`, `Google`) +- OIDC edge flow orchestration (Google start/callback with PKCE/state/nonce) +- Session-cookie issuance policy (secure/domain settings for cross-subdomain web auth) ## Prohibited - Direct DAL access - Identity policy decision ownership - Identity persistence concerns +- Provider secret-manager coupling inside domain/service logic diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 2fcfd16..ef192c2 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -11,7 +11,15 @@ docker build --build-arg NUGET_FEED_USERNAME= --build-arg NUGET ## Local Run ```bash -docker run --rm -p 8080:8080 --name thalos-bff agilewebs/thalos-bff:dev +docker run --rm -p 8080:8080 \ + -e ThalosService__GrpcAddress=http://thalos-service:8081 \ + -e ThalosBff__Oidc__Google__ClientId= \ + -e ThalosBff__Oidc__Google__ClientSecret= \ + -e ThalosBff__Oidc__Google__RedirectUri=https://auth.dream-views.com/api/identity/oidc/google/callback \ + -e ThalosBff__Oidc__StateSigningSecret= \ + -e ThalosBff__SessionCookieSecure=true \ + -e ThalosBff__SessionCookieDomain=.dream-views.com \ + --name thalos-bff agilewebs/thalos-bff:dev ``` ## Health Probe @@ -24,3 +32,5 @@ docker run --rm -p 8080:8080 --name thalos-bff agilewebs/thalos-bff:dev - Requires `ThalosService__GrpcAddress` to target thalos-service in distributed runs. - gRPC client contract protobuf is vendored at `src/Thalos.Bff.Rest/Protos/identity_runtime.proto` to keep image builds repo-local. +- OIDC callback requires `ThalosBff__Oidc__Google__ClientId`, `ClientSecret`, `RedirectUri`, and `StateSigningSecret`. +- For cross-subdomain SPA auth, set `ThalosBff__SessionCookieDomain=.dream-views.com` and secure cookies in non-local environments. diff --git a/src/Thalos.Bff.Application/Security/Oidc/GoogleOidcFlowService.cs b/src/Thalos.Bff.Application/Security/Oidc/GoogleOidcFlowService.cs new file mode 100644 index 0000000..2f598c2 --- /dev/null +++ b/src/Thalos.Bff.Application/Security/Oidc/GoogleOidcFlowService.cs @@ -0,0 +1,296 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Thalos.Bff.Application.Security.Oidc; + +/// +/// Builds and validates Google OIDC edge-flow artifacts (PKCE, state, nonce, and return-url policy). +/// +public sealed class GoogleOidcFlowService( + GoogleOidcOptions options, + string stateSigningSecret) +{ + /// + /// Builds the OIDC authorization redirect and signed state payload for callback validation. + /// + /// Optional caller return URL. + /// Optional tenant override. + /// Start-flow context for edge redirect and callback validation. + public GoogleOidcStartContext BuildStartContext(string? requestedReturnUrl, string? requestedTenantId) + { + ValidateConfiguration(); + + var state = GenerateRandomToken(32); + var nonce = GenerateRandomToken(32); + var codeVerifier = GenerateRandomToken(64); + var codeChallenge = ToBase64Url(SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier))); + var returnUrl = ResolveReturnUrl(requestedReturnUrl); + var tenantId = string.IsNullOrWhiteSpace(requestedTenantId) ? options.DefaultTenantId : requestedTenantId.Trim(); + + var payload = new GoogleOidcStatePayload( + state, + nonce, + codeVerifier, + returnUrl, + tenantId, + DateTimeOffset.UtcNow.Add(options.StateLifetime)); + var encodedState = EncodeState(payload); + + var query = new Dictionary + { + ["client_id"] = options.ClientId, + ["redirect_uri"] = options.RedirectUri, + ["response_type"] = "code", + ["scope"] = string.IsNullOrWhiteSpace(options.Scope) ? "openid profile email" : options.Scope, + ["state"] = state, + ["nonce"] = nonce, + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = "S256", + ["access_type"] = "offline", + ["prompt"] = "consent" + }; + + var authorizationUrl = BuildUrlWithQueryString(options.AuthorizationEndpoint, query); + return new GoogleOidcStartContext(authorizationUrl, encodedState, payload); + } + + /// + /// Validates callback state against signed payload. + /// + /// Signed state payload from cookie. + /// State value received from callback query. + /// Validated payload. + /// true when callback state is valid and not expired. + public bool TryValidateCallbackState(string? encodedState, string? callbackState, out GoogleOidcStatePayload payload) + { + payload = default!; + if (string.IsNullOrWhiteSpace(encodedState) || string.IsNullOrWhiteSpace(callbackState)) + { + return false; + } + + if (!TryDecodeState(encodedState, out var decoded)) + { + return false; + } + + if (!string.Equals(decoded.State, callbackState, StringComparison.Ordinal)) + { + return false; + } + + if (decoded.ExpiresAtUtc <= DateTimeOffset.UtcNow) + { + return false; + } + + payload = decoded; + return true; + } + + /// + /// Resolves default return URL for callback failure paths. + /// + /// Default return URL. + public string GetDefaultReturnUrl() + { + return options.DefaultReturnUrl; + } + + /// + /// Gets token endpoint URI configured for Google code exchange. + /// + /// Token endpoint URI. + public string GetTokenEndpoint() + { + return options.TokenEndpoint; + } + + /// + /// Builds token exchange form values for authorization-code callback. + /// + /// Authorization code from provider callback. + /// PKCE code verifier generated during start flow. + /// Form values for token endpoint exchange. + public IReadOnlyDictionary BuildTokenExchangeForm(string code, string codeVerifier) + { + ValidateConfiguration(); + return new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = options.RedirectUri, + ["client_id"] = options.ClientId, + ["client_secret"] = options.ClientSecret, + ["code_verifier"] = codeVerifier + }; + } + + private void ValidateConfiguration() + { + if (string.IsNullOrWhiteSpace(options.ClientId) || + string.IsNullOrWhiteSpace(options.ClientSecret) || + string.IsNullOrWhiteSpace(options.RedirectUri) || + string.IsNullOrWhiteSpace(options.AuthorizationEndpoint) || + string.IsNullOrWhiteSpace(options.TokenEndpoint) || + string.IsNullOrWhiteSpace(options.DefaultReturnUrl) || + string.IsNullOrWhiteSpace(options.DefaultTenantId) || + string.IsNullOrWhiteSpace(stateSigningSecret)) + { + throw new InvalidOperationException("Google OIDC configuration is incomplete."); + } + } + + private string ResolveReturnUrl(string? requestedReturnUrl) + { + if (string.IsNullOrWhiteSpace(requestedReturnUrl)) + { + return options.DefaultReturnUrl; + } + + if (!Uri.TryCreate(requestedReturnUrl, UriKind.Absolute, out var absoluteUri)) + { + return options.DefaultReturnUrl; + } + + if (options.AllowedReturnHosts.Count == 0) + { + return options.DefaultReturnUrl; + } + + foreach (var host in options.AllowedReturnHosts) + { + if (string.Equals(host.Trim(), absoluteUri.Host, StringComparison.OrdinalIgnoreCase)) + { + return absoluteUri.ToString(); + } + } + + return options.DefaultReturnUrl; + } + + private string EncodeState(GoogleOidcStatePayload payload) + { + var payloadJson = JsonSerializer.Serialize(payload); + var payloadBytes = Encoding.UTF8.GetBytes(payloadJson); + var payloadToken = ToBase64Url(payloadBytes); + var signature = Sign(payloadToken); + return $"{payloadToken}.{signature}"; + } + + private bool TryDecodeState(string encodedState, out GoogleOidcStatePayload payload) + { + payload = default!; + + var segments = encodedState.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length != 2) + { + return false; + } + + if (!VerifySignature(segments[0], segments[1])) + { + return false; + } + + try + { + var payloadBytes = FromBase64Url(segments[0]); + var deserialized = JsonSerializer.Deserialize(payloadBytes); + if (deserialized is null) + { + return false; + } + + payload = deserialized; + return true; + } + catch (FormatException) + { + return false; + } + catch (JsonException) + { + return false; + } + } + + private string Sign(string payloadToken) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(stateSigningSecret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payloadToken)); + return ToBase64Url(hash); + } + + private bool VerifySignature(string payloadToken, string signatureToken) + { + var expected = Sign(payloadToken); + var left = Encoding.UTF8.GetBytes(expected); + var right = Encoding.UTF8.GetBytes(signatureToken); + return CryptographicOperations.FixedTimeEquals(left, right); + } + + private static string GenerateRandomToken(int bytesLength) + { + var bytes = RandomNumberGenerator.GetBytes(bytesLength); + return ToBase64Url(bytes); + } + + private static string ToBase64Url(byte[] bytes) + { + var encoded = Convert.ToBase64String(bytes); + return encoded.TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static byte[] FromBase64Url(string value) + { + var normalized = value.Replace('-', '+').Replace('_', '/'); + var remainder = normalized.Length % 4; + if (remainder > 0) + { + normalized = normalized.PadRight(normalized.Length + (4 - remainder), '='); + } + + return Convert.FromBase64String(normalized); + } + + private static string BuildUrlWithQueryString(string baseUrl, IReadOnlyDictionary parameters) + { + var encoded = parameters + .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Value)) + .Select(parameter => + $"{Uri.EscapeDataString(parameter.Key)}={Uri.EscapeDataString(parameter.Value!)}"); + var query = string.Join("&", encoded); + var separator = baseUrl.Contains('?') ? "&" : "?"; + return $"{baseUrl}{separator}{query}"; + } +} + +/// +/// OIDC start context for redirect/callback flow. +/// +/// OIDC provider authorization URL. +/// Signed state payload for callback validation. +/// Original payload values. +public sealed record GoogleOidcStartContext( + string AuthorizationUrl, + string EncodedState, + GoogleOidcStatePayload Payload); + +/// +/// Signed OIDC callback state payload. +/// +/// Opaque callback state value. +/// OIDC nonce value. +/// PKCE code verifier. +/// Validated caller return URL. +/// Tenant value used for session start. +/// Payload expiration time in UTC. +public sealed record GoogleOidcStatePayload( + string State, + string Nonce, + string CodeVerifier, + string ReturnUrl, + string TenantId, + DateTimeOffset ExpiresAtUtc); diff --git a/src/Thalos.Bff.Application/Security/Oidc/GoogleOidcOptions.cs b/src/Thalos.Bff.Application/Security/Oidc/GoogleOidcOptions.cs new file mode 100644 index 0000000..81b203a --- /dev/null +++ b/src/Thalos.Bff.Application/Security/Oidc/GoogleOidcOptions.cs @@ -0,0 +1,16 @@ +namespace Thalos.Bff.Application.Security.Oidc; + +/// +/// Runtime options for Google OIDC flow orchestration in the BFF edge. +/// +public sealed record GoogleOidcOptions( + string ClientId, + string ClientSecret, + string RedirectUri, + string AuthorizationEndpoint, + string TokenEndpoint, + string Scope, + string DefaultReturnUrl, + string DefaultTenantId, + IReadOnlyCollection AllowedReturnHosts, + TimeSpan StateLifetime); diff --git a/src/Thalos.Bff.Rest/Program.cs b/src/Thalos.Bff.Rest/Program.cs index e014496..a516dd0 100644 --- a/src/Thalos.Bff.Rest/Program.cs +++ b/src/Thalos.Bff.Rest/Program.cs @@ -2,11 +2,14 @@ using BuildingBlock.Identity.Contracts.Conventions; using BuildingBlock.Identity.Contracts.Requests; using Core.Blueprint.Common.DependencyInjection; using Microsoft.AspNetCore.Http; +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Primitives; using Thalos.Bff.Application.Adapters; using Thalos.Bff.Application.DependencyInjection; using Thalos.Bff.Application.Handlers; using Thalos.Bff.Application.Security; +using Thalos.Bff.Application.Security.Oidc; using Thalos.Bff.Contracts.Api; using Thalos.Bff.Rest.Adapters; using Thalos.Bff.Rest.Endpoints; @@ -15,6 +18,7 @@ using Thalos.Service.Grpc; const string CorrelationHeaderName = "x-correlation-id"; const string SessionAccessCookieName = "thalos_session"; const string SessionRefreshCookieName = "thalos_refresh"; +const string OidcStateCookieName = "thalos_oidc_state"; var builder = WebApplication.CreateBuilder(args); var edgeProtocol = builder.Configuration["ThalosBff:EdgeProtocol"] ?? "rest"; @@ -29,6 +33,35 @@ builder.Services.AddHealthChecks(); builder.Services.AddBlueprintRuntimeCore(); builder.Services.AddThalosBffApplicationRuntime(); builder.Services.AddScoped(); +builder.Services.AddHttpClient("GoogleOidcToken"); +builder.Services.AddSingleton(_ => +{ + var options = new GoogleOidcOptions( + builder.Configuration["ThalosBff:Oidc:Google:ClientId"] ?? string.Empty, + builder.Configuration["ThalosBff:Oidc:Google:ClientSecret"] ?? string.Empty, + builder.Configuration["ThalosBff:Oidc:Google:RedirectUri"] ?? + "https://auth.dream-views.com/api/identity/oidc/google/callback", + builder.Configuration["ThalosBff:Oidc:Google:AuthorizationEndpoint"] ?? + "https://accounts.google.com/o/oauth2/v2/auth", + builder.Configuration["ThalosBff:Oidc:Google:TokenEndpoint"] ?? + "https://oauth2.googleapis.com/token", + builder.Configuration["ThalosBff:Oidc:Google:Scope"] ?? "openid profile email", + builder.Configuration["ThalosBff:Oidc:DefaultReturnUrl"] ?? "https://auth.dream-views.com/", + builder.Configuration["ThalosBff:Oidc:DefaultTenantId"] ?? "demo-tenant", + (builder.Configuration.GetSection("ThalosBff:Oidc:AllowedReturnHosts").Get() ?? + ["auth.dream-views.com", "furniture-display-demo.dream-views.com", "furniture-admin-demo.dream-views.com", + "kitchen-ops-demo.dream-views.com", "waiter-floor-demo.dream-views.com", "customer-orders-demo.dream-views.com", + "pos-transactions-demo.dream-views.com", "restaurant-admin-demo.dream-views.com", "localhost"]) + .Where(host => !string.IsNullOrWhiteSpace(host)) + .ToArray(), + TimeSpan.FromMinutes(builder.Configuration.GetValue("ThalosBff:Oidc:StateLifetimeMinutes", 10))); + + var stateSigningSecret = builder.Configuration["ThalosBff:Oidc:StateSigningSecret"] ?? + builder.Configuration["ThalosBff:SessionStateSigningSecret"] ?? + string.Empty; + + return new GoogleOidcFlowService(options, stateSigningSecret); +}); builder.Services.AddGrpcClient(options => { var serviceAddress = builder.Configuration["ThalosService:GrpcAddress"] ?? "http://localhost:5251"; @@ -46,6 +79,91 @@ app.Use(async (context, next) => await next(); }); +app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/start", ( + HttpContext context, + GoogleOidcFlowService oidcFlowService, + IConfiguration configuration) => +{ + var correlationId = ResolveCorrelationId(context); + var returnUrl = context.Request.Query["returnUrl"].ToString(); + var tenantId = context.Request.Query["tenantId"].ToString(); + + GoogleOidcStartContext startContext; + try + { + startContext = oidcFlowService.BuildStartContext(returnUrl, tenantId); + } + catch (InvalidOperationException) + { + return ErrorResult(StatusCodes.Status500InternalServerError, "oidc_configuration_invalid", "OIDC configuration is incomplete.", correlationId); + } + + context.Response.Cookies.Append( + OidcStateCookieName, + startContext.EncodedState, + CreateOidcStateCookieOptions(configuration)); + + return Results.Redirect(startContext.AuthorizationUrl); +}); + +app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/callback", async ( + HttpContext context, + GoogleOidcFlowService oidcFlowService, + IHttpClientFactory httpClientFactory, + IThalosServiceClient serviceClient, + IConfiguration configuration) => +{ + var correlationId = ResolveCorrelationId(context); + var providerError = context.Request.Query["error"].ToString(); + if (!string.IsNullOrWhiteSpace(providerError)) + { + return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_provider_error", $"Provider rejected authentication: {providerError}.", correlationId); + } + + var callbackState = context.Request.Query["state"].ToString(); + var code = context.Request.Query["code"].ToString(); + + if (!context.Request.Cookies.TryGetValue(OidcStateCookieName, out var encodedState) || + !oidcFlowService.TryValidateCallbackState(encodedState, callbackState, out var payload)) + { + return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_state_invalid", "OIDC callback state is invalid.", correlationId); + } + + if (string.IsNullOrWhiteSpace(code)) + { + return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_code_missing", "OIDC callback code is missing.", correlationId); + } + + var tokenResponse = await ExchangeGoogleCodeForTokenAsync( + code, + payload.CodeVerifier, + oidcFlowService, + httpClientFactory, + correlationId, + context.RequestAborted); + + if (tokenResponse is null || string.IsNullOrWhiteSpace(tokenResponse.IdToken)) + { + return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_exchange_failed", "Unable to exchange provider code.", correlationId); + } + + var serviceRequest = new IssueIdentityTokenRequest( + string.Empty, + payload.TenantId, + IdentityAuthProvider.Google, + tokenResponse.IdToken); + var sessionTokens = await serviceClient.StartSessionAsync(serviceRequest, correlationId); + + if (string.IsNullOrWhiteSpace(sessionTokens.AccessToken) || string.IsNullOrWhiteSpace(sessionTokens.RefreshToken)) + { + return ErrorResult(StatusCodes.Status401Unauthorized, "session_login_failed", "Unable to issue session.", correlationId); + } + + WriteSessionCookies(context, sessionTokens, configuration); + context.Response.Cookies.Delete(OidcStateCookieName, CreateOidcStateCookieOptions(configuration)); + return Results.Redirect(payload.ReturnUrl); +}); + app.MapPost($"{EndpointConventions.ApiPrefix}/session/login", async ( SessionLoginApiRequest request, HttpContext context, @@ -303,18 +421,65 @@ void DeleteSessionCookies(HttpContext context, IConfiguration configuration) context.Response.Cookies.Delete(SessionRefreshCookieName, options); } -static CookieOptions CreateCookieOptions(bool secure, int expiresInSeconds) +CookieOptions CreateCookieOptions(bool secure, int expiresInSeconds) { + var domain = builder.Configuration["ThalosBff:SessionCookieDomain"]; return new CookieOptions { HttpOnly = true, Secure = secure, SameSite = SameSiteMode.Lax, Path = "/", + Domain = string.IsNullOrWhiteSpace(domain) ? null : domain, MaxAge = TimeSpan.FromSeconds(Math.Max(0, expiresInSeconds)) }; } +async Task ExchangeGoogleCodeForTokenAsync( + string code, + string codeVerifier, + GoogleOidcFlowService oidcFlowService, + IHttpClientFactory httpClientFactory, + string correlationId, + CancellationToken cancellationToken) +{ + var formValues = oidcFlowService.BuildTokenExchangeForm(code, codeVerifier); + using var request = new HttpRequestMessage( + HttpMethod.Post, + oidcFlowService.GetTokenEndpoint()) + { + Content = new FormUrlEncodedContent(formValues) + }; + request.Headers.TryAddWithoutValidation(CorrelationHeaderName, correlationId); + + using var response = await httpClientFactory + .CreateClient("GoogleOidcToken") + .SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); +} + +CookieOptions CreateOidcStateCookieOptions(IConfiguration configuration) +{ + var secureCookie = configuration.GetValue("ThalosBff:SessionCookieSecure", false); + var domain = configuration["ThalosBff:SessionCookieDomain"]; + return new CookieOptions + { + HttpOnly = true, + Secure = secureCookie, + SameSite = SameSiteMode.Lax, + Path = "/", + Domain = string.IsNullOrWhiteSpace(domain) ? null : domain, + MaxAge = TimeSpan.FromMinutes(configuration.GetValue("ThalosBff:Oidc:StateLifetimeMinutes", 10)) + }; +} + static bool TryParseSessionProfile(string accessToken, out SessionMeApiResponse response) { response = new SessionMeApiResponse(false, string.Empty, string.Empty, IdentityAuthProvider.InternalJwt); @@ -356,3 +521,8 @@ static bool TryParseSessionProfile(string accessToken, out SessionMeApiResponse response = new SessionMeApiResponse(true, subjectId, tenantId, provider); return true; } + +sealed record GoogleOidcTokenExchangeResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("id_token")] string IdToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn); diff --git a/tests/Thalos.Bff.Application.UnitTests/GoogleOidcFlowServiceTests.cs b/tests/Thalos.Bff.Application.UnitTests/GoogleOidcFlowServiceTests.cs new file mode 100644 index 0000000..2466f07 --- /dev/null +++ b/tests/Thalos.Bff.Application.UnitTests/GoogleOidcFlowServiceTests.cs @@ -0,0 +1,69 @@ +using Thalos.Bff.Application.Security.Oidc; + +namespace Thalos.Bff.Application.UnitTests; + +public class GoogleOidcFlowServiceTests +{ + [Fact] + public void BuildStartContext_WhenReturnUrlAllowed_PreservesCallerReturnUrl() + { + var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret"); + + var context = service.BuildStartContext("https://furniture-display-demo.dream-views.com/dashboard", "tenant-1"); + + Assert.Equal("https://furniture-display-demo.dream-views.com/dashboard", context.Payload.ReturnUrl); + Assert.Equal("tenant-1", context.Payload.TenantId); + Assert.Contains("code_challenge_method=S256", context.AuthorizationUrl); + Assert.Contains("state=", context.AuthorizationUrl); + Assert.Contains("nonce=", context.AuthorizationUrl); + } + + [Fact] + public void BuildStartContext_WhenReturnUrlNotAllowed_UsesDefaultReturnUrl() + { + var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret"); + + var context = service.BuildStartContext("https://malicious.example.com/redirect", "tenant-1"); + + Assert.Equal("https://auth.dream-views.com/", context.Payload.ReturnUrl); + } + + [Fact] + public void TryValidateCallbackState_WhenStateMatchesAndNotExpired_ReturnsTrue() + { + var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret"); + var start = service.BuildStartContext("https://auth.dream-views.com/", "tenant-1"); + + var ok = service.TryValidateCallbackState(start.EncodedState, start.Payload.State, out var payload); + + Assert.True(ok); + Assert.Equal(start.Payload.CodeVerifier, payload.CodeVerifier); + Assert.Equal(start.Payload.TenantId, payload.TenantId); + } + + [Fact] + public void TryValidateCallbackState_WhenStateTampered_ReturnsFalse() + { + var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret"); + var start = service.BuildStartContext("https://auth.dream-views.com/", "tenant-1"); + + var ok = service.TryValidateCallbackState(start.EncodedState, $"{start.Payload.State}-tamper", out _); + + Assert.False(ok); + } + + private static GoogleOidcOptions CreateOptions() + { + return new GoogleOidcOptions( + "client-id-1", + "client-secret-1", + "https://auth.dream-views.com/api/identity/oidc/google/callback", + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + "openid profile email", + "https://auth.dream-views.com/", + "demo-tenant", + ["auth.dream-views.com", "furniture-display-demo.dream-views.com"], + TimeSpan.FromMinutes(10)); + } +}