Repository navigation
Connect OAuth without App:PublicBaseUrl and across sites - #2117
Merged
Merged
Conversation
ppXD
force-pushed
the
fix/shred-retired-secrets-and-gate-personal-tokens
branch
from
October 18, 2026 02:45
86cf4b8 to
e3342c4
Compare
ppXD
changed the base branch from
fix/shred-retired-secrets-and-gate-personal-tokens
to
main
October 18, 2026 02:46
#2104 made OAuthFlowService inject PublicBaseUrlSetting, whose constructor throws outside Development when App:PublicBaseUrl is unset. Every OAuth start and every callback then answered 500 on such a host, whatever the return address; the shipped docker-compose API is one. An absolute return address also had to equal that one origin, so a page opened on an alias or on 127.0.0.1 could not start a connect. OAuth now reads a non-throwing set of return origins: App:PublicBaseUrl when set, every Cors:AllowedOrigins entry, OAuth:CallbackUrl's own origin and, in Development, the dev SPA origins the CORS policy adds. A path or no return address needs no origin; an absolute one outside the set is a 400 invalid_request naming both settings. Invitation and reset links still fail fast without the key. The binding cookie from #2104 is SameSite=Lax and host-only. A browser never keeps such a cookie from a cross-site fetch, and never sends it to another host, so with the SPA and API on different sites, or a connect started on another host than OAuth:CallbackUrl names, every callback arrived without it and was refused. A callback without a matching nonce now consumes nothing and redirects to the SPA's /oauth-callback with the code and state in the fragment, which reaches no server log. The signed-in SPA finishes it with POST /api/credentials/oauth/complete, which consumes the state only for its InitiatorUserId. A forwarded authorize link lands on the victim's session, which is not the starter's, so it still completes nothing. The cookie remains the fast path. The hand-off lands on the origin the connect named, and only the page that started the connect holds the session and the opener that finish it. Vite prints http://127.0.0.1:5180 while the callback names localhost:5180, so the SPA names its own origin first, and asks again with a path only when the API answers on the page's origin and refuses that with invalid_request: an alias the API does not list, or an older pod that matched App:PublicBaseUrl alone. A popup that lands where no opener on its origin can hear it stays open with the outcome instead of closing into a reported cancel. No schema change: a state row either build writes is finished by the other. The page retries the POST on a 404, the answer of a pod that predates the endpoint, which consumes nothing.
ppXD
force-pushed
the
fix/connect-oauth-without-a-public-base-url
branch
from
October 18, 2026 02:46
779546d to
142a062
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
App:PublicBaseUrl. Since Close in-team escalations and account lockouts #2104, OAuth readPublicBaseUrlSetting, which throws outside Development whenApp:PublicBaseUrlis unset. On such a host, the shippeddocker-compose.ymlAPI among them, everyPOST /api/credentials/oauth/initand every callback answered 500.OAuthReturnOriginsSetting(backend/src/CodeSpace.Core/Settings/OAuth/OAuthReturnOriginsSetting.cs), which never throws. Its origins areApp:PublicBaseUrlwhen set, everyCors:AllowedOriginsentry, the origin ofOAuth:CallbackUrl, and in Development the dev SPA origins.invalid_requestthat names both settings.SameSite=Laxand host-only, so it never arrives when the SPA and API are on different sites, or when the connect started on another host thanOAuth:CallbackUrlnames (for examplehttp://127.0.0.1:5180, the address Vite prints, againstlocalhost:5180)./oauth-callbackwith the code and state in the URL fragment, which no server or proxy log sees.POST /api/credentials/oauth/complete. The API accepts this only when the caller is the person who started the connect (InitiatorUserId).frontend/src/hooks/use-oauth-flow.tsnames the page's own origin first, so a handed-off connect comes back to the page that holds the session and the opener. When the API is on the page's origin and answers 400invalid_request(an alias the API does not list, or an older pod), it asks again with a path.frontend/src/routes/oauth-callback.tsxuses the code exactly once under StrictMode.Deploy notes
/completewith a 404 and uses up nothing. The page retries up to 3 times, 200 ms apart.App__PublicBaseUrlstill answers 500 until it is replaced.App__PublicBaseUrlis no longer needed for OAuth. It is still required outside Development for invitation and password-reset links.OAuth:CallbackUrlmust still name the real callback in production; the default is the dev SPA.App:PublicBaseUrl.OAuth:CallbackUrlhost, and the popup stays open there with the outcome. To avoid this, list the origin inCors:AllowedOrigins(seefrontend/.env.example).Test plan
OAuthReturnOriginsSettingTestscovers each source of the origin set and that it never throws in Production without the key.OAuthReturnUrlTestscovers several origins, the fragment hand-off and the completion page. Full unit suite: 13525/13525 (1 skipped).App:PublicBaseUrl(OAuthConnectWithoutPublicBaseUrlTests,OAuthFlowTests):OAuthConnectBindingE2ETests,OAuthConnectWithoutPublicBaseUrlE2ETests):/completereturns 401.invalid_requestwith no pending row left behind, unknown-state callback 400, invite 500 as before.use-oauth-flow.test.ts: own origin then path in same-origin mode, own origin only in direct mode; path retry on this build's and the previous build's exact refusal; no retry on any other refusal.oauth-callback.test.tsx: completes exactly once when the network answer is delayed (the test fails if the StrictMode guard is removed); clears the code from the address bar; shows the refusal and sign-in outcomes; retries a 404 from an older pod; stays open when there is no opener on the same origin.pnpm lint,tsc --noEmit -p tsconfig.app.jsonandpnpm buildare clean.pnpm dev, openhttp://127.0.0.1:5180and connect a GitLab OAuth provider. The popup should close and the credential should appear, without signing in again on localhost.