Skip to content

fix(auth): keep the pending OAuth request across sign-in - #237

Open
Waiel5 wants to merge 2 commits into
choyiny:mainfrom
Waiel5:fix/oauth-login-continuation
Open

fix(auth): keep the pending OAuth request across sign-in#237
Waiel5 wants to merge 2 commits into
choyiny:mainfrom
Waiel5:fix/oauth-login-continuation

Conversation

@Waiel5

@Waiel5 Waiel5 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

When a client starts an OAuth authorization and the browser has no session, the provider redirects to /login with the pending request in the query string, signed and time-bounded by the server. Both sign-in handlers then run window.location.href = "/", discarding it. The user lands on the inbox and the client that started the flow waits for a callback that never arrives.

This is the path every third-party and native client takes on first connect, since such a client never has a browser session to begin with.

Two reasons it went unnoticed: every existing OAuth test signs in before calling authorize (runFlow in mcp-helpers.ts), so the logged-out branch has no coverage; and /login and /consent sit outside AuthGuard, so nothing else routes through it either.

Changes

  • Sign-in hands the pending parameters back to /api/auth/oauth2/authorize instead of navigating to /. Applies to both the passkey and the password path.
  • An ordinary sign-in with no pending request still goes to /, unchanged.

Release impact

  • Patch — bug fix or internal change, no new behaviour

  • Bug Fix

Test plan

  • yarn test worker/src/__tests__/oauth-logged-out-authorize.test.ts — 4 passed
  • npx playwright test e2e/specs/oauth-login-continuation.spec.ts — 2 passed
  • Verified the e2e test fails against the previous handler, with sign-in dropped the pending authorization and landed on /
  • prettier --check clean

Notes for reviewers

On the redirect being safe. It is not an open redirect. The destination is always this deployment's own /api/auth/oauth2/authorize, never a URL read out of the query; only the parameters are carried over, and the server re-verifies its own sig and exp before acting on them. The gate is the presence of both client_id and sig, so an arbitrary query string does not trigger it.

On the two test layers. The failure spans the browser and the server, so I covered both. The worker test pins the contract the page depends on — authorize redirects an unauthenticated request to the login page with signed, expiring parameters; replaying them with a session resumes rather than restarts; and no code is ever issued to an unauthenticated caller. Those pass on main too, which is the point: they document a previously untested branch rather than chase the bug. The e2e spec is the one that actually fails without the fix.

The e2e spec registers its client through RFC 7591 dynamic registration, exactly as an unknown client would, so it exercises the real path rather than a fixture.

Local environment note, in case it helps anyone else: running the e2e suite on macOS needed CHOKIDAR_USEPOLLING=1 — miniflare's file watcher hits EMFILE: too many open files against node_modules otherwise. CI is unaffected. Happy to add that to the README if you'd find it useful.

Checklist

  • Added or updated a migration (yarn db:generate) if the schema changed — n/a
  • Updated CHANGELOG.md under ## [Unreleased]
  • Updated docs — n/a

When a client begins an authorization and the browser has no session, the
provider redirects to `/login` with the pending request in the query string,
signed and time-bounded by the server. Both sign-in handlers then set
`window.location.href = "/"`, discarding it. The user landed on the inbox, and
the client that started the flow waited for a callback that never came.

This is not an edge case: it is the path every third-party and native client
takes on first connect, because such a client never has a browser session to
begin with. It went unnoticed because every existing OAuth test signs in
before calling authorize, so the logged-out branch was never exercised — and
because `/login` and `/consent` sit outside AuthGuard, nothing else routes
through it either.

Sign-in now hands the same parameters back to `/api/auth/oauth2/authorize`,
which resumes the request. This is not an open redirect: the destination is
always our own authorize endpoint rather than a URL taken from the query, and
the server re-verifies the signature and expiry before acting on it. An
ordinary sign-in with no pending request still goes to `/`.

Two layers of coverage, since the failure spans both:

- `oauth-logged-out-authorize.test.ts` pins the server contract the page
  relies on — authorize redirects an unauthenticated request to the login page
  with signed, expiring parameters, replaying them with a session resumes the
  flow, and no code is ever issued to an unauthenticated caller.
- `oauth-login-continuation.spec.ts` drives it in a browser, registering a
  client through dynamic registration exactly as an unknown client would. It
  fails against the previous handler with "sign-in dropped the pending
  authorization and landed on /", and carries a second case asserting an
  ordinary sign-in still reaches the inbox.
@choyiny

choyiny commented Aug 5, 2026

Copy link
Copy Markdown
Owner

I would appreciate reproduction steps before attempting a PR for it, thanks!

@Waiel5

Waiel5 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Repro, minimal:

  1. Deploy an instance and sign out of the dashboard entirely.
  2. From any OAuth client, hit GET /api/auth/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&code_challenge=...&code_challenge_method=S256.
  3. The provider sees no session and redirects to /login, carrying the request back as signed, time-bounded query parameters.
  4. Sign in.
  5. You land on /. The authorize request is gone, and the client waits on a callback that never arrives.

src/pages/LoginPage.tsx navigated to / unconditionally on success, in both the passkey and the password path, discarding the parameters the provider had just handed it. The fix hands them back to /api/auth/oauth2/authorize so the flow continues to consent.

This is the path every third-party client takes on first connect, since it always arrives logged out. The existing OAuth tests all authenticate before calling authorize, which is why the branch had no coverage — the PR adds an e2e that fails without the change.

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