From 82ffd5fb2c3d0bc15bb5b325ae05ed371a093f61 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 27 Jul 2026 14:48:35 -0400 Subject: [PATCH 1/8] fix(oauth): emit space-delimited scope and jti on M2M tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real WorkOS M2M token carries its granted scopes as a space-delimited `scope` string and always carries a `jti`. The emulator emitted a `scp` array and no `jti` at all, so a service that authorized on scopes — or verified the token through a WorkOS SDK, which reads `payload.scope` and rejects an M2M token missing `jti` — passed locally and failed against the real API. That is the failure mode an emulator exists to prevent. Reported in #26. --- README.md | 14 +++++++++++--- src/core/id.ts | 12 ++++++++++-- src/core/index.ts | 2 +- src/core/jwt.ts | 10 ++++++++-- src/workos/routes/oauth.spec.ts | 19 +++++++++++++++++-- src/workos/routes/oauth.ts | 14 ++++++++++---- 6 files changed, 57 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1bf4dcd..a62a731 100644 --- a/README.md +++ b/README.md @@ -328,11 +328,19 @@ The `access_token` is an RS256 JWT signed with the same key the emulator publish | `iss` | the emulator base URL (e.g. `http://localhost:4100`) | | `aud` | the app's `audience` if set, otherwise the `client_id` | | `sub` | the requesting `client_id` | -| `scp` | granted scopes (array) | +| `jti` | a unique token identifier (ULID) | +| `scope` | granted scopes, space-delimited | | `org_id` | the application's owning organization | -Set `audience` on the seeded application to match what your real WorkOS environment emits, so a -consumer that validates the `aud` claim accepts emulator tokens unchanged. +The claim set mirrors a production M2M token, because the SDKs parse it: scopes are a +space-delimited `scope` **string** (not an array, and not `scp`), and `jti` is always present — +the WorkOS SDKs reject an M2M token that lacks it, however well-signed. + +> **Set `audience` on the seeded application.** In production `aud` is your environment's client +> ID, which is _not_ the M2M application's `client_id` — and the SDKs default the expected +> audience to the environment client ID. The emulator has no environment-level client ID to fall +> back on, so it uses the requesting `client_id`. Pin `audience` to the value your real WorkOS +> environment emits and a consumer that validates `aud` accepts emulator tokens unchanged. A request may narrow to a subset of the application's scopes via `-d scope="posts:read"`; requesting a scope the application does not have returns `400 invalid_scope`, so scope-based diff --git a/src/core/id.ts b/src/core/id.ts index bf84fc7..882be93 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -5,7 +5,11 @@ const RANDOM_LEN = 16; // 16 chars of randomness let lastTime = 0; -export function generateId(prefix: string): string { +/** + * A bare, monotonic ULID with no resource prefix — for identifiers that are not + * resource ids, e.g. a JWT `jti`, which production emits unprefixed. + */ +export function generateUlid(): string { let now = Date.now(); if (now <= lastTime) { now = lastTime + 1; @@ -24,7 +28,11 @@ export function generateId(prefix: string): string { randStr += ENCODING[Math.floor(Math.random() * ENCODING_LEN)]; } - return `${prefix}_${timeStr}${randStr}`; + return `${timeStr}${randStr}`; +} + +export function generateId(prefix: string): string { + return `${prefix}_${generateUlid()}`; } export function resetIdState(): void { diff --git a/src/core/index.ts b/src/core/index.ts index abb5cb6..efa68f5 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,7 +7,7 @@ export { type SortFn, type CollectionHooks, } from './store.js'; -export { generateId, resetIdState, ID_PREFIXES } from './id.js'; +export { generateId, generateUlid, resetIdState, ID_PREFIXES } from './id.js'; export { parseListParams, cursorPaginate, diff --git a/src/core/jwt.ts b/src/core/jwt.ts index 679ca25..4a79c20 100644 --- a/src/core/jwt.ts +++ b/src/core/jwt.ts @@ -6,8 +6,14 @@ export interface JWTPayload { org_id?: string; role?: string; permissions?: string[]; - /** OAuth scopes granted to an M2M (client_credentials) token. */ - scp?: string[]; + /** + * OAuth scopes granted to an M2M (client_credentials) token: space-delimited, + * per RFC 8693 §4.2 — the claim name and encoding production emits, and the one + * the WorkOS SDKs read. Not an array, and not `scp`. + */ + scope?: string; + /** Unique token identifier. Required on M2M tokens by the WorkOS SDKs. */ + jti?: string; iss: string; aud: string; exp: number; diff --git a/src/workos/routes/oauth.spec.ts b/src/workos/routes/oauth.spec.ts index a58e924..130bd83 100644 --- a/src/workos/routes/oauth.spec.ts +++ b/src/workos/routes/oauth.spec.ts @@ -66,8 +66,23 @@ describe('OAuth M2M token routes', () => { expect(claims.sub).toBe('client_billing'); expect(claims.aud).toBe('client_billing'); expect(claims.iss).toBe('http://localhost:0'); - expect(claims.scp).toEqual(['invoices:read', 'invoices:write']); + // Space-delimited `scope` string, not a `scp` array — what production emits and what + // the SDKs read. An array here would pass locally and break against the real API. + expect(claims.scope).toBe('invoices:read invoices:write'); expect(claims.org_id).toMatch(/^org_/); + // The SDKs' M2M claim guard requires `jti`; without it a valid token reads as invalid. + expect(claims.jti).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + }); + + it('gives each token a distinct jti', async () => { + const credentials = { + grant_type: 'client_credentials', + client_id: 'client_billing', + client_secret: 'secret_billing_value', + }; + const first = jwt.verify((await json(await form(credentials))).access_token); + const second = jwt.verify((await json(await form(credentials))).access_token); + expect(first.jti).not.toBe(second.jti); }); it('accepts a JSON body', async () => { @@ -108,7 +123,7 @@ describe('OAuth M2M token routes', () => { expect(res.status).toBe(200); const body = await json(res); expect(body.scope).toBe('invoices:read'); - expect(jwt.verify(body.access_token).scp).toEqual(['invoices:read']); + expect(jwt.verify(body.access_token).scope).toBe('invoices:read'); }); it('ignores a caller-supplied organization_id; the token org is the application org', async () => { diff --git a/src/workos/routes/oauth.ts b/src/workos/routes/oauth.ts index 2891fe6..86bd0e5 100644 --- a/src/workos/routes/oauth.ts +++ b/src/workos/routes/oauth.ts @@ -1,5 +1,5 @@ import type { Context } from 'hono'; -import { type RouteContext } from '../../core/index.js'; +import { type RouteContext, generateUlid } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; /** @@ -15,8 +15,13 @@ import { getWorkOSStore } from '../store.js'; * of type `m2m`, see the `connectApplications` seed block) for a signed JWT. The token * is signed with the same key the emulator exposes at `/sso/jwks/:client_id` and * `/oauth2/jwks`, so a consumer validating with JWKS (e.g. `jose`, checking `iss`/`aud`) - * verifies it without any emulator-specific shims. Granted scopes ride in the `scp` - * claim so scope-based authorization can be exercised locally. + * verifies it without any emulator-specific shims. + * + * The claim set mirrors a production M2M token exactly, because the SDKs parse it: + * granted scopes ride in a space-delimited `scope` string (RFC 8693 §4.2 — the Node + * SDK reads `payload.scope`), and every token carries a `jti`, without which the SDKs' + * M2M claim guard rejects an otherwise-valid token. Neither is emulator-flavored: a + * scopes *array*, or an omitted `jti`, would pass locally and fail in production. */ const TOKEN_TTL_SECONDS = 3600; @@ -149,8 +154,9 @@ export function oauthRoutes(ctx: RouteContext): void { { sub: clientId, aud: application.audience ?? clientId, + jti: generateUlid(), org_id: application.organization_id ?? undefined, - scp: granted, + scope: granted.join(' '), }, { expiresIn: TOKEN_TTL_SECONDS }, ); From 5d34dd37a4700b2aa48ceacc311f33ff924ccc1d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 27 Jul 2026 14:48:52 -0400 Subject: [PATCH 2/8] fix(api-keys): return the api_key object from validations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation read `key` from the request body and answered `{ valid: boolean }`. The spec sends the secret as `value` and returns `{ api_key: ApiKey | null }`, which is what every SDK reads — so the request field never matched, the emulator saw no key at all, and callers got a shape they do not parse. The net effect was that no API key could be validated locally, and a key's permissions were unreachable even when it was. Returning the resource rather than a boolean also gives callers the `permissions` array, so permission-based authorization can be exercised against the emulator. A value registered only through the legacy allow-list map has no resource behind it and now validates as `api_key: null` rather than `true`: it can authenticate requests, but the emulator does not know its owner or permissions, and inventing them would report privileges that do not exist. Reported in #26. --- README.md | 34 ++++++++++++- src/workos/routes/api-keys.spec.ts | 76 ++++++++++++++++++++++-------- src/workos/routes/api-keys.ts | 20 +++++--- src/workos/seed-m2m.spec.ts | 17 ++++--- 4 files changed, 113 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index a62a731..391ec79 100644 --- a/README.md +++ b/README.md @@ -369,13 +369,45 @@ apiKeys: curl http://localhost:4100/connect/applications -H "Authorization: Bearer sk_test_ci_key" ``` +Validate a key the way the SDKs do — `POST /api_keys/validations` with the key in `value`: + +```bash +curl -X POST http://localhost:4100/api_keys/validations \ + -H "Authorization: Bearer sk_test_ci_key" -H "Content-Type: application/json" \ + -d '{"value":"sk_test_ci_key"}' +``` + +```json +{ + "api_key": { + "object": "api_key", + "id": "api_key_01K...", + "name": "CI Key", + "owner": { "type": "organization", "id": "org_01K..." }, + "obfuscated_value": "sk_..._key", + "permissions": ["posts:read", "posts:write"], + "last_used_at": null, + "expires_at": null, + "created_at": "2026-01-15T12:00:00.000Z", + "updated_at": "2026-01-15T12:00:00.000Z" + } +} +``` + +A valid key returns the whole `api_key` object — `permissions` included, so permission-based +authorization can be exercised locally. An invalid, expired, or unknown key is `200` with +`{"api_key": null}`, not an error — matching production and what the SDKs read. The raw value is +never echoed back; only `obfuscated_value`. + The `organization` (or the org supplied via `user_id`) must reference a seeded organization; an unresolved name fails fast at startup. A key seeded with an already-past `expires_at` is still created as a resource but does **not** authenticate, and deleting a key via `DELETE /api_keys/:id` stops it authenticating immediately — matching production. `apiKeys` also accepts the legacy auth allow-list map form (`{ sk_xxx: { environment } }`), which -only registers values for authentication without creating resources. +only registers values for authentication without creating resources. A map-form value authenticates +requests but has no `api_key` resource behind it, so validating one returns `{"api_key": null}` — +use the array form for keys your code validates. ## Testing Your Login Flow End-to-End diff --git a/src/workos/routes/api-keys.spec.ts b/src/workos/routes/api-keys.spec.ts index ebe15d8..e6ee8ed 100644 --- a/src/workos/routes/api-keys.spec.ts +++ b/src/workos/routes/api-keys.spec.ts @@ -11,6 +11,15 @@ function createTestApp() { return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); } +/** + * A server with its own allow-list object. The map is passed to the auth middleware by + * reference, so tests that register keys at runtime need a copy of their own rather than + * the shared `apiKeys` const, which would leak mutations into every other test. + */ +function createAppWithKeys(keys: ApiKeyMap) { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys: { ...keys } }); +} + describe('API Keys routes', () => { let app: ReturnType['app']; let store: Store; @@ -24,33 +33,60 @@ describe('API Keys routes', () => { const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); const json = (res: Response) => res.json() as Promise; - it('validates a known API key', async () => { + it('returns the whole api_key object, permissions included, for a valid key', async () => { + const server = createAppWithKeys({ sk_test_full: { environment: 'test' } }); + const record = insertKey(getWorkOSStore(server.store), 'CI Key', 'sk_test_full', ['posts:read', 'posts:write']); + + const res = await server.app.request('/api_keys/validations', { + method: 'POST', + headers: { Authorization: 'Bearer sk_test_full', 'Content-Type': 'application/json' }, + // The spec's ValidateApiKeyDto field is `value`. + body: JSON.stringify({ value: 'sk_test_full' }), + }); + expect(res.status).toBe(200); + const body = await json(res); + expect(body.api_key.id).toBe(record.id); + expect(body.api_key.object).toBe('api_key'); + expect(body.api_key.name).toBe('CI Key'); + // The reason a caller validates at all: the key's privileges. + expect(body.api_key.permissions).toEqual(['posts:read', 'posts:write']); + // Validation must not leak the secret it was handed back to the caller. + expect(body.api_key.obfuscated_value).toBe('sk_...full'); + expect(body.api_key.key).toBeUndefined(); + }); + + it('returns api_key: null for an unknown API key', async () => { const res = await req('/api_keys/validations', { method: 'POST', - body: JSON.stringify({ key: 'sk_test_org' }), + body: JSON.stringify({ value: 'sk_unknown' }), }); + // An invalid key is a 200 with an explicit null, not an error. expect(res.status).toBe(200); - expect((await json(res)).valid).toBe(true); + expect(await json(res)).toEqual({ api_key: null }); }); - it('rejects an unknown API key', async () => { + it('returns api_key: null for an allow-list key with no resource behind it', async () => { + // The map form registers a value for authentication without creating a resource. + // There is no ApiKey to return, and synthesizing an owner or permission set would + // report privileges the emulator does not hold. const res = await req('/api_keys/validations', { method: 'POST', - body: JSON.stringify({ key: 'sk_unknown' }), + body: JSON.stringify({ value: 'sk_test_org' }), }); expect(res.status).toBe(200); - expect((await json(res)).valid).toBe(false); + expect(await json(res)).toEqual({ api_key: null }); }); it('enforces allow-list key expiry for both auth and validation', async () => { - const expiredApp = createServer(workosPlugin, { - port: 0, - baseUrl: 'http://localhost:0', - apiKeys: { - sk_test_expired: { environment: 'test', expiresAt: '2000-01-01T00:00:00.000Z' }, - sk_test_future: { environment: 'test', expiresAt: '2999-01-01T00:00:00.000Z' }, - }, - }).app; + const server = createAppWithKeys({ + sk_test_expired: { environment: 'test', expiresAt: '2000-01-01T00:00:00.000Z' }, + sk_test_future: { environment: 'test', expiresAt: '2999-01-01T00:00:00.000Z' }, + }); + const expiredApp = server.app; + // Both keys resolve to a resource, so expiry is the only thing under test. + const ws = getWorkOSStore(server.store); + insertKey(ws, 'expired', 'sk_test_expired'); + insertKey(ws, 'future', 'sk_test_future'); const hdr = (k: string) => ({ Authorization: `Bearer ${k}`, 'Content-Type': 'application/json' }); @@ -65,12 +101,12 @@ describe('API Keys routes', () => { await expiredApp.request('/api_keys/validations', { method: 'POST', headers: hdr('sk_test_future'), - body: JSON.stringify({ key: k }), + body: JSON.stringify({ value: k }), }) ).json()) as any - ).valid; - expect(await v('sk_test_expired')).toBe(false); - expect(await v('sk_test_future')).toBe(true); + ).api_key; + expect(await v('sk_test_expired')).toBeNull(); + expect(await v('sk_test_future')).not.toBeNull(); }); it('fails closed on a malformed expiry timestamp', async () => { @@ -84,14 +120,14 @@ describe('API Keys routes', () => { expect((await badExpiryApp.request('/connect/applications', { headers: hdr })).status).toBe(401); }); - const insertKey = (ws: ReturnType, name: string, key: string) => + const insertKey = (ws: ReturnType, name: string, key: string, permissions: string[] = []) => ws.apiKeyRecords.insert({ object: 'api_key', name, key, environment: 'test', owner: { type: 'organization', id: 'org_123' }, - permissions: [], + permissions, last_used_at: null, expires_at: null, }); diff --git a/src/workos/routes/api-keys.ts b/src/workos/routes/api-keys.ts index 57d56c3..36fc3e8 100644 --- a/src/workos/routes/api-keys.ts +++ b/src/workos/routes/api-keys.ts @@ -8,16 +8,24 @@ export function apiKeyRoutes(ctx: RouteContext): void { const { app, store } = ctx; const ws = getWorkOSStore(store); - // Validate an API key + // Validate an API key. Request and response both follow the spec exactly + // (`ValidateApiKeyDto` in, `ApiKeyValidationResponse` out): the caller sends `value`, + // and a valid key returns the whole `api_key` object — including its `permissions`, so + // permission-based authorization can be exercised locally. An invalid key is an + // explicit `api_key: null`, not an error, matching production and what the SDKs read. app.post('/api_keys/validations', async (c) => { const body = await parseJsonBody(c); - const key = body.key as string | undefined; + const value = body.value as string | undefined; const apiKeyMap = store.getData(STORE_KEYS.apiKeyMap) ?? {}; - const entry = key ? apiKeyMap[key] : undefined; - // A key is valid only if it is in the allow-list and not past its expiry — the same + const entry = value ? apiKeyMap[value] : undefined; + // A key validates only if it is in the allow-list and not past its expiry — the same // test the auth middleware applies, so validation and real-request auth agree. - const valid = !!entry && !isApiKeyEntryExpired(entry); - return c.json({ valid }); + const authorized = !!entry && !isApiKeyEntryExpired(entry); + // The allow-list map form registers a value for authentication without creating a + // resource; there is no ApiKey object to return for one, and inventing an owner or + // permission set would report privileges the emulator does not actually hold. + const record = authorized && value ? ws.apiKeyRecords.findOneBy('key', value) : undefined; + return c.json({ api_key: record ? formatApiKeyRecord(record) : null }); }); // Delete an API key record diff --git a/src/workos/seed-m2m.spec.ts b/src/workos/seed-m2m.spec.ts index f0d51df..5017d48 100644 --- a/src/workos/seed-m2m.spec.ts +++ b/src/workos/seed-m2m.spec.ts @@ -82,14 +82,17 @@ describe('Seeding M2M applications and API keys', () => { }, }); - // The seeded value is registered in the auth allow-list, so it authenticates. + // The seeded value is registered in the auth allow-list, so it authenticates — and + // validation hands back the resource, permissions included. const validateRes = await fetch(`${emulator.url}/api_keys/validations`, { method: 'POST', headers: auth('sk_test_ci'), - body: JSON.stringify({ key: 'sk_test_ci' }), + body: JSON.stringify({ value: 'sk_test_ci' }), }); expect(validateRes.status).toBe(200); - expect(((await validateRes.json()) as any).valid).toBe(true); + const validated = ((await validateRes.json()) as any).api_key; + expect(validated.name).toBe('CI Key'); + expect(validated.permissions).toEqual(['posts:read']); // And it appears as a spec-aligned api_key resource. const oid = await firstOrgId('sk_test_ci'); @@ -226,9 +229,9 @@ describe('Seeding M2M applications and API keys', () => { const validateRes = await fetch(`${emulator.url}/api_keys/validations`, { method: 'POST', headers: auth('sk_test_live2'), - body: JSON.stringify({ key: 'sk_test_expired' }), + body: JSON.stringify({ value: 'sk_test_expired' }), }); - expect(((await validateRes.json()) as any).valid).toBe(false); + expect(((await validateRes.json()) as any).api_key).toBeNull(); const oid = await firstOrgId('sk_test_live2'); const listRes = await fetch(`${emulator.url}/organizations/${oid}/api_keys`, { headers: auth('sk_test_live2') }); @@ -279,9 +282,9 @@ describe('Seeding M2M applications and API keys', () => { const validateRes = await fetch(`${emulator.url}/api_keys/validations`, { method: 'POST', headers: auth('sk_test_reset'), - body: JSON.stringify({ key: 'sk_test_reset' }), + body: JSON.stringify({ value: 'sk_test_reset' }), }); - expect(((await validateRes.json()) as any).valid).toBe(true); + expect(((await validateRes.json()) as any).api_key).not.toBeNull(); }); }); From 0d4e62444f7a8419d10a5b9f94cf5f26f7506c39 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 27 Jul 2026 14:49:03 -0400 Subject: [PATCH 3/8] fix(sso): serve JWKS from the per-client path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only a bare `/sso/jwks` was registered, and Hono does not match that against `/sso/jwks/{clientId}` — the path the spec documents and the one the SDKs fetch when verifying a session or M2M access token. So every SDK-based verification against the emulator got a 404 while the key it needed was sitting one path segment away. The README has documented the per-client path all along; the route simply never existed. --- src/workos/routes/sso.spec.ts | 8 ++++++++ src/workos/routes/sso.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index e56bdb4..7746e03 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -98,6 +98,14 @@ describe('SSO routes', () => { expect(body.keys[0].alg).toBe('RS256'); }); + // The per-client path is the one the spec documents and the SDKs fetch when verifying a + // session or M2M token. A bare /sso/jwks does not match it, so it is registered too. + it('serves the same JWKS from the per-client path the SDKs fetch', async () => { + const res = await app.request('/sso/jwks/client_01ABCDEF'); + expect(res.status).toBe(200); + expect(await json(res)).toEqual(await json(await app.request('/sso/jwks'))); + }); + it('sso authorize rejects non-localhost redirect_uri', async () => { const { conn } = await createOrgWithConnection(); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index ff17407..f1b6e43 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -1,3 +1,4 @@ +import type { Context } from 'hono'; import { type RouteContext, parseJsonBody, WorkOSApiError, generateId } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatSSOProfile, expiresIn, isExpired, assertLocalRedirectUri, emitAuthenticationEvent } from '../helpers.js'; @@ -248,9 +249,13 @@ export function ssoRoutes(ctx: RouteContext): void { return c.json(formatSSOProfile(profile)); }); - app.get('/sso/jwks', (c) => { - return c.json(jwt.getJWKS()); - }); + // The environment's JWKS. The spec only documents the per-client path, which is what the + // SDKs fetch (`/sso/jwks/{clientId}`) when verifying a session or M2M access token — a + // bare `/sso/jwks` does not match it in Hono, so both are registered. Every token the + // emulator issues is signed with the one key, so the client is not used to select a key. + const jwks = (c: Context) => c.json(jwt.getJWKS()); + app.get('/sso/jwks', jwks); + app.get('/sso/jwks/:clientId', jwks); // SSO Single Logout — generate logout token app.post('/sso/logout/authorize', async (c) => { From f3ffd8fd49a521130b5ea83fcca37435cb0a11a6 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 27 Jul 2026 14:49:18 -0400 Subject: [PATCH 4/8] test(shapes): check response envelopes against the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing conformance loop keys on a resource's `object` discriminator, which means the envelope a route wraps a resource in was never checked — envelopes have no discriminator, and they are assembled inline in the handler rather than by a format* helper. That blind spot is exactly how `/api_keys/validations` shipped `{ valid }` for several releases against a spec saying `{ api_key }`: the resource catalog had nothing to say about it. Envelope requirements are extracted per operation from the spec's own `paths` entry, and extraction fails loudly when an operation's declared `$ref` stops matching the curated schema name — the counterpart to the discriminator guard, so a spec rename cannot leave the test asserting a contract that no longer exists. Coverage is curated because each case has to issue a real request, so the catalog asserts it is exercised exactly, and divergences live in ledgers as exact field sets rather than being tolerated silently. Adding `api_key` to the resource catalog closes a second gap of the same kind: a credential-bearing resource that had no shape or secret-leak coverage. --- scripts/gen-shapes-lib.spec.ts | 131 ++++++++++++- scripts/gen-shapes-lib.ts | 201 ++++++++++++++++++-- scripts/gen-shapes.ts | 7 +- src/workos/generated/response-shapes.ts | 112 +++++++++++- src/workos/response-envelopes.spec.ts | 234 ++++++++++++++++++++++++ src/workos/response-shapes.spec.ts | 33 +++- 6 files changed, 689 insertions(+), 29 deletions(-) create mode 100644 src/workos/response-envelopes.spec.ts diff --git a/scripts/gen-shapes-lib.spec.ts b/scripts/gen-shapes-lib.spec.ts index 7a0c115..ee8a595 100644 --- a/scripts/gen-shapes-lib.spec.ts +++ b/scripts/gen-shapes-lib.spec.ts @@ -2,15 +2,32 @@ import { describe, it, expect } from 'bun:test'; import { resolveSchema, extractShape, + extractEnvelope, parseShapeCatalog, + parseEnvelopeCatalog, generateShapesFile, type ShapeMapEntry, + type EnvelopeMapEntry, } from './gen-shapes-lib.js'; import type { EventSchemaNode } from './gen-events-lib.js'; function spec(schemas: Record): EventSchemaNode { return { components: { schemas } } as unknown as EventSchemaNode; } + +/** A spec with both component schemas and paths, for the envelope catalog. */ +function specWithPaths(schemas: Record, paths: Record): EventSchemaNode { + return { components: { schemas }, paths } as unknown as EventSchemaNode; +} + +/** A minimal `responses` block pointing a status at a component schema. */ +function jsonResponse(status: string, schemaName: string): Record { + return { + responses: { + [status]: { content: { 'application/json': { schema: { $ref: `#/components/schemas/${schemaName}` } } } }, + }, + }; +} function schema(s: EventSchemaNode, name: string): EventSchemaNode { return (s as { components: { schemas: Record } }).components.schemas[name]; } @@ -116,14 +133,122 @@ describe('parseShapeCatalog', () => { }); }); +describe('extractEnvelope', () => { + const envelopeSpec = specWithPaths( + { + WidgetValidation: { + type: 'object', + properties: { widget: { type: 'object' }, trace_id: { type: 'string' } }, + required: ['widget'], + }, + }, + { '/widgets/validations': { post: jsonResponse('200', 'WidgetValidation') } }, + ); + const entry: EnvelopeMapEntry = { + method: 'POST', + path: '/widgets/validations', + status: '200', + schemaName: 'WidgetValidation', + }; + + it('extracts sorted top-level properties and required from the declared response schema', () => { + const envelope = extractEnvelope(entry, envelopeSpec); + expect(envelope.operation).toBe('POST /widgets/validations'); + expect(envelope.properties).toEqual(['trace_id', 'widget']); + expect(envelope.required).toEqual(['widget']); + }); + + it('throws when the path is absent from the spec', () => { + expect(() => extractEnvelope({ ...entry, path: '/nope' }, envelopeSpec)).toThrow(/not found in spec paths/); + }); + + it('throws when the path declares no such method', () => { + expect(() => extractEnvelope({ ...entry, method: 'GET' }, envelopeSpec)).toThrow(/no GET operation/); + }); + + it('throws when the status has no application/json schema', () => { + expect(() => extractEnvelope({ ...entry, status: '404' }, envelopeSpec)).toThrow(/no application\/json schema/); + }); + + // The envelope counterpart to the object-discriminator guard: a spec rename, or an + // operation repointed at another schema, must fail rather than silently leave the + // consuming test asserting the previous contract. + it('throws when the operation declares a different schema than the one mapped', () => { + expect(() => extractEnvelope({ ...entry, schemaName: 'SomethingElse' }, envelopeSpec)).toThrow( + /declares response schema WidgetValidation, expected "SomethingElse"/, + ); + }); + + it('de-duplicates a field two allOf members both mark required', () => { + const s = specWithPaths( + { + Base: { type: 'object', properties: { id: {} }, required: ['id'] }, + Merged: { + allOf: [{ $ref: '#/components/schemas/Base' }, { type: 'object', properties: {}, required: ['id'] }], + } as unknown as EventSchemaNode, + }, + { '/merged': { get: jsonResponse('200', 'Merged') } }, + ); + const envelope = extractEnvelope({ method: 'GET', path: '/merged', status: '200', schemaName: 'Merged' }, s); + expect(envelope.required).toEqual(['id']); + }); + + it('throws when the response schema is inline rather than a $ref', () => { + const inline = specWithPaths( + { WidgetValidation: { type: 'object', properties: { widget: {} } } }, + { + '/widgets/validations': { + post: { responses: { '200': { content: { 'application/json': { schema: { type: 'object' } } } } } }, + }, + }, + ); + expect(() => extractEnvelope(entry, inline)).toThrow(/\(inline\)/); + }); +}); + +describe('parseEnvelopeCatalog', () => { + it('extracts each map entry and sorts by operation', () => { + const s = specWithPaths( + { + Beta: { type: 'object', properties: { b: {} } }, + Alpha: { type: 'object', properties: { a: {} } }, + }, + { + '/beta': { get: jsonResponse('200', 'Beta') }, + '/alpha': { get: jsonResponse('200', 'Alpha') }, + }, + ); + const map: EnvelopeMapEntry[] = [ + { method: 'GET', path: '/beta', status: '200', schemaName: 'Beta' }, + { method: 'GET', path: '/alpha', status: '200', schemaName: 'Alpha' }, + ]; + expect(parseEnvelopeCatalog(s, map).map((e) => e.operation)).toEqual(['GET /alpha', 'GET /beta']); + }); +}); + describe('generateShapesFile', () => { + const out = generateShapesFile( + [{ objectType: 'widget', schemaName: 'Widget', properties: ['id', 'object'], required: ['id'] }], + [ + { + operation: 'POST /widgets/validations', + schemaName: 'WidgetValidation', + properties: ['widget'], + required: ['widget'], + }, + ], + ); + it('emits a RESPONSE_SHAPE_REQUIREMENTS record keyed by object type', () => { - const out = generateShapesFile([ - { objectType: 'widget', schemaName: 'Widget', properties: ['id', 'object'], required: ['id'] }, - ]); expect(out).toContain('export const RESPONSE_SHAPE_REQUIREMENTS'); expect(out).toContain('widget: {'); expect(out).toContain("schema: 'Widget'"); expect(out).toContain('do not edit by hand'); }); + + it('emits a RESPONSE_ENVELOPE_REQUIREMENTS record keyed by quoted operation', () => { + expect(out).toContain('export const RESPONSE_ENVELOPE_REQUIREMENTS'); + expect(out).toContain("'POST /widgets/validations': {"); + expect(out).toContain("schema: 'WidgetValidation'"); + }); }); diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index 496cb17..0adbc8f 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -2,17 +2,30 @@ * Core codegen logic for gen-shapes. Separated from the CLI entry point so the * transformation functions can be unit-tested independently. * - * Extracts per-resource response *shapes* (property + required field sets) from - * a WorkOS OpenAPI spec and generates src/workos/generated/response-shapes.ts. + * Extracts response *shapes* (property + required field sets) from a WorkOS + * OpenAPI spec and generates src/workos/generated/response-shapes.ts. + * + * Two catalogs, because a response body has two layers that can drift apart: + * + * 1. OBJECT_SCHEMA_MAP — the *resource* objects (`user`, `api_key`, ...), + * keyed by the emulator's `object` discriminator. Covers what the + * hand-written format* helpers emit. + * 2. ENVELOPE_SCHEMA_MAP — the *envelope* a route wraps a resource in + * (`{ api_key }`, `{ object, data, list_metadata }`, ...), keyed by + * operation. Envelopes have no `object` discriminator, so catalog 1 cannot + * see them — and an envelope is assembled inline in the route handler, + * which is exactly where a plausible-looking invention like `{ valid }` + * slips past a spec that says `{ api_key }`. * * Unlike the event catalog — discovered structurally via properties.event.const * — resource schemas are neither uniformly named nor uniformly shaped in the * spec (e.g. `UserObject` is a partial SCIM-style user, while `UserlandUser` is - * the AuthKit User Management user). So the authoritative schema per emulator - * object type is curated in OBJECT_SCHEMA_MAP below: only the *selection* is - * hand-maintained — every field requirement is still extracted from the spec, - * and extraction fails loudly if a mapped schema's `object` discriminator does - * not match, so a spec rename can't silently point the test at the wrong shape. + * the AuthKit User Management user). So the authoritative schema is curated per + * entry in both maps below: only the *selection* is hand-maintained — every + * field requirement is still extracted from the spec, and extraction fails + * loudly when a mapped schema's `object` discriminator (catalog 1) or an + * operation's declared `$ref` (catalog 2) does not match, so a spec rename + * can't silently point the test at the wrong shape. */ import type { EventSchemaNode } from './gen-events-lib.js'; @@ -39,6 +52,70 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [ { objectType: 'directory_user', schemaName: 'DirectoryUserWithGroups' }, { objectType: 'role', schemaName: 'Role' }, { objectType: 'permission', schemaName: 'AuthorizationPermission' }, + { objectType: 'api_key', schemaName: 'ApiKey' }, +]; + +export interface EnvelopeMapEntry { + /** HTTP method, uppercase. */ + method: string; + /** The spec path with params in braces, e.g. "/organizations/{organizationId}/api_keys". */ + path: string; + /** The response status whose body is authoritative, e.g. "200". */ + status: string; + /** The expected components.schemas name — asserted against what the operation declares. */ + schemaName: string; +} + +/** + * Which operations' top-level response envelopes are checked against the spec. + * + * Scoped to operations whose envelope the emulator assembles by hand and whose + * body is pure data. Auth/flow endpoints (`/user_management/authenticate`, + * `/oauth2/token`) deliberately stay out: they are hand-authored runtime OAuth + * behavior the spec does not describe. + * + * Adding an entry here is cheap; the test that consumes it must exercise the + * operation for real, and asserts it covers this catalog exactly, so a new entry + * without a matching request fails rather than silently going unchecked. + */ +export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [ + // Single-resource and single-value envelopes — each a distinct hand-assembled shape, + // and the class of body where an invention is easiest to miss. + { method: 'POST', path: '/api_keys/validations', status: '200', schemaName: 'ApiKeyValidationResponse' }, + { method: 'POST', path: '/portal/generate_link', status: '201', schemaName: 'PortalLinkResponse' }, + { method: 'POST', path: '/widgets/token', status: '201', schemaName: 'WidgetSessionTokenResponse' }, + { + method: 'POST', + path: '/user_management/password_reset/confirm', + status: '200', + schemaName: 'ResetPasswordResponse', + }, + { + method: 'POST', + path: '/user_management/users/{id}/email_verification/send', + status: '200', + schemaName: 'SendVerificationEmailResponse', + }, + { + method: 'POST', + path: '/authorization/organization_memberships/{organization_membership_id}/check', + status: '200', + schemaName: 'AuthorizationCheck', + }, + { method: 'GET', path: '/sso/jwks/{clientId}', status: '200', schemaName: 'JwksResponse' }, + // 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: '/organizations/{organizationId}/api_keys', + status: '200', + schemaName: 'OrganizationApiKeyList', + }, ]; export interface ParsedShape { @@ -50,6 +127,25 @@ export interface ParsedShape { required: string[]; } +export interface ParsedEnvelope { + /** Catalog key, e.g. "POST /api_keys/validations". */ + operation: string; + schemaName: string; + /** Every top-level property the spec defines for the envelope, sorted. */ + properties: string[]; + /** Top-level properties the spec marks required, sorted. */ + required: string[]; +} + +/** + * Sorted, de-duplicated field list. `resolveSchema` concatenates `required` across allOf + * members, so a field two members both mark required would otherwise appear twice — a + * duplicate in the generated catalog reads like a spec quirk rather than a merge artifact. + */ +function sortedUnique(fields: string[] | undefined): string[] { + return [...new Set(fields ?? [])].sort(); +} + function getSchemas(spec: EventSchemaNode): Record { const components = (spec as { components?: { schemas?: Record } }).components; return components?.schemas ?? {}; @@ -137,7 +233,7 @@ export function extractShape(entry: ShapeMapEntry, spec: EventSchemaNode): Parse objectType: entry.objectType, schemaName: entry.schemaName, properties: [...properties].sort(), - required: [...(resolved.required ?? [])].sort(), + required: sortedUnique(resolved.required), }; } @@ -148,17 +244,79 @@ export function parseShapeCatalog( return map.map((entry) => extractShape(entry, spec)).sort((a, b) => a.objectType.localeCompare(b.objectType)); } -export function generateShapesFile(shapes: ParsedShape[]): string { +/** The catalog key for an envelope entry, e.g. "POST /api_keys/validations". */ +export function envelopeKey(entry: Pick): string { + return `${entry.method.toUpperCase()} ${entry.path}`; +} + +export function extractEnvelope(entry: EnvelopeMapEntry, spec: EventSchemaNode): ParsedEnvelope { + const key = envelopeKey(entry); + const paths = (spec as { paths?: Record }).paths ?? {}; + const pathItem = paths[entry.path]; + if (!pathItem) { + throw new Error(`gen-shapes: path "${entry.path}" (${key}) not found in spec paths`); + } + + const operation = pathItem[entry.method.toLowerCase()] as EventSchemaNode | undefined; + if (!operation) { + throw new Error(`gen-shapes: path "${entry.path}" declares no ${entry.method} operation`); + } + + const responses = operation.responses as Record | undefined; + const response = responses?.[entry.status]; + const content = response?.content as Record | undefined; + const schemaNode = content?.['application/json']?.schema as EventSchemaNode | undefined; + if (!schemaNode) { + throw new Error(`gen-shapes: ${key} declares no application/json schema for status ${entry.status}`); + } + + // Guard the curation: the operation must declare exactly the mapped schema. This is the + // envelope counterpart to the `object` discriminator check — a spec rename, or an + // operation repointed at a different response schema, fails here rather than leaving the + // test asserting yesterday's contract. + const declared = schemaNode.$ref?.match(/^#\/components\/schemas\/(.+)$/)?.[1]; + if (declared !== entry.schemaName) { + throw new Error( + `gen-shapes: ${key} declares response schema ${declared ?? '(inline)'}, expected "${entry.schemaName}"`, + ); + } + + const resolved = resolveSchema(schemaNode, spec); + const properties = Object.keys(resolved.properties ?? {}); + if (properties.length === 0) { + throw new Error(`gen-shapes: schema "${entry.schemaName}" (${key}) resolved to no properties`); + } + + return { + operation: key, + schemaName: entry.schemaName, + properties: [...properties].sort(), + required: sortedUnique(resolved.required), + }; +} + +export function parseEnvelopeCatalog( + spec: EventSchemaNode, + map: readonly EnvelopeMapEntry[] = ENVELOPE_SCHEMA_MAP, +): ParsedEnvelope[] { + return map.map((entry) => extractEnvelope(entry, spec)).sort((a, b) => a.operation.localeCompare(b.operation)); +} + +export function generateShapesFile(shapes: ParsedShape[], envelopes: ParsedEnvelope[]): string { const lines: string[] = []; lines.push('/**'); lines.push(' * Generated by scripts/gen-shapes.ts — do not edit by hand.'); lines.push(' * Source: the @workos/openapi-spec package. Regenerate with:'); lines.push(' * npm run gen:shapes'); lines.push(' *'); - lines.push(' * Per-resource response shape requirements, extracted from the spec schema'); - lines.push(' * curated for each object type in scripts/gen-shapes-lib.ts (OBJECT_SCHEMA_MAP).'); - lines.push(' * Consumed by src/workos/response-shapes.spec.ts to assert the hand-written'); - lines.push(' * format* helpers match the spec and never leak internal fields.'); + lines.push(' * Response shape requirements extracted from the spec schemas curated in'); + lines.push(' * scripts/gen-shapes-lib.ts:'); + lines.push(' * - RESPONSE_SHAPE_REQUIREMENTS per resource (OBJECT_SCHEMA_MAP)'); + lines.push(' * - RESPONSE_ENVELOPE_REQUIREMENTS per operation (ENVELOPE_SCHEMA_MAP)'); + lines.push(' *'); + lines.push(' * Consumed by src/workos/response-shapes.spec.ts and'); + lines.push(' * src/workos/response-envelopes.spec.ts to assert the emulator matches the'); + lines.push(' * spec and never leaks internal fields.'); lines.push(' */'); lines.push(''); lines.push('export interface ResponseShapeRequirement {'); @@ -182,5 +340,22 @@ export function generateShapesFile(shapes: ParsedShape[]): string { } lines.push('};'); lines.push(''); + lines.push('/**'); + lines.push(' * Top-level envelope requirements, keyed by "METHOD /spec/path". The nested'); + lines.push(' * resource is covered by RESPONSE_SHAPE_REQUIREMENTS; these are the wrapper'); + lines.push(' * fields the route handler itself is responsible for.'); + lines.push(' */'); + lines.push('export const RESPONSE_ENVELOPE_REQUIREMENTS: Record = {'); + for (const envelope of envelopes) { + const props = envelope.properties.map((p) => `'${p}'`).join(', '); + const req = envelope.required.map((p) => `'${p}'`).join(', '); + lines.push(` '${envelope.operation}': {`); + lines.push(` schema: '${envelope.schemaName}',`); + lines.push(` properties: [${props}],`); + lines.push(` required: [${req}],`); + lines.push(' },'); + } + lines.push('};'); + lines.push(''); return lines.join('\n'); } diff --git a/scripts/gen-shapes.ts b/scripts/gen-shapes.ts index 393e97f..8c8bc4e 100644 --- a/scripts/gen-shapes.ts +++ b/scripts/gen-shapes.ts @@ -26,7 +26,7 @@ import YAML from 'yaml'; import { format, type FormatConfig } from 'oxfmt'; import { type EventSchemaNode } from './gen-events-lib.js'; -import { parseShapeCatalog, generateShapesFile } from './gen-shapes-lib.js'; +import { parseShapeCatalog, parseEnvelopeCatalog, generateShapesFile } from './gen-shapes-lib.js'; /** Load the project's oxfmt config so generated output matches `npm run fmt`. */ function loadFormatConfig(): FormatConfig { @@ -65,9 +65,10 @@ async function main(): Promise { ext === '.yaml' || ext === '.yml' ? (YAML.parse(raw) as EventSchemaNode) : (JSON.parse(raw) as EventSchemaNode); const shapes = parseShapeCatalog(spec); + const envelopes = parseEnvelopeCatalog(spec); const resolvedOut = resolve(outFile); // The output path's `.ts` extension tells oxfmt to use the TypeScript parser. - const formatted = await format(resolvedOut, generateShapesFile(shapes), loadFormatConfig()); + const formatted = await format(resolvedOut, generateShapesFile(shapes, envelopes), loadFormatConfig()); if (formatted.errors.length > 0) { console.error('oxfmt reported errors while formatting generated output:'); for (const err of formatted.errors) console.error(` ${err.severity}: ${err.message}`); @@ -83,7 +84,7 @@ async function main(): Promise { mkdirSync(dirname(resolvedOut), { recursive: true }); writeFileSync(resolvedOut, content, 'utf-8'); console.log(` wrote ${resolvedOut}`); - console.log(`\nShapes: ${shapes.length} resources`); + console.log(`\nShapes: ${shapes.length} resources, ${envelopes.length} envelopes`); } await main(); diff --git a/src/workos/generated/response-shapes.ts b/src/workos/generated/response-shapes.ts index fedcf1d..1b3109a 100644 --- a/src/workos/generated/response-shapes.ts +++ b/src/workos/generated/response-shapes.ts @@ -3,10 +3,14 @@ * Source: the @workos/openapi-spec package. Regenerate with: * npm run gen:shapes * - * Per-resource response shape requirements, extracted from the spec schema - * curated for each object type in scripts/gen-shapes-lib.ts (OBJECT_SCHEMA_MAP). - * Consumed by src/workos/response-shapes.spec.ts to assert the hand-written - * format* helpers match the spec and never leak internal fields. + * Response shape requirements extracted from the spec schemas curated in + * scripts/gen-shapes-lib.ts: + * - RESPONSE_SHAPE_REQUIREMENTS per resource (OBJECT_SCHEMA_MAP) + * - RESPONSE_ENVELOPE_REQUIREMENTS per operation (ENVELOPE_SCHEMA_MAP) + * + * Consumed by src/workos/response-shapes.spec.ts and + * src/workos/response-envelopes.spec.ts to assert the emulator matches the + * spec and never leaks internal fields. */ export interface ResponseShapeRequirement { @@ -19,6 +23,33 @@ export interface ResponseShapeRequirement { } export const RESPONSE_SHAPE_REQUIREMENTS: Record = { + api_key: { + schema: 'ApiKey', + properties: [ + 'created_at', + 'expires_at', + 'id', + 'last_used_at', + 'name', + 'obfuscated_value', + 'object', + 'owner', + 'permissions', + 'updated_at', + ], + required: [ + 'created_at', + 'expires_at', + 'id', + 'last_used_at', + 'name', + 'obfuscated_value', + 'object', + 'owner', + 'permissions', + 'updated_at', + ], + }, connection: { schema: 'Connection', properties: [ @@ -208,3 +239,76 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record = { + 'GET /connect/applications': { + schema: 'ConnectApplicationList', + properties: ['data', 'list_metadata', 'object'], + required: ['data', 'list_metadata', 'object'], + }, + 'GET /events': { + schema: 'EventList', + properties: ['data', 'list_metadata', 'object'], + required: ['data', 'list_metadata', 'object'], + }, + 'GET /organizations': { + schema: 'OrganizationList', + properties: ['data', 'list_metadata', 'object'], + required: ['data', 'list_metadata', 'object'], + }, + 'GET /organizations/{organizationId}/api_keys': { + schema: 'OrganizationApiKeyList', + properties: ['data', 'list_metadata', 'object'], + required: ['data', 'list_metadata', 'object'], + }, + 'GET /sso/jwks/{clientId}': { + schema: 'JwksResponse', + properties: ['keys'], + required: ['keys'], + }, + 'GET /user_management/users': { + schema: 'UserlandUserList', + properties: ['data', 'list_metadata', 'object'], + required: ['data', 'list_metadata', 'object'], + }, + 'GET /webhook_endpoints': { + schema: 'WebhookEndpointList', + properties: ['data', 'list_metadata', 'object'], + required: ['data', 'list_metadata', 'object'], + }, + 'POST /api_keys/validations': { + schema: 'ApiKeyValidationResponse', + properties: ['agent_registration_id', 'api_key'], + required: ['api_key'], + }, + 'POST /authorization/organization_memberships/{organization_membership_id}/check': { + schema: 'AuthorizationCheck', + properties: ['authorized'], + required: ['authorized'], + }, + 'POST /portal/generate_link': { + schema: 'PortalLinkResponse', + properties: ['link'], + required: ['link'], + }, + 'POST /user_management/password_reset/confirm': { + schema: 'ResetPasswordResponse', + properties: ['user'], + required: ['user'], + }, + 'POST /user_management/users/{id}/email_verification/send': { + schema: 'SendVerificationEmailResponse', + properties: ['user'], + required: ['user'], + }, + 'POST /widgets/token': { + schema: 'WidgetSessionTokenResponse', + properties: ['token'], + required: ['token'], + }, +}; diff --git a/src/workos/response-envelopes.spec.ts b/src/workos/response-envelopes.spec.ts new file mode 100644 index 0000000..8b471db --- /dev/null +++ b/src/workos/response-envelopes.spec.ts @@ -0,0 +1,234 @@ +/** + * Envelope conformance: asserts the top-level response body each route assembles + * matches the OpenAPI spec. The companion to response-shapes.spec.ts, which + * checks the *resource* inside the envelope. + * + * This layer needs its own loop because an envelope has no `object` + * discriminator for the resource catalog to key on, and because it is built + * inline in the route handler rather than by a format* helper. That is precisely + * where an invention slips through: `POST /api_keys/validations` returned + * `{ valid: boolean }` for several releases while the spec said + * `{ api_key: ApiKey | null }`, so every SDK reading `response.api_key` saw + * every key as invalid. + * + * Requirements come from src/workos/generated/response-shapes.ts (regenerate + * with `npm run gen:shapes`); the cases below exercise each operation for real + * against a running emulator and diff the top-level key set. + * + * Two assertions per operation, mirroring the resource loop: + * 1. forward — every spec-required top-level field is present + * 2. reverse — no top-level field the spec doesn't define is returned + * + * Adding an operation to ENVELOPE_SCHEMA_MAP without adding a case here fails + * 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. + */ +import { describe, it, expect, beforeAll } from 'bun:test'; +import { createServer, type ApiKeyMap } from '../core/index.js'; +import { workosPlugin, seedFromConfig } from './index.js'; +import { getWorkOSStore } from './store.js'; +import { RESPONSE_ENVELOPE_REQUIREMENTS } from './generated/response-shapes.js'; + +const API_KEY = 'sk_test_envelope'; +const BASE_URL = 'http://localhost:0'; +const apiKeys: ApiKeyMap = { [API_KEY]: { environment: 'test' } }; +const headers = { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }; + +const sorted = (xs: Iterable): string[] => [...xs].sort(); + +type App = ReturnType['app']; + +/** IDs resolved during setup that the request callbacks need. */ +interface Fixtures { + organizationId: string; + userId: string; + membershipId: string; + clientId: string; + passwordResetToken: string; +} + +/** Each case names a catalog operation and returns that operation's live response body. */ +interface EnvelopeCase { + operation: string; + request: (app: App, f: Fixtures) => Response | Promise; +} + +const get = (path: string) => (app: App) => app.request(path, { headers }); +const post = (path: string, body?: unknown) => (app: App) => + app.request(path, { method: 'POST', headers, body: JSON.stringify(body ?? {}) }); + +const CASES: readonly EnvelopeCase[] = [ + { + operation: 'POST /api_keys/validations', + request: post('/api_keys/validations', { value: API_KEY }), + }, + { + operation: 'POST /portal/generate_link', + request: (app, f) => post('/portal/generate_link', { intent: 'sso', organization: f.organizationId })(app), + }, + { + operation: 'POST /widgets/token', + request: (app, f) => + post('/widgets/token', { + organization_id: f.organizationId, + user_id: f.userId, + scopes: ['widgets:users-table:manage'], + })(app), + }, + { + operation: 'POST /user_management/password_reset/confirm', + request: (app, f) => + post('/user_management/password_reset/confirm', { + token: f.passwordResetToken, + new_password: 'new-secret-123', + })(app), + }, + { + operation: 'POST /user_management/users/{id}/email_verification/send', + request: (app, f) => post(`/user_management/users/${f.userId}/email_verification/send`)(app), + }, + { + operation: 'POST /authorization/organization_memberships/{organization_membership_id}/check', + request: (app, f) => + post(`/authorization/organization_memberships/${f.membershipId}/check`, { permission: 'posts:read' })(app), + }, + { + operation: 'GET /sso/jwks/{clientId}', + request: (app, f) => get(`/sso/jwks/${f.clientId}`)(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') }, + { operation: 'GET /webhook_endpoints', request: get('/webhook_endpoints') }, + { operation: 'GET /events', request: get('/events') }, + { + operation: 'GET /organizations/{organizationId}/api_keys', + request: (app, f) => get(`/organizations/${f.organizationId}/api_keys`)(app), + }, +]; + +/** + * Spec-defined top-level fields the emulator does not return. Each is a real, + * tracked gap — closing one forces deleting its entry here, and any *new* gap + * fails the build. + */ +const KNOWN_MISSING_REQUIRED: Record = { + // The emulator returns the `email_verification` resource — including its `code` — so a + // test harness can complete the flow without an email channel. Production returns + // `{ user }` and delivers the code out of band, which a local emulator cannot do. This + // is the same deliberate trade as the other flow resources (magic auth, password reset + // send); see the SECRET_FIELDS scope note in response-shapes.spec.ts. + 'POST /user_management/users/{id}/email_verification/send': ['user'], +}; + +/** Top-level fields the emulator returns that the spec envelope does not define. */ +const KNOWN_EXTRA_FIELDS: Record = { + // Same trade as above: the whole email_verification resource, in place of `{ user }`. + 'POST /user_management/users/{id}/email_verification/send': [ + 'object', + 'id', + 'user_id', + 'email', + 'code', + 'expires_at', + 'created_at', + 'updated_at', + ], +}; + +describe('response envelope conformance (route bodies vs OpenAPI spec)', () => { + const bodies = new Map>(); + + beforeAll(async () => { + 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' }], + permissions: [{ slug: 'posts:read', name: 'Read Posts' }], + roles: [{ slug: 'member', name: 'Member', permissions: ['posts:read'] }], + webhookEndpoints: [{ endpoint_url: 'http://localhost:5005/webhooks', events: [] }], + connectApplications: [{ name: 'Billing', type: 'm2m', organization: 'Acme Corp', client_id: 'client_billing' }], + apiKeys: [{ name: 'Envelope Key', organization: 'Acme Corp', value: API_KEY, permissions: ['posts:read'] }], + }); + + const ws = getWorkOSStore(server.store); + const organizationId = ws.organizations.findOneBy('name', 'Acme Corp')!.id; + const userId = ws.users.findOneBy('email', 'alice@acme.com')!.id; + + // A membership carrying a role, so the permission check has something to authorize + // against; and a password reset, so confirm resolves a real token. + const membershipId = ws.organizationMemberships.insert({ + object: 'organization_membership', + user_id: userId, + organization_id: organizationId, + status: 'active', + role: { slug: 'member' }, + metadata: {}, + external_id: null, + }).id; + const passwordResetToken = ws.passwordResets.insert({ + object: 'password_reset', + user_id: userId, + email: 'alice@acme.com', + token: 'pw_reset_envelope', + expires_at: new Date(Date.now() + 600_000).toISOString(), + }).token; + + const fixtures: Fixtures = { + organizationId, + userId, + membershipId, + clientId: 'client_billing', + passwordResetToken, + }; + + for (const { operation, request } of CASES) { + const res = await request(server.app, fixtures); + expect(res.status, `${operation} did not return 2xx`).toBeLessThan(300); + bodies.set(operation, (await res.json()) as Record); + } + }); + + it('covers exactly the operations in the generated requirements catalog', () => { + expect(sorted(CASES.map((c) => c.operation))).toEqual(sorted(Object.keys(RESPONSE_ENVELOPE_REQUIREMENTS))); + }); + + for (const { operation } of CASES) { + const requirement = RESPONSE_ENVELOPE_REQUIREMENTS[operation]; + + describe(operation, () => { + it('returns every spec-required top-level field (modulo tracked gaps)', () => { + const keys = Object.keys(bodies.get(operation) ?? {}); + const missing = sorted(requirement.required.filter((field) => !keys.includes(field))); + expect(missing).toEqual(sorted(KNOWN_MISSING_REQUIRED[operation] ?? [])); + }); + + it('returns no top-level field absent from the spec envelope (modulo tracked extras)', () => { + const props = new Set(requirement.properties); + const extra = sorted(Object.keys(bodies.get(operation) ?? {}).filter((key) => !props.has(key))); + expect(extra).toEqual(sorted(KNOWN_EXTRA_FIELDS[operation] ?? [])); + }); + }); + } + + it('exercises every list operation against a non-empty page', () => { + // Guards the loop above: an empty `data` array would let both field assertions pass + // without the route having actually formatted a resource. + const lists = CASES.filter(({ operation }) => + RESPONSE_ENVELOPE_REQUIREMENTS[operation].properties.includes('data'), + ); + expect(lists.length).toBeGreaterThan(0); + for (const { operation } of lists) { + const data = (bodies.get(operation) as { data: unknown[] }).data; + expect(data.length, `${operation} returned an empty page`).toBeGreaterThan(0); + } + }); + + it('validates an api key that resolves to a resource', () => { + // An `api_key: null` body would satisfy the field assertions vacuously. + expect((bodies.get('POST /api_keys/validations') as { api_key: unknown }).api_key).not.toBeNull(); + }); +}); diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 88b6563..c663d4d 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -27,6 +27,7 @@ import { formatDirectoryUser, formatRole, formatPermission, + formatApiKeyRecord, } from './helpers.js'; import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js'; import type { @@ -38,6 +39,7 @@ import type { WorkOSDirectoryUser, WorkOSRole, WorkOSPermission, + WorkOSApiKey, } from './entities.js'; const TS = '2026-01-01T00:00:00.000Z'; @@ -154,6 +156,20 @@ const permission: WorkOSPermission = { updated_at: TS, }; +const apiKey: WorkOSApiKey = { + id: 'api_key_01', + object: 'api_key', + name: 'Production API Key', + key: 'sk_test_supersecret3456', + environment: 'test', + owner: { type: 'organization', id: 'org_01' }, + permissions: ['posts:read', 'posts:write'], + last_used_at: null, + expires_at: null, + created_at: TS, + updated_at: TS, +}; + const store = new Store(); const ws = getWorkOSStore(store); @@ -166,6 +182,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record { objectType: 'directory_user', output: formatDirectoryUser(directoryUser) }, { objectType: 'role', output: formatRole(role) }, { objectType: 'permission', output: formatPermission(permission) }, + { objectType: 'api_key', output: formatApiKeyRecord(apiKey) }, ]; /** @@ -200,13 +217,17 @@ const KNOWN_EXTRA_FIELDS: Record = { * * Scope note: this set deliberately omits auth-code/token field names * (`code`, `token`, ...). Those belong to flow resources — email verification, - * magic auth, password reset, client secrets, API keys — whose formatters - * intentionally surface the value so a test harness can complete the flow - * without an out-of-band channel. The real API hides them; an emulator must - * not, which is exactly why those formatters are not in this catalog. Listing - * those names here would imply a coverage this loop does not provide. + * magic auth, password reset, client secrets — whose formatters intentionally + * surface the value so a test harness can complete the flow without an + * out-of-band channel. The real API hides them; an emulator must not, which is + * exactly why those formatters are not in this catalog. Listing those names + * here would imply a coverage this loop does not provide. + * + * An API key is the exception among credential-bearing resources: production + * returns its raw value only once, at creation, so `formatApiKeyRecord` emits + * `obfuscated_value` and never `key` — hence `key` belongs here. */ -const SECRET_FIELDS = new Set(['password_hash', 'code_challenge', 'code_challenge_method']); +const SECRET_FIELDS = new Set(['password_hash', 'code_challenge', 'code_challenge_method', 'key']); describe('response shape conformance (format* helpers vs OpenAPI spec)', () => { it('covers exactly the resources in the generated requirements catalog', () => { From 857deebea1bc0fd404322ec713f144534ceba2dc Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 27 Jul 2026 14:49:33 -0400 Subject: [PATCH 5/8] ci: fail when generated spec catalogs are stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec-derived catalogs under src/workos/generated are committed so consumers of the package never need the spec — which also means a @workos/openapi-spec bump does not regenerate them, and the conformance tests go on asserting the previous spec's contract. A dependency bump could turn green while the emulator had silently drifted, which is the whole class of problem those tests exist to catch. Added as a step in the existing `test` job rather than its own job: the repo ruleset pins required check contexts by name, so a new job would not be enforced until someone updated the ruleset, while a step is enforced immediately. --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24ffb47..b8e4653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,20 @@ jobs: - name: Install run: bun install --frozen-lockfile + # The spec-derived catalogs under src/workos/generated are committed so consumers + # never need the spec, which means a @workos/openapi-spec bump does not update them + # on its own — and the conformance tests would go on asserting the previous spec's + # contract. Regenerate here and fail if anything moved, so a bump PR has to carry + # the regenerated catalogs (and any conformance fallout) with it. + - name: Check spec codegen is up to date + run: | + bun run gen:events + bun run gen:shapes + if ! git diff --exit-code -- src/workos/generated; then + echo "::error::Generated spec catalogs are stale. Run 'bun run gen:events && bun run gen:shapes' and commit the result." + exit 1 + fi + - name: Typecheck run: bun run typecheck From 98b6c0c98e4864edd03a2158ad43e2d12b064a54 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 27 Jul 2026 15:06:57 -0400 Subject: [PATCH 6/8] test(shapes): stop the envelope suite firing stray webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeding a catch-all webhook endpoint made the suite POST every resource it then created to a port nothing is listening on. Those fetches are async and outlive the file, so they land in whichever suite runs next — and one of those, event-bus.spec.ts, counts calls on a global fetch spy. Subscribing to an event the suite never triggers keeps the endpoint listable without generating traffic. --- src/workos/response-envelopes.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/workos/response-envelopes.spec.ts b/src/workos/response-envelopes.spec.ts index 8b471db..c074328 100644 --- a/src/workos/response-envelopes.spec.ts +++ b/src/workos/response-envelopes.spec.ts @@ -149,7 +149,11 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => { users: [{ email: 'alice@acme.com', password: 'secret123' }], permissions: [{ slug: 'posts:read', name: 'Read Posts' }], roles: [{ slug: 'member', name: 'Member', permissions: ['posts:read'] }], - webhookEndpoints: [{ endpoint_url: 'http://localhost:5005/webhooks', events: [] }], + // Subscribed to an event this test never triggers, not the catch-all `[]`. Webhook + // endpoints are seeded before api keys and connect applications, so a catch-all would + // fire deliveries at a port nothing is listening on — and those in-flight fetches + // outlive this file and land in whichever suite runs next. + webhookEndpoints: [{ endpoint_url: 'http://localhost:5005/webhooks', events: ['dsync.activated'] }], connectApplications: [{ name: 'Billing', type: 'm2m', organization: 'Acme Corp', client_id: 'client_billing' }], apiKeys: [{ name: 'Envelope Key', organization: 'Acme Corp', value: API_KEY, permissions: ['posts:read'] }], }); From 9dabd325584460aaf66173c27a922a3e541a7778 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 27 Jul 2026 15:20:34 -0400 Subject: [PATCH 7/8] test(event-bus): assert on this endpoint's delivery, not every fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertion counted all calls on a spy installed over the global `fetch`, so a webhook delivery still in flight from another suite was counted as if this test had made it — reliably enough to fail roughly one full-suite run in ten, with concurrent-webhooks.spec.ts's ten concurrent user.created deliveries as the usual source. Indexing calls[0] had the same flaw more quietly: a foreign call arriving first would have sent the HMAC and signature-format assertions off to verify someone else's request body. --- src/workos/event-bus.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/workos/event-bus.spec.ts b/src/workos/event-bus.spec.ts index cd55522..c449d18 100644 --- a/src/workos/event-bus.spec.ts +++ b/src/workos/event-bus.spec.ts @@ -109,8 +109,12 @@ describe('EventBus', () => { // Wait for async delivery await new Promise((resolve) => setTimeout(resolve, 50)); - expect(fetchSpy).toHaveBeenCalledTimes(1); - const [, init] = fetchSpy.mock.calls[0]; + // Count this endpoint's delivery, not every call on the spy: `fetch` is global, so a + // delivery still in flight from another suite lands here too — and could even be + // calls[0], which would make the signature assertions below check someone else's body. + const deliveries = fetchSpy.mock.calls.filter(([url]) => url === 'http://localhost:9999/webhook'); + expect(deliveries).toHaveLength(1); + const [, init] = deliveries[0]; receivedBody = init!.body as string; receivedSignature = (init!.headers as Record)['WorkOS-Signature']; From 83b1dd96b42892f8bbe9585e1d1d6d3338c29c9d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 27 Jul 2026 16:36:18 -0400 Subject: [PATCH 8/8] fix(seed): reject duplicate api key values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A key value is the identity of the secret: it is the auth allow-list's map key and the lookup key for the resource behind it. Two seed entries sharing one split that identity — the allow-list keeps the last entry's environment and expiry while the record lookup resolves the first, so validation gated on one entry and reported another's owner and permissions. Deleting either would also stop the other authenticating. Returning the resource from validations is what made the split observable, but the seed accepted the ambiguity to begin with, and production cannot issue one secret twice. Rejecting it alongside the existing duplicate organization name and id checks fixes every reader at once, rather than teaching each one which duplicate to prefer. --- README.md | 2 +- src/workos/config-validator.ts | 20 ++++++++++++++++++++ src/workos/seed-m2m.spec.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 391ec79..8156191 100644 --- a/README.md +++ b/README.md @@ -359,7 +359,7 @@ organizations: apiKeys: - name: CI Key organization: Acme Corp # owner org, by name (or use `user_id`) - value: sk_test_ci_key # optional; must start with `sk_`; generated if omitted + value: sk_test_ci_key # optional; must start with `sk_` and be unique; generated if omitted permissions: [posts:read, posts:write] # expires_at: 2030-01-01T00:00:00.000Z # optional; never expires if omitted ``` diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 4451f76..b6be467 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -503,6 +503,26 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe }); } }); + + // A key value is the identity of the secret: it is the auth allow-list's map key and + // the lookup key for the resource behind it. Two entries sharing one would split that + // identity — the allow-list keeps the last entry's environment and expiry while the + // record lookup resolves the first, so validation would gate on one seed entry and + // report another's owner and permissions. Deleting either would also stop the other + // authenticating. Production cannot issue one secret twice, so reject it here rather + // than pick a winner. + const seenKeyValues = new Set(); + config.apiKeys.forEach((keyConfig, index) => { + if (typeof keyConfig.value !== 'string' || keyConfig.value.length === 0) return; + if (seenKeyValues.has(keyConfig.value)) { + errors.push({ + path: `apiKeys[${index}].value`, + message: 'value must be unique across apiKeys', + value: keyConfig.value, + }); + } + seenKeyValues.add(keyConfig.value); + }); } return { diff --git a/src/workos/seed-m2m.spec.ts b/src/workos/seed-m2m.spec.ts index 5017d48..baf9673 100644 --- a/src/workos/seed-m2m.spec.ts +++ b/src/workos/seed-m2m.spec.ts @@ -351,4 +351,36 @@ describe('Seed config validation for M2M apps and API keys', () => { const result = validateSeedConfig({ apiKeys: { sk_test_x: { environment: 'test' } } }); expect(result.valid).toBe(true); }); + + // Two entries sharing a value split the key's identity: the auth allow-list keeps the + // last one's expiry while the record lookup resolves the first, so validation would gate + // on one entry and report the other's owner and permissions. + it('rejects duplicate api key values', () => { + expect( + findError( + { + organizations: [{ name: 'A' }, { name: 'B' }], + apiKeys: [ + { name: 'First', organization: 'A', value: 'sk_test_dup' }, + { name: 'Second', organization: 'B', value: 'sk_test_dup' }, + ], + }, + 'apiKeys[1].value', + )?.message, + ).toContain('unique'); + }); + + it('accepts distinct api key values, and omitted ones', () => { + const result = validateSeedConfig({ + organizations: [{ name: 'A' }], + apiKeys: [ + { name: 'First', organization: 'A', value: 'sk_test_one' }, + { name: 'Second', organization: 'A', value: 'sk_test_two' }, + // Omitted values are generated per key, so two of them are not a collision. + { name: 'Third', organization: 'A' }, + { name: 'Fourth', organization: 'A' }, + ], + }); + expect(result).toEqual({ valid: true, errors: [] }); + }); });