diff --git a/README.md b/README.md index 2d84a00..e4e1a8b 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."}`. WorkOS documents that refresh tokens _may_ be rotated after use, so production is free to hand back the same token and leave it valid. The emulator always takes the stricter path: a client that forgets to store the newly returned `refresh_token` fails locally instead of in production. + +### Authentication failure shapes + +`POST /user_management/authenticate` does not use one error shape for every failure. Three grants fail OAuth-style; everything else keeps the plain shape: + +| Failure | Body | Node SDK raises | +| --------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------- | +| `authorization_code` — unknown, expired, or bad `code_verifier` | `{"error": "invalid_grant", "error_description": "…"}` | `OauthException` | +| `refresh_token` — unknown, expired, rotated, or user deleted | `{"error": "invalid_grant", "error_description": "…"}` | `OauthException` | +| Device code — pending, expired, unknown | `{"error": "authorization_pending\|expired_token\|invalid_grant", …}` | `OauthException` | +| `password` — wrong password | `{"code": "invalid_credentials", "message": "…"}` (400) | `GenericServerException` | +| Magic Auth — wrong or expired code | `{"code": "invalid_one_time_code\|one_time_code_expired", …}` | `GenericServerException` | +| Step-up (MFA, org selection, email verification) | `{"code": "…", "message": "…"}` (403) | `AuthenticationException` | + +`password` is an RFC 6749 grant, but production fails it with the plain shape, so the emulator does too. `/sso/token` is OAuth-shaped throughout, matching its spec definition. ### Emitted events @@ -683,6 +698,12 @@ is stable for a pinned key without being pinned separately. Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.). +`@workos/emulate/core` exports the two error classes the emulator itself throws, for hooks that need to +raise a failure rather than describe one: `WorkOSApiError(status, message, code)` renders the plain +`{code, message}` envelope, and `OauthApiError(status, error, description)` the RFC 6749 +`{error, error_description}` one used by `/sso/token`, `/oauth2/token` and the OAuth-shaped +`authenticate` grants (see [Authentication failure shapes](#authentication-failure-shapes)). + ### Seed config Add `errorHooks` to your config file: 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..d02cecc 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,219 @@ describe('Auth routes', () => { }); expect(res.status).toBe(400); const body = await json(res); - expect(body.code).toBe('invalid_grant'); + expect(body).toEqual({ error: 'invalid_grant', error_description: 'Invalid refresh token.' }); + }); + + it('fails an expired refresh token OAuth-style, with its own description', async () => { + await createUser('staletoken@test.com'); + const auth = await json(await signInWithMagicAuth('staletoken@test.com')); + + const ws = getWorkOSStore(store); + const stored = ws.refreshTokens.findOneBy('token', auth.refresh_token)!; + ws.refreshTokens.update(stored.id, { expires_at: new Date(Date.now() - 60_000).toISOString() }); + + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: auth.refresh_token }), + }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ error: 'invalid_grant', error_description: 'Refresh token has expired.' }); + }); + + it('fails refresh OAuth-style when the user behind the token was deleted', async () => { + await createUser('deleted@test.com'); + const auth = await json(await signInWithMagicAuth('deleted@test.com')); + getWorkOSStore(store).users.delete(auth.user.id); + + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: auth.refresh_token }), + }); + expect(res.status).toBe(400); + const body = await json(res); + expect(body).toEqual({ error: 'invalid_grant', error_description: 'Invalid refresh token.' }); + }); + + it('fails an unknown authorization code OAuth-style', async () => { + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code: 'bogus' }), + }); + expect(res.status).toBe(400); + const body = await json(res); + expect(body).toEqual({ + error: 'invalid_grant', + error_description: "The code 'bogus' has expired or is invalid.", + }); + }); + + // Production does not distinguish unknown from expired here, so a code that was real and aged + // out has to be indistinguishable from one that never existed — same OAuth shape, same code, + // same description. A client that can tell them apart locally is reading a difference that + // production will not give it. + it('fails an expired authorization code exactly like an unknown one', async () => { + await createUser('stalecode@test.com'); + const authRes = await app.request( + '/user_management/authorize?redirect_uri=http://localhost:3000/callback&response_type=code', + ); + const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; + + const ws = getWorkOSStore(store); + const stored = ws.authCodes.findOneBy('code', code)!; + ws.authCodes.update(stored.id, { expires_at: new Date(Date.now() - 60_000).toISOString() }); + + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code }), + }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ + error: 'invalid_grant', + error_description: `The code '${code}' has expired or is invalid.`, + }); + }); + + it('fails a wrong magic auth code with the plain shape and production code string', async () => { + await createUser('wrongcode@test.com'); + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', + email: 'wrongcode@test.com', + code: '000000', + }), + }); + expect(res.status).toBe(400); + const body = await json(res); + expect(body).toEqual({ code: 'invalid_one_time_code', message: 'Invalid one-time code' }); + }); + + it('fails a PKCE verifier mismatch OAuth-style, like any other bad authorization code', async () => { + const user = await createUser('pkce@test.com'); + getWorkOSStore(store).authCodes.insert({ + object: 'authorization_code', + code: 'pkce-code', + user_id: user.id, + organization_id: null, + client_id: null, + code_challenge: 'a-challenge-no-verifier-will-hash-to', + code_challenge_method: 'S256', + expires_at: new Date(Date.now() + 600_000).toISOString(), + } as never); + + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code: 'pkce-code', code_verifier: 'wrong' }), + }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ + error: 'invalid_grant', + error_description: "The code 'pkce-code' has expired or is invalid.", + }); + }); + + it('fails device-code polling OAuth-style at every stage', async () => { + const start = await req('/user_management/authorize/device', { + method: 'POST', + body: JSON.stringify({ client_id: 'client_device' }), + }); + const { device_code } = await json(start); + + // Nobody has approved it yet. + const pending = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code }), + }); + expect(pending.status).toBe(400); + expect(await json(pending)).toEqual({ + error: 'authorization_pending', + error_description: 'The authorization request is still pending.', + }); + + const unknown = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: 'nope' }), + }); + expect(unknown.status).toBe(400); + expect(await json(unknown)).toEqual({ error: 'invalid_grant', error_description: 'Invalid device code.' }); + + // The third stage: the user walked away and the code aged out. Its own OAuth code, since a + // polling client stops on expired_token where it would keep polling on authorization_pending. + const ws = getWorkOSStore(store); + const stored = ws.deviceAuthorizations.findOneBy('device_code', device_code)!; + ws.deviceAuthorizations.update(stored.id, { expires_at: new Date(Date.now() - 60_000).toISOString() }); + + const expired = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code }), + }); + expect(expired.status).toBe(400); + expect(await json(expired)).toEqual({ + error: 'expired_token', + error_description: 'The device code has expired.', + }); + }); + + // The same hole the refresh_token grant had: an approved code whose user is gone fell through + // to the shared lookup and answered a polling client with the one plain 404 this endpoint never + // otherwise returns — a body it has no reason to be able to parse. + it('fails an approved device code OAuth-style when its user was deleted', async () => { + const user = await createUser('devicegone@test.com'); + const start = await req('/user_management/authorize/device', { + method: 'POST', + body: JSON.stringify({ client_id: 'client_device' }), + }); + const { device_code } = await json(start); + + const ws = getWorkOSStore(store); + const stored = ws.deviceAuthorizations.findOneBy('device_code', device_code)!; + ws.deviceAuthorizations.update(stored.id, { user_id: user.id }); + ws.users.delete(user.id); + + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code }), + }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ error: 'invalid_grant', error_description: 'Invalid device code.' }); + // Nothing consumed on a failure the caller cannot fix by polling again. + expect(ws.deviceAuthorizations.findOneBy('device_code', device_code)).toBeDefined(); + }); + + it('fails an expired magic auth code with the production code string', async () => { + const user = await createUser('expired@test.com'); + getWorkOSStore(store).magicAuths.insert({ + object: 'magic_auth', + user_id: user.id, + email: user.email, + code: '123456', + expires_at: new Date(Date.now() - 60_000).toISOString(), + }); + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', + email: 'expired@test.com', + code: '123456', + }), + }); + expect(res.status).toBe(400); + const body = await json(res); + expect(body).toEqual({ + code: 'one_time_code_expired', + message: "One-time code for 'expired@test.com' has expired.", + }); }); // --- Impersonation tests --- @@ -1649,7 +1866,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 +1874,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 +1910,7 @@ describe('authentication events (spec-named, spec-shaped)', () => { expect(event.data).toMatchObject({ type: 'oauth', status: 'failed', - error: { code: 'invalid_code', message: 'Invalid code' }, + error: { code: 'invalid_grant', message: "The code 'bogus' has expired or is invalid." }, }); }); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 985f6e4..8feead5 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.`), ); } @@ -322,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.`), ); } } @@ -349,10 +362,14 @@ 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 allowlist — authorization_code, refresh_token, device_code — 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'; @@ -377,13 +394,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,14 +450,19 @@ 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); + // 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; @@ -569,19 +591,34 @@ export function authRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'device_code is required', 'invalid_request'); } + // 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; @@ -694,7 +731,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..2a4a666 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -298,14 +298,95 @@ 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 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.' }); + }); + + // 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..a1825b3 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,31 @@ 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 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 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 +178,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',