Skip to content

feat!: storage-owned PKCE verifier cookie with OAuth state binding#25

Merged
nicknisi merged 29 commits intomainfrom
pkce-csrf
Apr 20, 2026
Merged

feat!: storage-owned PKCE verifier cookie with OAuth state binding#25
nicknisi merged 29 commits intomainfrom
pkce-csrf

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented Apr 17, 2026

Summary

Introduces two tightly-coupled breaking changes against the OAuth sign-in flow:

  1. PKCE state binding — the URL state parameter is now a sealed blob that is byte-compared against an HttpOnly wos-auth-verifier cookie before the code is exchanged. Prevents a stolen state from being replayed from a different browser / context.
  2. Storage-owned verifier cookie — the verifier cookie is now plumbed through new SessionStorage primitives (getCookie / setCookie / clearCookie). Adapters no longer serialize, read, or delete the cookie manually; the library owns the lifecycle end-to-end.

See MIGRATION.md for the full migration guide.

API changes

New methods (replace the old URL builders)

Old New
getAuthorizationUrl(...) createAuthorization(response, ...)
getSignInUrl(...) createSignIn(response, ...)
getSignUpUrl(...) createSignUp(response, ...)

All three take a response argument and return { url, response?, headers? }. Storage either mutates response (adapters that override applyHeaders) or emits headers via the headers bag — callers forward whichever is populated.

handleCallback — simpler signature

Before:

await auth.handleCallback(request, response, { code, state, cookieValue });

After:

await auth.handleCallback(request, response, { code, state });

cookieValue is gone — handleCallback reads the verifier via storage.getCookie(request, 'wos-auth-verifier').

On success, result.headers carries a Set-Cookie entry as a string[] with two entries: the session cookie and the verifier-delete cookie. Adapters MUST .append each entry individually — never comma-join into a single header.

The header-bag key is case-insensitive: mergeHeaderBags preserves the adapter's casing, so check headers['Set-Cookie'] ?? headers['set-cookie'].

New: clearPendingVerifier

Used on error paths where handleCallback doesn't run (OAuth error responses, missing code, early bail-outs). Accepts TResponse | undefined — headers-only adapters can pass undefined.

const { headers } = await auth.clearPendingVerifier(response);

New typed errors

  • OAuthStateMismatchError — URL state doesn't match the cookie
  • PKCECookieMissingError — verifier cookie absent on callback
  • (plus the existing SessionEncryptionError)

Cookie details

  • HttpOnly, SameSite=Lax (downgraded from Strict so the cookie actually rides the cross-site OAuth redirect back)
  • Secure inferred from redirectUri protocol (forced on when SameSite=None)
  • Max-Age=600
  • Path=/ so the cookie is visible in Chrome DevTools' Application panel from any page (path-scoped cookies are invisible from pages outside the scope, which made PKCE wiring look broken even when working end-to-end). HttpOnly + 10-minute TTL keeps exposure bounded.
  • Sealed payload embeds issuedAt; unsealState enforces the 600s TTL independently of the SessionEncryption adapter's ttl handling, with 60s bounded clock-skew tolerance for multi-node deploys.

Removed

  • getAuthorizationUrl / getSignInUrl / getSignUpUrl (replaced — see table above)
  • decodeStatestate is no longer a cleartext return-pathname carrier; use handleCallback's returned returnPathname instead
  • serializePKCESetCookie / buildPKCEDeleteCookieHeader / getPKCECookieOptions / PKCECookieOptions / PKCE_COOKIE_NAME from the public surface — storage owns all of this internally

Also in this PR

  • Case-insensitive `Set-Cookie` merge — adapters that normalize header keys through `Headers` objects (emitting lowercase `set-cookie`) no longer silently drop one cookie on callback.
  • Browser-safe bundling — `AuthKitCore` no longer imports `node:crypto`; state byte-compare uses a web-crypto-compatible constant-time helper so the library bundles cleanly for edge/worker runtimes.
  • `CookieSessionStorage` default cookie name aligned with `ConfigurationProvider` at `wos-session` (was `wos_session` in the fallback — divergent from the documented default; not user-visible on the `createAuthService` path, but the inconsistency is fixed).
  • Dependency: `valibot` added for runtime schema validation of the unsealed PKCE state payload (defense-in-depth).

BREAKING CHANGE: every adapter consumer must update. See `MIGRATION.md`.

