diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5fdfbcd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +All notable changes to `@workos-inc/authkit-react-router` are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +While the package is pre-1.0, minor version bumps (e.g. `0.4.x → 0.10.0`) are +used to signal breaking changes. + +## [Unreleased] + +### Added + +- **PKCE + CSRF protection** on the authorization-code flow. Each sign-in / + sign-up redirect now sets a short-lived (10 minute), flow-specific + `wos-auth-verifier-` cookie containing a sealed OAuth `state` value, + and the callback verifies the round-tripped `state` matches the cookie + (double-submit) before exchanging the code plus PKCE verifier. Concurrent + flows get distinct cookie names so stacked sign-in attempts don't + overwrite each other. +- `getSignInUrl` / `getSignUpUrl` / `getAuthorizationUrl` now accept the + incoming `Request` so the PKCE cookie's `Secure` attribute reflects the + live request protocol (fixes local-dev with `http://localhost` and an + `https://` `WORKOS_REDIRECT_URI`). + +### Changed + +- **Breaking:** `getSignInUrl`, `getSignUpUrl`, and `getAuthorizationUrl` + now return `{ url, headers }` instead of a bare URL string. The + `headers` include a `Set-Cookie` that **must** travel to the browser on + the redirect response; otherwise the callback will reject the flow as + a CSRF failure. See the + [migration guide](./README.md#migrating-from-04x) for the redirect-route + pattern that replaces the old "render a sign-in URL in a ``" + approach. +- **Breaking:** Minimum `@workos-inc/node` is now `^8.9.0` (for the + `pkce` namespace). +- `authkitLoader` and `switchToOrganization` automatically forward the + new PKCE cookie on redirects they initiate, so most consumers don't + need to thread it through manually. +- `switchToOrganization` no longer emits an empty `Set-Cookie: ''` header + when `refreshSession` returns without one. +- `signOut` / `terminateSession` now also clears any orphan + `wos-auth-verifier-*` cookies left behind by abandoned OAuth flows + (tabs closed mid-sign-in, etc.) so they don't accumulate under the + browser's per-domain cookie cap. +- The callback now clears the PKCE verifier cookie on WorkOS error + callbacks (`?error=…&state=…` with no `code`) instead of only on + success/exception paths, so abandoned flows don't leave orphan + cookies until the 10-minute TTL expires. +- `verifyAccessToken` now validates the JWT `iss` claim against + `https://api.workos.com` (the fixed issuer WorkOS stamps on every + access token) in addition to the signature, so a token signed by a + different WorkOS project whose JWKS happens to resolve to the same + keys is rejected. + +### Docs + +- New **Sign-in endpoint** section documenting the `initiate_login_uri` + dashboard setting, including a callout that a configured sign-in + endpoint is required for dashboard impersonation to work. +- New **Troubleshooting** entry for the + `Missing required auth parameter` error surfaced when an + impersonation flow reaches the callback without routing through the + sign-in endpoint. diff --git a/README.md b/README.md index 95f7a5a..2d30fde 100644 --- a/README.md +++ b/README.md @@ -142,10 +142,10 @@ import { authkitLoader } from '@workos-inc/authkit-react-router'; export const loader = (args: LoaderFunctionArgs) => authkitLoader(args); export function App() { - // Retrieves the user from the session or returns `null` if no user is signed in + // Retrieves the user from the session or returns `null` if no user is signed in. // Other supported values include `sessionId`, `organizationId`, // `role`, `permissions`, `entitlements`, `featureFlags`, and `impersonator`. - const { user, signInUrl, signUpUrl } = useLoaderData(); + const { user } = useLoaderData(); return (
@@ -155,33 +155,66 @@ export function App() { } ``` -For pages where you want to display a signed-in and signed-out view, use `authkitLoader` to retrieve the user profile from WorkOS. You can pass in additional data by providing a loader function directly to `authkitLoader`. +### Sign-in and sign-up routes + +`getSignInUrl` and `getSignUpUrl` return a `{ url, headers }` pair. The +`headers` contain a short-lived `Set-Cookie` used for PKCE + CSRF +protection, which **must** travel to the browser on the same redirect +response that sends the user to AuthKit. Create dedicated redirect routes +for sign-in and sign-up and link to those routes from your pages: + +```ts +// app/routes/login.ts +import { redirect, type LoaderFunctionArgs } from 'react-router'; +import { getSignInUrl } from '@workos-inc/authkit-react-router'; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const { url: authUrl, headers } = await getSignInUrl(url.searchParams.get('returnTo') ?? undefined, request); + return redirect(authUrl, { headers }); +} +``` + +```ts +// app/routes/signup.ts +import { redirect, type LoaderFunctionArgs } from 'react-router'; +import { getSignUpUrl } from '@workos-inc/authkit-react-router'; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const { url: authUrl, headers } = await getSignUpUrl(url.searchParams.get('returnTo') ?? undefined, request); + return redirect(authUrl, { headers }); +} +``` + +Passing `request` ensures the `Secure` attribute on the PKCE cookie +matches your app's live protocol (important in local dev, where the app +runs on `http://localhost` even if `WORKOS_REDIRECT_URI` is an `https://` +URL). + +Then link to those routes from any page where you want to offer sign-in +or sign-up: ```tsx -import { type ActionFunctionArgs, type LoaderFunctionArgs, data, Form, Link, useLoaderData } from 'react-router'; -import { getSignInUrl, getSignUpUrl, signOut, authkitLoader } from '@workos-inc/authkit-react-router'; +// app/routes/_index.tsx +import { type ActionFunctionArgs, type LoaderFunctionArgs, Form, Link, useLoaderData } from 'react-router'; +import { signOut, authkitLoader } from '@workos-inc/authkit-react-router'; -export const loader = (args: LoaderFunctionArgs) => - authkitLoader(args, async ({ request, auth }) => { - return data({ - signInUrl: await getSignInUrl(), - signUpUrl: await getSignUpUrl(), - }); - }); +export const loader = (args: LoaderFunctionArgs) => authkitLoader(args); export async function action({ request }: ActionFunctionArgs) { return await signOut(request); } export default function HomePage() { - const { user, signInUrl, signUpUrl } = useLoaderData(); + const { user } = useLoaderData(); if (!user) { return ( <> - Log in + Log in
- Sign Up + Sign Up ); } @@ -195,6 +228,35 @@ export default function HomePage() { } ``` +> [!NOTE] +> +> Prior to `0.10.0`, `getSignInUrl` / `getSignUpUrl` returned a bare URL +> string that could be rendered directly in a ``. That pattern is +> no longer supported — see [Migrating from 0.4.x](#migrating-from-04x) +> below. + +### Sign-in endpoint + +The sign-in route above doubles as your **Sign-in endpoint** (also known +as `initiate_login_uri`) — the URL WorkOS redirects to when it needs to +start an authentication flow on your app's behalf (for example, when an +admin impersonates a user from the dashboard, or when a password-reset +email lands on a device that is not already signed in). + +In the [WorkOS dashboard](https://dashboard.workos.com), go to +**Redirects** and set the **Sign-in endpoint** to the public URL of the +route (e.g., `http://localhost:5173/login` in development, +`https://your-app.com/login` in production). + +> [!IMPORTANT] +> A configured Sign-in endpoint is required for +> [impersonation](https://workos.com/docs/user-management/impersonation) +> to work. Without it, WorkOS-initiated flows (such as impersonating a +> user from the dashboard) redirect directly to your callback URL +> without a `state` parameter and fail the PKCE/CSRF verification this +> library enforces on every callback, surfacing as a +> `Missing required auth parameter` error. + ### Requiring auth For pages where a signed-in user is mandatory, you can use the `ensureSignedIn` option: @@ -497,3 +559,84 @@ export const loader = (args) => > When deploying to serverless environments like AWS Lambda, ensure you pass the same storage configuration to both your main routes and the callback route to handle cold starts properly. AuthKit works with any session storage that implements React Router's `SessionStorage` interface, including Redis-based or database-backed implementations. + +## Troubleshooting + +### `Missing required auth parameter` when impersonating from the WorkOS dashboard + +This error occurs when WorkOS-initiated flows (such as dashboard +impersonation) redirect directly to your callback URL without going +through your application's sign-in flow. Because this library enforces +PKCE/CSRF verification on every callback, the request is rejected when +the required `state` parameter is missing. + +**Fix:** Configure a [sign-in endpoint](#sign-in-endpoint) in your +WorkOS dashboard so that impersonation flows route through your app +first, allowing the PKCE verifier and CSRF state to be set up before +redirecting to WorkOS. + +## Migrating from 0.4.x + +`0.10.0` is a breaking release that adds PKCE and CSRF protection to the +authorization-code flow. Upgrading from `0.4.x` requires small changes to +any route that builds a sign-in or sign-up URL. + +### 1. `getSignInUrl` / `getSignUpUrl` now return `{ url, headers }` + +They used to return a bare URL string. They now return an object with a +`url` and a `Set-Cookie` header that **must** travel to the browser on +the redirect that starts the OAuth flow, so that the callback can verify +the response came from this browser (CSRF) and recover the PKCE code +verifier. + +```ts +// 0.4.x +const signInUrl = await getSignInUrl(); +return redirect(signInUrl); + +// 0.10.0+ +const { url, headers } = await getSignInUrl('/dashboard', request); +return redirect(url, { headers }); +``` + +### 2. Use a dedicated redirect route for sign-in / sign-up + +The old "load the URL into your page data and render it in a ``" +pattern no longer works: the cookie and the URL must leave the server on +the same response. Move the URL generation into a loader that returns a +redirect, and link to that loader's path from your page: + +```ts +// app/routes/login.ts +export async function loader({ request }: LoaderFunctionArgs) { + const { url, headers } = await getSignInUrl(undefined, request); + return redirect(url, { headers }); +} +``` + +```tsx +// Any page: +Log in +``` + +See [Sign-in and sign-up routes](#sign-in-and-sign-up-routes) above for +the full pattern. + +### 3. Pass `request` when calling from a loader + +`getSignInUrl` / `getSignUpUrl` (and `getAuthorizationUrl`) accept the +incoming `Request` as their second argument. Pass it when available so +the PKCE cookie's `Secure` attribute reflects the live request protocol +rather than the configured `redirectUri`'s — otherwise local dev on +`http://localhost` with `WORKOS_REDIRECT_URI=https://…` mints a `Secure` +cookie the browser silently drops, and the callback fails with +`Auth cookie missing`. + +### 4. `@workos-inc/node` minimum is `^8.9.0` + +PKCE is implemented in `@workos-inc/node`'s `pkce` namespace, which +requires `^8.9.0`. If your app pins an older version, upgrade: + +```bash +npm install @workos-inc/node@^8.9.0 +``` diff --git a/package-lock.json b/package-lock.json index 7726b01..4130c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.10.0", "license": "MIT", "dependencies": { - "@workos-inc/node": "^7.41.0", + "@workos-inc/node": "^8.9.0", "iron-session": "^8.0.1", - "jose": "^5.2.3" + "jose": "^5.2.3", + "tslib": "^2.8.1", + "valibot": "^1.2.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -19,7 +21,7 @@ "@types/jest": "^29.5.14", "@types/node": "^24.10.3", "@typescript-eslint/eslint-plugin": "^7.18.0", - "@workos-inc/node": "^7.77.0", + "@workos-inc/node": "^8.9.0", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-require-extensions": "^0.1.3", @@ -2724,45 +2726,6 @@ "node": ">= 8" } }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.13.tgz", - "integrity": "sha512-3Xq3a01WkHRZL8X04Zsfg//mGaA21xlL4tlVn4v2xGT0JStiztATRkMwa5b+f/HXmY2smsiLXYK46Gwgzvfg3g==", - "dev": true, - "dependencies": { - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2" - } - }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dev": true, - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@peculiar/webcrypto": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", - "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", - "dev": true, - "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/json-schema": "^1.1.12", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2", - "webcrypto-core": "^1.8.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2922,15 +2885,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/accepts": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", - "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2983,79 +2937,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/content-disposition": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.8.tgz", - "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==", - "dev": true - }, - "node_modules/@types/cookies": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", - "integrity": "sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==", - "dev": true, - "dependencies": { - "@types/connect": "*", - "@types/express": "*", - "@types/keygrip": "*", - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-assert": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", - "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", - "dev": true - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3134,43 +3015,6 @@ "parse5": "^7.0.0" } }, - "node_modules/@types/keygrip": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", - "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", - "dev": true - }, - "node_modules/@types/koa": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", - "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", - "dev": true, - "dependencies": { - "@types/accepts": "*", - "@types/content-disposition": "*", - "@types/cookies": "*", - "@types/http-assert": "*", - "@types/http-errors": "*", - "@types/keygrip": "*", - "@types/koa-compose": "*", - "@types/node": "*" - } - }, - "node_modules/@types/koa-compose": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.8.tgz", - "integrity": "sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==", - "dev": true, - "dependencies": { - "@types/koa": "*" - } - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true - }, "node_modules/@types/node": { "version": "24.10.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.3.tgz", @@ -3181,39 +3025,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@types/qs": { - "version": "6.9.17", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", - "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", - "dev": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3712,86 +3523,16 @@ ] }, "node_modules/@workos-inc/node": { - "version": "7.77.0", - "resolved": "https://registry.npmjs.org/@workos-inc/node/-/node-7.77.0.tgz", - "integrity": "sha512-6LGBAqih8kkzhHqmxueT9/xX93AJQxQhKekyNs0mqgWsrnqOPDiag1WIFzgxbQTvr564MqtEW502Tatfp1+x0Q==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/@workos-inc/node/-/node-8.13.0.tgz", + "integrity": "sha512-NgQKHpwh8AbT4KvAsW91Y+4f4jja2IvFPQ5atcy5NUxUMVRgXzRFEee3erawfXrTmiCVqJjd9PljHySKBXmHKQ==", "dev": true, "license": "MIT", "dependencies": { - "iron-session": "~6.3.1", - "jose": "~5.6.3", - "leb": "^1.0.0", - "qs": "6.14.0" + "eventemitter3": "^5.0.4" }, "engines": { - "node": ">=16" - } - }, - "node_modules/@workos-inc/node/node_modules/@types/cookie": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", - "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==", - "dev": true - }, - "node_modules/@workos-inc/node/node_modules/@types/node": { - "version": "17.0.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", - "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", - "dev": true - }, - "node_modules/@workos-inc/node/node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@workos-inc/node/node_modules/iron-session": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-6.3.1.tgz", - "integrity": "sha512-3UJ7y2vk/WomAtEySmPgM6qtYF1cZ3tXuWX5GsVX4PJXAcs5y/sV9HuSfpjKS6HkTL/OhZcTDWJNLZ7w+Erx3A==", - "dev": true, - "dependencies": { - "@peculiar/webcrypto": "^1.4.0", - "@types/cookie": "^0.5.1", - "@types/express": "^4.17.13", - "@types/koa": "^2.13.5", - "@types/node": "^17.0.41", - "cookie": "^0.5.0", - "iron-webcrypto": "^0.2.5" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "express": ">=4", - "koa": ">=2", - "next": ">=10" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - }, - "koa": { - "optional": true - }, - "next": { - "optional": true - } - } - }, - "node_modules/@workos-inc/node/node_modules/iron-webcrypto": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-0.2.8.tgz", - "integrity": "sha512-YPdCvjFMOBjXaYuDj5tiHst5CEk6Xw84Jo8Y2+jzhMceclAnb3+vNPP/CTtb5fO2ZEuXEaO4N+w62Vfko757KA==", - "dev": true, - "dependencies": { - "buffer": "^6" - }, - "funding": { - "url": "https://github.com/sponsors/brc-dd" + "node": ">=20.15.0" } }, "node_modules/acorn": { @@ -3950,20 +3691,6 @@ "node": ">=8" } }, - "node_modules/asn1js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", - "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", - "dev": true, - "dependencies": { - "pvtsutils": "^1.3.2", - "pvutils": "^1.1.3", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -4069,26 +3796,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/baseline-browser-mapping": { "version": "2.9.6", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz", @@ -4176,30 +3883,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4207,37 +3890,6 @@ "dev": true, "license": "MIT" }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4591,21 +4243,6 @@ "dev": true, "peer": true }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4663,39 +4300,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4952,6 +4556,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5165,15 +4776,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5193,31 +4795,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -5228,20 +4805,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -5332,19 +4895,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5388,31 +4938,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -5484,26 +5009,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -8418,13 +7923,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/leb": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/leb/-/leb-1.0.0.tgz", - "integrity": "sha512-Y3c3QZfvKWHX60BVOQPhLCvVGmDYWyJEiINE3drOog6KCyN2AOwvuQQzlS3uJg1J85kzpILXIUwRXULWavir+w==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8533,16 +8031,6 @@ "tmpl": "1.0.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8705,19 +8193,6 @@ "dev": true, "license": "MIT" }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9088,40 +8563,6 @@ ], "license": "MIT" }, - "node_modules/pvtsutils": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", - "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", - "dev": true, - "dependencies": { - "tslib": "^2.6.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9386,82 +8827,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9910,7 +9275,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -9950,7 +9315,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10109,6 +9474,20 @@ "node": ">=10.12.0" } }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -10131,19 +9510,6 @@ "makeerror": "1.0.12" } }, - "node_modules/webcrypto-core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", - "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", - "dev": true, - "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/json-schema": "^1.1.12", - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.5", - "tslib": "^2.7.0" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 95d30a9..e05fcde 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "main": "./dist/cjs/index.js", "types": "./dist/cjs/index.d.ts", "engines": { - "node": ">=20.0.0" + "node": ">=20.15.0" }, "files": [ "dist", @@ -27,9 +27,11 @@ "format": "prettier --write \"{src,__tests__}/**/*.{js,ts,tsx}\"" }, "dependencies": { - "@workos-inc/node": "^7.41.0", + "@workos-inc/node": "^8.9.0", "iron-session": "^8.0.1", - "jose": "^5.2.3" + "jose": "^5.2.3", + "tslib": "^2.8.1", + "valibot": "^1.2.0" }, "peerDependencies": { "react": "^18.0 || ^19.0.0", @@ -42,7 +44,7 @@ "@types/jest": "^29.5.14", "@types/node": "^24.10.3", "@typescript-eslint/eslint-plugin": "^7.18.0", - "@workos-inc/node": "^7.77.0", + "@workos-inc/node": "^8.9.0", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-require-extensions": "^0.1.3", diff --git a/src/auth.spec.ts b/src/auth.spec.ts index 9e00310..3e583f3 100644 --- a/src/auth.spec.ts +++ b/src/auth.spec.ts @@ -45,11 +45,23 @@ jest.mock('react-router', () => { describe('auth', () => { beforeEach(() => { jest.spyOn(authorizationUrl, 'getAuthorizationUrl'); + getConfig.mockImplementation((key: string) => { + const map: Record = { + clientId: 'client_1234567890', + redirectUri: 'http://localhost:5173/callback', + cookiePassword: 'kR620keEzOIzPThfnMEAba8XYgKdQ5vg', + apiKey: 'sk_test_1234567890', + cookieName: 'wos-session', + }; + return map[key]; + }); }); describe('getSignInUrl', () => { - it('should return a URL', async () => { - expect(await getSignInUrl('/test')).toMatch(/^https:\/\/api\.workos\.com/); + it('returns a URL and a PKCE Set-Cookie header', async () => { + const result = await getSignInUrl('/test'); + expect(result.url).toMatch(/^https:\/\/api\.workos\.com/); + expect(result.headers['Set-Cookie']).toMatch(/^wos-auth-verifier-/); expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalledWith( expect.objectContaining({ returnPathname: '/test', screenHint: 'sign-in' }), ); @@ -57,8 +69,10 @@ describe('auth', () => { }); describe('getSignUpUrl', () => { - it('should return a URL', async () => { - expect(await getSignUpUrl()).toMatch(/^https:\/\/api\.workos\.com/); + it('returns a URL and a PKCE Set-Cookie header', async () => { + const result = await getSignUpUrl(); + expect(result.url).toMatch(/^https:\/\/api\.workos\.com/); + expect(result.headers['Set-Cookie']).toMatch(/^wos-auth-verifier-/); expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalledWith( expect.objectContaining({ screenHint: 'sign-up' }), ); @@ -187,36 +201,45 @@ describe('auth', () => { it('should redirect to authorization URL for SSO_required errors', async () => { const authUrl = 'https://api.workos.com/sso/authorize'; + const pkceHeaders = { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/' }; const errorWithSSOCause = new Error('SSO Required', { cause: { error: 'sso_required' }, }); refreshSession.mockRejectedValueOnce(errorWithSSOCause); - (authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce(authUrl); + (authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce({ + url: authUrl, + headers: pkceHeaders, + }); const result = await switchToOrganization(request, organizationId); expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalled(); - expect(redirect).toHaveBeenCalledWith(authUrl); + expect(redirect).toHaveBeenCalledWith(authUrl, { headers: pkceHeaders }); assertIsResponse(result); expect(result.status).toBe(302); expect(result.headers.get('Location')).toBe(authUrl); + expect(result.headers.get('Set-Cookie')).toBe(pkceHeaders['Set-Cookie']); }); it('should handle mfa_enrollment errors', async () => { const authUrl = 'https://api.workos.com/sso/authorize'; + const pkceHeaders = { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/' }; const errorWithMFACause = new Error('MFA Enrollment Required', { cause: { error: 'mfa_enrollment' }, }); refreshSession.mockRejectedValueOnce(errorWithMFACause); - (authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce(authUrl); + (authorizationUrl.getAuthorizationUrl as jest.Mock).mockResolvedValueOnce({ + url: authUrl, + headers: pkceHeaders, + }); const result = await switchToOrganization(request, organizationId); expect(authorizationUrl.getAuthorizationUrl).toHaveBeenCalled(); - expect(redirect).toHaveBeenCalledWith(authUrl); + expect(redirect).toHaveBeenCalledWith(authUrl, { headers: pkceHeaders }); assertIsResponse(result); expect(result.status).toBe(302); @@ -260,7 +283,7 @@ describe('auth', () => { ); }); - it('should handle when Set-Cookie header is missing', async () => { + it('omits the Set-Cookie response header when refreshSession returns none', async () => { // Create a mock without the Set-Cookie header const mockResponseWithoutCookie = { ...mockAuthResponse, @@ -270,17 +293,10 @@ describe('auth', () => { await switchToOrganization(request, organizationId); - expect(data).toHaveBeenCalledWith( - { success: true, auth: mockResponseWithoutCookie }, - { - headers: { - 'Set-Cookie': '', - }, - }, - ); + expect(data).toHaveBeenCalledWith({ success: true, auth: mockResponseWithoutCookie }, undefined); }); - it('should handle when returnTo is provided but Set-Cookie header is missing', async () => { + it('omits the Set-Cookie response header on returnTo when refreshSession returns none', async () => { // Create a mock without the Set-Cookie header const mockResponseWithoutCookie = { ...mockAuthResponse, @@ -290,11 +306,7 @@ describe('auth', () => { await switchToOrganization(request, organizationId, { returnTo: '/dashboard' }); - expect(redirect).toHaveBeenCalledWith('/dashboard', { - headers: { - 'Set-Cookie': '', - }, - }); + expect(redirect).toHaveBeenCalledWith('/dashboard', undefined); }); }); diff --git a/src/auth.ts b/src/auth.ts index 7014306..3cc77dc 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,15 +1,42 @@ import { LoaderFunctionArgs, data, redirect } from 'react-router'; import { getAuthorizationUrl } from './get-authorization-url.js'; import { getClaimsFromAccessToken, getSessionFromCookie, refreshSession, terminateSession } from './session.js'; -import { NoUserInfo, UserInfo } from './interfaces.js'; +import { GetAuthURLResult, NoUserInfo, UserInfo } from './interfaces.js'; import { getConfig } from './config.js'; -export async function getSignInUrl(returnPathname?: string) { - return getAuthorizationUrl({ returnPathname, screenHint: 'sign-in' }); +/** + * Build a sign-in URL and the short-lived PKCE / CSRF cookie that must travel + * back to the browser on the redirect. + * + * Pass `request` when calling from a loader so the cookie's `Secure` + * attribute matches the live protocol (important for local dev on + * `http://` against an `https://` redirect URI). + * + * @example + * export async function loader({ request }: LoaderFunctionArgs) { + * const { url, headers } = await getSignInUrl('/dashboard', request); + * return redirect(url, { headers }); + * } + */ +export async function getSignInUrl(returnPathname?: string, request?: Request): Promise { + return getAuthorizationUrl({ returnPathname, screenHint: 'sign-in', request }); } -export async function getSignUpUrl(returnPathname?: string) { - return getAuthorizationUrl({ returnPathname, screenHint: 'sign-up' }); +/** + * Build a sign-up URL and the short-lived PKCE / CSRF cookie that must travel + * back to the browser on the redirect. + * + * Pass `request` when calling from a loader so the cookie's `Secure` + * attribute matches the live protocol. + * + * @example + * export async function loader({ request }: LoaderFunctionArgs) { + * const { url, headers } = await getSignUpUrl('/welcome', request); + * return redirect(url, { headers }); + * } + */ +export async function getSignUpUrl(returnPathname?: string, request?: Request): Promise { + return getAuthorizationUrl({ returnPathname, screenHint: 'sign-up', request }); } export async function signOut(request: Request, options?: { returnTo?: string }) { @@ -95,24 +122,20 @@ export async function switchToOrganization( try { const auth = await refreshSession(request, { organizationId }); + // `refreshSession` always returns a `Set-Cookie` header for a successful + // refresh; guard with `as Record` to satisfy the wider + // typing on AuthLoaderSuccessData without silently emitting an empty + // `Set-Cookie` header if the invariant ever changes. + const setCookie = (auth.headers as Record | undefined)?.['Set-Cookie']; + const responseHeaders = setCookie ? { 'Set-Cookie': setCookie } : undefined; + // if returnTo is provided, redirect to there if (returnTo) { - return redirect(returnTo, { - headers: { - 'Set-Cookie': auth.headers?.['Set-Cookie'] ?? '', - }, - }); + return redirect(returnTo, responseHeaders ? { headers: responseHeaders } : undefined); } // otherwise return the updated auth data - return data( - { success: true, auth }, - { - headers: { - 'Set-Cookie': auth.headers?.['Set-Cookie'] ?? '', - }, - }, - ); + return data({ success: true, auth }, responseHeaders ? { headers: responseHeaders } : undefined); } catch (error) { if (error instanceof Response && error.status === 302) { throw error; @@ -121,7 +144,8 @@ export async function switchToOrganization( // eslint-disable-next-line @typescript-eslint/no-explicit-any const errorCause: any = error instanceof Error ? error.cause : null; if (errorCause?.error === 'sso_required' || errorCause?.error === 'mfa_enrollment') { - return redirect(await getAuthorizationUrl({ organizationId })); + const { url, headers } = await getAuthorizationUrl({ organizationId, request }); + return redirect(url, { headers }); } return data( diff --git a/src/authkit-callback-route.spec.ts b/src/authkit-callback-route.spec.ts index ce37437..62e2f97 100644 --- a/src/authkit-callback-route.spec.ts +++ b/src/authkit-callback-route.spec.ts @@ -1,9 +1,11 @@ import { getWorkOS } from './workos.js'; import { authLoader } from './authkit-callback-route.js'; import { - createRequestWithSearchParams, - createAuthWithCodeResponse, assertIsResponse, + createAuthWithCodeResponse, + createRequestWithCookieAndParams, + createRequestWithSearchParams, + createSealedState, } from './test-utils/test-helpers.js'; import { configureSessionStorage } from './sessionStorage.js'; import { isDataWithResponseInit } from './utils.js'; @@ -25,6 +27,9 @@ jest.mock('./workos.js', () => ({ describe('authLoader', () => { let loader: ReturnType; let request: Request; + let sealedState: string; + let cookieHeader: string; + let codeVerifier: string; const workos = getWorkOS(); const authenticateWithCode = jest.mocked(workos.userManagement.authenticateWithCode); @@ -38,11 +43,14 @@ describe('authLoader', () => { const mockAuthResponse = createAuthWithCodeResponse(); authenticateWithCode.mockResolvedValue(mockAuthResponse); + ({ sealedState, cookieHeader, codeVerifier } = await createSealedState()); + loader = authLoader(); const url = new URL('http://example.com/callback'); - request = createRequestWithSearchParams(new Request(url), { + request = createRequestWithCookieAndParams(new Request(url), cookieHeader, { code: 'test-code', + state: sealedState, }); }); @@ -57,22 +65,88 @@ describe('authLoader', () => { expect(response).toBeUndefined(); }); - it('should handle authentication failure', async () => { - authenticateWithCode.mockRejectedValue(new Error('Auth failed')); - request = createRequestWithSearchParams(request, { code: 'invalid-code' }); + it('clears the PKCE cookie when WorkOS returns an error callback without a code', async () => { + request = createRequestWithSearchParams(new Request('http://example.com/callback'), { + state: 'sealed-state', + error: 'access_denied', + }); + const response = (await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs)) as Response; + + expect(response).toBeInstanceOf(Response); + const setCookie = response.headers.get('Set-Cookie') ?? ''; + expect(setCookie).toMatch(/^wos-auth-verifier-[0-9a-f]+=;/); + expect(setCookie).toMatch(/Max-Age=0/); + }); + + it('returns 500 when state is missing', async () => { + request = createRequestWithSearchParams(new Request('http://example.com/callback'), { + code: 'test-code', + }); + const response = (await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs)) as DataWithResponseInit; + + expect(isDataWithResponseInit(response)).toBeTruthy(); + expect(response?.init?.status).toBe(500); + }); + + it('returns 500 when PKCE cookie is missing (possible CSRF)', async () => { + request = createRequestWithSearchParams(new Request('http://example.com/callback'), { + code: 'test-code', + state: sealedState, + }); + const response = (await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs)) as DataWithResponseInit; + + expect(isDataWithResponseInit(response)).toBeTruthy(); + expect(response?.init?.status).toBe(500); + // Still clears the PKCE cookie even when it wasn't present — harmless and + // matches the invariant that the cookie is always cleared post-callback. + expect(findSetCookie(response?.init?.headers, 'wos-auth-verifier-')).toMatch(/Max-Age=0/); + }); + + it('returns 500 when state does not match the PKCE cookie value', async () => { + // Valid cookie issued for a different flow + const other = await createSealedState({ nonce: 'other' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), other.cookieHeader, { + code: 'test-code', + state: sealedState, + }); const response = (await loader({ request, params: {}, context: {}, } as LoaderFunctionArgs)) as DataWithResponseInit; + expect(isDataWithResponseInit(response)).toBeTruthy(); + expect(response?.init?.status).toBe(500); + expect(authenticateWithCode).not.toHaveBeenCalled(); + }); + + it('clears the PKCE cookie on authentication failure', async () => { + authenticateWithCode.mockRejectedValue(new Error('Auth failed')); + const response = (await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs)) as DataWithResponseInit; + expect(isDataWithResponseInit(response)).toBeTruthy(); expect(response?.init?.status).toBe(500); + expect(findSetCookie(response?.init?.headers, 'wos-auth-verifier-')).toMatch(/Max-Age=0/); }); it('should handle authentication failure with string error', async () => { authenticateWithCode.mockRejectedValue('Auth failed'); - request = createRequestWithSearchParams(request, { code: 'invalid-code' }); const response = (await loader({ request, params: {}, @@ -84,6 +158,16 @@ describe('authLoader', () => { }); }); + it('passes the PKCE code verifier to authenticateWithCode', async () => { + await loader({ request, params: {}, context: {} } as LoaderFunctionArgs); + + expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({ + clientId: process.env.WORKOS_CLIENT_ID, + code: 'test-code', + codeVerifier, + }); + }); + it('returns a response when a code is present', async () => { const response = await loader({ request, @@ -91,16 +175,19 @@ describe('authLoader', () => { context: {}, } as LoaderFunctionArgs); - expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({ - clientId: process.env.WORKOS_CLIENT_ID, - code: 'test-code', - }); - assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Set-Cookie')).toBeDefined(); }); + it('clears the PKCE cookie on successful sign-in', async () => { + const response = await loader({ request, params: {}, context: {} } as LoaderFunctionArgs); + + assertIsResponse(response); + const setCookies = response.headers.getSetCookie(); + expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-') && /Max-Age=0/.test(c))).toBe(true); + }); + it('should redirect to the returnPathname', async () => { loader = authLoader({ returnPathname: '/dashboard' }); const response = await loader({ @@ -127,6 +214,93 @@ describe('authLoader', () => { expect(response.headers.get('Location')).toBe('http://example.com/dashboard?foo=bar'); }); + it('preserves the fragment on the returnPathname', async () => { + loader = authLoader({ returnPathname: '/dashboard#section' }); + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/dashboard#section'); + }); + + it('preserves search params and fragment together', async () => { + loader = authLoader({ returnPathname: '/dashboard?foo=bar#section' }); + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/dashboard?foo=bar#section'); + }); + + it('falls back to the configured returnPathname when the sealed state value is hostile', async () => { + // Simulate a tampered / hand-forged sealed state that bypasses the + // sanitization in getAuthorizationUrl. The callback must reject the + // hostile value and fall back to the configured option rather than + // redirecting the user to an attacker-controlled destination. + loader = authLoader({ returnPathname: '/dashboard' }); + const scoped = await createSealedState({ returnPathname: '//evil.com/pwn' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/dashboard'); + }); + + it("honors an explicit returnPathname='/' from the sealed state over the configured option", async () => { + loader = authLoader({ returnPathname: '/dashboard' }); + const scoped = await createSealedState({ returnPathname: '/' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/'); + }); + + it('rejects a CRLF-smuggled returnPathname in the sealed state', async () => { + loader = authLoader({ returnPathname: '/dashboard' }); + const scoped = await createSealedState({ returnPathname: '/foo\r\nSet-Cookie: bad' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + + const response = await loader({ + request, + params: {}, + context: {}, + } as LoaderFunctionArgs); + + assertIsResponse(response); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://example.com/dashboard'); + }); + it('handles calling onSuccess when provided', async () => { const onSuccess = jest.fn(); loader = authLoader({ onSuccess }); @@ -139,11 +313,15 @@ describe('authLoader', () => { expect(onSuccess).toHaveBeenCalled(); }); - it('uses returnPathname from state when provided', async () => { + it('uses returnPathname from the sealed state when provided', async () => { + const scoped = await createSealedState({ returnPathname: '/profile' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + const response = await loader({ - request: createRequestWithSearchParams(request, { - state: btoa(JSON.stringify({ returnPathname: '/profile' })), - }), + request, params: {}, context: {}, } as LoaderFunctionArgs); @@ -152,6 +330,20 @@ describe('authLoader', () => { expect(response.headers.get('Location')).toBe('http://example.com/profile'); }); + it('forwards customState from the sealed state to onSuccess', async () => { + const onSuccess = jest.fn(); + loader = authLoader({ onSuccess }); + + const scoped = await createSealedState({ customState: 'caller-state' }); + request = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { + code: 'test-code', + state: scoped.sealedState, + }); + + await loader({ request, params: {}, context: {} } as LoaderFunctionArgs); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ state: 'caller-state' })); + }); + it('provides impersonator to onSuccess callback when provided', async () => { const onSuccess = jest.fn(); authenticateWithCode.mockResolvedValue( @@ -207,13 +399,15 @@ describe('authLoader', () => { process.env.WORKOS_REDIRECT_URI = 'https://example.com/callback'; try { - const request = createRequestWithSearchParams(new Request('http://example.com/callback'), { + const scoped = await createSealedState(); + const req = createRequestWithCookieAndParams(new Request('http://example.com/callback'), scoped.cookieHeader, { code: 'test-code-123', + state: scoped.sealedState, }); const loader = authLoader(); const response = await loader({ - request, + request: req, params: {}, context: {}, } as LoaderFunctionArgs); @@ -242,13 +436,19 @@ describe('authLoader', () => { process.env.WORKOS_REDIRECT_URI = 'https://example.com:8443/callback'; try { - const request = createRequestWithSearchParams(new Request('http://example.com:3000/callback'), { - code: 'test-code-123', - }); + const scoped = await createSealedState(); + const req = createRequestWithCookieAndParams( + new Request('http://example.com:3000/callback'), + scoped.cookieHeader, + { + code: 'test-code-123', + state: scoped.sealedState, + }, + ); const loader = authLoader(); const response = await loader({ - request, + request: req, params: {}, context: {}, } as LoaderFunctionArgs); @@ -272,3 +472,9 @@ describe('authLoader', () => { } }); }); + +function findSetCookie(headers: HeadersInit | undefined, prefix: string): string | undefined { + if (!headers) return undefined; + const h = new Headers(headers); + return h.getSetCookie().find((c) => c.startsWith(prefix)); +} diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index d318459..14fc56d 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -1,6 +1,8 @@ import { LoaderFunctionArgs, data, redirect } from 'react-router'; import { getConfig } from './config.js'; import { HandleAuthOptions } from './interfaces.js'; +import { getPKCECookieString, getStateFromPKCECookieValue, readPKCECookie } from './pkce.js'; +import { sanitizeReturnPathname } from './return-pathname.js'; import { encryptSession } from './session.js'; import { configureSessionStorage } from './sessionStorage.js'; import { getWorkOS } from './workos.js'; @@ -15,87 +17,134 @@ export function authLoader(options: HandleAuthOptions = {}) { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); - let returnPathname = state && state !== 'null' ? JSON.parse(atob(state)).returnPathname : null; - - if (code) { - try { - const { accessToken, refreshToken, user, impersonator, oauthTokens, organizationId } = - await getWorkOS().userManagement.authenticateWithCode({ - clientId: getConfig('clientId'), - code, - }); - - // Clean up params - url.searchParams.delete('code'); - url.searchParams.delete('state'); - - // Redirect to the requested path and store the session - returnPathname = returnPathname ?? returnPathnameOption; - - // Extract the search params if they are present - if (returnPathname.includes('?')) { - const newUrl = new URL(returnPathname, 'https://example.com'); - url.pathname = newUrl.pathname; - - for (const [key, value] of newUrl.searchParams) { - url.searchParams.append(key, value); - } - } else { - url.pathname = returnPathname; - } - - // The refreshToken should never be accesible publicly, hence why we encrypt it - // in the cookie session. Alternatively you could persist the refresh token in a - // backend database. - const encryptedSession = await encryptSession({ + + // Cleared on every exit (success, failure, and WorkOS error callbacks + // where `?error=…&state=…` arrives with no code) so abandoned flows + // don't leave orphan verifier cookies until their 10-minute TTL expires. + const pkceClearCookie = state ? getPKCECookieString(state, { expired: true, request }) : null; + + if (!code) { + if (pkceClearCookie) { + return new Response(null, { headers: { 'Set-Cookie': pkceClearCookie } }); + } + return; + } + + try { + if (!state) { + throw new Error('Missing required auth parameter: state'); + } + + const pkceCookieValue = readPKCECookie(request.headers.get('Cookie'), state); + + // CSRF verification (double-submit cookie): both the cookie and the URL + // state must be present and identical. A missing cookie means either + // the browser never started this flow (forged link) or the cookie has + // been cleared (expired / tampered). + if (!pkceCookieValue) { + throw new Error( + 'Auth cookie missing — cannot verify OAuth state. Ensure Set-Cookie headers are propagated on the redirect that started this flow.', + ); + } + + if (state !== pkceCookieValue) { + throw new Error('OAuth state mismatch'); + } + + const { + codeVerifier, + customState, + returnPathname: returnPathnameState, + } = await getStateFromPKCECookieValue(pkceCookieValue); + + const { accessToken, refreshToken, user, impersonator, oauthTokens, authenticationMethod, organizationId } = + await getWorkOS().userManagement.authenticateWithCode({ + clientId: getConfig('clientId'), + code, + codeVerifier, + }); + + // Clean up params + url.searchParams.delete('code'); + url.searchParams.delete('state'); + + // `sanitizeReturnPathname` maps both rejection and a legitimate '/' + // to '/'; disambiguate by comparing to the raw input so an explicit + // '/' from the caller still beats the configured option. + let returnPathname: string; + if (returnPathnameState !== undefined) { + const sanitizedState = sanitizeReturnPathname(returnPathnameState); + const stateWasRejected = sanitizedState === '/' && returnPathnameState !== '/'; + returnPathname = stateWasRejected ? sanitizeReturnPathname(returnPathnameOption) : sanitizedState; + } else { + returnPathname = sanitizeReturnPathname(returnPathnameOption); + } + + // Reconstruct pathname + search + hash together. Using `.pathname = ...` + // on a raw string with a fragment would percent-encode the `#`, so we + // parse the return target and reassign each piece. + const parsedReturn = new URL(returnPathname, 'https://placeholder.invalid'); + url.pathname = parsedReturn.pathname; + for (const [key, value] of parsedReturn.searchParams) { + url.searchParams.append(key, value); + } + url.hash = parsedReturn.hash; + + // The refreshToken should never be accessible publicly, hence why we encrypt it + // in the cookie session. Alternatively you could persist the refresh token in a + // backend database. + const encryptedSession = await encryptSession({ + accessToken, + refreshToken, + user, + impersonator, + headers: {}, + }); + + const session = await getSession(cookieName); + session.set('jwt', encryptedSession); + const sessionCookie = await commitSession(session); + + if (onSuccess) { + await onSuccess({ accessToken, + impersonator: impersonator ?? null, + oauthTokens: oauthTokens ?? null, refreshToken, user, - impersonator, - headers: {}, + organizationId: organizationId ?? null, + authenticationMethod, + state: customState, }); + } - const session = await getSession(cookieName); - - session.set('jwt', encryptedSession); - const cookie = await commitSession(session); - - if (onSuccess) { - await onSuccess({ - accessToken, - impersonator: impersonator ?? null, - oauthTokens: oauthTokens ?? null, - refreshToken, - user, - organizationId: organizationId ?? null, - }); - } - - // Fix protocol mismatch for load balancer scenarios - // If WORKOS_REDIRECT_URI is HTTPS but request is HTTP, use HTTPS for redirect - const redirectUri = getConfig('redirectUri'); - const configUrl = new URL(redirectUri); - if (configUrl.protocol === 'https:' && url.protocol === 'http:') { - url.protocol = 'https:'; - } - - return redirect(url.toString(), { - headers: { - 'Set-Cookie': cookie, - }, - }); - } catch (error) { - const errorRes = { - error: error instanceof Error ? error.message : String(error), - }; + // Fix protocol mismatch for load balancer scenarios + // If WORKOS_REDIRECT_URI is HTTPS but request is HTTP, use HTTPS for redirect + const redirectUri = getConfig('redirectUri'); + const configUrl = new URL(redirectUri); + if (configUrl.protocol === 'https:' && url.protocol === 'http:') { + url.protocol = 'https:'; + } + + const headers = new Headers(); + headers.append('Set-Cookie', sessionCookie); + if (pkceClearCookie) { + headers.append('Set-Cookie', pkceClearCookie); + } + + return redirect(url.toString(), { headers }); + } catch (error) { + const errorRes = { + error: error instanceof Error ? error.message : String(error), + }; - console.error(errorRes); + console.error(errorRes); - return errorResponse(); + const headers = new Headers(); + if (pkceClearCookie) { + headers.append('Set-Cookie', pkceClearCookie); } - } - function errorResponse() { return data( { error: { @@ -103,7 +152,7 @@ export function authLoader(options: HandleAuthOptions = {}) { description: 'Couldn’t sign in. If you are not sure what happened, please contact your organization admin.', }, }, - { status: 500 }, + { status: 500, headers }, ); } }; diff --git a/src/get-authorization-url.spec.ts b/src/get-authorization-url.spec.ts index 1ad2620..5b657ba 100644 --- a/src/get-authorization-url.spec.ts +++ b/src/get-authorization-url.spec.ts @@ -1,26 +1,66 @@ +import { unsealData } from 'iron-session'; import { getAuthorizationUrl } from './get-authorization-url.js'; import { getConfig } from './config.js'; +import { getPKCECookieNameForState, PKCE_COOKIE_NAME } from './pkce.js'; +import type { State } from './interfaces.js'; describe('getAuthorizationUrl', () => { - it('should generate a valid WorkOS authorization URL', async () => { - const url = await getAuthorizationUrl(); + it('generates a valid WorkOS authorization URL with PKCE parameters', async () => { + const { url } = await getAuthorizationUrl(); expect(url).toMatch(/^https:\/\/api\.workos\.com\/user_management\/authorize\?/); expect(url).toContain(`client_id=${getConfig('clientId')}`); expect(url).toContain(`redirect_uri=${encodeURIComponent(getConfig('redirectUri'))}`); expect(url).toContain('provider=authkit'); + expect(url).toMatch(/code_challenge=[^&]+/); + expect(url).toContain('code_challenge_method=S256'); }); - it('should include envoded state when returnPathname is provided', async () => { - const returnPathname = '/dashboard'; - const url = await getAuthorizationUrl({ returnPathname }); - const expectedSstate = btoa(JSON.stringify({ returnPathname })); - expect(url).toContain(`state=${encodeURIComponent(expectedSstate)}`); + it('seals return-trip state into the OAuth state parameter', async () => { + const { url } = await getAuthorizationUrl({ returnPathname: '/dashboard' }); + const parsed = new URL(url); + const state = parsed.searchParams.get('state'); + expect(state).toBeTruthy(); + + const unsealed = await unsealData(state!, { password: getConfig('cookiePassword') }); + expect(unsealed.returnPathname).toBe('/dashboard'); + expect(unsealed.codeVerifier).toEqual(expect.any(String)); + expect(unsealed.nonce).toEqual(expect.any(String)); + }); + + it('emits a flow-specific PKCE cookie tied to the sealed state', async () => { + const { url, headers } = await getAuthorizationUrl(); + + const state = new URL(url).searchParams.get('state')!; + const setCookie = headers['Set-Cookie']; + expect(setCookie).toContain(`${getPKCECookieNameForState(state)}=${state}`); + expect(setCookie).toContain('Path=/'); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).toContain('SameSite=Lax'); + expect(setCookie).toMatch(/Max-Age=600\b/); + }); + + it('gives concurrent flows distinct cookie names', async () => { + const a = await getAuthorizationUrl(); + const b = await getAuthorizationUrl(); + + const aName = a.headers['Set-Cookie'].split('=')[0]; + const bName = b.headers['Set-Cookie'].split('=')[0]; + expect(aName).toMatch(new RegExp(`^${PKCE_COOKIE_NAME}-[0-9a-f]{8}$`)); + expect(bName).toMatch(new RegExp(`^${PKCE_COOKIE_NAME}-[0-9a-f]{8}$`)); + expect(aName).not.toBe(bName); + }); + + it('includes screenHint when provided', async () => { + const { url } = await getAuthorizationUrl({ screenHint: 'sign-up' }); + expect(url).toContain('screen_hint=sign-up'); }); - it('should include screenHint when provided', async () => { - const screenHint = 'sign-up'; - const url = await getAuthorizationUrl({ screenHint }); - expect(url).toContain(`screen_hint=${screenHint}`); + it('forwards caller-provided custom state through the sealed payload', async () => { + const { url } = await getAuthorizationUrl({ state: 'caller-state', returnPathname: '/foo' }); + const state = new URL(url).searchParams.get('state')!; + const unsealed = await unsealData(state, { password: getConfig('cookiePassword') }); + expect(unsealed.customState).toBe('caller-state'); + expect(unsealed.returnPathname).toBe('/foo'); }); }); diff --git a/src/get-authorization-url.ts b/src/get-authorization-url.ts index db6b34e..e771f4f 100644 --- a/src/get-authorization-url.ts +++ b/src/get-authorization-url.ts @@ -1,24 +1,78 @@ +import { sealData } from 'iron-session'; import { getConfig } from './config.js'; +import type { GetAuthURLOptions, GetAuthURLResult, State } from './interfaces.js'; +import { getPKCECookieString } from './pkce.js'; +import { sanitizeReturnPathname } from './return-pathname.js'; import { getWorkOS } from './workos.js'; -interface GetAuthURLOptions { - screenHint?: 'sign-up' | 'sign-in'; - returnPathname?: string; - organizationId?: string; - redirectUri?: string; - loginHint?: string; -} +/** + * Build an AuthKit authorization URL and the PKCE / CSRF cookie that must + * travel back with the user on the cross-site redirect. + * + * The caller attaches the returned `headers` to their redirect response: + * + * ```ts + * const { url, headers } = await getAuthorizationUrl({ returnPathname: '/dashboard' }); + * return redirect(url, { headers }); + * ``` + * + * Internally this: + * 1. Generates a PKCE verifier / challenge pair (RFC 7636, S256). + * 2. Seals `{ nonce, codeVerifier, customState, returnPathname }` with + * iron-session under the configured cookie password. + * 3. Sends the sealed value as the OAuth `state` parameter. + * 4. Sets an HTTP-only, flow-specific cookie (`wos-auth-verifier-`) + * with the same sealed value so the callback can: + * - prove the response came from a flow this browser initiated + * (CSRF: `cookie === state`); and + * - recover the `codeVerifier` to complete the PKCE exchange. + */ +export async function getAuthorizationUrl(options: GetAuthURLOptions = {}): Promise { + const { + returnPathname, + screenHint, + organizationId, + redirectUri, + loginHint, + prompt, + state: customState, + request, + } = options; -export async function getAuthorizationUrl(options: GetAuthURLOptions = {}) { - const { returnPathname, screenHint, organizationId, redirectUri, loginHint } = options; + const pkce = await getWorkOS().pkce.generate(); - return getWorkOS().userManagement.getAuthorizationUrl({ + const state = { + nonce: crypto.randomUUID(), + codeVerifier: pkce.codeVerifier, + customState, + // Sanitize before sealing so a hostile caller can't plant a malicious + // return target (absolute URL, CRLF smuggle, dot-segment traversal, etc.) + // that the callback would later redirect to. + returnPathname: returnPathname !== undefined ? sanitizeReturnPathname(returnPathname) : undefined, + } satisfies State; + + const sealedState = await sealData(state, { + password: getConfig('cookiePassword'), + // Match the PKCE cookie's Max-Age so a stale sealed state can't be + // replayed after the cookie itself has expired. + ttl: 600, + }); + + const url = getWorkOS().userManagement.getAuthorizationUrl({ provider: 'authkit', clientId: getConfig('clientId'), redirectUri: redirectUri || getConfig('redirectUri'), - state: returnPathname ? btoa(JSON.stringify({ returnPathname })) : undefined, screenHint, organizationId, loginHint, + prompt, + state: sealedState, + codeChallenge: pkce.codeChallenge, + codeChallengeMethod: pkce.codeChallengeMethod, }); + + return { + url, + headers: { 'Set-Cookie': getPKCECookieString(sealedState, { request, redirectUri }) }, + }; } diff --git a/src/interfaces.ts b/src/interfaces.ts index 0424e53..0393298 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,6 @@ import type { SessionStorage, SessionIdStorageStrategy, data, SessionData } from 'react-router'; -import type { OauthTokens, User } from '@workos-inc/node'; +import type { AuthenticationResponse, OauthTokens, User } from '@workos-inc/node'; +import * as v from 'valibot'; export type DataWithResponseInit = ReturnType>; @@ -26,6 +27,8 @@ export interface AuthLoaderSuccessData { refreshToken: string; user: User; organizationId: string | null; + authenticationMethod?: AuthenticationResponse['authenticationMethod']; + state?: string; } export interface RefreshErrorOptions { @@ -93,8 +96,59 @@ export interface NoUserInfo { export interface GetAuthURLOptions { screenHint?: 'sign-up' | 'sign-in'; returnPathname?: string; + organizationId?: string; + redirectUri?: string; + loginHint?: string; + prompt?: 'consent'; + /** + * Custom state value echoed back to `onSuccess` after a successful callback. + * The library always generates its own internal OAuth `state` parameter so + * that PKCE and CSRF protection cannot be bypassed — this value is sealed + * alongside it for round-trip delivery only. + */ + state?: string; + /** + * The incoming `Request`, if available. When provided the PKCE cookie's + * `Secure` attribute is derived from the live request protocol rather than + * the configured `redirectUri` — necessary so local dev on `http://` with + * a `https://` redirect URI still sets a cookie the browser will keep. + */ + request?: Request; +} + +/** + * Result of building an authorization URL. The caller must attach `headers` + * to whatever redirect response they return so the short-lived PKCE / + * CSRF-binding cookie is set on the browser before WorkOS redirects back. + * + * The concrete `{ 'Set-Cookie': string }` shape is still assignable to + * `HeadersInit` (via `Record`), so callers can spread it + * directly into a `new Headers({ ...headers, 'Cache-Control': 'no-store' })` + * or pass it straight to `redirect(url, { headers })`. + * + * @example + * const { url, headers } = await getSignInUrl('/dashboard', request); + * return redirect(url, { headers }); + */ +export interface GetAuthURLResult { + url: string; + headers: { 'Set-Cookie': string }; } +/** + * Sealed state stored in the PKCE cookie and round-tripped through WorkOS as + * the OAuth `state` parameter. `codeVerifier` is the PKCE secret that binds + * the authorization code to this browser session. + */ +export const StateSchema = v.object({ + nonce: v.string(), + customState: v.optional(v.string()), + returnPathname: v.optional(v.string()), + codeVerifier: v.string(), +}); + +export type State = v.InferOutput; + export type AuthKitLoaderOptions = { ensureSignedIn?: boolean; debug?: boolean; diff --git a/src/pkce.spec.ts b/src/pkce.spec.ts new file mode 100644 index 0000000..0057019 --- /dev/null +++ b/src/pkce.spec.ts @@ -0,0 +1,152 @@ +import { getPKCECookieString } from './pkce.js'; +import * as configModule from './config.js'; + +jest.mock('./config', () => ({ + getConfig: jest.fn(), +})); + +const getConfig = jest.mocked(configModule.getConfig); + +function cookieAttrs(cookie: string): Set { + return new Set(cookie.split(';').map((s) => s.trim())); +} + +describe('getPKCECookieString', () => { + const sealedState = 'sealed-state-value'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Secure attribute', () => { + it('uses the live request protocol over the configured redirectUri', () => { + // Simulate the footgun: redirect URI is https but dev server is http. + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('http://localhost:5173/login'), + }); + + expect(cookieAttrs(cookie)).not.toContain('Secure'); + }); + + it('marks the cookie Secure when the request is https', () => { + getConfig.mockImplementation((key: string) => (key === 'redirectUri' ? 'http://localhost/callback' : undefined)); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('https://app.example.com/login'), + }); + + expect(cookieAttrs(cookie)).toContain('Secure'); + }); + + it('falls back to redirectUri when no request is supplied', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + expect(cookieAttrs(getPKCECookieString(sealedState))).toContain('Secure'); + + getConfig.mockImplementation((key: string) => (key === 'redirectUri' ? 'http://localhost/callback' : undefined)); + + expect(cookieAttrs(getPKCECookieString(sealedState))).not.toContain('Secure'); + }); + + it('honors X-Forwarded-Proto=https behind a TLS-terminating proxy', () => { + // TLS terminator forwards a plain http:// request upstream but the + // public site is https://. The PKCE cookie must still be Secure. + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('http://internal.lb:8080/login', { + headers: { 'X-Forwarded-Proto': 'https' }, + }), + }); + + expect(cookieAttrs(cookie)).toContain('Secure'); + }); + + it('honors X-Forwarded-Proto=http even when request.url is https', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('https://internal.lb/login', { + headers: { 'X-Forwarded-Proto': 'http' }, + }), + }); + + expect(cookieAttrs(cookie)).not.toContain('Secure'); + }); + + it('uses the leftmost entry of a chained X-Forwarded-Proto header', () => { + getConfig.mockImplementation((key: string) => (key === 'redirectUri' ? 'http://localhost/callback' : undefined)); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('http://internal.lb/login', { + headers: { 'X-Forwarded-Proto': 'https, http' }, + }), + }); + + expect(cookieAttrs(cookie)).toContain('Secure'); + }); + + it('prefers a per-call redirectUri override over the global config when no request is supplied', () => { + // Global config says https, but this specific flow is being initiated + // against a different redirect URI (e.g. a dev tunnel on http). + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + redirectUri: 'http://localhost:5173/callback', + }); + + expect(cookieAttrs(cookie)).not.toContain('Secure'); + }); + + it('honors an explicit secure override over both request and redirectUri', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { + request: new Request('https://app.example.com/login'), + secure: false, + }); + + expect(cookieAttrs(cookie)).not.toContain('Secure'); + }); + + it('defaults to Secure=true and warns when redirectUri is unparseable', () => { + getConfig.mockImplementation((key: string) => (key === 'redirectUri' ? 'not a url' : undefined)); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const cookie = getPKCECookieString(sealedState); + expect(cookieAttrs(cookie)).toContain('Secure'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('redirectUri')); + } finally { + warn.mockRestore(); + } + }); + }); + + describe('expired variant', () => { + it('emits Max-Age=0 and an empty value so the browser clears the cookie', () => { + getConfig.mockImplementation((key: string) => + key === 'redirectUri' ? 'https://app.example.com/callback' : undefined, + ); + + const cookie = getPKCECookieString(sealedState, { expired: true }); + + expect(cookie).toMatch(/^wos-auth-verifier-[0-9a-f]{8}=;/); + expect(cookieAttrs(cookie)).toContain('Max-Age=0'); + }); + }); +}); diff --git a/src/pkce.ts b/src/pkce.ts new file mode 100644 index 0000000..741612f --- /dev/null +++ b/src/pkce.ts @@ -0,0 +1,190 @@ +import { createHash } from 'node:crypto'; +import { unsealData } from 'iron-session'; +import * as v from 'valibot'; +import { getConfig } from './config.js'; +import { State, StateSchema } from './interfaces.js'; + +export const PKCE_COOKIE_NAME = 'wos-auth-verifier'; + +// 10 minutes. PKCE cookies are single-use and short-lived, and the OAuth +// authorization request should complete long before this expires. +const PKCE_COOKIE_MAX_AGE = 600; + +/** + * Short, deterministic hex fingerprint of an arbitrary string. + * Used to give each PKCE flow its own cookie name without depending on the + * internal format of the sealed state value. Collision resistance is not + * security-critical here (cookie values are still integrity-checked via + * iron-session); the fingerprint only needs to spread concurrent flows + * across distinct cookie names. + */ +function shortHash(input: string): string { + return createHash('sha256').update(input).digest('hex').slice(0, 8); +} + +/** + * Derive a flow-specific cookie name so concurrent auth flows don't overwrite + * each other's PKCE cookies. + */ +export function getPKCECookieNameForState(state: string): string { + return `${PKCE_COOKIE_NAME}-${shortHash(state)}`; +} + +/** + * Decide whether the PKCE cookie should carry the `Secure` attribute. + * + * Preference order: + * 1. An explicit `secure` override from the caller. + * 2. `X-Forwarded-Proto` from the incoming request — the canonical signal + * when TLS is terminated upstream (load balancer, reverse proxy) and + * the app receives a plain http:// request for an https:// site. + * 3. The live request protocol — the cookie is set on *this* response, + * and the browser will drop `Secure` cookies on http:// pages even if + * the configured redirect URI is https://. + * 4. The per-call `redirectUri` override, when provided, so that a caller + * passing `getAuthorizationUrl({ redirectUri })` without a request can + * still control the cookie's Secure attribute for that specific flow. + * 5. Fall back to the configured redirectUri's protocol. + * + * A misconfigured / unparseable redirectUri is not fatal; we default to + * `Secure=true` and warn so the misconfiguration is visible. + */ +function resolveSecure({ + secure, + request, + redirectUri, +}: { secure?: boolean; request?: Request; redirectUri?: string } = {}): boolean { + if (typeof secure === 'boolean') return secure; + + if (request) { + const forwardedProto = request.headers.get('x-forwarded-proto'); + if (forwardedProto) { + // X-Forwarded-Proto may be a comma-separated list when multiple proxies + // chain; the leftmost value is the client-facing protocol. + return forwardedProto.split(',')[0].trim().toLowerCase() === 'https'; + } + try { + return new URL(request.url).protocol === 'https:'; + } catch { + // fall through to redirectUri-based detection + } + } + + const uri = redirectUri ?? getConfig('redirectUri'); + try { + return new URL(uri).protocol === 'https:'; + } catch { + console.warn( + `[AuthKit] Could not parse redirectUri (${JSON.stringify(uri)}); defaulting PKCE cookie to Secure=true.`, + ); + return true; + } +} + +/** + * Build a `Set-Cookie` header string for the PKCE verifier cookie. + * + * `SameSite=Strict` would be stripped on the cross-site redirect back from + * WorkOS, so it is set to `Lax` — the minimum that survives a top-level + * cross-site navigation back to our origin. + * + * Callers that have the incoming `Request` in hand should pass it via + * `options.request` so the `Secure` attribute reflects the actual protocol + * in use rather than the configured redirect URI's — otherwise running + * `npm run dev` on http:// while `WORKOS_REDIRECT_URI=https://…` would mint + * a `Secure` cookie the browser silently drops. + */ +export function getPKCECookieString( + sealedState: string, + options: { expired?: boolean; request?: Request; secure?: boolean; redirectUri?: string } = {}, +): string { + const { expired = false, request, secure, redirectUri } = options; + const name = getPKCECookieNameForState(sealedState); + const value = expired ? '' : sealedState; + + const parts = [ + `${name}=${value}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${expired ? 0 : PKCE_COOKIE_MAX_AGE}`, + ]; + if (resolveSecure({ secure, request, redirectUri })) { + parts.push('Secure'); + } + return parts.join('; '); +} + +/** + * Read the PKCE cookie value for a given OAuth state from a Cookie header. + * Returns `undefined` when the browser didn't send back the cookie (which + * indicates either a brand-new user, a stolen authorization URL, or an + * unrelated cross-site redirect — all of which are CSRF-failure conditions). + */ +export function readPKCECookie(cookieHeader: string | null, state: string): string | undefined { + if (!cookieHeader) { + return undefined; + } + const name = getPKCECookieNameForState(state); + // Cookie header values are `name1=value1; name2=value2`. Split on `;` and + // match the first exact-name entry — cookie values themselves never contain + // `;` (they are percent-encoded) so this is safe. + for (const raw of cookieHeader.split(';')) { + const trimmed = raw.trim(); + if (trimmed.startsWith(`${name}=`)) { + return trimmed.slice(name.length + 1); + } + } + return undefined; +} + +/** + * Build a `Set-Cookie: =; Max-Age=0` string for every PKCE verifier + * cookie on the incoming request. Used on sign-out so abandoned flows + * (tabs closed mid-OAuth, etc.) don't leave orphan `wos-auth-verifier-*` + * cookies accumulating under the browser's per-domain cookie cap. + */ +export function getPKCECleanupCookieStrings( + cookieHeader: string | null, + options: { request?: Request; secure?: boolean; redirectUri?: string } = {}, +): string[] { + if (!cookieHeader) return []; + + const names = new Set(); + for (const raw of cookieHeader.split(';')) { + const trimmed = raw.trim(); + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const name = trimmed.slice(0, eq); + // Match both the bare constant and every per-flow `-` variant, + // in case the naming scheme ever changes. + if (name === PKCE_COOKIE_NAME || name.startsWith(`${PKCE_COOKIE_NAME}-`)) { + names.add(name); + } + } + + const secure = resolveSecure(options); + return Array.from(names).map((name) => { + const parts = [`${name}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']; + if (secure) parts.push('Secure'); + return parts.join('; '); + }); +} + +/** + * Read and unseal the PKCE cookie, returning the code verifier, nonce, and + * any caller-supplied custom state and return pathname. + * + * Throws if the cookie was tampered with, encrypted under a different + * password, or is missing required fields. Runtime validation via valibot + * is an acceptable tradeoff here — this is not a hot path, and + * sealing/unsealing does not prove the unsealed payload has the expected + * shape. + */ +export async function getStateFromPKCECookieValue(cookieValue: string): Promise { + const unsealed = await unsealData(cookieValue, { + password: getConfig('cookiePassword'), + }); + + return v.parse(StateSchema, unsealed); +} diff --git a/src/return-pathname.spec.ts b/src/return-pathname.spec.ts new file mode 100644 index 0000000..469d0ff --- /dev/null +++ b/src/return-pathname.spec.ts @@ -0,0 +1,50 @@ +import { sanitizeReturnPathname } from './return-pathname.js'; + +describe('sanitizeReturnPathname', () => { + describe('accepts', () => { + it.each(['/', '/foo', '/foo/bar', '/foo?bar=1', '/foo?a=1&b=2', '/foo#baz', '/foo?bar=1#baz', '/a-b_c.d~e'])( + '%s', + (input) => { + expect(sanitizeReturnPathname(input)).toBe(input); + }, + ); + }); + + describe('rejects', () => { + it.each([ + ['non-leading-slash', 'foo'], + ['empty', ''], + ['protocol-relative //evil.com', '//evil.com'], + ['protocol-relative with path', '//evil.com/foo'], + ['absolute http', 'http://evil.com'], + ['absolute https', 'https://evil.com/path'], + ['backslash prefix', '/\\evil.com'], + ['CR injection', '/foo\rbar'], + ['LF injection', '/foo\nbar'], + ['CRLF Set-Cookie smuggle', '/foo\r\nSet-Cookie: bad'], + ['dot-segment traversal', '/app/../admin'], + ['dot-segment same-dir', '/app/./x'], + ['URL-encoded absolute', '/%2F%2Fevil.com'], + ['URL-encoded protocol', '//%65vil.com'], + ['URL-encoded backslash', '/%5Cevil.com'], + ['malformed percent encoding', '/%ZZ'], + ])('%s → /', (_label, input) => { + expect(sanitizeReturnPathname(input)).toBe('/'); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['number', 42], + ['object', { pathname: '/foo' }], + ['array', ['/foo']], + ['boolean', true], + ])('non-string %s → /', (_label, input) => { + expect(sanitizeReturnPathname(input)).toBe('/'); + }); + + it('oversized string (>2048 chars) → /', () => { + expect(sanitizeReturnPathname('/' + 'a'.repeat(2048))).toBe('/'); + }); + }); +}); diff --git a/src/return-pathname.ts b/src/return-pathname.ts new file mode 100644 index 0000000..2506e7d --- /dev/null +++ b/src/return-pathname.ts @@ -0,0 +1,28 @@ +const PLACEHOLDER = 'https://placeholder.invalid'; + +/** + * Restrict a user-supplied return target to a same-origin pathname. + * Rejects absolute URLs, protocol-relative paths, backslash-prefixed paths, CRLF + * header-injection attempts, dot-segment traversal, URL-encoded bypasses, and + * oversized inputs. Returns the input on success, '/' on rejection. + */ +export function sanitizeReturnPathname(input: unknown): string { + if (typeof input !== 'string' || input.length === 0) return '/'; + if (input.length > 2048) return '/'; + if (!input.startsWith('/')) return '/'; + if (input.startsWith('//')) return '/'; + if (input.startsWith('/\\')) return '/'; + if (/[\r\n]/.test(input)) return '/'; + + try { + const u = new URL(input, PLACEHOLDER); + if (u.origin !== PLACEHOLDER) return '/'; + const normalized = u.pathname + u.search + u.hash; + if (normalized !== input) return '/'; + const decoded = decodeURIComponent(u.pathname); + if (decoded.startsWith('//') || decoded.startsWith('/\\')) return '/'; + } catch { + return '/'; + } + return input; +} diff --git a/src/session.spec.ts b/src/session.spec.ts index 1670c61..d406fd8 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -122,7 +122,10 @@ describe('session', () => { // Reset getAuthorizationUrl mock getAuthorizationUrlMock.mockReset(); - getAuthorizationUrlMock.mockResolvedValue('https://auth.workos.com/oauth/authorize'); + getAuthorizationUrlMock.mockResolvedValue({ + url: 'https://auth.workos.com/oauth/authorize', + headers: { 'Set-Cookie': 'wos-auth-verifier-default=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, + }); }); describe('encryptSession', () => { @@ -230,6 +233,52 @@ describe('session', () => { expect(getLogoutUrl).not.toHaveBeenCalled(); }); + it('clears orphan wos-auth-verifier cookies from abandoned OAuth flows', async () => { + const mockSession = createMockSession({ + has: jest.fn().mockReturnValue(true), + get: jest.fn().mockReturnValue('encrypted-jwt'), + }); + getSession.mockResolvedValueOnce(mockSession); + unsealData.mockResolvedValueOnce({ + accessToken: 'token.without.sessionid', + refreshToken: 'refresh-token', + user: { id: 'user-id' }, + impersonator: null, + }); + (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); + + const request = createMockRequest( + 'wos-session=value; wos-auth-verifier-aaaaaaaa=sealed1; wos-auth-verifier-bbbbbbbb=sealed2; other=ignored', + ); + const response = await terminateSession(request); + + const setCookies = response.headers.getSetCookie(); + expect(setCookies).toContain('destroyed-session-cookie'); + expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-aaaaaaaa=;') && /Max-Age=0/.test(c))).toBe(true); + expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-bbbbbbbb=;') && /Max-Age=0/.test(c))).toBe(true); + expect(setCookies.every((c) => !c.startsWith('other='))).toBe(true); + }); + + it('emits no PKCE cleanup headers when no orphan cookies are present', async () => { + const mockSession = createMockSession({ + has: jest.fn().mockReturnValue(true), + get: jest.fn().mockReturnValue('encrypted-jwt'), + }); + getSession.mockResolvedValueOnce(mockSession); + unsealData.mockResolvedValueOnce({ + accessToken: 'token.without.sessionid', + refreshToken: 'refresh-token', + user: { id: 'user-id' }, + impersonator: null, + }); + (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); + + const response = await terminateSession(createMockRequest('wos-session=value; other=still-ignored')); + + const setCookies = response.headers.getSetCookie(); + expect(setCookies).toEqual(['destroyed-session-cookie']); + }); + it('should redirect to WorkOS logout URL when valid session exists', async () => { // Setup a session with jwt const mockSession = createMockSession({ @@ -309,7 +358,9 @@ describe('session', () => { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toMatch(/^https:\/\/auth\.workos\.com\/oauth/); - expect(response.headers.get('Set-Cookie')).toBe('destroyed-session-cookie'); + const setCookies = response.headers.getSetCookie(); + expect(setCookies).toContain('destroyed-session-cookie'); + expect(setCookies.some((c) => c.startsWith('wos-auth-verifier-'))).toBe(true); } }); @@ -405,6 +456,16 @@ describe('session', () => { jsonSpy.mockRestore(); }); + it('validates the access token issuer claim against https://api.workos.com', async () => { + await authkitLoader(createLoaderArgs(createMockRequest())); + + expect(jwtVerify).toHaveBeenCalled(); + for (const call of jwtVerify.mock.calls) { + expect(call[0]).toBe('valid.jwt.token'); + expect(call[2]).toEqual({ issuer: 'https://api.workos.com' }); + } + }); + it('should return authorized data with session claims', async () => { const { data } = await authkitLoader(createLoaderArgs(createMockRequest())); @@ -617,7 +678,10 @@ describe('session', () => { authenticateWithRefreshToken.mockRejectedValue(new Error('Refresh token invalid')); // Setup the mock to return a URL with state parameter - getAuthorizationUrlMock.mockResolvedValue('https://auth.workos.com/oauth/authorize?state=abc123'); + getAuthorizationUrlMock.mockResolvedValue({ + url: 'https://auth.workos.com/oauth/authorize?state=abc123', + headers: { 'Set-Cookie': 'wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600' }, + }); try { const mockRequest = createMockRequest('test-cookie', 'https://app.example.com/dashboard/settings'); @@ -627,12 +691,19 @@ describe('session', () => { assertIsResponse(response); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('https://auth.workos.com/oauth/authorize?state=abc123'); - expect(response.headers.get('Set-Cookie')).toBe('destroyed-session-cookie'); + // The destroy cookie and the new PKCE cookie must both be present + const setCookies = response.headers.getSetCookie(); + expect(setCookies).toContain('destroyed-session-cookie'); + expect(setCookies).toContain('wos-auth-verifier-abc=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600'); // Verify getAuthorizationUrl was called with the correct returnPathname - expect(getAuthorizationUrlMock).toHaveBeenCalledWith({ - returnPathname: '/dashboard/settings', - }); + // and the request is threaded through for Secure-attribute detection. + expect(getAuthorizationUrlMock).toHaveBeenCalledWith( + expect.objectContaining({ + returnPathname: '/dashboard/settings', + request: expect.any(Request), + }), + ); } }); diff --git a/src/session.ts b/src/session.ts index ae34023..b88e9f3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -14,6 +14,7 @@ import { getWorkOS } from './workos.js'; import { sealData, unsealData } from 'iron-session'; import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; import { getConfig } from './config.js'; +import { getPKCECleanupCookieStrings } from './pkce.js'; import { configureSessionStorage, getSessionStorage } from './sessionStorage.js'; import { isDataWithResponseInit, isJsonResponse, isRedirect, isResponse } from './utils.js'; import type { AuthenticationResponse } from '@workos-inc/node'; @@ -44,7 +45,8 @@ export async function refreshSession(request: Request, options: { organizationId const cookie = request.headers.get('Cookie'); const session = cookie ? await getSessionFromCookie(cookie) : null; if (!session) { - throw redirect(await getAuthorizationUrl()); + const { url, headers } = await getAuthorizationUrl({ request }); + throw redirect(url, { headers }); } try { @@ -361,10 +363,12 @@ export async function authkitLoader( const returnPathname = getReturnPathname(request.url); const cookieSession = await getSession(request.headers.get('Cookie')); - throw redirect(await getAuthorizationUrl({ returnPathname }), { - headers: { - 'Set-Cookie': await destroySession(cookieSession), - }, + const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname, request }); + throw redirect(url, { + headers: [ + ['Set-Cookie', await destroySession(cookieSession)], + ['Set-Cookie', authHeaders['Set-Cookie']], + ], }); } @@ -443,10 +447,12 @@ export async function authkitLoader( } const returnPathname = getReturnPathname(request.url); - throw redirect(await getAuthorizationUrl({ returnPathname }), { - headers: { - 'Set-Cookie': await destroySession(cookieSession), - }, + const { url, headers: authHeaders } = await getAuthorizationUrl({ returnPathname, request }); + throw redirect(url, { + headers: [ + ['Set-Cookie', await destroySession(cookieSession)], + ['Set-Cookie', authHeaders['Set-Cookie']], + ], }); } @@ -531,17 +537,23 @@ async function handleAuthLoader( export async function terminateSession(request: Request, { returnTo }: { returnTo?: string } = {}) { const { getSession, destroySession } = await getSessionStorage(); - const encryptedSession = await getSession(request.headers.get('Cookie')); - const { accessToken } = (await getSessionFromCookie( - request.headers.get('Cookie') as string, - encryptedSession, - )) as Session; + const cookieHeader = request.headers.get('Cookie'); + const encryptedSession = await getSession(cookieHeader); + const { accessToken } = (await getSessionFromCookie(cookieHeader as string, encryptedSession)) as Session; const { sessionId } = getClaimsFromAccessToken(accessToken); - const headers = { + // Destroy the session cookie plus any orphan `wos-auth-verifier-*` cookies + // from abandoned OAuth flows — the per-flow cookie scheme means an + // unfinished flow leaves a cookie behind that the browser will keep + // sending until its 10-minute Max-Age expires, and stacking enough of + // them can exceed the per-domain cookie cap. + const headers = new Headers({ 'Set-Cookie': await destroySession(encryptedSession), - }; + }); + for (const cleanup of getPKCECleanupCookieStrings(cookieHeader, { request })) { + headers.append('Set-Cookie', cleanup); + } if (sessionId) { return redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo }), { @@ -595,10 +607,22 @@ export async function getSessionFromCookie(cookie: string, session?: SessionData } } +// WorkOS access tokens carry a fixed `iss` claim regardless of environment +// or client id; see +// https://workos.com/docs/reference/user-management/session-tokens/access-token. +// Validating it defends against tokens signed by a different WorkOS project +// whose JWKS happens to resolve to the same keys, and matches the team's +// "always validate iss" JWT rule. +// +// WorkOS access tokens do not carry a standard `aud` claim — the target +// client is encoded as `client_id` instead — so we do not pass `audience` +// to jwtVerify here; doing so would reject every token. +const WORKOS_JWT_ISSUER = 'https://api.workos.com'; + async function verifyAccessToken(accessToken: string) { const JWKS = createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(getConfig('clientId')))); try { - await jwtVerify(accessToken, JWKS); + await jwtVerify(accessToken, JWKS, { issuer: WORKOS_JWT_ISSUER }); return true; } catch (e) { return false; diff --git a/src/test-utils/test-helpers.ts b/src/test-utils/test-helpers.ts index e7dce37..64335a3 100644 --- a/src/test-utils/test-helpers.ts +++ b/src/test-utils/test-helpers.ts @@ -1,6 +1,10 @@ /* istanbul ignore file */ import type { User } from '@workos-inc/node'; +import { sealData } from 'iron-session'; +import { getConfig } from '../config.js'; +import { getPKCECookieNameForState } from '../pkce.js'; +import type { State } from '../interfaces.js'; type SearchParamsModifier = Record | ((params: URLSearchParams) => void); @@ -35,6 +39,45 @@ export function createRequestWithSearchParams(request: Request, modifier: Search return new Request(url, request); } +/** + * Build a sealed PKCE state value and matching Cookie header, the way + * `getAuthorizationUrl` would emit them on the outbound redirect. + * + * Returns `{ sealedState, cookieHeader, codeVerifier }` — pass `sealedState` + * as the URL's `state` search param and `cookieHeader` as the inbound + * `Cookie` header in the callback request. + */ +export async function createSealedState( + overrides: Partial = {}, +): Promise<{ sealedState: string; cookieHeader: string; codeVerifier: string }> { + const state: State = { + nonce: overrides.nonce ?? 'test-nonce', + codeVerifier: overrides.codeVerifier ?? 'test-code-verifier', + customState: overrides.customState, + returnPathname: overrides.returnPathname, + }; + const sealedState = await sealData(state, { password: getConfig('cookiePassword') }); + const cookieHeader = `${getPKCECookieNameForState(sealedState)}=${sealedState}`; + return { sealedState, cookieHeader, codeVerifier: state.codeVerifier }; +} + +/** + * Mutate an existing Request to include the given `Cookie` header plus the + * given search params, returning a fresh Request instance. + */ +export function createRequestWithCookieAndParams( + request: Request, + cookieHeader: string, + modifier: SearchParamsModifier, +): Request { + const next = createRequestWithSearchParams(request, modifier); + const headers = new Headers(next.headers); + // Append rather than set so callers can stack cookies + const existing = headers.get('Cookie'); + headers.set('Cookie', existing ? `${existing}; ${cookieHeader}` : cookieHeader); + return new Request(next.url, { ...next, headers, body: next.body }); +} + /** * Creates a mock WorkOS authentication response object. * @param overrides - Any properties to override in the mock response. diff --git a/src/workos.spec.ts b/src/workos.spec.ts index eb59570..9ac6381 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -1,4 +1,3 @@ -import type { WorkOS as WorkOSType } from '@workos-inc/node'; import type { AuthKitConfig } from './interfaces.js'; describe('workos', () => { @@ -11,55 +10,41 @@ describe('workos', () => { apiHostname: 'api.workos.com', } as const; - const options = { - apiHostname: config.apiHostname, - https: true, - port: undefined, - appInfo: { - name: 'authkit-react-router', - version: expect.any(String), - }, - } as const; - - let getWorkOS: () => WorkOSType; - let WorkOS: typeof WorkOSType; let configure: (config: Partial) => void; - beforeEach(() => { + beforeEach(async () => { jest.resetModules(); - ({ configure } = require('./config.js')); + ({ configure } = await import('./config.js')); }); - it('should initialize WorkOS with correct API key', async () => { + it('should initialize WorkOS with correct API key and options', async () => { configure({ ...config }); - jest.mock('@workos-inc/node', () => ({ WorkOS: jest.fn() })); - ({ getWorkOS } = await import('./workos.js')); - ({ WorkOS } = await import('@workos-inc/node')); + const { getWorkOS } = await import('./workos.js'); const workos = getWorkOS(); - expect(WorkOS).toHaveBeenCalledWith(config.apiKey, expect.objectContaining(options)); expect(workos).toBeDefined(); + expect(workos.options.apiHostname).toBe(config.apiHostname); + expect(workos.options.https).toBe(true); + expect(workos.options.port).toBeUndefined(); + expect(workos.options.appInfo).toEqual({ + name: 'authkit-react-router', + version: expect.any(String), + }); }); - it('sets https when WORKOS_API_HTTPS is set', async () => { + it('sets https when apiHttps is set', async () => { configure({ ...config, apiHttps: false }); - jest.mock('@workos-inc/node', () => ({ WorkOS: jest.fn() })); - ({ getWorkOS } = await import('./workos.js')); - ({ WorkOS } = await import('@workos-inc/node')); + const { getWorkOS } = await import('./workos.js'); const workos = getWorkOS(); - expect(WorkOS).toHaveBeenCalledWith(config.apiKey, expect.objectContaining({ ...options, https: false })); - expect(workos).toBeDefined(); + expect(workos.options.https).toBe(false); }); - it('does not set the port when not provided', async () => { + it('sets the port when provided', async () => { configure({ ...config, apiPort: 3000 }); - jest.mock('@workos-inc/node', () => ({ WorkOS: jest.fn() })); - ({ getWorkOS } = await import('./workos.js')); - ({ WorkOS } = await import('@workos-inc/node')); + const { getWorkOS } = await import('./workos.js'); const workos = getWorkOS(); - expect(WorkOS).toHaveBeenCalledWith(config.apiKey, expect.objectContaining({ ...options, port: 3000 })); - expect(workos).toBeDefined(); + expect(workos.options.port).toBe(3000); }); });