Skip to content

feat!: PKCE state binding + storage-owned verifier cookies (authkit-session 0.4.0)#66

Draft
nicknisi wants to merge 12 commits intomainfrom
feat/pkce-state-binding
Draft

feat!: PKCE state binding + storage-owned verifier cookies (authkit-session 0.4.0)#66
nicknisi wants to merge 12 commits intomainfrom
feat/pkce-state-binding

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented Apr 18, 2026

Summary

Adopts @workos/authkit-session@0.4.0's two coupled changes — OAuth state binding (URL state is sealed and byte-compared against an HttpOnly verifier cookie) and storage-owned verifier cookies (the library owns the cookie lifecycle through new SessionStorage primitives). The adapter's public API is unchanged; all rework is internal.

Phase 2 of the coordinated three-repo refactor. Spec: docs/ideation/storage-owned-pkce-cookies/spec-phase-2.md in authkit-session.

Public adapter API unchanged. getSignInUrl / getSignUpUrl / getAuthorizationUrl still return Promise<string>; handleCallbackRoute({ onSuccess, onError }) signature unchanged.

What moved internally

Storage — minimal surface, maximum ownership

  • TanStackStartCookieSessionStorage now implements getCookie(request, name); the old getSession override is gone (inherited from the base class as a one-line wrapper over getCookie).
  • The applyHeaders override keeps its two-mode behavior (middleware context → __setPendingHeader / fallback → mutate Response) so upstream cookie writes flow through middleware-aware infrastructure when available.

Server functions — forward, don't serialize

  • server-functions.ts calls upstream createAuthorization / createSignIn / createSignUp and forwards each Set-Cookie from the returned HeadersBag through __setPendingHeader (append per value — never comma-joined).
  • New forEachHeaderBagEntry(bag, emit) helper in src/server/headers-bag.ts deduplicates the bag-iteration pattern across forwardAuthorizationCookies, appendSessionHeaders, and the pre-existing signOut path.

Callback — upstream owns the cookie on every exit path

  • server.ts no longer reads the PKCE cookie or passes cookieValue into handleCallback. The library emits both the session cookie and the verifier-delete cookie as a string[]; the adapter appends each via .append so they survive as distinct HTTP headers.
  • Error paths call authkit.clearPendingVerifier(new Response(), { redirectUri }) to get the correct verifier-delete Set-Cookie. The redirectUri comes from middleware context via getRedirectUriFromContext() so the delete's Path matches whatever createSignIn originally set — this closes a bug where an authkitMiddleware({ redirectUri: '...' }) override would get a Path=/ delete that didn't match the scoped cookie the browser actually holds.
  • STATIC_FALLBACK_DELETE_HEADERS is preserved as a safety net for the case where getAuthkit() itself throws at setup — upstream instance unavailable means clearPendingVerifier can't be called at all.
  • readPKCECookie is removed from cookie-utils.ts; parseCookies remains as a generic helper used by storage.ts.

Example app — sign-in as a redirect endpoint

Reworked so the PKCE verifier Set-Cookie lands on an actual redirect response rather than a page-loader response (which doesn't propagate cookies through TanStack's client-side navigation):

  • New /api/auth/sign-in route that calls getSignInUrl at request time and issues a 307 redirect with the cookie attached.
  • __root.tsx, _authenticated.tsx, and index.tsx now route the sign-in button through that endpoint instead of pre-resolving the URL in their loaders.

Review-driven fixes included

Two bugs surfaced mid-review and were fixed on-branch:

  1. buildVerifierDeleteHeaders originally only read result.headers['Set-Cookie'], but this adapter's applyHeaders returns { response } with the Set-Cookie attached to the response — the headers bag is empty in that path. Now reads response.headers.getSetCookie() first, falls back to the bag, then to the static default only if both are empty. (commit 3d0222f)
  2. clearPendingVerifier(new Response()) was being called with no options, so upstream silently defaulted to the config-level redirectUri when computing the delete's Path. Now pulls the per-request override from middleware context. (commit 50284ef)

Test plan

  • pnpm test — 196/196 passing (17 files)
  • pnpm run typecheck — clean
  • pnpm run lint — 0 warnings / 0 errors (oxlint)
  • pnpm run format:check — clean
  • pnpm run build — clean (tsc)
  • Manual: run example app, complete happy-path sign-in, confirm wos-auth-verifier is written on sign-in and cleared on callback (expect 2 Set-Cookie headers on the callback response: session + verifier-delete)
  • Manual: simulate state tampering (edit the verifier cookie via devtools), confirm callback redirects to error handler AND verifier is cleared

