Skip to content
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,22 @@ Only `active` memberships count — an unaccepted invitation or a deactivated me

### Refresh tokens always rotate

The emulator issues a new refresh token on every refresh and invalidates the one you presented, so replaying it returns `invalid_grant`. WorkOS documents that refresh tokens _may_ be rotated after use, so production is free to hand back the same token and leave it valid. The emulator always takes the stricter path: a client that forgets to store the newly returned `refresh_token` fails locally instead of in production.
The emulator issues a new refresh token on every refresh and invalidates the one you presented, so replaying it returns `{"error": "invalid_grant", "error_description": "Invalid refresh token."}`. WorkOS documents that refresh tokens _may_ be rotated after use, so production is free to hand back the same token and leave it valid. The emulator always takes the stricter path: a client that forgets to store the newly returned `refresh_token` fails locally instead of in production.

### Authentication failure shapes

`POST /user_management/authenticate` does not use one error shape for every failure. Three grants fail OAuth-style; everything else keeps the plain shape:

| Failure | Body | Node SDK raises |
| --------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------- |
| `authorization_code` — unknown, expired, or bad `code_verifier` | `{"error": "invalid_grant", "error_description": "…"}` | `OauthException` |
| `refresh_token` — unknown, expired, rotated, or user deleted | `{"error": "invalid_grant", "error_description": "…"}` | `OauthException` |
| Device code — pending, expired, unknown | `{"error": "authorization_pending\|expired_token\|invalid_grant", …}` | `OauthException` |
| `password` — wrong password | `{"code": "invalid_credentials", "message": "…"}` (400) | `GenericServerException` |
| Magic Auth — wrong or expired code | `{"code": "invalid_one_time_code\|one_time_code_expired", …}` | `GenericServerException` |
| Step-up (MFA, org selection, email verification) | `{"code": "…", "message": "…"}` (403) | `AuthenticationException` |

`password` is an RFC 6749 grant, but production fails it with the plain shape, so the emulator does too. `/sso/token` is OAuth-shaped throughout, matching its spec definition.

### Emitted events

Expand Down Expand Up @@ -683,6 +698,12 @@ is stable for a pinned key without being pinned separately.

Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.).

`@workos/emulate/core` exports the two error classes the emulator itself throws, for hooks that need to
raise a failure rather than describe one: `WorkOSApiError(status, message, code)` renders the plain
`{code, message}` envelope, and `OauthApiError(status, error, description)` the RFC 6749
`{error, error_description}` one used by `/sso/token`, `/oauth2/token` and the OAuth-shaped
`authenticate` grants (see [Authentication failure shapes](#authentication-failure-shapes)).

### Seed config

Add `errorHooks` to your config file:
Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { createServer, type ServerOptions } from './server.js';
export { type ServicePlugin, type RouteContext } from './plugin.js';
export {
WorkOSApiError,
OauthApiError,
createApiErrorHandler,
requestIdMiddleware,
notFound,
Expand Down
16 changes: 16 additions & 0 deletions src/core/middleware/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,24 @@ export class WorkOSApiError extends Error {
}
}

/**
* A failure of an RFC 6749 grant, rendered OAuth-style as `{error, error_description}` —
* production only uses this shape for the standard grants; the `urn:workos:` grants keep
* the plain `{code, message}` shape. Reuses `code`/`message` storage so event payloads
* (which always carry `{code, message}`) need no special casing.
*/
export class OauthApiError extends WorkOSApiError {
constructor(status: number, error: string, description: string) {
super(status, description, error);
this.name = 'OauthApiError';
}
}

export function createApiErrorHandler(): ErrorHandler {
return (err, c) => {
if (err instanceof OauthApiError) {
return c.json({ error: err.code, error_description: err.message }, err.status as ContentfulStatusCode);
}
if (err instanceof WorkOSApiError) {
const body: Record<string, unknown> = {
message: err.message,
Expand Down
4 changes: 2 additions & 2 deletions src/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,14 +279,14 @@ describe('end-to-end login flow (workos.com/docs story)', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'password', email, password: 'wrong password' }),
});
expect(res.status).toBe(401);
expect(res.status).toBe(400);

const webhook = await waitForWebhook('authentication.password_failed', { after: cursor });
expect(webhook.data).toMatchObject({
type: 'password',
status: 'failed',
email,
error: { code: 'invalid_credentials', message: 'Invalid credentials' },
error: { code: 'invalid_credentials', message: `Invalid credentials for '${email}'.` },
});
verifySignature(webhook);
expectSpecShape(webhook);
Expand Down
229 changes: 223 additions & 6 deletions src/workos/routes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,11 @@ describe('Auth routes', () => {
password: 'wrong',
}),
});
expect(res.status).toBe(401);
expect(res.status).toBe(400);
expect(await json(res)).toEqual({
code: 'invalid_credentials',
message: "Invalid credentials for 'bad@test.com'.",
});
});

