fix(account): validate OAuth2 state on the redirect callback by Meldiron · Pull Request #14012 · appwrite/appwrite · GitHub
Skip to content

fix(account): validate OAuth2 state on the redirect callback - #14012

Merged
Meldiron merged 5 commits into
mainfrom
fix/oauth2-state-csrf
Sep 30, 2026
Merged

Meldiron merged 5 commits into
mainfrom
fix/oauth2-state-csrf

Conversation

@Meldiron

@Meldiron Meldiron commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

The state handed to an OAuth2 provider now carries a per-flow nonce that is also kept in a short-lived, host-only a_oauth2_<projectId> cookie (HttpOnly, SameSite=Lax, Secure on HTTPS, one hour). The redirect callback checks the two against each other before exchanging the code, as RFC 6749 §10.12 recommends, and answers with the new USER_OAUTH2_STATE_INVALID (401) when they do not match. createOAuth2Session and createOAuth2Token share the callback, so both set it.

A browser can have several flows open at once (two tabs, the session and the token flow), so the cookie keeps the newest five nonces. The callback does not rewrite the cookie: two callbacks finishing together would race on it, and replaying an authorization code is already refused by the provider (RFC 6749 §4.1.2). The cookie expires on its own.

Flows already in flight during a rollout carry a state without a nonce and fail once with a clear "start the sign-in again" error.

Test Plan

  • The E2E walkers in AccountCustomClientTest and AvatarsCustomClientTest forward the cookie on the Appwrite hops the way a browser would; the provider hop never sees it.
  • New testCreateOAuth2SessionRejectsCallbackFromAnotherBrowser: a callback URL from a flow one browser started is completed by a different, signed-in browser → failure redirect with user_oauth2_state_invalid, no session cookie set, the existing session intact and no identity added; a hand-written state without a nonce is rejected the same way; the browser that started the flow then completes the same URL and lands on its own account; and a client with a cookie jar following the redirects completes a flow with no hand-carried header.
  • New testCreateOAuth2SessionKeepsConcurrentFlows: two flows started in one browser both complete, in either order.
  • composer lint, composer refactor:check and PHPStan on the changed files pass locally; the E2E suites run in CI.

Related PRs and Issues

  • appwrite-labs/cloud#6206 forwards the cookie through cloud's E2E hops and lands with the server-ce bump.

Checklist

  • Have you read the Contributing Guidelines on issues?
  • If the PR includes a change to an API's metadata (desc, label, params, etc.), does it also include updated API specs and example docs? — no route metadata changes; only a new error type.

🤖 Generated with Claude Code

The state handed to an OAuth2 provider now carries a per-flow nonce that
is also kept in a short-lived, host-only cookie. The redirect callback
checks the two against each other before exchanging the code, as
RFC 6749 §10.12 asks, and answers USER_OAUTH2_STATE_INVALID when they do
not match. The cookie is expired once the callback has used it.

The session and the token flow share the callback, so both set it. The
E2E walkers forward the cookie the way a browser would, and a new test
covers a callback completed from a browser that did not start the flow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Meldiron
Meldiron marked this pull request as ready for review September 30, 2026 10:12
@hansi-codes

hansi-codes Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

Comment thread app/controllers/api/account.php Outdated
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix/oauth2-state-csrf (after).

Metric Before After Change
🚀 Requests/sec 201.09 211.01 ⚪ +4.9%
⏱️ Latency P50 85.46 ms 81.86 ms ⚪ -4.2%
⏱️ Latency P95 195.96 ms 195.06 ms ⚪ -0.5%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 81.86 195.06 13,338 211.01 -0.9
Account 158.27 282.21 702 11.64 -13.49
TablesDB 78.02 139.17 7,254 117.85 -21.88
Storage 78.5 174.82 3,510 58.38 +1.25
Functions 115.81 242.69 1,872 31.78 +23.95

Top API waits (after)

API request Max wait (ms)
account.name.update 452.97
storage.buckets.create 416.71
account.prefs.update 400.77
functions.variables.update 347.61
functions.variables.create 323.31

@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Comment thread app/controllers/api/account.php Outdated
Comment thread tests/e2e/Services/Account/AccountCustomClientTest.php
Comment thread tests/e2e/Services/Account/AccountCustomClientTest.php Outdated
A second flow started in the same browser, in another tab or through the
token route, replaced the first one's nonce and the first callback then
failed. The cookie now keeps the newest few nonces and the callback
consumes only the one it matched.

The E2E walkers send the cookie to the Appwrite hops only, the new test
no longer pins the intermediate routes, and a client with a cookie jar
shows the callback host receiving the cookie by itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

…backs

The two failure-redirect tests start a real flow and then drive the
callback with a provider error from a fresh client, so the nonce cookie
has to ride along like it would in a browser. The custom failure URL test
now also checks that the error it lands with is the provider's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread app/controllers/api/account.php Outdated
Comment thread tests/e2e/Services/Account/AccountCustomClientTest.php Outdated
Meldiron and others added 2 commits September 30, 2026 12:44
Rewriting the cookie to drop the nonce a callback matched raced with any
other callback finishing in the same browser: whichever response landed
last put the other's nonce back. A browser cookie cannot make a nonce
single-use, and the provider already refuses a replayed authorization
code (RFC 6749 §4.1.2), so the callback now only reads the cookie and
lets it expire on its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The OAuth2 walkers and the new tests named the nonce cookie and copied
it by hand. A browser sends back whatever cookies it was given, so the
tests now forward every cookie the start response set and no longer
know its name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Meldiron
Meldiron merged commit 3cf587b into main Sep 30, 2026
52 checks passed
abnegate added a commit that referenced this pull request Sep 30, 2026
Brings in main's OAuth2 state and nonce validation (#14012), which cloud
needs, along with the realtime origin alignment (#14020), subscriber
target ownership (#14019), GitLab primary email (#14010), impersonation
MFA (#14017), write-only webhook passwords (#14016), chunked-upload
resume ownership (#14014), preview authorize validation (#14013),
nightly forcing (#14015) and scheduled-function enqueue checks (#14011).

The merge had no textual conflicts, and main left composer.json and
composer.lock unchanged. So the lock keeps the branch's database and
async pins.

Main's assertResumeAllowed() in Storage Buckets/Files/Create checks
Input(Database::PERMISSION_UPDATE, ...). utopia-php/database 8 has no
PERMISSION_* constants, so every chunked resume would fail with an
undefined-constant error. It now passes PermissionType::Update, as the
rest of that action already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ChiragAgg5k added a commit that referenced this pull request Oct 6, 2026
The token flow goes back to how it behaved before #14012, signed-in browser user included. Binding the token flow to the browser moves to the optional state and PKCE parameters and an explicit link action, tracked in #14156.
NancyWei123 pushed a commit to NancyWei123/appwrite that referenced this pull request Oct 6, 2026
The token flow hands userId and secret to the app, whose server often starts the flow (the SSR pattern in the docs). The nonce cookie then lands on that server instead of the browser, so every server-started sign-in failed with user_oauth2_state_invalid since appwrite#14012. The session flow keeps the check: Appwrite sets that session itself, and the flow is browser-only.
@HarshMN2345 HarshMN2345 mentioned this pull request Oct 12, 2026
2 tasks done
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