fix(thalos-bff): harden oidc callback redirects
This commit is contained in:
parent
37d2551565
commit
93a841d3be
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"];
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user