diff --git a/README.md b/README.md index 2d84a00..684f24b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: 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/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 43d39d4..b7fdb9a 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 () => { @@ -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 () => { @@ -374,7 +379,324 @@ 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.`, + }); + }); + + // 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.` }, + }); + }); + + // 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', { + 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 --- @@ -1649,7 +1971,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(); @@ -1657,7 +1979,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'." }, }); }); @@ -1693,7 +2015,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..56a16e6 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'; @@ -190,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; @@ -297,22 +303,30 @@ 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. 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.`), ); } 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; @@ -322,15 +336,34 @@ 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. 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 }, - new WorkOSApiError(400, 'Invalid code_verifier', 'invalid_code_verifier'), + new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), ); } } 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. @@ -344,15 +377,21 @@ 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 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 }, - new WorkOSApiError(401, 'Invalid credentials', 'invalid_credentials'), + new WorkOSApiError(400, `Invalid credentials for '${email}'.`, 'invalid_credentials'), ); } authMethod = 'Password'; @@ -372,18 +411,18 @@ 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); 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'), ); } @@ -399,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); @@ -428,19 +467,24 @@ 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); 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); + // 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; @@ -461,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.', ); } @@ -475,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); @@ -520,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.', ); } @@ -566,29 +610,50 @@ 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 + // 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 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); + // 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; } + // `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'); @@ -694,7 +759,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)!; 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 7746e03..fa1a9bf 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -298,14 +298,96 @@ 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 }, }); }); + + // 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 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', + 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.' }); + }); + + // 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 f1b6e43..4148394 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'; @@ -135,19 +135,32 @@ 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 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 + // "not supported: undefined" describes neither. + if (!grantType) { + throw new OauthApiError(400, 'invalid_request', 'grant_type is required.'); + } 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'); + throw new OauthApiError(400, 'invalid_request', 'code is required.'); } 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 +179,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', @@ -186,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'); }