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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,25 @@ 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. Which shape you get depends on the failure, not only on the grant: any malformed request is OAuth-shaped, and among credential failures three grants are OAuth-shaped and the rest plain.

| Failure | Body | Node SDK raises |
| ---------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------- |
| Malformed request — missing or unrecognized parameter, any grant | `{"error": "invalid_request", "error_description": "…"}` | `OauthException` |
| `authorization_code` — unknown, expired, bad verifier, user gone | `{"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, or user deleted | `{"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 its credentials with the plain shape, so the emulator does too — while a `password` request that omits a parameter still answers `invalid_request` OAuth-style. Both halves come from the spec, whose authenticate 400 lists `invalid_request` and `invalid_grant` only as `{error, error_description}` and `invalid_credentials` and the one-time-code errors only as `{code, message}`. An unrecognized `grant_type` is reported as `invalid_request` rather than `unsupported_grant_type`, which the spec gives to `/sso/token` alone.

`/sso/token` is OAuth-shaped throughout, matching its spec definition.

### Emitted events

Expand Down Expand Up @@ -683,6 +701,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
Loading
Loading