fix(account): restore server-started OAuth2 token sign-in, with guest fallback and optional state by ChiragAgg5k · Pull Request #14157 · appwrite/appwrite · GitHub
Skip to content

fix(account): restore server-started OAuth2 token sign-in, with guest fallback and optional state - #14157

Merged
ChiragAgg5k merged 7 commits into
mainfrom
fix/oauth2-token-flow-nonce
Oct 6, 2026
Merged

ChiragAgg5k merged 7 commits into
mainfrom
fix/oauth2-token-flow-nonce

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Problem

Since #14012, OAuth2 sign-in started from an app's server (the SSR pattern in our docs) always fails with user_oauth2_state_invalid. The server SDK sends the start request, so the state cookie lands on the server, not in the browser. Reproduced on Cloud production.

browser ─► app server ─► Appwrite: start            Set-Cookie a_oauth2_* ─► stays on the server ✗
browser ─► Google ─► Appwrite: callback (no cookie) ─► 401 user_oauth2_state_invalid

Fix

Appwrite callback
├─ state cookie matches ─────────────► unchanged (links to signed-in user if any)
└─ no state cookie
   ├─ session flow ──────────────────► 401, as today
   └─ token flow ────────────────────► continue as guest:
                                        user = owner of the provider identity
                                        never linked to the browser's signed-in user
                                        redirect to app: ?userId&secret&state
  • Guest fallback: without the cookie, the signed-in user is no proof they started the flow. Linking to them would let anyone attach their identity to a signed-in account by sending a callback link.
  • Optional state on createOAuth2Token / createOAuth2Session (printable ASCII per RFC 6749 Appendix A.5, max 256 chars), returned unchanged on the success and failure URLs (RFC 6749 §4.1.2). The app compares it with its own cookie, so a forged callback is rejected by the app:
// /login
const state = randomString();
cookies.set('oauth_state', state, { httpOnly: true, secure: true, sameSite: 'lax' });
redirect(await account.createOAuth2Token({ provider, state, success, failure }));

// /success?userId&secret&state
if (url.searchParams.get('state') !== cookies.get('oauth_state')) throw new Error('OAuth2 state mismatch');
await account.createSession({ userId, secret });

Behaviour

Flow Before After
Session flow works unchanged
Token flow, browser-started works unchanged
Token flow, server-started 401 works (no linking to a signed-in user; explicit linking follows in #14156)

Tests

  • testCreateOAuth2TokenStartedByServer: server-started flow, completed by a browser signed in to another account, without the cookie. Gets the same state back, a token for the provider's own account that exchanges for a session, and links nothing. Fails on main and on the previous branch head.
  • testCreateOAuth2TokenReturnsStateOnFailure: provider error; the longest state (256 chars that JSON escaping doubles) comes back on the failure URL, and a non-ASCII state is rejected at the start.
  • All OAuth2|Oidc tests pass (11 tests, 260 assertions). Pint and PHPStan clean.

Part of #14156.

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 #14012. The session flow keeps the check: Appwrite sets that session itself, and the flow is browser-only.
@ChiragAgg5k ChiragAgg5k self-assigned this Oct 6, 2026
@hansi-codes

hansi-codes Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Tier S · Ready to merge

The incremental changes update both producers and consumers consistently, with no new defects found.

Restores server-started OAuth2 token sign-in by treating callbacks without a matching nonce cookie as guests, avoiding identity linking to the browser’s signed-in account. Both OAuth2 initiation endpoints accept optional application state and return it on success and failure redirects. E2E tests cover token exchange, identity isolation, state round-tripping, and validation.

Latest changes: The newest commits rename the application-state payload key from appState to state and update both success and failure redirects to read it consistently.

Verdict New comments Fixed Still open
✅ Approved 0 0 0
📂 Walkthrough · 2
File Change
app/controllers/api/account.php Adds guest fallback for unbound token callbacks and optional ASCII application state, now stored consistently under state.
tests/e2e/Services/Account/AccountCustomClientTest.php Covers server-started token sign-in, identity isolation, token exchange, failure-state round-tripping, and invalid state rejection.

Reviewed the commits since 478d849 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@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 F · 1 blocking finding to address. Summary

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

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix/oauth2-token-flow-nonce (after).

Metric Before After Change
🚀 Requests/sec 199.66 199.68 ⚪ 0%
⏱️ Latency P50 87.29 ms 87.39 ms ⚪ +0.1%
⏱️ Latency P95 204.26 ms 201.81 ms ⚪ -1.2%
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 87.39 201.81 12,540 199.68 -2.46
Account 164 313.62 660 11.18 -2.3
TablesDB 85.19 161.39 6,820 111.6 +3.04
Storage 79.38 173.06 3,300 55.46 -12.84
Functions 118.42 243.98 1,760 30.29 +1.68

Top API waits (after)

API request Max wait (ms)
account.name.update 520.34
storage.files.create 425.58
account.prefs.update 381.01
storage.buckets.create 378.01
functions.variables.update 364.84

Without the nonce cookie, the browser's signed-in user is no proof that it started the flow. Linking the provider identity to that user would let anyone attach their own identity to a signed-in account by sending the callback link, so an unbound token callback now resolves the user from the provider identity only.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@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

@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 D · 1 blocking finding to address. Summary

Comment thread app/controllers/api/account.php
One flow covers both properties: a browser signed in to another account completes a server-started token flow without the state cookie, gets a token for the provider identity's account that exchanges for a session, and gains no identity.
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.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@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 F · 1 blocking finding to address. Summary

Comment thread app/controllers/api/account.php Outdated

@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 F · See the inline comments. Summary

…rn the app's state

Without the state cookie, the browser's signed-in user is no proof that it started the flow, so an unbound token callback resolves the user from the provider identity only and never links to that user. createOAuth2Token and createOAuth2Session take an optional state, returned unchanged on the success and failure URLs, so the app can bind the token it receives to the sign-in it started (RFC 6749 section 4.1.2).
@ChiragAgg5k ChiragAgg5k changed the title fix(account): stop requiring the OAuth2 nonce cookie on the token flow fix(account): restore server-started OAuth2 token sign-in, with guest fallback and optional state Oct 6, 2026
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@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
…imit

Text counts characters, but json_encode escapes non-ASCII as \uXXXX, so 256 emoji grew past the callback's Text(2048) and failed before any redirect. state now takes printable ASCII only (RFC 6749 Appendix A.5), which JSON escaping at most doubles, and the callbacks allow 512 more for it.

@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

Comment thread app/controllers/api/account.php Outdated
The public parameter is state, so the provider state carries it under the same name rather than appState.
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