Skip to content

feat(oauth)!: serve the AuthKit OAuth server MCP clients sign in through - #142

Merged
gjtorikian merged 11 commits into
mainfrom
fix/authkit-oauth-server-fidelity
Oct 15, 2026
Merged

gjtorikian merged 11 commits into
mainfrom
fix/authkit-oauth-server-fidelity

Conversation

@gjtorikian

Copy link
Copy Markdown
Collaborator

Supersedes #127 by @flyinprogrammer. Main moved under that PR and its fork does not allow maintainer pushes, so this branch carries Alan's five commits unchanged (same SHAs, same authorship) plus two production-fidelity commits and a merge of main. The design, route table and the end-to-end MCP client spec are his; read #127 for the full write-up.

What #127 adds

An MCP client signs in to a WorkOS environment through its AuthKit domain acting as an OAuth 2.1 authorization server. The emulator now serves that flow: discovery at /.well-known/oauth-authorization-server and /.well-known/openid-configuration, dynamic client registration at POST /oauth2/register (RFC 7591), hosted sign-in through GET /oauth2/authorize with PKCE and RFC 8707 resource, public-client and refresh_token support at POST /oauth2/token, GET /oauth2/error, and the spec's /user_management/authkit_oauth_resources routes with a resourceIndicators seed key.

What this branch changes on top (d8e4c22, b1e72e1)

The rules #127 derived from observed responses or RFC reasoning are replaced with the ones the production authkit-idp handlers, guards and consent token service apply. Each commit message cites the monorepo file and line for every rule.

  • Authorize failures never answer with an API body: the error page for invalid_query_params, application_not_found and invalid_redirect_uri, the callback for everything after, with descriptions only where production gives them. Both redirects are built from baseUrl like every other minted link.
  • Scopes: an omitted scope grants nothing; the standard scopes are open to every OAuth application and a custom scope must be the application's own; a custom scope removed later drops out of tokens while the standard ones stay.
  • PKCE is checked where production checks it: a challenge must name its method, a public client without one fails at the token endpoint, a bad verifier never spends the code, and the verifier's shape is not checked.
  • Dynamic registration: client_name is required; application_type is echoed and defaults to web (native defaults to none); scopes the client could not be granted are dropped and the effective set echoed; scope is returned only when requested.
  • Client authentication is the two token-endpoint guards: the body's client_id wins outright, Basic is decoded without further form-decoding, credentials come from the body or Basic alike, the guards run before the body is validated, and the messages are Missing client_secret., Unsupported client_secret. and Application not found.
  • Refresh tokens follow shouldIssueRefreshToken: offline_access always earns one; otherwise a dynamically registered client gets one iff it did not ask for openid. Registered grant_types no longer gate it. Rotation applies to public clients only.
  • Token responses carry production's shapes: lowercase bearer, an id_token on every code exchange with no scope field, id_token plus rotated refresh_token plus scope on refresh, auth_time on access tokens, a sorted scope that is absent rather than empty, bare invalid_grant for every code and refresh failure, and client_credentials omitting an empty scope.
  • Resource indicators match by normalized URL. A dynamically registered client's resource is validated only once resources are registered; with none registered the parameter is ignored; a client bound to nothing takes the resource flagged default; a bound resource since deleted is invalid_target with no description. The aud fallback is documented as the one deviation on that claim.
  • authkit_oauth_resource is registered in the response-shape catalogs.

BREAKING CHANGE

Answers on the existing /oauth2/authorize and /oauth2/token routes change to what production returns: authorize failures redirect to the error page or the callback instead of answering an API body, and a missing or unknown client at the token endpoint is 401 invalid_client with production's messages. An OAuth application with no login_url now gets the hosted sign-in instead of 400 unauthorized_client. Applications with a login_url keep Standalone Connect.

Merge order

Land #137 (refresh-token replay grace) first. It makes exchanged_at and exchanged_for_id required on WorkOSRefreshToken, so this branch's three refreshTokens.insert calls (src/workos/routes/oauth.ts and two specs) need those two fields added on rebase. Production applies the same replay grace on the OAuth refresh grant, so extending #137's chain logic to /oauth2/token is a follow-up.

Verification

Run locally on this head (no CI has run on the branch yet):

bun run typecheck
bun run lint
bun run gen:events && bun run gen:shapes && bun run gen:supported && git diff --exit-code -- src/workos/generated SUPPORTED.md
bun test        # 1532 pass, 0 fail across 80 files

