Skip to content

Resume OAuth2 authorize requests through PAR handles across login#3110

Merged
ChiragAgg5k merged 7 commits into
mainfrom
feat-oauth2-par-login-redirect
Jul 8, 2026
Merged

Resume OAuth2 authorize requests through PAR handles across login#3110
ChiragAgg5k merged 7 commits into
mainfrom
feat-oauth2-par-login-redirect

Conversation

@ChiragAgg5k

Copy link
Copy Markdown
Member

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/consent and 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 client state of up to 2 KB, and PKCE params. When the user clicks Sign in with GitHub, the whole thing is embedded in the OAuth success URL, which the backend round-trips through GitHub's OAuth state — 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:

  1. Pre-login push — the consent page pushes the raw authorize request server-side via oauth2.createPAR(...) and redirects to login with only client_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's state.
  2. Post-login resume — a new request_uri branch on the consent page verifies the session first, then calls authorize({ 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).
  3. Reload safety — once authorize returns a grant, the URL is rewritten to ?grant_id=… with replaceState, so refresh/back-forward resumes via getGrant instead of failing on the consumed handle.
  4. Graceful degradation — if createPAR fails (older or self-hosted servers without the endpoint), the page falls back to the legacy full-URL redirect, so nothing regresses.
  5. Friendly expiry — expired/consumed handles (600 s TTL) render "This sign-in request has expired. Return to the application and try connecting again."

Implementation notes

  • Anonymous client for PAR: the server serves /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, so createPAR goes through a one-off new Client().setCredentials('omit'). The endpoint needs no session anyway.
  • Effect keyed on the query string ($derived): after email login, goto(redirect) is followed by invalidate(Dependencies.ACCOUNT), which replaces the page.url object without changing the query. An effect tracking page.url directly 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.
  • resource params are now forwarded with params.getAll (RFC 8707 repeats the key; the old code silently dropped all but the first value).
  • SDK pinned to the preview build of the cloud PR (pkg.vc @4b3ae78, generated via the Cloud Console SDK Preview workflow dispatch). ⚠️ Re-pin to the regular build after appwrite-labs/cloud#4660 merges. That spec also renamed Models.LocaleModels.CloudLocale; the seven usages are updated here.

Flow

MCP client ──authorize──▶ cloud /authorize ──302──▶ /console/oauth2/consent?client_id&scope&state&…   (huge)
                                                        │  no session
                                                        ▼
                                              oauth2.createPAR(…)  ──▶  request_uri (urn:…, 600s, single-use)
                                                        │
                                                        ▼
                              /login?redirect=/console/oauth2/consent?client_id&request_uri   (~182 chars)
                                                        │  GitHub OAuth round trip fits in state
                                                        ▼
                                    authorize({clientId, requestUri}) ──▶ grant ──▶ ?grant_id=… (replaceState)
                                                        │
                                                        ▼
                                     consent card ──approve──▶ redirect_uri?code&state (state byte-identical)

Screenshots

1. Post-PAR login redirect — the resume URL is 182 chars (was 2,276) and carries only client_id + request_uri:

login with compact redirect

2. Consent card fully rehydrated from the pushed request after login (scopes, RAR resource selectors — URL already rewritten to ?grant_id=…):

consent card

3. Consumed/expired handle — friendly retry message:

expired handle error

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-char state):

  • ✅ Logged-out fat URL → PAR push → 182-char login redirect containing request_uri
  • ✅ Email login → handle dereferenced once → consent card renders the full rehydrated request
  • ✅ URL rewritten to ?grant_id=…; page reload still renders the consent card
  • ✅ Approve → callback receives code + byte-identical 1,790-char state
  • ✅ Token exchange with the PKCE verifier pushed via PAR returns access/refresh/id tokens with correct scope + authorization_details
  • ✅ Re-using the consumed request_uri → friendly error card
  • ✅ Already-approved identity: post-login authorize({clientId, requestUri}) returns redirectUrl directly (skip-consent path) and lands on the callback
  • bun run format / check (0 errors) / lint (0 errors) / test:unit (235 passed) / build

Related

  • Depends on appwrite-labs/cloud#4660 (endpoint + SDK). Draft until that merges and the SDK pin is updated.
  • Cloud-side follow-up raised during review: the server's unauthenticated consent redirect still expands a request_uri back 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. Passing client_id + request_uri through unconsumed would let this page skip the re-push entirely.

