Skip to content

feat(pkce)!: consume storage-owned verifier cookie API (authkit-session 0.4.0)#66

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

feat(pkce)!: consume storage-owned verifier cookie API (authkit-session 0.4.0)#66
nicknisi wants to merge 3 commits intomainfrom
feat/pkce-state-binding

Conversation

@nicknisi
Copy link
Copy Markdown
Member

Summary

Internal refactor of the TanStack Start adapter to consume authkit-session 0.4.0's storage-owned PKCE verifier cookie flow. Phase 2 of the coordinated three-repo refactor; spec at 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

  • TanStackStartCookieSessionStorage now implements getCookie(request, name); the getSession override is deleted and inherits from the base class as a wrapper over getCookie.
  • 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).
  • server.ts (callback handler) no longer reads the PKCE cookie or passes cookieValue into handleCallback. The library now 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.
  • The error path now uses authkit.clearPendingVerifier(new Response()) to obtain the verifier-delete Set-Cookie. STATIC_FALLBACK_DELETE_HEADERS is preserved as a safety net for the case where getAuthkit() itself throws during callback setup (upstream instance unavailable → can't call clearPendingVerifier).
  • readPKCECookie is removed from cookie-utils.ts; parseCookies remains as a generic helper used by storage.ts.
  • Tests updated: server.spec.ts drops cookieValue, asserts both Set-Cookies land on the success path; storage.spec.ts gets direct getCookie tests plus a proof that the inherited getSession wrapper still works; server-functions.spec.ts consumes the new { url, headers } shape.

Example app

Reworked the sign-in flow 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 path):

  • 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.

Upstream pin

  • @workos/authkit-session is pinned via pnpm.overrides to link:../authkit-session for the duration of this coordinated refactor. package.json still lists 0.4.0 as the dependency specifier; the override flips back to the registry once 0.4.0 publishes to npm.

Test plan

  • pnpm test — 193/193 passing
  • pnpm typecheck — clean
  • pnpm lint — 0 warnings / 0 errors
  • pnpm format:check — clean
  • pnpm build — succeeds
  • Manual: run example app, complete happy-path sign-in, confirm wos-auth-verifier cookie 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 cookie is cleared.

Coordination notes

  • Do not merge until authkit-session 0.4.0 is tagged / published. The link:../authkit-session override is a local-dev convenience, not a publishable dependency.
  • Parallel work on authkit-sveltekit (Phase 3) consumes the same upstream API (createSignIn/createSignUp/createAuthorization, clearPendingVerifier, storage getCookie). This PR's approach — forward the HeadersBag directly and append each Set-Cookie — should translate 1:1 to the SvelteKit side.

Deviations from spec

  • forwardAuthorizationCookies (renamed from the spec's suggested forwardSetCookies) was implemented inline in server-functions.ts rather than extracted to a utility module — only one call site needs it and the logic is ~15 lines. Easy to extract later if a second consumer appears.
  • appendSessionHeaders was generalized to (a) prefer the headers bag when present and (b) fall back to the mutated Response's getSetCookie(). This handles both upstream routing modes (context-available vs context-unavailable in storage) without the caller caring which path fired.
  • Kept STATIC_FALLBACK_DELETE_HEADERS per the spec's failure-mode guidance — emits a static verifier-delete when getAuthkit() throws at setup.

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
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 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
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.

@nicknisi nicknisi marked this pull request as draft April 18, 2026 15:54
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.

1 participant