Repository navigation
feat(oauth)!: serve the AuthKit OAuth server MCP clients sign in through - #127
Open
flyinprogrammer wants to merge 5 commits into
Open
flyinprogrammer wants to merge 5 commits into
flyinprogrammer wants to merge 5 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 validatesiss,audandclient_idunchanged from production.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.
GET/.well-known/oauth-authorization-serverGET/.well-known/openid-configurationPOST/oauth2/registerGET/oauth2/authorizeresourcePOST/oauth2/tokenrefresh_tokengrantGET/oauth2/errorPOSTGETDELETE/user_management/authkit_oauth_resourcesThe behaviour comes from a production AuthKit domain, not the spec
None of the
/oauth2surface is in the OpenAPI spec, so the ground truth is what a production AuthKit domain answers to unauthenticated requests, observed 2026-09-30:302to/oauth2/error?error=application_not_found, a200 text/htmlpage that reflects theerror_descriptionit is given.401 {"error":"invalid_client","error_description":"Missing authorization header."}withWWW-Authenticate: Basic realm="AuthKit".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_endpointandclient_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-configurationis untouched. Its five fields are what production serves there, and that reasoning still holds.A registered client is a real Connect application
POST /oauth2/registercreates a third-partyoauthapplication, sowas_dynamically_registeredand the list route'sregistration_typesfilter now have something to report.token_endpoint_auth_method: nonemakes a public client with no secret. Thegrant_typesand auth method a client registers are stored and enforced at/oauth2/token, so a client that does not registerrefresh_tokenis not issued one.The resource is bound at authorize
A registered resource indicator decides
aud. Declare them with theresourceIndicatorsseed key or through the API:A request naming one that is not registered, or none, keeps the existing fallback to the application's
audience, then itsclient_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:
POST /oauth2/token, missing or unknown client400 invalid_request, or401without the header401 invalid_clientwithWWW-AuthenticateGET /oauth2/authorize, unknown client400 invalid_client302to/oauth2/errorGET /oauth2/authorize, OAuth application with nologin_url400 unauthorized_clientclient_credentialsis otherwise unchanged, claims included. Applications with alogin_urlkeep Standalone Connect.Not implemented, deliberately
Client ID metadata documents, the device grant on this surface, introspection, userinfo, and
id_tokenfor theopenidscope. Wildcard resource indicators are refused.Assumptions
Three things are the emulator's choice, not an observation, and the README records each:
iss,aud,sub,client_idandexp. No production token was captured.offline_access.401 Missing authorization header.Only the unknown-client cases were observed.Conformance and tests
Two existing assertions in
standalone-connect.spec.tschanged, 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 discoveredjwks_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:checkclean.gen:supportedre-run byte-identical.