Skip to content

fix: align M2M tokens, API key validation, and JWKS with the spec - #29

Merged
gjtorikian merged 8 commits into
mainfrom
fix/m2m-scope-claim-and-api-key-validation
Jul 27, 2026
Merged

fix: align M2M tokens, API key validation, and JWKS with the spec#29
gjtorikian merged 8 commits into
mainfrom
fix/m2m-scope-claim-and-api-key-validation

Conversation

@gjtorikian

Copy link
Copy Markdown
Collaborator

A service could get an M2M access token out of the emulator but could not
read its scopes, and could not validate an API key at all. Both turned out to be the
emulator inventing a shape the real API never emits.

Summary

  • M2M token claims: scopes rode in a scp array with no jti. Production sends a
    space-delimited scope string and always sends jti; the SDKs read payload.scope and
    reject an M2M token missing jti, so a well-signed emulator token verified as invalid.
  • POST /api_keys/validations: read key from the request and answered
    { valid: boolean }. The spec sends the secret as value and returns
    { api_key: ApiKey | null }. The request field never matched, so the emulator never saw
    the key at all; returning the resource also makes a key's permissions reachable.
  • GET /sso/jwks/{clientId}: never registered. Only a bare /sso/jwks existed, which
    the emulation server does not match against the sub-path the spec documents and the SDKs fetch, so every
    SDK-based token verification 404'd.
  • Envelope conformance tests: the existing loop keys on a resource's object
    discriminator, so response envelopes, assembled inline in route handlers, had no
    coverage at all. Now extracted per
    operation from the spec's own paths, with divergences held in
    ledgers as exact field sets rather than tolerated silently.
  • CI drift gate: the generated catalogs are committed, so a @workos/openapi-spec bump
    did not regenerate them and the conformance tests would keep asserting the previous
    spec's contract. Added as a step in the existing test job, because the
    ruleset pins required check contexts by name.

Wire-format changes

Callers written against the old shapes need updating:

Before After
M2M token scopes scp: ["a", "b"] scope: "a b"
Validation request { "key": "sk_..." } { "value": "sk_..." }
Validation response { "valid": true } { "api_key": { ... } }

A value registered only through the legacy allow-list map form now validates as
api_key: null rather than true. It still authenticates requests, but it has no
api_key resource behind it, and synthesizing an owner and permission set would report
privileges the emulator does not hold. Use the array form for keys your code validates.

Also fixed

A pre-existing flake, found while verifying the above and confirmed present on main at
the same rate: event-bus.spec.ts asserted on every call to a spy installed over the
global fetch, so another suite's in-flight webhook delivery counted as its own.

Closes #26

A real WorkOS M2M token carries its granted scopes as a
space-delimited `scope` string and always carries a `jti`. The
emulator emitted a `scp` array and no `jti` at all, so a service
that authorized on scopes — or verified the token through a WorkOS
SDK, which reads `payload.scope` and rejects an M2M token missing
`jti` — passed locally and failed against the real API. That is the
failure mode an emulator exists to prevent.

Reported in #26.
Validation read `key` from the request body and answered
`{ valid: boolean }`. The spec sends the secret as `value` and
returns `{ api_key: ApiKey | null }`, which is what every SDK reads
— so the request field never matched, the emulator saw no key at
all, and callers got a shape they do not parse. The net effect was
that no API key could be validated locally, and a key's permissions
were unreachable even when it was.

Returning the resource rather than a boolean also gives callers the
`permissions` array, so permission-based authorization can be
exercised against the emulator.

A value registered only through the legacy allow-list map has no
resource behind it and now validates as `api_key: null` rather than
`true`: it can authenticate requests, but the emulator does not know
its owner or permissions, and inventing them would report
privileges that do not exist.

Reported in #26.
Only a bare `/sso/jwks` was registered, and Hono does not match that
against `/sso/jwks/{clientId}` — the path the spec documents and the
one the SDKs fetch when verifying a session or M2M access token. So
every SDK-based verification against the emulator got a 404 while
the key it needed was sitting one path segment away. The README has
documented the per-client path all along; the route simply never
existed.
The existing conformance loop keys on a resource's `object`
discriminator, which means the envelope a route wraps a resource in
was never checked — envelopes have no discriminator, and they are
assembled inline in the handler rather than by a format* helper.
That blind spot is exactly how `/api_keys/validations` shipped
`{ valid }` for several releases against a spec saying
`{ api_key }`: the resource catalog had nothing to say about it.

Envelope requirements are extracted per operation from the spec's
own `paths` entry, and extraction fails loudly when an operation's
declared `$ref` stops matching the curated schema name — the
counterpart to the discriminator guard, so a spec rename cannot
leave the test asserting a contract that no longer exists.

