You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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.
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.
minor bump for the fixed group. This is 0.x, and the changeset marks the breaking parts, as earlier changesets do.
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.
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>
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.
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>
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));
- 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>
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>
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>
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>
…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>
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.
…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>
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.
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ownHost(Cloud Run routes on it). So the OpenID Connect handler, which buildsredirect_urifrom the request, sent providers to the API's internalrun.appaddress.This was found on csag-blueprint-web's staging, the package's only NuGet consumer (csag-blueprint-web#317):
redirect_uri_mismatchforhttps://…-api-….a.run.app/signin-google./signin-googleon the public host is nginx's SPA fallback (405).The app fixed this in its own code (
FrontendRoutedOidcCallbackExtensions). This moves that fix into the package, whereAddOidcAuthenticationlives, and corrects theFrontendBaseUrldocs that described sign-in as completing on the API origin.What
With
OAuth.FrontendBaseUrlset:redirect_uri= origin ofFrontendBaseUrl+ the scheme'sCallbackPath. It is set inOnRedirectToIdentityProvider, 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 (OpenIdConnectHandler10.0.x, lines ~463-478 and 1265), so the authorize and token requests send the same URI. Nothing is read from forwarded headers.AuthenticationProperties.RedirectUriwhen 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 toFrontendBaseUrlwith?error=external_auth_failed.PostConfigure. They wrap whatever the provider profile and any applicationConfigureinstalled, in any registration order. That step also refuses, naming the provider, a scheme whose events would bypass the wrapping (it setsEventsType, or its events object overridesRedirectToIdentityProviderorRemoteFailure), or whose finalCallbackPathan applicationConfiguremoved out of/api/. The Entra normalization refuses the same forTokenValidated.OAuthSettingsValidatorrejects 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-googlenor/api/%2e%2e%2fsignin-googlepasses.FrontendBaseUrlitself may no longer carry a query or fragment, since redirects append a path to it.Regardless of
FrontendBaseUrl:CallbackPathis now/api/auth/signin-oidc/{scheme}instead of/signin-oidc/{scheme}.OidcCallbackPaths(Resolve,ProxiedPrefix,IsUnderProxiedPrefix). Registration and validation used to compute the effective path separately; both now use it.IOidcProviderProfile.PostConfigure(a default no-op, so existing implementations keep compiling), and is composed ahead of whateverOnTokenValidatedhandler is there. This matters because the normalization is what the external callback's email-trust gate relies on: it drops anyemail_verifiedthat arrived in the token and stamps a trustworthy one. Before, an applicationConfigureon an Entra scheme could replace it (registered afterAddBlueprintServices) or be discarded by it (registered before), because the profile replacedOpenIdConnectEvents.Decisions worth a second opinion
FrontendBaseUrlnow 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-readableXSRF-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./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.RedirectUri, not straight to the frontend. That keeps the error contract in one place, the app's callback endpoint, and preserves the user'sreturnUrl. The accept-invitation page shows the error there.minorbump 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.FrontendBaseUrl;Configureregistered before or after the package running first, for Google and Entra;redirect_uribeing overruled;email_verified=truebeing dropped on an Entra scheme whose application handler was configured after the package;EventsTypeor an events subclass overriding a method the package relies on (withFrontendBaseUrl, and on Entra), or with its callback path moved by the application, failing with the provider named;//host,/\hostand absolute URLs;access_deniedform, 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/mainpasses.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