diff --git a/bun.lock b/bun.lock index 07ace0c..06e5912 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@workos/emulate", "dependencies": { - "@hono/node-server": "^1", + "@hono/node-server": "^2.1.1", "chalk": "^5.6.2", "hono": "^4", "semver": "^7.7.4", @@ -24,7 +24,7 @@ }, }, "packages": { - "@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="], + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw=="], diff --git a/package.json b/package.json index 3f53360..296ab47 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ } }, "dependencies": { - "@hono/node-server": "^1", + "@hono/node-server": "^2.1.1", "chalk": "^5.6.2", "hono": "^4", "semver": "^7.7.4", diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index 588e085..c8404cf 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -57,6 +57,11 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [ { objectType: 'api_key', schemaName: 'ApiKey' }, { objectType: 'password_reset', schemaName: 'PasswordReset' }, { objectType: 'feature_flag', schemaName: 'Flag' }, + // The secretless factor GET and LIST return. `AuthenticationFactorEnrolled` shares its + // discriminator and top-level fields; the two differ only inside `totp`, below this loop's + // depth — enrollment's secrets are pinned by the route tests instead. + { objectType: 'authentication_factor', schemaName: 'AuthenticationFactor' }, + { objectType: 'authentication_challenge', schemaName: 'AuthenticationChallenge' }, ]; export interface EnvelopeMapEntry { @@ -111,6 +116,25 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [ schemaName: 'AuthorizationCheck', }, { method: 'GET', path: '/sso/jwks/{clientId}', status: '200', schemaName: 'JwksResponse' }, + // MFA. Enrollment is the envelope that went out bare for several releases (issue #110): every + // SDK reads `{ authentication_factor, authentication_challenge }`, and none could enroll a + // factor through the emulator. The legacy `/auth` routes are resource bodies, listed for the + // same reason as the password reset above: the route is the surface the SDKs read. + { + method: 'POST', + path: '/user_management/users/{userlandUserId}/auth_factors', + status: '201', + schemaName: 'UserlandUserAuthenticationFactorEnrollResponse', + }, + { method: 'POST', path: '/auth/factors/enroll', status: '201', schemaName: 'AuthenticationFactorEnrolled' }, + { method: 'GET', path: '/auth/factors/{id}', status: '200', schemaName: 'AuthenticationFactor' }, + { method: 'POST', path: '/auth/factors/{id}/challenge', status: '201', schemaName: 'AuthenticationChallenge' }, + { + method: 'POST', + path: '/auth/challenges/{id}/verify', + status: '201', + schemaName: 'AuthenticationChallengeVerifyResponse', + }, // Paginated list envelopes. Several, not one, because each is wrapped by a different // route — a route that forgets `list_metadata` is invisible if only its neighbour is checked. { method: 'GET', path: '/organizations', status: '200', schemaName: 'OrganizationList' }, @@ -118,6 +142,12 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [ { method: 'GET', path: '/connect/applications', status: '200', schemaName: 'ConnectApplicationList' }, { method: 'GET', path: '/webhook_endpoints', status: '200', schemaName: 'WebhookEndpointList' }, { method: 'GET', path: '/events', status: '200', schemaName: 'EventList' }, + { + method: 'GET', + path: '/user_management/users/{userlandUserId}/auth_factors', + status: '200', + schemaName: 'UserlandUserAuthenticationFactorList', + }, { method: 'GET', path: '/organizations/{organizationId}/api_keys', diff --git a/src/e2e.spec.ts b/src/e2e.spec.ts index 1de47c3..fdb4e81 100644 --- a/src/e2e.spec.ts +++ b/src/e2e.spec.ts @@ -340,7 +340,7 @@ describe('end-to-end login flow (workos.com/docs story)', () => { }), }); expect(factorRes.status).toBe(201); - const factor = (await factorRes.json()) as any; + const { authentication_factor: factor } = (await factorRes.json()) as any; // Step 2: Authenticate with password - should trigger MFA challenge const passwordRes = await fetch(`${emulator.url}/user_management/authenticate`, { @@ -363,7 +363,7 @@ describe('end-to-end login flow (workos.com/docs story)', () => { expect(authWebhooks.length).toBe(0); // Cleanup: Remove the MFA factor for other tests - await api(`/user_management/auth_factors/${factor.id}`, { + await api(`/auth/factors/${factor.id}`, { method: 'DELETE', }); }); @@ -379,7 +379,7 @@ describe('end-to-end login flow (workos.com/docs story)', () => { }), }); expect(factorRes.status).toBe(201); - const factor = (await factorRes.json()) as any; + const { authentication_factor: factor } = (await factorRes.json()) as any; // Step 2: Authenticate with password to trigger MFA challenge const passwordRes = await fetch(`${emulator.url}/user_management/authenticate`, { @@ -439,7 +439,7 @@ describe('end-to-end login flow (workos.com/docs story)', () => { expectSpecShape(authWebhook); // Cleanup: Remove the MFA factor for other tests - await api(`/user_management/auth_factors/${factor.id}`, { + await api(`/auth/factors/${factor.id}`, { method: 'DELETE', }); }); diff --git a/src/workos/entities.ts b/src/workos/entities.ts index ce78249..d121cfa 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -133,6 +133,8 @@ export interface WorkOSAuthenticationFactor extends Entity { totp: { issuer: string; user: string; + /** Base32, as authenticator apps take it. Enrollment is the only response that shows it. */ + secret: string; uri: string; }; } diff --git a/src/workos/generated/response-shapes.ts b/src/workos/generated/response-shapes.ts index 015dfeb..2a5c747 100644 --- a/src/workos/generated/response-shapes.ts +++ b/src/workos/generated/response-shapes.ts @@ -50,6 +50,16 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record = { + 'GET /auth/factors/{id}': { + schema: 'AuthenticationFactor', + properties: ['created_at', 'id', 'object', 'sms', 'totp', 'type', 'updated_at', 'user_id'], + required: ['created_at', 'id', 'object', 'type', 'updated_at'], + }, 'GET /connect/applications': { schema: 'ConnectApplicationList', properties: ['data', 'list_metadata', 'object'], @@ -365,6 +380,11 @@ export const RESPONSE_ENVELOPE_REQUIREMENTS: Record { return formatEntity(ma); } +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +/** Production validates a caller-supplied `totp_secret` against exactly this. */ +export const BASE32_SECRET = /^[A-Z2-7]+=*$/; + +/** + * The TOTP details an enrollment stores. Without a caller-supplied secret it mints 32 Base32 + * characters — the 160 bits RFC 4226 recommends — so authenticator apps and TOTP libraries + * accept the secret verbatim; `uri` is the otpauth form those apps import. + */ +export function newTotp(issuer: string, user: string, secret?: string): WorkOSAuthenticationFactor['totp'] { + secret ??= Array.from(randomBytes(32), (b) => BASE32_ALPHABET[b & 31]).join(''); + const issuerParam = encodeURIComponent(issuer); + return { + issuer, + user, + secret, + uri: `otpauth://totp/${issuerParam}:${encodeURIComponent(user)}?secret=${secret}&issuer=${issuerParam}`, + }; +} + +// ponytail: a valid 1×1 PNG, not a scannable code — the spec requires the field and SDKs require a +// string, and the emulator never checks a real TOTP code. Add a QR encoder if a consumer's UI +// test needs to scan it; `uri` already carries everything the code would. +const TOTP_QR_CODE = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +/** The spec's AuthenticationFactor: what GET and LIST return, secrets stripped. */ export function formatAuthFactor(f: WorkOSAuthenticationFactor): Record { - return formatEntity(f); + return { ...formatEntity(f), totp: { issuer: f.totp.issuer, user: f.totp.user } }; +} + +/** The spec's AuthenticationFactorEnrolled: the one response that shows the secrets. */ +export function formatAuthFactorEnrolled(f: WorkOSAuthenticationFactor): Record { + return { ...formatEntity(f), totp: { ...f.totp, qr_code: TOTP_QR_CODE } }; } /** @@ -905,10 +937,13 @@ export function assertAllowedRedirectUri(uri: string, store: Store): void { ); } -const AUTH_CHALLENGE_EXCLUDE = new Set([...INTERNAL_FIELDS, 'code']); +// `code` stays server-side as production keeps a TOTP challenge's; `user_id` and `factor_id` +// are the store's join columns — the spec's challenge names its factor `authentication_factor_id` +// and carries no user at all. +const AUTH_CHALLENGE_EXCLUDE = new Set([...INTERNAL_FIELDS, 'code', 'user_id', 'factor_id']); export function formatAuthChallenge(c: WorkOSAuthenticationChallenge): Record { - return formatEntity(c, { exclude: AUTH_CHALLENGE_EXCLUDE }); + return { ...formatEntity(c, { exclude: AUTH_CHALLENGE_EXCLUDE }), authentication_factor_id: c.factor_id }; } export function formatRole(role: WorkOSRole, ws: WorkOSStore): Record { diff --git a/src/workos/index.ts b/src/workos/index.ts index 60004c8..427ae73 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -18,7 +18,6 @@ import { connectionRoutes } from './routes/connections.js'; import { ssoRoutes } from './routes/sso.js'; import { pipeRoutes } from './routes/pipes.js'; import { connectedAccountRoutes } from './routes/connected-accounts.js'; -import { authChallengeRoutes } from './routes/auth-challenges.js'; import { invitationRoutes } from './routes/invitations.js'; import { configRoutes } from './routes/config.js'; import { userFeatureRoutes } from './routes/user-features.js'; @@ -79,6 +78,7 @@ import { formatConnectedAccountEvent, dataIntegrationIdFor, linkOAuthIdentity, + newTotp, } from './helpers.js'; import type { WorkOSConnectionType, @@ -466,20 +466,13 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee } // The same record the enrollment route writes, so ListAuthFactors reports it and the - // password grant challenges it like any enrolled second factor. The secret surfaces only - // inside the URI, as enrollment leaves it. + // password grant challenges it like any enrolled second factor. if (userConfig.totp) { - const issuer = 'WorkOS Emulator'; - const secret = randomBytes(20).toString('hex').slice(0, 32).toUpperCase(); ws.authFactors.insert({ object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { - issuer, - user: user.email, - uri: `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user.email)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`, - }, + totp: newTotp('WorkOS Emulator', user.email), }); } } @@ -922,7 +915,6 @@ export const workosPlugin: ServicePlugin = { passwordResetRoutes(ctx); magicAuthRoutes(ctx); authFactorRoutes(ctx); - authChallengeRoutes(ctx); sessionRoutes(ctx); authRoutes(ctx); connectionRoutes(ctx); diff --git a/src/workos/response-envelopes.spec.ts b/src/workos/response-envelopes.spec.ts index 2d9d9c6..f9f7763 100644 --- a/src/workos/response-envelopes.spec.ts +++ b/src/workos/response-envelopes.spec.ts @@ -23,8 +23,9 @@ * the coverage test, so a catalog entry can't sit unexercised. * * Scope: response *bodies*, not status codes. Some routes return 200 where the - * spec says 201 (`/portal/generate_link`, `/widgets/token`); status conformance - * is a separate axis and this loop only requires a 2xx. + * spec says 201 (`/portal/generate_link`, `/widgets/token`, + * `/auth/challenges/{id}/verify`); status conformance is a separate axis and + * this loop only requires a 2xx. */ import { describe, it, expect, beforeAll } from 'bun:test'; import { createServer, type ApiKeyMap } from '../core/index.js'; @@ -49,6 +50,8 @@ interface Fixtures { clientId: string; passwordResetToken: string; passwordResetId: string; + factorId: string; + challengeId: string; } /** Each case names a catalog operation and returns that operation's live response body. */ @@ -108,6 +111,24 @@ const CASES: readonly EnvelopeCase[] = [ operation: 'GET /sso/jwks/{clientId}', request: (app, f) => get(`/sso/jwks/${f.clientId}`)(app), }, + { + operation: 'POST /user_management/users/{userlandUserId}/auth_factors', + request: (app, f) => post(`/user_management/users/${f.userId}/auth_factors`, { type: 'totp' })(app), + }, + { + operation: 'GET /user_management/users/{userlandUserId}/auth_factors', + request: (app, f) => get(`/user_management/users/${f.userId}/auth_factors`)(app), + }, + { operation: 'POST /auth/factors/enroll', request: post('/auth/factors/enroll', { type: 'totp' }) }, + { operation: 'GET /auth/factors/{id}', request: (app, f) => get(`/auth/factors/${f.factorId}`)(app) }, + { + operation: 'POST /auth/factors/{id}/challenge', + request: (app, f) => post(`/auth/factors/${f.factorId}/challenge`)(app), + }, + { + operation: 'POST /auth/challenges/{id}/verify', + request: (app, f) => post(`/auth/challenges/${f.challengeId}/verify`, { code: '123456' })(app), + }, { operation: 'GET /organizations', request: get('/organizations') }, { operation: 'GET /user_management/users', request: get('/user_management/users') }, { operation: 'GET /connect/applications', request: get('/connect/applications') }, @@ -164,7 +185,9 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => { const server = createServer(workosPlugin, { port: 0, baseUrl: BASE_URL, apiKeys }); seedFromConfig(server.store, BASE_URL, { organizations: [{ name: 'Acme Corp' }], - users: [{ email: 'alice@acme.com', password: 'secret123' }], + // A seeded factor, so the factor list is a non-empty page whatever order the cases run in, + // and the legacy `/auth/factors/{id}` routes have a factor to read and challenge. + users: [{ email: 'alice@acme.com', password: 'secret123', totp: true }], permissions: [{ slug: 'posts:read', name: 'Read Posts' }], roles: [{ slug: 'member', name: 'Member', permissions: ['posts:read'] }], // Subscribed to an event this test never triggers, not the catch-all `[]`. Webhook @@ -207,6 +230,17 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => { const passwordResetToken = insertPasswordReset('pw_reset_envelope').password_reset_token; const passwordResetId = insertPasswordReset('pw_reset_envelope_get').id; + // Verify spends the challenge it is handed, so it gets one of its own with a known code + // rather than the one the challenge case creates. + const factorId = ws.authFactors.findOneBy('user_id', userId)!.id; + const challengeId = ws.authChallenges.insert({ + object: 'authentication_challenge', + user_id: userId, + factor_id: factorId, + expires_at: new Date(Date.now() + 600_000).toISOString(), + code: '123456', + }).id; + const fixtures: Fixtures = { organizationId, userId, @@ -214,6 +248,8 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => { clientId: 'client_billing', passwordResetToken, passwordResetId, + factorId, + challengeId, }; for (const { operation, request } of CASES) { diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index f8b1ce9..6926d84 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -30,6 +30,8 @@ import { formatApiKeyRecord, formatPasswordReset, formatFeatureFlag, + formatAuthFactor, + formatAuthChallenge, } from './helpers.js'; import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js'; import type { @@ -44,6 +46,8 @@ import type { WorkOSApiKey, WorkOSPasswordReset, WorkOSFeatureFlag, + WorkOSAuthenticationFactor, + WorkOSAuthenticationChallenge, } from './entities.js'; const TS = '2026-01-01T00:00:00.000Z'; @@ -204,6 +208,33 @@ const featureFlag: WorkOSFeatureFlag = { updated_at: TS, }; +const authFactor: WorkOSAuthenticationFactor = { + id: 'auth_factor_01', + object: 'authentication_factor', + user_id: 'user_01', + type: 'totp', + totp: { + issuer: 'Acme', + user: 'alice@example.com', + secret: 'JBSWY3DPEHPK3PXP', + uri: 'otpauth://totp/Acme:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme', + }, + created_at: TS, + updated_at: TS, +}; + +// The store's join columns and the code, all of which the wire challenge must not carry. +const authChallenge: WorkOSAuthenticationChallenge = { + id: 'auth_challenge_01', + object: 'authentication_challenge', + user_id: 'user_01', + factor_id: 'auth_factor_01', + expires_at: TS, + code: '123456', + created_at: TS, + updated_at: TS, +}; + const store = new Store(); const ws = getWorkOSStore(store); @@ -219,6 +250,8 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record { objectType: 'api_key', output: formatApiKeyRecord(apiKey) }, { objectType: 'password_reset', output: formatPasswordReset(passwordReset) }, { objectType: 'feature_flag', output: formatFeatureFlag(featureFlag) }, + { objectType: 'authentication_factor', output: formatAuthFactor(authFactor) }, + { objectType: 'authentication_challenge', output: formatAuthChallenge(authChallenge) }, ]; /** diff --git a/src/workos/routes/auth-challenges.spec.ts b/src/workos/routes/auth-challenges.spec.ts index ad98a81..49d1760 100644 --- a/src/workos/routes/auth-challenges.spec.ts +++ b/src/workos/routes/auth-challenges.spec.ts @@ -45,7 +45,7 @@ describe('Auth challenge routes', () => { object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://totp/test' }, + totp: { issuer: 'Test', user: user.email, secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://totp/test' }, }); return { user, factor }; } @@ -53,14 +53,18 @@ describe('Auth challenge routes', () => { it('creates a challenge for a factor', async () => { const { factor } = seedUserWithFactor(); - const res = await req(`/user_management/auth_factors/${factor.id}/challenges`, { + const res = await req(`/auth/factors/${factor.id}/challenge`, { method: 'POST', body: JSON.stringify({}), }); expect(res.status).toBe(201); const body = await json(res); expect(body.object).toBe('authentication_challenge'); - expect(body.factor_id).toBe(factor.id); + expect(body.authentication_factor_id).toBe(factor.id); + // The store's join columns are not part of the spec's challenge. + expect(body).not.toHaveProperty('factor_id'); + expect(body).not.toHaveProperty('user_id'); + expect(body).not.toHaveProperty('code'); }); it('verifies a challenge with correct code', async () => { @@ -76,7 +80,7 @@ describe('Auth challenge routes', () => { code: '999999', }); - const res = await req(`/user_management/auth_challenges/${challenge.id}/verify`, { + const res = await req(`/auth/challenges/${challenge.id}/verify`, { method: 'POST', body: JSON.stringify({ code: '999999' }), }); @@ -97,7 +101,7 @@ describe('Auth challenge routes', () => { code: '111111', }); - const res = await req(`/user_management/auth_challenges/${challenge.id}/verify`, { + const res = await req(`/auth/challenges/${challenge.id}/verify`, { method: 'POST', body: JSON.stringify({ code: '000000' }), }); @@ -118,7 +122,7 @@ describe('Auth challenge routes', () => { code: '123456', }); - const res = await req(`/user_management/auth_challenges/${challenge.id}/verify`, { + const res = await req(`/auth/challenges/${challenge.id}/verify`, { method: 'POST', body: JSON.stringify({ code: '123456' }), }); @@ -128,7 +132,7 @@ describe('Auth challenge routes', () => { }); it('returns 404 for nonexistent factor', async () => { - const res = await req('/user_management/auth_factors/auth_factor_bogus/challenges', { + const res = await req('/auth/factors/auth_factor_bogus/challenge', { method: 'POST', body: JSON.stringify({}), }); diff --git a/src/workos/routes/auth-challenges.ts b/src/workos/routes/auth-challenges.ts deleted file mode 100644 index 970e8cf..0000000 --- a/src/workos/routes/auth-challenges.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatAuthChallenge, expiresIn, isExpired, generateCode } from '../helpers.js'; - -export function authChallengeRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/user_management/auth_factors/:id/challenges', async (c) => { - const factorId = c.req.param('id'); - const factor = ws.authFactors.get(factorId); - if (!factor) throw notFound('AuthenticationFactor'); - - const user = ws.users.get(factor.user_id); - if (!user) throw notFound('User'); - - // Emulator generates a code and stores it for verification - const code = generateCode(); - - const challenge = ws.authChallenges.insert({ - object: 'authentication_challenge', - user_id: user.id, - factor_id: factor.id, - expires_at: expiresIn(10), - code, - }); - - return c.json(formatAuthChallenge(challenge), 201); - }); - - app.post('/user_management/auth_challenges/:id/verify', async (c) => { - const challengeId = c.req.param('id'); - const challenge = ws.authChallenges.get(challengeId); - if (!challenge) throw notFound('AuthenticationChallenge'); - - if (isExpired(challenge.expires_at)) { - ws.authChallenges.delete(challenge.id); - throw new WorkOSApiError(400, 'Challenge has expired', 'expired_challenge'); - } - - const body = await parseJsonBody(c); - const code = body.code as string; - if (!code) { - throw new WorkOSApiError(400, 'code is required', 'invalid_request'); - } - - // In the emulator, accept the stored code or any 6-digit code for convenience - if (challenge.code && code !== challenge.code) { - throw new WorkOSApiError(400, 'Invalid one-time code', 'invalid_one_time_code'); - } - - ws.authChallenges.delete(challenge.id); - - return c.json({ - challenge: formatAuthChallenge(challenge), - valid: true, - }); - }); -} diff --git a/src/workos/routes/auth-factors.spec.ts b/src/workos/routes/auth-factors.spec.ts new file mode 100644 index 0000000..d4ecb0c --- /dev/null +++ b/src/workos/routes/auth-factors.spec.ts @@ -0,0 +1,92 @@ +/** + * The wire contract every backend SDK derives from the spec (workos/emulate#110): enrollment + * answers `{ authentication_factor, authentication_challenge }` with the factor's TOTP secrets, + * and only enrollment does — GET and LIST strip them. + */ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { createServer, type ApiKeyMap } from '../../core/index.js'; +import { workosPlugin } from '../index.js'; + +const apiKeys: ApiKeyMap = { sk_test_mfa: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_mfa', 'Content-Type': 'application/json' }; + +function createTestApp() { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); +} + +describe('Auth factor routes', () => { + let app: ReturnType['app']; + let userId: string; + + beforeEach(async () => { + app = createTestApp().app; + const res = await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email: 'mfa@test.com', password: 'a strong enough passphrase', email_verified: true }), + }); + userId = (await json(res)).id; + }); + + const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); + const json = (res: Response) => res.json() as Promise; + const enroll = (body: Record = { type: 'totp' }) => + req(`/user_management/users/${userId}/auth_factors`, { method: 'POST', body: JSON.stringify(body) }); + + it('enrolls inside the spec envelope, with the secrets and the enrollment challenge', async () => { + const res = await enroll({ type: 'totp', totp_issuer: 'Acme', totp_user: 'alice' }); + expect(res.status).toBe(201); + const { authentication_factor: factor, authentication_challenge: challenge, ...rest } = await json(res); + expect(rest).toEqual({}); + + expect(factor.object).toBe('authentication_factor'); + expect(factor.type).toBe('totp'); + expect(factor.user_id).toBe(userId); + expect(factor.totp.issuer).toBe('Acme'); + expect(factor.totp.user).toBe('alice'); + // 32 Base32 characters: what authenticator apps and TOTP libraries accept verbatim. + expect(factor.totp.secret).toMatch(/^[A-Z2-7]{32}$/); + expect(factor.totp.uri).toBe(`otpauth://totp/Acme:alice?secret=${factor.totp.secret}&issuer=Acme`); + expect(factor.totp.qr_code).toStartWith('data:image/png;base64,'); + + expect(challenge.object).toBe('authentication_challenge'); + expect(challenge.authentication_factor_id).toBe(factor.id); + expect(challenge.expires_at).toBeTruthy(); + expect(challenge).not.toHaveProperty('code'); + }); + + it('honors a caller-supplied Base32 secret and rejects one that is not', async () => { + const ok = await enroll({ type: 'totp', totp_secret: 'JBSWY3DPEHPK3PXP' }); + expect(ok.status).toBe(201); + const { authentication_factor: factor } = await json(ok); + expect(factor.totp.secret).toBe('JBSWY3DPEHPK3PXP'); + expect(factor.totp.uri).toContain('secret=JBSWY3DPEHPK3PXP'); + + for (const totp_secret of ['not base32!', 234567]) { + const bad = await enroll({ type: 'totp', totp_secret }); + expect(bad.status, `totp_secret ${JSON.stringify(totp_secret)}`).toBe(422); + expect((await json(bad)).code).toBe('invalid_totp_secret'); + } + }); + + it('defaults the TOTP account name to the user email', async () => { + const { authentication_factor: factor } = await json(await enroll()); + expect(factor.totp.user).toBe('mfa@test.com'); + }); + + it('lists factors without their secrets', async () => { + await enroll(); + const res = await req(`/user_management/users/${userId}/auth_factors`); + expect(res.status).toBe(200); + const body = await json(res); + expect(body.data).toHaveLength(1); + expect(body.data[0].totp).toEqual({ issuer: 'WorkOS Emulator', user: 'mfa@test.com' }); + }); + + it('answers 404 for an unknown user', async () => { + const res = await req('/user_management/users/user_nope/auth_factors', { + method: 'POST', + body: JSON.stringify({ type: 'totp' }), + }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/workos/routes/auth-factors.ts b/src/workos/routes/auth-factors.ts index ec9e9f1..25aa4aa 100644 --- a/src/workos/routes/auth-factors.ts +++ b/src/workos/routes/auth-factors.ts @@ -1,7 +1,14 @@ -import { type RouteContext, notFound, parseJsonBody } from '../../core/index.js'; +import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatAuthFactor } from '../helpers.js'; -import { randomBytes } from 'node:crypto'; +import { + BASE32_SECRET, + expiresIn, + formatAuthChallenge, + formatAuthFactor, + formatAuthFactorEnrolled, + generateCode, + newTotp, +} from '../helpers.js'; export function authFactorRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -14,22 +21,40 @@ export function authFactorRoutes(ctx: RouteContext): void { const body = await parseJsonBody(c); const type = (body.type as string) ?? 'totp'; - const issuer = (body.totp_issuer as string) ?? 'WorkOS Emulator'; - const secret = randomBytes(20).toString('hex').slice(0, 32).toUpperCase(); - const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user.email)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; + const secret = body.totp_secret; + // A JSON number of 2–7 digits would pass the regex by coercion and be stored as a number. + if (secret !== undefined && (typeof secret !== 'string' || !BASE32_SECRET.test(secret))) { + throw new WorkOSApiError(422, 'TOTP secret must be a valid Base32 string', 'invalid_totp_secret'); + } const factor = ws.authFactors.insert({ object: 'authentication_factor', user_id: user.id, type: type as 'totp', - totp: { - issuer, - user: user.email, - uri, - }, + totp: newTotp( + (body.totp_issuer as string) ?? 'WorkOS Emulator', + (body.totp_user as string) ?? user.email, + secret, + ), + }); + + // Enrollment answers with the challenge whose verification completes it, as production does. + // A TOTP code is delivered nowhere, so the stored code is what a test reads to verify it. + const challenge = ws.authChallenges.insert({ + object: 'authentication_challenge', + user_id: user.id, + factor_id: factor.id, + expires_at: expiresIn(10), + code: generateCode(), }); - return c.json(formatAuthFactor(factor), 201); + return c.json( + { + authentication_factor: formatAuthFactorEnrolled(factor), + authentication_challenge: formatAuthChallenge(challenge), + }, + 201, + ); }); app.get('/user_management/users/:userlandUserId/auth_factors', (c) => { @@ -44,13 +69,4 @@ export function authFactorRoutes(ctx: RouteContext): void { list_metadata: { before: null, after: null }, }); }); - - app.delete('/user_management/auth_factors/:id', (c) => { - const factorId = c.req.param('id'); - const factor = ws.authFactors.get(factorId); - if (!factor) throw notFound('AuthenticationFactor'); - - ws.authFactors.delete(factor.id); - return c.body(null, 204); - }); } diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index cbefa84..fc404d1 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -1817,7 +1817,7 @@ describe('Auth routes', () => { object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + totp: { issuer: 'Test', user: user.email, secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://...' }, }); // MFA comes first: the org is not resolved while the login is still unauthenticated. @@ -2062,7 +2062,7 @@ describe('Auth routes', () => { object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + totp: { issuer: 'Test', user: user.email, secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://...' }, }); const passwordRes = await app.request('/user_management/authenticate', { @@ -2110,7 +2110,7 @@ describe('Auth routes', () => { object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + totp: { issuer: 'Test', user: user.email, secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://...' }, }); const passwordRes = await app.request('/user_management/authenticate', { @@ -2193,7 +2193,7 @@ describe('Auth routes', () => { object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + totp: { issuer: 'Test', user: user.email, secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://...' }, }); // Create a challenge @@ -2239,7 +2239,7 @@ describe('Auth routes', () => { object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + totp: { issuer: 'Test', user: user.email, secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://...' }, }); const challenge = ws.authChallenges.insert({ @@ -2891,7 +2891,7 @@ describe('authentication events (spec-named, spec-shaped)', () => { object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + totp: { issuer: 'Test', user: user.email, secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://...' }, }); const challenge = ws.authChallenges.insert({ object: 'authentication_challenge', @@ -2984,7 +2984,7 @@ describe('authentication events (spec-named, spec-shaped)', () => { object: 'authentication_factor', user_id: user.id, type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + totp: { issuer: 'Test', user: user.email, secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://...' }, }); // First factor: password returns an mfa_challenge carrying a pending token + challenge, diff --git a/src/workos/routes/legacy-mfa.spec.ts b/src/workos/routes/legacy-mfa.spec.ts index 110c51f..60a6beb 100644 --- a/src/workos/routes/legacy-mfa.spec.ts +++ b/src/workos/routes/legacy-mfa.spec.ts @@ -29,6 +29,11 @@ describe('Legacy MFA routes', () => { expect(factor.object).toBe('authentication_factor'); expect(factor.type).toBe('totp'); expect(factor.id).toMatch(/^auth_factor_/); + // Enrollment is the one response that shows the secrets; GET strips them. + expect(factor.totp.secret).toMatch(/^[A-Z2-7]{32}$/); + expect(factor.totp.qr_code).toStartWith('data:image/png;base64,'); + const got = await json(await req(`/auth/factors/${factor.id}`)); + expect(got.totp).toEqual({ issuer: 'TestApp', user: 'user@test.com' }); }); it('gets a factor by id', async () => { diff --git a/src/workos/routes/legacy-mfa.ts b/src/workos/routes/legacy-mfa.ts index c9ac435..41f68e9 100644 --- a/src/workos/routes/legacy-mfa.ts +++ b/src/workos/routes/legacy-mfa.ts @@ -1,7 +1,14 @@ import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatAuthFactor, formatAuthChallenge, expiresIn, isExpired, generateCode } from '../helpers.js'; -import { randomBytes } from 'node:crypto'; +import { + formatAuthFactor, + formatAuthFactorEnrolled, + formatAuthChallenge, + expiresIn, + isExpired, + generateCode, + newTotp, +} from '../helpers.js'; export function legacyMfaRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -13,17 +20,15 @@ export function legacyMfaRoutes(ctx: RouteContext): void { const type = (body.type as string) ?? 'totp'; const issuer = (body.totp_issuer as string) ?? 'WorkOS Emulator'; const totpUser = (body.totp_user as string) ?? 'legacy@emulator'; - const secret = randomBytes(20).toString('hex').slice(0, 32).toUpperCase(); - const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(totpUser)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; const factor = ws.authFactors.insert({ object: 'authentication_factor', user_id: 'legacy', type: type as 'totp', - totp: { issuer, user: totpUser, uri }, + totp: newTotp(issuer, totpUser), }); - return c.json(formatAuthFactor(factor), 201); + return c.json(formatAuthFactorEnrolled(factor), 201); }); // Get factor diff --git a/src/workos/seed-auth-factors.spec.ts b/src/workos/seed-auth-factors.spec.ts index 1e39096..a3520c4 100644 --- a/src/workos/seed-auth-factors.spec.ts +++ b/src/workos/seed-auth-factors.spec.ts @@ -43,9 +43,8 @@ describe('Seeding TOTP authentication factors', () => { expect(factor.object).toBe('authentication_factor'); expect(factor.id).toMatch(/^auth_factor_/); expect(factor.type).toBe('totp'); - expect(factor.totp.issuer).toBe('WorkOS Emulator'); - expect(factor.totp.user).toBe('alice@acme.com'); - expect(factor.totp.uri).toStartWith('otpauth://totp/'); + // The list's AuthenticationFactor shows no secrets; only enrollment's response does. + expect(factor.totp).toEqual({ issuer: 'WorkOS Emulator', user: 'alice@acme.com' }); }); it('drives the password grant through the mfa_challenge step-up', async () => {