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.
|
- 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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"];
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user