From c6ebc3d7924ecbab6f421149d58228146401ded5 Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 22 Sep 2026 09:28:20 +0100 Subject: [PATCH 1/3] feat!: complete the Connect Applications contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applications was the emulator's most incomplete surface at 4/5 read and 5/8 write endpoints. This implements the four the spec defines and the emulator did not, and fixes the places the existing routes diverged from it. Added: GET /connect/applications/:id/client_secrets PUT /connect/applications/:id DELETE /connect/applications/:id POST /client/token `GET /connect/applications` now honours the documented `organization_id` and `registration_types` filters, and the application resource carries the `is_first_party`, `was_dynamically_registered` and `uses_pkce` fields the spec's oneOf discriminates on — a third-party OAuth application was previously unrepresentable and always reported as first-party with no owner. DELETE drains the application's client secrets and any in-flight Standalone Connect session, so a login cannot outlive the application it was started for. A secret's `last_used_at` is stamped when an exchange actually produces a token, not merely when the secret is presented. The client secret resource is brought onto the shape-conformance harness, so its plaintext can no longer escape a serializer without a test failing. BREAKING CHANGE: the client secret resource now matches the spec's `NewConnectApplicationSecret`. `object` is `connect_application_secret` rather than `client_secret`, `last_four` is `secret_hint`, the plaintext is returned as `secret` rather than `value`, `last_used_at` is new, and `application_id` is no longer serialized. Connect Application and client secret ids now use the prefixes the spec's own examples use — `conn_app_` and `secret_`, replacing `connect_app_` and `client_secret_`. The `connectApplications` seed key is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 34 +- SUPPORTED.md | 4 +- scripts/gen-shapes-lib.ts | 18 + scripts/gen-supported-lib.ts | 2 + src/core/id.ts | 4 +- src/workos/entities.ts | 11 +- src/workos/generated/response-shapes.ts | 15 + src/workos/helpers.ts | 21 +- src/workos/index.ts | 19 +- src/workos/response-envelopes.spec.ts | 10 + src/workos/response-shapes.spec.ts | 36 +- src/workos/routes/client-api.spec.ts | 66 ++++ src/workos/routes/client-api.ts | 46 +++ src/workos/routes/connect.spec.ts | 434 +++++++++++++++++++++++- src/workos/routes/connect.ts | 208 ++++++++++-- src/workos/routes/oauth.spec.ts | 24 +- src/workos/routes/oauth.ts | 11 +- 17 files changed, 907 insertions(+), 56 deletions(-) create mode 100644 src/workos/routes/client-api.spec.ts create mode 100644 src/workos/routes/client-api.ts diff --git a/README.md b/README.md index c8e89a5..f07ca9d 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,23 @@ connectApplications: 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`) @@ -530,6 +546,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 diff --git a/SUPPORTED.md b/SUPPORTED.md index 00d526a..5375ab3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -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 @@ -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. | diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index c8404cf..350bdf9 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -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 { @@ -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 diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index c84f65c..24c92ad 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -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', diff --git a/src/core/id.ts b/src/core/id.ts index 8d6bfc2..67940c3 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -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', diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 35f1853..23c7837 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -495,6 +495,11 @@ 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; @@ -502,10 +507,12 @@ export interface WorkOSConnectApplication extends Entity { } 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 { diff --git a/src/workos/generated/response-shapes.ts b/src/workos/generated/response-shapes.ts index 2a5c747..c6f6aba 100644 --- a/src/workos/generated/response-shapes.ts +++ b/src/workos/generated/response-shapes.ts @@ -60,6 +60,11 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record ({ 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 { return formatEntity(s, { exclude: CLIENT_SECRET_EXCLUDE }); diff --git a/src/workos/index.ts b/src/workos/index.ts index 43fbce4..63bbe0c 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -33,6 +33,7 @@ import { apiKeyRoutes } from './routes/api-keys.js'; import { vaultRoutes } from './routes/vault.js'; import { radarRoutes } from './routes/radar.js'; import { connectRoutes } from './routes/connect.js'; +import { clientApiRoutes } from './routes/client-api.js'; import { oauthRoutes } from './routes/oauth.js'; import { standaloneConnectRoutes } from './routes/standalone-connect.js'; import { directoryRoutes } from './routes/directories.js'; @@ -318,6 +319,13 @@ export interface WorkOSSeedConnectApplication { client_secret?: string; /** OAuth redirect URIs. Ignored for `m2m` applications. */ redirect_uris?: string[]; + /** + * `oauth` only. A third-party application (`false`) is reported with the organization it + * belongs to, so `organization` is required for one. Defaults to `true`. + */ + is_first_party?: boolean; + /** `oauth` only. Reported on the application; the emulator does not enforce PKCE from it. */ + uses_pkce?: boolean; /** Emulator-only Standalone Connect login page, receiving an external_auth_id. */ login_url?: string | null; } @@ -886,6 +894,11 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee scopes: appConfig.scopes ?? [], audience: appConfig.audience ?? null, redirect_uris: appConfig.redirect_uris ?? [], + is_first_party: appConfig.is_first_party ?? true, + // Seeding is the dashboard's stand-in, and dynamic client registration is a runtime + // act no seed file performs. + was_dynamically_registered: false, + uses_pkce: appConfig.uses_pkce ?? false, login_url: appConfig.login_url ?? null, client_id: appConfig.client_id ?? generateClientId(), logo_url: null, @@ -894,10 +907,11 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee // Always provision a client secret so the seeded app has usable credentials. const secretValue = appConfig.client_secret ?? `secret_${generateVerificationToken()}`; ws.clientSecrets.insert({ - object: 'client_secret', + object: 'connect_application_secret', application_id: application.id, value: secretValue, - last_four: secretValue.slice(-4), + secret_hint: secretValue.slice(-4), + last_used_at: null, }); } } @@ -1081,6 +1095,7 @@ export const workosPlugin: ServicePlugin = { vaultRoutes(ctx); radarRoutes(ctx); connectRoutes(ctx); + clientApiRoutes(ctx); oauthRoutes(ctx); standaloneConnectRoutes(ctx); directoryRoutes(ctx); diff --git a/src/workos/response-envelopes.spec.ts b/src/workos/response-envelopes.spec.ts index f9f7763..cb03714 100644 --- a/src/workos/response-envelopes.spec.ts +++ b/src/workos/response-envelopes.spec.ts @@ -48,6 +48,7 @@ interface Fixtures { userId: string; membershipId: string; clientId: string; + connectApplicationId: string; passwordResetToken: string; passwordResetId: string; factorId: string; @@ -129,6 +130,14 @@ const CASES: readonly EnvelopeCase[] = [ operation: 'POST /auth/challenges/{id}/verify', request: (app, f) => post(`/auth/challenges/${f.challengeId}/verify`, { code: '123456' })(app), }, + { + operation: 'POST /client/token', + request: (app, f) => post('/client/token', { organization_id: f.organizationId, user_id: f.userId })(app), + }, + { + operation: 'POST /connect/applications/{id}/client_secrets', + request: (app, f) => post(`/connect/applications/${f.connectApplicationId}/client_secrets`)(app), + }, { operation: 'GET /organizations', request: get('/organizations') }, { operation: 'GET /user_management/users', request: get('/user_management/users') }, { operation: 'GET /connect/applications', request: get('/connect/applications') }, @@ -246,6 +255,7 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => { userId, membershipId, clientId: 'client_billing', + connectApplicationId: ws.connectApplications.findOneBy('client_id', 'client_billing')!.id, passwordResetToken, passwordResetId, factorId, diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 6926d84..071e5cb 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -32,6 +32,7 @@ import { formatFeatureFlag, formatAuthFactor, formatAuthChallenge, + formatClientSecret, } from './helpers.js'; import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js'; import type { @@ -48,6 +49,7 @@ import type { WorkOSFeatureFlag, WorkOSAuthenticationFactor, WorkOSAuthenticationChallenge, + WorkOSClientSecret, } from './entities.js'; const TS = '2026-01-01T00:00:00.000Z'; @@ -238,6 +240,17 @@ const authChallenge: WorkOSAuthenticationChallenge = { const store = new Store(); const ws = getWorkOSStore(store); +const clientSecret: WorkOSClientSecret = { + id: 'secret_01', + object: 'connect_application_secret', + application_id: 'conn_app_01', + value: 'secret_supersecretplaintext', + secret_hint: 'text', + last_used_at: null, + created_at: TS, + updated_at: TS, +}; + const CASES: ReadonlyArray<{ objectType: string; output: Record }> = [ { objectType: 'user', output: formatUser(user) }, { objectType: 'organization', output: formatOrganization(organization, ws, { domains: [] }) }, @@ -252,6 +265,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record { objectType: 'feature_flag', output: formatFeatureFlag(featureFlag) }, { objectType: 'authentication_factor', output: formatAuthFactor(authFactor) }, { objectType: 'authentication_challenge', output: formatAuthChallenge(authChallenge) }, + { objectType: 'connect_application_secret', output: formatClientSecret(clientSecret) }, ]; /** @@ -262,6 +276,11 @@ const KNOWN_MISSING_REQUIRED: Record = { // Spec models a connection `status` distinct from `state`; the emulator's // WorkOSConnection carries only `state`. connection: ['status'], + // `NewConnectApplicationSecret` is the creation shape, and the plaintext it requires is + // returned exactly once — the create route appends it to this formatter's output. Every + // later read serves the secretless inline shape the spec gives the list route, which is + // this formatter alone, so the gap is the whole point of it. + connect_application_secret: ['secret'], }; /** @@ -281,11 +300,16 @@ const KNOWN_EXTRA_FIELDS: Record = { * * Scope note: this set deliberately omits auth-code/token field names * (`code`, `token`, ...). Those belong to flow resources — email verification, - * magic auth, client secrets — whose formatters intentionally surface the - * value so a test harness can complete the flow without an out-of-band - * channel. The real API hides them; an emulator must not, which is exactly - * why those formatters are not in this catalog. Listing those names here - * would imply a coverage this loop does not provide. + * magic auth — whose formatters intentionally surface the value so a test + * harness can complete the flow without an out-of-band channel. The real API + * hides them; an emulator must not, which is exactly why those formatters are + * not in this catalog. Listing those names here would imply a coverage this + * loop does not provide. + * + * A client secret is not one of them: production returns its plaintext once at + * creation and never again, so `formatClientSecret` strips `value` and the + * create route appends the plaintext itself. `value` therefore belongs here — + * it is the field whose escape this guard exists to catch. * * A password reset is the flow resource that *is* in the catalog: its spec * schema documents `password_reset_token` — the endpoint exists so an app can @@ -296,7 +320,7 @@ const KNOWN_EXTRA_FIELDS: Record = { * returns its raw value only once, at creation, so `formatApiKeyRecord` emits * `obfuscated_value` and never `key` — hence `key` belongs here. */ -const SECRET_FIELDS = new Set(['password_hash', 'code_challenge', 'code_challenge_method', 'key']); +const SECRET_FIELDS = new Set(['password_hash', 'code_challenge', 'code_challenge_method', 'key', 'value']); describe('response shape conformance (format* helpers vs OpenAPI spec)', () => { it('covers exactly the resources in the generated requirements catalog', () => { diff --git a/src/workos/routes/client-api.spec.ts b/src/workos/routes/client-api.spec.ts new file mode 100644 index 0000000..5278363 --- /dev/null +++ b/src/workos/routes/client-api.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { createServer, type ApiKeyMap } from '../../core/index.js'; +import { workosPlugin } from '../index.js'; + +const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; + +function createTestApp() { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); +} + +describe('Client API token', () => { + let app: ReturnType['app']; + let jwt: ReturnType['jwt']; + let organizationId: string; + let userId: string; + + beforeEach(async () => { + const testApp = createTestApp(); + app = testApp.app; + jwt = testApp.jwt; + + // Created through the API rather than the store, so the fixtures stay valid as the + // organization and user entities gain fields. + const create = async (path: string, body: unknown) => { + const res = await app.request(path, { method: 'POST', headers, body: JSON.stringify(body) }); + return (await res.json()) as any; + }; + organizationId = (await create('/organizations', { name: 'Acme' })).id; + userId = (await create('/user_management/users', { email: 'alice@acme.com' })).id; + }); + + const post = (body: unknown) => app.request('/client/token', { method: 'POST', headers, body: JSON.stringify(body) }); + const json = (res: Response) => res.json() as Promise; + + it('mints a token scoped to the organization and user', async () => { + const res = await post({ organization_id: organizationId, user_id: userId }); + expect(res.status).toBe(201); + + const body = await json(res); + expect(Object.keys(body)).toEqual(['token']); + + const claims = JSON.parse(Buffer.from(body.token.split('.')[1], 'base64url').toString()); + expect(claims.sub).toBe(userId); + expect(claims.org_id).toBe(organizationId); + expect(claims.aud).toBe('client'); + expect(claims.exp - claims.iat).toBe(300); + }); + + it('signs with the emulator key, so the token verifies', async () => { + const { token } = await json(await post({ organization_id: organizationId, user_id: userId })); + expect(jwt.verify(token).sub).toBe(userId); + }); + + it('requires organization_id and user_id', async () => { + expect((await post({ user_id: userId })).status).toBe(422); + expect((await post({ organization_id: organizationId })).status).toBe(422); + expect((await post({ organization_id: '', user_id: userId })).status).toBe(422); + expect((await post({ organization_id: organizationId, user_id: 42 })).status).toBe(422); + }); + + it('404s an unknown organization or user', async () => { + expect((await post({ organization_id: 'org_nope', user_id: userId })).status).toBe(404); + expect((await post({ organization_id: organizationId, user_id: 'user_nope' })).status).toBe(404); + }); +}); diff --git a/src/workos/routes/client-api.ts b/src/workos/routes/client-api.ts new file mode 100644 index 0000000..b7d1cef --- /dev/null +++ b/src/workos/routes/client-api.ts @@ -0,0 +1,46 @@ +import { type RouteContext, notFound, parseJsonBody, validationError } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; + +/** + * The `aud` on a Client API token. The spec documents the request and the `{ token }` response + * but says nothing about the claims inside, so this is an emulator convention: it keeps a Client + * API token distinguishable from the session and widget tokens the same key signs. + */ +const CLIENT_API_TOKEN_AUDIENCE = 'client'; + +/** The spec calls the token "short-lived"; five minutes is the shortest plausible reading. */ +const CLIENT_API_TOKEN_TTL_SECONDS = 300; + +export function clientApiRoutes(ctx: RouteContext): void { + const { app, jwt, store } = ctx; + const ws = getWorkOSStore(store); + + // The emulator does not serve the Client GraphQL API, so the token is mintable and verifiable + // against JWKS but has nothing to authenticate against. + app.post('/client/token', async (c) => { + const body = await parseJsonBody(c); + const organizationId = body.organization_id; + const userId = body.user_id; + + if (typeof organizationId !== 'string' || organizationId.length === 0) { + throw validationError('organization_id is required', [{ field: 'organization_id', code: 'required' }]); + } + if (typeof userId !== 'string' || userId.length === 0) { + throw validationError('user_id is required', [{ field: 'user_id', code: 'required' }]); + } + + // The token names both principals, so a dangling id would mint a credential scoped to + // something that cannot be looked up. `/widgets/token` skips this check; the spec + // documents 404 on this route, so it is answered here. Membership is deliberately not + // checked: pairing any user with any organization is how a test sets up a scenario. + if (!ws.organizations.get(organizationId)) throw notFound('Organization'); + if (!ws.users.get(userId)) throw notFound('User'); + + const token = jwt.sign( + { sub: userId, org_id: organizationId, aud: CLIENT_API_TOKEN_AUDIENCE }, + { expiresIn: CLIENT_API_TOKEN_TTL_SECONDS }, + ); + + return c.json({ token }, 201); + }); +} diff --git a/src/workos/routes/connect.spec.ts b/src/workos/routes/connect.spec.ts index 8c44842..b590cf7 100644 --- a/src/workos/routes/connect.spec.ts +++ b/src/workos/routes/connect.spec.ts @@ -33,7 +33,7 @@ describe('Connect routes', () => { expect(app.object).toBe('connect_application'); expect(app.name).toBe('My App'); expect(app.client_id).toBeDefined(); - expect(app.id).toMatch(/^connect_app_/); + expect(app.id).toMatch(/^conn_app_/); }); it('stores the emulator-only login_url without adding it to the API response', async () => { @@ -157,7 +157,7 @@ describe('Connect routes', () => { }); it('returns 404 for nonexistent application', async () => { - const res = await req('/connect/applications/connect_app_nonexistent'); + const res = await req('/connect/applications/conn_app_nonexistent'); expect(res.status).toBe(404); }); @@ -195,9 +195,13 @@ describe('Connect routes', () => { }); expect(secretRes.status).toBe(201); const secret = await json(secretRes); - expect(secret.object).toBe('client_secret'); - expect(secret.value).toBeDefined(); - expect(secret.last_four).toBe(secret.value.slice(-4)); + expect(secret.object).toBe('connect_application_secret'); + expect(secret.secret).toBeDefined(); + expect(secret.secret).toBe(getWorkOSStore(store).clientSecrets.get(secret.id)?.value); + expect(secret.secret_hint).toBe(secret.secret.slice(-4)); + expect(secret.last_used_at).toBeNull(); + // The owner is the emulator's foreign key, not a spec field. + expect(secret.application_id).toBeUndefined(); const delRes = await req(`/connect/client_secrets/${secret.id}`, { method: 'DELETE' }); expect(delRes.status).toBe(204); @@ -214,7 +218,423 @@ describe('Connect routes', () => { const res = await req(`/connect/applications/${application.client_id}/client_secrets`, { method: 'POST' }); expect(res.status).toBe(201); const secret = await json(res); - expect(secret.object).toBe('client_secret'); - expect(secret.application_id).toBe(application.id); + expect(secret.object).toBe('connect_application_secret'); + expect(getWorkOSStore(store).clientSecrets.get(secret.id)?.application_id).toBe(application.id); + }); + + it('lists client secrets oldest first, without the plaintext value', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'Secret List' }) }), + ); + const first = await json(await req(`/connect/applications/${application.id}/client_secrets`, { method: 'POST' })); + const second = await json(await req(`/connect/applications/${application.id}/client_secrets`, { method: 'POST' })); + // Back-dated so insertion order and creation order disagree: without the sort, ids alone + // would already come back in the asserted order and the assertion could never fail. + getWorkOSStore(store).clientSecrets.updateSilent(second.id, { created_at: '2020-01-01T00:00:00.000Z' }); + + const res = await req(`/connect/applications/${application.id}/client_secrets`); + expect(res.status).toBe(200); + const secrets = await json(res); + // A bare array, not the `list` envelope the other collection routes return. + expect(Array.isArray(secrets)).toBe(true); + expect(secrets.map((s: any) => s.id)).toEqual([second.id, first.id]); + expect(secrets[1].object).toBe('connect_application_secret'); + expect(secrets[1].secret).toBeUndefined(); + // Pinned against the stored plaintext, not against the response's own hint. + expect(secrets[1].secret_hint).toBe(getWorkOSStore(store).clientSecrets.get(first.id)!.value.slice(-4)); + }); + + it('lists client secrets for an application referenced by client_id', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'Secret List By Client' }) }), + ); + await req(`/connect/applications/${application.id}/client_secrets`, { method: 'POST' }); + + const res = await req(`/connect/applications/${application.client_id}/client_secrets`); + expect(res.status).toBe(200); + expect(await json(res)).toHaveLength(1); + }); + + it('returns an empty array for an application with no client secrets', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'No Secrets' }) }), + ); + expect(await json(await req(`/connect/applications/${application.id}/client_secrets`))).toEqual([]); + }); + + it('404s listing client secrets for an unknown application', async () => { + expect((await req('/connect/applications/conn_app_nope/client_secrets')).status).toBe(404); + }); + + it('updates name, description and scopes', async () => { + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Before', description: 'old', scopes: ['openid'] }), + }), + ); + + const res = await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ name: ' After ', description: null, scopes: ['openid', 'profile'] }), + }); + expect(res.status).toBe(200); + const updated = await json(res); + expect(updated.name).toBe('After'); + expect(updated.description).toBeNull(); + expect(updated.scopes).toEqual(['openid', 'profile']); + expect(updated.id).toBe(application.id); + expect(updated.client_id).toBe(application.client_id); + + // The update response is a merge of the patch, so re-read to prove it landed in the store. + const reread = await json(await req(`/connect/applications/${application.id}`)); + expect(reread).toEqual(updated); + }); + + it('leaves omitted fields untouched', async () => { + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Keep', description: 'kept', scopes: ['openid'] }), + }), + ); + + const updated = await json( + await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ name: 'Renamed' }), + }), + ); + expect(updated.description).toBe('kept'); + expect(updated.scopes).toEqual(['openid']); + }); + + it('updates redirect_uris in both the spec and string forms', async () => { + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Redirects', redirect_uris: ['http://localhost:3000/a'] }), + }), + ); + + const specForm = await json( + await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ redirect_uris: [{ uri: 'http://localhost:3000/b', default: true }] }), + }), + ); + expect(specForm.redirect_uris).toEqual([{ uri: 'http://localhost:3000/b', default: false }]); + + const stringForm = await json( + await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ redirect_uris: ['http://localhost:3000/c'] }), + }), + ); + expect(stringForm.redirect_uris).toEqual([{ uri: 'http://localhost:3000/c', default: false }]); + }); + + it('accepts the spec redirect_uris form on create', async () => { + const created = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Spec Redirects', redirect_uris: [{ uri: 'http://localhost:3000/cb' }] }), + }), + ); + expect(created.redirect_uris).toEqual([{ uri: 'http://localhost:3000/cb', default: false }]); + expect(getWorkOSStore(store).connectApplications.get(created.id)?.redirect_uris).toEqual([ + 'http://localhost:3000/cb', + ]); + }); + + it('clears list fields on an explicit null', async () => { + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Clear', scopes: ['openid'], redirect_uris: ['http://localhost:3000/a'] }), + }), + ); + + const updated = await json( + await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ scopes: null, redirect_uris: null }), + }), + ); + expect(updated.scopes).toEqual([]); + expect(updated.redirect_uris).toEqual([]); + }); + + it('updates an application referenced by client_id', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'By Client ID' }) }), + ); + + const updated = await json( + await req(`/connect/applications/${application.client_id}`, { + method: 'PUT', + body: JSON.stringify({ name: 'Updated By Client ID' }), + }), + ); + expect(updated.id).toBe(application.id); + expect(updated.name).toBe('Updated By Client ID'); + }); + + it('rejects redirect_uris on an m2m application', async () => { + const org = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'M2M Org' }) })); + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'M2M', application_type: 'm2m', organization_id: org.id }), + }), + ); + + const res = await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ redirect_uris: ['http://localhost:3000/cb'] }), + }); + expect(res.status).toBe(422); + }); + + it('rejects invalid update payloads', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'Validation' }) }), + ); + const put = (body: unknown) => + req(`/connect/applications/${application.id}`, { method: 'PUT', body: JSON.stringify(body) }); + + expect((await put({ name: ' ' })).status).toBe(422); + expect((await put({ name: null })).status).toBe(422); + expect((await put({ description: 42 })).status).toBe(422); + expect((await put({ scopes: 'openid' })).status).toBe(422); + expect((await put({ scopes: [1] })).status).toBe(422); + expect((await put({ redirect_uris: 'http://localhost:3000/cb' })).status).toBe(422); + expect((await put({ redirect_uris: [{ default: true }] })).status).toBe(422); + }); + + it('404s updating an unknown application', async () => { + const res = await req('/connect/applications/conn_app_nope', { + method: 'PUT', + body: JSON.stringify({ name: 'Nope' }), + }); + expect(res.status).toBe(404); + }); + + it('deletes an application and its client secrets', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'Doomed' }) }), + ); + const secret = await json(await req(`/connect/applications/${application.id}/client_secrets`, { method: 'POST' })); + + const res = await req(`/connect/applications/${application.id}`, { method: 'DELETE' }); + expect(res.status).toBe(204); + expect((await req(`/connect/applications/${application.id}`)).status).toBe(404); + expect(getWorkOSStore(store).clientSecrets.get(secret.id)).toBeUndefined(); + }); + + it('deletes an application referenced by client_id', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'Doomed By Client ID' }) }), + ); + expect((await req(`/connect/applications/${application.client_id}`, { method: 'DELETE' })).status).toBe(204); + expect(getWorkOSStore(store).connectApplications.get(application.id)).toBeUndefined(); + }); + + it('404s deleting an unknown application', async () => { + expect((await req('/connect/applications/conn_app_nope', { method: 'DELETE' })).status).toBe(404); + }); + + // The spec's oauth application is a oneOf on how the application came to exist, and each arm + // carries a different field set. The generated shape catalog models one flat shape per object, + // so it cannot express this — these pin the arms instead. + it('reports a first-party oauth application without an owner', async () => { + const created = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'First Party' }) }), + ); + expect(created.is_first_party).toBe(true); + expect(created.organization_id).toBeUndefined(); + expect(created.was_dynamically_registered).toBeUndefined(); + expect(created.uses_pkce).toBe(false); + }); + + it('reports a third-party oauth application with its owning organization', async () => { + const org = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Owner' }) })); + const created = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Third Party', is_first_party: false, organization_id: org.id, uses_pkce: true }), + }), + ); + expect(created.is_first_party).toBe(false); + expect(created.was_dynamically_registered).toBe(false); + expect(created.organization_id).toBe(org.id); + expect(created.uses_pkce).toBe(true); + }); + + it('requires an organization for a third-party oauth application', async () => { + const res = await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Ownerless', is_first_party: false }), + }); + expect(res.status).toBe(422); + }); + + it('rejects redirect_uris on an m2m application at create', async () => { + const org = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'M2M Org' }) })); + const res = await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ + name: 'M2M', + application_type: 'm2m', + organization_id: org.id, + redirect_uris: ['http://localhost:3000/cb'], + }), + }); + expect(res.status).toBe(422); + }); + + it('rejects blank redirect_uris on create and update', async () => { + const blank = { name: 'Blank', redirect_uris: [''] }; + expect((await req('/connect/applications', { method: 'POST', body: JSON.stringify(blank) })).status).toBe(422); + + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'Blank Update' }) }), + ); + const res = await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ redirect_uris: [' '] }), + }); + expect(res.status).toBe(422); + }); + + it('treats an empty update body as a no-op rather than a parse error', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'No Body' }) }), + ); + const res = await req(`/connect/applications/${application.id}`, { method: 'PUT' }); + expect(res.status).toBe(200); + expect((await json(res)).name).toBe('No Body'); + }); + + it('ignores identity fields sent in an update body', async () => { + const application = await json( + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'Immutable' }) }), + ); + const updated = await json( + await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ + id: 'conn_app_evil', + client_id: 'client_evil', + object: 'evil', + application_type: 'm2m', + is_first_party: false, + name: 'Renamed', + }), + }), + ); + expect(updated.id).toBe(application.id); + expect(updated.client_id).toBe(application.client_id); + expect(updated.object).toBe('connect_application'); + expect(updated.application_type).toBe('oauth'); + expect(updated.is_first_party).toBe(true); + expect(updated.name).toBe('Renamed'); + }); + + it('updates the emulator-only login_url', async () => { + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Login URL', login_url: 'http://localhost:3000/login' }), + }), + ); + const res = await req(`/connect/applications/${application.id}`, { + method: 'PUT', + body: JSON.stringify({ login_url: 'http://localhost:3000/signin' }), + }); + expect(res.status).toBe(200); + expect(getWorkOSStore(store).connectApplications.get(application.id)?.login_url).toBe( + 'http://localhost:3000/signin', + ); + }); + + it('filters the list by organization and registration type', async () => { + const org = await json( + await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Filter Org' }) }), + ); + const mine = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Mine', is_first_party: false, organization_id: org.id }), + }), + ); + await req('/connect/applications', { method: 'POST', body: JSON.stringify({ name: 'Theirs' }) }); + + const filtered = await json(await req(`/connect/applications?organization_id=${org.id}`)); + expect(filtered.data.map((a: any) => a.id)).toEqual([mine.id]); + + // "Defaults to `authenticated` only when not specified" — nothing here was dynamically + // registered, so asking for dynamic alone must come back empty. + expect((await json(await req('/connect/applications?registration_types=dynamic'))).data).toEqual([]); + expect((await json(await req('/connect/applications?registration_types=dynamic,authenticated'))).data).toHaveLength( + 2, + ); + expect((await req('/connect/applications?registration_types=nonsense')).status).toBe(422); + }); + + it('stops the m2m grant when the application is deleted', async () => { + const org = await json( + await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Grant Org' }) }), + ); + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Grant', application_type: 'm2m', organization_id: org.id }), + }), + ); + const secret = await json(await req(`/connect/applications/${application.id}/client_secrets`, { method: 'POST' })); + + const exchange = () => + app.request('/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=client_credentials&client_id=${application.client_id}&client_secret=${secret.secret}`, + }); + expect((await exchange()).status).toBe(200); + + await req(`/connect/applications/${application.id}`, { method: 'DELETE' }); + expect((await exchange()).status).toBe(401); + }); + + it('records last_used_at only when an exchange produces a token', async () => { + const org = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Used Org' }) })); + const application = await json( + await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ + name: 'Used', + application_type: 'm2m', + organization_id: org.id, + scopes: ['posts:read'], + }), + }), + ); + const secret = await json(await req(`/connect/applications/${application.id}/client_secrets`, { method: 'POST' })); + const exchange = (extra: string) => + app.request('/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=client_credentials&client_id=${application.client_id}&client_secret=${secret.secret}${extra}`, + }); + const listed = async () => (await json(await req(`/connect/applications/${application.id}/client_secrets`)))[0]; + + // A rejected exchange presented the secret but never produced a token. + expect((await exchange('&scope=posts:write')).status).toBe(400); + expect((await listed()).last_used_at).toBeNull(); + + expect((await exchange('')).status).toBe(200); + expect((await listed()).last_used_at).not.toBeNull(); + // Using a secret is not an edit to it. + expect((await listed()).updated_at).toBe(getWorkOSStore(store).clientSecrets.get(secret.id)!.created_at); }); }); diff --git a/src/workos/routes/connect.ts b/src/workos/routes/connect.ts index 4cb1743..23bf561 100644 --- a/src/workos/routes/connect.ts +++ b/src/workos/routes/connect.ts @@ -1,4 +1,5 @@ import { type RouteContext, notFound, parseJsonBody, validationError, parseListParams } from '../../core/index.js'; +import type { WorkOSConnectApplication } from '../entities.js'; import { getWorkOSStore } from '../store.js'; import { formatConnectApplication, @@ -8,6 +9,39 @@ import { formatListResponse, } from '../helpers.js'; +/** + * Redirect URIs as the spec sends them (`RedirectUriDto`: `{ uri, default? }`) reduced to the + * bare URI strings the emulator stores. Plain strings are accepted too, because the create + * route has always taken that shorter form and the `connectApplications[].redirect_uris` seed + * key is typed as `string[]`. + * + * `default` is parsed and discarded: nothing in the emulator distinguishes a default callback, + * and `formatConnectApplication` reports `default: false` for every URI. + */ +function parseRedirectUris(value: unknown): string[] { + const field = 'redirect_uris'; + if (!Array.isArray(value)) { + throw validationError(`${field} must be an array`, [{ field, code: 'invalid' }]); + } + return value.map((entry) => { + const uri = + typeof entry === 'string' + ? entry + : entry && typeof entry === 'object' && typeof (entry as { uri?: unknown }).uri === 'string' + ? (entry as { uri: string }).uri + : undefined; + if (uri === undefined) { + throw validationError(`${field} entries must be a string or an object with a uri`, [{ field, code: 'invalid' }]); + } + // A blank entry is worse than none: `/oauth2/authorize` treats a non-empty list as an + // allow-list, so one empty string locks out every callback the app could present. + if (uri.trim().length === 0) { + throw validationError(`${field} entries must not be blank`, [{ field, code: 'invalid' }]); + } + return uri; + }); +} + export function connectRoutes(ctx: RouteContext): void { const { app, store } = ctx; const ws = getWorkOSStore(store); @@ -18,11 +52,37 @@ export function connectRoutes(ctx: RouteContext): void { const findApplication = (ref: string) => ws.connectApplications.get(ref) ?? ws.connectApplications.findOneBy('client_id', ref); + const requireApplication = (ref: string): WorkOSConnectApplication => { + const application = findApplication(ref); + if (!application) throw notFound('ConnectApplication'); + return application; + }; + // List applications app.get('/connect/applications', (c) => { const url = new URL(c.req.url); const params = parseListParams(url); - const result = ws.connectApplications.list({ ...params }); + + const organizationId = url.searchParams.get('organization_id') ?? undefined; + // "Defaults to `authenticated` only when not specified" — so an unfiltered list hides + // dynamically registered applications rather than showing everything. + const registrationTypes = (url.searchParams.get('registration_types') ?? 'authenticated') + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + const unknown = registrationTypes.filter((t) => t !== 'dynamic' && t !== 'authenticated'); + if (unknown.length > 0) { + throw validationError(`registration_types must be 'dynamic' or 'authenticated': ${unknown.join(', ')}`, [ + { field: 'registration_types', code: 'invalid' }, + ]); + } + + const result = ws.connectApplications.list({ + ...params, + filter: (a) => + (organizationId === undefined || a.organization_id === organizationId) && + registrationTypes.includes(a.was_dynamically_registered ? 'dynamic' : 'authenticated'), + }); return c.json(formatListResponse(result, formatConnectApplication)); }); @@ -47,14 +107,28 @@ export function connectRoutes(ctx: RouteContext): void { const applicationType = body.application_type === 'm2m' ? 'm2m' : 'oauth'; const organizationId = (body.organization_id as string) ?? null; + + if (body.is_first_party !== undefined && typeof body.is_first_party !== 'boolean') { + throw validationError('is_first_party must be a boolean', [{ field: 'is_first_party', code: 'invalid' }]); + } + if (body.uses_pkce !== undefined && body.uses_pkce !== null && typeof body.uses_pkce !== 'boolean') { + throw validationError('uses_pkce must be a boolean or null', [{ field: 'uses_pkce', code: 'invalid' }]); + } + // The spec marks `is_first_party` required on an oauth create; defaulting to true keeps + // the bodies that predate it creating the same first-party application they always did. + const isFirstParty = (body.is_first_party as boolean | undefined) ?? true; + // m2m applications are owned by an organization; reject a null or dangling owner so // the emulator never returns an m2m app (or later signs a token) for an org that - // doesn't exist. - if (applicationType === 'm2m') { + // doesn't exist. A third-party oauth application names an owner for the same reason. + if (applicationType === 'm2m' || !isFirstParty) { if (!organizationId) { - throw validationError('organization_id is required for m2m applications', [ - { field: 'organization_id', code: 'required' }, - ]); + throw validationError( + applicationType === 'm2m' + ? 'organization_id is required for m2m applications' + : 'organization_id is required when is_first_party is false', + [{ field: 'organization_id', code: 'required' }], + ); } if (!ws.organizations.get(organizationId)) { throw validationError('organization_id must reference an existing organization', [ @@ -63,6 +137,14 @@ export function connectRoutes(ctx: RouteContext): void { } } + // `CreateM2MApplicationDto` has no redirect_uris, and an m2m application never reaches a + // callback — storing one would leave a value no route reads and no update can clear. + if (applicationType === 'm2m' && body.redirect_uris != null) { + throw validationError('redirect_uris can only be set on oauth applications', [ + { field: 'redirect_uris', code: 'invalid' }, + ]); + } + const application = ws.connectApplications.insert({ object: 'connect_application', name: name.trim(), @@ -71,7 +153,12 @@ export function connectRoutes(ctx: RouteContext): void { organization_id: organizationId, scopes: (body.scopes as string[]) ?? [], audience: (body.audience as string) ?? null, - redirect_uris: (body.redirect_uris as string[]) ?? [], + redirect_uris: body.redirect_uris == null ? [] : parseRedirectUris(body.redirect_uris), + is_first_party: isFirstParty, + // Nothing in the emulator performs dynamic client registration, so an application + // created through this route is always one an authenticated caller registered. + was_dynamically_registered: false, + uses_pkce: (body.uses_pkce as boolean | undefined) ?? false, login_url: (body.login_url as string) ?? null, client_id: generateClientId(), logo_url: (body.logo_url as string) ?? null, @@ -82,32 +169,111 @@ export function connectRoutes(ctx: RouteContext): void { // Get application app.get('/connect/applications/:id', (c) => { - const application = findApplication(c.req.param('id')); - if (!application) throw notFound('ConnectApplication'); - return c.json(formatConnectApplication(application)); + return c.json(formatConnectApplication(requireApplication(c.req.param('id')))); + }); + + // Update application + app.put('/connect/applications/:id', async (c) => { + const application = requireApplication(c.req.param('id')); + // An empty update is a no-op, not a parse error — `parseJsonBody` rejects an absent body. + const body = c.req.raw.body ? await parseJsonBody(c) : {}; + + // `UpdateOAuthApplicationDto` is the only update body the spec defines, and every field on + // it is optional: an absent key leaves the stored value alone. An explicit null clears — + // to null for `description`, to an empty array for the two list fields, which have no + // nullable representation in the store. + const patch: Partial = {}; + + if (body.name !== undefined) { + if (typeof body.name !== 'string' || body.name.trim().length === 0) { + throw validationError('name must be a non-empty string', [{ field: 'name', code: 'invalid' }]); + } + patch.name = body.name.trim(); + } + + if (body.description !== undefined) { + if (body.description !== null && typeof body.description !== 'string') { + throw validationError('description must be a string or null', [{ field: 'description', code: 'invalid' }]); + } + patch.description = body.description; + } + + if (body.scopes !== undefined) { + if (body.scopes === null) { + patch.scopes = []; + } else if (!Array.isArray(body.scopes) || !body.scopes.every((s) => typeof s === 'string')) { + throw validationError('scopes must be an array of strings', [{ field: 'scopes', code: 'invalid' }]); + } else { + patch.scopes = body.scopes as string[]; + } + } + + if (body.redirect_uris !== undefined) { + // The spec scopes redirect URIs to OAuth applications, as the create route does. An + // explicit null still passes: clearing what an m2m app cannot have is a no-op, not an error. + if (application.application_type === 'm2m' && body.redirect_uris !== null) { + throw validationError('redirect_uris can only be set on oauth applications', [ + { field: 'redirect_uris', code: 'invalid' }, + ]); + } + patch.redirect_uris = body.redirect_uris === null ? [] : parseRedirectUris(body.redirect_uris); + } + + // Emulator-only, so it is not in the spec's update DTO — but create accepts it and this is + // the only other write path, so without it a seeded login page could never be changed. + if (body.login_url !== undefined) { + if (body.login_url !== null && typeof body.login_url !== 'string') { + throw validationError('login_url must be a string or null', [{ field: 'login_url', code: 'invalid' }]); + } + patch.login_url = body.login_url; + } + + const updated = ws.connectApplications.update(application.id, patch); + return c.json(formatConnectApplication(updated!)); + }); + + // Delete application + app.delete('/connect/applications/:id', (c) => { + const application = requireApplication(c.req.param('id')); + ws.clientSecrets.deleteBy('application_id', application.id); + // An in-flight Standalone Connect login outlives its application otherwise: the browser + // still completes at `/oauth2/authorize/complete`, creates a user and lands on the callback + // with a code that `/oauth2/token` can no longer redeem. Fail the login, not the callback. + ws.externalAuthSessions.deleteBy('client_id', application.client_id); + // authCodes is not indexed on client_id, so this is the `.all()` sweep organizations.ts uses. + for (const authCode of ws.authCodes.all()) { + if (authCode.client_id === application.client_id) ws.authCodes.delete(authCode.id); + } + ws.connectApplications.delete(application.id); + return c.body(null, 204); + }); + + // List client secrets. The spec returns a bare array here, not the `list` envelope the other + // collection routes use, so there is no cursor to order against: oldest first, because these + // are rotated in place and the order a caller cares about is the order they were issued. + app.get('/connect/applications/:id/client_secrets', (c) => { + const application = requireApplication(c.req.param('id')); + const secrets = ws.clientSecrets + .findBy('application_id', application.id) + .sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)); + return c.json(secrets.map(formatClientSecret)); }); // Create client secret app.post('/connect/applications/:id/client_secrets', (c) => { - const application = findApplication(c.req.param('id')); - if (!application) throw notFound('ConnectApplication'); + const application = requireApplication(c.req.param('id')); const value = `secret_${generateVerificationToken()}`; const secret = ws.clientSecrets.insert({ - object: 'client_secret', + object: 'connect_application_secret', application_id: application.id, value, - last_four: value.slice(-4), + secret_hint: value.slice(-4), + last_used_at: null, }); // Return full value only on creation - return c.json( - { - ...formatClientSecret(secret), - value: secret.value, - }, - 201, - ); + return c.json({ ...formatClientSecret(secret), secret: secret.value }, 201); }); // Revoke client secret diff --git a/src/workos/routes/oauth.spec.ts b/src/workos/routes/oauth.spec.ts index 2ab9b18..3845cce 100644 --- a/src/workos/routes/oauth.spec.ts +++ b/src/workos/routes/oauth.spec.ts @@ -247,12 +247,16 @@ describe('OAuth M2M token routes', () => { client_id: 'client_aud', logo_url: null, login_url: null, + is_first_party: true, + was_dynamically_registered: false, + uses_pkce: false, }); ws.clientSecrets.insert({ - object: 'client_secret', + object: 'connect_application_secret', application_id: appRec.id, value: 'secret_aud', - last_four: 'aud', + secret_hint: '_aud', + last_used_at: null, }); const res = await form({ grant_type: 'client_credentials', client_id: 'client_aud', client_secret: 'secret_aud' }); @@ -275,12 +279,16 @@ describe('OAuth M2M token routes', () => { client_id: 'client_percent', logo_url: null, login_url: null, + is_first_party: true, + was_dynamically_registered: false, + uses_pkce: false, }); ws.clientSecrets.insert({ - object: 'client_secret', + object: 'connect_application_secret', application_id: appRec.id, value: 'secret_%_local', - last_four: 'ocal', + secret_hint: 'ocal', + last_used_at: null, }); const basic = Buffer.from('client_percent:secret_%_local').toString('base64'); @@ -310,12 +318,16 @@ describe('OAuth M2M token routes', () => { client_id: 'client_malformed', logo_url: null, login_url: null, + is_first_party: true, + was_dynamically_registered: false, + uses_pkce: false, }); ws.clientSecrets.insert({ - object: 'client_secret', + object: 'connect_application_secret', application_id: appRec.id, value: 'secret_malformed', - last_four: 'med', + secret_hint: 'rmed', + last_used_at: null, }); // No scope requested: must not throw on a non-array (no .join on a string). diff --git a/src/workos/routes/oauth.ts b/src/workos/routes/oauth.ts index fafac95..1abc7ad 100644 --- a/src/workos/routes/oauth.ts +++ b/src/workos/routes/oauth.ts @@ -155,9 +155,9 @@ export function oauthRoutes(ctx: RouteContext): void { } const application = ws.connectApplications.findOneBy('client_id', clientId); - const secretMatches = - application && ws.clientSecrets.findBy('application_id', application.id).some((s) => s.value === clientSecret); - if (!application || !secretMatches) { + const matchedSecret = + application && ws.clientSecrets.findBy('application_id', application.id).find((s) => s.value === clientSecret); + if (!application || !matchedSecret) { throw new OauthApiError(401, 'invalid_client', 'Invalid client ID or secret.'); } const expectedType = grantType === 'client_credentials' ? 'm2m' : 'oauth'; @@ -224,6 +224,11 @@ export function oauthRoutes(ctx: RouteContext): void { if (authCode) ws.authCodes.delete(authCode.id); + // Stamped only once the exchange has actually produced a token — a secret presented on a + // request that then fails on grant type, code or scope was never used to get one. Silent, + // because presenting a secret is not an edit to it, so `updated_at` stays put. + ws.clientSecrets.updateSilent(matchedSecret.id, { last_used_at: new Date().toISOString() }); + return c.json({ access_token: accessToken, token_type: 'Bearer', From 26f08e51bacb34a945fe1528fd72016795b73a50 Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 22 Sep 2026 09:50:41 +0100 Subject: [PATCH 2/3] fix(seed): require an organization for third-party oauth applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seeded oauth application with `is_first_party: false` and no resolvable organization was inserted with a null owner, so the list and get routes returned a third-party application missing the `organization_id` its arm of the spec's oneOf requires — the exact shape `POST /connect/applications` rejects. Seed config and the API now agree. `is_first_party` and `uses_pkce` are validated as booleans, and the organization requirement is reported by config validation rather than at insert time, so the failure names the offending key. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +++ src/workos/config-validator.ts | 24 +++++++++++- src/workos/index.ts | 10 +++-- src/workos/seed-m2m.spec.ts | 69 ++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f07ca9d..e2de5a5 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,12 @@ 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 diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 7f9c234..431c0f5 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -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, }); } diff --git a/src/workos/index.ts b/src/workos/index.ts index 63bbe0c..604c572 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -876,10 +876,12 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee if (config.connectApplications) { for (const appConfig of config.connectApplications) { const type = appConfig.type ?? 'm2m'; + const isFirstParty = appConfig.is_first_party ?? true; const org = appConfig.organization ? ws.organizations.findOneBy('name', appConfig.organization) : undefined; - // An m2m application must be tied to a real organization; a name that does not - // resolve would otherwise produce an app with a null owner (invalid m2m shape). - if (type === 'm2m' && !org) { + // An m2m application must be tied to a real organization, and so must a third-party + // oauth one — the spec requires `organization_id` on both. A name that does not resolve + // would otherwise seed an app with a null owner, the shape the create route rejects. + if ((type === 'm2m' || !isFirstParty) && !org) { throw new Error( `workos seed config: connectApplications[].organization not found: ${JSON.stringify(appConfig.organization)}`, ); @@ -894,7 +896,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee scopes: appConfig.scopes ?? [], audience: appConfig.audience ?? null, redirect_uris: appConfig.redirect_uris ?? [], - is_first_party: appConfig.is_first_party ?? true, + is_first_party: isFirstParty, // Seeding is the dashboard's stand-in, and dynamic client registration is a runtime // act no seed file performs. was_dynamically_registered: false, diff --git a/src/workos/seed-m2m.spec.ts b/src/workos/seed-m2m.spec.ts index aa9b568..0c1c40a 100644 --- a/src/workos/seed-m2m.spec.ts +++ b/src/workos/seed-m2m.spec.ts @@ -192,6 +192,44 @@ describe('Seeding M2M applications and API keys', () => { ).rejects.toThrow(/organization not found/i); }); + it('throws when a seeded third-party oauth application has no organization', async () => { + await expect( + createEmulator({ + port: 0, + seed: { + organizations: [{ name: 'Acme Corp' }], + connectApplications: [{ name: 'Third Party', type: 'oauth', is_first_party: false }], + }, + }), + // Caught by config validation, before seeding runs. + ).rejects.toThrow(/organization is required when is_first_party is false/i); + }); + + it('seeds a third-party oauth application with its owning organization', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + organizations: [{ name: 'Acme Corp' }], + connectApplications: [ + { name: 'Third Party', type: 'oauth', is_first_party: false, organization: 'Acme Corp' }, + { name: 'First Party', type: 'oauth' }, + ], + apiKeys: [{ name: 'Key', organization: 'Acme Corp', value: 'sk_test_third_party' }], + }, + }); + + const res = await fetch(`${emulator.url}/connect/applications`, { headers: auth('sk_test_third_party') }); + const body = (await res.json()) as any; + const third = body.data.find((a: any) => a.name === 'Third Party'); + const first = body.data.find((a: any) => a.name === 'First Party'); + expect(third.is_first_party).toBe(false); + expect(third.was_dynamically_registered).toBe(false); + expect(third.organization_id).toMatch(/^org_/); + // The first-party arm of the spec's oneOf carries neither field. + expect(first.is_first_party).toBe(true); + expect(first.organization_id).toBeUndefined(); + }); + it('throws when a seeded api key references an unknown organization', async () => { await expect( createEmulator({ @@ -301,6 +339,37 @@ describe('Seed config validation for M2M apps and API keys', () => { ).toBeDefined(); }); + it('rejects a third-party oauth application without an organization', () => { + expect( + findError( + { connectApplications: [{ name: 'Third Party', type: 'oauth', is_first_party: false }] }, + 'connectApplications[0].organization', + )?.message, + ).toContain('is_first_party'); + }); + + it('accepts a first-party oauth application without an organization', () => { + expect(validateSeedConfig({ connectApplications: [{ name: 'First Party', type: 'oauth' }] })).toEqual({ + valid: true, + errors: [], + }); + }); + + it('rejects non-boolean is_first_party and uses_pkce', () => { + expect( + findError( + { connectApplications: [{ name: 'Bad', type: 'oauth', is_first_party: 'yes' as never }] }, + 'connectApplications[0].is_first_party', + ), + ).toBeDefined(); + expect( + findError( + { connectApplications: [{ name: 'Bad', type: 'oauth', uses_pkce: 1 as never }] }, + 'connectApplications[0].uses_pkce', + ), + ).toBeDefined(); + }); + it('rejects an unknown connect application type', () => { expect( findError( From f8f5c49918fafb3ad03790f826fad5058b7376f9 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 13:29:10 -0400 Subject: [PATCH 3/3] test(connect): cover the login cascade of application deletion Deleting an application drops its external-auth sessions and codes, but the tests only asserted the secrets went with it: either cascade could be removed and the suite would still pass. --- src/workos/routes/standalone-connect.spec.ts | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/workos/routes/standalone-connect.spec.ts b/src/workos/routes/standalone-connect.spec.ts index 0fe62d5..2050a1b 100644 --- a/src/workos/routes/standalone-connect.spec.ts +++ b/src/workos/routes/standalone-connect.spec.ts @@ -330,4 +330,40 @@ describe('Standalone Connect', () => { expect(res.status).toBe(400); expect((await json(res)).error).toBe('invalid_grant'); }); + + // Deleting the application fails the logins in flight with it — the pending session can no + // longer complete and the issued code can no longer be redeemed — and only those: another + // application's login carries on. + it('fails in-flight logins when their application is deleted, and only theirs', async () => { + const pending = await mint(); + const { callbackUrl } = await issueCode(); + const code = callbackUrl.searchParams.get('code')!; + + const bystander = await json( + await server.app.request('/connect/applications', { + method: 'POST', + headers, + body: JSON.stringify({ + name: 'Bystander', + login_url: 'http://localhost:3000/login', + redirect_uris: [callback], + }), + }), + ); + const bystanderLogin = await authorize({ client_id: bystander.client_id }); + expect(bystanderLogin.status).toBe(302); + const bystanderPending = new URL(bystanderLogin.headers.get('location')!).searchParams.get('external_auth_id')!; + + const application = ws.connectApplications.findOneBy('client_id', 'client_standalone')!; + const res = await server.app.request(`/connect/applications/${application.id}`, { method: 'DELETE', headers }); + expect(res.status).toBe(204); + + expect(ws.externalAuthSessions.get(pending)).toBeUndefined(); + expect((await complete(pending)).status).toBe(404); + expect(ws.authCodes.findOneBy('code', code)).toBeUndefined(); + expect((await exchange(code)).status).toBe(401); + + expect(ws.externalAuthSessions.get(bystanderPending)?.client_id).toBe(bystander.client_id); + expect((await complete(bystanderPending, { id: 'user_67890', email: 'bystander@example.com' })).status).toBe(200); + }); });