Coverage is curated because each case has to issue a real request,
so the catalog asserts it is exercised exactly, and divergences live
in ledgers as exact field sets rather than being tolerated
silently. Adding `api_key` to the resource catalog closes a second
gap of the same kind: a credential-bearing resource that had no
shape or secret-leak coverage.
The spec-derived catalogs under src/workos/generated are committed
so consumers of the package never need the spec — which also means a
@workos/openapi-spec bump does not regenerate them, and the
conformance tests go on asserting the previous spec's contract. A
dependency bump could turn green while the emulator had silently
drifted, which is the whole class of problem those tests exist to
catch.

Added as a step in the existing `test` job rather than its own job:
the repo ruleset pins required check contexts by name, so a new job
would not be enforced until someone updated the ruleset, while a
step is enforced immediately.
Seeding a catch-all webhook endpoint made the suite POST every
resource it then created to a port nothing is listening on. Those
fetches are async and outlive the file, so they land in whichever
suite runs next — and one of those, event-bus.spec.ts, counts calls
on a global fetch spy. Subscribing to an event the suite never
triggers keeps the endpoint listable without generating traffic.
The assertion counted all calls on a spy installed over the global
`fetch`, so a webhook delivery still in flight from another suite was
counted as if this test had made it — reliably enough to fail roughly
one full-suite run in ten, with concurrent-webhooks.spec.ts's ten
concurrent user.created deliveries as the usual source.

Indexing calls[0] had the same flaw more quietly: a foreign call
arriving first would have sent the HMAC and signature-format
assertions off to verify someone else's request body.
@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR aligns authentication-related emulator behavior with the WorkOS specification.

  • Emits M2M tokens with a space-delimited scope claim and unique jti.
  • Returns API key resources from validation and rejects duplicate seeded key values.
  • Adds the client-specific JWKS route used by SDKs.
  • Generates and tests spec-derived response-envelope requirements, with a CI drift gate.
  • Isolates webhook assertions from unrelated global fetch calls.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/workos/config-validator.ts Rejects duplicate explicit API key values before array-form seed records are inserted, resolving the previously reported metadata ambiguity.
src/workos/routes/api-keys.ts Updates validation to consume value and return the matching formatted API key resource or null.
src/workos/routes/oauth.ts Emits specification-compatible M2M scope and token identifier claims.
src/workos/routes/sso.ts Registers the client-specific JWKS endpoint expected by SDK token verification.
scripts/gen-shapes-lib.ts Adds spec-derived response-envelope extraction and generated contract catalogs.
src/workos/response-envelopes.spec.ts Exercises cataloged operations and verifies their top-level response fields against generated requirements.
.github/workflows/ci.yml Regenerates spec catalogs in CI and fails when committed output is stale.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Seed[Seed configuration] --> Validate[Validate unique API key values]
  Validate --> Store[Create allow-list and API key records]
  Request[POST /api_keys/validations with value] --> Authorize[Check allow-list and expiry]
  Authorize --> Lookup[Find corresponding API key record]
  Lookup --> Response[Return formatted api_key or null]
Loading

Reviews (2): Last reviewed commit: "fix(seed): reject duplicate api key valu..." | Re-trigger Greptile

Comment on lines +27 to +28
const record = authorized && value ? ws.apiKeyRecords.findOneBy('key', value) : undefined;
return c.json({ api_key: record ? formatApiKeyRecord(record) : null });

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 keys return wrong metadata

When an accepted array-form seed contains duplicate API key values, the authentication map retains the last entry while findOneBy returns the first record, causing validation to return the wrong owner, permissions, and expiration metadata.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/api-keys.ts
Line: 27-28

Comment:
**Duplicate keys return wrong metadata**

When an accepted array-form seed contains duplicate API key values, the authentication map retains the last entry while `findOneBy` returns the first record, causing validation to return the wrong owner, permissions, and expiration metadata.

How can I resolve this? If you propose a fix, please make it concise.

A key value is the identity of the secret: it is the auth allow-list's
map key and the lookup key for the resource behind it. Two seed entries
sharing one split that identity — the allow-list keeps the last entry's
environment and expiry while the record lookup resolves the first, so
validation gated on one entry and reported another's owner and
permissions. Deleting either would also stop the other authenticating.

Returning the resource from validations is what made the split
observable, but the seed accepted the ambiguity to begin with, and
production cannot issue one secret twice. Rejecting it alongside the
existing duplicate organization name and id checks fixes every reader
at once, rather than teaching each one which duplicate to prefer.
@gjtorikian
gjtorikian merged commit b7746ab into main Jul 27, 2026
7 checks passed
@gjtorikian
gjtorikian deleted the fix/m2m-scope-claim-and-api-key-validation branch July 27, 2026 20:45
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.

[report] using m2m emulation followup

1 participant