Skip to content

feat(oauth): bind a flow to its redirect target and to a state - #278

Merged
aquie00t merged 1 commit into
mainfrom
feature/oauth-redirect-allowlist
Sep 5, 2026
Merged

feat(oauth): bind a flow to its redirect target and to a state#278
aquie00t merged 1 commit into
mainfrom
feature/oauth-redirect-allowlist

Conversation

@aquie00t

@aquie00t aquie00t commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #273. Builds on #277, which is already merged.

Summary

GET /oauth/{github,google}?redirect=… picks where the flow comes back to, from an exact-match allow-list: OAUTH_REDIRECT_ALLOWLIST for browser targets, OAUTH_NATIVE_REDIRECT_ALLOWLIST for the app's scheme. No prefix test, no host comparison, no "starts with our domain" — the target receives the exchange code, and with it a session, so every loose match is somebody else's login. A target that is not on a list is a 400, not a quiet fallback to somewhere safe. Absent means the web app's own page, which is exactly what the flow did before it could be asked.

The target is recorded against a random state (BeginOAuthUseCase, ten-minute TTL in the cache) and spent by the callback (ConsumeOAuthStateUseCase, single use). Every exit from a callback is a redirect to the target the flow was started for, because by then it is a browser being bounced back from a provider and there is nobody left to read a problem document.

The delivery channel rides the same decision. Whether the session ends up in a cookie or in the exchange response body is recorded on the exchange code when the flow starts, from the target it was started for, and read back in OAuthExchangeUseCase. Whoever calls the exchange endpoint chooses nothing. This is what lets the native channel removed from that endpoint in #277 come back without reopening what it closed: a browser holding a code it can see in its own URL still gets a cookie.

Root cause

The callback redirected to a hard-coded ${FRONTEND_URL}/oauth-success?code=…, so an Expo app had nowhere to be returned to and no way to finish a sign-in.

While adding the target it became clear the flow had no state parameter at all. That is a real hole and not one this PR introduced: an attacker could start a flow with their own account, hand a victim the resulting callback URL, and have the victim's browser complete it — leaving the victim signed in as the attacker and typing into an account somebody else can read. A callback that cannot be tied to a flow started here now completes nothing and is answered with ?error=invalid_state.

Tests

  • Unit, 1459 passing (13 new): resolveRedirectTarget against the default, an allow-listed web target, an allow-listed app target and a batch of near-misses that must all be refused — a look-alike host (tdn.example.evil.test), a traversal suffix, an appended ?next=, a scheme-relative //evil.example, a prefix-extended app scheme, and javascript:; plus BeginOAuthUseCase (state reaches the provider, target is recorded, nothing is minted for a refused target, the right provider is used) and ConsumeOAuthStateUseCase (target returned and state deleted, unknown state, absent state, unreadable value, fallback target).
  • E2E, rewritten tests/e2e/oauth/redirect.test.ts: both providers redirect with a state, states differ between flows, an unknown target is refused, an allow-listed app target is accepted, provider errors and missing codes land on the target the flow started for, an app flow's failure reaches the app, a callback with no state and one with an invented state complete nothing, and a state can be spent exactly once — the replay gets invalid_state.
  • OAUTH_NATIVE_REDIRECT_ALLOWLIST: tdn://oauth-success added to the CI env for those cases.
  • tsc -p tsconfig.build.json --noEmit, eslint, prettier --check clean.

Both new settings default to empty, which means "the web app's own page only" — the behaviour that exists today, so the service keeps working with no configuration. To let the app in, add its scheme to OAUTH_NATIVE_REDIRECT_ALLOWLIST on the service.

AI Asistan: Opus 5

The callback redirected to a fixed FRONTEND_URL page, so an app had nowhere
to be returned to. The target is now chosen per flow from an exact-match
allow-list - no prefix test, no host comparison, since the target receives
the exchange code and a loose match hands whoever owns the address a session.
An unknown target is refused rather than quietly redirected somewhere safe.

The target is recorded against a random state and spent by the callback,
which also closes something that was open before: with no state, an attacker
could start a flow with their own account, hand a victim the callback URL and
have their browser finish it, leaving them signed in as the attacker. A
callback that cannot be tied to a flow now completes nothing.

The channel the session is delivered on rides the same decision. It is
recorded on the exchange code when the flow starts and read back at the
exchange, so whoever calls that endpoint chooses nothing - a browser holding
a code it can see in its own URL cannot ask for a thirty-day refresh token.
This is what lets the native channel removed in #277 come back safely.
@aquie00t
aquie00t merged commit 9d1f9d4 into main Sep 5, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/oauth-redirect-allowlist branch September 5, 2026 22:31
github-actions Bot pushed a commit that referenced this pull request Sep 5, 2026
# [1.24.0](v1.23.0...v1.24.0) (2026-09-05)

### Features

* **oauth:** bind a flow to its redirect target and to a state ([#278](#278)) ([9d1f9d4](9d1f9d4)), closes [#277](#277)
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.24.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature] Let the OAuth callback return to the app's scheme

1 participant