From fedabc7f2694b18dedff215220987617ace4fcf5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:08:55 -0700 Subject: [PATCH] Add WorkOS TOTP factor and challenge support --- README.md | 2 +- apps/web/app/docs/workos/page.mdx | 6 + .../core/src/__tests__/control-plane.test.ts | 16 +- packages/@emulators/core/src/ledger.ts | 3 +- packages/@emulators/workos/package.json | 9 +- .../workos/src/__tests__/workos.test.ts | 37 ++++ packages/@emulators/workos/src/entities.ts | 17 ++ packages/@emulators/workos/src/index.ts | 2 + packages/@emulators/workos/src/manifest.ts | 31 ++- packages/@emulators/workos/src/routes/mfa.ts | 126 ++++++++++++ .../@emulators/workos/src/routes/openapi.ts | 41 ++++ packages/@emulators/workos/src/store.ts | 6 + packages/emulate/package.json | 26 +-- packages/emulate/src/index.ts | 1 + pnpm-lock.yaml | 180 ++++++++++++++++++ skills/workos/SKILL.md | 6 + 16 files changed, 489 insertions(+), 20 deletions(-) create mode 100644 packages/@emulators/workos/src/routes/mfa.ts diff --git a/README.md b/README.md index f496d6bfa..5f764e497 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/apps/web/app/docs/workos/page.mdx b/apps/web/app/docs/workos/page.mdx index 101ea857a..b0b8ed52a 100644 --- a/apps/web/app/docs/workos/page.mdx +++ b/apps/web/app/docs/workos/page.mdx @@ -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. diff --git a/packages/@emulators/core/src/__tests__/control-plane.test.ts b/packages/@emulators/core/src/__tests__/control-plane.test.ts index a26496974..9a8532547 100644 --- a/packages/@emulators/core/src/__tests__/control-plane.test.ts +++ b/packages/@emulators/core/src/__tests__/control-plane.test.ts @@ -72,7 +72,13 @@ 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); @@ -80,7 +86,10 @@ describe("control plane", () => { entries: Array<{ method: string; path: string; - request: { headers: Record; body: { token: string } }; + request: { + headers: Record; + body: { token: string; code: string; qr_code: string; uri: string }; + }; response: { status: number; body: { token: string } }; identity: { user?: { login: string } }; }>; @@ -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"); diff --git a/packages/@emulators/core/src/ledger.ts b/packages/@emulators/core/src/ledger.ts index a6a8e9700..55661bc5d 100644 --- a/packages/@emulators/core/src/ledger.ts +++ b/packages/@emulators/core/src/ledger.ts @@ -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[] = []; diff --git a/packages/@emulators/workos/package.json b/packages/@emulators/workos/package.json index d9307a62f..462c5876c 100644 --- a/packages/@emulators/workos/package.json +++ b/packages/@emulators/workos/package.json @@ -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" } } diff --git a/packages/@emulators/workos/src/__tests__/workos.test.ts b/packages/@emulators/workos/src/__tests__/workos.test.ts index d90922b36..44584db5e 100644 --- a/packages/@emulators/workos/src/__tests__/workos.test.ts +++ b/packages/@emulators/workos/src/__tests__/workos.test.ts @@ -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"; @@ -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([]); + }); +}); diff --git a/packages/@emulators/workos/src/entities.ts b/packages/@emulators/workos/src/entities.ts index 6343ce1ee..a301176b7 100644 --- a/packages/@emulators/workos/src/entities.ts +++ b/packages/@emulators/workos/src/entities.ts @@ -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; +} diff --git a/packages/@emulators/workos/src/index.ts b/packages/@emulators/workos/src/index.ts index 1334a58a9..db40b9af3 100644 --- a/packages/@emulators/workos/src/index.ts +++ b/packages/@emulators/workos/src/index.ts @@ -4,6 +4,7 @@ import { getWorkosStore, type WorkosStore } from "./store.js"; 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"; @@ -63,6 +64,7 @@ export const workosPlugin: ServicePlugin = { const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; oauthRoutes(ctx); userManagementRoutes(ctx); + mfaRoutes(ctx); organizationRoutes(ctx); apiKeyRoutes(ctx); vaultRoutes(ctx); diff --git a/packages/@emulators/workos/src/manifest.ts b/packages/@emulators/workos/src/manifest.ts index 946c1e3f0..4344ead0d 100644 --- a/packages/@emulators/workos/src/manifest.ts +++ b/packages/@emulators/workos/src/manifest.ts @@ -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: "/" }, @@ -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", @@ -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" }, diff --git a/packages/@emulators/workos/src/routes/mfa.ts b/packages/@emulators/workos/src/routes/mfa.ts new file mode 100644 index 000000000..bcc69973a --- /dev/null +++ b/packages/@emulators/workos/src/routes/mfa.ts @@ -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); + }); +} diff --git a/packages/@emulators/workos/src/routes/openapi.ts b/packages/@emulators/workos/src/routes/openapi.ts index fd9124238..a09585125 100644 --- a/packages/@emulators/workos/src/routes/openapi.ts +++ b/packages/@emulators/workos/src/routes/openapi.ts @@ -54,6 +54,47 @@ function buildSpec(baseUrl: string): Record { }, 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", diff --git a/packages/@emulators/workos/src/store.ts b/packages/@emulators/workos/src/store.ts index aac0836ac..49060af4a 100644 --- a/packages/@emulators/workos/src/store.ts +++ b/packages/@emulators/workos/src/store.ts @@ -1,6 +1,8 @@ import { Store, type Collection } from "@emulators/core"; import type { + WorkosMfaFactor, + WorkosMfaChallenge, WorkosApiKey, WorkosAuthCode, WorkosInvitation, @@ -16,6 +18,8 @@ import type { } from "./entities.js"; export interface WorkosStore { + mfaFactors: Collection; + mfaChallenges: Collection; users: Collection; organizations: Collection; organizationDomains: Collection; @@ -32,6 +36,8 @@ export interface WorkosStore { export function getWorkosStore(store: Store): WorkosStore { return { + mfaFactors: store.collection("workos.mfa_factors", ["workos_id", "user_id"]), + mfaChallenges: store.collection("workos.mfa_challenges", ["workos_id", "factor_id"]), users: store.collection("workos.users", ["workos_id", "email"]), organizations: store.collection("workos.organizations", ["workos_id"]), organizationDomains: store.collection("workos.organization_domains", [ diff --git a/packages/emulate/package.json b/packages/emulate/package.json index 312d1c6fe..89565acbb 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -68,7 +68,9 @@ "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", + "otpauth": "9.5.2", "picocolors": "^1.1.1", + "qrcode": "1.5.4", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", @@ -76,27 +78,27 @@ "yaml": "^2" }, "devDependencies": { + "@emulators/apple": "workspace:*", + "@emulators/autumn": "workspace:*", + "@emulators/aws": "workspace:*", + "@emulators/clerk": "workspace:*", "@emulators/core": "workspace:*", "@emulators/github": "workspace:*", "@emulators/gitlab": "workspace:*", + "@emulators/google": "workspace:*", "@emulators/mcp": "workspace:*", - "@emulators/apple": "workspace:*", "@emulators/microsoft": "workspace:*", - "@emulators/okta": "workspace:*", - "@emulators/aws": "workspace:*", - "@emulators/google": "workspace:*", "@emulators/mongoatlas": "workspace:*", - "@emulators/slack": "workspace:*", - "@emulators/vercel": "workspace:*", - "@emulators/resend": "workspace:*", - "@emulators/stripe": "workspace:*", - "@emulators/clerk": "workspace:*", + "@emulators/okta": "workspace:*", "@emulators/posthog": "workspace:*", + "@emulators/resend": "workspace:*", + "@emulators/slack": "workspace:*", "@emulators/spotify": "workspace:*", + "@emulators/stripe": "workspace:*", + "@emulators/vercel": "workspace:*", + "@emulators/workos": "workspace:*", "@emulators/x": "workspace:*", "tsup": "^8", - "typescript": "^5.7", - "@emulators/autumn": "workspace:*", - "@emulators/workos": "workspace:*" + "typescript": "^5.7" } } diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index f97c8b69b..69a14bf27 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -36,6 +36,7 @@ Control plane (under /_emulate on each service): GET /_emulate/connections copyable SDK, CLI, env, and curl snippets GET /_emulate/openapi OpenAPI document (when supported) Stripe also serves its OpenAPI subset at /openapi.json and /openapi.yaml. + WorkOS supports TOTP enrollment, factor listing, and one-use challenges. WorkOS supports filtered GET /user_management/users queries. Its domains support POST /organization_domains/:id/verify and the test control route POST /_emulate/organization_domains/:id/verify. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80342cff4..e1ecc60ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -826,7 +826,16 @@ importers: jose: specifier: ^6 version: 6.2.2 + otpauth: + specifier: 9.5.2 + version: 9.5.2 + qrcode: + specifier: 1.5.4 + version: 1.5.4 devDependencies: + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@workos-inc/node': specifier: ^8.13.0 version: 8.13.0 @@ -906,9 +915,15 @@ importers: openid-client: specifier: ^6.8.4 version: 6.8.4 + otpauth: + specifier: 9.5.2 + version: 9.5.2 picocolors: specifier: ^1.1.1 version: 1.1.1 + qrcode: + specifier: 1.5.4 + version: 1.5.4 resend: specifier: ^6.16.0 version: 6.16.0 @@ -2452,6 +2467,10 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@node-minify/core@8.0.6': resolution: {integrity: sha512-/vxN46ieWDLU67CmgbArEvOb41zlYFOkOtr9QW9CnTrBLuTyGgkyNWC2y5+khvRw3Br58p2B5ZVSx/PxCTru6g==} engines: {node: '>=16.0.0'} @@ -4072,6 +4091,9 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -4630,6 +4652,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} @@ -4691,6 +4717,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -5024,6 +5053,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -5079,6 +5112,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -5481,6 +5517,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -6266,6 +6306,10 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -6790,6 +6834,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + otpauth@9.5.2: + resolution: {integrity: sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -6798,10 +6845,18 @@ packages: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -6818,6 +6873,10 @@ packages: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -6897,6 +6956,10 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -6975,6 +7038,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -7153,6 +7221,13 @@ packages: engines: {node: '>= 6'} deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -7281,6 +7356,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -8080,6 +8158,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -8118,6 +8199,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8145,6 +8230,9 @@ packages: utf-8-validate: optional: true + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -8167,10 +8255,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@22.0.0: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@18.0.0: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -10035,6 +10131,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@noble/hashes@2.4.0': {} + '@node-minify/core@8.0.6': dependencies: '@node-minify/utils': 8.0.6 @@ -11788,6 +11886,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.17 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -12369,6 +12471,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001780: {} caseless@0.12.0: {} @@ -12423,6 +12527,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -12751,6 +12861,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -12799,6 +12911,8 @@ snapshots: diff@8.0.3: {} + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -13467,6 +13581,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -14354,6 +14473,10 @@ snapshots: load-tsconfig@0.2.5: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -15138,6 +15261,10 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + otpauth@9.5.2: + dependencies: + '@noble/hashes': 2.4.0 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -15146,10 +15273,18 @@ snapshots: p-finally@1.0.0: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -15168,6 +15303,8 @@ snapshots: dependencies: p-finally: 1.0.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -15236,6 +15373,8 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + pngjs@5.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -15322,6 +15461,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -15629,6 +15774,10 @@ snapshots: tunnel-agent: 0.6.0 uuid: 3.4.0 + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + reselect@5.1.1: {} resend@6.10.0: @@ -15812,6 +15961,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -16716,6 +16867,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -16765,6 +16918,12 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -16787,6 +16946,8 @@ snapshots: ws@8.21.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -16797,8 +16958,27 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@22.0.0: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@18.0.0: dependencies: cliui: 9.0.1 diff --git a/skills/workos/SKILL.md b/skills/workos/SKILL.md index da5a6f752..80320a3fb 100644 --- a/skills/workos/SKILL.md +++ b/skills/workos/SKILL.md @@ -54,3 +54,9 @@ curl -X POST "$WORKOS_EMULATOR_URL/_emulate/seed" -H "Content-Type: application/ ``` Inspect calls at `GET /_emulate/ledger`; reset with `POST /_emulate/reset`. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. + +## 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.