Coordination & merge order

  • Do not merge until @workos/authkit-session@0.4.0 publishes to npm. The pnpm.overrideslink:../authkit-session entry is a local-dev convenience, not a publishable dependency. Post-publish, flip it back to ^0.4.0 (one commit).
  • Parallel Phase 3 lives at workos/authkit-sveltekit#16. Both adapters adopt the same upstream shape; no cross-repo inconsistencies.

BREAKING CHANGE: requires @workos/authkit-session@0.4.0+. Public adapter surface unchanged — only the upstream dep floor moves.

Consume authkit-session 0.4.0's PKCE primitives across the three URL
server functions and the callback route. The adapter now writes the
sealed verifier cookie via the middleware's pending-header channel when
generating auth URLs, reads it from the callback request, passes it to
core for cryptographic verification, and deletes it on every callback
exit path (success, error, onError, missing-code, setup-failure).

Removes the old base64-plus-dot state format and the `decodeState`
helper it depended on. `customState` and `returnPathname` now come
directly from the cryptographically-verified sealed blob returned by
`authkit.handleCallback`, closing the state-confusion attack vector.

Security properties:
- Delete-cookie Set-Cookie appended on every response leaving the
  callback handler, including when `getAuthkit()` itself fails (static
  fallback broadside covering SameSite=Lax and SameSite=None;Secure).
- Error response bodies no longer echo `error.message` / `details` --
  operational detail stays server-side via console.error.
- `sealedState` is not exposed through the public server-function
  return types; callers still receive `Promise<string>` (URL only).
- Fails loud with actionable error if `authkitMiddleware` context is
  unavailable when generating auth URLs, rather than silently producing
  a URL that always fails at callback.

BREAKING CHANGES:
- Requires @workos/authkit-session@0.4.0.
- `decodeState` helper removed from internal and public surface.
- `customState`/`returnPathname` on OAuth callbacks are now
  integrity-protected by the seal; tampered state parameters fail
  closed with OAuthStateMismatchError instead of silently falling back
  to a root redirect.

Also extracts `parseCookies` from the private `storage.ts` method into
a shared `src/server/cookie-utils.ts` module and re-exports
`OAuthStateMismatchError` / `PKCECookieMissingError` for adopter error
handling.
Collapse the three copy-pasted "inject contextRedirectUri if caller
didn't provide one" blocks across getAuthorizationUrl/getSignInUrl/
getSignUpUrl into a single generic helper. Pure refactor: no behavior
change, same public return types (`Promise<string>`), PKCE cookie
wiring via `writeCookieAndReturn` untouched.
Internal refactor of the TanStack Start adapter to track authkit-session
0.4.0's storage-owned PKCE cookie flow (Phase 2 of the coordinated
refactor in ./docs/ideation/storage-owned-pkce-cookies in authkit-session).

Public adapter surface is unchanged:
- `getSignInUrl`, `getSignUpUrl`, `getAuthorizationUrl` still return
  `Promise<string>`.
- `handleCallbackRoute({ onSuccess, onError })` signature unchanged.

Internally:
- `TanStackStartCookieSessionStorage` now implements `getCookie(request,
  name)`; the `getSession` override is deleted and inherited from the
  base class as a wrapper over `getCookie`.
- Server functions call upstream `createAuthorization`/`createSignIn`/
  `createSignUp` and forward each `Set-Cookie` from the returned
  `HeadersBag` through `__setPendingHeader` (append-per-value, never
  comma-joined).
- Callback handler reads no cookies itself and passes no `cookieValue`
  into `handleCallback`. The library emits both the session cookie and
  the verifier-delete cookie; the adapter appends each via `.append`.
- Error path uses `authkit.clearPendingVerifier(new Response())` to
  obtain the verifier-delete `Set-Cookie`; `STATIC_FALLBACK_DELETE_HEADERS`
  is preserved for the case where `getAuthkit()` itself throws.
- `readPKCECookie` is removed from `cookie-utils.ts`; `parseCookies` is
  kept as a generic helper used by storage.