flyinprogrammer and others added 8 commits September 30, 2026 11:03
An MCP client signs in to a WorkOS environment through its AuthKit domain,
which acts as an OAuth 2.1 authorization server. The emulator had the pieces
on either side of that flow (the hosted sign-in under /user_management, and
Connect applications with /oauth2/token) but not the flow itself, so an MCP
resource server could not be exercised against it.

  GET    /.well-known/oauth-authorization-server
  GET    /.well-known/openid-configuration
  POST   /oauth2/register
  GET    /oauth2/error
  POST   /user_management/authkit_oauth_resources
  GET    /user_management/authkit_oauth_resources
  DELETE /user_management/authkit_oauth_resources/:id

/oauth2/authorize sends an application with no `login_url` to the hosted
sign-in, carrying PKCE, `state`, `scope` and an RFC 8707 `resource`.
/oauth2/token exchanges the code for a public client on its `code_verifier`
alone, and gains the `refresh_token` grant. Registration creates a real
Connect application, so `was_dynamically_registered` and the list route's
`registration_types` filter now have something to report.

The discovery documents advertise only what is served. A production AuthKit
domain also lists device authorization, introspection, userinfo and client ID
metadata documents; those are omitted rather than stubbed, so a client never
follows discovery into a 404.

A registered resource indicator decides the token's `aud`. A request naming
one that is not registered, or none, keeps the existing fallback to the
application's `audience` and then its `client_id`. The resource is bound at
authorize: the token request may restate it or omit it, never introduce one.

Three answers on existing routes change, each to what a production AuthKit
domain returns. /oauth2/token answers a missing or unknown client with
`401 invalid_client` and `WWW-Authenticate: Basic realm="AuthKit"` under every
grant type, where it answered 400. /oauth2/authorize redirects an unknown
client to /oauth2/error, where it answered 400. An OAuth application with no
`login_url` reaches the hosted sign-in, where it was refused.

The claim set beyond `iss`, `aud`, `sub`, `client_id` and `exp` is the
emulator's choice, not a captured production token, and the README says so.
Refresh re-checked only that its session was still active, so a grant
outlived everything that should end it. It now refuses with invalid_grant
once the session has expired or the user has left the organization the
token names, and a rotated refresh token's expiry is capped at the
session's rather than restarting at 30 days on every rotation.

Both the code exchange and refresh intersect the grant's scope with the
application's current scopes, so a scope removed with
PUT /connect/applications/:id is not issued again; nothing left is
invalid_scope rather than a token no one consented to.

The OpenID configuration advertises ID token signing and `openid` is an
accepted scope, but no id_token was ever issued. The code exchange now
returns one when `openid` is granted, signed with the /oauth2/jwks key and
carrying the authorize request's `nonce`; `email` and `profile` gate their
claims. Refresh does not return one, which OIDC leaves optional.

Registration refuses metadata that could never produce a usable sign-in:
`grant_types` without `authorization_code`, and an empty or unsupported
`response_types`, which was accepted and then reported as `["code"]`.
Deleting an application also removes its refresh tokens and any sign-in
still parked for its client.
…ication

Deleting a Connect application removed every refresh token carrying its
client_id. /user_management/authenticate stores whatever client_id its
caller sent, so an AuthKit session that shared the id lost its refresh
token to an unrelated deletion. The sweep now matches Connect-issued tokens
only.
The AuthKit OAuth server diverged from the monorepo on rules an MCP
client hits in ordinary use, each of which would pass locally and fail
against a real AuthKit domain or the reverse. Every rule below comes
from the production source (workos/workos), not from inference.

- resource (RFC 8707): packages/api/src/authkit-idp/handlers/
  authorize.handler.ts:240-283 and :368-430. A dynamically registered
  client's resource is validated only once resources are registered:
  a malformed one is invalid_target with the parser's description, an
  unregistered one invalid_target with none, and with none registered
  the parameter is ignored. A client bound to nothing takes the
  resource flagged default. Other clients' resource sits behind the
  connect-resource-indicators-for-all-clients flag, so the flag-off
  path is reproduced. At the token endpoint the rules are the resource
  locator's (packages/api-services/src/authkit-oauth-resources/
  authkit-oauth-resource-locator.ts:60-137): a grant bound to none may
  name a registered resource, a bound resource since deleted is
  invalid_target, and no registered resources means ignore.
- refresh tokens: packages/api-services/src/application-consents/
  application-consent-tokens.service.ts:900-927 shouldIssueRefreshToken.
  offline_access always earns one; otherwise a dynamically registered
  client gets one iff it did not ask for openid. Nothing under
  authkit-idp reads registered grant_types, so that gate is gone.
- registration: dynamic-client-registrations.controller.ts:129
  (client_name required), :216-225 (web default; native defaults to
  none), :362-379 (application_type always echoed, scope only when
  requested). The invented "Dynamically registered application" name
  goes with it.
- client auth: guards/application-credentials.guard.ts:36-57 and
  guards/application-credentials-or-public-client.guard.ts:160-182.
  A client is only public or confidential, credentials come from the
  body or Basic alike whatever it registered, and the messages are
  "Missing client_secret.", "Unsupported client_secret." and
  "Application not found.".
