Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/workos/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}`,
},
});
}
}
}

Expand Down
116 changes: 116 additions & 0 deletions src/workos/seed-auth-factors.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading