feat(pkce)!: consume storage-owned verifier cookie API (authkit-session 0.4.0)#66
feat(pkce)!: consume storage-owned verifier cookie API (authkit-session 0.4.0)#66
Conversation
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 SummaryThis PR refactors the TanStack Start adapter to consume
Confidence Score: 4/5Safe to merge once the One P1 finding: the
Important Files Changed
Reviews (1): Last reviewed commit: "feat(pkce)!: consume storage-owned verif..." | Re-trigger Greptile |
| "overrides": { | ||
| "@workos/authkit-session": "link:../authkit-session" | ||
| } |
There was a problem hiding this comment.
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.
| "overrides": { | |
| "@workos/authkit-session": "link:../authkit-session" | |
| } | |
| "pnpm": { | |
| "onlyBuiltDependencies": [ | |
| "@parcel/watcher", | |
| "esbuild" | |
| ] | |
| } |
| 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; |
There was a problem hiding this comment.
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.
| return { 'Set-Cookie': setCookie }; | ||
| } | ||
|
|
||
| function appendSessionHeaders(target: Headers, result: any): void { |
There was a problem hiding this comment.
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.
Summary
Internal refactor of the TanStack Start adapter to consume
authkit-session0.4.0's storage-owned PKCE verifier cookie flow. Phase 2 of the coordinated three-repo refactor; spec atdocs/ideation/storage-owned-pkce-cookies/spec-phase-2.mdinauthkit-session.Public adapter API unchanged.
getSignInUrl/getSignUpUrl/getAuthorizationUrlstill returnPromise<string>;handleCallbackRoute({ onSuccess, onError })signature unchanged.What moved internally
TanStackStartCookieSessionStoragenow implementsgetCookie(request, name); thegetSessionoverride is deleted and inherits from the base class as a wrapper overgetCookie.server-functions.tscalls upstreamcreateAuthorization/createSignIn/createSignUpand forwards eachSet-Cookiefrom the returnedHeadersBagthrough__setPendingHeader(append-per-value — never comma-joined).server.ts(callback handler) no longer reads the PKCE cookie or passescookieValueintohandleCallback. The library now emits both the session cookie and the verifier-delete cookie as astring[]; the adapter appends each via.appendso they survive as distinct HTTP headers.authkit.clearPendingVerifier(new Response())to obtain the verifier-deleteSet-Cookie.STATIC_FALLBACK_DELETE_HEADERSis preserved as a safety net for the case wheregetAuthkit()itself throws during callback setup (upstream instance unavailable → can't callclearPendingVerifier).readPKCECookieis removed fromcookie-utils.ts;parseCookiesremains as a generic helper used bystorage.ts.server.spec.tsdropscookieValue, asserts both Set-Cookies land on the success path;storage.spec.tsgets directgetCookietests plus a proof that the inheritedgetSessionwrapper still works;server-functions.spec.tsconsumes the new{ url, headers }shape.Example app
Reworked the sign-in flow so the PKCE verifier
Set-Cookielands on an actual redirect response rather than a page-loader response (which doesn't propagate cookies through TanStack's client-side navigation path):/api/auth/sign-inroute that callsgetSignInUrlat request time and issues a 307 redirect with the cookie attached.__root.tsx,_authenticated.tsx, andindex.tsxnow route the sign-in button through that endpoint instead of pre-resolving the URL in their loaders.Upstream pin
@workos/authkit-sessionis pinned viapnpm.overridestolink:../authkit-sessionfor the duration of this coordinated refactor.package.jsonstill lists0.4.0as the dependency specifier; the override flips back to the registry once 0.4.0 publishes to npm.Test plan
pnpm test— 193/193 passingpnpm typecheck— cleanpnpm lint— 0 warnings / 0 errorspnpm format:check— cleanpnpm build— succeedswos-auth-verifiercookie is written on sign-in and cleared on callback (expect 2Set-Cookieheaders on the callback response: session + verifier-delete).Coordination notes
authkit-session0.4.0 is tagged / published. Thelink:../authkit-sessionoverride is a local-dev convenience, not a publishable dependency.authkit-sveltekit(Phase 3) consumes the same upstream API (createSignIn/createSignUp/createAuthorization,clearPendingVerifier, storagegetCookie). This PR's approach — forward theHeadersBagdirectly and append eachSet-Cookie— should translate 1:1 to the SvelteKit side.Deviations from spec
forwardAuthorizationCookies(renamed from the spec's suggestedforwardSetCookies) was implemented inline inserver-functions.tsrather 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.appendSessionHeaderswas generalized to (a) prefer theheadersbag when present and (b) fall back to the mutated Response'sgetSetCookie(). This handles both upstream routing modes (context-available vs context-unavailable in storage) without the caller caring which path fired.STATIC_FALLBACK_DELETE_HEADERSper the spec's failure-mode guidance — emits a static verifier-delete whengetAuthkit()throws at setup.