diff --git a/SUPPORTED.md b/SUPPORTED.md index 8c22807..a8c7870 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **145 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**68.4%**). +The emulator implements **149 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**70.3%**). Endpoint coverage says whether a route exists, not whether a feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is @@ -32,7 +32,7 @@ answers "can I actually emulate this?". | Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | | Vault | ❌ 0/5 | ❌ 0/6 | ❌ none | Not implemented. | | Feature Flags | ✅ 4/4 | ⚠️ 1/4 | ⚠️ API only | Enable/disable and targeting exist, but under different verbs than the spec (`POST /feature-flags/:slug/enable` where the spec says `PUT`), so they do not count toward coverage. | -| API Keys | ⚠️ 1/2 | ⚠️ 2/5 | ✅ seed `apiKeys` | Seeded keys authenticate real requests. User-scoped API key endpoints are not implemented. | +| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | | Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. | | Applications | ⚠️ 4/5 | ⚠️ 4/8 | ✅ seed `connectApplications` | | | JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index ac8e5a5..4dfdc6d 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -181,7 +181,7 @@ export const FEATURES: FeatureDef[] = [ name: 'API Keys', tags: ['api_keys', 'organizations.api_keys'], seedKeys: ['apiKeys'], - notes: 'Seeded keys authenticate real requests. User-scoped API key endpoints are not implemented.', + notes: 'Created and seeded keys authenticate real requests.', }, { name: 'Pipes / Connected Apps', diff --git a/src/core/middleware/auth.ts b/src/core/middleware/auth.ts index f18ed3c..7cb4485 100644 --- a/src/core/middleware/auth.ts +++ b/src/core/middleware/auth.ts @@ -28,7 +28,7 @@ export type ApiKeyMap = Record; export function isApiKeyEntryExpired(entry: ApiKeyEntry): boolean { if (!entry.expiresAt) return false; const expiresAt = new Date(entry.expiresAt).getTime(); - return Number.isNaN(expiresAt) || expiresAt < Date.now(); + return Number.isNaN(expiresAt) || expiresAt <= Date.now(); } export function authMiddleware(apiKeys: ApiKeyMap) { diff --git a/src/workos/routes/api-keys.spec.ts b/src/workos/routes/api-keys.spec.ts index e6ee8ed..146b870 100644 --- a/src/workos/routes/api-keys.spec.ts +++ b/src/workos/routes/api-keys.spec.ts @@ -28,6 +28,16 @@ describe('API Keys routes', () => { const server = createTestApp(); app = server.app; store = server.store; + getWorkOSStore(store).organizations.insert({ + id: 'org_123', + object: 'organization', + name: 'Acme', + external_id: null, + metadata: {}, + stripe_customer_id: null, + allow_profiles_outside_organization: false, + entitlements: [], + }); }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -145,6 +155,78 @@ describe('API Keys routes', () => { expect(res.status).toBe(404); }); + it('creates an organization API key that authenticates requests', async () => { + const res = await req('/organizations/org_123/api_keys', { + method: 'POST', + body: JSON.stringify({ name: 'Runtime key', permissions: ['posts:read'] }), + }); + expect(res.status).toBe(201); + const key = await json(res); + expect(key.owner).toEqual({ type: 'organization', id: 'org_123' }); + expect(key.value.startsWith('sk_test_')).toBe(true); + expect(key.permissions).toEqual(['posts:read']); + + const authenticated = await app.request('/connect/applications', { + headers: { Authorization: `Bearer ${key.value}` }, + }); + expect(authenticated.status).toBe(200); + }); + + it('creates and lists API keys for an active organization member', async () => { + const ws = getWorkOSStore(store); + ws.users.insert({ + id: 'user_123', + object: 'user', + email: 'member@acme.test', + name: null, + first_name: null, + last_name: null, + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: null, + impersonator: null, + }); + ws.organizationMemberships.insert({ + object: 'organization_membership', + organization_id: 'org_123', + user_id: 'user_123', + role: { slug: 'member' }, + status: 'active', + external_id: null, + metadata: {}, + }); + + const created = await req('/user_management/users/user_123/api_keys', { + method: 'POST', + body: JSON.stringify({ name: 'User key', organization_id: 'org_123' }), + }); + expect(created.status).toBe(201); + expect((await json(created)).owner).toEqual({ type: 'user', id: 'user_123', organization_id: 'org_123' }); + + const listed = await req('/user_management/users/user_123/api_keys?organization_id=org_123'); + expect(listed.status).toBe(200); + expect((await json(listed)).data).toHaveLength(1); + }); + + it('expires a key in the auth allow-list', async () => { + const created = await req('/organizations/org_123/api_keys', { + method: 'POST', + body: JSON.stringify({ name: 'Short-lived key' }), + }); + const key = await json(created); + + const expired = await req(`/api_keys/${key.id}/expire`, { method: 'POST', body: '{}' }); + expect(expired.status).toBe(200); + expect((await json(expired)).expires_at).not.toBeNull(); + expect( + (await app.request('/connect/applications', { headers: { Authorization: `Bearer ${key.value}` } })).status, + ).toBe(401); + }); + it('lists API key records', async () => { const ws = getWorkOSStore(store); insertKey(ws, 'key-1', 'sk_test_aaaa1111'); @@ -166,4 +248,9 @@ describe('API Keys routes', () => { expect(key.obfuscated_value).toBe('sk_...1111'); expect(key.key).toBeUndefined(); }); + + it('returns 404 when listing keys for an unknown owner', async () => { + expect((await req('/organizations/org_missing/api_keys')).status).toBe(404); + expect((await req('/user_management/users/user_missing/api_keys')).status).toBe(404); + }); }); diff --git a/src/workos/routes/api-keys.ts b/src/workos/routes/api-keys.ts index 36fc3e8..ea9da65 100644 --- a/src/workos/routes/api-keys.ts +++ b/src/workos/routes/api-keys.ts @@ -1,13 +1,59 @@ -import { type RouteContext, notFound, parseJsonBody, parseListParams, isApiKeyEntryExpired } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatApiKeyRecord, formatListResponse } from '../helpers.js'; +import { + type RouteContext, + isApiKeyEntryExpired, + notFound, + parseJsonBody, + parseListParams, + validationError, + WorkOSApiError, +} from '../../core/index.js'; import type { ApiKeyMap } from '../../core/index.js'; +import type { WorkOSApiKeyOwner } from '../entities.js'; +import { formatApiKeyRecord, formatListResponse, generateVerificationToken } from '../helpers.js'; +import { getWorkOSStore } from '../store.js'; import { STORE_KEYS } from '../constants.js'; export function apiKeyRoutes(ctx: RouteContext): void { const { app, store } = ctx; const ws = getWorkOSStore(store); + const createApiKey = (body: Record, owner: WorkOSApiKeyOwner, environment = 'test') => { + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) throw validationError('name is required', [{ field: 'name', code: 'required' }]); + if ( + body.permissions !== undefined && + (!Array.isArray(body.permissions) || !body.permissions.every((p) => typeof p === 'string')) + ) { + throw validationError('permissions must be an array of strings', [{ field: 'permissions', code: 'invalid' }]); + } + + const expiresAt = body.expires_at; + if ( + expiresAt !== undefined && + (typeof expiresAt !== 'string' || Number.isNaN(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now()) + ) { + throw validationError('expires_at must be a future ISO-8601 timestamp', [ + { field: 'expires_at', code: 'invalid' }, + ]); + } + + const value = `sk_${environment === 'production' ? 'live' : 'test'}_${generateVerificationToken()}`; + const record = ws.apiKeyRecords.insert({ + object: 'api_key', + name, + key: value, + environment, + owner, + permissions: (body.permissions as string[] | undefined) ?? [], + last_used_at: null, + expires_at: (expiresAt as string | undefined) ?? null, + }); + const apiKeyMap = store.getData(STORE_KEYS.apiKeyMap) ?? {}; + apiKeyMap[value] = { environment, expiresAt: record.expires_at }; + store.setData(STORE_KEYS.apiKeyMap, apiKeyMap); + return { ...formatApiKeyRecord(record), value }; + }; + // 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 @@ -40,17 +86,91 @@ export function apiKeyRoutes(ctx: RouteContext): void { return c.body(null, 204); }); + app.post('/api_keys/:id/expire', async (c) => { + const record = ws.apiKeyRecords.get(c.req.param('id')); + if (!record) throw notFound('ApiKey'); + if (record.expires_at && Date.parse(record.expires_at) <= Date.now()) { + throw new WorkOSApiError(409, 'API key is already expired', 'api_key_already_expired'); + } + + const body = c.req.raw.body ? await parseJsonBody(c) : {}; + if (body.expires_at !== undefined && body.expires_at !== null && typeof body.expires_at !== 'string') { + throw validationError('expires_at must be an ISO-8601 timestamp or null', [ + { field: 'expires_at', code: 'invalid' }, + ]); + } + if (typeof body.expires_at === 'string' && Number.isNaN(Date.parse(body.expires_at))) { + throw validationError('expires_at must be an ISO-8601 timestamp or null', [ + { field: 'expires_at', code: 'invalid' }, + ]); + } + + const expiresAt = + body.expires_at === null + ? null + : typeof body.expires_at === 'string' && Date.parse(body.expires_at) > Date.now() + ? body.expires_at + : new Date().toISOString(); + const updated = ws.apiKeyRecords.update(record.id, { expires_at: expiresAt })!; + const apiKeyMap = store.getData(STORE_KEYS.apiKeyMap); + if (apiKeyMap?.[record.key]) apiKeyMap[record.key].expiresAt = expiresAt; + return c.json(formatApiKeyRecord(updated)); + }); + // List API keys for an organization — scoped to the path organization so one org's // keys never leak into another org's listing. A key belongs to the org when it is // org-owned (owner.id) or user-owned within that org (owner.organization_id). app.get('/organizations/:orgId/api_keys', (c) => { const orgId = c.req.param('orgId'); - const url = new URL(c.req.url); - const params = parseListParams(url); + if (!ws.organizations.get(orgId)) throw notFound('Organization'); + const params = parseListParams(new URL(c.req.url)); const result = ws.apiKeyRecords.list({ ...params, filter: (k) => (k.owner.type === 'organization' ? k.owner.id : k.owner.organization_id) === orgId, }); return c.json(formatListResponse(result, formatApiKeyRecord)); }); + + app.post('/organizations/:orgId/api_keys', async (c) => { + const orgId = c.req.param('orgId'); + if (!ws.organizations.get(orgId)) throw notFound('Organization'); + return c.json( + createApiKey(await parseJsonBody(c), { type: 'organization', id: orgId }, c.get('auth')?.environment), + 201, + ); + }); + + app.get('/user_management/users/:userId/api_keys', (c) => { + const userId = c.req.param('userId'); + if (!ws.users.get(userId)) throw notFound('User'); + const url = new URL(c.req.url); + const organizationId = url.searchParams.get('organization_id'); + const result = ws.apiKeyRecords.list({ + ...parseListParams(url), + filter: (k) => + k.owner.type === 'user' && + k.owner.id === userId && + (!organizationId || k.owner.organization_id === organizationId), + }); + return c.json(formatListResponse(result, formatApiKeyRecord)); + }); + + app.post('/user_management/users/:userId/api_keys', async (c) => { + const userId = c.req.param('userId'); + if (!ws.users.get(userId)) throw notFound('User'); + const body = await parseJsonBody(c); + const organizationId = typeof body.organization_id === 'string' ? body.organization_id : ''; + if (!organizationId) { + throw validationError('organization_id is required', [{ field: 'organization_id', code: 'required' }]); + } + if (!ws.organizations.get(organizationId)) throw notFound('Organization'); + const membership = ws.organizationMemberships + .findBy('user_id', userId) + .find((m) => m.organization_id === organizationId && m.status === 'active'); + if (!membership) throw validationError('User must have an active membership in the organization'); + return c.json( + createApiKey(body, { type: 'user', id: userId, organization_id: organizationId }, c.get('auth')?.environment), + 201, + ); + }); }