From a6c125c0723084d91716457cbcb1827be9b13047 Mon Sep 17 00:00:00 2001 From: Grayash Date: Thu, 6 Aug 2026 17:38:22 +0900 Subject: [PATCH 01/12] fix(auth): render RFC 6749 grant failures OAuth-style and adopt production magic-auth error codes --- src/core/index.ts | 1 + src/core/middleware/error-handler.ts | 16 +++++++ src/workos/routes/auth.spec.ts | 63 ++++++++++++++++++++++++++-- src/workos/routes/auth.ts | 23 ++++++---- 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index bfea445..d086af2 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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, diff --git a/src/core/middleware/error-handler.ts b/src/core/middleware/error-handler.ts index 2ec1466..787f848 100644 --- a/src/core/middleware/error-handler.ts +++ b/src/core/middleware/error-handler.ts @@ -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 = { message: err.message, diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 43d39d4..f11b337 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -363,7 +363,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 () => { @@ -374,7 +375,63 @@ 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 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.", + }); + }); + + 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 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 --- @@ -1693,7 +1750,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." }, }); }); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 985f6e4..eca7a67 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -4,6 +4,7 @@ import { notFound, parseJsonBody, WorkOSApiError, + OauthApiError, generateId, generateUlid, } from '../../core/index.js'; @@ -299,13 +300,21 @@ export function authRoutes(ctx: RouteContext): void { const code = body.code as string; if (!code) throw new WorkOSApiError(400, 'code is required', 'invalid_request'); + // Production does not distinguish unknown from expired codes: both fail OAuth-style + // as invalid_grant with the same description. const authCode = ws.authCodes.findOneBy('code', code); - if (!authCode) failAuth('OAuth', {}, new WorkOSApiError(400, 'Invalid code', 'invalid_code')); + if (!authCode) { + failAuth( + 'OAuth', + {}, + new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), + ); + } if (isExpired(authCode.expires_at)) { failAuth( 'OAuth', { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, - new WorkOSApiError(400, 'Code has expired', 'expired_code'), + new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), ); } @@ -377,13 +386,13 @@ export function authRoutes(ctx: RouteContext): void { const magicAuth = ws.magicAuths.all().find((ma) => ma.code === code && ma.email === email); if (!magicAuth) { - failAuth('MagicAuth', { email }, new WorkOSApiError(400, 'Invalid code', 'invalid_code')); + failAuth('MagicAuth', { email }, new WorkOSApiError(400, 'Invalid one-time code', 'invalid_one_time_code')); } if (isExpired(magicAuth.expires_at)) { failAuth( 'MagicAuth', { email: magicAuth.email, userId: magicAuth.user_id }, - new WorkOSApiError(400, 'Code has expired', 'expired_code'), + new WorkOSApiError(400, `One-time code for '${magicAuth.email}' has expired.`, 'one_time_code_expired'), ); } @@ -433,11 +442,11 @@ export function authRoutes(ctx: RouteContext): void { const refreshToken = ws.refreshTokens.findOneBy('token', token); if (!refreshToken) { - throw new WorkOSApiError(400, 'Invalid refresh token', 'invalid_grant'); + throw new OauthApiError(400, 'invalid_grant', 'Invalid refresh token.'); } if (isExpired(refreshToken.expires_at)) { ws.refreshTokens.delete(refreshToken.id); - throw new WorkOSApiError(400, 'Refresh token has expired', 'invalid_grant'); + throw new OauthApiError(400, 'invalid_grant', 'Refresh token has expired.'); } user = ws.users.get(refreshToken.user_id); @@ -694,7 +703,7 @@ export function authRoutes(ctx: RouteContext): void { }); } else { const existing = refreshSessionId ? ws.sessions.get(refreshSessionId) : undefined; - if (!existing) throw new WorkOSApiError(400, 'Invalid refresh token', 'invalid_grant'); + if (!existing) throw new OauthApiError(400, 'invalid_grant', 'Invalid refresh token.'); session = existing; } const updatedUser = ws.users.get(user.id)!; From 0f625bedaba04909bd893aa9242b31ae94565d5d Mon Sep 17 00:00:00 2001 From: Grayash Date: Thu, 6 Aug 2026 19:39:23 +0900 Subject: [PATCH 02/12] fix(auth): fail refresh OAuth-style when the token's user is gone --- src/workos/routes/auth.spec.ts | 15 +++++++++++++++ src/workos/routes/auth.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index f11b337..7aec9f7 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -378,6 +378,21 @@ describe('Auth routes', () => { expect(body).toEqual({ error: 'invalid_grant', error_description: 'Invalid refresh token.' }); }); + 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', diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index eca7a67..1a28fe5 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -450,6 +450,11 @@ export function authRoutes(ctx: RouteContext): void { } user = ws.users.get(refreshToken.user_id); + // A token whose user was deleted is as invalid as an unknown one — verified live; + // without this the shared lookup below would answer with a plain 404. + if (!user) { + throw new OauthApiError(400, 'invalid_grant', 'Invalid refresh token.'); + } // Allow body.organization_id to switch org context (switchToOrganization) organizationId = (body.organization_id as string) ?? refreshToken.organization_id; From e09d42e56cd4fc4a17693d3eefddfe4827086cb3 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 10:05:03 -0400 Subject: [PATCH 03/12] fix(auth): apply the failure-shape split consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec settles what probing could not. Its authenticate 400 lists the device-flow codes (expired_token, authorization_pending, slow_down, access_denied) as {error, error_description}, gives /sso/token nothing but OAuth-shaped errors, and puts invalid_credentials in the plain set at 400. That leaves the rule as an explicit allowlist rather than "RFC 6749 grants fail OAuth-style": password is an RFC 6749 grant and still fails plain, verified against a live environment. Stating it as a category was going to mislead the next reader, since the category does not predict the shape. PKCE is the one case decided by reasoning rather than evidence: the spec does not enumerate invalid_grant for authenticate at all, even though the live API returns it, so silence there is not evidence against. RFC 7636 §4.6 makes a failed verifier an invalid_grant, and it is the same grant on the same endpoint already verified to fail that way. --- README.md | 17 ++++++++- src/e2e.spec.ts | 4 +-- src/workos/routes/auth.spec.ts | 63 ++++++++++++++++++++++++++++++++-- src/workos/routes/auth.ts | 22 +++++++++--- src/workos/routes/sso.spec.ts | 8 ++++- src/workos/routes/sso.ts | 10 +++--- 6 files changed, 108 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2d84a00..8446f8b 100644 --- a/README.md +++ b/README.md @@ -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."}` — the OAuth shape described below, which the Node SDK surfaces as an `OauthException`. 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, and neither does production. Two 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 the one that surprises people: it 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 diff --git a/src/e2e.spec.ts b/src/e2e.spec.ts index f5f269f..92059b7 100644 --- a/src/e2e.spec.ts +++ b/src/e2e.spec.ts @@ -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); diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 7aec9f7..5e7cebb 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -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 () => { @@ -423,6 +427,59 @@ describe('Auth routes', () => { 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.' }); + }); + it('fails an expired magic auth code with the production code string', async () => { const user = await createUser('expired@test.com'); getWorkOSStore(store).magicAuths.insert({ @@ -1721,7 +1778,7 @@ 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(); @@ -1729,7 +1786,7 @@ describe('authentication events (spec-named, spec-shaped)', () => { 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'." }, }); }); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 1a28fe5..d985386 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -331,10 +331,14 @@ export function authRoutes(ctx: RouteContext): void { challenge = codeVerifier; } if (challenge !== authCode.code_challenge) { + // A failed verifier is a failure of the authorization_code grant, so it fails the + // same OAuth-style way an unknown code does (RFC 7636 §4.6). Leaving it plain put + // the shape of a failure at odds with the reason for it, on the one path every + // PKCE client takes. failAuth( 'OAuth', { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, - new WorkOSApiError(400, 'Invalid code_verifier', 'invalid_code_verifier'), + new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), ); } } @@ -358,10 +362,13 @@ export function authRoutes(ctx: RouteContext): void { user = ws.users.findOneBy('email', email); if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { + // Verified live: 400 (not 401) with the email interpolated. `password` is an RFC 6749 + // grant that nonetheless fails with the plain shape, which is why the rule here is an + // explicit two-grant allowlist rather than "standard grants fail OAuth-style". failAuth( 'Password', { email, userId: user?.id }, - new WorkOSApiError(401, 'Invalid credentials', 'invalid_credentials'), + new WorkOSApiError(400, `Invalid credentials for '${email}'.`, 'invalid_credentials'), ); } authMethod = 'Password'; @@ -583,16 +590,21 @@ export function authRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'device_code is required', 'invalid_request'); } + // The spec renders every device-flow code — expired_token, authorization_pending, + // slow_down, access_denied — as {error, error_description}. These previously used the + // plain envelope while already carrying OAuth error codes, so a polling client matching + // `error` saw nothing and one matching `code` worked: the exact inverse of every other + // grant here. const deviceAuth = ws.deviceAuthorizations.findOneBy('device_code', deviceCode); if (!deviceAuth) { - throw new WorkOSApiError(400, 'Invalid device code', 'invalid_grant'); + throw new OauthApiError(400, 'invalid_grant', 'Invalid device code.'); } if (isExpired(deviceAuth.expires_at)) { ws.deviceAuthorizations.delete(deviceAuth.id); - throw new WorkOSApiError(400, 'Device code has expired', 'expired_token'); + throw new OauthApiError(400, 'expired_token', 'The device code has expired.'); } if (!deviceAuth.user_id) { - throw new WorkOSApiError(400, 'Authorization pending', 'authorization_pending'); + throw new OauthApiError(400, 'authorization_pending', 'The authorization request is still pending.'); } user = ws.users.get(deviceAuth.user_id); diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 7746e03..311efa6 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -298,13 +298,19 @@ describe('SSO authentication events', () => { body: JSON.stringify({ grant_type: 'authorization_code', code: 'sso_bogus' }), }); expect(res.status).toBe(400); + // The response is OAuth-shaped, but the event's error object keeps the spec's + // {code, message} — OauthApiError reuses those fields, so both stay correct. + expect(await res.json()).toEqual({ + error: 'invalid_grant', + error_description: "The code 'sso_bogus' has expired or is invalid.", + }); const [event] = eventsNamed('authentication.sso_failed'); expect(event).toBeDefined(); expect(event.data).toMatchObject({ type: 'sso', status: 'failed', - error: { code: 'invalid_code', message: 'Invalid authorization code' }, + error: { code: 'invalid_grant', message: "The code 'sso_bogus' has expired or is invalid." }, sso: { organization_id: null, connection_id: null, session_id: null }, }); }); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index f1b6e43..0ab9d86 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -1,5 +1,5 @@ import type { Context } from 'hono'; -import { type RouteContext, parseJsonBody, WorkOSApiError, generateId } from '../../core/index.js'; +import { type RouteContext, parseJsonBody, WorkOSApiError, OauthApiError, generateId } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatSSOProfile, expiresIn, isExpired, assertLocalRedirectUri, emitAuthenticationEvent } from '../helpers.js'; import type { WorkOSConnection } from '../entities.js'; @@ -138,8 +138,10 @@ export function ssoRoutes(ctx: RouteContext): void { const grantType = body.grant_type as string; const code = body.code as string; + // The spec gives /sso/token only OAuth-shaped 400s — invalid_client, unauthorized_client, + // invalid_grant, unsupported_grant_type — so every failure below is rendered that way. if (grantType !== 'authorization_code') { - throw new WorkOSApiError(400, 'Unsupported grant_type', 'invalid_request'); + throw new OauthApiError(400, 'unsupported_grant_type', `The grant type is not supported: ${grantType}`); } if (!code) { throw new WorkOSApiError(400, 'code is required', 'invalid_request'); @@ -147,7 +149,7 @@ export function ssoRoutes(ctx: RouteContext): void { const auth = ws.ssoAuthorizations.findOneBy('code', code); if (!auth) { - const error = new WorkOSApiError(400, 'Invalid authorization code', 'invalid_code'); + const error = new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`); emitAuthenticationEvent({ eventBus: store.getData(STORE_KEYS.eventBus), method: 'SSO', @@ -166,7 +168,7 @@ export function ssoRoutes(ctx: RouteContext): void { if (isExpired(auth.expires_at)) { ws.ssoAuthorizations.delete(auth.id); const expiredProfile = ws.ssoProfiles.get(auth.profile_id); - const error = new WorkOSApiError(400, 'Authorization code has expired', 'expired_code'); + const error = new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`); emitAuthenticationEvent({ eventBus: store.getData(STORE_KEYS.eventBus), method: 'SSO', From db4474ad293250211d5504377efd59e9e6d79360 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 10:09:26 -0400 Subject: [PATCH 04/12] style: align the failure-shape table --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8446f8b..50e531b 100644 --- a/README.md +++ b/README.md @@ -519,14 +519,14 @@ The emulator issues a new refresh token on every refresh and invalidates the one `POST /user_management/authenticate` does not use one error shape for every failure, and neither does production. Two 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` | +| 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 the one that surprises people: it 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. From 1ab8ec2cd38f5b5dfcd889bf6e4c9b73e85c7c63 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 11:42:50 -0400 Subject: [PATCH 05/12] fix(auth): render every OAuth-shaped failure through one renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups, all narrowing the gap between what the comments claim and what the code does. /sso/token's missing-code path still threw the plain envelope, directly under a new comment asserting the endpoint answers OAuth-shaped throughout. RFC 6749 §5.2 names a missing required parameter invalid_request, and it is the one failure a client meets before it has a code to present — the worst one to make it parse differently from the rest. /oauth2/token had its own oauthError() building the identical {error, error_description} body by hand, so the OAuth envelope was defined in two places and the reusable one was reachable from everywhere except the endpoint most obviously about OAuth. Throwing OauthApiError leaves one definition; the m2m tests pass untouched, which is the point. Three shapes shipped with no test: the expired refresh token's distinct description, the device flow's expired_token (a polling client stops there where authorization_pending tells it to keep going), and /sso/token's unsupported_grant_type, which had no coverage before this change either. --- src/workos/routes/auth.spec.ts | 34 ++++++++++++++++++++++++++++++++++ src/workos/routes/oauth.ts | 28 +++++++++++++++------------- src/workos/routes/sso.spec.ts | 23 +++++++++++++++++++++++ src/workos/routes/sso.ts | 7 +++++-- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 5e7cebb..4130a0c 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -382,6 +382,23 @@ describe('Auth routes', () => { 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')); @@ -478,6 +495,23 @@ describe('Auth routes', () => { }); 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.', + }); }); it('fails an expired magic auth code with the production code string', async () => { diff --git a/src/workos/routes/oauth.ts b/src/workos/routes/oauth.ts index 86bd0e5..2bf1a0a 100644 --- a/src/workos/routes/oauth.ts +++ b/src/workos/routes/oauth.ts @@ -1,5 +1,5 @@ import type { Context } from 'hono'; -import { type RouteContext, generateUlid } from '../../core/index.js'; +import { type RouteContext, OauthApiError, generateUlid } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; /** @@ -22,6 +22,11 @@ import { getWorkOSStore } from '../store.js'; * SDK reads `payload.scope`), and every token carries a `jti`, without which the SDKs' * M2M claim guard rejects an otherwise-valid token. Neither is emulator-flavored: a * scopes *array*, or an omitted `jti`, would pass locally and fail in production. + * + * Failures throw `OauthApiError`, the same RFC 6749 §5.2 renderer `/sso/token` and the + * OAuth-shaped authenticate grants use. This endpoint had a local `oauthError()` helper that + * built the identical body by hand, which meant the OAuth envelope was defined in two places + * and only one of them was reachable from anywhere else. */ const TOKEN_TTL_SECONDS = 3600; @@ -33,11 +38,6 @@ interface TokenParams { scope?: string; } -/** RFC 6749 §5.2 error body. */ -function oauthError(c: Context, status: 400 | 401, error: string, description: string) { - return c.json({ error, error_description: description }, status); -} - /** * Decode a Basic-auth credential component. RFC 6749 §2.3.1 form-urlencodes the * client_id/secret before base64, but many clients send them literally; a literal `%` @@ -105,21 +105,24 @@ export function oauthRoutes(ctx: RouteContext): void { const { grantType, clientId, clientSecret, scope } = await readTokenParams(c); if (grantType !== 'client_credentials') { - return oauthError(c, 400, 'unsupported_grant_type', `The grant type is not supported: ${grantType ?? '(none)'}`); + throw new OauthApiError( + 400, + 'unsupported_grant_type', + `The grant type is not supported: ${grantType ?? '(none)'}`, + ); } if (!clientId || !clientSecret) { - return oauthError(c, 400, 'invalid_request', 'client_id and client_secret are required.'); + throw new OauthApiError(400, 'invalid_request', 'client_id and client_secret are required.'); } const application = ws.connectApplications.findOneBy('client_id', clientId); const secretMatches = application && ws.clientSecrets.findBy('application_id', application.id).some((s) => s.value === clientSecret); if (!application || !secretMatches) { - return oauthError(c, 401, 'invalid_client', 'Invalid client ID or secret.'); + throw new OauthApiError(401, 'invalid_client', 'Invalid client ID or secret.'); } if (application.application_type !== 'm2m') { - return oauthError( - c, + throw new OauthApiError( 400, 'unauthorized_client', 'The client is not authorized to use the client_credentials grant type.', @@ -137,8 +140,7 @@ export function oauthRoutes(ctx: RouteContext): void { const requested = scope.trim().split(/\s+/); const unknown = requested.filter((s) => !appScopes.includes(s)); if (unknown.length > 0) { - return oauthError( - c, + throw new OauthApiError( 400, 'invalid_scope', `The application is not granted the requested scope(s): ${unknown.join(', ')}.`, diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 311efa6..9d70755 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -314,4 +314,27 @@ describe('SSO authentication events', () => { sso: { organization_id: null, connection_id: null, session_id: null }, }); }); + + // Every /sso/token failure is OAuth-shaped, including the two a client hits before it has a + // code to present — the endpoint has no plain-shaped response for a caller to have to parse. + it('rejects a wrong grant type and a missing code OAuth-style', async () => { + const wrongGrant = await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'client_credentials', code: 'whatever' }), + }); + expect(wrongGrant.status).toBe(400); + expect(await wrongGrant.json()).toEqual({ + error: 'unsupported_grant_type', + error_description: 'The grant type is not supported: client_credentials', + }); + + const noCode = await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code' }), + }); + expect(noCode.status).toBe(400); + expect(await noCode.json()).toEqual({ error: 'invalid_request', error_description: 'code is required.' }); + }); }); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 0ab9d86..250f3be 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -139,12 +139,15 @@ export function ssoRoutes(ctx: RouteContext): void { const code = body.code as string; // The spec gives /sso/token only OAuth-shaped 400s — invalid_client, unauthorized_client, - // invalid_grant, unsupported_grant_type — so every failure below is rendered that way. + // invalid_grant, unsupported_grant_type — so every failure below is rendered that way, + // including the missing-parameter case the spec leaves out and RFC 6749 §5.2 names + // invalid_request. A plain envelope there would have made the one failure a client hits + // before it has a code the one failure it cannot parse like the rest. if (grantType !== 'authorization_code') { throw new OauthApiError(400, 'unsupported_grant_type', `The grant type is not supported: ${grantType}`); } if (!code) { - throw new WorkOSApiError(400, 'code is required', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'code is required.'); } const auth = ws.ssoAuthorizations.findOneBy('code', code); From 47f4988abda688a39fbd20be3aadde3b6a3db272 Mon Sep 17 00:00:00 2001 From: Garen Torikian Date: Thu, 6 Aug 2026 11:50:37 -0400 Subject: [PATCH 06/12] Fix formatting issues in README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 50e531b..0515051 100644 --- a/README.md +++ b/README.md @@ -513,11 +513,11 @@ 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 `{"error": "invalid_grant", "error_description": "Invalid refresh token."}` — the OAuth shape described below, which the Node SDK surfaces as an `OauthException`. 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, and neither does production. Two grants fail OAuth-style; everything else keeps the plain shape: +`POST /user_management/authenticate` does not use one error shape for every failure. Two grants fail OAuth-style; everything else keeps the plain shape: | Failure | Body | Node SDK raises | | --------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------- | @@ -528,7 +528,7 @@ The emulator issues a new refresh token on every refresh and invalidates the one | 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 the one that surprises people: it 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. +`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 From d074fc25100b0d580cc67ba8792833f481d72443 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 12:23:16 -0400 Subject: [PATCH 07/12] fix(auth): close the device grant's plain-404 path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An approved device code whose user was deleted fell through to the shared lookup and answered with a {message, code} 404 — the one envelope this endpoint otherwise never returns, on the grant whose whole contract is that a polling client reads `error` to decide whether to keep going. The refresh_token twin of this was already fixed; this mirrors it, and throws before the delete so nothing is spent on a failure polling cannot resolve. /sso/token reported an omitted grant_type as "not supported: undefined", which names neither the problem nor anything the caller sent. Absent is a malformed request — RFC 6749 §5.2 invalid_request — not a request for a grant the endpoint declines to support. The device-flow comment also implied slow_down and access_denied are among the codes returned here. The spec defines them; the emulator has no polling-interval or user-denial surface to emit either from. And documents both error classes where error hooks are described, since a hook that raises a failure rather than describing one now has two envelopes to choose between. --- README.md | 6 ++++++ src/workos/routes/auth.spec.ts | 27 +++++++++++++++++++++++++++ src/workos/routes/auth.ts | 20 +++++++++++++++----- src/workos/routes/sso.spec.ts | 12 ++++++++++++ src/workos/routes/sso.ts | 15 +++++++++++---- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0515051..8fce412 100644 --- a/README.md +++ b/README.md @@ -698,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: diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 4130a0c..65ff850 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -514,6 +514,33 @@ describe('Auth routes', () => { }); }); + // 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({ diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index d985386..086f4dc 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -590,11 +590,13 @@ export function authRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'device_code is required', 'invalid_request'); } - // The spec renders every device-flow code — expired_token, authorization_pending, - // slow_down, access_denied — as {error, error_description}. These previously used the - // plain envelope while already carrying OAuth error codes, so a polling client matching - // `error` saw nothing and one matching `code` worked: the exact inverse of every other - // grant here. + // The spec renders every device-flow code as {error, error_description}, so the three + // this endpoint can reach — invalid_grant, expired_token, authorization_pending — are + // rendered that way. (The spec also defines slow_down and access_denied; the emulator + // never emits them, having no polling-interval or user-denial surface.) These previously + // used the plain envelope while already carrying OAuth error codes, so a polling client + // matching `error` saw nothing and one matching `code` worked: the exact inverse of + // every other grant here. const deviceAuth = ws.deviceAuthorizations.findOneBy('device_code', deviceCode); if (!deviceAuth) { throw new OauthApiError(400, 'invalid_grant', 'Invalid device code.'); @@ -608,6 +610,14 @@ export function authRoutes(ctx: RouteContext): void { } user = ws.users.get(deviceAuth.user_id); + // Mirrors the refresh_token guard: an approved code whose user was deleted is as invalid + // as an unknown one, and without this the shared lookup below answers a polling client + // with a plain 404 — the one shape this endpoint otherwise never returns, on the grant + // whose whole contract is that the client reads `error` to decide whether to keep going. + // Thrown before the delete, so nothing is consumed on a failure the caller cannot fix. + if (!user) { + throw new OauthApiError(400, 'invalid_grant', 'Invalid device code.'); + } ws.deviceAuthorizations.delete(deviceAuth.id); authMethod = 'OAuth'; break; diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 9d70755..4bfead1 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -337,4 +337,16 @@ describe('SSO authentication events', () => { expect(noCode.status).toBe(400); expect(await noCode.json()).toEqual({ error: 'invalid_request', error_description: 'code is required.' }); }); + + // Absent is a malformed request, not a request for an unsupported grant — and describing it as + // "not supported: undefined" names neither the problem nor anything the caller sent. + it('reports an omitted grant type as invalid_request, not unsupported_grant_type', async () => { + const res = await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: 'whatever' }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request', error_description: 'grant_type is required.' }); + }); }); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 250f3be..a1825b3 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -135,14 +135,21 @@ export function ssoRoutes(ctx: RouteContext): void { app.post('/sso/token', async (c) => { const body = await parseJsonBody(c); - const grantType = body.grant_type as string; + const grantType = body.grant_type as string | undefined; const code = body.code as string; // The spec gives /sso/token only OAuth-shaped 400s — invalid_client, unauthorized_client, // invalid_grant, unsupported_grant_type — so every failure below is rendered that way, - // including the missing-parameter case the spec leaves out and RFC 6749 §5.2 names - // invalid_request. A plain envelope there would have made the one failure a client hits - // before it has a code the one failure it cannot parse like the rest. + // including the missing-parameter cases the spec leaves out and RFC 6749 §5.2 names + // invalid_request. A plain envelope there would have made the failures a client hits before + // it has a code the ones it cannot parse like the rest. + // + // Absent and wrong are different failures: an omitted grant_type is a malformed request, + // not a request for a grant this endpoint declines to support, and reporting it as + // "not supported: undefined" describes neither. + if (!grantType) { + throw new OauthApiError(400, 'invalid_request', 'grant_type is required.'); + } if (grantType !== 'authorization_code') { throw new OauthApiError(400, 'unsupported_grant_type', `The grant type is not supported: ${grantType}`); } From dfa57751ffef53e0e58b69bb276da455f6b7d83f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 13:21:27 -0400 Subject: [PATCH 08/12] docs(auth): count the OAuth-shaped grants correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e09d42e replaced "RFC 6749 grants fail OAuth-style" with an explicit allowlist precisely because the category did not predict the shape and was going to mislead the next reader. The allowlist then grew a third member — device_code — without the prose following it, so both the README and the comment beside the password grant still said two, one line above a table listing three. A reader checking whether their grant is OAuth-shaped counts the rows, not the sentence, but a sentence that disagrees with the table beneath it costs them the trust that makes the table worth reading. --- README.md | 2 +- src/workos/routes/auth.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8fce412..e4e1a8b 100644 --- a/README.md +++ b/README.md @@ -517,7 +517,7 @@ The emulator issues a new refresh token on every refresh and invalidates the one ### Authentication failure shapes -`POST /user_management/authenticate` does not use one error shape for every failure. Two grants fail OAuth-style; everything else keeps the plain shape: +`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 | | --------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------- | diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 086f4dc..8feead5 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -364,7 +364,8 @@ export function authRoutes(ctx: RouteContext): void { if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { // Verified live: 400 (not 401) with the email interpolated. `password` is an RFC 6749 // grant that nonetheless fails with the plain shape, which is why the rule here is an - // explicit two-grant allowlist rather than "standard grants fail OAuth-style". + // explicit allowlist — authorization_code, refresh_token, device_code — rather than + // "standard grants fail OAuth-style". failAuth( 'Password', { email, userId: user?.id }, From a7a678e6c86d233778a3a6026c293ee8b8fa04fa Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 13:21:42 -0400 Subject: [PATCH 09/12] test(auth): cover both expired-code paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two remaining shapes this branch changed without pinning. Both expired-code paths moved off the plain envelope — authenticate's authorization_code from expired_code, /sso/token's from expired_code — and neither had a test, so the only evidence they render invalid_grant was that their unknown-code siblings do. They are not those siblings with a different label. /sso/token's expired branch resolves the profile behind the code first, so the authentication.sso_failed it emits carries the organization and connection the unknown-code event leaves null, and it consumes the authorization where the unknown one has nothing to consume. Asserting the org and connection is also what proves the test reached the expired branch at all rather than falling through to the unknown one. Both mint a real code and back-date it, matching how the expired refresh token and device code are already tested, so an expiry that stops being detected fails here rather than passing as a bad code. --- src/workos/routes/auth.spec.ts | 27 +++++++++++++++++++++++ src/workos/routes/sso.spec.ts | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 65ff850..d02cecc 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -428,6 +428,33 @@ describe('Auth routes', () => { }); }); + // 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', { diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 4bfead1..2a4a666 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -315,6 +315,46 @@ describe('SSO authentication events', () => { }); }); + // The expired branch is not the invalid one with a different label: it resolves the profile + // behind the code first, so the event it emits carries the organization and connection the + // unknown-code event has to leave null. Both still answer the caller the same OAuth-shaped + // invalid_grant, because production does not distinguish aged-out from never-existed. + it('emits authentication.sso_failed with the profile’s org and connection for an expired code', async () => { + const { org, conn } = await createOrgWithConnection(); + + const authRes = await app.request( + `/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback`, + ); + const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; + + const ws = getWorkOSStore(store); + const stored = ws.ssoAuthorizations.findOneBy('code', code)!; + ws.ssoAuthorizations.update(stored.id, { expires_at: new Date(Date.now() - 60_000).toISOString() }); + + const res = await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'invalid_grant', + error_description: `The code '${code}' has expired or is invalid.`, + }); + + const [event] = eventsNamed('authentication.sso_failed'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'sso', + status: 'failed', + error: { code: 'invalid_grant', message: `The code '${code}' has expired or is invalid.` }, + sso: { organization_id: org.id, connection_id: conn.id, session_id: null }, + }); + + // Spent, unlike the unknown-code path — there was a real authorization to consume. + expect(ws.ssoAuthorizations.findOneBy('code', code)).toBeUndefined(); + }); + // Every /sso/token failure is OAuth-shaped, including the two a client hits before it has a // code to present — the endpoint has no plain-shaped response for a caller to have to parse. it('rejects a wrong grant type and a missing code OAuth-style', async () => { From f3049c4bad758663e207bf34e4b73523a39ee5bf Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:49:12 -0400 Subject: [PATCH 10/12] fix(auth): close the authorization_code grant's plain-404 path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third instance of a hole already closed twice on this branch. Deleting a user cascades to its sessions, memberships, factors, identities, password resets, email verifications and magic auths — but not to its authorization codes, so a code outlives the user it was minted for. Redeeming one fell through to the shared lookup and answered 404 {"message":"User not found","code":"not_found"}: the spec shapes an authenticate 404 as a bare {message}, so there is no `error` for a client matching invalid_grant and no `code` worth reading either. It is also the grant an AuthKit callback actually takes, and the path authkit-nextjs uses to decide a session is over. Guarded before the delete, like the device code's twin of this, so a failure no retry can fix does not also cost the caller their code. Routed through failAuth rather than a bare throw, because every other authorization_code failure emits authentication.oauth_failed and this one was silently skipping it. --- src/workos/routes/auth.spec.ts | 41 ++++++++++++++++++++++++++++++++++ src/workos/routes/auth.ts | 14 ++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index d02cecc..e06937d 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -455,6 +455,47 @@ describe('Auth routes', () => { }); }); + // The third grant to need this, and the one an AuthKit callback takes. Deleting a user does not + // cascade to its authorization codes, so the code outlives the user; before the guard the shared + // lookup answered a 404, which is the bare {message} the spec shapes 404s as — nothing for a + // client matching on invalid_grant, on the path authkit-nextjs uses to end a dead session. + it('fails an authorization code OAuth-style when its user was deleted', async () => { + const user = await createUser('codegone@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')!; + + // Through the route, so the test breaks if a future cascade starts collecting auth codes. + const del = await req(`/user_management/users/${user.id}`, { method: 'DELETE' }); + expect(del.status).toBe(204); + + 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.`, + }); + + const ws = getWorkOSStore(store); + // Unspent, like the device code's twin of this: polling or retrying cannot fix it, so the + // caller should not pay their code for the answer. + expect(ws.authCodes.findOneBy('code', code)).toBeDefined(); + // And it reports the failure, which the 404 path skipped — every other authorization_code + // failure emits this event. + const [event] = ws.events.all().filter((e) => e.event === 'authentication.oauth_failed'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'oauth', + status: 'failed', + error: { code: 'invalid_grant', message: `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', { diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 8feead5..03ce9f2 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -344,6 +344,20 @@ export function authRoutes(ctx: RouteContext): void { } user = ws.users.get(authCode.user_id); + // The third grant to need this guard, and the one an AuthKit callback actually takes: + // deleting a user leaves its authorization codes behind (only sessions, memberships, + // factors, identities, password resets, email verifications and magic auths cascade), + // so a code can outlive its user. Without it the shared lookup below answers with a 404 + // the spec shapes as a bare {message} — no `error` for the client that is matching on + // invalid_grant, and no authentication.oauth_failed event either. Thrown before the + // delete, so a failure the caller cannot fix does not also cost them the code. + if (!user) { + failAuth( + 'OAuth', + { userId: authCode.user_id }, + new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), + ); + } organizationId = authCode.organization_id; // Bind the token's client_id to the authorization grant, not the unvalidated // redemption-time request parameter. From eadb2a92390287d00f361e1ddb74bfe0ee371547 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:49:37 -0400 Subject: [PATCH 11/12] fix(auth): render authenticate's malformed requests OAuth-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec settles what the last pass had to reason about. Its authenticate 400 lists `invalid_request` among the {error, error_description} variants and nowhere among the {code, message} ones, so a missing or unrecognized parameter is OAuth-shaped on every grant — including the grants whose credential failures are plain. The envelope is a property of the failure, not only of the grant, and the eleven throws here now say so. This is the argument 1ab8ec2 made for /sso/token's missing-code path, applied to the endpoint it was skipped on. It was sharpest on PKCE, where a wrong code_verifier answered {error, error_description} and a missing one answered {code, message} two lines away: the same grant, the same request, two envelopes depending on which way the client got it wrong. The unrecognized-grant_type branch keeps `invalid_request` rather than moving to `unsupported_grant_type`, which appears exactly once in the whole spec, under /sso/token, and never in authenticate's 400. The asymmetry reads as real rather than an omission: authenticate's body is a oneOf discriminated on grant_type, so an unknown value fails body validation instead of reaching a handler that could decline it. What changes is the message, which claimed "Unsupported grant_type" under a code that says malformed request. Also corrects the PKCE comment. e09d42e recorded that the spec does not enumerate invalid_grant for authenticate at all, which is why that case was decided by RFC 7636 alone; it does enumerate it, as an {error, error_description} variant, so the case is spec-backed like its siblings. --- README.md | 27 +++++++------- src/workos/routes/auth.spec.ts | 64 ++++++++++++++++++++++++++++++++++ src/workos/routes/auth.ts | 50 ++++++++++++++++---------- 3 files changed, 111 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e4e1a8b..684f24b 100644 --- a/README.md +++ b/README.md @@ -517,18 +517,21 @@ The emulator issues a new refresh token on every refresh and invalidates the one ### 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. +`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 diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index e06937d..b7fdb9a 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -496,6 +496,70 @@ describe('Auth routes', () => { }); }); + // The spec's authenticate 400 lists `invalid_request` among its {error, error_description} + // variants and never among its {code, message} ones, so a malformed request is OAuth-shaped + // whatever grant it names — including the grants whose credential failures are plain. That + // makes the envelope a property of the failure, not only of the grant. + it('fails a malformed request OAuth-style on every grant, plain-shaped ones included', async () => { + const post = (payload: Record) => + app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const noGrant = await post({ code: 'whatever' }); + expect(noGrant.status).toBe(400); + expect(await json(noGrant)).toEqual({ error: 'invalid_request', error_description: 'grant_type is required.' }); + + const noCode = await post({ grant_type: 'authorization_code' }); + expect(noCode.status).toBe(400); + expect(await json(noCode)).toEqual({ error: 'invalid_request', error_description: 'code is required.' }); + + // `password` renders its *credential* failure plain; a missing parameter is a different + // failure and the spec shapes it the other way. + const noPassword = await post({ grant_type: 'password', email: 'nobody@test.com' }); + expect(noPassword.status).toBe(400); + expect(await json(noPassword)).toEqual({ + error: 'invalid_request', + error_description: 'email and password are required.', + }); + + // An unrecognized grant_type fails authenticate's oneOf body validation, so the spec's code + // for it is invalid_request — `unsupported_grant_type` appears only under /sso/token. + const badGrant = await post({ grant_type: 'urn:workos:oauth:grant-type:nonsense' }); + expect(badGrant.status).toBe(400); + expect(await json(badGrant)).toEqual({ + error: 'invalid_request', + error_description: 'The grant type is not supported: urn:workos:oauth:grant-type:nonsense', + }); + }); + + // A PKCE code's two adjacent failures used to answer in two envelopes: a wrong verifier + // OAuth-shaped, a missing one plain. Both are OAuth-shaped now, with the codes that name what + // went wrong — absent is malformed, wrong is a failed grant. + it('separates a missing code_verifier from a wrong one without changing envelope', async () => { + const user = await createUser('pkce-missing@test.com'); + getWorkOSStore(store).authCodes.insert({ + object: 'authorization_code', + code: 'pkce-needs-verifier', + 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-needs-verifier' }), + }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ error: 'invalid_request', error_description: 'code_verifier is required.' }); + }); + 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', { diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 03ce9f2..56a16e6 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -191,8 +191,13 @@ export function authRoutes(ctx: RouteContext): void { const clientId = body.client_id as string | undefined; const clientSecret = body.client_secret as string | undefined; + // Every malformed-request failure on this endpoint is OAuth-shaped, and not by inference: + // the spec's authenticate 400 lists `invalid_request` among its {error, error_description} + // variants and nowhere among its {code, message} ones. So the shape here is decided by the + // *failure*, not only by the grant — a grant whose credential failures are plain + // (`password`, Magic Auth) still reports a missing parameter OAuth-style. if (!grantType) { - throw new WorkOSApiError(400, 'grant_type is required', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'grant_type is required.'); } const requestIp = c.req.header('x-forwarded-for') ?? null; @@ -298,7 +303,7 @@ export function authRoutes(ctx: RouteContext): void { switch (grantType) { case 'authorization_code': { const code = body.code as string; - if (!code) throw new WorkOSApiError(400, 'code is required', 'invalid_request'); + if (!code) throw new OauthApiError(400, 'invalid_request', 'code is required.'); // Production does not distinguish unknown from expired codes: both fail OAuth-style // as invalid_grant with the same description. @@ -321,7 +326,7 @@ export function authRoutes(ctx: RouteContext): void { if (authCode.code_challenge) { const codeVerifier = body.code_verifier as string; if (!codeVerifier) { - throw new WorkOSApiError(400, 'code_verifier is required', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'code_verifier is required.'); } const method = authCode.code_challenge_method ?? 'S256'; let challenge: string; @@ -334,7 +339,8 @@ export function authRoutes(ctx: RouteContext): void { // A failed verifier is a failure of the authorization_code grant, so it fails the // same OAuth-style way an unknown code does (RFC 7636 §4.6). Leaving it plain put // the shape of a failure at odds with the reason for it, on the one path every - // PKCE client takes. + // PKCE client takes. The spec does enumerate `invalid_grant` for authenticate, as + // an {error, error_description} variant, so this is not inference from RFC alone. failAuth( 'OAuth', { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, @@ -371,15 +377,17 @@ export function authRoutes(ctx: RouteContext): void { const email = body.email as string; const password = body.password as string; if (!email || !password) { - throw new WorkOSApiError(400, 'email and password are required', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'email and password are required.'); } user = ws.users.findOneBy('email', email); if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { // Verified live: 400 (not 401) with the email interpolated. `password` is an RFC 6749 - // grant that nonetheless fails with the plain shape, which is why the rule here is an - // explicit allowlist — authorization_code, refresh_token, device_code — rather than - // "standard grants fail OAuth-style". + // grant that nonetheless fails with the plain shape, which is why the credential + // failures rendered OAuth-style are an explicit allowlist — authorization_code, + // refresh_token, device_code — rather than "standard grants fail OAuth-style". The + // malformed-request failure just above is a different question, and the spec answers + // it the other way for every grant. failAuth( 'Password', { email, userId: user?.id }, @@ -403,7 +411,7 @@ export function authRoutes(ctx: RouteContext): void { const code = body.code as string; const email = body.email as string; if (!code || !email) { - throw new WorkOSApiError(400, 'code and email are required', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'code and email are required.'); } const magicAuth = ws.magicAuths.all().find((ma) => ma.code === code && ma.email === email); @@ -430,7 +438,7 @@ export function authRoutes(ctx: RouteContext): void { const code = body.code as string; const userId = body.user_id as string; if (!code || !userId) { - throw new WorkOSApiError(400, 'code and user_id are required', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'code and user_id are required.'); } const ev = ws.emailVerifications.findBy('user_id', userId).find((v) => v.code === code); @@ -459,7 +467,7 @@ export function authRoutes(ctx: RouteContext): void { case 'refresh_token': { const token = body.refresh_token as string; if (!token) { - throw new WorkOSApiError(400, 'refresh_token is required', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'refresh_token is required.'); } const refreshToken = ws.refreshTokens.findOneBy('token', token); @@ -497,10 +505,10 @@ export function authRoutes(ctx: RouteContext): void { const challengeId = body.authentication_challenge_id as string; if (!code || !pendingToken || !challengeId) { - throw new WorkOSApiError( + throw new OauthApiError( 400, - 'code, pending_authentication_token, and authentication_challenge_id are required', 'invalid_request', + 'code, pending_authentication_token, and authentication_challenge_id are required.', ); } @@ -511,7 +519,7 @@ export function authRoutes(ctx: RouteContext): void { const challenge = ws.authChallenges.get(challengeId); if (!challenge) { - throw new WorkOSApiError(400, 'Invalid authentication challenge', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'Invalid authentication challenge.'); } if (isExpired(challenge.expires_at)) { ws.authChallenges.delete(challenge.id); @@ -556,10 +564,10 @@ export function authRoutes(ctx: RouteContext): void { const orgId = body.organization_id as string; if (!pendingToken || !orgId) { - throw new WorkOSApiError( + throw new OauthApiError( 400, - 'pending_authentication_token and organization_id are required', 'invalid_request', + 'pending_authentication_token and organization_id are required.', ); } @@ -602,7 +610,7 @@ export function authRoutes(ctx: RouteContext): void { case 'urn:ietf:params:oauth:grant-type:device_code': { const deviceCode = body.device_code as string; if (!deviceCode) { - throw new WorkOSApiError(400, 'device_code is required', 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', 'device_code is required.'); } // The spec renders every device-flow code as {error, error_description}, so the three @@ -638,8 +646,14 @@ export function authRoutes(ctx: RouteContext): void { break; } + // `unsupported_grant_type` appears exactly once in the spec, under /sso/token, and nowhere + // in authenticate's 400 — which does list `invalid_request`. The asymmetry reads as real + // rather than an omission: authenticate's body is a oneOf discriminated on grant_type, so + // an unrecognized one fails body validation rather than reaching a grant handler that could + // decline it. Keeping the code the spec gives us, and saying what it means in the + // description instead of naming a code the endpoint never returns. default: - throw new WorkOSApiError(400, `Unsupported grant_type: ${grantType}`, 'invalid_request'); + throw new OauthApiError(400, 'invalid_request', `The grant type is not supported: ${grantType}`); } if (!user) throw notFound('User'); From 0ae35560c09156897463805e24b1be7149dc410b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:49:51 -0400 Subject: [PATCH 12/12] docs(sso): name the profile-missing 500 as the exception it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1ab8ec2 asserted that /sso/token answers OAuth-shaped throughout, and the test beside it went further: "the endpoint has no plain-shaped response for a caller to have to parse." Twenty lines below the comment, a stored authorization pointing at a missing profile throws a plain 500. The code is right — that is emulator state gone wrong, not a request anyone can fix by sending something else, and RFC 6749 §5.2's code list covers client errors only, so there is nothing to render it as. The claim is what was wrong. Both now say every failure a caller can *cause*, and the branch itself explains why it is the one that isn't. --- src/workos/routes/sso.spec.ts | 5 +++-- src/workos/routes/sso.ts | 14 ++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 2a4a666..fa1a9bf 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -355,8 +355,9 @@ describe('SSO authentication events', () => { expect(ws.ssoAuthorizations.findOneBy('code', code)).toBeUndefined(); }); - // Every /sso/token failure is OAuth-shaped, including the two a client hits before it has a - // code to present — the endpoint has no plain-shaped response for a caller to have to parse. + // Every /sso/token failure a caller can cause is OAuth-shaped, including the two they hit + // before they have a code to present. The only plain body the endpoint can return is the + // profile-missing 500, which no request can provoke. it('rejects a wrong grant type and a missing code OAuth-style', async () => { const wrongGrant = await app.request('/sso/token', { method: 'POST', diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index a1825b3..4148394 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -139,10 +139,11 @@ export function ssoRoutes(ctx: RouteContext): void { const code = body.code as string; // The spec gives /sso/token only OAuth-shaped 400s — invalid_client, unauthorized_client, - // invalid_grant, unsupported_grant_type — so every failure below is rendered that way, - // including the missing-parameter cases the spec leaves out and RFC 6749 §5.2 names - // invalid_request. A plain envelope there would have made the failures a client hits before - // it has a code the ones it cannot parse like the rest. + // invalid_grant, unsupported_grant_type — so every failure a caller can cause below is + // rendered that way, including the missing-parameter cases the spec leaves out and RFC 6749 + // §5.2 names invalid_request. A plain envelope there would have made the failures a client + // hits before it has a code the ones it cannot parse like the rest. What a caller cannot + // cause is the profile-missing 500 further down, which stays plain and says why there. // // Absent and wrong are different failures: an omitted grant_type is a malformed request, // not a request for a grant this endpoint declines to support, and reporting it as @@ -198,6 +199,11 @@ export function ssoRoutes(ctx: RouteContext): void { } const profile = ws.ssoProfiles.get(auth.profile_id); + // The deliberate exception to the OAuth-shaped rule above, and the reason it says every + // *failure a caller can cause*: a stored authorization pointing at a profile that no longer + // exists is emulator state gone wrong, not a request anyone can fix by sending something + // else. RFC 6749 §5.2's code list covers client errors only — it has no entry to render this + // as — so it stays plain, like every other 500 the emulator returns. if (!profile) { throw new WorkOSApiError(500, 'Profile not found', 'server_error'); }