feat(oauth): bind a flow to its redirect target and to a state - #278
Merged
Conversation
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.
|
🎉 This PR is included in version 1.24.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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.
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_ALLOWLISTfor browser targets,OAUTH_NATIVE_REDIRECT_ALLOWLISTfor 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
stateparameter 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
resolveRedirectTargetagainst 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, andjavascript:; plusBeginOAuthUseCase(state reaches the provider, target is recorded, nothing is minted for a refused target, the right provider is used) andConsumeOAuthStateUseCase(target returned and state deleted, unknown state, absent state, unreadable value, fallback target).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 getsinvalid_state.OAUTH_NATIVE_REDIRECT_ALLOWLIST: tdn://oauth-successadded to the CI env for those cases.tsc -p tsconfig.build.json --noEmit,eslint,prettier --checkclean.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_ALLOWLISTon the service.AI Asistan: Opus 5