…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.
@appwrite

appwrite Bot commented Jul 8, 2026

Copy link
Copy Markdown

Console (appwrite/console)

Project ID: 688b7bf400350cbd60e9

Sites (1)
Site Status Logs Preview QR
 console-stage
688b7cf6003b1842c9dc
Ready Ready View Logs Preview URL QR Code

Tip

JWT tokens let functions act on behalf of users while preserving their permissions

@ChiragAgg5k ChiragAgg5k marked this pull request as ready for review July 8, 2026 07:04
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes OAuth2/MCP login breakage caused by oversized authorize URLs exceeding GitHub's OAuth state size limit. It implements RFC 9126 Pushed Authorization Request (PAR) support on the consent page, compressing a multi-kilobyte authorize URL into a short request_uri handle before redirecting through login.

  • PAR pre-login push: when an unauthenticated user lands on the consent page with raw authorize params, the page pushes those params server-side via createPAR, then redirects to login with only client_id + request_uri (~182 chars vs 2,276 for the full URL). Graceful fallback to the legacy full-URL redirect if createPAR fails (older/self-hosted servers).
  • Post-login resume: a new request_uri branch in init checks for an active session before dereferencing the single-use handle, rewrites the URL to ?grant_id=… via replaceState after the handle is consumed, and shows a friendly expiry message when the handle is stale (oauth2_invalid_request exception type).
  • Refactoring & fixes: the monolithic effect is decomposed into focused async helpers; the cancellation mechanism is upgraded from a boolean to a counter-based closure to survive same-object page.url replacements; resource params are now forwarded with getAll (RFC 8707) on both the PAR and direct-authorize paths; and seven Models.LocaleModels.CloudLocale renames track the SDK model rename in the preview build.

Confidence Score: 4/5

The 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

Filename Overview
src/routes/(public)/oauth2/consent/+page.svelte Major refactor to support RFC 9126 PAR handles: splits the monolithic $effect/init into focused async functions, fixes the cancellation mechanism from a boolean flag to a counter-based closure, and unifies resource/param extraction via readAuthorizeParams. One open question around PAR credential mode vs. the server's CORS posture.
src/routes/(public)/oauth2/errors.ts New file extracting OAuth2 error type strings and user-facing messages into typed constants; clean and well-documented.
package.json SDK pinned to preview build @4b3ae78 (acknowledged as temporary; must be re-pinned after appwrite-labs/cloud#4660 merges).
src/routes/(console)/account/payments/addressModal.svelte Mechanical type rename Models.Locale → Models.CloudLocale to match renamed SDK model; no logic change.
src/routes/(console)/account/payments/billingAddress.svelte Mechanical type rename Models.Locale → Models.CloudLocale; no logic change.
src/routes/(console)/account/payments/editAddressModal.svelte Mechanical type rename Models.Locale → Models.CloudLocale; no logic change.
src/routes/(console)/organization-[organization]/billing/billingAddress.svelte Mechanical type rename Models.Locale → Models.CloudLocale; no logic change.
src/routes/(console)/organization-[organization]/billing/replaceAddress.svelte Mechanical type rename Models.Locale → Models.CloudLocale; no logic change.
src/routes/(console)/organization-[organization]/settings/Soc2.svelte Mechanical type rename Models.Locale → Models.CloudLocale; no logic change.
src/routes/(console)/organization-[organization]/settings/Soc2Modal.svelte Mechanical type rename Models.Locale → Models.CloudLocale; no logic change.

Reviews (7): Last reviewed commit: "(refactor): flatten the consent-page flo..." | Re-trigger Greptile

Comment thread src/routes/(public)/oauth2/consent/+page.svelte Outdated
… 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.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

Addressed the remaining Greptile finding in 32ee3fc — the authenticated direct-authorize path now forwards all resource values via params.getAll, matching the PAR path (RFC 8707 repeats the key; the old call dropped everything after the first value).

Comment thread src/routes/(public)/oauth2/consent/+page.svelte Outdated
…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.
@ChiragAgg5k ChiragAgg5k merged commit c9543b2 into main Jul 8, 2026
4 checks passed
@ChiragAgg5k ChiragAgg5k deleted the feat-oauth2-par-login-redirect branch July 8, 2026 11:16
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.

3 participants