Every sign-in now generates a PKCE verifier, seals it alongside
returnPathname + customState + a nonce into a single blob, and uses that
blob as BOTH the OAuth `state` query param AND the value of a short-lived
`wos-auth-verifier` cookie. On callback, the adapter passes the cookie
value; the library byte-compares it against the URL state before
decrypting and then threads `codeVerifier` into `authenticateWithCode`.
A leaked state value on its own can no longer be used to complete a
session hijack.

Breaking changes (minor bump, pre-1.0):
- `getAuthorizationUrl` / `getSignInUrl` / `getSignUpUrl` now return
  `{ url, sealedState, cookieOptions }` instead of a bare URL string.
- `handleCallback` requires a new `cookieValue: string | undefined` field.
- `SessionEncryption.unsealData` interface gains optional `ttl`.
- The `{internal}.{userState}` split-state format is removed; customState
  now lives inside the sealed blob and round-trips byte-identically.

New module: `src/core/pkce/` — constants, state schema, seal/unseal,
cookie options, and orchestrator. New errors: `OAuthStateMismatchError`,
`PKCECookieMissingError`. New AuthService delegators:
`getPKCECookieOptions`, `buildPKCEDeleteCookieHeader`.

Coverage lifted to 90/82/85/90 (lines/branches/functions/statements) with
new tests across pkce.spec, state.spec, cookieOptions.spec, and extended
ironWebcryptoEncryption.spec + AuthService.spec + AuthOperations.spec.
- `getPKCECookieOptions`: collapse redundant three-way ternary. Since
  `configuredSameSite ?? 'lax'` already defaults non-'none' values to
  'lax', the explicit `strict → lax` branch was dead. Replaced with a
  single `none ? 'none' : 'lax'` check and an inline comment
  documenting the `strict → lax` downgrade intent.
- `AuthService.getPKCECookieOptions`: strip JSDoc paragraph that
  restated what the method name and one-line delegator body already
  convey.

No behavior change. 194/194 tests pass.
…-level TTL

Two defense-in-depth fixes for the PKCE verifier cookie:

1. Scope `wos-auth-verifier` cookie `Path` to the redirect URI's pathname
   instead of `/`. Multiple AuthKit apps on the same host under different
   subpaths no longer collide on a shared cookie slot — starting sign-in
   in one app no longer overwrites another's in-flight verifier. Falls
   back to `/` if the redirect URI is missing or invalid.

2. Embed `issuedAt` in the sealed PKCE state payload and enforce age in
   `unsealState` independently of the `SessionEncryption` adapter's `ttl`
   handling. Custom encryptors may legally ignore the optional `ttl`
   option; the payload-level check is the authoritative 600s guard.

No breaking changes to the public surface: `PKCECookieOptions.path` is
widened from the literal `'/'` to `string`; `PKCEState` gains `issuedAt`
(consumers destructure the fields they need).
Strict `issuedAt > Date.now()` rejection broke multi-node deployments
where sign-in on node A and callback on node B could see node B's clock
slightly behind, producing a negative `ageMs` and a spurious
`SessionEncryptionError('PKCE state expired')`.

Allow up to 60s of negative skew — matches iron-webcrypto's default
`timestampSkewSec`. Payloads meaningfully in the future (> 60s) still
fail closed.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the OAuth flow by binding the OAuth state parameter to a short-lived PKCE verifier cookie, so callbacks must present both the URL state and the matching cookie before the code exchange proceeds.

Changes:

  • Introduces PKCE state sealing/unsealing with TTL enforcement and new typed errors for state mismatch / missing cookie.
  • Updates URL-generation APIs to return { url, sealedState, cookieOptions } and updates callback handling to require cookieValue.
  • Adds PKCE cookie helpers (options + delete header builder) and updates public exports + docs/tests accordingly.

Reviewed changes

