Repository navigation
feat(oauth)!: serve the AuthKit OAuth server MCP clients sign in through - #142
Merged
Merged
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.
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.
…ver-fidelity # Conflicts: # SUPPORTED.md
…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
[Critical impact] The PR appears safe to merge, with no outstanding findings. SummaryAdds the AuthKit OAuth server used by MCP clients, including discovery, client registration, hosted sign-in, PKCE, refresh tokens, and resource indicators.
|
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.
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.
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-serverand/.well-known/openid-configuration, dynamic client registration atPOST /oauth2/register(RFC 7591), hosted sign-in throughGET /oauth2/authorizewith PKCE and RFC 8707resource, public-client andrefresh_tokensupport atPOST /oauth2/token,GET /oauth2/error, and the spec's/user_management/authkit_oauth_resourcesroutes with aresourceIndicatorsseed 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-idphandlers, guards and consent token service apply. Each commit message cites the monorepo file and line for every rule.invalid_query_params,application_not_foundandinvalid_redirect_uri, the callback for everything after, with descriptions only where production gives them. Both redirects are built frombaseUrllike every other minted link.scopegrants 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.client_nameis required;application_typeis echoed and defaults toweb(nativedefaults tonone); scopes the client could not be granted are dropped and the effective set echoed;scopeis returned only when requested.client_idwins 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 areMissing client_secret.,Unsupported client_secret.andApplication not found.shouldIssueRefreshToken:offline_accessalways earns one; otherwise a dynamically registered client gets one iff it did not ask foropenid. Registeredgrant_typesno longer gate it. Rotation applies to public clients only.bearer, anid_tokenon every code exchange with noscopefield,id_tokenplus rotatedrefresh_tokenplusscopeon refresh,auth_timeon access tokens, a sortedscopethat is absent rather than empty, bareinvalid_grantfor every code and refresh failure, andclient_credentialsomitting an empty scope.resourceis validated only once resources are registered; with none registered the parameter is ignored; a client bound to nothing takes the resource flaggeddefault; a bound resource since deleted isinvalid_targetwith no description. Theaudfallback is documented as the one deviation on that claim.authkit_oauth_resourceis registered in the response-shape catalogs.BREAKING CHANGE
Answers on the existing
/oauth2/authorizeand/oauth2/tokenroutes 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 is401 invalid_clientwith production's messages. An OAuth application with nologin_urlnow gets the hosted sign-in instead of400 unauthorized_client. Applications with alogin_urlkeep Standalone Connect.Merge order
Land #137 (refresh-token replay grace) first. It makes
exchanged_atandexchanged_for_idrequired onWorkOSRefreshToken, so this branch's threerefreshTokens.insertcalls (src/workos/routes/oauth.tsand 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/tokenis a follow-up.Verification
Run locally on this head (no CI has run on the branch yet):