Compare commits

...

2 Commits

Author SHA1 Message Date
José René White Enciso
37d2551565 feat(auth): add google oidc start and callback session flow 2026-03-11 12:07:07 -06:00
José René White Enciso
730abb95ec feat(thalos-bff): add google oidc start and callback flow 2026-03-11 10:13:21 -06:00
7 changed files with 571 additions and 3 deletions

View File

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

View File

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

View File

@ -11,7 +11,15 @@ docker build --build-arg NUGET_FEED_USERNAME=<gitea-login> --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=<google-client-id> \
-e ThalosBff__Oidc__Google__ClientSecret=<google-client-secret> \
-e ThalosBff__Oidc__Google__RedirectUri=https://auth.dream-views.com/api/identity/oidc/google/callback \
-e ThalosBff__Oidc__StateSigningSecret=<state-signing-secret> \
-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.

View File

@ -0,0 +1,296 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Thalos.Bff.Application.Security.Oidc;
/// <summary>
/// Builds and validates Google OIDC edge-flow artifacts (PKCE, state, nonce, and return-url policy).
/// </summary>
public sealed class GoogleOidcFlowService(
GoogleOidcOptions options,
string stateSigningSecret)
{
/// <summary>
/// Builds the OIDC authorization redirect and signed state payload for callback validation.
/// </summary>
/// <param name="requestedReturnUrl">Optional caller return URL.</param>
/// <param name="requestedTenantId">Optional tenant override.</param>
/// <returns>Start-flow context for edge redirect and callback validation.</returns>
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<string, string?>
{
["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);
}
/// <summary>
/// Validates callback state against signed payload.
/// </summary>
/// <param name="encodedState">Signed state payload from cookie.</param>
/// <param name="callbackState">State value received from callback query.</param>
/// <param name="payload">Validated payload.</param>
/// <returns><c>true</c> when callback state is valid and not expired.</returns>
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;
}
/// <summary>
/// Resolves default return URL for callback failure paths.
/// </summary>
/// <returns>Default return URL.</returns>
public string GetDefaultReturnUrl()
{
return options.DefaultReturnUrl;
}
/// <summary>
/// Gets token endpoint URI configured for Google code exchange.
/// </summary>
/// <returns>Token endpoint URI.</returns>
public string GetTokenEndpoint()
{
return options.TokenEndpoint;
}
/// <summary>
/// Builds token exchange form values for authorization-code callback.
/// </summary>
/// <param name="code">Authorization code from provider callback.</param>
/// <param name="codeVerifier">PKCE code verifier generated during start flow.</param>
/// <returns>Form values for token endpoint exchange.</returns>
public IReadOnlyDictionary<string, string> BuildTokenExchangeForm(string code, string codeVerifier)
{
ValidateConfiguration();
return new Dictionary<string, string>(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<GoogleOidcStatePayload>(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<string, string?> 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}";
}
}
/// <summary>
/// OIDC start context for redirect/callback flow.
/// </summary>
/// <param name="AuthorizationUrl">OIDC provider authorization URL.</param>
/// <param name="EncodedState">Signed state payload for callback validation.</param>
/// <param name="Payload">Original payload values.</param>
public sealed record GoogleOidcStartContext(
string AuthorizationUrl,
string EncodedState,
GoogleOidcStatePayload Payload);
/// <summary>
/// Signed OIDC callback state payload.
/// </summary>
/// <param name="State">Opaque callback state value.</param>
/// <param name="Nonce">OIDC nonce value.</param>
/// <param name="CodeVerifier">PKCE code verifier.</param>
/// <param name="ReturnUrl">Validated caller return URL.</param>
/// <param name="TenantId">Tenant value used for session start.</param>
/// <param name="ExpiresAtUtc">Payload expiration time in UTC.</param>
public sealed record GoogleOidcStatePayload(
string State,
string Nonce,
string CodeVerifier,
string ReturnUrl,
string TenantId,
DateTimeOffset ExpiresAtUtc);

View File

@ -0,0 +1,16 @@
namespace Thalos.Bff.Application.Security.Oidc;
/// <summary>
/// Runtime options for Google OIDC flow orchestration in the BFF edge.
/// </summary>
public sealed record GoogleOidcOptions(
string ClientId,
string ClientSecret,
string RedirectUri,
string AuthorizationEndpoint,
string TokenEndpoint,
string Scope,
string DefaultReturnUrl,
string DefaultTenantId,
IReadOnlyCollection<string> AllowedReturnHosts,
TimeSpan StateLifetime);

View File

@ -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<IThalosServiceClient, ThalosServiceGrpcClientAdapter>();
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<string[]>() ??
["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<IdentityRuntime.IdentityRuntimeClient>(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<GoogleOidcTokenExchangeResponse?> 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<GoogleOidcTokenExchangeResponse>(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);

View File

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