Copilot reviewed 24 out of 25 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/service/factory.ts Exposes new PKCE cookie helper methods on the service factory output.
src/service/factory.spec.ts Verifies the factory output includes the new helper functions.
src/service/AuthService.ts Adopts the new URL-generation result shape and enforces cookie-bound callback verification.
src/service/AuthService.spec.ts Updates tests for the new URL result shape + cookie-bound callback flow and errors.
src/operations/AuthOperations.ts Switches authorization URL generation to PKCE-backed generateAuthorizationUrl and returns the new triple shape.
src/operations/AuthOperations.spec.ts Adds assertions for sealed state, cookie options, and PKCE params in the generated URL.
src/index.ts Exports new PKCE helpers/constants and new error types as part of the public API.
src/core/session/types.ts Extends SessionEncryption to support optional ttl and adds PKCE-related public types.
src/core/pkce/state.ts Adds sealed PKCE state format with runtime validation + TTL enforcement.
src/core/pkce/state.spec.ts Adds coverage for state round-tripping, tamper detection, schema validation, and TTL behavior.
src/core/pkce/pkce.spec.ts Adds end-to-end PKCE round-trip coverage through generateAuthorizationUrl and verifyCallbackState.
src/core/pkce/index.ts Creates a PKCE submodule export surface for internal/public consumption.
src/core/pkce/generateAuthorizationUrl.ts Implements PKCE URL generation returning { url, sealedState, cookieOptions }.
src/core/pkce/cookieOptions.ts Implements PKCE cookie option derivation + Set-Cookie serialization helper.
src/core/pkce/cookieOptions.spec.ts Adds unit tests for cookie option derivation and Set-Cookie serialization.
src/core/pkce/constants.ts Introduces PKCE cookie name and TTL constants.
src/core/errors.ts Adds OAuthStateMismatchError and PKCECookieMissingError.
src/core/errors.spec.ts Adds tests validating new error types and inheritance.
src/core/encryption/ironWebcryptoEncryption.ts Adds TTL support to unsealData (and already supports TTL in sealing).
src/core/encryption/ironWebcryptoEncryption.spec.ts Adds tests validating TTL enforcement behavior.
src/core/AuthKitCore.ts Adds verifyCallbackState that checks state vs cookie before decrypting/unsealing.
src/core/AuthKitCore.spec.ts Adds tests for verifyCallbackState error cases and refresh behaviors.
pnpm-lock.yaml Locks the new valibot dependency.
package.json Bumps version to 0.4.0 and adds valibot.
README.md Documents the new PKCE cookie-bound flow, updated APIs, and adapter responsibilities.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/core/AuthKitCore.ts Outdated
Comment thread src/core/pkce/cookieOptions.ts Outdated
Comment thread src/core/pkce/state.ts
Comment thread src/core/pkce/state.spec.ts Outdated
…w intent

- Use crypto.timingSafeEqual for state-vs-cookie compare to avoid timing
  side-channels (with length gate).
- Document cookieOptions `secure` forcing when sameSite=none.
- Expand PKCE_CLOCK_SKEW_MS docs: asymmetric by design (negative skew for
  multi-node drift, strict upper bound keeps payload check authoritative).
- Fix misleading state.spec test comment: assert the real 601s boundary,
  not a fictional 660s grace window.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Generalize SessionStorage with getCookie/setCookie/clearCookie primitives so
the PKCE verifier cookie flows through the same abstraction as the session
cookie. AuthService now writes and reads the verifier internally — callers
no longer see sealed blobs or cookie options.

Hard rename to match library precedent:
- getAuthorizationUrl/getSignInUrl/getSignUpUrl → createAuthorization/
  createSignIn/createSignUp. Each takes `response` and returns
  { url, response?, headers? }.
- handleCallback drops `cookieValue`; reads via storage.getCookie.
- buildPKCEDeleteCookieHeader/getPKCECookieOptions removed; replaced by
  auth.clearPendingVerifier(response, { redirectUri? }).

Seal per-call `redirectUri` override into PKCE state so handleCallback can
emit a verifier-delete Set-Cookie with the matching Path — no orphan
cookies under overrides.

handleCallback success now returns headers['Set-Cookie'] as string[] (session
cookie + verifier delete). Adapters must append each entry as its own HTTP
header; document HeadersBag invariant accordingly.

Remove from public API: PKCE_COOKIE_NAME, getPKCECookieOptions,
serializePKCESetCookie, PKCECookieOptions, buildPKCEDeleteCookieHeader.

