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
183 changes: 178 additions & 5 deletions README.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Supported Features

The emulator implements **199 of 264** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.123.0`) (**75.4%**).
The emulator implements **202 of 264** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.123.0`) (**76.5%**).

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,11 +34,11 @@ 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/8 | ⚠️ 4/19 | ✅ seed `connectedAccounts` | User-owned connected-account CRUD and access-token retrieval are supported; a refresh mints a local `di_mock_` token rather than contacting the provider. Organization-owned connected accounts are not implemented. The older `/pipes/connections` routes remain emulator-specific. |
| 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. |
| 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; `uses_pkce` makes a public client, whose codes `/oauth2/token` redeems by PKCE verifier alone and which may present no client secret. `POST /oauth2/register` (RFC 7591, outside the spec) creates `was_dynamically_registered` applications, which `registration_types` filters. `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. |
| AuthKit Configuration | ❌ 0/3 | ⚠️ 2/5 | ⚠️ API only | Redirect URIs are accepted but not enforced against authorize requests. OAuth resource indicators are not implemented. |
| AuthKit Configuration | ⚠️ 1/3 | ⚠️ 4/5 | ✅ seed `resourceIndicators` | Redirect URIs are accepted but not enforced against authorize requests. MCP resource indicators (RFC 8707) are stored and, when a request names a registered one, become the `aud` of tokens from `/oauth2/token`; once any is registered, a dynamically registered client naming an unregistered one at `/oauth2/authorize` is answered `invalid_target`, and one naming none is bound to the resource flagged `default`. Resources are matched by normalized URL; wildcard patterns are refused. |
| Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. |
| Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `<ApiKeys>` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. |
| Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. |
Expand Down
10 changes: 10 additions & 0 deletions scripts/gen-shapes-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [
// 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' },
{ objectType: 'authkit_oauth_resource', schemaName: 'AuthkitOauthResource' },
// `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
Expand Down Expand Up @@ -121,6 +122,15 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [
// one that drifted to a bare `token` (issue #98). The resource catalog only sees the formatter.
{ method: 'POST', path: '/user_management/password_reset', status: '201', schemaName: 'PasswordReset' },
{ method: 'GET', path: '/user_management/password_reset/{id}', status: '200', schemaName: 'PasswordReset' },
// The create route's body is the resource itself. Its list route is not here: the spec gives
// that envelope inline (an allOf over the paginated list with `AuthkitOauthResource` items)
// rather than by name, and this catalog asserts a named schema per operation.
{
method: 'POST',
path: '/user_management/authkit_oauth_resources',
status: '201',
schemaName: 'AuthkitOauthResource',
},
{
method: 'POST',
path: '/user_management/users/{id}/email_verification/send',
Expand Down
5 changes: 3 additions & 2 deletions scripts/gen-supported-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export const FEATURES: FeatureDef[] = [
],
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.",
'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; `uses_pkce` makes a public client, whose codes `/oauth2/token` redeems by PKCE verifier alone and which may present no client secret. `POST /oauth2/register` (RFC 7591, outside the spec) creates `was_dynamically_registered` applications, which `registration_types` filters. `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 All @@ -233,8 +233,9 @@ export const FEATURES: FeatureDef[] = [
{
name: 'AuthKit Configuration',
tags: ['user-management.redirect-uris', 'user-management.cors-origins', 'user-management.authkit-oauth-resources'],
seedKeys: ['resourceIndicators'],
notes:
'Redirect URIs are accepted but not enforced against authorize requests. OAuth resource indicators are not implemented.',
'Redirect URIs are accepted but not enforced against authorize requests. MCP resource indicators (RFC 8707) are stored and, when a request names a registered one, become the `aud` of tokens from `/oauth2/token`; once any is registered, a dynamically registered client naming an unregistered one at `/oauth2/authorize` is answered `invalid_target`, and one naming none is bound to the resource flagged `default`. Resources are matched by normalized URL; wildcard patterns are refused.',
},
{
name: 'Admin Portal',
Expand Down
1 change: 1 addition & 0 deletions src/core/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const ID_PREFIXES = {
role: 'role',
permission: 'perm',
role_permission: 'rp',
authkit_oauth_resource: 'authkit_oauth_resource',
authorization_resource: 'authz_resource',
role_assignment: 'role_assignment',
group_role_assignment: 'gra',
Expand Down
18 changes: 15 additions & 3 deletions src/core/middleware/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,28 @@ export class WorkOSApiError extends Error {
* (which always carry `{code, message}`) need no special casing.
*/
export class OauthApiError extends WorkOSApiError {
constructor(status: number, error: string, description: string) {
super(status, description, error);
/** Response headers the failure carries, e.g. the `WWW-Authenticate` a 401 invalid_client needs (RFC 6749 §5.2). */
readonly headers?: Record<string, string>;

/**
* `description` is optional because production's OAuth2 exception renders `error_description`
* only when one was given: its code and refresh-token failures are the bare `{ error }`.
*/
constructor(status: number, error: string, description?: string, headers?: Record<string, string>) {
super(status, description ?? '', error);
this.name = 'OauthApiError';
this.headers = headers;
}
}

export function createApiErrorHandler(): ErrorHandler {
return (err, c) => {
if (err instanceof OauthApiError) {
return c.json({ error: err.code, error_description: err.message }, err.status as ContentfulStatusCode);
return c.json(
{ error: err.code, ...(err.message ? { error_description: err.message } : {}) },
err.status as ContentfulStatusCode,
err.headers,
);
}
if (err instanceof WorkOSApiError) {
const body: Record<string, unknown> = {
Expand Down
7 changes: 6 additions & 1 deletion src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export interface ServerOptions {
* constant accepts emulator tokens unchanged.
*
* Not the whole claim: an AuthKit access token carries `{issuer}/user_management/{client_id}`,
* as production does. Only the M2M, SSO and widget tokens carry the bare value.
* as production does. Only the M2M, SSO, widget and OAuth-server (`/oauth2/token`) tokens carry
* the bare value.
*/
issuer?: string;
/** Pinned RSA signing key, keeping the JWKS stable across restarts. */
Expand Down Expand Up @@ -84,6 +85,10 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
'/user_management/authorize/device/verify',
'/user_management/authenticate',
'/user_management/sessions/logout',
// The AuthKit domain's discovery documents, fetched by an OAuth client before it holds any
// credential. Only these two: the RFC 8414 path-inserted form is not served (see the routes).
'/.well-known/oauth-authorization-server',
'/.well-known/openid-configuration',
]);

// /oauth2/* is the M2M authorization server: the token endpoint authenticates by
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface EmulatorSeedConfig {
permissions?: WorkOSSeedConfig['permissions'];
webhookEndpoints?: WorkOSSeedConfig['webhookEndpoints'];
connectApplications?: WorkOSSeedConfig['connectApplications'];
resourceIndicators?: WorkOSSeedConfig['resourceIndicators'];
jwtTemplate?: WorkOSSeedConfig['jwtTemplate'];
featureFlags?: WorkOSSeedConfig['featureFlags'];
directories?: WorkOSSeedConfig['directories'];
Expand All @@ -64,7 +65,8 @@ export interface EmulatorOptions {
* test-only branch. The verifier must still fetch JWKS from the emulator.
*
* Not the whole claim: an AuthKit access token carries `{issuer}/user_management/{client_id}`,
* as production does. Only the M2M, SSO and widget tokens carry the bare value.
* as production does. Only the M2M, SSO, widget and OAuth-server (`/oauth2/token`) tokens carry
* the bare value, and the latter's discovery documents advertise it as `issuer`.
*/
issuer?: string;
/**
Expand Down
Loading
Loading