- PKCE: authorize.handler.ts:222-232 with the mapping at :55-67. A
  code_challenge without a method is invalid_request with production's
  description rather than an assumed S256.
- token responses: types.ts:186-212, handlers/authorization-code-grant.
  handler.ts:161-168, handlers/refresh-token-grant.handler.ts:93-104,
  handlers/client-credentials-grant.handler.ts:80-89. token_type is
  lowercase bearer; a code exchange carries an id_token whatever the
  scope and no scope field; a refresh carries id_token, a rotated
  refresh_token and scope; client_credentials omits an empty scope.

The two authorize redirects are now built from baseUrl like every
other minted link, and the uses_pkce seed note no longer claims it is
unenforced.
…rules

Each rule the AuthKit OAuth server emulation had derived from observed
responses or RFC reasoning is replaced with the one production's
authkit-idp handlers, guards and consent token service apply:

- authorize failures never answer with an API body: the error page for
  invalid_query_params, application_not_found and invalid_redirect_uri,
  the callback for everything after, with descriptions only where
  production gives them
- an omitted scope grants nothing; the standard scopes are open to every
  OAuth application and a custom scope must be the application's own;
  a custom scope removed later drops out of tokens while the standard
  ones stay, and an empty grant is not refused
- PKCE is checked where production checks it: a challenge must name its
  method, a public client without one fails at the token endpoint, a bad
  verifier never spends the code, and the verifier's shape is not checked
- dynamic registration drops scopes the client could not be granted and
  echoes the effective set instead of failing the registration
- client authentication is the two token-endpoint guards: the body's
  client_id wins outright, the Basic header is decoded without further
  form-decoding, and the guards run before the body is validated
- token responses carry production's shapes: bare invalid_grant for every
  code and refresh failure, refresh tokens rotated only for public
  clients, a sorted scope that is absent rather than empty, auth_time on
  access tokens, and id_token claims composed the way production composes
  them
- resource indicators match by normalized URL, invalid_target at the
  token endpoint carries no description, and the aud fallback is
  documented as the one deviation on that claim
- authkit_oauth_resource is registered in the response-shape catalogs
…ver-fidelity

# Conflicts:
#	SUPPORTED.md
#	src/workos/entities.ts
#	src/workos/routes/auth.ts
@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Critical impact] The PR appears safe to merge, with no outstanding findings.

Summary

Adds the AuthKit OAuth server used by MCP clients, including discovery, client registration, hosted sign-in, PKCE, refresh tokens, and resource indicators.

  • MCP clients can sign in through the emulator’s AuthKit domain.
  • OAuth code and refresh grants return AuthKit-style tokens and errors.
  • Registered resource servers can set the audience on OAuth tokens.

Diagram

sequenceDiagram
  participant Client as MCP client
  participant OAuth as AuthKit OAuth server
  participant Login as Hosted sign-in
  Client->>OAuth: Discover endpoints and register
  Client->>OAuth: Authorize with PKCE and resource
  OAuth->>Login: Redirect to sign-in
  Login-->>Client: Redirect to callback with code
  Client->>OAuth: Exchange code and verifier
  OAuth-->>Client: Access token, ID token, optional refresh token
  Client->>OAuth: Refresh
  OAuth-->>Client: New tokens
Loading

Reviews (2) · Last reviewed commit: "fix(oauth): Sweep a gone session's refre..." · Reviewed by Greptile

Comment thread src/workos/routes/oauth.ts
Comment thread src/workos/config-validator.ts Outdated
Comment thread src/workos/routes/oauth.ts Outdated
Comment thread src/workos/routes/authkit-oauth-server.spec.ts Outdated
A seed could pin a client_secret containing ':' that the token
endpoint can never accept over Basic, because production's
parseAuthorization splits on every colon and the emulator decodes the
header the same way; refusing it at validation surfaces the problem
at startup instead of as a 401. Resource-indicator uniqueness compared
raw strings while the API and every matcher compare normalized URLs,
so a seed could hold two spellings of one resource and let the wrong
one win as `aud`.
Logout and revoke delete the session row, and the AuthKit grant now
deletes every token of a session that is gone when one is presented.
The OAuth refresh grant rejected the same case but left the rows
behind, and nothing else would ever sweep them. The Basic split now
cites production's parseAuthorization, and the confidential-client
test retries the original code after a bad verifier, which it had
claimed was spent.
@gjtorikian
gjtorikian merged commit dcfb3d4 into main Oct 15, 2026
10 checks passed
@gjtorikian
gjtorikian deleted the fix/authkit-oauth-server-fidelity branch October 15, 2026 22:19
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.

2 participants