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
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,11 +415,33 @@ connectApplications:
client_id: client_local_backend # optional; generated if omitted
client_secret: secret_local_backend # optional; generated if omitted
audience: https://api.acme.example # optional; the token `aud` claim, defaults to client_id

- name: Partner App
type: oauth
is_first_party: false # optional, oauth only; a third-party app needs `organization`
organization: Acme Corp
uses_pkce: true # optional, oauth only; reported on the app, not enforced
```

Each seeded application is provisioned with a client secret. Pin `client_secret` to bake a known
value into a service's environment; otherwise one is generated. The application is then available
via `GET /connect/applications`.
through the full Connect Applications surface:

| Method | Path | Notes |
| -------- | ------------------------------------------ | ----------------------------------------------------- |
| `GET` | `/connect/applications` | Filters on `organization_id` and `registration_types` |
| `POST` | `/connect/applications` | |
| `GET` | `/connect/applications/:id` | `:id` is the application ID **or** the client ID |
| `PUT` | `/connect/applications/:id` | `name`, `description`, `scopes`, `redirect_uris` |
| `DELETE` | `/connect/applications/:id` | Cascades secrets and in-flight Connect logins |
| `GET` | `/connect/applications/:id/client_secrets` | A bare array; never includes the plaintext |
| `POST` | `/connect/applications/:id/client_secrets` | The one response carrying the plaintext, as `secret` |
| `DELETE` | `/connect/client_secrets/:id` | |

`registration_types` defaults to `authenticated`, as production does — nothing in the emulator
performs dynamic client registration, so an unfiltered list shows every application it can create.
A secret's `last_used_at` is stamped when a token exchange actually succeeds, not merely when the
secret is presented.

#### Token exchange (`client_credentials`)

Expand Down Expand Up @@ -530,6 +552,22 @@ at token exchange. The emulator's completion URL uses `/oauth2/authorize/complet
not production's AuthKit-domain `/oauth/authorize/complete?state=...`; always follow the returned URL
rather than constructing it. This is a local testing flow, not a replacement authentication service.

### Client API tokens

`POST /client/token` mints the short-lived token the Client GraphQL API expects, scoped to an
organization and a user:

```bash
curl -X POST http://localhost:4100/client/token \
-H "Authorization: Bearer sk_test_ci_key" -H "Content-Type: application/json" \
-d '{"organization_id":"org_01K...","user_id":"user_01K..."}'
```

Unknown ids return `404`. The token is signed with the emulator key, so it verifies against
`/sso/jwks`, and carries `sub`, `org_id`, and `aud: client` with a five-minute expiry. The spec
documents only the `{ token }` response, so those claims are an emulator convention — and the
emulator does not serve the Client GraphQL API itself, so nothing consumes the token.

### API Keys

Seed organization- or user-owned API keys. Each seeded key is created as an `api_key` resource
Expand Down
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 **180 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**72.0%**).
The emulator implements **184 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**73.6%**).

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 @@ -34,7 +34,7 @@ answers "can I actually emulate this?".
| Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. |
| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. |
| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connected-account CRUD and access-token retrieval are supported; a refresh mints a local `di_mock_` token rather than contacting the provider. The older `/pipes/connections` routes remain emulator-specific. |
| Applications | ⚠️ 4/5 | ⚠️ 5/8 | ✅ seed `connectApplications` | |
| Applications | ✅ 5/5 | ✅ 8/8 | ✅ seed `connectApplications` | A redirect URI is stored as a bare string, so `default` is accepted on create and update but always reported as `false`. `uses_pkce` and `is_first_party` are stored and reported, but nothing is registered dynamically, so `was_dynamically_registered` is always `false` and the list route's `registration_types` filter only ever matches `authenticated`. `POST /client/token` mints a signed, short-lived token, but the spec documents only the `{ token }` envelope — the claims inside are an emulator convention, and no Client GraphQL API is served for it to authenticate against. |
| JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. |
| Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. |
| Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. |
Expand Down
18 changes: 18 additions & 0 deletions scripts/gen-shapes-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [
// depth — enrollment's secrets are pinned by the route tests instead.
{ objectType: 'authentication_factor', schemaName: 'AuthenticationFactor' },
{ objectType: 'authentication_challenge', schemaName: 'AuthenticationChallenge' },
// The spec names only the secret-bearing creation shape; the list route's secretless
// variant is an inline schema, so `secret` is carried as a tracked gap on this entry.
{ objectType: 'connect_application_secret', schemaName: 'NewConnectApplicationSecret' },
// `connect_application` is deliberately absent: `ConnectApplication` is an allOf over a
// four-way oneOf (first-party / dynamically registered / third-party oauth, and m2m), and
// this catalog models one flat shape per object. Flattening it would drop the discriminated
// fields — the exact thing resolveSchema refuses to do — so each variant is pinned by a route
// test in src/workos/routes/connect.spec.ts instead.
];

