OAuth login: signed state cookie; bind accounts to the provider subject - #30
Merged
Merged
Conversation
…e provider subject Authlib keeps the OAuth state, nonce and PKCE verifier in request.session, which nothing in enlace_auth provided, so the flow could not complete as wired. The router now backs request.session with a signed, HttpOnly, SameSite=Lax cookie scoped to /auth (10-minute lifetime) whenever no SessionMiddleware is present, and clears it after a successful callback. A callback whose state was not issued to this browser is refused. OAuth identities are now bound to the provider's stable subject (sub; tid/oid for Entra ID; GitHub's id), recorded as oauth_links[provider]. A later login must present the same subject; an existing password account, or an account linked to or created by another provider, is refused instead of being opened by an email match. Accounts this provider created before links existed are linked on their next login. Tests drive Authlib's real redirect/state code against a stub provider (only the token exchange and userinfo calls are stubbed). Closes #28 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Every callback, including refused or failed ones, spends its provider's pending states (the cookie is rewritten on the error response too), so an error callback no longer leaves the state usable for a later code. - Two state cookies of the same name (cookie tossing) are refused. - tid/oid identify the subject only for Entra ID issuers; elsewhere `sub`. - The legacy re-link re-reads the record before writing, so it cannot revert a concurrent password change. - A password reset (admin, emailed link, CLI) unlinks external sign-ins: recovery must evict whoever linked one. - authlib >= 1.4 (1.3 never evicts old states, so the cookie could grow). - Residual limits documented in the module docstring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thorwhalen
force-pushed
the
security/28-oauth-state-and-links
branch
from
September 22, 2026 16:33
b2f7699 to
99abe0d
Compare
Member
Author
|
Dependents against this branch (rebased on 0.1.26): i2mint/enlace 308 passed; i2mint/enlace_docker 58 passed; tw_platform 576 passed, 4 failed (the pre-existing root-only |
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 #28
What
state, the nonce and the PKCE verifier inrequest.session, which needs aSessionMiddlewarethat enlace_auth never installed. Login could not complete as wired, and the existing tests mock both Authlib calls, so they never noticed.make_oauth_routernow backsrequest.sessionwith a signed cookie (enlace_oauth_state, itsdangerous with the platform signing key and saltoauth-state,Path=/auth, HttpOnly, SameSite=Lax, Secure whensecure_cookies,Max-Age=600), but only when no real session is present. The cookie is cleared after a successful callback. A callback whosestatewas not issued to this browser, or was already used, is refused with 401 (login CSRF). Every callback spends its provider's pending states, whether it succeeds or fails.sub, ortid/oidfor Entra ID, or GitHub's numericid. It is stored asoauth_links[provider]on the user record. Rules (_login_refusal):New accounts are created with the link.
Not done (left for later)
Impact
No login providers are configured on the live platform, so nothing deployed changes. No fleet dependent calls
make_oauth_router. The two new parameters (state_cookie_name,state_max_age) are keyword-only and have defaults.Tests
tests/test_oauth_state_and_links.pyuses a real Authlib registry for a stub provider. Onlyfetch_access_tokenanduserinfoare stubbed. It covers the round trip, a callback from another browser, a forged state, a single-use state, a forged unsigned cookie, and the linking rules. I checked with mutations that the tests catch the bugs: disabling the cookie session fails 9 of 10 tests, and disabling the link check fails 5. Full suite: 395 passed; ruff clean.Independent refute-review (security): findings and fixes
?error=callback left it usable for a later?code=. Every callback now spends its provider's states and rewrites the cookie, including on error responses.enlace_oauth_stateis planted atPath=/and Starlette keeps the last one. Two cookies of that name are now refused. Residual, documented: a same-origin script can still plant a state for a browser that has none of its own. The state cannot be bound to the browser without a browser-held secret, and same-origin scripts are already a known platform-wide limit (SECURITY L11). A__Host-prefix would forcePath=/, which sends the cookie to every proxied app.oauth_links. Before, recovery left an attacker's linked identity in place. A self-service change that knows the old password keeps the links.tid/oididentify the subject only for Entra issuers (login.microsoftonline.com,sts.windows.net). The legacy re-link now re-reads the record before writing.authlib>=1.4, because 1.3 never evicts states and the cookie could grow.Path=/authassumes no root path. A legacy account that later got a password is now refused for OAuth. The subject can flip if an Entra tenant changes which claims the ID token carries.🤖 Generated with Claude Code