Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 45 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -351,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
```
Expand All @@ -361,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

Expand Down
131 changes: 128 additions & 3 deletions scripts/gen-shapes-lib.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, EventSchemaNode>): EventSchemaNode {
return { components: { schemas } } as unknown as EventSchemaNode;
}

/** A spec with both component schemas and paths, for the envelope catalog. */
function specWithPaths(schemas: Record<string, EventSchemaNode>, paths: Record<string, unknown>): 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<string, unknown> {
return {
responses: {
[status]: { content: { 'application/json': { schema: { $ref: `#/components/schemas/${schemaName}` } } } },
},
};
}
function schema(s: EventSchemaNode, name: string): EventSchemaNode {
return (s as { components: { schemas: Record<string, EventSchemaNode> } }).components.schemas[name];
}
Expand Down Expand Up @@ -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'");
});
});
Loading