From b8d5d97a6cf71a2135ea669ad304b094b505afa3 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 15 Sep 2026 10:02:59 -0400 Subject: [PATCH 1/5] fix(http): send 204 responses without a Content-Type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every no-content reply — deleting a user, a factor, an organization — went out as `Content-Type: text/plain; charset=UTF-8`, which production never sends. The header was not ours: @hono/node-server 1.x stamped a text/plain default onto any response lacking one, empty body included. 2.x skips the default when the body is null, so the bump fixes all 27 routes at once with no adapter-level workaround. Refs #110 --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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", From 963639dab1cd68966579ecd17777efeb53f1bee5 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 15 Sep 2026 10:04:02 -0400 Subject: [PATCH 2/5] fix(mfa): name the challenge's factor authentication_factor_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every authentication_challenge on the wire — from the challenge routes, the password grant's mfa_challenge step and the legacy MFA API — carried the store's join columns, `factor_id` and `user_id`, where the spec's AuthenticationChallenge has `authentication_factor_id` and no user. Every generated SDK reads the spec's name: Kotlin, Rust, Swift, Python and PHP refuse to deserialize without it, and Go, Ruby, .NET and Elixir hand back an empty factor id. The store keeps its columns; only the formatter, where every route already meets, changes. Refs #110 --- src/workos/helpers.ts | 7 +++++-- src/workos/routes/auth-challenges.spec.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 6861335..fc5a8b4 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -905,10 +905,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/routes/auth-challenges.spec.ts b/src/workos/routes/auth-challenges.spec.ts index ad98a81..d0e3750 100644 --- a/src/workos/routes/auth-challenges.spec.ts +++ b/src/workos/routes/auth-challenges.spec.ts @@ -60,7 +60,11 @@ describe('Auth challenge routes', () => { 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 () => { From 82dde5f0a4320d066e5fba397cac938a04a6f24b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 15 Sep 2026 10:35:20 -0400 Subject: [PATCH 3/5] fix(mfa): return the spec's enrollment envelope with TOTP secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /user_management/users/:id/auth_factors` answered with a bare factor, where the spec's UserlandUserAuthenticationFactorEnrollResponse wraps it as `{ authentication_factor, authentication_challenge }`. Every backend SDK reads that envelope: Node throws a TypeError before returning, Python, Kotlin, Rust, Swift and PHP refuse to deserialize, and Go, Ruby, .NET and Elixir hand back nil for both halves — so no SDK could drive TOTP enrollment through the emulator. The wrapper alone would not have been enough. The spec's enrolled factor requires `totp.secret`, `totp.qr_code` and `totp.uri`, and the same SDKs fail one level down without them; the emulator stored only a hex secret buried in the URI. Enrollment now mints a Base32 secret (honoring a caller-supplied `totp_secret`, validated as production does) and is the only response that shows it — GET and LIST return the spec's secretless AuthenticationFactor, as production does. The `qr_code` is a valid 1x1 PNG rather than a scannable code: the field is required and typed as a string, the emulator never verifies a real TOTP code, and `uri` already carries everything the code would. Fixes #110 --- src/e2e.spec.ts | 4 +- src/workos/entities.ts | 2 + src/workos/helpers.ts | 34 ++++++++- src/workos/index.ts | 12 +-- src/workos/routes/auth-challenges.spec.ts | 2 +- src/workos/routes/auth-factors.spec.ts | 90 +++++++++++++++++++++++ src/workos/routes/auth-factors.ts | 48 +++++++++--- src/workos/routes/auth.spec.ts | 14 ++-- src/workos/routes/legacy-mfa.spec.ts | 5 ++ src/workos/routes/legacy-mfa.ts | 17 +++-- src/workos/seed-auth-factors.spec.ts | 5 +- 11 files changed, 192 insertions(+), 41 deletions(-) create mode 100644 src/workos/routes/auth-factors.spec.ts diff --git a/src/e2e.spec.ts b/src/e2e.spec.ts index 1de47c3..b4bcf99 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`, { @@ -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`, { 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/helpers.ts b/src/workos/helpers.ts index fc5a8b4..8de1a44 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -371,8 +371,40 @@ export function formatMagicAuth(ma: WorkOSMagicAuth): 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 } }; } /** diff --git a/src/workos/index.ts b/src/workos/index.ts index 60004c8..3315c60 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -79,6 +79,7 @@ import { formatConnectedAccountEvent, dataIntegrationIdFor, linkOAuthIdentity, + newTotp, } from './helpers.js'; import type { WorkOSConnectionType, @@ -466,20 +467,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), }); } } diff --git a/src/workos/routes/auth-challenges.spec.ts b/src/workos/routes/auth-challenges.spec.ts index d0e3750..0672bb5 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 }; } diff --git a/src/workos/routes/auth-factors.spec.ts b/src/workos/routes/auth-factors.spec.ts new file mode 100644 index 0000000..e5d45e0 --- /dev/null +++ b/src/workos/routes/auth-factors.spec.ts @@ -0,0 +1,90 @@ +/** + * 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'); + + const bad = await enroll({ type: 'totp', totp_secret: 'not base32!' }); + expect(bad.status).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..1ee0709 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,39 @@ 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 as string | undefined; + if (secret !== undefined && !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, + ), }); - return c.json(formatAuthFactor(factor), 201); + // 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( + { + authentication_factor: formatAuthFactorEnrolled(factor), + authentication_challenge: formatAuthChallenge(challenge), + }, + 201, + ); }); app.get('/user_management/users/:userlandUserId/auth_factors', (c) => { 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 () => { From 99ae0562cb66364aa8df372825aaf0606fa9d95a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 15 Sep 2026 10:45:56 -0400 Subject: [PATCH 4/5] test(mfa): pin the MFA routes to the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #110 shipped in several releases because neither conformance loop looked at MFA: the enrollment envelope, the factor and the challenge were all outside the curated catalogs, so the bare factor and the challenge's `factor_id` never met the spec. Both loops already do exactly the check that was needed — the gap was coverage, not design. The six spec operations with a JSON body and the two resources join the catalogs; run against main without the fix, the new cases fail six times, each naming one of the drifts. The loops diff top-level keys, so they cannot see the enrolled factor's `totp.secret`, `qr_code` and `uri`; the route tests added with the fix pin those. Refs #110 --- scripts/gen-shapes-lib.ts | 30 ++++++++++++++++++ src/workos/generated/response-shapes.ts | 40 +++++++++++++++++++++++ src/workos/response-envelopes.spec.ts | 42 +++++++++++++++++++++++-- src/workos/response-shapes.spec.ts | 33 +++++++++++++++++++ 4 files changed, 142 insertions(+), 3 deletions(-) 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/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 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) }, ]; /** From 2de3807f1e9930ba99563fbf2972765d19ba305d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 15 Sep 2026 11:03:00 -0400 Subject: [PATCH 5/5] fix(mfa): reject a non-string totp_secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RegExp.test` coerces its argument, and the digits 2–7 are Base32, so a JSON number like 234567 passed validation and was stored and returned as a number — a `totp.secret` the strictly typed SDKs would refuse to deserialize. Refs #110 --- src/workos/routes/auth-factors.spec.ts | 8 +++++--- src/workos/routes/auth-factors.ts | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/workos/routes/auth-factors.spec.ts b/src/workos/routes/auth-factors.spec.ts index e5d45e0..d4ecb0c 100644 --- a/src/workos/routes/auth-factors.spec.ts +++ b/src/workos/routes/auth-factors.spec.ts @@ -61,9 +61,11 @@ describe('Auth factor routes', () => { expect(factor.totp.secret).toBe('JBSWY3DPEHPK3PXP'); expect(factor.totp.uri).toContain('secret=JBSWY3DPEHPK3PXP'); - const bad = await enroll({ type: 'totp', totp_secret: 'not base32!' }); - expect(bad.status).toBe(422); - expect((await json(bad)).code).toBe('invalid_totp_secret'); + 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 () => { diff --git a/src/workos/routes/auth-factors.ts b/src/workos/routes/auth-factors.ts index 1ee0709..7071dbf 100644 --- a/src/workos/routes/auth-factors.ts +++ b/src/workos/routes/auth-factors.ts @@ -21,8 +21,9 @@ export function authFactorRoutes(ctx: RouteContext): void { const body = await parseJsonBody(c); const type = (body.type as string) ?? 'totp'; - const secret = body.totp_secret as string | undefined; - if (secret !== undefined && !BASE32_SECRET.test(secret)) { + 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'); }