Skip to content

Connect OAuth without App:PublicBaseUrl and across sites - #2117

Merged
ppXD merged 1 commit into
mainfrom
fix/connect-oauth-without-a-public-base-url
Oct 18, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/connect-oauth-without-a-public-base-url

Conversation

@ppXD

@ppXD ppXD commented Oct 13, 2026

Copy link
Copy Markdown
Owner

Summary

  • No more 500s without App:PublicBaseUrl. Since Close in-team escalations and account lockouts #2104, OAuth read PublicBaseUrlSetting, which throws outside Development when App:PublicBaseUrl is unset. On such a host, the shipped docker-compose.yml API among them, every POST /api/credentials/oauth/init and every callback answered 500.
    • OAuth now reads OAuthReturnOriginsSetting (backend/src/CodeSpace.Core/Settings/OAuth/OAuthReturnOriginsSetting.cs), which never throws. Its origins are App:PublicBaseUrl when set, every Cors:AllowedOrigins entry, the origin of OAuth:CallbackUrl, and in Development the dev SPA origins.
    • A path or null return address needs no origin.
    • Any other absolute return address is a 400 invalid_request that names both settings.
    • Invitation and password-reset links still fail fast without the key.
  • Connects now finish without the binding cookie. The cookie is SameSite=Lax and host-only, so it never arrives when the SPA and API are on different sites, or when the connect started on another host than OAuth:CallbackUrl names (for example http://127.0.0.1:5180, the address Vite prints, against localhost:5180).
    • The callback then uses up nothing. It redirects (302) to the SPA's /oauth-callback with the code and state in the URL fragment, which no server or proxy log sees.
    • The signed-in SPA posts them to POST /api/credentials/oauth/complete. The API accepts this only when the caller is the person who started the connect (InitiatorUserId).
    • A forwarded authorize link lands in the victim's session, which is not the starter's, so it still completes nothing.
    • The cookie stays the fast path.
  • SPA changes.
    • frontend/src/hooks/use-oauth-flow.ts names 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 400 invalid_request (an alias the API does not list, or an older pod), it asks again with a path.
    • frontend/src/routes/oauth-callback.tsx uses the code exactly once under StrictMode.
    • The popup stays open with the outcome when no opener on its origin can hear it, instead of closing and leaving the opener to report "cancelled".

Deploy notes

  • No migration. A rolling deploy in any order, and a rollback, keep working:
    • A pending-state row written by either build is finished by the other, through the cookie or by its starter's hand-off. This includes rows from before Close in-team escalations and account lockouts #2104, which have no nonce hash.
    • An old pod answers /complete with a 404 and uses up nothing. The page retries up to 3 times, 200 ms apart.
  • New SPA with an old API: the SPA sends the same request the old SPA sent (the absolute page origin), plus one path retry if the old pod answers 400. An old pod outside Development without App__PublicBaseUrl still answers 500 until it is replaced.
  • Old SPA with the new API: the cookie path is unchanged. A handed-off connect shows "Authorization failed", because the old page ignores the fragment; that connect also failed before this change. Deploy the SPA right after the API.
  • App__PublicBaseUrl is no longer needed for OAuth. It is still required outside Development for invitation and password-reset links. OAuth:CallbackUrl must still name the real callback in production; the default is the dev SPA.
  • Behaviour changes for the release notes:
    • An absolute OAuth return address may now be any configured origin, not only App:PublicBaseUrl.
    • On an origin the API does not list, a connect started through a same-origin proxy finishes on the OAuth:CallbackUrl host, and the popup stays open there with the outcome. To avoid this, list the origin in Cors:AllowedOrigins (see frontend/.env.example).

Test plan

  • Unit. OAuthReturnOriginsSettingTests covers each source of the origin set and that it never throws in Production without the key. OAuthReturnUrlTests covers several origins, the fragment hand-off and the completion page. Full unit suite: 13525/13525 (1 skipped).
  • Integration, Production config without App:PublicBaseUrl (OAuthConnectWithoutPublicBaseUrlTests, OAuthFlowTests):
    • Path and null return addresses start and finish.
    • An absolute address on a CORS origin returns 200 and hands off there.
    • Any other absolute address is a 400 naming both settings.
    • An unknown-state callback is refused rather than failing with a 500.
    • An invitation still fails fast.
    • State rows from older builds, with and without a nonce hash, are finished by their starter.
    • The previous build's exact callback lookup matches a row this build wrote.
  • HTTP E2E (OAuthConnectBindingE2ETests, OAuthConnectWithoutPublicBaseUrlE2ETests):
    • A cross-site connect with no cookie is finished by its starter's SPA.
    • A different signed-in Owner posting the forwarded code and state is refused, and nothing is used up.
    • Without a session, /complete returns 401.
    • The same-site cookie path is unchanged over http and https.
    • Production without the key: path 200, CORS absolute 200, any other absolute 400 invalid_request with no pending row left behind, unknown-state callback 400, invite 500 as before.
  • Vitest.
    • 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.
    • Full suite 2515/2515. pnpm lint, tsc --noEmit -p tsconfig.app.json and pnpm build are clean.
  • Manual. Run pnpm dev, open http://127.0.0.1:5180 and connect a GitLab OAuth provider. The popup should close and the credential should appear, without signing in again on localhost.

@ppXD
ppXD force-pushed the fix/shred-retired-secrets-and-gate-personal-tokens branch from 86cf4b8 to e3342c4 Compare October 18, 2026 02:45
@ppXD
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
ppXD force-pushed the fix/connect-oauth-without-a-public-base-url branch from 779546d to 142a062 Compare October 18, 2026 02:46
@ppXD
ppXD merged commit 3eaec6f into main Oct 18, 2026
8 checks passed
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.

1 participant