feat: seed M2M apps and API keys#10
Conversation
Add seed config for Connect Applications and API keys; seeded keys register in the auth allow-list so they authenticate real requests. The runtime client_credentials exchange is left out as it is absent from the WorkOS OpenAPI spec this repo generates from. Refs #7
Greptile SummaryThis PR adds seeded M2M credentials and API key resources for the WorkOS emulator. The main changes are:
Confidence Score: 2/5The M2M seed and OAuth credential paths need fixes before this can be merged safely.
src/workos/index.ts and src/workos/routes/oauth.ts
What T-Rex did
|
services can now swap a seeded Connect Application's client_id + secret for a scoped access token, matching the production client_credentials flow. - POST /oauth2/token: client_credentials grant. Validates client_id/secret against seeded m2m Connect Applications, mints an RS256 JWT carrying the granted scopes in the `scp` claim, signed with the same key as the JWKS endpoints. Accepts form-encoded or JSON bodies and HTTP Basic auth for the client credentials. Supports scope narrowing (invalid_scope on overreach) and returns RFC 6749 error bodies (invalid_client/unauthorized_client/ unsupported_grant_type). - GET /oauth2/jwks: same signing key as /sso/jwks/:client_id, so a service pointed at the authoritative server's JWKS validates M2M tokens. - /oauth2/* is public (token endpoint authenticates by client credentials). - Add `scp` to the JWT payload; README documents the exchange and claims. This is hand-authored runtime behavior: client_credentials is absent from the WorkOS OpenAPI spec at every version (its /sso/token only documents authorization_code), so it cannot be generated — mirroring how /user_management/authenticate already implements grants beyond the spec.
bbfd42a to
8779313
Compare
Resolves the seven Greptile findings on this PR: - oauth.ts: M2M token org_id is now always the application's stored organization, never caller-supplied. A request-supplied organization_id is ignored (removed), so a client can't mint a token for a tenant the app isn't tied to. - connect.ts: POST /connect/applications requires organization_id for m2m apps (422 otherwise) — no more null-owner m2m applications. - seedFromConfig: throw when a seeded m2m application's or API key's organization name doesn't resolve, instead of silently producing a null/empty owner that still provisions credentials. - seedFromConfig: an already-expired seeded API key is created as a resource but not registered in the auth allow-list, so it doesn't authenticate. - DELETE /api_keys/:id: also removes the value from the auth allow-list, so a deleted key stops authenticating, not just stops resolving. - createEmulator.reset(): restore the captured apiKeys object in place and re-register it, so re-seeded array-form keys land in the map the middleware reads — real-request auth and /api_keys/validations no longer diverge. Adds 8 tests covering each path. typecheck, lint, fmt clean; 437 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the six new P1 findings: - auth middleware: API key entries carry an optional expiresAt and are rejected live once past it, so a key that lapses after seeding stops authenticating (not only already-expired ones). /api_keys/validations applies the same check, so validation and real-request auth agree. - src/index.ts: array-form apiKeys no longer seed the well-known sk_test_default into the allow-list — it would otherwise authenticate protected routes and surface as emulator.apiKey despite pinned keys. - api-keys route: GET /organizations/:orgId/api_keys filters by the path org, so one org's keys can't leak into another's listing. - connect route: m2m create now also rejects an organization_id that doesn't reference an existing organization (no dangling-tenant tokens), and rejects a non-array scopes value. - oauth token endpoint: Basic-auth credentials decode safely (a literal `%` in a secret yields invalid_client, not a 500), and a malformed non-array stored scopes value can't substring-match or crash on .join. - config validator: connectApplications[].scopes must be an array. Adds 7 tests. typecheck, lint, fmt clean; 444 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- api-keys route: GET /organizations/:orgId/api_keys now matches user-owned keys too. They store the org under owner.organization_id (owner.id is the user), so the previous owner.id-only filter hid them from the org listing even though they authenticate for that org. - config validator: reject two connectApplications pinning the same client_id. A client_id identifies exactly one application; duplicates make /oauth2/token ambiguous (the lookup resolves only the first match). Seeding is the only path to a collision since the create route generates unique ids. Adds 2 tests. typecheck, lint, fmt clean; 446 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-closed
- auth middleware: a malformed expires_at (NaN) is now treated as expired
(fail closed) instead of authenticating forever.
- connect route + config validator: scopes must be an array of strings;
element-level non-strings (e.g. [123], [{}]) are rejected so the token
endpoint can't sign garbage into the scp claim or join "[object Object]".
Adds 2 tests. typecheck, lint, fmt clean; 448 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The aud claim was hard-coded to the requesting client_id (a default copied from the user-token path), which can't match a real WorkOS environment that emits a different audience. Add an optional `audience` to Connect Applications (seed config + create route); the m2m token's aud uses it when set, falling back to client_id. Audience is server-side (a property of the app), not a request parameter, so a caller can't mint a token for an arbitrary aud — same posture as org_id. - entity + seed interface + create route + config validator gain `audience` - formatConnectApplication exposes it on m2m apps - README documents the field and the aud-matching guidance Adds 1 test. typecheck, lint, fmt clean; 449 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| const type = appConfig.type ?? 'm2m'; | ||
| 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) { | ||
| throw new Error( | ||
| `workos seed config: connectApplications[].organization not found: ${JSON.stringify(appConfig.organization)}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Duplicate Org Picks First Tenant
When seed config contains two organizations with the same name, this lookup silently selects the first match for the seeded M2M app. /oauth2/token then signs that first org's id into org_id, so a service can receive a valid token for the wrong tenant.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/index.ts
Line: 407-415
Comment:
**Duplicate Org Picks First Tenant**
When seed config contains two organizations with the same name, this lookup silently selects the first match for the seeded M2M app. `/oauth2/token` then signs that first org's id into `org_id`, so a service can receive a valid token for the wrong tenant.
How can I resolve this? If you propose a fix, please make it concise.| function formDecode(value: string): string { | ||
| try { | ||
| return decodeURIComponent(value); | ||
| } catch { | ||
| return value; | ||
| } | ||
| } |
There was a problem hiding this comment.
OAuth Basic credentials are form-url-encoded before base64 encoding, but decodeURIComponent leaves + unchanged. A seeded secret like secret local sent by a compliant client as secret+local is compared literally and returns invalid_client even though the credential is valid.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/oauth.ts
Line: 42-48
Comment:
**Plus Credentials Stay Encoded**
OAuth Basic credentials are form-url-encoded before base64 encoding, but `decodeURIComponent` leaves `+` unchanged. A seeded secret like `secret local` sent by a compliant client as `secret+local` is compared literally and returns `invalid_client` even though the credential is valid.
How can I resolve this? If you propose a fix, please make it concise.
Summary
connectApplicationsseed block for WorkOS Connect Applications (M2M by default), each provisioned with a client secret — so a service has a known client_id/secret on startupapiKeysthat creates spec-alignedapi_keyresources (owner, obfuscated_value, permissions, expires_at). Seeded keys also register in the auth allow-list, so the value authenticates real requests. The legacy map form stays supported, disambiguated at runtime by shape.POST /connect/applicationshonor application_type/scopes/organization_id/description.sk_).client_credentialstoken exchange (see below).Runtime M2M token exchange
POST /oauth2/tokenlets a service swap its seededclient_id+client_secretfor a scoped access token, matching the productionclient_credentialsflow:scpclaim (plusiss/aud/sub/org_id), signed with the same key as/sso/jwks/:client_id— so a consumer validating with JWKS (e.g.jose, checkingiss/aud) verifies it with no emulator-specific shims.GET /oauth2/jwksexposes that key at the authoritative-server path too.400 invalid_scopeon overreach) so scope-based authz can be exercised locally; returns RFC 6749 error bodies (invalid_client/unauthorized_client/unsupported_grant_type).This is hand-authored runtime behavior:
client_credentialsis absent from the WorkOS OpenAPI spec at every version (its/sso/tokenonly documentsauthorization_code), so it cannot be generated — mirroring how/user_management/authenticatealready implements grants beyond the spec.Closes #7