Skip to content

Route OpenID Connect callbacks through the frontend origin - #45

Open
neotrow wants to merge 11 commits into
mainfrom
feat/frontend-routed-oidc-callbacks
Open

neotrow wants to merge 11 commits into
mainfrom
feat/frontend-routed-oidc-callbacks

Conversation

@neotrow

@neotrow neotrow commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Why

In a blueprint deployment the browser only ever talks to the SPA's origin, which reverse-proxies /api/ to the API with the upstream's own Host (Cloud Run routes on it). So the OpenID Connect handler, which builds redirect_uri from the request, sent providers to the API's internal run.app address.

This was found on csag-blueprint-web's staging, the package's only NuGet consumer (csag-blueprint-web#317):

  • Google refused the authorize request with redirect_uri_mismatch for https://…-api-….a.run.app/signin-google.
  • A POST to that callback returns 404, because staging's API ingress is internal.
  • /signin-google on the public host is nginx's SPA fallback (405).
  • The challenge's correlation and nonce cookies are host-only on the public host, so no callback on another host could have validated anyway.

The app fixed this in its own code (FrontendRoutedOidcCallbackExtensions). This moves that fix into the package, where AddOidcAuthentication lives, and corrects the FrontendBaseUrl docs that described sign-in as completing on the API origin.

What

With OAuth.FrontendBaseUrl set:

  • redirect_uri = origin of FrontendBaseUrl + the scheme's CallbackPath. It is set in OnRedirectToIdentityProvider, after any existing handler has run, so nothing undoes it. The handler reads the message back afterwards and stores that value for the code exchange (OpenIdConnectHandler 10.0.x, lines ~463-478 and 1265), so the authorize and token requests send the same URI. Nothing is read from forwarded headers.
  • A failed round trip no longer throws into a 500. This covers a user who cancels at the provider, and a missing or expired correlation cookie. The failure is logged as a warning and the external cookie is cleared. The user is then redirected to the challenge's AuthenticationProperties.RedirectUri when it is a local path, which is the app's external callback; that callback answers a missing external login with its usual error redirect. Otherwise the user goes to FrontendBaseUrl with ?error=external_auth_failed.
  • Both handlers are installed in a per-scheme PostConfigure. They wrap whatever the provider profile and any application Configure installed, in any registration order. That step also refuses, naming the provider, a scheme whose events would bypass the wrapping (it sets EventsType, or its events object overrides RedirectToIdentityProvider or RemoteFailure), or whose final CallbackPath an application Configure moved out of /api/. The Entra normalization refuses the same for TokenValidated.
  • OAuthSettingsValidator rejects an enabled provider whose callback path isn't under /api/. The error names the provider. Such a path would land on the SPA. The check is on the path as a proxy would route it: empty, . or .. segments, percent-encoding, backslashes, a query or a fragment are rejected, so neither /api/../signin-google nor /api/%2e%2e%2fsignin-google passes. FrontendBaseUrl itself may no longer carry a query or fragment, since redirects append a path to it.

Regardless of FrontendBaseUrl:

  • The default CallbackPath is now /api/auth/signin-oidc/{scheme} instead of /signin-oidc/{scheme}.
  • New OidcCallbackPaths (Resolve, ProxiedPrefix, IsUnderProxiedPrefix). Registration and validation used to compute the effective path separately; both now use it.
  • The Entra claim normalization now runs after the application's configuration. It moves into a new per-scheme post-configure hook, IOidcProviderProfile.PostConfigure (a default no-op, so existing implementations keep compiling), and is composed ahead of whatever OnTokenValidated handler is there. This matters because the normalization is what the external callback's email-trust gate relies on: it drops any email_verified that arrived in the token and stamps a trustworthy one. Before, an application Configure on an Entra scheme could replace it (registered after AddBlueprintServices) or be discarded by it (registered before), because the profile replaced OpenIdConnectEvents.

Decisions worth a second opinion

  1. FrontendBaseUrl now also drives the callback, with no separate opt-in setting. It already names the origin the browser uses, and the package's CSRF design (a host-only, JS-readable XSRF-TOKEN) only works when the SPA and the API share that origin through a proxy. A split-origin deployment without a proxy was never workable end to end. The only consumer runs behind the proxy in every environment.
  2. The default callback path moved under /api/, rather than leaving the default to fail the new rule. It is breaking only for a provider that relied on the default and registered it with its IdP. The consumer sets explicit paths.
  3. Failure handling redirects to the challenge's RedirectUri, not straight to the frontend. That keeps the error contract in one place, the app's callback endpoint, and preserves the user's returnUrl. The accept-invitation page shows the error there.
  4. minor bump for the fixed group. This is 0.x, and the changeset marks the breaking parts, as earlier changesets do.

Verification

  • dotnet build Csag.Blueprint.slnx -c Release: 0 warnings, 0 errors.
  • All test projects pass: Web unit 493 (56 new), Application 132, Infrastructure 190, source generators 17, Testing 19, Tests.Shared 6, integration 143.
  • New tests cover:
    • the redirect URI for bare, trailing-slash and path-carrying base URLs;
    • no change without FrontendBaseUrl;
    • the Entra profile being routed like the others;
    • an application Configure registered before or after the package running first, for Google and Entra;
    • an application handler that sets its own redirect_uri being overruled;
    • the Entra profile keeping existing events and handing them normalized claims;
    • a forged email_verified=true being dropped on an Entra scheme whose application handler was configured after the package;
    • a scheme with EventsType or an events subclass overriding a method the package relies on (with FrontendBaseUrl, and on Entra), or with its callback path moved by the application, failing with the provider named;
    • an application handler that handles the failure itself not being overridden;
    • the external cookie being cleared;
    • the local-path check against //host, /\host and absolute URLs;
    • the validator rule with default, proxied, disabled, non-proxied and non-normalized paths.
  • The identical app-side implementation is deployed on csag-blueprint-web staging and production. A simulated cancel on staging posted Google's access_denied form, with the real challenge state and cookies, to the public /api/auth/signin-google. It was validated, cleared the external cookie, and landed on /auth/login?error=external_auth_failed.
  • pnpm exec changeset status --since=origin/main passes.

Consumer migration

csag-blueprint-web removes FrontendRoutedOidcCallbackExtensions, its registration, tests and startup check when it upgrades, and points its OIDC docs at the package. Its explicit /api/auth/signin-* callback paths already satisfy the new rule. Details are in the changeset.

🤖 Generated with Claude Code

With OAuth.FrontendBaseUrl set, each provider now returns to that origin
plus the scheme's callback path, which the frontend's /api/ proxy
forwards to the API together with the correlation and nonce cookies.
The handler's own redirect_uri named the proxy's upstream host.

- The redirect_uri is set in OnRedirectToIdentityProvider; a failed
  round trip clears the external cookie and redirects back to the
  application instead of throwing. Both are wrapped around existing
  handlers in a per-scheme post-configure step.
- OAuthSettingsValidator rejects an enabled provider whose callback path
  is not under /api/ when FrontendBaseUrl is set.
- The default callback path is /api/auth/signin-oidc/{scheme}.
- OidcCallbackPaths resolves the effective path for registration and
  validation alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 18, 2026 20:38
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    7 files      7 suites   2m 39s ⏱️
1 003 tests 1 003 ✅ 0 💤 0 ❌
1 005 runs  1 005 ✅ 0 💤 0 ❌

Results for commit 9decc6c.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved issues affect callback URI enforcement, event preservation, and callback-path validation.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Routes OpenID Connect callbacks through the configured frontend origin and improves failed authentication handling.

Changes:

  • Adds frontend-routed redirect and failure handling.
  • Moves default callback paths under /api/.
  • Adds shared path resolution, validation, tests, documentation, and release notes.
File summaries
File Reviewed changes
tests/Csag.Blueprint.Web.UnitTests/Options/Api/Security/OAuth/OidcCallbackPathsTests.cs Tests callback-path resolution.
tests/Csag.Blueprint.Web.UnitTests/Options/Api/Security/OAuth/OAuthSettingsValidatorTests.cs Tests callback-path validation.
tests/Csag.Blueprint.Web.UnitTests/Extensions/OidcAuthenticationExtensionsTests.cs Tests routing and failure behavior.
packages/Csag.Blueprint.Web/Options/Api/Security/OAuth/OidcProviderSettings.cs Updates callback-path documentation.
packages/Csag.Blueprint.Web/Options/Api/Security/OAuth/OidcCallbackPaths.cs Centralizes effective callback paths.
packages/Csag.Blueprint.Web/Options/Api/Security/OAuth/OAuthSettingsValidator.cs Validates proxied callback paths.
packages/Csag.Blueprint.Web/Options/Api/Security/OAuth/OAuthSettings.cs Updates frontend URL documentation.
packages/Csag.Blueprint.Web/Helpers/OAuthHelpers.cs Updates redirect documentation.
packages/Csag.Blueprint.Web/Extensions/OidcAuthenticationExtensions.cs Registers routed OIDC handlers.
packages/Csag.Blueprint.Web/Extensions/Oidc/FrontendRoutedCallbacks.cs Implements callback routing and failure handling.
.changeset/frontend-routed-oidc-callbacks.md Documents behavior and breaking changes.
Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/Csag.Blueprint.Web/Extensions/Oidc/FrontendRoutedCallbacks.cs Outdated
Comment on lines +57 to +59
// A post-configure step runs after every Configure, so it wraps whatever events the profile and
// the application installed, in whichever order they were registered.
services.PostConfigure<OpenIdConnectOptions>(scheme, options => FrontendRoutedCallbacks.Apply(options, frontendBaseUrl));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right: the Entra profile replaced OpenIdConnectEvents wholesale, so an application Configure registered before AddOidcAuthentication was dropped before the post-configure step could wrap it. Fixed in the latest commit at the source. EntraOidcProfile now composes its OnTokenValidated claim normalization with the handler already on the options, and runs the normalization first so that handler sees the trusted claims. With that, the either-order claim holds, and the changeset notes the Entra change. RemoteFailure_ApplicationHandlerConfiguredBeforeRegistration_StillRuns covers Google and Entra, and EntraOidcProfileTests.Configure_ExistingEvents_AreKeptAndSeeTheNormalizedClaimsAsync covers the profile on its own.

Comment on lines +89 to +91
return providers
.Where(kvp => kvp.Value.Enabled
&& !OidcCallbackPaths.Resolve(kvp.Key, kvp.Value).StartsWith(OidcCallbackPaths.ProxiedPrefix, StringComparison.Ordinal))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. The rule now uses OidcCallbackPaths.IsUnderProxiedPrefix, which counts a path only when it starts with /api/ and has no empty, . or .. segments (percent-encoded or not) and no backslash. /api/../signin-google, /api/%2e%2E/…, /api/./…, /api//…, a trailing slash and /api/auth\..\… are all rejected, and the validation message says so. Covered in OidcCallbackPathsTests and OAuthSettingsValidatorTests.

…alize callback paths

- The frontend redirect_uri is assigned after any existing
  OnRedirectToIdentityProvider handler, so none can undo it.
- The Entra profile composes its OnTokenValidated normalization with the
  handler already on the options instead of replacing the events, so an
  application's earlier Configure survives.
- A callback path only counts as under /api/ when it has no empty, dot
  or dot-dot segments (percent-encoded or not) and no backslash, since a
  proxy routes the normalized path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 18, 2026 21:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Encoded-separator path validation and EventsType event handling remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

packages/Csag.Blueprint.Web/Extensions/OidcAuthenticationExtensions.cs:59

  • This post-configure only wraps options.Events. If an application uses the supported OpenIdConnectOptions.EventsType, RemoteAuthenticationHandler resolves the event instance from DI at request time and bypasses this options event object, so neither the frontend redirect_uri override nor the remote-failure recovery runs. Such a scheme will still send the provider to the API/upstream host and can turn correlation failures into 500s; compose with the resolved event type or explicitly prevent this configuration.
                // A post-configure step runs after every Configure, so it wraps whatever events the profile and
                // the application installed, in whichever order they were registered.
                services.PostConfigure<OpenIdConnectOptions>(scheme, options => FrontendRoutedCallbacks.Apply(options, frontendBaseUrl));
  • Files reviewed: 13/13 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread packages/Csag.Blueprint.Web/Options/Api/Security/OAuth/OidcCallbackPaths.cs Outdated
- A callback path with percent-encoding no longer counts as under /api/:
  an encoded separator could hide a dot segment from the check that a
  decoding proxy would then resolve.
- With FrontendBaseUrl set, a scheme that sets EventsType now fails when
  its options are built. The handler would resolve those events from DI
  and never run the frontend routing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 18, 2026 21:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Callback-path validation must reject query strings and fragments before approval.

Review effort: Lite
Findings: 2 Medium severity

Open (2)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Reject query and fragment in proxied callback paths

packages/​Csag.Blueprint.Web/​Options/​Api/​Security/​OAuth/​OidcCallbackPaths.cs:47

IsUnderProxiedPrefix accepts paths such as /api/auth/signin-google?x=1 and /api/auth/signin-google#fragment. OpenIdConnectOptions.CallbackPath is matched against Request.Path, which excludes the query and fragment, so validation can approve a callback URI that the handler will never recognize. Reject ? and # here and cover those cases in the path tests.

The handler matches its callback path against the request path alone, so
a configured path carrying ? or # would pass validation and never match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 18, 2026 21:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Frontend URL query or fragment handling can produce malformed failure redirects and must be corrected before approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 Medium severity · 1 Low severity

Open (3)

Comment thread .changeset/frontend-routed-oidc-callbacks.md Outdated
Post-login and failure redirects append a path to the base URL, so a
query or fragment in it produced redirects like
https://app.example.com/?x=1/?error=external_auth_failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 18, 2026 22:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Validate the final OpenIdConnectOptions.CallbackPath so application configuration cannot bypass the /api/ routing guarantee.

Review effort: Lite
Findings: 2 Medium severity

Open (2)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Validate final OIDC callback path after application configuration

packages/​Csag.Blueprint.Web/​Extensions/​Oidc/​FrontendRoutedCallbacks.cs:45

The settings validator only checks OidcProviderSettings.CallbackPath, but this post-configure uses the final OpenIdConnectOptions.CallbackPath. An application Configure<OpenIdConnectOptions> registered after AddOidcAuthentication can change it to /signin-google (the same extension point this code deliberately supports for events); the package will then advertise https://app.example.com/signin-google even though the frontend proxy forwards only /api/, so the callback lands on the SPA and the sign-in fails. Validate the final callback path here (and reject or restore it) so the /api/ guarantee also covers application option configuration.

Settings validation sees OidcProviderSettings.CallbackPath, but an
application Configure can still change OpenIdConnectOptions.CallbackPath.
With FrontendBaseUrl set, the post-configure step now refuses a final
path outside /api/, naming the provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 18, 2026 22:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical Entra event-composition issue and two moderate callback and validation issues remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 2 Medium severity

Open (3)

Comment thread packages/Csag.Blueprint.Web/Extensions/Oidc/EntraOidcProfile.cs
…ration

The normalization drops any email_verified that arrived in the token and
stamps a trustworthy one, which the external callback's email-trust gate
relies on. An application Configure on an Entra scheme registered after
AddBlueprintServices could replace it. It now runs in a per-scheme
post-configure step (new IOidcProviderProfile.PostConfigure, a no-op on
the other profiles), composed with and ahead of any existing handler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 18, 2026 23:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +53 to +54
var tokenValidated = options.Events.OnTokenValidated;
options.Events.OnTokenValidated = context =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. EntraOidcProfile.PostConfigure now refuses a scheme that sets EventsType, whether or not FrontendBaseUrl is set. With EventsType the handler resolves its events from DI, so the normalization could not be installed. I rejected the combination rather than composing into the DI-resolved type: that type is an application class the package cannot wrap without replacing its registration. Covered by EntraOidcProfileTests.PostConfigure_EventsType_Throws.

Comment thread packages/Csag.Blueprint.Web/Extensions/Oidc/IOidcProviderProfile.cs Outdated
…igure hook

- An Entra scheme that sets EventsType would resolve its events from DI
  and skip the claim normalization, so the profile now refuses it whether
  or not FrontendBaseUrl is set.
- IOidcProviderProfile.PostConfigure is a default no-op, so a type that
  implements the interface directly keeps compiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 18, 2026 23:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +65 to +66
var tokenValidated = options.Events.OnTokenValidated;
options.Events.OnTokenValidated = context =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. A new OpenIdConnectEventsGuard checks whether the events object overrides the virtual event methods a path relies on. The handler dispatches through those methods, so an override would never reach the delegates. The Entra normalization refuses an override of TokenValidated, and the frontend routing refuses overrides of RedirectToIdentityProvider and RemoteFailure. Both also refuse EventsType, and the error names the provider and the overriding type. A subclass that only overrides unrelated events is still accepted. Tests: PostConfigure_EventsSubclassOverridingTokenValidated_Throws, PostConfigure_EventsSubclassOverridingOtherMethods_IsAccepted and AddOidcAuthentication_FrontendBaseUrlWithEventsOverridingRemoteFailure_FailsNamingTheScheme.

Comment on lines +60 to +62
throw new InvalidOperationException(
"An Entra OpenID Connect provider sets EventsType, which would bypass the claim normalization its " +
"email-trust policy depends on. Configure its events on OpenIdConnectOptions.Events instead.");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. IOidcProviderProfile.PostConfigure now receives the scheme; it is new in this PR, so changing the signature costs nothing. The Entra error reads "OpenID Connect provider 'entra' sets EventsType, which would bypass the claim normalization…", and PostConfigure_EventsType_Throws asserts the scheme name.

The handler dispatches through the virtual OpenIdConnectEvents methods,
so an events subclass overriding TokenValidated skipped the Entra claim
normalization, and one overriding RedirectToIdentityProvider or
RemoteFailure skipped the frontend routing. OpenIdConnectEventsGuard now
refuses those, and EventsType, with the provider named. The profile
post-configure hook takes the scheme so its errors can name it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 18, 2026 23:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uri reports them as a one-character query or fragment, which the rule
already refuses; these cases keep it that way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 19, 2026 00:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread packages/Csag.Blueprint.Web/Options/Api/Security/OAuth/OAuthSettingsValidator.cs Outdated
The redirect URI is built from the base URL's origin, so a hostless
absolute URL would produce an invalid one. Checking the host explicitly
does not depend on how the platform's Uri parser treats forms such as
https:/app.example.com.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 19, 2026 00:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The validator must reject FrontendBaseUrl values containing user-info credentials before constructing the redirect URI.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 3 High severity · 3 Medium severity

Open (6)
Resolved since last review (1)

}

var isHttp = parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps;
var isPlainOrigin = parsed.Host.Length > 0 && parsed.Query.Length == 0 && parsed.Fragment.Length == 0;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants