Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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-<hash>` 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 `<Link>`"
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.
173 changes: 158 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof loader>();
const { user } = useLoaderData<typeof loader>();

return (
<div>
Expand All @@ -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<typeof loader>();
const { user } = useLoaderData<typeof loader>();

if (!user) {
return (
<>
<Link to={signInUrl}>Log in</Link>
<Link to="/login">Log in</Link>
<br />
<Link to={signUpUrl}>Sign Up</Link>
<Link to="/signup">Sign Up</Link>
</>
);
}
Expand All @@ -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 `<Link>`. 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:
Expand Down Expand Up @@ -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 `<Link>`"
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:
<Link to="/login">Log in</Link>
```

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
```
Loading