export interface EnvelopeMapEntry {
Expand Down Expand Up @@ -116,6 +124,16 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [
schemaName: 'AuthorizationCheck',
},
{ method: 'GET', path: '/sso/jwks/{clientId}', status: '200', schemaName: 'JwksResponse' },
{ method: 'POST', path: '/client/token', status: '201', schemaName: 'ClientApiTokenResponse' },
// The created secret is the one place the plaintext `secret` is ever returned, so the
// envelope and the resource are the same body — listed here because no named spec schema
// covers the secretless variant the list route serves.
{
method: 'POST',
path: '/connect/applications/{id}/client_secrets',
status: '201',
schemaName: 'NewConnectApplicationSecret',
},
// MFA. Enrollment is the envelope that went out bare for several releases (issue #110): every
// SDK reads `{ authentication_factor, authentication_challenge }`, and none could enroll a
// factor through the emulator. The legacy `/auth` routes are resource bodies, listed for the
Expand Down
2 changes: 2 additions & 0 deletions scripts/gen-supported-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export const FEATURES: FeatureDef[] = [
'workos-connect',
],
seedKeys: ['connectApplications'],
notes:
"A redirect URI is stored as a bare string, so `default` is accepted on create and update but always reported as `false`. `uses_pkce` and `is_first_party` are stored and reported, but nothing is registered dynamically, so `was_dynamically_registered` is always `false` and the list route's `registration_types` filter only ever matches `authenticated`. `POST /client/token` mints a signed, short-lived token, but the spec documents only the `{ token }` envelope — the claims inside are an emulator convention, and no Client GraphQL API is served for it to authenticate against.",
},
{
name: 'JWT Templates',
Expand Down
4 changes: 2 additions & 2 deletions src/core/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ export const ID_PREFIXES = {
audit_log_export: 'audit_export',
feature_flag: 'flag',
flag_target: 'flag_target',
connect_application: 'connect_app',
client_secret: 'client_secret',
connect_application: 'conn_app',
client_secret: 'secret',
data_integration_auth: 'di_auth',
radar_attempt: 'radar_attempt',
webhook_endpoint: 'we',
Expand Down
24 changes: 22 additions & 2 deletions src/workos/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -934,10 +934,30 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe
});
}
const type = appConfig.type ?? 'm2m';
if (type === 'm2m' && (!appConfig.organization || typeof appConfig.organization !== 'string')) {
if (appConfig.is_first_party !== undefined && typeof appConfig.is_first_party !== 'boolean') {
errors.push({
path: `connectApplications[${index}].is_first_party`,
message: 'is_first_party must be a boolean if provided',
value: appConfig.is_first_party,
});
}
if (appConfig.uses_pkce !== undefined && typeof appConfig.uses_pkce !== 'boolean') {
errors.push({
path: `connectApplications[${index}].uses_pkce`,
message: 'uses_pkce must be a boolean if provided',
value: appConfig.uses_pkce,
});
}
// A third-party oauth application is reported with its owning organization, so it
// needs one for the same reason an m2m application does.
const needsOrganization = type === 'm2m' || appConfig.is_first_party === false;
if (needsOrganization && (!appConfig.organization || typeof appConfig.organization !== 'string')) {
errors.push({
path: `connectApplications[${index}].organization`,
message: 'organization is required for m2m applications',
message:
type === 'm2m'
? 'organization is required for m2m applications'
: 'organization is required when is_first_party is false',
value: appConfig.organization,
});
}
Expand Down
11 changes: 9 additions & 2 deletions src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,17 +495,24 @@ export interface WorkOSConnectApplication extends Entity {
/** The `aud` claim minted into m2m tokens. Falls back to client_id when null. */
audience: string | null;
redirect_uris: string[];
/** oauth only. A third-party application (`false`) names the organization it belongs to. */
is_first_party: boolean;
/** oauth third-party only: registered through dynamic client registration rather than the dashboard. */
was_dynamically_registered: boolean;
uses_pkce: boolean;
/** Emulator-only Standalone Connect login page; never serialized on the API application. */
login_url: string | null;
client_id: string;
logo_url: string | null;
}

export interface WorkOSClientSecret extends Entity {
object: 'client_secret';
object: 'connect_application_secret';
application_id: string;
/** The plaintext secret. Returned once at creation and never serialized again. */
value: string;
last_four: string;
secret_hint: string;
last_used_at: string | null;
}

export interface WorkOSDataIntegrationAuth extends Entity {
Expand Down
15 changes: 15 additions & 0 deletions src/workos/generated/response-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record<string, ResponseShapeRequiremen
properties: ['created_at', 'id', 'object', 'sms', 'totp', 'type', 'updated_at', 'user_id'],
required: ['created_at', 'id', 'object', 'type', 'updated_at'],
},
connect_application_secret: {
schema: 'NewConnectApplicationSecret',
properties: ['created_at', 'id', 'last_used_at', 'object', 'secret', 'secret_hint', 'updated_at'],
required: ['created_at', 'id', 'last_used_at', 'object', 'secret', 'secret_hint', 'updated_at'],
},
connection: {
schema: 'Connection',
properties: [
Expand Down Expand Up @@ -415,6 +420,16 @@ export const RESPONSE_ENVELOPE_REQUIREMENTS: Record<string, ResponseShapeRequire
properties: ['authorized'],
required: ['authorized'],
},
'POST /client/token': {
schema: 'ClientApiTokenResponse',
properties: ['token'],
required: ['token'],
},
'POST /connect/applications/{id}/client_secrets': {
schema: 'NewConnectApplicationSecret',
properties: ['created_at', 'id', 'last_used_at', 'object', 'secret', 'secret_hint', 'updated_at'],
required: ['created_at', 'id', 'last_used_at', 'object', 'secret', 'secret_hint', 'updated_at'],
},
'POST /portal/generate_link': {
schema: 'PortalLinkResponse',
properties: ['link'],
Expand Down
21 changes: 17 additions & 4 deletions src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1140,16 +1140,29 @@ export function formatConnectApplication(a: WorkOSConnectApplication): Record<st
return { ...base, application_type: 'm2m', organization_id: a.organization_id, audience: a.audience };
}

return {
const oauth = {
...base,
application_type: 'oauth',
redirect_uris: a.redirect_uris.map((uri) => ({ uri, default: false })),
uses_pkce: false,
is_first_party: true,
uses_pkce: a.uses_pkce,
};

// The spec's oauth branch is a three-way oneOf on how the application came to exist, and each
// arm carries a different field set: a first-party app names nothing else, a dynamically
// registered one says so, and a third-party one must name its owning organization.
if (a.is_first_party) return { ...oauth, is_first_party: true };
if (a.was_dynamically_registered) return { ...oauth, is_first_party: false, was_dynamically_registered: true };
return {
...oauth,
is_first_party: false,
was_dynamically_registered: false,
organization_id: a.organization_id,
};
}

const CLIENT_SECRET_EXCLUDE = new Set([...INTERNAL_FIELDS, 'value']);
// `application_id` is the emulator's foreign key, not a spec field: the secret is always
// addressed through its application, so the spec's shape never restates the owner.
const CLIENT_SECRET_EXCLUDE = new Set([...INTERNAL_FIELDS, 'value', 'application_id']);

export function formatClientSecret(s: WorkOSClientSecret): Record<string, unknown> {
return formatEntity(s, { exclude: CLIENT_SECRET_EXCLUDE });
Expand Down
Loading
Loading