Skip to content

feat(auth): store PKCE verifiers per flow to survive overlapping flows - #1622

Open
itsybitsci wants to merge 1 commit into
supabase:mainfrom
itsybitsci:feat/pkce-flow-id
Open

itsybitsci wants to merge 1 commit into
supabase:mainfrom
itsybitsci:feat/pkce-flow-id

Conversation

@itsybitsci

Copy link
Copy Markdown

What kind of change does this PR introduce?

Feature (SDK parity). Closes #1560.

What is the current behavior?

supabase_auth keeps a single PKCE code verifier at {storage_key}-code-verifier. Starting a second PKCE flow while one is pending (two OAuth sign-ins, or an OAuth sign-in while a link-identity flow is pending) overwrites the first verifier, so whichever flow completes second fails at exchange_code_for_session.

What is the new behavior?

Ports the per-flow verifier storage that already shipped in supabase-js (supabase-js#2569), Dart (supabase-flutter#1662) and Swift (supabase-swift#1245), using the same key shapes and semantics:

  • Each PKCE flow gets a generated flow_id (32 hex chars) and its verifier is stored at {storage_key}-flow-{flow_id}-code-verifier.
  • An ordered index at {storage_key}-flows-code-verifier (oldest first) bounds the ring to PKCE_MAX_CONCURRENT_FLOWS = 5; the oldest pending flow is evicted when a sixth starts. Storage backends cannot enumerate keys, hence the explicit index.
  • OAuthResponse.flow_id is returned by sign_in_with_oauth and link_identity (None on the implicit flow).
  • exchange_code_for_session accepts flow_id in CodeExchangeParams. With a flow id only that flow's verifier is used; a miss raises the new AuthPKCECodeVerifierMissingError before any request is made, rather than spending the single-use auth code on another flow's verifier. Without a flow id the legacy key is used, so existing callers keep working.
  • The legacy {storage_key}-code-verifier key is still written with the newest verifier and is cleared alongside a slot only when it holds that slot's value, so finishing one flow never destroys a newer flow's fallback.
  • sign_out clears every pending slot, the index and the legacy key.
  • Flow ids are validated (^[a-zA-Z0-9_-]{8,64}$) both when supplied by callers and when read back from storage, since they are embedded in storage keys.
  • Index read-modify-write is serialized with a lock (asyncio in the async client, threading in the generated sync client), which reviewers flagged as necessary in both the Dart and Swift ports.

Scope deliberately matches the Dart and Swift ports rather than the seven methods listed in the auto-generated issue text: sign_in_with_otp, sign_in_with_sso, resend, update_user and reset_password_for_email do not send a PKCE challenge in this SDK today, so there is no verifier for them to keep. The opt-in sb_flow_id redirect parameter from supabase-js is also left out; a server-side caller holds the flow_id and passes it explicitly. Both can be follow-ups if wanted.

Usage

client = AsyncGoTrueClient(url=..., flow_type="pkce")

first = await client.sign_in_with_oauth({"provider": "github"})
second = await client.sign_in_with_oauth({"provider": "google"})

# each callback exchanges with its own verifier, in any order
await client.exchange_code_for_session({"auth_code": code, "flow_id": first.flow_id})

Additional context

  • New module supabase_auth/_async/pkce_verifier_store.py (sync twin generated by run-unasync.py, which gains an asyncio to threading replacement for the lock).
  • CodeExchangeParams.code_verifier and redirect_to become NotRequired, matching how the code already reads them with .get().
  • sdk-compliance.yaml declares auth.sign_in.concurrent_pkce_flows.
  • Tests: store unit tests (slots, eviction, re-store, corrupt index, no-borrow retrieval, targeted removal, remove-all, key shape) generated for both clients; an async-only concurrency test (20 interleaved starts keep exactly 5 indexed slots); helper tests for id generation, validation and key layout; client tests for distinct overlapping flows, exchanging by flow_id with the right verifier and slot-only cleanup, fail-fast on unknown or malformed ids with no request sent, legacy-key fallback, and sign_out clearing all slots. The two existing tests that hardcoded the old two-tuple return of _get_url_for_provider are updated.
  • make auth.tests (mypy + pytest against the docker infra) and make ruff are clean.

Store each PKCE code verifier in its own slot keyed by a generated flow id,
bounded to five pending flows through an ordered index, instead of one shared
key that a second flow overwrites. sign_in_with_oauth and link_identity return
the flow_id, and exchange_code_for_session accepts it to select the matching
verifier; a missing slot fails fast rather than spending the single-use code
on another flow's verifier. The legacy key is still written and used when no
flow id is given, and sign_out clears every pending slot.

Closes supabase#1560
@itsybitsci
itsybitsci requested review from a team and o-santi as code owners September 8, 2026 00:32
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.

parity(auth): implement concurrent PKCE flow support (flow id) [from supabase-js]

1 participant