Example app additions:
- New `/api/auth/sign-in` route that calls `getSignInUrl` at request
  time so the PKCE verifier `Set-Cookie` lands on an actual redirect
  response (rather than a page-loader response that doesn't propagate
  cookies through TanStack's client-side navigation path).
- `__root.tsx`, `_authenticated.tsx`, and `index.tsx` now route the
  sign-in button through that endpoint.
- `package.json` + `pnpm-lock.yaml`: `@workos/authkit-session` is
  pinned via `pnpm.overrides` to `link:../authkit-session`. Reverts
  once 0.4.0 publishes to npm.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 18, 2026

Greptile Summary

This PR refactors the TanStack Start adapter to consume authkit-session 0.4.0's storage-owned PKCE verifier cookie API: createSignIn/createSignUp/createAuthorization replace the old getSignInUrl/getSignUpUrl helpers, the storage class gains a getCookie implementation (dropping the custom getSession override), and the callback handler now receives both the session and verifier-delete cookies as a string[] from the library rather than assembling them locally. The example app ships a new /api/auth/sign-in redirect route so the verifier cookie attaches to an actual HTTP response instead of a page-loader response.

  • P1 — package.json: The pnpm.overrides block pins @workos/authkit-session to link:../authkit-session. This will fail pnpm install in any environment without the sibling repo at ../authkit-session (CI, fresh clones, contributor machines). This block must be removed before the PR merges.

Confidence Score: 4/5

Safe to merge once the link:../authkit-session override is removed from package.json and upstream 0.4.0 is published to npm.

One P1 finding: the pnpm.overrides link: entry breaks installs in any environment without the sibling repo. All other findings are P2. The implementation is well-tested (193 passing), the PKCE cookie wiring is sound, and the public API is unchanged.

package.json — the pnpm.overrides block must be removed before merge.

Important Files Changed

Filename Overview
package.json pnpm.overrides pins @workos/authkit-session to link:../authkit-session; breaks pnpm install in any environment without the sibling repo (P1 — must be removed before merge).
src/server/server-functions.ts Switches to createSignIn/createSignUp/createAuthorization; new forwardAuthorizationCookies helper wires PKCE verifier cookie but silently no-ops if both headers and response are absent (P2).
src/server/server.ts Callback handler refactored to consume handleCallback without cookieValue; appendSessionHeaders uses any type (P2).
src/server/storage.ts Adds getCookie implementation; getSession override deleted and now inherited from base class. Clean.
src/server/cookie-utils.ts readPKCECookie removed; only parseCookies remains as a generic helper. Correct.
example/src/routes/api/auth/sign-in.tsx New GET handler calls getSignInUrl then issues a 307 redirect so the PKCE verifier cookie attaches to the redirect response.
src/server/server.spec.ts Tests updated to remove cookieValue, assert both Set-Cookie headers on the success path, and verify the static fallback path.
src/server/storage.spec.ts New getCookie tests plus inherited getSession wrapper coverage. Good edge-case coverage.

Reviews (1): Last reviewed commit: "feat(pkce)!: consume storage-owned verif..." | Re-trigger Greptile

Comment thread package.json Outdated
Comment on lines +101 to +103
"overrides": {
"@workos/authkit-session": "link:../authkit-session"
}
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.

P1 link: override must be removed before merging

The pnpm.overrides entry resolves @workos/authkit-session to link:../authkit-session, which means any environment that only has this repo checked out (including most CI systems and every downstream contributor) will get a hard install failure — ../authkit-session won't exist. The package.json dependencies field correctly specifies "0.4.0", but pnpm still resolves through the override before publishing. This entire block must be removed (or reverted to a registry version) before the PR lands on main.

Suggested change
"overrides": {
"@workos/authkit-session": "link:../authkit-session"
}
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"esbuild"
]
}

Comment thread src/server/server-functions.ts Outdated
Comment on lines +33 to +48
if (result.headers) {
for (const [key, value] of Object.entries(result.headers)) {
if (Array.isArray(value)) {
for (const v of value) ctx.__setPendingHeader(key, v);
} else if (typeof value === 'string') {
ctx.__setPendingHeader(key, value);
}
}
} else if (result.response) {
// Fallback: storage mutated the Response directly (context-unavailable path).
for (const value of result.response.headers.getSetCookie()) {
ctx.__setPendingHeader('Set-Cookie', value);
}
}

return result.url;
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.

P2 Silent no-op when both headers and response are absent

If the upstream library returns an AuthorizationResult with neither headers nor response populated (e.g., due to a future upstream refactor or a version mismatch), the function returns the URL without setting any cookie and without any log or error. The PKCE verifier cookie is silently dropped, and the failure only surfaces later as a state-mismatch error in the callback. A console.warn here would make the root cause immediately obvious.

Comment thread src/server/server.ts Outdated
return { 'Set-Cookie': setCookie };
}