Bump to 0.4.0. MIGRATION.md and README.md rewritten for the collapsed API.
Tests: 207 pass (+4 new — clearPendingVerifier paths, multi-Set-Cookie
assertion, getCookie/setCookie/clearCookie primitives, redirectUri
round-trip through sealed state).
@nicknisi nicknisi changed the title feat!: bind OAuth state to PKCE verifier cookie feat!: storage-owned PKCE verifier cookie + OAuth state binding (0.4.0) Apr 19, 2026
@nicknisi nicknisi changed the title feat!: storage-owned PKCE verifier cookie + OAuth state binding (0.4.0) feat!: storage-owned PKCE verifier cookie + OAuth state binding Apr 19, 2026
@nicknisi nicknisi requested a review from Copilot April 19, 2026 20:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated 10 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/service/AuthService.ts
Comment thread src/service/AuthService.ts Outdated
Comment thread src/service/AuthService.ts Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread src/index.ts Outdated
Comment thread src/core/session/types.ts Outdated
Comment thread src/service/factory.ts Outdated
Comment thread src/service/factory.ts Outdated
Comment thread README.md
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 25 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread README.md Outdated
…erifier

Two regressions caught on third-party review of the 0.4.0 storage-owned
cookie API. Both break real adapters even though in-repo tests passed:

- `mergeHeaderBags` matched the literal key `Set-Cookie`, so adapters that
  normalize through `Headers` objects (lowercase `set-cookie`) dropped one
  cookie on callback — either the session cookie or the verifier delete.
  Now matches case-insensitively and reuses the existing key's casing.

- `clearPendingVerifier(response: TResponse, ...)` required a concrete
  response even though `SessionStorage.clearCookie` accepts `undefined`
  for headers-only emission. The documented error-path cleanup was
  unreachable for headers-only adapters. Widened to `TResponse | undefined`.

Also fix a rename leftover: `GetAuthorizationUrlOptions` JSDoc referenced
the old `getAuthorizationUrl` name.

+2 regression tests (lowercase Set-Cookie merge, undefined clearPendingVerifier).
…fy mergeHeaderBags

Docs:
- MIGRATION.md was documenting a transitional API (`{ url, sealedState,
  cookieOptions }`, `buildPKCEDeleteCookieHeader`, `cookieValue` on
  `handleCallback`, `serializePKCESetCookie`, `decodeState`) that never
  shipped. Rewritten against real v0.3.4: `getSignInUrl(): Promise<string>`,
  `handleCallback(req, res, { code, state? })`, plaintext `{internal}.{userState}`
  state, abstract `getSession` on `CookieSessionStorage`. Deleted the
  "Removed public exports" and `decodeState` sections — nothing to migrate
  from. Added header-bag casing note covering the eb39cd7 invariant.

- README: removed bogus `parseCookieHeader` import (not exported); inline
  cookie parsing in the storage example. Middleware refresh path now
  appends each `Set-Cookie` entry individually — `headers.set('Set-Cookie',
  string[])` would comma-join, exactly the failure mode we warn against.
  Noted `clearPendingVerifier` accepts `undefined` for headers-only adapters.

Code cleanup (simplify pass on eb39cd7):
- `mergeHeaderBags`: flattened — dropped dead `left === undefined` branch,
  early-continue for non-cookie and no-existing-key cases. JSDoc rationale
  trimmed to one line (reasoning lives in commit history).
- Test: dropped `Object.keys().find()` theater — the mock authored lowercase
  `set-cookie`, assert on `headers['set-cookie']` directly. Comment trimmed.
- Token refresh: rewrote the "Token Expiry Buffer: 60 seconds default" line
  that didn't match reality. Actual behavior: validateAndRefresh refreshes
  when verifyToken fails (expired/invalid). isTokenExpiring is a standalone
  helper, not wired into the refresh path. Now documented as such.

- Direct Access example: was broken — passed getConfigurationProvider() to
  constructors that need AuthKitConfig, and referenced undefined `encryption`.
  Swapped to getConfig() + the exported sessionEncryption default.