it('authorization_code grant flow', async () => {
Expand Down Expand Up @@ -363,7 +367,8 @@ describe('Auth routes', () => {
});
expect(retryRes.status).toBe(400);
const retryBody = await json(retryRes);
expect(retryBody.code).toBe('invalid_grant');
expect(retryBody.error).toBe('invalid_grant');
expect(retryBody.error_description).toBe('Invalid refresh token.');
});

it('rejects invalid refresh token', async () => {
Expand All @@ -374,7 +379,219 @@ describe('Auth routes', () => {
});
expect(res.status).toBe(400);
const body = await json(res);
expect(body.code).toBe('invalid_grant');
expect(body).toEqual({ error: 'invalid_grant', error_description: 'Invalid refresh token.' });
});

it('fails an expired refresh token OAuth-style, with its own description', async () => {
await createUser('staletoken@test.com');
const auth = await json(await signInWithMagicAuth('staletoken@test.com'));

const ws = getWorkOSStore(store);
const stored = ws.refreshTokens.findOneBy('token', auth.refresh_token)!;
ws.refreshTokens.update(stored.id, { expires_at: new Date(Date.now() - 60_000).toISOString() });

const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: auth.refresh_token }),
});
expect(res.status).toBe(400);
expect(await json(res)).toEqual({ error: 'invalid_grant', error_description: 'Refresh token has expired.' });
});

it('fails refresh OAuth-style when the user behind the token was deleted', async () => {
await createUser('deleted@test.com');
const auth = await json(await signInWithMagicAuth('deleted@test.com'));
getWorkOSStore(store).users.delete(auth.user.id);

const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: auth.refresh_token }),
});
expect(res.status).toBe(400);
const body = await json(res);
expect(body).toEqual({ error: 'invalid_grant', error_description: 'Invalid refresh token.' });
});

it('fails an unknown authorization code OAuth-style', async () => {
const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'authorization_code', code: 'bogus' }),
});
expect(res.status).toBe(400);
const body = await json(res);
expect(body).toEqual({
error: 'invalid_grant',
error_description: "The code 'bogus' has expired or is invalid.",
});
});

// Production does not distinguish unknown from expired here, so a code that was real and aged
// out has to be indistinguishable from one that never existed — same OAuth shape, same code,
// same description. A client that can tell them apart locally is reading a difference that
// production will not give it.
it('fails an expired authorization code exactly like an unknown one', async () => {
await createUser('stalecode@test.com');
const authRes = await app.request(
'/user_management/authorize?redirect_uri=http://localhost:3000/callback&response_type=code',
);
const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!;

const ws = getWorkOSStore(store);
const stored = ws.authCodes.findOneBy('code', code)!;
ws.authCodes.update(stored.id, { expires_at: new Date(Date.now() - 60_000).toISOString() });

const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'authorization_code', code }),
});
expect(res.status).toBe(400);
expect(await json(res)).toEqual({
error: 'invalid_grant',
error_description: `The code '${code}' has expired or is invalid.`,
});
});

it('fails a wrong magic auth code with the plain shape and production code string', async () => {
await createUser('wrongcode@test.com');
const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'urn:workos:oauth:grant-type:magic-auth:code',
email: 'wrongcode@test.com',
code: '000000',
}),
});
expect(res.status).toBe(400);
const body = await json(res);
expect(body).toEqual({ code: 'invalid_one_time_code', message: 'Invalid one-time code' });
});

it('fails a PKCE verifier mismatch OAuth-style, like any other bad authorization code', async () => {
const user = await createUser('pkce@test.com');
getWorkOSStore(store).authCodes.insert({
object: 'authorization_code',
code: 'pkce-code',
user_id: user.id,
organization_id: null,
client_id: null,
code_challenge: 'a-challenge-no-verifier-will-hash-to',
code_challenge_method: 'S256',
expires_at: new Date(Date.now() + 600_000).toISOString(),
} as never);

const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'authorization_code', code: 'pkce-code', code_verifier: 'wrong' }),
});
expect(res.status).toBe(400);
expect(await json(res)).toEqual({
error: 'invalid_grant',
error_description: "The code 'pkce-code' has expired or is invalid.",
});
});