function appendSessionHeaders(target: Headers, result: any): void {
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.

P2 result: any widens the type unnecessarily

appendSessionHeaders accepts result: any, which removes all type-checking for the result.headers, result.response, and result.response.headers property accesses that follow. Given that AuthorizationResult (defined in server-functions.ts) already captures the expected shape, or a similar local interface could be defined here, tightening this type would surface future breaking changes from the upstream library at compile time rather than at runtime.

Consolidate three near-duplicate HeadersBag iteration blocks
(forwardAuthorizationCookies, signOut, appendSessionHeaders) behind a
single helper in headers-bag.ts. Also drop the `any` parameter on
appendSessionHeaders and remove a change-narrating comment.
…aths

`buildVerifierDeleteHeaders` previously read only `result.headers['Set-Cookie']`
from `clearPendingVerifier`, but this adapter's `applyHeaders` override always
returns `{ response }` with the Set-Cookie attached to the response — the
headers bag is empty. Every error path therefore fell back to the static
root-path delete, which doesn't match per-request `redirectUri` overrides and
leaves the verifier cookie in place for the next attempt.

Read from `response.headers.getSetCookie()` first, fall back to the headers
bag, then to the static default as a last resort.

Reported by Codex review.
Prior fix ensured the verifier-delete header was *extracted* correctly
from the adapter's `{ response }` return shape, but the call was still
`clearPendingVerifier(new Response())` with no options — so upstream
computed the delete cookie's `Path` from the config-default
`redirectUri`, ignoring any per-request override set via
`authkitMiddleware({ redirectUri })`. A failed callback on a scoped
path would therefore emit a `Path=/` delete that doesn't match the
browser's scoped cookie, leaving the verifier behind.

Pull the override from middleware context (same source used by
`applyContextRedirectUri` on the sign-in path) and pass it to
`clearPendingVerifier` so the delete's `Path` matches the cookie
`createSignIn` originally set.

Tests now assert the *call arguments* to `clearPendingVerifier`, not
just the return transport — the prior test only proved a mocked scoped
header would propagate.

Reported by Codex review (follow-up to 3d0222f).
@nicknisi nicknisi changed the title feat(pkce)!: consume storage-owned verifier cookie API (authkit-session 0.4.0) feat!: PKCE state binding + storage-owned verifier cookies (authkit-session 0.4.0) Apr 19, 2026
… or response

Previously `forwardAuthorizationCookies` silently returned the URL when the
upstream authorization result had neither `headers` nor `response` populated.
That path should be impossible under the current `@workos/authkit-session`
contract, but if a future refactor or version mismatch ever hit it, the PKCE
verifier cookie would be dropped and the failure would only surface later
as an opaque state-mismatch error on the callback.

Fail loudly at the drop site so the real cause is visible, matching the
existing throw for a missing middleware context above.
- Extract `emitHeadersFrom` in headers-bag.ts; both
  `forwardAuthorizationCookies` (server-functions.ts) and
  `appendSessionHeaders` (server.ts) now share the "HeadersBag or mutated
  Response → emit" path instead of hand-rolling it twice. The helper
  returns a boolean so each call site keeps its own policy — throw on
  empty for PKCE forwarding, silent no-op for session headers.

- Defer `buildVerifierDeleteHeaders` into `errorResponse`. The success
  path never used the result, so every happy-path callback was paying
  for an extra `clearPendingVerifier` storage round-trip.

- Fix `parseCookies('')` returning `{ '': '' }` — return `{}`. The old
  shape would silently mask "no cookie" as "empty cookie" for any caller
  that indexed by name.

- Drop a test comment that narrated an upstream shape change; it would
  rot the first time upstream shifted again.
`emitHeadersFrom` previously returned `false` when `source.response` was
present but produced zero `Set-Cookie` values, causing
`forwardAuthorizationCookies` to throw on the happy path.

The zero-emit case is legitimate: the storage adapter forwards cookies
directly via `ctx.__setPendingHeader` and hands back a bare
`new Response()` as a contract stub. The server function should consider
"one of headers/response present" as the contract — not "bytes emitted."

Regression from de4e5cb. 196 tests passed because all mocks populated
`headers`; none exercised the response-present-but-empty shape. Added a
regression test across all three authorization URL paths.
Ports workos/authkit-nextjs#402 to this SDK. Adds:

- Setup step for a `/api/auth/sign-in` route used as the dashboard's
  Sign-in endpoint (initiate_login_uri). Required for WorkOS-initiated
  flows like dashboard impersonation, which otherwise hit the callback
  with no `state` and fail PKCE/CSRF verification.
- Dashboard-configuration list now includes the Sign-in endpoint and
  links directly to the Redirects page.
- Troubleshooting entry for `Missing required auth parameter` pointing
  back to the setup step.
Replaces the four remaining `getSignInUrl()`-in-loader + `redirect({ href: signInUrl })`
code samples with `redirect({ href: '/api/auth/sign-in' })` so the
documented patterns match the example app and the Sign-In Endpoint step
added in 5e7cef0. The old inline pattern could race the PKCE verifier
cookie against the cross-origin redirect; routing through the dedicated
endpoint keeps both the cookie set and the WorkOS redirect on the same
server response.
Drops the `link:../authkit-session` pnpm override that pinned the SDK to
the local sibling workspace. The `clearPendingVerifier(response, options)`
signature used by the callback path is now shipped in 0.4.0 on the
registry, so the override is no longer required.

Side effect: the example's authkit-loader server chunk shrinks from
188 kB to 2.9 kB — the registry package tree-shakes correctly where the
workspace link pulled the unbundled module graph.
@devin-ai-integration
Copy link
Copy Markdown

Review — authkit-tanstack-start#66

Reviewed the full diff against main. The PR delivers what the description promises: upstream createSignIn/createSignUp/createAuthorization replace the old URL helpers, the storage's getCookie primitive replaces the custom getSession override, and the callback handler now receives session + verifier-delete as distinct Set-Cookie entries. CI is green (5/5) and the two review-driven fixes (3d0222f, 50284ef) tighten real edge cases (Response-only cookie emission + per-request redirectUri scoping of the verifier-delete Path).

A few observations — none blocking:

1. STATIC_FALLBACK_DELETE_HEADERS hardcodes the verifier cookie name and Path=/. If a consumer customizes the verifier cookie name via config (or scopes the redirectUri to a non-root path), the two static Set-Cookie values in <ref_snippet file="/home/ubuntu/repos/authkit-tanstack-start/src/server/server.ts" lines="7-10" /> won't match whatever the browser actually holds. In practice this only triggers when getAuthkit() itself throws at setup — the library is unavailable so you can't call clearPendingVerifier — so the scope is narrow. Worth a TODO or a doc comment acknowledging the limitation.

2. forwardAuthorizationCookies throws when middleware context is unavailable. This is a meaningful behavior change vs. the pre-0.4.0 flow: <ref_snippet file="/home/ubuntu/repos/authkit-tanstack-start/src/server/server-functions.ts" lines="22-37" /> Before, getSignInUrl() was a pure URL minter that worked in any server function. Now it requires authkitMiddleware registration because it has cookies to write. The README addition covers this, and the error message points at the right fix, so users who hit it will self-diagnose quickly. Just flagging it's a breaking expectation for anyone mid-upgrade who calls getSignInUrl from a non-middleware server-function path (e.g. a background job that only wants the URL string). Consider documenting in a migration note in the release.

3. Example sign-in.tsx forwards returnPathname from the query string without validation. <ref_snippet file="/home/ubuntu/repos/authkit-tanstack-start/example/src/routes/api/auth/sign-in.tsx" lines="1-17" /> This is fine given authkit-session seals returnPathname into the signed state and validates same-origin at callback time — but it's worth a line of comment in the example noting that upstream (not the endpoint) is what makes this safe, so consumers copying this pattern don't remove the library call and invent their own.

4. Nit — the removal of decodeState from auth-helpers.ts and the handoff of state/returnPathname to result.state/result.returnPathname is a nice simplification. server.ts:131 cleanly preserves the options.returnPathname ?? result.returnPathname ?? '/' precedence.

5. emitHeadersFrom returning true for a bare Response stub (the 67af29f fix) is the right call. The contract "headers OR response was present" is what consumers actually need to know; zero-emit when storage forwarded out-of-band is a legitimate outcome, not a bug.

6. forEachHeaderBagEntry dedup across forwardAuthorizationCookies / appendSessionHeaders / signOut is clean. Solid consolidation; gets rid of the three slightly-different copies that existed before.

No P1 findings. The P1 greptile flagged (pnpm.overrides link:../authkit-session) has already been removed — CI confirms a clean pnpm install against the published 0.4.0. The manual test-plan items (happy-path + state-tampering) are the right things to verify pre-merge, since the automated coverage can't catch browser-cookie-lifecycle regressions.

Approving pending the manual QA checklist.

Copy link
Copy Markdown
Contributor

@gjtorikian gjtorikian left a comment

Choose a reason for hiding this comment

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

I don't think Devin's documentation concerns are worth addressing.

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.

2 participants