Skip to content
Draft
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ All services start with sensible defaults. No config file needed:
- **Clerk** on `http://localhost:4011`
- **Spotify** on `http://localhost:4012`
- **X** on `http://localhost:4013`
- **WorkOS** on `http://localhost:4014` (AuthKit, user directory, organizations, organization domains, Vault, and OAuth)
- **WorkOS** on `http://localhost:4014` (AuthKit, TOTP MFA, user directory, organizations, organization domains, Vault, and OAuth)
- **Autumn** on `http://localhost:4015`
- **PostHog** on `http://localhost:4016`
- **MCP** on `http://localhost:4017`
Expand Down
6 changes: 6 additions & 0 deletions apps/web/app/docs/workos/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ const domain = await workos.organizationDomains.create({
```

Use `GET /_emulate/ledger` to inspect calls with credentials and tokens redacted.

## TOTP MFA

The real WorkOS SDK can enroll user-bound factors, list verified factors, issue challenges, verify six-digit TOTP codes, and delete factors. Enrollment returns the setup secret, URI, and a PNG QR code. Pending factors are not listed until verification succeeds. A verified challenge cannot be used again.

TOTP uses SHA-1, six digits, and 30-second steps with a one-step clock tolerance. Generate codes with an authenticator or an OTP library from the returned secret. Challenge expiration and per-user retry limits belong to the application under test; this emulator does not enforce a WorkOS rate limit or a hosted AuthKit MFA policy.
16 changes: 14 additions & 2 deletions packages/@emulators/core/src/__tests__/control-plane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,24 @@ describe("control plane", () => {
"content-type": "application/json",
authorization: "Bearer admin",
},
body: JSON.stringify({ name: "created", token: "secret-token" }),
body: JSON.stringify({
name: "created",
token: "secret-token",
code: "123456",
qr_code: "data:image/png;base64,test",
uri: "otpauth://totp/test?secret=test",
}),
});
expect(createRes.status).toBe(201);

const ledger = (await (await app.request("/_emulate/ledger")).json()) as {
entries: Array<{
method: string;
path: string;
request: { headers: Record<string, string>; body: { token: string } };
request: {
headers: Record<string, string>;
body: { token: string; code: string; qr_code: string; uri: string };
};
response: { status: number; body: { token: string } };
identity: { user?: { login: string } };
}>;
Expand All @@ -90,6 +99,9 @@ describe("control plane", () => {
expect(ledger.entries[0]!.path).toBe("/things");
expect(ledger.entries[0]!.request.headers.authorization).toBe("[redacted]");
expect(ledger.entries[0]!.request.body.token).toBe("[redacted]");
expect(ledger.entries[0]!.request.body.code).toBe("[redacted]");
expect(ledger.entries[0]!.request.body.qr_code).toBe("[redacted]");
expect(ledger.entries[0]!.request.body.uri).toBe("[redacted]");
expect(ledger.entries[0]!.response.body.token).toBe("[redacted]");
expect(ledger.entries[0]!.response.status).toBe(201);
expect(ledger.entries[0]!.identity.user?.login).toBe("admin");
Expand Down
3 changes: 2 additions & 1 deletion packages/@emulators/core/src/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ const SENSITIVE_HEADERS = new Set([
"x-github-token",
"stripe-signature",
]);
const SENSITIVE_KEYS = /token|secret|password|authorization|api[_-]?key|client[_-]?secret|private[_-]?key/i;
const SENSITIVE_KEYS =
/token|secret|password|authorization|api[_-]?key|client[_-]?secret|private[_-]?key|^code$|^qr_code$|^uri$/i;

export class RequestLedger {
private entries: LedgerEntry[] = [];
Expand Down
9 changes: 6 additions & 3 deletions packages/@emulators/workos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,15 @@
},
"dependencies": {
"@emulators/core": "workspace:*",
"jose": "^6"
"jose": "^6",
"otpauth": "9.5.2",
"qrcode": "1.5.4"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@workos-inc/node": "^8.13.0",
"tsup": "^8",
"typescript": "^5.7",
"vitest": "^4.1.0",
"@workos-inc/node": "^8.13.0"
"vitest": "^4.1.0"
}
}
37 changes: 37 additions & 0 deletions packages/@emulators/workos/src/__tests__/workos.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createServer, serve } from "@emulators/core";
import { TOTP } from "otpauth";
import { WorkOS } from "@workos-inc/node";
import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } from "jose";

Expand Down Expand Up @@ -626,3 +627,39 @@ describe("workos emulator with the real @workos-inc/node SDK", () => {
expect(auth.user.firstName).toBe("Seeded");
});
});

describe("AuthKit TOTP factors", () => {
it("excludes pending enrollments, activates on a valid code, and rejects replay", async () => {
const code = await signInAndGetCode("mfa@example.test");
const { user } = await workos.userManagement.authenticateWithCode({ code, clientId: CLIENT_ID });
const first = await workos.userManagement.enrollAuthFactor({ userId: user.id, type: "totp" });
const restarted = await workos.userManagement.enrollAuthFactor({ userId: user.id, type: "totp" });
expect(restarted.authenticationFactor.id).not.toBe(first.authenticationFactor.id);
expect((await workos.userManagement.listAuthFactors({ userId: user.id })).data).toEqual([]);
const factor = restarted.authenticationFactor;
const challengeId = restarted.authenticationChallenge.id;
const invalid = await workos.mfa.verifyChallenge({ authenticationChallengeId: challengeId, code: "invalid" });
expect(invalid.valid).toBe(false);
const otp = new TOTP({ secret: factor.totp.secret }).generate();
expect((await workos.mfa.verifyChallenge({ authenticationChallengeId: challengeId, code: otp })).valid).toBe(true);
await expect(
workos.mfa.verifyChallenge({ authenticationChallengeId: challengeId, code: otp }),
).rejects.toMatchObject({ status: 422 });
const listed = (await workos.userManagement.listAuthFactors({ userId: user.id })).data;
expect(listed).toHaveLength(1);
expect(listed[0]?.id).toBe(factor.id);
expect(listed[0]?.totp).not.toHaveProperty("secret");
expect(factor.totp.qrCode).toMatch(/^data:image\/png;base64,/);
const next = await workos.mfa.challengeFactor({ authenticationFactorId: factor.id });
expect(
(
await workos.mfa.verifyChallenge({
authenticationChallengeId: next.id,
code: new TOTP({ secret: factor.totp.secret }).generate(),
})
).valid,
).toBe(true);
await workos.mfa.deleteFactor(factor.id);
expect((await workos.userManagement.listAuthFactors({ userId: user.id })).data).toEqual([]);
});
});
17 changes: 17 additions & 0 deletions packages/@emulators/workos/src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,20 @@ export interface WorkosOAuthCode extends Entity {
scope: string | null;
used: boolean;
}

/** A user-bound TOTP factor, active only after a successful challenge. */
export interface WorkosMfaFactor extends Entity {
workos_id: string;
user_id: string;
issuer: string;
label: string;
secret: string;
verified: boolean;
}

/** A TOTP challenge can be verified only once. */
export interface WorkosMfaChallenge extends Entity {
workos_id: string;
factor_id: string;
verified: boolean;
}
2 changes: 2 additions & 0 deletions packages/@emulators/workos/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { Hono, Store, WebhookDispatcher, TokenMap, AppEnv, RouteContext, ServicePlugin } from "@emulators/core";

import { getWorkosStore, type WorkosStore } from "./store.js";

Check warning on line 3 in packages/@emulators/workos/src/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'WorkosStore' is defined but never used. Allowed unused vars must match /^_/u
import { userManagementRoutes, ensureUserByEmail } from "./routes/user-management.js";
import { organizationRoutes } from "./routes/organizations.js";
import { apiKeyRoutes } from "./routes/api-keys.js";
import { mfaRoutes } from "./routes/mfa.js";
import { vaultRoutes } from "./routes/vault.js";
import { oauthRoutes } from "./routes/oauth.js";
import { openapiRoutes } from "./routes/openapi.js";
import { workosId } from "./helpers.js";
import { manifest } from "./manifest.js";

Check warning on line 12 in packages/@emulators/workos/src/index.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'manifest' is defined but never used. Allowed unused vars must match /^_/u

export { getWorkosStore, type WorkosStore } from "./store.js";
export * from "./entities.js";
Expand Down Expand Up @@ -63,6 +64,7 @@
const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap };
oauthRoutes(ctx);
userManagementRoutes(ctx);
mfaRoutes(ctx);
organizationRoutes(ctx);
apiKeyRoutes(ctx);
vaultRoutes(ctx);
Expand Down
31 changes: 30 additions & 1 deletion packages/@emulators/workos/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const manifest: ServiceManifest = {
id: "workos",
name: "WorkOS",
description:
"Stateful WorkOS emulator: AuthKit user management (hosted login, code + refresh grants, sealed-session JWKS), organizations and organization domains, memberships, invitations, API keys, Vault KV, and an OAuth authorization server for MCP clients.",
"Stateful WorkOS emulator: AuthKit user management (hosted login, code + refresh grants, sealed-session JWKS), organizations and organization domains, memberships, invitations, TOTP MFA, API keys, Vault KV, and an OAuth authorization server for MCP clients.",
docsUrl: "https://docs.emulators.dev/workos",
surfaces: [
{ id: "rest", kind: "rest", title: "WorkOS REST API", status: "partial", basePath: "/" },
Expand All @@ -26,6 +26,33 @@ export const manifest: ServiceManifest = {
coverage: "hand-authored",
url: "/openapi.json",
operations: [
{
operationId: "userManagement.enrollAuthFactor",
method: "POST",
path: "/user_management/users/:id/auth_factors",
status: "hand-authored",
},
{
operationId: "userManagement.listAuthFactors",
method: "GET",
path: "/user_management/users/:id/auth_factors",
status: "hand-authored",
},
{
operationId: "mfa.challengeFactor",
method: "POST",
path: "/auth/factors/:id/challenge",
status: "hand-authored",
},
{
operationId: "mfa.verifyChallenge",
method: "POST",
path: "/auth/challenges/:id/verify",
status: "hand-authored",
},
{ operationId: "mfa.getFactor", method: "GET", path: "/auth/factors/:id", status: "hand-authored" },
{ operationId: "mfa.deleteFactor", method: "DELETE", path: "/auth/factors/:id", status: "hand-authored" },

{
operationId: "userManagement.authenticate",
method: "POST",
Expand Down Expand Up @@ -194,6 +221,8 @@ export const manifest: ServiceManifest = {
description: "Entities mutated by WorkOS provider calls.",
collections: [
{ name: "workos.users" },
{ name: "workos.mfa_factors" },
{ name: "workos.mfa_challenges" },
{ name: "workos.organizations" },
{ name: "workos.organization_domains" },
{ name: "workos.memberships" },
Expand Down
126 changes: 126 additions & 0 deletions packages/@emulators/workos/src/routes/mfa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { RouteContext } from "@emulators/core";
import { Secret, TOTP } from "otpauth";
import { toDataURL } from "qrcode";
import { getWorkosStore } from "../store.js";
import type { WorkosMfaFactor, WorkosMfaChallenge } from "../entities.js";
import { listEnvelope, workosError, workosId } from "../helpers.js";

const factorJson = (factor: WorkosMfaFactor) => ({
object: "authentication_factor",
id: factor.workos_id,
created_at: factor.created_at,
updated_at: factor.updated_at,
type: "totp",
user_id: factor.user_id,
totp: { issuer: factor.issuer, user: factor.label },
});
const challengeJson = (challenge: WorkosMfaChallenge) => ({
object: "authentication_challenge",
id: challenge.workos_id,
created_at: challenge.created_at,
updated_at: challenge.updated_at,
authentication_factor_id: challenge.factor_id,
});
const totpFor = (factor: WorkosMfaFactor) =>
new TOTP({
issuer: factor.issuer,
label: factor.label,
secret: factor.secret,
algorithm: "SHA1",
digits: 6,
period: 30,
});

/** AuthKit TOTP enrollment and MFA challenge endpoints, including one-use verification. */
export function mfaRoutes({ app, store }: RouteContext): void {
const ws = getWorkosStore(store);
const challengeFor = (factorId: string) =>
ws.mfaChallenges.insert({
workos_id: workosId("auth_challenge"),
factor_id: factorId,
verified: false,
});

app.post("/user_management/users/:id/auth_factors", async (c) => {
const user = ws.users.findOneBy("workos_id", c.req.param("id"));
if (!user) return workosError(c, 404, "user_not_found", "User not found");
const body: unknown = await c.req.json();
if (typeof body !== "object" || body === null || !("type" in body) || body.type !== "totp") {
return workosError(c, 400, "invalid_request", "Only TOTP factors are supported");
}
const issuer = "totp_issuer" in body && typeof body.totp_issuer === "string" ? body.totp_issuer : "WorkOS";
const label = "totp_user" in body && typeof body.totp_user === "string" ? body.totp_user : user.email;
const secret =
"totp_secret" in body && typeof body.totp_secret === "string"
? body.totp_secret
: new Secret({ size: 20 }).base32;
if (!/^[A-Z2-7]{16,}={0,6}$/i.test(secret)) return workosError(c, 400, "invalid_request", "Invalid TOTP secret");
const factor = ws.mfaFactors.insert({
workos_id: workosId("auth_factor"),
user_id: user.workos_id,
issuer,
label,
secret,
verified: false,
});
const totp = totpFor(factor);
return c.json({
authentication_factor: {
...factorJson(factor),
totp: { issuer, user: label, secret, uri: totp.toString(), qr_code: await toDataURL(totp.toString()) },
},
authentication_challenge: challengeJson(challengeFor(factor.workos_id)),
});
});

app.get("/user_management/users/:id/auth_factors", (c) => {
const user = ws.users.findOneBy("workos_id", c.req.param("id"));
if (!user) return workosError(c, 404, "user_not_found", "User not found");
// AuthKit excludes unfinished enrollments from the active-factor list.
return c.json(
listEnvelope(
ws.mfaFactors
.findBy("user_id", user.workos_id)
.filter((factor) => factor.verified)
.map(factorJson),
),
);
});

app.post("/auth/factors/:id/challenge", (c) => {
const factor = ws.mfaFactors.findOneBy("workos_id", c.req.param("id"));
if (!factor) return workosError(c, 404, "authentication_factor_not_found", "Factor not found");
return c.json(challengeJson(challengeFor(factor.workos_id)));
});

app.post("/auth/challenges/:id/verify", async (c) => {
const challenge = ws.mfaChallenges.findOneBy("workos_id", c.req.param("id"));
if (!challenge) return workosError(c, 404, "authentication_challenge_not_found", "Challenge not found");
if (challenge.verified)
return workosError(c, 422, "authentication_challenge_previously_verified", "Challenge already verified");
const factor = ws.mfaFactors.findOneBy("workos_id", challenge.factor_id);
if (!factor) return workosError(c, 404, "authentication_factor_not_found", "Factor not found");
const body: unknown = await c.req.json();
const code =
typeof body === "object" && body !== null && "code" in body && typeof body.code === "string" ? body.code : "";
const valid = /^\d{6}$/.test(code) && totpFor(factor).validate({ token: code, window: 1 }) !== null;
if (valid) {
ws.mfaChallenges.update(challenge.id, { verified: true });
ws.mfaFactors.update(factor.id, { verified: true });
}
return c.json({ valid, challenge: challengeJson(challenge) });
});

app.get("/auth/factors/:id", (c) => {
const factor = ws.mfaFactors.findOneBy("workos_id", c.req.param("id"));
return factor
? c.json(factorJson(factor))
: workosError(c, 404, "authentication_factor_not_found", "Factor not found");
});
app.delete("/auth/factors/:id", (c) => {
const factor = ws.mfaFactors.findOneBy("workos_id", c.req.param("id"));
if (!factor) return workosError(c, 404, "authentication_factor_not_found", "Factor not found");
ws.mfaFactors.delete(factor.id);
return c.body(null, 204);
});
}
41 changes: 41 additions & 0 deletions packages/@emulators/workos/src/routes/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,47 @@ function buildSpec(baseUrl: string): Record<string, unknown> {
},
security: [{ bearerAuth: [] }],
paths: {
"/user_management/users/{id}/auth_factors": {
get: {
operationId: "userManagement.listAuthFactors",
parameters: [id],
responses: { "200": ok("Verified factors, without secrets") },
},
post: {
operationId: "userManagement.enrollAuthFactor",
parameters: [id],
requestBody: jsonBody(
{
type: { const: "totp" },
totp_issuer: { type: "string" },
totp_user: { type: "string" },
totp_secret: { type: "string" },
},
["type"],
"Begin TOTP enrollment",
),
responses: { "200": ok("Pending factor with setup details and a challenge") },
},
},
"/auth/factors/{id}/challenge": {
post: {
operationId: "mfa.challengeFactor",
parameters: [id],
responses: { "200": ok("New one-use challenge") },
},
},
"/auth/challenges/{id}/verify": {
post: {
operationId: "mfa.verifyChallenge",
parameters: [id],
requestBody: jsonBody({ code: { type: "string" } }, ["code"], "Verify a current TOTP code"),
responses: { "200": ok("Verification result"), "422": ok("Already verified") },
},
},
"/auth/factors/{id}": {
get: { operationId: "mfa.getFactor", parameters: [id], responses: { "200": ok("Factor without its secret") } },
delete: { operationId: "mfa.deleteFactor", parameters: [id], responses: { "204": noContent("Deleted") } },
},
"/user_management/authenticate": {
post: {
operationId: "userManagement.authenticate",
Expand Down
Loading
Loading