- Set-Cookie casing: handleCallback output passes through mergeHeaderBags,
  which preserves the adapter's key casing. Callers using a literal
  `headers['Set-Cookie']` with lowercase-emitting adapters miss both cookies.
  Updated both README and MIGRATION to check both casings. (The README
  middleware example uses the bundled CookieSessionStorage which always
  emits capital-S, so it's unchanged.)

- Default cookie name: README said `wos_session`; actual config default
  (via ConfigurationProvider) is `wos-session`. The CookieSessionStorage
  fallback at :14 still says `wos_session` — that's a codebase bug to fix
  separately; users on the normal createAuthService path hit the hyphen.
Previous attempt used `getConfig()` (takes a key, returns one value) and
`getWorkOS(config)` (takes no args). Use `getConfigurationProvider().getConfig()`
for the full AuthKitConfig and call `getWorkOS()` zero-arg.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 27 out of 28 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread README.md
Comment thread README.md
…nProvider

Both sites documented `wos-session` as the default, but the
CookieSessionStorage constructor fell back to `wos_session` (underscore) —
divergent from the `wos-session` (hyphen) default in ConfigurationProvider.
Users on the normal createAuthService path see hyphen; direct
CookieSessionStorage instantiation with an unresolved config saw underscore.

Not user-visible in practice — the provider always resolves cookieName
before CookieSessionStorage sees the config — but the inconsistency is
real and the underscore default contradicts the docs.
- errors.spec.ts (21 → 8 tests): was testing Node's built-in Error behavior
  5 different ways for each subclass. Consolidated subclass name/inheritance
  checks into one `it.each` table; kept one base-class test covering
  message/cause/data and two TokenRefreshError tests for the actual custom
  fields.

- pkce.spec.ts (8 → 3 tests): integration tests overlapped with state.spec.ts
  (tamper, TTL, customState variants) and AuthKitCore.spec.ts (missing
  state/cookie). Kept the three behaviors that only emerge at the
  integration seam: full round-trip, empty-string cookie falsy, concurrent
  sign-in cross-flow rejection.

- state.spec.ts: three customState preservation tests (dots, JSON-like, 2KB)
  collapsed into one `it.each` — all testing the same opacity property.

209 → 191 tests, -303 spec LOC. No coverage loss.
@nicknisi nicknisi requested a review from Copilot April 20, 2026 13:46
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 27 out of 28 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/service/AuthService.ts
Comment thread src/core/pkce/state.ts Outdated
The 2-line constants.ts file was the only constants module in the codebase
— elsewhere, defaults live inline next to the code that uses them
(CookieSessionStorage defaults, ConfigurationProvider defaults, token
expiry buffer). Move PKCE_COOKIE_NAME and PKCE_COOKIE_MAX_AGE into
cookieOptions.ts where they're most tightly coupled to behavior, and
document the 600s rationale — 10-minute convention matching Arctic,
openid-client, Clerk, Okta. state.ts imports from cookieOptions.ts now.
…age context to PKCE expiry errors

- mergeHeaderBags: if `b` contained both `Set-Cookie` and `set-cookie`
  keys, the pre-loop scan computed the merge target once from `a`, so the
  second casing from `b` would be inserted as a separate key instead of
  merged. Pathological (no real adapter emits both), but a real gap. Now
  captures the first set-cookie key we see and reuses it.

- PKCE state expired: the docstring claims callers differentiate via the
  `cause` chain, but the expiry throw had no cause. Attach age/maxAge/skew
  context so production clock-skew issues are diagnosable via `cause`
  without leaking the sealed blob.
@nicknisi nicknisi marked this pull request as ready for review April 20, 2026 13:58
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 20, 2026

Greptile Summary

This PR introduces PKCE state binding and storage-owned verifier cookie management for the OAuth sign-in flow. The sealed state blob doubles as both the OAuth state query parameter and the wos-auth-verifier cookie value, enabling a constant-time byte-comparison before decryption — a solid defense against cross-context state replay. The SessionStorage interface now owns the full verifier cookie lifecycle (getCookie/setCookie/clearCookie), and handleCallback emits a string[] Set-Cookie bag with case-insensitive merge handled via mergeHeaderBags.

Confidence Score: 4/5

Safe to merge after addressing the expires serialization gap and confirming JWT claim validation expectations.

Two P2 findings: serializeCookie silently drops CookieOptions.expires (data-loss for any adapter that sets expires instead of maxAge), and jwtVerify is called without explicit issuer/audience options (violates the team's JWT validation rule). Neither blocks the primary PKCE flow, but both are correctness gaps in new code introduced by this PR.

src/core/session/serializeCookie.ts (missing Expires attribute), src/core/AuthKitCore.ts (iss/aud claim validation)

Important Files Changed

Filename Overview
src/core/session/serializeCookie.ts New shared serializer for all Set-Cookie headers. Has a correctness gap: CookieOptions.expires is part of the public interface but is never emitted as an Expires= attribute.
src/core/AuthKitCore.ts Core auth logic for JWT verification, session encryption/decryption, and the new verifyCallbackState for PKCE state binding. jwtVerify is called without explicit iss/aud options.
src/service/AuthService.ts Orchestration layer; replaces old URL builders with createAuthorization/createSignIn/createSignUp, adds clearPendingVerifier and bestEffortClearVerifier, and implements the two-cookie Set-Cookie merge in handleCallback. Logic is well-thought-out with error-path cleanup.
src/core/pkce/state.ts New PKCE state sealing/unsealing with valibot schema validation, dual TTL enforcement (adapter-layer + payload-level issuedAt), and 60s clock-skew tolerance. Implementation is solid.
src/core/pkce/generateAuthorizationUrl.ts Generates PKCE-bound authorization URLs with two-layer size guards: an early check on caller-supplied state bytes and a post-seal check on the serialized cookie header length.
src/core/session/CookieSessionStorage.ts Abstract base class now owns the full cookie lifecycle with the new getCookie/setCookie/clearCookie primitives. getCookie is correctly documented to require URL-decoded return values.
package.json Adds valibot runtime dependency; MIGRATION.md is still absent from the files array (previously flagged as P1).

Sequence Diagram

sequenceDiagram
    participant C as Client/Browser
    participant A as Adapter
    participant S as AuthService
    participant Op as AuthOperations
    participant Enc as Encryption
    participant WOS as WorkOS API

    Note over C,WOS: Sign-in initiation
    C->>A: GET /sign-in
    A->>S: createSignIn(response, options)
    S->>Op: createAuthorization(options)
    Op->>WOS: pkce.generate()
    WOS-->>Op: { codeVerifier, codeChallenge }
    Op->>Enc: sealState({ nonce, codeVerifier, issuedAt, ... })
    Enc-->>Op: sealedState
    Op-->>S: { url, sealedState, cookieOptions }
    S->>S: storage.setCookie(response, wos-auth-verifier, sealedState)
    S-->>A: { url, response?, headers? }
    A->>C: Redirect to url + Set-Cookie: wos-auth-verifier=sealedState

    Note over C,WOS: OAuth callback
    C->>A: GET /callback?code=X&state=sealedState
    A->>S: handleCallback(request, response, { code, state })
    S->>S: storage.getCookie(request, wos-auth-verifier) cookieValue
    S->>S: verifyCallbackState({ stateFromUrl, cookieValue })
    Note right of S: constantTimeEqual(urlBytes, cookieBytes)
    S->>Enc: unsealState(cookieValue)
    Note right of Enc: schema validate + issuedAt TTL check
    Enc-->>S: { codeVerifier, returnPathname, ... }
    S->>WOS: authenticateWithCode({ code, codeVerifier })
    WOS-->>S: { accessToken, refreshToken, user }
    S->>Enc: encryptSession(session)
    S->>S: storage.saveSession(response, encryptedSession)
    S->>S: storage.clearCookie(response, wos-auth-verifier)
    S-->>A: { response?, headers: { Set-Cookie: [sessionCookie, verifierDelete] }, returnPathname }
Loading

Reviews (8): Last reviewed commit: "feat(pkce)!: reject oversized sealed sta..." | Re-trigger Greptile

Comment thread src/service/AuthService.ts
Comment thread src/core/session/CookieSessionStorage.ts
Comment thread src/service/AuthService.ts
…irectUri plumbing

Path-scoped verifier cookies (Path=<redirectUri.pathname>) are invisible
in Chrome DevTools' Application panel from any page outside the scoped
path, which makes PKCE wiring look broken even when it works end-to-end.
Drop the scoping: the cookie is HttpOnly with a 10-minute TTL, so the
cost of sending it on unrelated same-origin requests is negligible vs.
the debugging pain.

Removes the dead plumbing that existed solely to recover the per-call
path at callback time:
- StateSchema no longer stamps `redirectUri` into the sealed blob
- generateAuthorizationUrl stops including it when sealing
- AuthService.handleCallback drops the `sealedRedirectUri` destructure
- AuthService.clearPendingVerifier loses its `{ redirectUri? }` option

BREAKING CHANGE: `clearPendingVerifier(response, opts)` is now
`clearPendingVerifier(response)`. Callers passing `{ redirectUri }`
must drop the argument. Sealed-blob schema stays `optional`-tolerant
of an unused `redirectUri` field so in-flight cookies from prior
builds continue to round-trip during deploy.
de13e36 added `timingSafeEqual` from `node:crypto` and `Buffer.from` to
`verifyCallbackState`, which broke client bundles in consumers (Vite
externalizes `node:crypto` for the browser; `timingSafeEqual` has no
browser equivalent, causing Rollup to fail on the missing export).

Replace with a runtime-portable `constantTimeEqual` helper in `src/utils.ts`
(XOR-accumulate loop) and `TextEncoder` for byte conversion. AuthKitCore
now truly matches its "pure business logic, no framework-specific code"
docstring — works in Node, browsers, and edge runtimes.
Comment thread MIGRATION.md Outdated
@nicknisi nicknisi changed the title feat!: storage-owned PKCE verifier cookie + OAuth state binding feat!: storage-owned PKCE verifier cookie with OAuth state binding Apr 20, 2026
Comment thread README.md
Comment thread MIGRATION.md Outdated
Adapters that want the PKCE-delete cookie's attributes to match a
per-request redirectUri (e.g. middleware-scoped overrides) need to pass
the same override used at sign-in time. Forward it to
`getPKCECookieOptions`, which already accepts it.

Additive-only change: existing single-arg callers are unaffected.
- Replace the 3x-repeated `GetAuthorizationUrlResult & { response?,
  headers? }` intersection with a named `CreateAuthorizationResult<TResponse>`.
- Delete orphan `src/core/pkce/index.ts` — nothing imports from it and
  its contents are documented as internal.
- Drop a comment in `cookieOptions.ts` that restated the adjacent JSDoc.
- Document URL-decode requirement on SessionStorage.getCookie (addresses
  greptile P2): custom encryption backends emitting seals with chars
  encodeURIComponent escapes (+/=) would silently break state comparison.
- Correct MIGRATION.md §7 (greptile P1): PKCE cookie path is always '/',
  not redirect-URI-scoped. Multi-app isolation requires cookieDomain.
Two issues flagged during code review:

1. handleCallback only cleared the verifier cookie on the success path —
   failed callbacks (state mismatch, tampered seal, exchange failure,
   save failure) left the verifier live for the full 600s TTL. Now
   best-effort clears on all post-cookie-read exits; cleanup errors are
   swallowed so the original failure propagates.

2. The per-request redirectUri override passed to createAuthorization was
   threaded into creation but not into the handleCallback success-path
   delete — so cookie attributes (notably secure) could mismatch between
   set and clear. Now persisted into the sealed state and recovered on
   unseal so the delete matches the original set.

Adds six tests covering the new error-path cleanup, clear-swallowing,
and the override round-trip (using an http:// override so the secure
attribute flip is observable).
Codex flagged a residual gap: pre-unseal errors (state mismatch,
tampered seal) can't recover the per-request redirectUri override, so
the fallback delete used config-default `secure=true`. On an http://
override + http:// callback, the browser may reject the Set-Cookie
and the verifier would linger.

Cookie replacement matches on (name, domain, path), not Secure — so
dropping Secure on the fallback clear is safe. Only do it for
sameSite=lax; sameSite=none requires Secure and forces it on both set
and clear already.

Adds two tests proving the scheme-agnostic behavior for lax and that
sameSite=none still keeps Secure.
PR review flagged a silent-failure regression: the sealed PKCE state is
stored as the wos-auth-verifier cookie, so a few KB of caller-supplied
`options.state` can push the cookie past the per-cookie browser limit
(~4 KB). Browsers drop it silently and the next callback always fails
with PKCECookieMissingError — with no signal at sign-in time. Pre-0.4.0,
`state` was URL-only and callers could carry much more.

Guard in two layers:
1. Cheap early check: `options.state` > 2048 UTF-8 bytes throws
   PKCEPayloadTooLargeError before any crypto work runs.
2. Authoritative check: serialized Set-Cookie header > 3800 bytes throws
   the same error. Catches combinations the input-only check can't —
   returnPathname + state near the limit, or a long cookieDomain eating
   the budget.

Extracts `serializeCookie` from CookieSessionStorage into a shared helper
so the guard measures the exact bytes the adapter will emit.

Exports `PKCEPayloadTooLargeError` from the package root.

Six boundary tests cover: at-limit pass, just-over reject, multibyte
UTF-8 counted in bytes not chars, returnPathname overflow, cookieDomain
attribute pressure, and error-message content.

Docs: MIGRATION.md §5 documents the 2KB state cap and the regression
from URL-only state; JSDoc on `AuthUrlOptions.state` mirrors it.
@nicknisi nicknisi merged commit 29fdc66 into main Apr 20, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants