Skip to content

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

Open
flyinprogrammer wants to merge 5 commits into
workos:mainfrom
ogeeloop:feat/authkit-oauth-server
Open

flyinprogrammer wants to merge 5 commits into
workos:mainfrom
ogeeloop:feat/authkit-oauth-server

Conversation

@flyinprogrammer

Copy link
Copy Markdown

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 both halves of that — the hosted sign-in under /user_management, and Connect applications with /oauth2/token — but not the flow that joins them, so an MCP resource server could not be exercised against it. An MCP client now registers, signs in and calls a tool against the emulator, with a resource server that validates iss, aud and client_id unchanged from production.

Coverage Before After
AuthKit Configuration, read ❌ 0/3 ⚠️ 1/3
AuthKit Configuration, write ⚠️ 2/5 ⚠️ 4/5
Repo 184 of 261 (70.5%) 187 of 261 (71.6%)

The three counted endpoints are the resource-indicator routes. The rest of this change is outside the spec, so it does not move the figure.

Method Path
GET /.well-known/oauth-authorization-server new
GET /.well-known/openid-configuration new
POST /oauth2/register new, RFC 7591
GET /oauth2/authorize hosted sign-in, PKCE, resource
POST /oauth2/token public clients, refresh_token grant
GET /oauth2/error new
POST GET DELETE /user_management/authkit_oauth_resources new, in the spec

The behaviour comes from a production AuthKit domain, not the spec

None of the /oauth2 surface is in the OpenAPI spec, so the ground truth is what a production AuthKit domain answers to unauthenticated requests, observed 2026-09-30:

  • Discovery — both root documents, field for field, minus what is not served (below).
  • An unknown client on authorize — 302 to /oauth2/error?error=application_not_found, a 200 text/html page that reflects the error_description it is given.
  • A missing client on token — 401 {"error":"invalid_client","error_description":"Missing authorization header."} with WWW-Authenticate: Basic realm="AuthKit".
  • An unknown client on token — 401 invalid_client, Application not found., same header, under every grant type including an unsupported one. The client lookup precedes grant-type validation.

Discovery advertises only what is served

Production also lists device_authorization_endpoint, introspection_endpoint, userinfo_endpoint and client_id_metadata_document_supported. They are omitted rather than stubbed, so a client never follows discovery into a 404. A test reads the route table and fails if an advertised endpoint has no route.

The per-client document at /user_management/{client_id}/.well-known/openid-configuration is untouched. Its five fields are what production serves there, and that reasoning still holds.

A registered client is a real Connect application

POST /oauth2/register creates a third-party oauth application, so was_dynamically_registered and the list route's registration_types filter now have something to report. token_endpoint_auth_method: none makes a public client with no secret. The grant_types and auth method a client registers are stored and enforced at /oauth2/token, so a client that does not register refresh_token is not issued one.

The resource is bound at authorize

A registered resource indicator decides aud. Declare them with the resourceIndicators seed key or through the API:

resourceIndicators:
  - uri: https://mcp.example.test/mcp

A request naming one that is not registered, or none, keeps the existing fallback to the application's audience, then its client_id. The token request may restate the authorized resource or omit it, never introduce one: a grant authorized for no resource cannot pick one up at exchange.

PKCE is required of a public client, and a wrong verifier spends the code

The verifier must be 43 to 128 unreserved characters, so a client cannot pass here on a verifier production would refuse.

BREAKING CHANGE

Three answers on existing routes change, each to what production returns:

Route Before After
POST /oauth2/token, missing or unknown client 400 invalid_request, or 401 without the header 401 invalid_client with WWW-Authenticate
GET /oauth2/authorize, unknown client 400 invalid_client 302 to /oauth2/error
GET /oauth2/authorize, OAuth application with no login_url 400 unauthorized_client hosted sign-in

client_credentials is otherwise unchanged, claims included. Applications with a login_url keep Standalone Connect.

Not implemented, deliberately

Client ID metadata documents, the device grant on this surface, introspection, userinfo, and id_token for the openid scope. Wildcard resource indicators are refused.

Assumptions

Three things are the emulator's choice, not an observation, and the README records each:

  • The claim set beyond iss, aud, sub, client_id and exp. No production token was captured.
  • A refresh token is issued whenever the client registered the grant, regardless of offline_access.
  • A known confidential client that presents no secret answers 401 Missing authorization header. Only the unknown-client cases were observed.

Conformance and tests

Two existing assertions in standalone-connect.spec.ts changed, both for the routes in the table above, and the unknown-client case now asserts the redirect.

One spec behaves as an MCP client end to end: discovery, registration, PKCE authorize with a resource, exchange, then verification of the token against the discovered jwks_uri, and again after a refresh. The interactive pages are driven through password, email verification, TOTP and organization selection to a working exchange.

bun test: 1424 pass, 0 fail. typecheck, lint, fmt:check clean. gen:supported re-run byte-identical.

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.
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Critical risk] Adds OAuth 2.1 authorization server for MCP client sign-in.

The PR appears safe to merge based on the reviewed changes and thread states.

Summary

The PR adds AuthKit-domain OAuth discovery, dynamic client registration, hosted authorization, token exchange and refresh, and resource indicators for MCP clients. The latest change rejects an empty-scope authorization before sign-in.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Client[MCP client] --> Discovery[OAuth discovery]
  Client --> Registration[Dynamic registration]
  Client --> Authorize[Authorize]
  Authorize --> SignIn[Hosted sign-in]
  SignIn --> Code[Authorization code]
  Code --> Token[Token exchange]
  Token --> Resource[MCP resource server]
  Token --> Refresh[Refresh rotation]
Loading

Reviews (5) · Last reviewed commit: "fix(oauth): reject empty scope before si..."

Comment thread src/workos/routes/oauth.ts
Comment thread src/workos/routes/oauth.ts Outdated
Comment thread src/workos/routes/oauth.ts
Comment thread src/workos/routes/authkit-oauth.ts
Comment thread src/workos/routes/authkit-oauth.ts
Comment thread src/workos/routes/authkit-oauth.ts Outdated
Comment thread src/workos/routes/oauth.ts
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.
Comment thread src/workos/routes/connect.ts Outdated
…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.
Comment thread src/workos/authkit-oauth.ts
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.

1 participant