Compare commits

...

2 Commits

Author SHA1 Message Date
José René White Enciso
93a841d3be fix(thalos-bff): harden oidc callback redirects 2026-03-31 16:02:08 -06:00
José René White Enciso
37d2551565 feat(auth): add google oidc start and callback session flow 2026-03-11 12:07:07 -06:00
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. - Session login and refresh call canonical thalos-service session gRPC operations.
- OIDC start/callback handlers generate and validate PKCE/state/nonce payloads. - 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. - 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. - Token issuance and policy evaluation contracts remain available for compatibility calls.
- Business orchestration remains in thalos-service. - Business orchestration remains in thalos-service.
- Identity abstractions remain owned by Thalos repositories. - 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. - 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`. - 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. - 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; 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> /// <summary>
/// Gets token endpoint URI configured for Google code exchange. /// Gets token endpoint URI configured for Google code exchange.
/// </summary> /// </summary>

View File

@ -117,7 +117,12 @@ app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/callback", async (
var providerError = context.Request.Query["error"].ToString(); var providerError = context.Request.Query["error"].ToString();
if (!string.IsNullOrWhiteSpace(providerError)) 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(); 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) || if (!context.Request.Cookies.TryGetValue(OidcStateCookieName, out var encodedState) ||
!oidcFlowService.TryValidateCallbackState(encodedState, callbackState, out var payload)) !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)) 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( var tokenResponse = await ExchangeGoogleCodeForTokenAsync(
@ -144,7 +159,12 @@ app.MapGet($"{EndpointConventions.ApiPrefix}/oidc/google/callback", async (
if (tokenResponse is null || string.IsNullOrWhiteSpace(tokenResponse.IdToken)) 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( 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)) 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); WriteSessionCookies(context, sessionTokens, configuration);
context.Response.Cookies.Delete(OidcStateCookieName, CreateOidcStateCookieOptions(configuration)); DeleteOidcStateCookie(context, configuration);
return Results.Redirect(payload.ReturnUrl); return Results.Redirect(payload.ReturnUrl);
}); });
@ -421,6 +446,11 @@ void DeleteSessionCookies(HttpContext context, IConfiguration configuration)
context.Response.Cookies.Delete(SessionRefreshCookieName, options); 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) CookieOptions CreateCookieOptions(bool secure, int expiresInSeconds)
{ {
var domain = builder.Configuration["ThalosBff:SessionCookieDomain"]; var domain = builder.Configuration["ThalosBff:SessionCookieDomain"];

View File

@ -52,6 +52,35 @@ public class GoogleOidcFlowServiceTests
Assert.False(ok); 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() private static GoogleOidcOptions CreateOptions()
{ {
return new GoogleOidcOptions( return new GoogleOidcOptions(