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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
}
},
"dependencies": {
"@hono/node-server": "^1",
"@hono/node-server": "^2.1.1",
"chalk": "^5.6.2",
"hono": "^4",
"semver": "^7.7.4",
Expand Down
30 changes: 30 additions & 0 deletions scripts/gen-shapes-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [
{ objectType: 'api_key', schemaName: 'ApiKey' },
{ objectType: 'password_reset', schemaName: 'PasswordReset' },
{ objectType: 'feature_flag', schemaName: 'Flag' },
// The secretless factor GET and LIST return. `AuthenticationFactorEnrolled` shares its
// discriminator and top-level fields; the two differ only inside `totp`, below this loop's
// depth — enrollment's secrets are pinned by the route tests instead.
{ objectType: 'authentication_factor', schemaName: 'AuthenticationFactor' },
{ objectType: 'authentication_challenge', schemaName: 'AuthenticationChallenge' },
];

export interface EnvelopeMapEntry {
Expand Down Expand Up @@ -111,13 +116,38 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [
schemaName: 'AuthorizationCheck',
},
{ method: 'GET', path: '/sso/jwks/{clientId}', status: '200', schemaName: 'JwksResponse' },
// MFA. Enrollment is the envelope that went out bare for several releases (issue #110): every
// SDK reads `{ authentication_factor, authentication_challenge }`, and none could enroll a
// factor through the emulator. The legacy `/auth` routes are resource bodies, listed for the
// same reason as the password reset above: the route is the surface the SDKs read.
{
method: 'POST',
path: '/user_management/users/{userlandUserId}/auth_factors',
status: '201',
schemaName: 'UserlandUserAuthenticationFactorEnrollResponse',
},
{ method: 'POST', path: '/auth/factors/enroll', status: '201', schemaName: 'AuthenticationFactorEnrolled' },
{ method: 'GET', path: '/auth/factors/{id}', status: '200', schemaName: 'AuthenticationFactor' },
{ method: 'POST', path: '/auth/factors/{id}/challenge', status: '201', schemaName: 'AuthenticationChallenge' },
{
method: 'POST',
path: '/auth/challenges/{id}/verify',
status: '201',
schemaName: 'AuthenticationChallengeVerifyResponse',
},
// Paginated list envelopes. Several, not one, because each is wrapped by a different
// route — a route that forgets `list_metadata` is invisible if only its neighbour is checked.
{ method: 'GET', path: '/organizations', status: '200', schemaName: 'OrganizationList' },
{ method: 'GET', path: '/user_management/users', status: '200', schemaName: 'UserlandUserList' },
{ method: 'GET', path: '/connect/applications', status: '200', schemaName: 'ConnectApplicationList' },
{ method: 'GET', path: '/webhook_endpoints', status: '200', schemaName: 'WebhookEndpointList' },
{ method: 'GET', path: '/events', status: '200', schemaName: 'EventList' },
{
method: 'GET',
path: '/user_management/users/{userlandUserId}/auth_factors',
status: '200',
schemaName: 'UserlandUserAuthenticationFactorList',
},
{
method: 'GET',
path: '/organizations/{organizationId}/api_keys',
Expand Down
8 changes: 4 additions & 4 deletions src/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ describe('end-to-end login flow (workos.com/docs story)', () => {
}),
});
expect(factorRes.status).toBe(201);
const factor = (await factorRes.json()) as any;
const { authentication_factor: factor } = (await factorRes.json()) as any;

// Step 2: Authenticate with password - should trigger MFA challenge
const passwordRes = await fetch(`${emulator.url}/user_management/authenticate`, {
Expand All @@ -363,7 +363,7 @@ describe('end-to-end login flow (workos.com/docs story)', () => {
expect(authWebhooks.length).toBe(0);

// Cleanup: Remove the MFA factor for other tests
await api(`/user_management/auth_factors/${factor.id}`, {
await api(`/auth/factors/${factor.id}`, {
method: 'DELETE',
});
});
Expand All @@ -379,7 +379,7 @@ describe('end-to-end login flow (workos.com/docs story)', () => {
}),
});
expect(factorRes.status).toBe(201);
const factor = (await factorRes.json()) as any;
const { authentication_factor: factor } = (await factorRes.json()) as any;

// Step 2: Authenticate with password to trigger MFA challenge
const passwordRes = await fetch(`${emulator.url}/user_management/authenticate`, {
Expand Down Expand Up @@ -439,7 +439,7 @@ describe('end-to-end login flow (workos.com/docs story)', () => {
expectSpecShape(authWebhook);

// Cleanup: Remove the MFA factor for other tests
await api(`/user_management/auth_factors/${factor.id}`, {
await api(`/auth/factors/${factor.id}`, {
method: 'DELETE',
});
});
Expand Down
2 changes: 2 additions & 0 deletions src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export interface WorkOSAuthenticationFactor extends Entity {
totp: {
issuer: string;
user: string;
/** Base32, as authenticator apps take it. Enrollment is the only response that shows it. */
secret: string;
uri: string;
};
}
Expand Down
40 changes: 40 additions & 0 deletions src/workos/generated/response-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record<string, ResponseShapeRequiremen
'updated_at',
],
},
authentication_challenge: {
schema: 'AuthenticationChallenge',
properties: ['authentication_factor_id', 'code', 'created_at', 'expires_at', 'id', 'object', 'updated_at'],
required: ['authentication_factor_id', 'created_at', 'id', 'object', 'updated_at'],
},
authentication_factor: {
schema: 'AuthenticationFactor',
properties: ['created_at', 'id', 'object', 'sms', 'totp', 'type', 'updated_at', 'user_id'],
required: ['created_at', 'id', 'object', 'type', 'updated_at'],
},
connection: {
schema: 'Connection',
properties: [
Expand Down Expand Up @@ -297,6 +307,11 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record<string, ResponseShapeRequiremen
* fields the route handler itself is responsible for.
*/
export const RESPONSE_ENVELOPE_REQUIREMENTS: Record<string, ResponseShapeRequirement> = {
'GET /auth/factors/{id}': {
schema: 'AuthenticationFactor',
properties: ['created_at', 'id', 'object', 'sms', 'totp', 'type', 'updated_at', 'user_id'],
required: ['created_at', 'id', 'object', 'type', 'updated_at'],
},
'GET /connect/applications': {
schema: 'ConnectApplicationList',
properties: ['data', 'list_metadata', 'object'],
Expand Down Expand Up @@ -365,6 +380,11 @@ export const RESPONSE_ENVELOPE_REQUIREMENTS: Record<string, ResponseShapeRequire
properties: ['data', 'list_metadata', 'object'],
required: ['data', 'list_metadata', 'object'],
},
'GET /user_management/users/{userlandUserId}/auth_factors': {
schema: 'UserlandUserAuthenticationFactorList',
properties: ['data', 'list_metadata', 'object'],
required: ['data', 'list_metadata', 'object'],
},
'GET /webhook_endpoints': {
schema: 'WebhookEndpointList',
properties: ['data', 'list_metadata', 'object'],
Expand All @@ -375,6 +395,21 @@ export const RESPONSE_ENVELOPE_REQUIREMENTS: Record<string, ResponseShapeRequire
properties: ['agent_registration_id', 'api_key'],
required: ['api_key'],
},
'POST /auth/challenges/{id}/verify': {
schema: 'AuthenticationChallengeVerifyResponse',
properties: ['challenge', 'valid'],
required: ['challenge', 'valid'],
},
'POST /auth/factors/{id}/challenge': {
schema: 'AuthenticationChallenge',
properties: ['authentication_factor_id', 'code', 'created_at', 'expires_at', 'id', 'object', 'updated_at'],
required: ['authentication_factor_id', 'created_at', 'id', 'object', 'updated_at'],
},
'POST /auth/factors/enroll': {
schema: 'AuthenticationFactorEnrolled',
properties: ['created_at', 'id', 'object', 'sms', 'totp', 'type', 'updated_at', 'user_id'],
required: ['created_at', 'id', 'object', 'type', 'updated_at'],
},
'POST /authorization/organization_memberships/{organization_membership_id}/check': {
schema: 'AuthorizationCheck',
properties: ['authorized'],
Expand Down Expand Up @@ -418,6 +453,11 @@ export const RESPONSE_ENVELOPE_REQUIREMENTS: Record<string, ResponseShapeRequire
properties: ['user'],
required: ['user'],
},
'POST /user_management/users/{userlandUserId}/auth_factors': {
schema: 'UserlandUserAuthenticationFactorEnrollResponse',
properties: ['authentication_challenge', 'authentication_factor'],
required: ['authentication_challenge', 'authentication_factor'],
},
'POST /widgets/token': {
schema: 'WidgetSessionTokenResponse',
properties: ['token'],
Expand Down
41 changes: 38 additions & 3 deletions src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,40 @@ export function formatMagicAuth(ma: WorkOSMagicAuth): Record<string, unknown> {
return formatEntity(ma);
}

const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/** Production validates a caller-supplied `totp_secret` against exactly this. */
export const BASE32_SECRET = /^[A-Z2-7]+=*$/;

/**
* The TOTP details an enrollment stores. Without a caller-supplied secret it mints 32 Base32
* characters — the 160 bits RFC 4226 recommends — so authenticator apps and TOTP libraries
* accept the secret verbatim; `uri` is the otpauth form those apps import.
*/
export function newTotp(issuer: string, user: string, secret?: string): WorkOSAuthenticationFactor['totp'] {
secret ??= Array.from(randomBytes(32), (b) => BASE32_ALPHABET[b & 31]).join('');
const issuerParam = encodeURIComponent(issuer);
return {
issuer,
user,
secret,
uri: `otpauth://totp/${issuerParam}:${encodeURIComponent(user)}?secret=${secret}&issuer=${issuerParam}`,
};
}

// ponytail: a valid 1×1 PNG, not a scannable code — the spec requires the field and SDKs require a
// string, and the emulator never checks a real TOTP code. Add a QR encoder if a consumer's UI
// test needs to scan it; `uri` already carries everything the code would.
const TOTP_QR_CODE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';

/** The spec's AuthenticationFactor: what GET and LIST return, secrets stripped. */
export function formatAuthFactor(f: WorkOSAuthenticationFactor): Record<string, unknown> {
return formatEntity(f);
return { ...formatEntity(f), totp: { issuer: f.totp.issuer, user: f.totp.user } };
}

/** The spec's AuthenticationFactorEnrolled: the one response that shows the secrets. */
export function formatAuthFactorEnrolled(f: WorkOSAuthenticationFactor): Record<string, unknown> {
return { ...formatEntity(f), totp: { ...f.totp, qr_code: TOTP_QR_CODE } };
}

/**
Expand Down Expand Up @@ -905,10 +937,13 @@ export function assertAllowedRedirectUri(uri: string, store: Store): void {
);
}

const AUTH_CHALLENGE_EXCLUDE = new Set([...INTERNAL_FIELDS, 'code']);
// `code` stays server-side as production keeps a TOTP challenge's; `user_id` and `factor_id`
// are the store's join columns — the spec's challenge names its factor `authentication_factor_id`
// and carries no user at all.
const AUTH_CHALLENGE_EXCLUDE = new Set([...INTERNAL_FIELDS, 'code', 'user_id', 'factor_id']);

export function formatAuthChallenge(c: WorkOSAuthenticationChallenge): Record<string, unknown> {
return formatEntity(c, { exclude: AUTH_CHALLENGE_EXCLUDE });
return { ...formatEntity(c, { exclude: AUTH_CHALLENGE_EXCLUDE }), authentication_factor_id: c.factor_id };
}

export function formatRole(role: WorkOSRole, ws: WorkOSStore): Record<string, unknown> {
Expand Down
14 changes: 3 additions & 11 deletions src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { connectionRoutes } from './routes/connections.js';
import { ssoRoutes } from './routes/sso.js';
import { pipeRoutes } from './routes/pipes.js';
import { connectedAccountRoutes } from './routes/connected-accounts.js';
import { authChallengeRoutes } from './routes/auth-challenges.js';
import { invitationRoutes } from './routes/invitations.js';
import { configRoutes } from './routes/config.js';
import { userFeatureRoutes } from './routes/user-features.js';
Expand Down Expand Up @@ -79,6 +78,7 @@ import {
formatConnectedAccountEvent,
dataIntegrationIdFor,
linkOAuthIdentity,
newTotp,
} from './helpers.js';
import type {
WorkOSConnectionType,
Expand Down Expand Up @@ -466,20 +466,13 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee
}

// 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.
// password grant challenges it like any enrolled second factor.
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)}`,
},
totp: newTotp('WorkOS Emulator', user.email),
});
}
}
Expand Down Expand Up @@ -922,7 +915,6 @@ export const workosPlugin: ServicePlugin = {
passwordResetRoutes(ctx);
magicAuthRoutes(ctx);
authFactorRoutes(ctx);
authChallengeRoutes(ctx);
sessionRoutes(ctx);
authRoutes(ctx);
connectionRoutes(ctx);
Expand Down
42 changes: 39 additions & 3 deletions src/workos/response-envelopes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
* the coverage test, so a catalog entry can't sit unexercised.
*
* Scope: response *bodies*, not status codes. Some routes return 200 where the
* spec says 201 (`/portal/generate_link`, `/widgets/token`); status conformance
* is a separate axis and this loop only requires a 2xx.
* spec says 201 (`/portal/generate_link`, `/widgets/token`,
* `/auth/challenges/{id}/verify`); status conformance is a separate axis and
* this loop only requires a 2xx.
*/
import { describe, it, expect, beforeAll } from 'bun:test';
import { createServer, type ApiKeyMap } from '../core/index.js';
Expand All @@ -49,6 +50,8 @@ interface Fixtures {
clientId: string;
passwordResetToken: string;
passwordResetId: string;
factorId: string;
challengeId: string;
}

/** Each case names a catalog operation and returns that operation's live response body. */
Expand Down Expand Up @@ -108,6 +111,24 @@ const CASES: readonly EnvelopeCase[] = [
operation: 'GET /sso/jwks/{clientId}',
request: (app, f) => get(`/sso/jwks/${f.clientId}`)(app),
},
{
operation: 'POST /user_management/users/{userlandUserId}/auth_factors',
request: (app, f) => post(`/user_management/users/${f.userId}/auth_factors`, { type: 'totp' })(app),
},
{
operation: 'GET /user_management/users/{userlandUserId}/auth_factors',
request: (app, f) => get(`/user_management/users/${f.userId}/auth_factors`)(app),
},
{ operation: 'POST /auth/factors/enroll', request: post('/auth/factors/enroll', { type: 'totp' }) },
{ operation: 'GET /auth/factors/{id}', request: (app, f) => get(`/auth/factors/${f.factorId}`)(app) },
{
operation: 'POST /auth/factors/{id}/challenge',
request: (app, f) => post(`/auth/factors/${f.factorId}/challenge`)(app),
},
{
operation: 'POST /auth/challenges/{id}/verify',
request: (app, f) => post(`/auth/challenges/${f.challengeId}/verify`, { code: '123456' })(app),
},
{ operation: 'GET /organizations', request: get('/organizations') },
{ operation: 'GET /user_management/users', request: get('/user_management/users') },
{ operation: 'GET /connect/applications', request: get('/connect/applications') },
Expand Down Expand Up @@ -164,7 +185,9 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => {
const server = createServer(workosPlugin, { port: 0, baseUrl: BASE_URL, apiKeys });
seedFromConfig(server.store, BASE_URL, {
organizations: [{ name: 'Acme Corp' }],
users: [{ email: 'alice@acme.com', password: 'secret123' }],
// A seeded factor, so the factor list is a non-empty page whatever order the cases run in,
// and the legacy `/auth/factors/{id}` routes have a factor to read and challenge.
users: [{ email: 'alice@acme.com', password: 'secret123', totp: true }],
permissions: [{ slug: 'posts:read', name: 'Read Posts' }],
roles: [{ slug: 'member', name: 'Member', permissions: ['posts:read'] }],
// Subscribed to an event this test never triggers, not the catch-all `[]`. Webhook
Expand Down Expand Up @@ -207,13 +230,26 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => {
const passwordResetToken = insertPasswordReset('pw_reset_envelope').password_reset_token;
const passwordResetId = insertPasswordReset('pw_reset_envelope_get').id;

// Verify spends the challenge it is handed, so it gets one of its own with a known code
// rather than the one the challenge case creates.
const factorId = ws.authFactors.findOneBy('user_id', userId)!.id;
const challengeId = ws.authChallenges.insert({
object: 'authentication_challenge',
user_id: userId,
factor_id: factorId,
expires_at: new Date(Date.now() + 600_000).toISOString(),
code: '123456',
}).id;

const fixtures: Fixtures = {
organizationId,
userId,
membershipId,
clientId: 'client_billing',
passwordResetToken,
passwordResetId,
factorId,
challengeId,
};

for (const { operation, request } of CASES) {
Expand Down
Loading
Loading