Resume OAuth2 authorize requests through PAR handles across login#3110
Conversation
…ogin When an unauthenticated user lands on the consent screen, push the authorization request server-side (RFC 9126 PAR) and round-trip only a short request_uri handle through login, instead of the full consent URL. The full URL — scope, 2KB state, PKCE and authorization_details — previously travelled inside the GitHub OAuth state during 'Sign in with GitHub' and could exceed the provider's state size limits, breaking MCP client logins. - createPAR goes through an anonymous no-credentials client: the server serves /par (like /token) with wildcard CORS and credentials disabled, which the browser rejects for the SDK's default credentialed fetch. - The post-login request_uri path requires a session before dereferencing (the handle is single-use and consumed even by failing authorize calls) and sends only clientId + requestUri, as the server rejects any other param alongside the handle. - After authorize returns a grant, the URL is rewritten to ?grant_id= with replaceState so reloads resume via getGrant instead of failing on the consumed handle. - The consent effect is keyed on the query string via $derived: login calls invalidate(ACCOUNT) right after goto(redirect), which replaces the page.url object without changing the query — an effect tracking page.url directly re-runs, cancels the in-flight authorize and re-dereferences the consumed handle. - Expired/consumed handles render a friendly retry message; PAR failures (e.g. servers without the endpoint) fall back to the legacy full-URL redirect. - resource params are forwarded with getAll (RFC 8707 repeats the key). SDK pinned to the preview build of appwrite-labs/cloud#4660 (@4b3ae78); re-pin after the cloud PR merges. Models.Locale was renamed to Models.CloudLocale in that spec, updated the seven usages.
Console (appwrite/console)Project ID: Tip JWT tokens let functions act on behalf of users while preserving their permissions |
Greptile SummaryThis PR fixes OAuth2/MCP login breakage caused by oversized authorize URLs exceeding GitHub's OAuth
Confidence Score: 4/5The core auth flow refactor is well-structured and fully E2E verified, but the SDK is pinned to a preview build that must be re-pinned before merging, and there is an open question about whether the PAR endpoint's CORS posture requires a credentials-free client that is described in the PR but absent from the code. The consent page logic — cancellation, state resets, error handling, and the resource/param unification — all look correct. The main uncertainty is whether sdk.forConsole.oauth2.createPAR (a credentialed fetch) actually works against the cloud PAR endpoint described as serving wildcard CORS, or whether an anonymous client is still needed. The E2E test passed in a local stack, but if the production endpoint enforces wildcard CORS, every createPAR call would throw a CORS error, be silently swallowed, and fall back to the oversized URL — leaving the original GitHub login bug unfixed. src/routes/(public)/oauth2/consent/+page.svelte — specifically the createPAR call and whether the credentials mode is correct for the target endpoint's CORS configuration. Important Files Changed
Reviews (7): Last reviewed commit: "(refactor): flatten the consent-page flo..." | Re-trigger Greptile |
… substring
Greptile flagged the message.includes('request_uri') heuristic as over-broad. Match the AppwriteException type oauth2_invalid_request instead — the server reports every dead-handle case (expired, consumed, malformed) under that type, and since the request_uri authorize call sends nothing but the handle, the type can only mean the handle is unusable. Verified against the local cloud branch: a consumed handle returns type oauth2_invalid_request with message 'Invalid request_uri.' (which the suggested 'expired'/'not found' substrings would have missed).
Greptile follow-up: the PAR path already used params.getAll('resource') but the authenticated direct-authorize call still sent only the first value, dropping repeated RFC 8707 resource indicators.
|
Addressed the remaining Greptile finding in 32ee3fc — the authenticated direct-authorize path now forwards all |
…nstants - Hoist the flow out of the 220-line effect into one named function per entry branch (grant_id / request_uri / raw client_id), cancelled via a run counter instead of a closed-over flag. - Dedupe the authorize-request param extraction shared by createPAR and authorize into readAuthorizeParams(), plus getAccount()/fail() helpers. - Move error messages and the oauth2_invalid_request exception type into a colocated errors.ts. - Build navigation targets with resolve() to satisfy svelte/no-navigation-without-resolve.

What does this PR do?
Fixes GitHub-login breakage for OAuth2/MCP clients by resuming authorization requests through RFC 9126 Pushed Authorization Request (PAR) handles instead of round-tripping the full consent URL through login.
The problem
When an MCP client (or any OAuth2 client) sends a user to
/console/oauth2/consentand the user has no session, the consent page redirects to/login?redirect=<full consent URL>. That URL carries the entire authorize request —scope(up to 8 KB),authorization_details, a clientstateof up to 2 KB, and PKCE params. When the user clicks Sign in with GitHub, the whole thing is embedded in the OAuthsuccessURL, which the backend round-trips through GitHub's OAuthstate— and GitHub caps state size, so the login dies or comes back truncated.The fix
Pairs with appwrite-labs/cloud#4660, which adds
POST /v1/oauth2/{projectId}/par:oauth2.createPAR(...)and redirects to login with onlyclient_id+request_uri. In the verified E2E run a 2,276-char consent URL became a 182-char login URL — that compact URL is what travels through GitHub'sstate.request_uribranch on the consent page verifies the session first, then callsauthorize({ clientId, requestUri })with nothing else (the server rejects any other param alongside the handle, and consumes the single-use handle even on failing calls — so it is never dereferenced while logged out).?grant_id=…withreplaceState, so refresh/back-forward resumes viagetGrantinstead of failing on the consumed handle.createPARfails (older or self-hosted servers without the endpoint), the page falls back to the legacy full-URL redirect, so nothing regresses.Implementation notes
/par(like/token) with wildcard CORS and credentials disabled — per RFC 9126 any client may push without platform registration. The console SDK defaults to credentialed fetches, which the browser rejects against that posture, socreatePARgoes through a one-offnew Client().setCredentials('omit'). The endpoint needs no session anyway.$derived): after email login,goto(redirect)is followed byinvalidate(Dependencies.ACCOUNT), which replaces thepage.urlobject without changing the query. An effect trackingpage.urldirectly re-runs on that identity change, cancelling the in-flight (successful!) authorize and re-dereferencing the consumed single-use handle. Deriveds only propagate on value change, which makes the flow immune to this. Caught live during E2E verification.resourceparams are now forwarded withparams.getAll(RFC 8707 repeats the key; the old code silently dropped all but the first value).pkg.vc @4b3ae78, generated via the Cloud Console SDK Preview workflow dispatch).Models.Locale→Models.CloudLocale; the seven usages are updated here.Flow
Screenshots
1. Post-PAR login redirect — the resume URL is 182 chars (was 2,276) and carries only
client_id+request_uri:2. Consent card fully rehydrated from the pushed request after login (scopes, RAR resource selectors — URL already rewritten to
?grant_id=…):3. Consumed/expired handle — friendly retry message:
Test plan
Verified E2E with Playwright against a local cloud stack running the PR branch (public PKCE app, MCP-style request: 6 scopes + RAR
authorization_details+ 1,790-charstate):request_uri?grant_id=…; page reload still renders the consent cardcode+ byte-identical 1,790-charstatescope+authorization_detailsrequest_uri→ friendly error cardauthorize({clientId, requestUri})returnsredirectUrldirectly (skip-consent path) and lands on the callbackbun run format/check(0 errors) /lint(0 errors) /test:unit(235 passed) /buildRelated
request_uriback into full params and consumes the handle, so PAR-native clients hit the console with long URLs and a dead handle, and the console re-pushes. Passingclient_id+request_urithrough unconsumed would let this page skip the re-push entirely.