fix(thalos-bff): harden oidc callback redirects

This commit is contained in:
José René White Enciso 2026-03-31 16:02:08 -06:00
parent 37d2551565
commit 93a841d3be
5 changed files with 86 additions and 6 deletions

View File

@ -27,6 +27,8 @@
- Session login and refresh call canonical thalos-service session gRPC operations.
- 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.
- Callback failures are redirected back to the central auth UX with stable `authError` and `correlationId` query values instead of returning a raw provider-facing JSON payload.
- The temporary OIDC state cookie is cleared on both callback success and callback failure paths.
- 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

@ -34,3 +34,5 @@ docker run --rm -p 8080:8080 \
- 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.
- Callback failures should land back on the central auth host (or another allowlisted return host) with `authError` and `correlationId` query values for UX recovery and support diagnostics.
- The OIDC state cookie is transient and should be cleared after any callback attempt, successful or failed.

View File

@ -98,6 +98,23 @@ public sealed class GoogleOidcFlowService(
return options.DefaultReturnUrl;
}
/// <summary>
/// Builds a safe callback-failure redirect back to the central auth experience or an allowed caller return URL.
/// </summary>
/// <param name="requestedReturnUrl">Optional caller return URL that must pass the same host allowlist policy as start flow.</param>
/// <param name="errorCode">Stable auth error code for UI handling.</param>
/// <param name="correlationId">Correlation identifier for support/troubleshooting.</param>
/// <returns>Safe redirect URL with error context query values.</returns>
public string BuildFailureRedirectUrl(string? requestedReturnUrl, string errorCode, string correlationId)
{
var redirectBaseUrl = ResolveReturnUrl(requestedReturnUrl);
return BuildUrlWithQueryString(redirectBaseUrl, new Dictionary<string, string?>
{
["authError"] = errorCode,
["correlationId"] = correlationId
});
}
/// <summary>
/// Gets token endpoint URI configured for Google code exchange.
/// </summary>

View File

@ -117,7 +117,12 @@ app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/callback", async (
var providerError = context.Request.Query["error"].ToString();
if (!string.IsNullOrWhiteSpace(providerError))
{
return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_provider_error", $"Provider rejected authentication: {providerError}.", correlationId);
DeleteOidcStateCookie(context, configuration);
return Results.Redirect(
oidcFlowService.BuildFailureRedirectUrl(
null,
"oidc_provider_error",
correlationId));
}
var callbackState = context.Request.Query["state"].ToString();
@ -126,12 +131,22 @@ app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/callback", async (
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);
DeleteOidcStateCookie(context, configuration);
return Results.Redirect(
oidcFlowService.BuildFailureRedirectUrl(
null,
"oidc_state_invalid",
correlationId));
}
if (string.IsNullOrWhiteSpace(code))
{
return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_code_missing", "OIDC callback code is missing.", correlationId);
DeleteOidcStateCookie(context, configuration);
return Results.Redirect(
oidcFlowService.BuildFailureRedirectUrl(
payload.ReturnUrl,
"oidc_code_missing",
correlationId));
}
var tokenResponse = await ExchangeGoogleCodeForTokenAsync(
@ -144,7 +159,12 @@ app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/callback", async (
if (tokenResponse is null || string.IsNullOrWhiteSpace(tokenResponse.IdToken))
{
return ErrorResult(StatusCodes.Status401Unauthorized, "oidc_exchange_failed", "Unable to exchange provider code.", correlationId);
DeleteOidcStateCookie(context, configuration);
return Results.Redirect(
oidcFlowService.BuildFailureRedirectUrl(
payload.ReturnUrl,
"oidc_exchange_failed",
correlationId));
}
var serviceRequest = new IssueIdentityTokenRequest(
@ -156,11 +176,16 @@ app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/callback", async (
if (string.IsNullOrWhiteSpace(sessionTokens.AccessToken) || string.IsNullOrWhiteSpace(sessionTokens.RefreshToken))
{
return ErrorResult(StatusCodes.Status401Unauthorized, "session_login_failed", "Unable to issue session.", correlationId);
DeleteOidcStateCookie(context, configuration);
return Results.Redirect(
oidcFlowService.BuildFailureRedirectUrl(
payload.ReturnUrl,
"session_login_failed",
correlationId));
}
WriteSessionCookies(context, sessionTokens, configuration);
context.Response.Cookies.Delete(OidcStateCookieName, CreateOidcStateCookieOptions(configuration));
DeleteOidcStateCookie(context, configuration);
return Results.Redirect(payload.ReturnUrl);
});
@ -421,6 +446,11 @@ void DeleteSessionCookies(HttpContext context, IConfiguration configuration)
context.Response.Cookies.Delete(SessionRefreshCookieName, options);
}
void DeleteOidcStateCookie(HttpContext context, IConfiguration configuration)
{
context.Response.Cookies.Delete(OidcStateCookieName, CreateOidcStateCookieOptions(configuration));
}
CookieOptions CreateCookieOptions(bool secure, int expiresInSeconds)
{
var domain = builder.Configuration["ThalosBff:SessionCookieDomain"];

View File

@ -52,6 +52,35 @@ public class GoogleOidcFlowServiceTests
Assert.False(ok);
}
[Fact]
public void BuildFailureRedirectUrl_WhenReturnUrlAllowed_PreservesCallerHostAndAddsErrorContext()
{
var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret");
var redirectUrl = service.BuildFailureRedirectUrl(
"https://furniture-display-demo.dream-views.com/dashboard",
"oidc_exchange_failed",
"corr-123");
Assert.StartsWith("https://furniture-display-demo.dream-views.com/dashboard", redirectUrl, StringComparison.Ordinal);
Assert.Contains("authError=oidc_exchange_failed", redirectUrl, StringComparison.Ordinal);
Assert.Contains("correlationId=corr-123", redirectUrl, StringComparison.Ordinal);
}
[Fact]
public void BuildFailureRedirectUrl_WhenReturnUrlNotAllowed_UsesDefaultReturnUrl()
{
var service = new GoogleOidcFlowService(CreateOptions(), "state-signing-secret");
var redirectUrl = service.BuildFailureRedirectUrl(
"https://malicious.example.com/redirect",
"oidc_state_invalid",
"corr-456");
Assert.StartsWith("https://auth.dream-views.com/", redirectUrl, StringComparison.Ordinal);
Assert.Contains("authError=oidc_state_invalid", redirectUrl, StringComparison.Ordinal);
}
private static GoogleOidcOptions CreateOptions()
{
return new GoogleOidcOptions(