Skip to content

feat: seed M2M apps and API keys#10

Open
gjtorikian wants to merge 7 commits into
mainfrom
feat/seed-m2m-apps-api-keys
Open

feat: seed M2M apps and API keys#10
gjtorikian wants to merge 7 commits into
mainfrom
feat/seed-m2m-apps-api-keys

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a connectApplications seed block for WorkOS Connect Applications (M2M by default), each provisioned with a client secret — so a service has a known client_id/secret on startup
  • Adds an array form for apiKeys that creates spec-aligned api_key resources (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.
  • Extends the connect-application + api-key entities/formatters to the spec shapes, and makes POST /connect/applications honor application_type/scopes/organization_id/description.
  • Adds seed-config validation for both blocks (m2m requires an org; api keys require an owner; pinned values must be sk_).
  • Adds the runtime client_credentials token exchange (see below).

Runtime M2M token exchange

POST /oauth2/token lets a service swap its seeded client_id + client_secret for a scoped access token, matching the production client_credentials flow:

  • Validates credentials against seeded m2m Connect Applications; mints an RS256 JWT carrying the granted scopes in the scp claim (plus iss/aud/sub/org_id), signed with the same key as /sso/jwks/:client_id — so a consumer validating with JWKS (e.g. jose, checking iss/aud) verifies it with no emulator-specific shims.
  • GET /oauth2/jwks exposes that key at the authoritative-server path too.
  • Accepts form-encoded or JSON bodies, and HTTP Basic auth for the client credentials.
  • Supports scope narrowing (400 invalid_scope on 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_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.

Closes #7

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-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds seeded M2M credentials and API key resources for the WorkOS emulator. The main changes are:

  • Seeded Connect Applications with client secrets.
  • Runtime client_credentials token exchange with JWT and JWKS support.
  • Array-form API key resources with expiry, deletion, and organization listing behavior.
  • Seed-config validation for M2M apps and API keys.

Confidence Score: 2/5

The M2M seed and OAuth credential paths need fixes before this can be merged safely.

  • Duplicate organization names can bind a seeded M2M application to an ambiguous tenant and mint a token with the selected org_id.
  • OAuth Basic credentials containing spaces can be rejected when sent with compliant form encoding, causing valid clients to fail token exchange.
  • The remaining API contract for seeded M2M exchange, JWKS, scope narrowing, and seeded API key authentication was exercised successfully.

src/workos/index.ts and src/workos/routes/oauth.ts

T-Rex T-Rex Logs

What T-Rex did

  • Ran the m2m API contract test script and compared before and after results, observing that the before run returned 401 Unauthorized on key endpoints, while the after run returned 200 OK for /oauth2/token, /oauth2/jwks, /organizations, and /organizations/{id}/api_keys, with invalid-scope requests yielding 400 Bad Request and a JWT issued with alg RS256, kid matching, sub/aud client_contract_m2m, org_id, scp: [posts:read], and verifiedAgainstJwks: true.
  • Investigated the duplicate-organization seeds by examining trex-artifacts/duplicate-org-m2m-01-before.log and trex-artifacts/duplicate-org-m2m-02-after.log; both show two 'Duplicate Tenant' orgs and an ambiguous_duplicate_count: 2, the seeded connect app receiving an organization_id from a duplicate org and the token carrying that same ambiguous org_id.
  • Compared credential handling around the plus-encoded Basic credentials; before the change, client_billing:secret local succeeded but client_billing:secret+local failed with 401 and invalid_client, and after the change, plus-encoded credentials succeeded with a 200 bearer token.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (2)

  1. General comment

    P1 Duplicate organization names allow ambiguous M2M tenant binding

    • Bug
      • When seed config contains two organizations with the same name and an M2M connect application references that name, the emulator starts successfully and binds the application to one matching organization. A subsequent client_credentials exchange returns HTTP 200 and signs that selected organization id into the JWT org_id claim, even though the seed reference is ambiguous.
    • Cause
      • seedFromConfig resolves connectApplications[].organization with a single-result lookup (findOneBy('name', ...)) and only checks for a missing organization. It does not check whether multiple organizations share the referenced name before creating the M2M application.
    • Fix
      • Resolve organization names with findBy('name', ...) for M2M connect applications and reject both zero matches and multiple matches with a clear seed validation error. Alternatively require an unambiguous organization id for M2M seed references.

    T-Rex Ran code and verified through T-Rex

  2. General comment

    P1 Plus-encoded Basic client secrets are rejected

    • Bug
      • A valid M2M client secret containing a space is rejected when sent through HTTP Basic auth using OAuth's form-url-encoded credential form (secret+local). The same seeded secret authenticates successfully when sent with a literal space, proving the client/secret pair is otherwise valid.
    • Cause
      • formDecode used decodeURIComponent(value) directly. decodeURIComponent decodes percent escapes but does not translate + to a space, so secret+local was compared literally against the stored secret local.
    • Fix
      • Normalize plus signs to spaces before percent decoding in Basic credential component decoding, e.g. decodeURIComponent(value.replace(/\+/g, ' ')), while preserving the existing fallback for malformed percent escapes.

    T-Rex Ran code and verified through T-Rex

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/workos/index.ts:407-415
**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.

### Issue 2 of 2
src/workos/routes/oauth.ts:42-48
**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.

Reviews (8): Last reviewed commit: "feat: make M2M token audience configurab..." | Re-trigger Greptile

Comment thread src/workos/index.ts Outdated
Comment thread src/workos/index.ts
Comment thread src/workos/index.ts
Comment thread src/workos/routes/connect.ts Outdated
Comment thread src/index.ts
Comment thread src/workos/routes/oauth.ts Outdated
Comment thread src/workos/index.ts Outdated
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.
@gjtorikian gjtorikian force-pushed the feat/seed-m2m-apps-api-keys branch from bbfd42a to 8779313 Compare June 26, 2026 16:50
Comment thread src/workos/routes/oauth.ts Outdated
Comment thread src/workos/routes/oauth.ts Outdated
Comment thread src/workos/index.ts
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>
Comment thread src/index.ts Outdated
Comment thread src/workos/index.ts Outdated
Comment thread src/workos/routes/connect.ts Outdated
Comment thread src/workos/helpers.ts
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>
Comment thread src/workos/routes/api-keys.ts Outdated
Comment thread src/workos/index.ts
- 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>
Comment thread src/workos/routes/connect.ts Outdated
Comment thread src/core/middleware/auth.ts
gjtorikian and others added 2 commits June 26, 2026 14:36
…-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>
Comment thread src/workos/index.ts
Comment on lines +407 to +415
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)}`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +42 to +48
function formDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

[question] using m2m emulation

1 participant