it('fails device-code polling OAuth-style at every stage', async () => {
const start = await req('/user_management/authorize/device', {
method: 'POST',
body: JSON.stringify({ client_id: 'client_device' }),
});
const { device_code } = await json(start);

// Nobody has approved it yet.
const pending = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code }),
});
expect(pending.status).toBe(400);
expect(await json(pending)).toEqual({
error: 'authorization_pending',
error_description: 'The authorization request is still pending.',
});

const unknown = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: 'nope' }),
});
expect(unknown.status).toBe(400);
expect(await json(unknown)).toEqual({ error: 'invalid_grant', error_description: 'Invalid device code.' });

// The third stage: the user walked away and the code aged out. Its own OAuth code, since a
// polling client stops on expired_token where it would keep polling on authorization_pending.
const ws = getWorkOSStore(store);
const stored = ws.deviceAuthorizations.findOneBy('device_code', device_code)!;
ws.deviceAuthorizations.update(stored.id, { expires_at: new Date(Date.now() - 60_000).toISOString() });

const expired = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code }),
});
expect(expired.status).toBe(400);
expect(await json(expired)).toEqual({
error: 'expired_token',
error_description: 'The device code has expired.',
});
});

// The same hole the refresh_token grant had: an approved code whose user is gone fell through
// to the shared lookup and answered a polling client with the one plain 404 this endpoint never
// otherwise returns — a body it has no reason to be able to parse.
it('fails an approved device code OAuth-style when its user was deleted', async () => {
const user = await createUser('devicegone@test.com');
const start = await req('/user_management/authorize/device', {
method: 'POST',
body: JSON.stringify({ client_id: 'client_device' }),
});
const { device_code } = await json(start);

const ws = getWorkOSStore(store);
const stored = ws.deviceAuthorizations.findOneBy('device_code', device_code)!;
ws.deviceAuthorizations.update(stored.id, { user_id: user.id });
ws.users.delete(user.id);

const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code }),
});
expect(res.status).toBe(400);
expect(await json(res)).toEqual({ error: 'invalid_grant', error_description: 'Invalid device code.' });
// Nothing consumed on a failure the caller cannot fix by polling again.
expect(ws.deviceAuthorizations.findOneBy('device_code', device_code)).toBeDefined();
});

it('fails an expired magic auth code with the production code string', async () => {
const user = await createUser('expired@test.com');
getWorkOSStore(store).magicAuths.insert({
object: 'magic_auth',
user_id: user.id,
email: user.email,
code: '123456',
expires_at: new Date(Date.now() - 60_000).toISOString(),
});
const res = await app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'urn:workos:oauth:grant-type:magic-auth:code',
email: 'expired@test.com',
code: '123456',
}),
});
expect(res.status).toBe(400);
const body = await json(res);
expect(body).toEqual({
code: 'one_time_code_expired',
message: "One-time code for 'expired@test.com' has expired.",
});
});

// --- Impersonation tests ---
Expand Down Expand Up @@ -1649,15 +1866,15 @@ describe('authentication events (spec-named, spec-shaped)', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'password', email: 'evt-fail@test.com', password: 'wrong' }),
});
expect(res.status).toBe(401);
expect(res.status).toBe(400);

const [event] = eventsNamed('authentication.password_failed');
expect(event).toBeDefined();
expect(event.data).toMatchObject({
type: 'password',
status: 'failed',
email: 'evt-fail@test.com',
error: { code: 'invalid_credentials', message: 'Invalid credentials' },
error: { code: 'invalid_credentials', message: "Invalid credentials for 'evt-fail@test.com'." },
});
});

Expand Down Expand Up @@ -1693,7 +1910,7 @@ describe('authentication events (spec-named, spec-shaped)', () => {
expect(event.data).toMatchObject({
type: 'oauth',
status: 'failed',
error: { code: 'invalid_code', message: 'Invalid code' },
error: { code: 'invalid_grant', message: "The code 'bogus' has expired or is invalid." },
});
});

Expand Down
Loading
Loading