Skip to content
Open
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 SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion scripts/gen-supported-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/core/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export type ApiKeyMap = Record<string, ApiKeyEntry>;
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) {
Expand Down
87 changes: 87 additions & 0 deletions src/workos/routes/api-keys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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');
Expand All @@ -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);
});
});
130 changes: 125 additions & 5 deletions src/workos/routes/api-keys.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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<ApiKeyMap>(STORE_KEYS.apiKeyMap) ?? {};
apiKeyMap[value] = { environment, expiresAt: record.expires_at };
Comment on lines +47 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Owner deletion leaves credentials

When a user or organization owning a runtime-created API key is deleted, the deletion cascades remove neither the key record nor its live allow-list entry, causing the orphaned key to continue authenticating protected requests. The creation path needs corresponding owner-deletion cleanup for both stores.

How this was verified: The owner-deletion handlers omit both apiKeyRecords and apiKeyMap, while authentication continues accepting every unexpired entry retained in that map.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/api-keys.ts
Line: 47-52

Comment:
**Owner deletion leaves credentials**

When a user or organization owning a runtime-created API key is deleted, the deletion cascades remove neither the key record nor its live allow-list entry, causing the orphaned key to continue authenticating protected requests. The creation path needs corresponding owner-deletion cleanup for both stores.

**How this was verified:** The owner-deletion handlers omit both `apiKeyRecords` and `apiKeyMap`, while authentication continues accepting every unexpired entry retained in that map.

**Knowledge Base Used:**
- [State storage and seed data](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/emulate/-/docs/state-storage-and-seeding.md)
- [Organizations, users, and memberships](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/emulate/-/docs/organizations-users-and-memberships.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid and already fixed locally in commit 95ed139:

  • Revokes user-owned keys when deleting a user.
  • Revokes organization- and member-owned keys when deleting an organization.
  • Removes both stored records and live authentication entries.
  • Adds regression coverage; 69 focused tests pass.

This Greptile comment still targets remote SHA 5a12133 because GitHub rejected TARS’s push. PR #87 remains unchanged; a verified human follow-up is required before TARS can retry publishing the fix.

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
Expand Down Expand Up @@ -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<ApiKeyMap>(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,
);
});
}
Loading