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..23704dc 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -120,6 +120,16 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: user.oauth_idp_id, }); } + // 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`, + 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'); + }); +});