From 27b7c4f9e645e77bcb715875f51dac617b0f4f7a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 21 Aug 2026 15:46:19 -0400 Subject: [PATCH 1/2] feat(seed): enroll TOTP auth factors from user config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factors were the one authentication method a seed could not give a user — password and oauth_provider already are — so anything touching MFA administration needed post-boot enrollment calls whose state a restart loses: the environment came back with its users but no factors, and an empty ListAuthFactors is indistinguishable from "this user has no MFA". `totp: true` writes the same record the enrollment route writes, so the seeded factor is listed by GET .../auth_factors and drives the password grant's existing mfa_challenge step-up. --- README.md | 19 +++++ src/workos/config-validator.ts | 9 +++ src/workos/index.ts | 27 +++++++ src/workos/seed-auth-factors.spec.ts | 116 +++++++++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 src/workos/seed-auth-factors.spec.ts diff --git a/README.md b/README.md index 9bb5921..49c496d 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,25 @@ A JWT template reads the same fact as `user.identities` — a provider→`idp_id provider the user has not linked, so `{{ user.identities.GoogleOAuth }}` renders the id and an unlinked provider renders a claim the emulator drops. +### Seeded MFA factors + +Set `totp: true` on a user to enroll a TOTP authentication factor at boot, exactly as +`POST /user_management/users/{id}/auth_factors` would: + +```yaml +users: + - email: alice@acme.com + password: test123 + email_verified: true + totp: true # enrolls a TOTP factor; password sign-ins answer with mfa_challenge +``` + +The factor is reported by `GET /user_management/users/{id}/auth_factors`, and a password +sign-in for the user returns the spec's `mfa_challenge` step (a `pending_authentication_token` +plus an `authentication_challenge`) instead of a session, completed with the +`urn:workos:oauth:grant-type:mfa-totp` grant — so MFA administration and step-up login flows +need no post-boot enrollment calls that in-memory state would lose on restart. + ### Pipes connected accounts `GET|POST|PUT|DELETE /user_management/users/{id}/connected_accounts/{slug}` serve a user's diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 93dedd9..7e19182 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -120,6 +120,15 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: user.oauth_idp_id, }); } + // A truthy non-boolean (YAML's `totp: yes` parses to true, but `totp: "yes"` does not) + // would silently enroll — or silently not enroll — a factor the config never decided on. + if (user.totp !== undefined && typeof user.totp !== 'boolean') { + errors.push({ + path: `users[${index}].totp`, + message: 'totp must be a boolean if provided', + value: user.totp, + }); + } }); // Email is the lookup key org memberships join on; duplicates would silently diff --git a/src/workos/index.ts b/src/workos/index.ts index 5467e34..f47899a 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -152,6 +152,15 @@ export interface WorkOSSeedUser { * `oauth_provider` — there is no identity to put it on. */ oauth_idp_id?: string; + /** + * Enroll a TOTP authentication factor for this user at boot, exactly as + * `POST /user_management/users/{id}/auth_factors` would. The factor is reported by + * `GET /user_management/users/{id}/auth_factors`, and a password sign-in answers with the + * spec's `mfa_challenge` step instead of a session — so MFA administration and step-up + * login flows are testable without post-boot enrollment calls that in-memory state loses + * on restart. + */ + totp?: boolean; } export interface WorkOSSeedConnection { @@ -359,6 +368,24 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee userConfig.oauth_idp_id ?? `idp_${generateId('usr')}`, ); } + + // 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. + 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)}`, + }, + }); + } } } diff --git a/src/workos/seed-auth-factors.spec.ts b/src/workos/seed-auth-factors.spec.ts new file mode 100644 index 0000000..1e39096 --- /dev/null +++ b/src/workos/seed-auth-factors.spec.ts @@ -0,0 +1,116 @@ +/** + * Seeding TOTP authentication factors. Every other authentication method a user can hold is + * seedable (`password`, `oauth_provider`), and with in-memory state a factor enrolled over + * HTTP after boot does not survive a restart while the seeded users do. `totp: true` writes + * the same record `POST /user_management/users/{id}/auth_factors` writes, so ListAuthFactors + * reports it and a password sign-in walks the spec's `mfa_challenge` step-up — no post-boot + * enrollment calls. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createEmulator, type Emulator } from '../index.js'; +import { getWorkOSStore } from './store.js'; +import { validateSeedConfig } from './config-validator.js'; + +const PINNED_USER_ID = 'user_01SEEDTOTP0000000000000000'; + +describe('Seeding TOTP authentication factors', () => { + let emulator: Emulator | undefined; + + afterEach(async () => { + await emulator?.close(); + emulator = undefined; + }); + + const auth = (apiKey: string) => ({ Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }); + + it("reports the seeded factor with the enrollment route's shape", async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ id: PINNED_USER_ID, email: 'alice@acme.com', password: 'test123', totp: true }], + }, + }); + + const res = await fetch(`${emulator.url}/user_management/users/${PINNED_USER_ID}/auth_factors`, { + headers: auth(emulator.apiKey), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.object).toBe('list'); + expect(body.data).toHaveLength(1); + + const factor = body.data[0]; + 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/'); + }); + + it('drives the password grant through the mfa_challenge step-up', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: 'mfa@acme.com', password: 'test123', email_verified: true, totp: true }], + }, + }); + + // The seeded factor is an enrolled second factor: no session until it clears. + const passwordRes = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'mfa@acme.com', password: 'test123' }), + }); + expect(passwordRes.status).toBe(403); + const challengeBody = (await passwordRes.json()) as any; + expect(challengeBody.code).toBe('mfa_challenge'); + + // The response withholds the one-time code, as production does; read it off the challenge. + const ws = getWorkOSStore(emulator.store); + const challengeCode = ws.authChallenges.get(challengeBody.authentication_challenge.id)!.code; + + const mfaRes = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:mfa-totp', + code: challengeCode, + pending_authentication_token: challengeBody.pending_authentication_token, + authentication_challenge_id: challengeBody.authentication_challenge.id, + }), + }); + expect(mfaRes.status).toBe(200); + const session = (await mfaRes.json()) as any; + expect(session.user.email).toBe('mfa@acme.com'); + // The session reports the primary factor the pending token recorded. + expect(session.authentication_method).toBe('Password'); + }); + + it('does not enroll a factor for users the seed left alone', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [ + { id: PINNED_USER_ID, email: 'plain@acme.com', password: 'test123' }, + { email: 'enrolled@acme.com', totp: true }, + ], + }, + }); + + const res = await fetch(`${emulator.url}/user_management/users/${PINNED_USER_ID}/auth_factors`, { + headers: auth(emulator.apiKey), + }); + const body = (await res.json()) as any; + expect(body.data).toEqual([]); + }); + + it('accepts a boolean totp and rejects anything else', () => { + const ok = validateSeedConfig({ users: [{ email: 'a@b.co', totp: true }] }); + expect(ok.valid).toBe(true); + + const bad = validateSeedConfig({ users: [{ email: 'a@b.co', totp: 'yes' as unknown as boolean }] }); + expect(bad.valid).toBe(false); + expect(bad.errors[0].path).toBe('users[0].totp'); + }); +}); From 670faa44dbf60aa69060c31b9a494a2e4029f89a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 21 Aug 2026 15:59:19 -0400 Subject: [PATCH 2/2] docs(seed): correct the YAML semantics in the totp validator note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed YAML parses `totp: yes` to true, but the CLI's yaml v2 parser follows YAML 1.2, where `yes` — and `no` — are plain strings. Both are truthy, which is the actual hazard the check guards against: `totp: no` would otherwise enroll a factor the config explicitly declined. --- src/workos/config-validator.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 7e19182..23704dc 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -120,8 +120,9 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: user.oauth_idp_id, }); } - // A truthy non-boolean (YAML's `totp: yes` parses to true, but `totp: "yes"` does not) - // would silently enroll — or silently not enroll — a factor the config never decided on. + // The YAML 1.2 parser the CLI uses reads `totp: yes` as the string "yes", not a boolean — + // and `totp: no` as the equally truthy string "no", which the seed's truthiness check + // would silently enroll a factor from, against what the config explicitly declined. if (user.totp !== undefined && typeof user.totp !== 'boolean') { errors.push({ path: `users[${index}].totp`,