diff --git a/README.md b/README.md index fe8561f..4447742 100644 --- a/README.md +++ b/README.md @@ -486,7 +486,7 @@ connectApplications: type: oauth is_first_party: false # optional, oauth only; a third-party app needs `organization` organization: Acme Corp - uses_pkce: true # optional, oauth only; reported on the app, not enforced + uses_pkce: true # optional, oauth only; marks a public client: /oauth2/token redeems its codes by PKCE verifier alone ``` Each seeded application is provisioned with a client secret. Pin `client_secret` to bake a known @@ -504,8 +504,8 @@ through the full Connect Applications surface: | `POST` | `/connect/applications/:id/client_secrets` | The one response carrying the plaintext, as `secret` | | `DELETE` | `/connect/client_secrets/:id` | | -`registration_types` defaults to `authenticated`, as production does — nothing in the emulator -performs dynamic client registration, so an unfiltered list shows every application it can create. +`registration_types` defaults to `authenticated`, as production does, so an unfiltered list hides the +applications `POST /oauth2/register` created; ask for them with `?registration_types=dynamic`. A secret's `last_used_at` is stamped when a token exchange actually succeeds, not merely when the secret is presented. @@ -618,6 +618,179 @@ at token exchange. The emulator's completion URL uses `/oauth2/authorize/complet not production's AuthKit-domain `/oauth/authorize/complete?state=...`; always follow the returned URL rather than constructing it. This is a local testing flow, not a replacement authentication service. +### AuthKit OAuth server (MCP clients) + +An MCP client (Claude Code and the like) signs in to a WorkOS environment through its **AuthKit +domain**, which acts as an OAuth 2.1 authorization server. The emulator reproduces that flow on its +single origin, so an MCP resource server can be tested against it locally: + +1. **Discovery.** `GET /.well-known/oauth-authorization-server` and + `GET /.well-known/openid-configuration`, unauthenticated, with the field sets a production + AuthKit domain serves. Endpoint URLs follow the host the document was fetched over; `issuer` is + the configured bare issuer (`--issuer`, default the emulator's own URL). There is no RFC 8414 + path-inserted form, because the issuer has no path. Only what the emulator implements is + advertised: production's `device_authorization_endpoint`, `introspection_endpoint`, + `userinfo_endpoint` and `client_id_metadata_document_supported` are **omitted, not stubbed**, so + a client never follows discovery into a 404. +2. **Registration.** `POST /oauth2/register` (RFC 7591), unauthenticated. It creates a real Connect + `oauth` application: third-party, `was_dynamically_registered`, listed under + `registration_types=dynamic`. `client_name` is required, as production requires it. + `application_type` is `web` or `native` (default `web`) and is echoed back. + `token_endpoint_auth_method: none` makes a public client (PKCE only, no secret); anything else a + confidential one with a generated `client_secret`; omitted, it is `none` for + `application_type: native` and `client_secret_basic` otherwise, as in production. The + `grant_types` (default `["authorization_code"]`) and `token_endpoint_auth_method` a client + registers are stored and echoed, as production stores them, but neither is enforced at + `/oauth2/token`: production reads neither there, so a refresh token is issued by the scope rule + in step 4 and a confidential client may send its secret in the body or as Basic either way. + `scope` follows production's registration: a scope the client cannot be granted is **dropped, + not refused**, and the effective scope is echoed back (only when one was requested, and as an + empty string when nothing survived). The grantable scopes are the standard four, + `openid profile email offline_access`; production also grants an environment's + dynamic-registration permissions, which the emulator does not model, so a custom scope is + always dropped. A registered application has no scopes of its own (`scopes: []`), as production + serves that field from assigned permissions. The response carries the resolved + `application_type`, `grant_types`, `response_types` and `token_endpoint_auth_method`. `logo_uri`, + `client_uri`, `policy_uri` and `tos_uri` must be http(s) URLs. `redirect_uris` go through the + [redirect-host policy](#redirect-uri-hosts). +3. **Authorize.** `GET /oauth2/authorize` on an application with no `login_url` validates the + request, then redirects to the hosted AuthKit sign-in, the page `/user_management/authorize` + serves. With `--interactive` that is the login page; without it the sign-in completes at once, + with the first user or the one named by `login_hint`, as non-interactive `/user_management/authorize` + does. **There is no consent step**: the first user (or `login_hint`) is signed in and authorized + without a prompt. Accepted: `code_challenge` with `code_challenge_method=S256` (the only method), + `state`, `scope`, RFC 8707 `resource`, plus `login_hint` and `organization_id`. Failures never + come back as an API error body, as in production. Until the client and its callback are + validated they redirect to `/oauth2/error?error=…`: `invalid_query_params` when `client_id` or + `redirect_uri` is missing, `application_not_found` for an unknown client (observed against a + production AuthKit domain, 2026-09-30), `invalid_redirect_uri` for a callback the client did not + register or the host policy refuses. After that they come back on the callback as + `error`/`error_description`/`state`: `unsupported_response_type`, `invalid_request` for a + missing `response_type` or an unknown `code_challenge_method`, `unauthorized_client` for an + application without the authorization-code grant, `invalid_scope`, `invalid_target`, with a + description only where production gives one. The error page is `200 text/html` and reflects the + `error_description` it is given, escaped. PKCE is taken as production takes it: a + `code_challenge` must name its method (`invalid_request`, "code_challenge_method is required + when code_challenge is provided."), a method without a challenge is ignored, nothing is checked + about the challenge's shape, and **a public client is not refused here for omitting PKCE**; that + is the token endpoint's question. `scope`: the standard four are open to every OAuth + application, a custom scope must be one of the application's own, and an **omitted `scope` + grants nothing** (a token with no `scope` claim), not everything. Applications with a + `login_url` keep the [Standalone Connect](#standalone-connect) behavior. +4. **Token.** `POST /oauth2/token`. Client authentication follows production's token-endpoint + guards: the body's `client_id` wins outright when present (the Authorization header is then not + read), else the Basic header is decoded and split on the first `:`. A client is only public or + confidential, whatever method it registered. Failures are `401 invalid_client` with + `WWW-Authenticate: Basic realm="AuthKit"`: "Missing authorization header." with neither + (observed against a production AuthKit domain, 2026-09-30), "Missing client_id." for an empty + Basic id, "Application not found." for an unknown `client_id` or a wrong secret (production looks + the pair up together), "Missing client_secret." for a confidential client without one, and + "Unsupported client_secret." for a public client with one; a public client asking for + `client_credentials` is `400 unauthorized_client`. These run before the body is validated, so an + unknown client is refused the same way under every grant type; then an unknown grant is + `unsupported_grant_type` and a body missing a required field `invalid_request` "Invalid token + request body.". With `authorization_code`, a public client authenticates by its `code_verifier` + alone, a confidential one by its secret plus the verifier when the code carries a challenge, and + a public client whose code carries no challenge is refused. Every failure of the code itself + (unknown, expired, wrong `redirect_uri`, another client's, a wrong or missing verifier) is the + bare `{"error":"invalid_grant"}` production returns, and **none of them spends the code**: the + verifier is checked before the exchange, so a wrong one leaves the code for a retry, and the + verifier's shape is not checked. The response is production's: `access_token`, `token_type` + (`bearer`, lowercase), `expires_in`, an `id_token` whatever the scope (production mints one + unconditionally), and `refresh_token` by production's rule: when the grant carries + `offline_access`, or when a dynamically registered client did not ask for `openid`; otherwise + none. There is no `scope` on a code exchange, and a `scope` parameter on it is ignored. + `grant_type=refresh_token` answers with `access_token`, `expires_in`, `id_token`, + `refresh_token`, `scope` (absent when none) and `token_type`; a public client's token is rotated + (the old one is spent) and a confidential client keeps the one it presented, as OAuth 2.1 §6.1 + lets production. A `scope` on refresh narrows that token alone, to the requested scopes the + grant covers; the grant is unchanged, and only a request that leaves nothing is `invalid_scope`. + A refresh token minted by `/user_management/authenticate` is `400 unauthorized_client` pointing + at that endpoint; every other refresh failure is the bare `invalid_grant`. An + `authorization_code` request from an M2M application, or a `client_credentials` request from an + OAuth application, is `401 invalid_client` without the Basic challenge. Token responses carry + `Cache-Control: no-store` and `Pragma: no-cache`. `client_credentials` keeps its behavior and + claims, with `token_type` `bearer`, a sorted `scope` omitted when none was issued, and the + client errors above rather than the earlier `400 invalid_request` / `401 Invalid client ID or + secret.`. +5. **Claims.** The access token carries what production's consent token service mints: `iss` (the + bare issuer, signed by the key at `/oauth2/jwks`), `aud` (step 6), `sub` (the user id), `sid`, + `jti`, `client_id`, `org_id` when the session has an organization, `auth_time` (when the + sign-in happened), `exp`, `iat`, and a sorted space-delimited `scope` that is absent rather than + empty. Production can add the environment's JWT template claims on top; the emulator does not, + and the token carries no `email`. +6. **Resource indicators.** Declare the resource servers your environment registers, by seed or + through the API (`POST/GET /user_management/authkit_oauth_resources`, `DELETE …/:id`). The rules + are production's. At `/oauth2/authorize`, a dynamically registered client's `resource` is + validated once any resource is registered: a malformed value or one matching no registered + resource is `invalid_target` on the callback; with none registered the parameter is ignored. A + dynamically registered client bound to nothing (it named no `resource`, or none is registered) + is bound to the resource flagged `default`, when there is one. Other clients' `resource` is + ignored at authorize, as production ignores it without a feature flag. At `/oauth2/token`, a + bound grant may restate or omit its resource and nothing else (a bound resource since deleted is + `invalid_target`); a grant bound to none may name a registered resource and have that token + minted for it, an unregistered one is `invalid_target`, and with no resources registered the + parameter is ignored. Resources are matched by normalized URL, as production matches them, and + the token carries the URI as registered. A bound resource is the token's `aud` and is carried + across every refresh. With none, production's `aud` is the environment's own client id, which + the emulator has no equivalent of: it falls back to the application's `audience`, then its + `client_id`. That fallback is the one deviation on this claim. + +```yaml +resourceIndicators: + - uri: https://mcp.example.test/mcp +users: + - email: alice@acme.test +``` + +```bash +BASE=http://localhost:4100 +curl -s $BASE/.well-known/oauth-authorization-server + +# Register a public client +CLIENT_ID=$(curl -s $BASE/oauth2/register -H 'Content-Type: application/json' \ + -d '{"client_name":"demo","redirect_uris":["http://localhost:33418/callback"],"token_endpoint_auth_method":"none"}' \ + | jq -r .client_id) + +# PKCE pair +VERIFIER=$(openssl rand -base64 48 | tr -d '=+/' | cut -c1-64) +CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=') + +# Authorize: two redirects (hosted sign-in, then your callback carrying ?code=…) +curl -si "$BASE/oauth2/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2Flocalhost%3A33418%2Fcallback&code_challenge=$CHALLENGE&code_challenge_method=S256&state=s1&scope=openid%20offline_access&resource=https%3A%2F%2Fmcp.example.test%2Fmcp&login_hint=alice%40acme.test" | grep -i '^location' +# curl -si "" | grep -i '^location' -> http://localhost:33418/callback?code=auth_code_…&state=s1 + +curl -s $BASE/oauth2/token -d grant_type=authorization_code -d client_id=$CLIENT_ID \ + -d code=auth_code_… -d redirect_uri=http://localhost:33418/callback -d code_verifier=$VERIFIER +curl -s $BASE/oauth2/token -d grant_type=refresh_token -d client_id=$CLIENT_ID -d refresh_token=ref_… +``` + +**`id_token`.** Every code exchange and every refresh carries an RS256 `id_token`, whatever the +scope, because production mints one unconditionally. It is signed with the key at `/oauth2/jwks` +and carries production's claims: `iss` (the bare issuer), `sub`, `aud` (the `client_id`), `exp`, +`iat`, and `nonce` when the authorize request sent one (a refresh carries none). With the `profile` +scope: `name`, composed from the user's first and last names (so an empty string when there are +none), and `given_name` and `family_name` where each exists. With the `email` scope: `email` and +`email_verified`. Production can add the environment's JWT template claims; the emulator does not. + +**Grant lifetime.** Each refresh re-checks the grant: it is refused with `invalid_grant` when the +session has expired or been revoked, or when the organization the token was scoped to is no longer +one the user is an active member of (the code exchange makes the same membership check). A refresh +token never outlives its session. Deleting an application also removes its refresh tokens and any +sign-in request it had parked. Those rules are emulator choices, not observed production behavior. +Scopes follow production: a custom scope removed from the application with +`PUT /connect/applications/:id` stops being issued at exchange and at refresh, the standard four +are not application scopes and are unaffected, and a grant left with no scope issues a token with +no `scope` claim rather than being refused. + +**Assumptions and gaps.** Not implemented: Client ID Metadata Documents, the device grant on this +surface, token introspection, `userinfo`, environment-level permissions for dynamically registered +clients, and the environment's own client id as a fallback `aud`. A registration must include +`authorization_code` in `grant_types` (an emulator rule: `response_types` is always `code`), and +`response_types` is `["code"]` or omitted. The registered resource is matched by normalized URL, +without wildcards. A seeded application with an empty `redirect_uris` accepts any allowed redirect +host, as Standalone Connect does; a dynamically registered one is matched exactly. + ### Client API tokens `POST /client/token` mints the short-lived token the Client GraphQL API expects, scoped to an @@ -1309,8 +1482,8 @@ What this buys you: `--issuer` is the base the client id hangs off, not the whole claim. An AuthKit access token from `/user_management/authenticate` carries `iss` of `{issuer}/user_management/{client_id}`, which is what production mints — so `--issuer https://api.workos.com` with a client of `client_123` gives -`https://api.workos.com/user_management/client_123`. The M2M, SSO and widget tokens carry the bare -value, as does an AuthKit token from a grant that named no `client_id` — there is no client to hang +`https://api.workos.com/user_management/client_123`. The M2M, SSO, widget and OAuth-server +(`/oauth2/token`) tokens carry the bare value, as does an AuthKit token from a grant that named no `client_id` — there is no client to hang off, and inventing a placeholder would advertise an issuer whose discovery document is not there. The key must be a PEM-encoded RSA private key, since tokens are signed RS256; anything else fails at diff --git a/SUPPORTED.md b/SUPPORTED.md index c67e613..d938057 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **199 of 264** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.123.0`) (**75.4%**). +The emulator implements **202 of 264** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.123.0`) (**76.5%**). Endpoint coverage says whether a route exists, not whether a feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is @@ -34,11 +34,11 @@ answers "can I actually emulate this?". | Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. | | API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | | Pipes / Connected Apps | ⚠️ 2/8 | ⚠️ 4/19 | ✅ seed `connectedAccounts` | User-owned connected-account CRUD and access-token retrieval are supported; a refresh mints a local `di_mock_` token rather than contacting the provider. Organization-owned connected accounts are not implemented. The older `/pipes/connections` routes remain emulator-specific. | -| Applications | ✅ 5/5 | ✅ 8/8 | ✅ seed `connectApplications` | A redirect URI is stored as a bare string, so `default` is accepted on create and update but always reported as `false`. `uses_pkce` and `is_first_party` are stored and reported, but nothing is registered dynamically, so `was_dynamically_registered` is always `false` and the list route's `registration_types` filter only ever matches `authenticated`. `POST /client/token` mints a signed, short-lived token, but the spec documents only the `{ token }` envelope — the claims inside are an emulator convention, and no Client GraphQL API is served for it to authenticate against. | +| Applications | ✅ 5/5 | ✅ 8/8 | ✅ seed `connectApplications` | A redirect URI is stored as a bare string, so `default` is accepted on create and update but always reported as `false`. `uses_pkce` and `is_first_party` are stored and reported; `uses_pkce` makes a public client, whose codes `/oauth2/token` redeems by PKCE verifier alone and which may present no client secret. `POST /oauth2/register` (RFC 7591, outside the spec) creates `was_dynamically_registered` applications, which `registration_types` filters. `POST /client/token` mints a signed, short-lived token, but the spec documents only the `{ token }` envelope — the claims inside are an emulator convention, and no Client GraphQL API is served for it to authenticate against. | | JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | | Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. | | Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. | -| AuthKit Configuration | ❌ 0/3 | ⚠️ 2/5 | ⚠️ API only | Redirect URIs are accepted but not enforced against authorize requests. OAuth resource indicators are not implemented. | +| AuthKit Configuration | ⚠️ 1/3 | ⚠️ 4/5 | ✅ seed `resourceIndicators` | Redirect URIs are accepted but not enforced against authorize requests. MCP resource indicators (RFC 8707) are stored and, when a request names a registered one, become the `aud` of tokens from `/oauth2/token`; once any is registered, a dynamically registered client naming an unregistered one at `/oauth2/authorize` is answered `invalid_target`, and one naming none is bound to the resource flagged `default`. Resources are matched by normalized URL; wildcard patterns are refused. | | Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | | Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. | | Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index f91237a..5ebd350 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -75,6 +75,7 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [ // The spec names only the secret-bearing creation shape; the list route's secretless // variant is an inline schema, so `secret` is carried as a tracked gap on this entry. { objectType: 'connect_application_secret', schemaName: 'NewConnectApplicationSecret' }, + { objectType: 'authkit_oauth_resource', schemaName: 'AuthkitOauthResource' }, // `connect_application` is deliberately absent: `ConnectApplication` is an allOf over a // four-way oneOf (first-party / dynamically registered / third-party oauth, and m2m), and // this catalog models one flat shape per object. Flattening it would drop the discriminated @@ -121,6 +122,15 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [ // one that drifted to a bare `token` (issue #98). The resource catalog only sees the formatter. { method: 'POST', path: '/user_management/password_reset', status: '201', schemaName: 'PasswordReset' }, { method: 'GET', path: '/user_management/password_reset/{id}', status: '200', schemaName: 'PasswordReset' }, + // The create route's body is the resource itself. Its list route is not here: the spec gives + // that envelope inline (an allOf over the paginated list with `AuthkitOauthResource` items) + // rather than by name, and this catalog asserts a named schema per operation. + { + method: 'POST', + path: '/user_management/authkit_oauth_resources', + status: '201', + schemaName: 'AuthkitOauthResource', + }, { method: 'POST', path: '/user_management/users/{id}/email_verification/send', diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index 179265b..0860b40 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -208,7 +208,7 @@ export const FEATURES: FeatureDef[] = [ ], seedKeys: ['connectApplications'], notes: - "A redirect URI is stored as a bare string, so `default` is accepted on create and update but always reported as `false`. `uses_pkce` and `is_first_party` are stored and reported, but nothing is registered dynamically, so `was_dynamically_registered` is always `false` and the list route's `registration_types` filter only ever matches `authenticated`. `POST /client/token` mints a signed, short-lived token, but the spec documents only the `{ token }` envelope — the claims inside are an emulator convention, and no Client GraphQL API is served for it to authenticate against.", + 'A redirect URI is stored as a bare string, so `default` is accepted on create and update but always reported as `false`. `uses_pkce` and `is_first_party` are stored and reported; `uses_pkce` makes a public client, whose codes `/oauth2/token` redeems by PKCE verifier alone and which may present no client secret. `POST /oauth2/register` (RFC 7591, outside the spec) creates `was_dynamically_registered` applications, which `registration_types` filters. `POST /client/token` mints a signed, short-lived token, but the spec documents only the `{ token }` envelope — the claims inside are an emulator convention, and no Client GraphQL API is served for it to authenticate against.', }, { name: 'JWT Templates', @@ -233,8 +233,9 @@ export const FEATURES: FeatureDef[] = [ { name: 'AuthKit Configuration', tags: ['user-management.redirect-uris', 'user-management.cors-origins', 'user-management.authkit-oauth-resources'], + seedKeys: ['resourceIndicators'], notes: - 'Redirect URIs are accepted but not enforced against authorize requests. OAuth resource indicators are not implemented.', + 'Redirect URIs are accepted but not enforced against authorize requests. MCP resource indicators (RFC 8707) are stored and, when a request names a registered one, become the `aud` of tokens from `/oauth2/token`; once any is registered, a dynamically registered client naming an unregistered one at `/oauth2/authorize` is answered `invalid_target`, and one naming none is bound to the resource flagged `default`. Resources are matched by normalized URL; wildcard patterns are refused.', }, { name: 'Admin Portal', diff --git a/src/core/id.ts b/src/core/id.ts index 7c9cf63..70e6b2c 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -80,6 +80,7 @@ export const ID_PREFIXES = { role: 'role', permission: 'perm', role_permission: 'rp', + authkit_oauth_resource: 'authkit_oauth_resource', authorization_resource: 'authz_resource', role_assignment: 'role_assignment', group_role_assignment: 'gra', diff --git a/src/core/middleware/error-handler.ts b/src/core/middleware/error-handler.ts index de15222..7125ee6 100644 --- a/src/core/middleware/error-handler.ts +++ b/src/core/middleware/error-handler.ts @@ -20,16 +20,28 @@ export class WorkOSApiError extends Error { * (which always carry `{code, message}`) need no special casing. */ export class OauthApiError extends WorkOSApiError { - constructor(status: number, error: string, description: string) { - super(status, description, error); + /** Response headers the failure carries, e.g. the `WWW-Authenticate` a 401 invalid_client needs (RFC 6749 §5.2). */ + readonly headers?: Record; + + /** + * `description` is optional because production's OAuth2 exception renders `error_description` + * only when one was given: its code and refresh-token failures are the bare `{ error }`. + */ + constructor(status: number, error: string, description?: string, headers?: Record) { + super(status, description ?? '', error); this.name = 'OauthApiError'; + this.headers = headers; } } export function createApiErrorHandler(): ErrorHandler { return (err, c) => { if (err instanceof OauthApiError) { - return c.json({ error: err.code, error_description: err.message }, err.status as ContentfulStatusCode); + return c.json( + { error: err.code, ...(err.message ? { error_description: err.message } : {}) }, + err.status as ContentfulStatusCode, + err.headers, + ); } if (err instanceof WorkOSApiError) { const body: Record = { diff --git a/src/core/server.ts b/src/core/server.ts index 8f7a008..8b93bcd 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -27,7 +27,8 @@ export interface ServerOptions { * constant accepts emulator tokens unchanged. * * Not the whole claim: an AuthKit access token carries `{issuer}/user_management/{client_id}`, - * as production does. Only the M2M, SSO and widget tokens carry the bare value. + * as production does. Only the M2M, SSO, widget and OAuth-server (`/oauth2/token`) tokens carry + * the bare value. */ issuer?: string; /** Pinned RSA signing key, keeping the JWKS stable across restarts. */ @@ -84,6 +85,10 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) '/user_management/authorize/device/verify', '/user_management/authenticate', '/user_management/sessions/logout', + // The AuthKit domain's discovery documents, fetched by an OAuth client before it holds any + // credential. Only these two: the RFC 8414 path-inserted form is not served (see the routes). + '/.well-known/oauth-authorization-server', + '/.well-known/openid-configuration', ]); // /oauth2/* is the M2M authorization server: the token endpoint authenticates by diff --git a/src/index.ts b/src/index.ts index 3b177c9..8e33de8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export interface EmulatorSeedConfig { permissions?: WorkOSSeedConfig['permissions']; webhookEndpoints?: WorkOSSeedConfig['webhookEndpoints']; connectApplications?: WorkOSSeedConfig['connectApplications']; + resourceIndicators?: WorkOSSeedConfig['resourceIndicators']; jwtTemplate?: WorkOSSeedConfig['jwtTemplate']; featureFlags?: WorkOSSeedConfig['featureFlags']; directories?: WorkOSSeedConfig['directories']; @@ -64,7 +65,8 @@ export interface EmulatorOptions { * test-only branch. The verifier must still fetch JWKS from the emulator. * * Not the whole claim: an AuthKit access token carries `{issuer}/user_management/{client_id}`, - * as production does. Only the M2M, SSO and widget tokens carry the bare value. + * as production does. Only the M2M, SSO, widget and OAuth-server (`/oauth2/token`) tokens carry + * the bare value, and the latter's discovery documents advertise it as `issuer`. */ issuer?: string; /** diff --git a/src/workos/authkit-oauth-mcp-client.spec.ts b/src/workos/authkit-oauth-mcp-client.spec.ts new file mode 100644 index 0000000..c7b1a2a --- /dev/null +++ b/src/workos/authkit-oauth-mcp-client.spec.ts @@ -0,0 +1,181 @@ +/** + * End to end, as an MCP client sees the AuthKit domain: over real HTTP against a running emulator, + * holding no API key, learning every URL from discovery and verifying the tokens it is given + * against the JWKS discovery names — the way an MCP resource server would, with nothing shared + * with the emulator's in-process state. Only the seeded user and the resource indicator are set up + * out of band, as the environment's owner would in the dashboard. + */ +import { createHash, createPublicKey, createVerify, randomBytes, type JsonWebKey } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { createEmulator, type Emulator } from '../index.js'; +import { getWorkOSStore } from './store.js'; + +const RESOURCE = 'https://mcp.example.test/mcp'; +const REDIRECT_URI = 'http://localhost:33418/callback'; + +interface Jwks { + keys: Array; +} + +/** What a resource server does with a bearer token: check the signature against the JWKS, then the claims. */ +function verifyAccessToken(token: string, jwks: Jwks): { header: Record; claims: Record } { + const [headerB64, payloadB64, signature] = token.split('.'); + const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString()); + const claims = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); + expect(header.alg).toBe('RS256'); + const jwk = jwks.keys.find((k) => k.kid === header.kid); + expect(jwk).toBeDefined(); + const verifier = createVerify('RSA-SHA256').update(`${headerB64}.${payloadB64}`); + expect(verifier.verify(createPublicKey({ key: jwk!, format: 'jwk' }), signature, 'base64url')).toBe(true); + expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)); + return { header, claims }; +} + +describe('MCP client against the AuthKit OAuth server', () => { + let emulator: Emulator; + + beforeAll(async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: 'alice@acme.test', first_name: 'Alice' }], + organizations: [{ name: 'Acme', memberships: [{ email: 'alice@acme.test' }] }], + resourceIndicators: [{ uri: RESOURCE }], + }, + }); + }); + + afterAll(async () => { + await emulator.close(); + }); + + const postForm = (url: string, body: Record) => + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body), + }); + + /** Follow redirects by hand until one lands on the client's own callback, as a browser would. */ + async function followToCallback(start: string): Promise { + let next = start; + for (let hop = 0; hop < 5; hop++) { + const res = await fetch(next, { redirect: 'manual' }); + expect(res.status).toBe(302); + next = new URL(res.headers.get('location')!, next).toString(); + if (next.startsWith(REDIRECT_URI)) return new URL(next); + } + throw new Error('never reached the callback'); + } + + it('discovers, registers, authorizes with PKCE and a resource, exchanges, verifies and refreshes', async () => { + // 1. Discovery, unauthenticated. + const discovery = await fetch(`${emulator.url}/.well-known/oauth-authorization-server`); + expect(discovery.status).toBe(200); + const metadata = (await discovery.json()) as Record; + expect(metadata.issuer).toBe(emulator.url); + expect(metadata.code_challenge_methods_supported).toEqual(['S256']); + expect(metadata.token_endpoint_auth_methods_supported).toContain('none'); + + // 2. Dynamic client registration: a public client. + const registration = await fetch(metadata.registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'MCP test client', + redirect_uris: [REDIRECT_URI], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }), + }); + expect(registration.status).toBe(201); + const client = (await registration.json()) as { client_id: string }; + + // 3. Authorize with PKCE and the RFC 8707 resource; the hosted sign-in completes on its own + // because the emulator is not interactive. + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const state = randomBytes(8).toString('hex'); + const callback = await followToCallback( + `${metadata.authorization_endpoint}?${new URLSearchParams({ + response_type: 'code', + client_id: client.client_id, + redirect_uri: REDIRECT_URI, + code_challenge: challenge, + code_challenge_method: 'S256', + scope: 'openid profile offline_access', + state, + resource: RESOURCE, + })}`, + ); + expect(callback.searchParams.get('state')).toBe(state); + const code = callback.searchParams.get('code')!; + expect(code).toBeTruthy(); + + // 4. Exchange: no client secret, only the verifier. + const tokenResponse = await postForm(metadata.token_endpoint, { + grant_type: 'authorization_code', + client_id: client.client_id, + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + resource: RESOURCE, + }); + expect(tokenResponse.status).toBe(200); + const tokens = (await tokenResponse.json()) as Record; + // Production's code-exchange field set: lowercase `bearer`, an `id_token`, no `scope`. + expect(tokens).toMatchObject({ token_type: 'bearer', expires_in: 3600 }); + expect(tokens.id_token.split('.')).toHaveLength(3); + expect(tokens).not.toHaveProperty('scope'); + // `offline_access` was granted, so a refresh token comes with it. + expect(tokens.refresh_token).toMatch(/^ref_/); + + // 5. Verify against the JWKS discovery named. + const jwks = (await (await fetch(metadata.jwks_uri)).json()) as Jwks; + const ws = getWorkOSStore(emulator.store); + const alice = ws.users.findOneBy('email', 'alice@acme.test')!; + const first = verifyAccessToken(tokens.access_token, jwks); + expect(first.claims).toMatchObject({ + iss: metadata.issuer, + aud: RESOURCE, + sub: alice.id, + client_id: client.client_id, + // Sorted, as production formats the claim. + scope: 'offline_access openid profile', + }); + expect(typeof first.claims.auth_time).toBe('number'); + expect(first.claims.org_id).toBe(ws.organizations.findOneBy('name', 'Acme')!.id); + expect(first.claims.sid).toBeTruthy(); + expect(first.claims.jti).toBeTruthy(); + expect(first.claims).not.toHaveProperty('email'); + + // 6. Refresh, then verify the new token the same way. + const refreshed = await postForm(metadata.token_endpoint, { + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: tokens.refresh_token, + }); + expect(refreshed.status).toBe(200); + const next = (await refreshed.json()) as Record; + expect(next.refresh_token).not.toBe(tokens.refresh_token); + const second = verifyAccessToken(next.access_token, jwks); + expect(second.claims).toMatchObject({ + iss: metadata.issuer, + aud: RESOURCE, + sub: alice.id, + client_id: client.client_id, + sid: first.claims.sid, + }); + expect(second.claims.jti).not.toBe(first.claims.jti); + + // The spent refresh token is refused. + const replay = await postForm(metadata.token_endpoint, { + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: tokens.refresh_token, + }); + expect(replay.status).toBe(400); + expect(((await replay.json()) as { error: string }).error).toBe('invalid_grant'); + }); +}); diff --git a/src/workos/authkit-oauth.ts b/src/workos/authkit-oauth.ts new file mode 100644 index 0000000..34ba34b --- /dev/null +++ b/src/workos/authkit-oauth.ts @@ -0,0 +1,196 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import type { WorkOSStore } from './store.js'; +import type { WorkOSConnectApplication } from './entities.js'; + +/** + * The AuthKit domain acting as an OAuth 2.1 authorization server for MCP clients: what + * `/oauth2/authorize`, `/oauth2/token` and `/oauth2/register` share with the discovery documents. + * Kept out of the route files because the documents advertise exactly what the routes enforce, and + * one list is what keeps the two from drifting. + */ + +/** + * The standard scopes a production AuthKit domain advertises. Production's scope parser accepts + * every one of them from any application that is not machine-to-machine, whatever permissions the + * application was assigned; only a non-standard scope has to be one of the application's own. + */ +export const AUTHKIT_OAUTH_SCOPES = ['email', 'offline_access', 'openid', 'profile'] as const; + +/** Only S256: production advertises no other, and OAuth 2.1 drops `plain`. */ +export const AUTHKIT_CODE_CHALLENGE_METHODS = ['S256'] as const; + +/** `none` is a public client, which `/oauth2/register` maps to a PKCE-only application. */ +export const AUTHKIT_TOKEN_ENDPOINT_AUTH_METHODS = ['none', 'client_secret_post', 'client_secret_basic'] as const; + +/** RFC 7591 `application_type`, the two values production's registration accepts. */ +export const AUTHKIT_APPLICATION_TYPES = ['web', 'native'] as const; + +export const isStandardScope = (scope: string): boolean => (AUTHKIT_OAUTH_SCOPES as readonly string[]).includes(scope); + +/** + * Whether the application is a public client: one that cannot keep a secret, so PKCE is its only + * proof. `uses_pkce` is the field WorkOS gives an application for exactly this (production + * serializes it from `clientConfidentiality === 'public'`), and `/oauth2/register` sets it for + * `token_endpoint_auth_method: none`. + */ +export function isPublicClient(application: WorkOSConnectApplication): boolean { + return application.application_type === 'oauth' && application.uses_pkce; +} + +/** + * RFC 7636 §4.6 for S256, as production's `validateCodeChallenge` does it: hash whatever verifier + * was sent and compare, with no check on the verifier's length or alphabet. Constant-time, because + * the verifier is a secret until the exchange. + */ +export function pkceMatches(codeVerifier: string | undefined, codeChallenge: string): boolean { + if (!codeVerifier) return false; + const computed = Buffer.from(createHash('sha256').update(codeVerifier).digest('base64url')); + const expected = Buffer.from(codeChallenge); + return computed.length === expected.length && timingSafeEqual(computed, expected); +} + +/** + * The scopes an OAuth application may be asked for at `/oauth2/authorize`: the standard set, which + * production allows every non-M2M application, plus the application's own (custom) scopes. The + * emulator has no environment-level permissions for dynamically registered clients, so for those + * the custom half is empty. + */ +export function allowedScopes(application: WorkOSConnectApplication): string[] { + const custom = Array.isArray(application.scopes) ? application.scopes : []; + return [...AUTHKIT_OAUTH_SCOPES, ...custom.filter((s) => !isStandardScope(s))]; +} + +/** + * The scopes of a grant that can still be issued. Production reads a consent's scopes back as its + * standard scopes (stored on the consent itself) plus whichever of its permission scopes still + * exist, so a custom scope removed from the application drops out of every later token while the + * standard ones stay. Nothing is refused for being empty: a consent granted no scope yields a + * token with no `scope` claim. + */ +export function grantableScopes(application: WorkOSConnectApplication, granted: readonly string[]): string[] { + const custom = Array.isArray(application.scopes) ? application.scopes : []; + return granted.filter((s) => isStandardScope(s) || custom.includes(s)); +} + +/** + * The `scope` string production puts on a token and in a token response: sorted, de-duplicated, + * space-delimited, and `undefined` rather than empty when there is none, so the claim and the + * response field are both absent instead of `""`. + */ +export function formatScope(scopes: readonly string[]): string | undefined { + return [...new Set(scopes)].sort((a, b) => a.localeCompare(b)).join(' ') || undefined; +} + +/** A space-delimited `scope` parameter as production parses it: split on whitespace, de-duplicated, empty when absent. */ +export function parseScopeParam(value: string | undefined): string[] { + return value ? [...new Set(value.split(/\s+/).filter(Boolean))] : []; +} + +/** + * Why production's resource URI parser refuses a value, or `null` when it does not. RFC 8707 §2: + * an absolute URI with no fragment. The two reasons carry production's own descriptions. + */ +export function resourceUriError(value: string): 'MalformedUri' | 'UriContainsFragment' | null { + const trimmed = value.trim(); + try { + new URL(trimmed); + } catch { + return 'MalformedUri'; + } + return trimmed.includes('#') ? 'UriContainsFragment' : null; +} + +export function isValidResourceUri(value: string): boolean { + return resourceUriError(value) === null; +} + +/** + * Whether two resource URIs identify the same resource, the way production's `OauthResourceUri` + * compares them: by normalized `href`, so `https://a.test` and `https://a.test/` are one resource. + */ +export function resourceUrisEqual(a: string, b: string): boolean { + try { + return new URL(a.trim()).href === new URL(b.trim()).href; + } catch { + return false; + } +} + +/** The registered resource identifying `uri`, if any. Wildcard patterns are refused at registration, so matching is exact. */ +export function findRegisteredResource(ws: WorkOSStore, uri: string) { + return ws.authkitOauthResources.all().find((r) => resourceUrisEqual(r.uri, uri)); +} + +/** + * The `aud` of a Connect access token: the resource the grant is bound to when there is one. + * Production otherwise uses the environment's own client id (`environment.clientId`), which the + * emulator has no equivalent of, since the AuthKit client id is whatever each caller supplies. The + * emulator falls back to the application's `audience` and then its `client_id`, which is what this + * surface minted before resource indicators existed. That fallback is the one documented deviation + * on this claim. + */ +export function resolveAudience(application: WorkOSConnectApplication, resource: string | null): string { + return resource ?? application.audience ?? application.client_id; +} + +export type AuthorizeResourceResult = + | { ok: true; resource: string | null } + | { ok: false; description: string | undefined }; + +/** + * The RFC 8707 `resource` of an `/oauth2/authorize` request, resolved the way production's + * authorize handler does it (`validateResource` and the default-resource fallback that follows it): + * + * - A dynamically registered client's `resource` is validated only once the environment has + * registered resources. Then a malformed or fragment-bearing value, or one matching no registered + * resource, is `invalid_target` on the callback, with a description only for the malformed case. + * With no resources registered the parameter is ignored, for backwards compatibility. + * - Any other client's `resource` is ignored: production gates resource indicators for those + * behind the `connect-resource-indicators-for-all-clients` environment flag, so the flag-off path + * is the one reproduced. + * - A dynamically registered client that ends up bound to nothing (it omitted `resource`, or + * nothing is registered) is bound to the resource flagged `default`, when one exists, so its + * tokens still carry a resource audience. Some MCP clients omit the parameter entirely. + * + * What is bound is the registered URI as registered, which is also what the token carries. + */ +export function resolveAuthorizeResource( + ws: WorkOSStore, + application: WorkOSConnectApplication, + resource: string | null, +): AuthorizeResourceResult { + if (!application.was_dynamically_registered) return { ok: true, resource: null }; + const registered = ws.authkitOauthResources.all(); + let bound: string | null = null; + if (resource !== null && registered.length > 0) { + const reason = resourceUriError(resource); + if (reason !== null) { + return { + ok: false, + description: + reason === 'UriContainsFragment' + ? 'Invalid target: resource URIs cannot contain a URL fragment.' + : 'Invalid target: resource URI is malformed.', + }; + } + const match = findRegisteredResource(ws, resource); + if (!match) return { ok: false, description: undefined }; + bound = match.uri; + } + if (bound === null) bound = registered.find((r) => r.default)?.uri ?? null; + return { ok: true, resource: bound }; +} + +/** + * Whether a code exchange is answered with a refresh token, as production decides it + * (`shouldIssueRefreshToken` in the application consent tokens service): always when the grant + * carries `offline_access`; otherwise a dynamically registered client, which today means an MCP + * client, gets one exactly when it did not ask for `openid`, since outside OIDC mode issuing one + * is at the server's discretion and some connectors never send a scope yet fail without one; any + * other client gets none. The `grant_types` a client registered play no part. + */ +export function shouldIssueRefreshToken(application: WorkOSConnectApplication, scopes: readonly string[]): boolean { + if (scopes.includes('offline_access')) return true; + if (application.was_dynamically_registered) return !scopes.includes('openid'); + return false; +} diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 431c0f5..bb793eb 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -6,6 +6,7 @@ import { validateJwtTemplateContent } from './jwt-template.js'; import { isValidResourceTypeSlug } from './constants.js'; import { AGENT_SESSION_SETTING_LIMITS } from './agent-sessions.js'; import { normalizeEmail, type NormalizedEmail } from './helpers.js'; +import { isValidResourceUri, resourceUrisEqual } from './authkit-oauth.js'; /** * A seed is the one creation path that does not go through a route, so it is held to what the @@ -948,6 +949,24 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: appConfig.uses_pkce, }); } + // Production's token endpoint splits Basic credentials on every colon and keeps the first + // two parts (parseAuthorization), so a secret containing ':' is truncated there, and the + // emulator decodes the header the same way. A pinned secret has to be one it can accept. + if (appConfig.client_secret !== undefined) { + if (typeof appConfig.client_secret !== 'string' || !appConfig.client_secret) { + errors.push({ + path: `connectApplications[${index}].client_secret`, + message: 'client_secret must be a non-empty string if provided', + value: appConfig.client_secret, + }); + } else if (appConfig.client_secret.includes(':')) { + errors.push({ + path: `connectApplications[${index}].client_secret`, + message: "client_secret cannot contain ':', which Basic credentials split on at the token endpoint", + value: appConfig.client_secret, + }); + } + } // A third-party oauth application is reported with its owning organization, so it // needs one for the same reason an m2m application does. const needsOrganization = type === 'm2m' || appConfig.is_first_party === false; @@ -1007,6 +1026,60 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe } } + // Validate MCP resource indicators. The routes refuse the same shapes, so a seed cannot hold one + // the API would not have created, and the uniqueness the create route enforces holds here too. + if (config.resourceIndicators) { + if (!Array.isArray(config.resourceIndicators)) { + errors.push({ + path: 'resourceIndicators', + message: 'resourceIndicators must be an array', + value: config.resourceIndicators, + }); + } else { + const seenUris = new Set(); + let defaults = 0; + config.resourceIndicators.forEach((entry, index) => { + const at = (field: string) => `resourceIndicators[${index}].${field}`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + errors.push({ + path: `resourceIndicators[${index}]`, + message: 'each resource must be an object', + value: entry, + }); + return; + } + if (typeof entry.uri !== 'string' || !entry.uri) { + errors.push({ path: at('uri'), message: 'uri is required and must be a string', value: entry.uri }); + } else if (entry.uri.includes('*') || !isValidResourceUri(entry.uri)) { + errors.push({ + path: at('uri'), + message: 'uri must be an absolute URI without a fragment or wildcard', + value: entry.uri, + }); + } else if ([...seenUris].some((seen) => resourceUrisEqual(seen, entry.uri))) { + // Compared the way the API's duplicate check and every matcher compare, by normalized + // URL, so `https://a.test` and `https://a.test/` cannot both be seeded. + errors.push({ + path: at('uri'), + message: 'uri must be unique across resourceIndicators', + value: entry.uri, + }); + } else { + seenUris.add(entry.uri); + } + if (entry.default !== undefined && typeof entry.default !== 'boolean') { + errors.push({ path: at('default'), message: 'default must be a boolean if provided', value: entry.default }); + } else if (entry.default === true && ++defaults > 1) { + errors.push({ + path: at('default'), + message: 'only one resource may be the default', + value: entry.default, + }); + } + }); + } + } + // Validate API key resources. The map form is the legacy auth allow-list and is // intentionally left unvalidated here; only the array (resource) form is checked. if (config.apiKeys && Array.isArray(config.apiKeys)) { diff --git a/src/workos/connect-authorize-request.ts b/src/workos/connect-authorize-request.ts new file mode 100644 index 0000000..4e6b782 --- /dev/null +++ b/src/workos/connect-authorize-request.ts @@ -0,0 +1,56 @@ +import { generateId, type Store } from '../core/index.js'; +import { STORE_KEY_PREFIXES } from './constants.js'; +import { expiresIn, isExpired } from './helpers.js'; + +/** A validated `/oauth2/authorize` request held while the hosted sign-in runs. */ +export interface ConnectAuthorizeRequest { + client_id: string; + redirect_uri: string; + state: string | null; + code_challenge: string | null; + code_challenge_method: string | null; + scope: string[]; + resource: string | null; + /** OIDC `nonce`, echoed into the `id_token`. */ + nonce: string | null; + expires_at: string; +} + +const requestKey = (token: string) => `${STORE_KEY_PREFIXES.connectAuthorize}${token}`; + +/** Store one validated request and discard abandoned requests that have expired. */ +export function createConnectAuthorizeRequest( + store: Store, + request: Omit, +): string { + store.deleteDataByPrefix(STORE_KEY_PREFIXES.connectAuthorize, (value) => + isExpired((value as ConnectAuthorizeRequest).expires_at), + ); + const token = generateId('connect_req'); + store.setData(requestKey(token), { ...request, expires_at: expiresIn(10) }); + return token; +} + +/** Read a live request, removing an expired one when it is encountered. */ +export function getConnectAuthorizeRequest(store: Store, token: string): ConnectAuthorizeRequest | undefined { + const key = requestKey(token); + const request = store.getData(key); + if (request && !isExpired(request.expires_at)) return request; + if (request) store.deleteData(key); + return undefined; +} + +/** Consume a request after it has produced its single authorization code. */ +export function consumeConnectAuthorizeRequest(store: Store, token: string): ConnectAuthorizeRequest | undefined { + const request = getConnectAuthorizeRequest(store, token); + if (request) store.deleteData(requestKey(token)); + return request; +} + +/** Remove every parked sign-in for a deleted Connect application. */ +export function deleteConnectAuthorizeRequestsForClient(store: Store, clientId: string): number { + return store.deleteDataByPrefix( + STORE_KEY_PREFIXES.connectAuthorize, + (value) => (value as ConnectAuthorizeRequest).client_id === clientId, + ); +} diff --git a/src/workos/constants.ts b/src/workos/constants.ts index b9a87d4..d4007c5 100644 --- a/src/workos/constants.ts +++ b/src/workos/constants.ts @@ -15,6 +15,8 @@ export const STORE_KEY_PREFIXES = { pendingAuth: 'pending_auth:', /** A password the interactive page has checked, carried across the organization page instead of the password itself. */ interactiveLogin: 'interactive_login:', + /** A validated `/oauth2/authorize` request, waiting on the hosted sign-in it redirected to. */ + connectAuthorize: 'connect_authorize:', ssoToken: 'sso_token:', ssoLogout: 'sso_logout:', auditSchema: 'audit_schema_', diff --git a/src/workos/entities.ts b/src/workos/entities.ts index c86b239..f5af4c2 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -167,6 +167,24 @@ export interface WorkOSExternalAuthSession extends Entity { user_id: string | null; } +/** + * What a grant minted on the AuthKit-domain OAuth surface (`/oauth2/authorize` → `/oauth2/token`) + * carries beyond an ordinary AuthKit one. Its presence is also the marker that says which token + * endpoint may redeem the code or refresh token: `/oauth2/token` takes only these, and + * `/user_management/authenticate` refuses them, so a grant can't cross between two issuers. + */ +export interface ConnectGrant { + /** Scopes granted at authorize time; a refresh may narrow them, never widen. */ + scope: string[]; + /** + * The RFC 8707 `resource` as the client asked for it, registered or not. Whether it is a + * registered indicator is decided at each mint, so deleting one takes effect on the next refresh. + */ + resource: string | null; + /** OIDC `nonce` from the authorize request; only a code carries one, since only the code exchange issues an `id_token`. */ + nonce?: string | null; +} + export interface WorkOSAuthorizationCode extends Entity { user_id: string; organization_id: string | null; @@ -177,6 +195,8 @@ export interface WorkOSAuthorizationCode extends Entity { code_challenge_method: string | null; /** The OAuth client that initiated the authorization, bound to the code so the token claim can't be spoofed at redemption. */ client_id: string | null; + /** Set on codes minted by `/oauth2/authorize`'s hosted sign-in; redeemable only at `/oauth2/token`. */ + connect?: ConnectGrant | null; /** * How the user proved who they were on the way to this code, when the emulator knows. The * interactive password page records 'Password'; the default auto-redirect checks nothing and @@ -284,6 +304,18 @@ export interface WorkOSRedirectUri extends Entity { uri: string; } +/** + * An MCP resource indicator (RFC 8707) registered for the environment. The `resource` an + * `/oauth2/authorize` or `/oauth2/token` request names becomes the token's `aud` only when it + * matches one of these; see `authkit_oauth_resources` in the spec. + */ +export interface WorkOSAuthkitOauthResource extends Entity { + object: 'authkit_oauth_resource'; + uri: string; + /** The environment default. At most one resource holds it; setting it clears the previous holder. */ + default: boolean; +} + export interface WorkOSCorsOrigin extends Entity { object: 'cors_origin'; origin: string; @@ -343,6 +375,8 @@ export interface WorkOSRefreshToken extends Entity { expires_at: string; /** The client_id the original access token was minted for, carried forward across refresh rotations. */ client_id: string | null; + /** Set when the token was issued by `/oauth2/token`; only that endpoint may rotate it. */ + connect?: ConnectGrant | null; /** * When this token was exchanged for its successor; null while it is the live token. A retired * token stays stored through the replay grace period so a concurrent or retried refresh can @@ -547,6 +581,14 @@ export interface WorkOSConnectApplication extends Entity { /** oauth third-party only: registered through dynamic client registration rather than the dashboard. */ was_dynamically_registered: boolean; uses_pkce: boolean; + /** + * oauth, dynamic registration only (RFC 7591): the metadata the client registered, stored as + * production stores it and not enforced at `/oauth2/token`, where production reads neither. + * Undefined on a seeded or dashboard-created application. + */ + grant_types?: string[]; + response_types?: string[]; + token_endpoint_auth_method?: 'none' | 'client_secret_post' | 'client_secret_basic'; /** Emulator-only Standalone Connect login page; never serialized on the API application. */ login_url: string | null; client_id: string; diff --git a/src/workos/generated/response-shapes.ts b/src/workos/generated/response-shapes.ts index 1674519..956be2c 100644 --- a/src/workos/generated/response-shapes.ts +++ b/src/workos/generated/response-shapes.ts @@ -63,6 +63,11 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record return formatEntity(r); } +/** + * The organizations a session could be scoped to. 'pending' is an unaccepted invitation and + * 'inactive' a deactivated member; neither is one production would scope a session to. + */ +export function activeOrganizationsFor(ws: WorkOSStore, userId: string): Array<{ id: string; name: string }> { + const orgs: Array<{ id: string; name: string }> = []; + for (const m of ws.organizationMemberships.findBy('user_id', userId)) { + if (m.status !== 'active') continue; + const org = ws.organizations.get(m.organization_id); + if (org) orgs.push({ id: org.id, name: org.name }); + } + return orgs; +} + +/** + * Record a fresh sign-in: stamp the user's last sign-in and create the session it starts. Shared by + * `/user_management/authenticate` and `/oauth2/token`, so a login through either leaves the same + * trace. The caller emits the authentication event, which it knows the method of. + * + * `verifyEmail` marks the mailbox proven (a redeemed magic-auth code does), folded into the sign-in + * write so it is one write and one `user.updated`. + */ +export function startLoginSession( + ws: WorkOSStore, + input: { + user: WorkOSUser; + organizationId: string | null; + ipAddress: string | null; + userAgent: string | null; + /** The method the session records; see AUTH_METHOD_SESSION_VALUES. */ + authMethod: string; + verifyEmail?: boolean; + }, +): WorkOSSession { + const { user } = input; + if (input.verifyEmail) { + // A redeemed magic-auth code proves mailbox ownership; production marks the email + // verified via the standard update path, which emits user.updated. Folded into the + // sign-in write so it is one write, one event, and nothing persists before the + // template gate above — which keeps a failed render from implying a login that + // never completed. + ws.users.update(user.id, { + last_sign_in_at: new Date().toISOString(), + email_verified: true, + }); + } else { + // No real attribute change: production stamps last_sign_in_at via a dedicated, + // silent updateWithSignIn path (a raw, debounced DB write) that bypasses the + // event-emitting update(), so a login fires session.created without a spurious + // user.updated. See https://github.com/workos/emulate/issues/55. + ws.users.updateSilent(user.id, { last_sign_in_at: new Date().toISOString() }); + } + return ws.sessions.insert({ + object: 'session', + user_id: user.id, + organization_id: input.organizationId, + ip_address: input.ipAddress, + user_agent: input.userAgent, + auth_method: AUTH_METHOD_SESSION_VALUES[input.authMethod] ?? 'unknown', + status: 'active', + expires_at: expiresIn(30 * 24 * 60), // matches refresh token lifetime + ended_at: null, + }); +} + +export function formatAuthkitOauthResource(r: WorkOSAuthkitOauthResource): Record { + return formatEntity(r); +} + export function formatCorsOrigin(o: WorkOSCorsOrigin): Record { return formatEntity(o); } diff --git a/src/workos/index.ts b/src/workos/index.ts index 6119a7f..e87d611 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -36,6 +36,7 @@ import { radarRoutes } from './routes/radar.js'; import { connectRoutes } from './routes/connect.js'; import { clientApiRoutes } from './routes/client-api.js'; import { oauthRoutes } from './routes/oauth.js'; +import { authkitOauthRoutes } from './routes/authkit-oauth.js'; import { standaloneConnectRoutes } from './routes/standalone-connect.js'; import { directoryRoutes } from './routes/directories.js'; import { auditLogRoutes } from './routes/audit-logs.js'; @@ -333,12 +334,28 @@ export interface WorkOSSeedConnectApplication { * belongs to, so `organization` is required for one. Defaults to `true`. */ is_first_party?: boolean; - /** `oauth` only. Reported on the application; the emulator does not enforce PKCE from it. */ + /** + * `oauth` only. Marks a public client, as production's `clientConfidentiality: public` does: + * `/oauth2/token` redeems its codes by `code_verifier` alone (a code minted without a challenge + * cannot be redeemed), refuses it a client secret, and rotates its refresh tokens. + * `/oauth2/authorize` requires nothing of it. + */ uses_pkce?: boolean; - /** Emulator-only Standalone Connect login page, receiving an external_auth_id. */ + /** + * Emulator-only Standalone Connect login page, receiving an external_auth_id. Leave it out and + * `/oauth2/authorize` signs users in on the AuthKit hosted page instead, as an MCP client's + * dynamically registered application does. + */ login_url?: string | null; } +export interface WorkOSSeedAuthkitOauthResource { + /** The resource indicator (RFC 8707), an absolute URI with no fragment or wildcard. Unique. */ + uri: string; + /** Make it the environment default. At most one entry may set it. Defaults to `false`. */ + default?: boolean; +} + export interface WorkOSSeedApiKey { name: string; /** Owning organization, by name. Required unless `user_id` is set. */ @@ -451,6 +468,12 @@ export interface WorkOSSeedConfig { permissions?: WorkOSSeedPermission[]; webhookEndpoints?: WorkOSSeedWebhookEndpoint[]; connectApplications?: WorkOSSeedConnectApplication[]; + /** + * MCP resource indicators (RFC 8707). A `resource` an OAuth client sends to `/oauth2/authorize` + * or `/oauth2/token` becomes the access token's `aud` when it matches one of these; otherwise + * `aud` is the application's `audience`, then its `client_id`. + */ + resourceIndicators?: WorkOSSeedAuthkitOauthResource[]; /** * API keys. Either the legacy auth allow-list map (value → environment) or an array * of API key resources. The array form creates `api_key` records AND registers each @@ -936,6 +959,16 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee } } + if (config.resourceIndicators) { + for (const resourceConfig of config.resourceIndicators) { + ws.authkitOauthResources.insert({ + object: 'authkit_oauth_resource', + uri: resourceConfig.uri, + default: resourceConfig.default === true, + }); + } + } + // The array form seeds API key resources; the map form is the legacy auth allow-list // handled at server creation (see createEmulator), so it is skipped here. if (Array.isArray(config.apiKeys)) { @@ -1118,6 +1151,7 @@ export const workosPlugin: ServicePlugin = { connectRoutes(ctx); clientApiRoutes(ctx); oauthRoutes(ctx); + authkitOauthRoutes(ctx); standaloneConnectRoutes(ctx); directoryRoutes(ctx); auditLogRoutes(ctx); diff --git a/src/workos/login-page.ts b/src/workos/login-page.ts index c6a03d4..e4ceb26 100644 --- a/src/workos/login-page.ts +++ b/src/workos/login-page.ts @@ -75,6 +75,25 @@ export function renderDeviceVerifyPage(options: DeviceVerifyPageOptions): string `; } +/** + * The page `/oauth2/error` serves. `application_not_found` gets a fixed explanation unless a + * description is supplied; any other error shows its code and description. Both values arrive on + * the query string, so they are attacker-controlled text: the card renderer escapes them. + */ +export function renderOAuthErrorPage(options: { error: string; description?: string }): string { + const { error, description } = options; + if (error === 'application_not_found') { + return renderDeviceVerifyPage({ + title: 'Application not found', + message: description || 'The application that sent you here is not registered with this environment.', + }); + } + return renderDeviceVerifyPage({ + title: 'Authorization error', + message: description ? `${error}: ${description}` : error, + }); +} + export function renderLoginPage(options: LoginPageOptions): string { const { title, subtitle, emailHint, formAction, hiddenFields, users } = options; diff --git a/src/workos/response-envelopes.spec.ts b/src/workos/response-envelopes.spec.ts index 04bf63c..ad904e7 100644 --- a/src/workos/response-envelopes.spec.ts +++ b/src/workos/response-envelopes.spec.ts @@ -147,6 +147,12 @@ const CASES: readonly EnvelopeCase[] = [ operation: 'POST /client/token', request: (app, f) => post('/client/token', { organization_id: f.organizationId, user_id: f.userId })(app), }, + { + // The create route's body is the resource itself; its list route is served inline by the + // spec and so has no catalog entry (see ENVELOPE_SCHEMA_MAP). + operation: 'POST /user_management/authkit_oauth_resources', + request: post('/user_management/authkit_oauth_resources', { uri: 'https://mcp.example.test/envelope' }), + }, { operation: 'POST /connect/applications/{id}/client_secrets', request: (app, f) => post(`/connect/applications/${f.connectApplicationId}/client_secrets`)(app), diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 17df3f3..0ae8800 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -36,6 +36,7 @@ import { formatClientSecret, formatItContact, formatSSOProfile, + formatAuthkitOauthResource, } from './helpers.js'; import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js'; import type { @@ -56,6 +57,7 @@ import type { WorkOSClientSecret, WorkOSItContact, WorkOSSSOProfile, + WorkOSAuthkitOauthResource, } from './entities.js'; const TS = '2026-01-01T00:00:00.000Z'; @@ -301,6 +303,15 @@ const ssoProfile: WorkOSSSOProfile = { updated_at: TS, }; +const authkitOauthResource: WorkOSAuthkitOauthResource = { + id: 'authkit_oauth_resource_01', + object: 'authkit_oauth_resource', + uri: 'https://mcp.example.test', + default: false, + created_at: TS, + updated_at: TS, +}; + const CASES: ReadonlyArray<{ objectType: string; output: Record }> = [ { objectType: 'user', output: formatUser(user) }, { objectType: 'organization', output: formatOrganization(organization, ws, { domains: [] }) }, @@ -319,6 +330,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record { objectType: 'connect_application_secret', output: formatClientSecret(clientSecret) }, { objectType: 'it_contact', output: formatItContact(itContact) }, { objectType: 'profile', output: formatSSOProfile(ssoProfile) }, + { objectType: 'authkit_oauth_resource', output: formatAuthkitOauthResource(authkitOauthResource) }, ]; /** diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index dc9cfbc..c4c718a 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -18,7 +18,6 @@ import { isExpired, expiresIn, assertAllowedRedirectUri, - AUTH_METHOD_SESSION_VALUES, resolveResponseAuthMethod, resolveSessionResponseAuthMethod, emitAuthenticationEvent, @@ -28,11 +27,18 @@ import { findUserByEmail, requireEmailString, emailsMatch, + activeOrganizationsFor, + startLoginSession, } from '../helpers.js'; import { renderConfiguredJwtTemplate } from '../jwt-template.js'; import type { EventBus } from '../event-bus.js'; import type { WorkOSInvitation, WorkOSRefreshToken, WorkOSSSOAuthorization, WorkOSUser } from '../entities.js'; import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; +import { + consumeConnectAuthorizeRequest, + getConnectAuthorizeRequest, + type ConnectAuthorizeRequest, +} from '../connect-authorize-request.js'; import { renderLoginPage, renderDeviceVerifyPage, @@ -175,26 +181,18 @@ interface AuthorizeParams { code: string | null; /** Proof from an earlier password page that this login is already verified, carried across every page after it. */ pendingToken: string | null; + /** + * The `/oauth2/authorize` request this sign-in is finishing, by token. Set only by the + * AuthKit-domain OAuth surface, which sends the browser here rather than rendering a sign-in + * page of its own; the token carries the validated request through every page that follows. + */ + connectRequest: string | null; } export function authRoutes(ctx: RouteContext): void { const { app, store, jwt } = ctx; const ws = getWorkOSStore(store); - /** - * The organizations a session could be scoped to. 'pending' is an unaccepted invitation and - * 'inactive' a deactivated member; neither is one production would scope a session to. - */ - function activeOrganizationsFor(userId: string): Array<{ id: string; name: string }> { - const orgs: Array<{ id: string; name: string }> = []; - for (const m of ws.organizationMemberships.findBy('user_id', userId)) { - if (m.status !== 'active') continue; - const org = ws.organizations.get(m.organization_id); - if (org) orgs.push({ id: org.id, name: org.name }); - } - return orgs; - } - /** * The authorize parameters a page carries through its form, so the POST that follows finishes * the request the GET started. `email` and `organization_id` are added by the pages that know @@ -206,9 +204,22 @@ export function authRoutes(ctx: RouteContext): void { if (params.codeChallenge) fields.code_challenge = params.codeChallenge; if (params.codeChallengeMethod) fields.code_challenge_method = params.codeChallengeMethod; if (params.clientId) fields.client_id = params.clientId; + if (params.connectRequest) fields.connect_request = params.connectRequest; return fields; } + /** + * The validated `/oauth2/authorize` request behind a `connect_request` token. It is the source of + * truth for everything it holds: a POST cannot substitute a different redirect_uri, client or + * challenge for the ones that request was validated with, so the checks made there hold on every + * page that follows. + */ + function loadConnectRequest(token: string): ConnectAuthorizeRequest { + const request = getConnectAuthorizeRequest(store, token); + if (request) return request; + throw new OauthApiError(400, 'invalid_request', 'The authorization request has expired or is invalid.'); + } + function resolveAndRedirect(c: any, params: AuthorizeParams) { const { redirectUri, @@ -252,7 +263,7 @@ export function authRoutes(ctx: RouteContext): void { // a code for one they are not a member of would put that org_id on the token with no role or // permissions behind it, which is a session no membership justifies and, for anything // authorizing on org_id, the wrong tenant entirely. - if (organizationId && !activeOrganizationsFor(user.id).some((o) => o.id === organizationId)) { + if (organizationId && !activeOrganizationsFor(ws, user.id).some((o) => o.id === organizationId)) { throw new WorkOSApiError( 400, `User is not an active member of organization ${organizationId}`, @@ -478,7 +489,7 @@ export function authRoutes(ctx: RouteContext): void { // Interactive only: a headless caller drives the documented API and handles that response // itself, and this is the one mode that can put a page in front of a human. if (!organizationId && interactive) { - const selectable = activeOrganizationsFor(user.id); + const selectable = activeOrganizationsFor(ws, user.id); if (selectable.length > 1) { const hiddenFields: Record = { ...carriedFields(params), email: user.email }; // The verified login rides along under its token, so an organization POST that leaves @@ -496,6 +507,10 @@ export function authRoutes(ctx: RouteContext): void { } } + const connectRequest = params.connectRequest ? consumeConnectAuthorizeRequest(store, params.connectRequest) : null; + if (params.connectRequest && !connectRequest) { + throw new OauthApiError(400, 'invalid_request', 'The authorization request has expired or is invalid.'); + } const authCode = ws.authCodes.insert({ user_id: user.id, organization_id: organizationId, @@ -505,6 +520,10 @@ export function authRoutes(ctx: RouteContext): void { code_challenge: codeChallenge ?? null, code_challenge_method: codeChallengeMethod ?? null, client_id: clientId, + // What makes this code redeemable at /oauth2/token instead of here. + connect: connectRequest + ? { scope: connectRequest.scope, resource: connectRequest.resource, nonce: connectRequest.nonce } + : undefined, auth_method: login?.auth_method ?? null, step_up_method: login?.step_up_method ?? null, }); @@ -520,12 +539,16 @@ export function authRoutes(ctx: RouteContext): void { app.get('/user_management/authorize', (c) => { const url = new URL(c.req.url); - const redirectUri = url.searchParams.get('redirect_uri'); - const state = url.searchParams.get('state'); - const codeChallenge = url.searchParams.get('code_challenge'); - const codeChallengeMethod = url.searchParams.get('code_challenge_method'); + // A request that /oauth2/authorize has already validated supplies its own parameters, and + // the query cannot override them. + const connectToken = url.searchParams.get('connect_request'); + const connect = connectToken ? loadConnectRequest(connectToken) : null; + const redirectUri = connect ? connect.redirect_uri : url.searchParams.get('redirect_uri'); + const state = connect ? connect.state : url.searchParams.get('state'); + const codeChallenge = connect ? connect.code_challenge : url.searchParams.get('code_challenge'); + const codeChallengeMethod = connect ? connect.code_challenge_method : url.searchParams.get('code_challenge_method'); const loginHint = url.searchParams.get('login_hint'); - const clientId = url.searchParams.get('client_id'); + const clientId = connect ? connect.client_id : url.searchParams.get('client_id'); const organizationId = url.searchParams.get('organization_id'); if (!redirectUri) { @@ -547,6 +570,7 @@ export function authRoutes(ctx: RouteContext): void { // Carried through the login page so a caller that already knows the organization skips // the selection page after the POST, rather than losing the GET's pre-selection here. if (organizationId) hiddenFields.organization_id = organizationId; + if (connectToken) hiddenFields.connect_request = connectToken; return c.html( renderLoginPage({ @@ -574,28 +598,32 @@ export function authRoutes(ctx: RouteContext): void { password: null, code: null, pendingToken: null, + connectRequest: connectToken, }); }); app.post('/user_management/authorize', async (c) => { const form = await c.req.parseBody(); - const redirectUri = form.redirect_uri as string; + const connectToken = typeof form.connect_request === 'string' && form.connect_request ? form.connect_request : null; + const connect = connectToken ? loadConnectRequest(connectToken) : null; + const redirectUri = connect ? connect.redirect_uri : (form.redirect_uri as string); if (!redirectUri) { throw new WorkOSApiError(400, 'redirect_uri is required', 'invalid_request'); } return resolveAndRedirect(c, { redirectUri, - state: (form.state as string) ?? null, - codeChallenge: (form.code_challenge as string) ?? null, - codeChallengeMethod: (form.code_challenge_method as string) ?? null, + state: connect ? connect.state : ((form.state as string) ?? null), + codeChallenge: connect ? connect.code_challenge : ((form.code_challenge as string) ?? null), + codeChallengeMethod: connect ? connect.code_challenge_method : ((form.code_challenge_method as string) ?? null), loginHint: (form.email as string) ?? null, - clientId: (form.client_id as string) ?? null, + clientId: connect ? connect.client_id : ((form.client_id as string) ?? null), organizationId: (form.organization_id as string) ?? null, // A string, even an empty one, is an attempt; absent means the form has not asked yet. password: typeof form.password === 'string' ? form.password : null, code: typeof form.code === 'string' ? form.code : null, pendingToken: (form.pending_authentication_token as string) ?? null, + connectRequest: connectToken, }); }); @@ -919,9 +947,10 @@ export function authRoutes(ctx: RouteContext): void { new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), ); } - // Standalone Connect codes belong to /oauth2/token, which enforces the Connect - // client's secret and redirect_uri. Reject them here without consuming the code. - if (authCode.auth_method === 'external_auth' || isExpired(authCode.expires_at)) { + // Standalone Connect codes, and those from /oauth2/authorize's hosted sign-in, belong to + // /oauth2/token, which enforces the Connect client's authentication and redirect_uri. + // Reject them here without consuming the code. + if (authCode.auth_method === 'external_auth' || authCode.connect || isExpired(authCode.expires_at)) { failAuth( 'OAuth', { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, @@ -983,7 +1012,7 @@ export function authRoutes(ctx: RouteContext): void { // unauthenticated at this point and the distinction is not theirs to learn. if ( authCode.organization_id && - !activeOrganizationsFor(authCode.user_id).some((o) => o.id === authCode.organization_id) + !activeOrganizationsFor(ws, authCode.user_id).some((o) => o.id === authCode.organization_id) ) { failAuth( 'OAuth', @@ -1148,7 +1177,9 @@ export function authRoutes(ctx: RouteContext): void { } const presented = ws.refreshTokens.findOneBy('token', token); - if (!presented) { + // A refresh token from /oauth2/token is that endpoint's to rotate: it carries a scope and + // resource this grant would drop, and belongs to a Connect client, not an AuthKit one. + if (!presented || presented.connect) { throw new OauthApiError(400, 'invalid_grant', 'Invalid refresh token.'); } @@ -1407,7 +1438,7 @@ export function authRoutes(ctx: RouteContext): void { // refresh is excluded: it reuses a session whose scope is already settled, and an explicit // body.organization_id stays the only way to move an existing session between orgs. if (isFreshLogin && !organizationId) { - const selectableOrgs = activeOrganizationsFor(user.id); + const selectableOrgs = activeOrganizationsFor(ws, user.id); if (selectableOrgs.length === 1) { organizationId = selectableOrgs[0].id; @@ -1456,34 +1487,13 @@ export function authRoutes(ctx: RouteContext): void { // reuses the existing session, so it emits neither session.created nor an auth event. let session; if (isFreshLogin) { - const verifyEmail = authMethod === 'MagicAuth' && !user.email_verified; - if (verifyEmail) { - // A redeemed magic-auth code proves mailbox ownership; production marks the email - // verified via the standard update path, which emits user.updated. Folded into the - // sign-in write so it is one write, one event, and nothing persists before the - // template gate above — which keeps a failed render from implying a login that - // never completed. - ws.users.update(user.id, { - last_sign_in_at: new Date().toISOString(), - email_verified: true, - }); - } else { - // No real attribute change: production stamps last_sign_in_at via a dedicated, - // silent updateWithSignIn path (a raw, debounced DB write) that bypasses the - // event-emitting update(), so a login fires session.created without a spurious - // user.updated. See https://github.com/workos/emulate/issues/55. - ws.users.updateSilent(user.id, { last_sign_in_at: new Date().toISOString() }); - } - session = ws.sessions.insert({ - object: 'session', - user_id: user.id, - organization_id: organizationId, - ip_address: requestIp, - user_agent: requestUserAgent, - auth_method: AUTH_METHOD_SESSION_VALUES[sessionAuthMethod ?? authMethod] ?? 'unknown', - status: 'active', - expires_at: expiresIn(30 * 24 * 60), // matches refresh token lifetime - ended_at: null, + session = startLoginSession(ws, { + user, + organizationId, + ipAddress: requestIp, + userAgent: requestUserAgent, + authMethod: sessionAuthMethod ?? authMethod, + verifyEmail: authMethod === 'MagicAuth' && !user.email_verified, }); } else { const existing = refreshSessionId ? ws.sessions.get(refreshSessionId) : undefined; diff --git a/src/workos/routes/authkit-oauth-server.spec.ts b/src/workos/routes/authkit-oauth-server.spec.ts new file mode 100644 index 0000000..1633b38 --- /dev/null +++ b/src/workos/routes/authkit-oauth-server.spec.ts @@ -0,0 +1,1809 @@ +/** + * The AuthKit domain as an OAuth 2.1 authorization server: `/oauth2/authorize` on an application + * with no `login_url`, and `/oauth2/token` for the codes and refresh tokens that leads to. The + * `client_credentials` and Standalone Connect halves of the same endpoints are covered by + * oauth.spec.ts and standalone-connect.spec.ts. + */ +import { createHash, randomBytes } from 'node:crypto'; +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { createServer } from '../../core/index.js'; +import { createEmulator, type Emulator } from '../../index.js'; +import { seedFromConfig, workosPlugin } from '../index.js'; +import { getWorkOSStore } from '../store.js'; + +const baseUrl = 'http://localhost:4100'; +const callback = 'http://localhost:33418/callback'; +const json = (res: Response) => res.json() as Promise; +const decode = (token: string) => + JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf-8')) as Record; + +const pkce = () => { + const verifier = randomBytes(32).toString('base64url'); + return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }; +}; + +function createTestApp() { + const server = createServer(workosPlugin, { + port: 0, + baseUrl, + apiKeys: { sk_test_default: { environment: 'test' } }, + }); + seedFromConfig(server.store, baseUrl, { + users: [{ email: 'alice@acme.test', first_name: 'Alice' }, { email: 'bob@other.test' }], + organizations: [{ name: 'Acme', memberships: [{ email: 'alice@acme.test' }] }], + resourceIndicators: [{ uri: 'https://mcp.example.test' }], + }); + return server; +} + +describe('AuthKit OAuth server', () => { + let server: ReturnType; + let ws: ReturnType; + + beforeEach(() => { + server = createTestApp(); + ws = getWorkOSStore(server.store); + }); + + const request = (path: string, init?: RequestInit) => server.app.request(path, init); + + /** Register a client the way an MCP client does, returning what it would hold. */ + const register = async (extra: Record = {}) => { + const res = await request('/oauth2/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Test client', + redirect_uris: [callback], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + ...extra, + }), + }); + expect(res.status).toBe(201); + return (await json(res)) as { client_id: string; client_secret?: string }; + }; + + const authorizeUrl = (params: Record) => + `/oauth2/authorize?${new URLSearchParams({ response_type: 'code', redirect_uri: callback, ...params })}`; + + /** + * Follow authorize through the hosted sign-in, without interactive mode, to the callback. Returns + * the final redirect, which carries either a code or an error. + */ + const signIn = async (params: Record) => { + const authorize = await request(authorizeUrl(params)); + expect(authorize.status).toBe(302); + const location = new URL(authorize.headers.get('location')!); + if (location.pathname !== '/user_management/authorize') return { authorize, callback: location }; + const hosted = await request(`${location.pathname}${location.search}`); + expect(hosted.status).toBe(302); + return { authorize, callback: new URL(hosted.headers.get('location')!) }; + }; + + const authorizeCode = async (client_id: string, extra: Record = {}) => { + const { verifier, challenge } = pkce(); + const { callback: url } = await signIn({ + client_id, + code_challenge: challenge, + code_challenge_method: 'S256', + state: 'xyz', + login_hint: 'alice@acme.test', + ...extra, + }); + expect(url.searchParams.get('error')).toBeNull(); + expect(url.searchParams.get('state')).toBe('xyz'); + return { code: url.searchParams.get('code')!, verifier }; + }; + + const token = (body: Record, headers: Record = {}) => + request('/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body: new URLSearchParams(body).toString(), + }); + + const exchange = async ( + client_id: string, + extra: Record = {}, + authorize: Record = {}, + ) => { + const { code, verifier } = await authorizeCode(client_id, authorize); + return token({ + grant_type: 'authorization_code', + client_id, + code, + redirect_uri: callback, + code_verifier: verifier, + ...extra, + }); + }; + + describe('authorize', () => { + it('sends the browser to the hosted sign-in, which completes with a code and the state', async () => { + const { client_id } = await register(); + const { challenge } = pkce(); + const authorize = await request( + authorizeUrl({ client_id, code_challenge: challenge, code_challenge_method: 'S256', state: 's t/a?te' }), + ); + expect(authorize.status).toBe(302); + const signInUrl = new URL(authorize.headers.get('location')!); + expect(signInUrl.pathname).toBe('/user_management/authorize'); + expect(signInUrl.searchParams.get('connect_request')).toMatch(/^connect_req_/); + // The request itself is parked server-side; nothing a caller could tamper with rides the URL. + expect(signInUrl.searchParams.has('redirect_uri')).toBe(false); + expect(signInUrl.searchParams.has('code_challenge')).toBe(false); + + const hosted = await request(`${signInUrl.pathname}${signInUrl.search}`); + expect(hosted.status).toBe(302); + const done = new URL(hosted.headers.get('location')!); + expect(`${done.origin}${done.pathname}`).toBe(callback); + expect(done.searchParams.get('code')).toMatch(/^auth_code_/); + expect(done.searchParams.get('state')).toBe('s t/a?te'); + // Spent with the code it produced. + expect( + server.store.getData(`connect_authorize:${signInUrl.searchParams.get('connect_request')}`), + ).toBeUndefined(); + }); + + it('answers an unknown client with a redirect to the error page, which is served', async () => { + const res = await request(authorizeUrl({ client_id: 'client_nope' })); + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location')!); + expect(location.pathname).toBe('/oauth2/error'); + expect(location.searchParams.get('error')).toBe('application_not_found'); + const page = await request(`${location.pathname}${location.search}`); + expect(page.status).toBe(200); + expect(page.headers.get('content-type')).toContain('text/html'); + expect(await page.text()).toContain('Application not found'); + }); + + it('sends failures to the error page until the callback is trusted, then to the callback, as production does', async () => { + const { client_id } = await register(); + // Production's authorize handler never answers with an API error body. Before the client and + // its callback are known, the browser goes to the AuthKit domain's error page. + const errorPage = async (params: Record, error: string) => { + const res = await request(`/oauth2/authorize?${new URLSearchParams(params)}`); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${baseUrl}/oauth2/error?error=${error}`); + }; + // A query without the two shallow parameters at all. + await errorPage({ response_type: 'code', redirect_uri: callback }, 'invalid_query_params'); + await errorPage({ response_type: 'code', client_id }, 'invalid_query_params'); + // An empty client_id is a string that names no application. + await errorPage({ response_type: 'code', client_id: '', redirect_uri: callback }, 'application_not_found'); + // A callback the client did not register, the empty one included. + await errorPage({ response_type: 'code', client_id, redirect_uri: '' }, 'invalid_redirect_uri'); + await errorPage( + { response_type: 'code', client_id, redirect_uri: 'http://localhost:33418/other' }, + 'invalid_redirect_uri', + ); + + // From then on, the callback carries the error and the state, with no description. + const onCallback = async (params: Record, error: string) => { + const res = await request( + `/oauth2/authorize?${new URLSearchParams({ client_id, redirect_uri: callback, state: 'kept', ...params })}`, + ); + expect(res.status).toBe(302); + const url = new URL(res.headers.get('location')!); + expect(`${url.origin}${url.pathname}`).toBe(callback); + expect(url.searchParams.get('error')).toBe(error); + expect(url.searchParams.get('state')).toBe('kept'); + return url; + }; + expect( + (await onCallback({ response_type: 'token' }, 'unsupported_response_type')).searchParams.has( + 'error_description', + ), + ).toBe(false); + expect((await onCallback({}, 'invalid_request')).searchParams.has('error_description')).toBe(false); + + const m2m = ws.connectApplications.insert({ + object: 'connect_application', + name: 'svc', + description: null, + application_type: 'm2m', + organization_id: null, + scopes: [], + audience: null, + redirect_uris: [], + is_first_party: true, + was_dynamically_registered: false, + uses_pkce: false, + login_url: null, + client_id: 'client_m2m', + logo_url: null, + }); + expect(m2m.application_type).toBe('m2m'); + // An application the authorization-code grant is not enabled for: production's + // `authorization_code_grant_disabled`, as unauthorized_client with this description. + const res = await request(authorizeUrl({ client_id: 'client_m2m', state: 'kept' })); + expect(res.status).toBe(302); + const url = new URL(res.headers.get('location')!); + expect(`${url.origin}${url.pathname}`).toBe(callback); + expect(url.searchParams.get('error')).toBe('unauthorized_client'); + expect(url.searchParams.get('error_description')).toBe( + 'The client is not authorized to request an authorization code using this method', + ); + expect(url.searchParams.get('state')).toBe('kept'); + }); + + it('lets a public client through authorize without PKCE; the token endpoint is where it is refused', async () => { + // Production's authorize handler requires nothing of a public client; its token endpoint + // then finds no challenge to verify against and answers the bare invalid_grant. + const { client_id } = await register(); + const { callback: url } = await signIn({ client_id, state: 'keep', login_hint: 'alice@acme.test' }); + expect(`${url.origin}${url.pathname}`).toBe(callback); + const code = url.searchParams.get('code')!; + expect(code).toMatch(/^auth_code_/); + expect(url.searchParams.get('state')).toBe('keep'); + const res = await token({ grant_type: 'authorization_code', client_id, code, redirect_uri: callback }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ error: 'invalid_grant' }); + // Refused before the code is exchanged, so it is not spent. + expect(ws.authCodes.findOneBy('code', code)).toBeDefined(); + }); + + it('takes any challenge string at authorize and holds the verifier to it at the token endpoint', async () => { + const { client_id, client_secret } = await register({ token_endpoint_auth_method: 'client_secret_basic' }); + const { callback: ok } = await signIn({ client_id, login_hint: 'alice@acme.test' }); + expect(ok.searchParams.get('code')).toMatch(/^auth_code_/); + // Production does not validate the shape of a challenge; nothing can hash to this one, so + // the code it produces can never be redeemed. + const { callback: short } = await signIn({ + client_id, + code_challenge: 'short', + code_challenge_method: 'S256', + login_hint: 'alice@acme.test', + }); + const code = short.searchParams.get('code')!; + expect(code).toMatch(/^auth_code_/); + const res = await token({ + grant_type: 'authorization_code', + client_id, + client_secret: client_secret!, + code, + redirect_uri: callback, + code_verifier: 'short', + }); + expect(await json(res)).toEqual({ error: 'invalid_grant' }); + }); + + it('supports only S256, ignores a method without a challenge, and refuses a challenge without a method', async () => { + const { client_id } = await register(); + const { challenge } = pkce(); + // A method production's query schema does not know fails the parse: invalid_request, no description. + const plain = await signIn({ client_id, code_challenge: challenge, code_challenge_method: 'plain', state: 'st' }); + expect(plain.callback.searchParams.get('error')).toBe('invalid_request'); + expect(plain.callback.searchParams.has('error_description')).toBe(false); + expect(plain.callback.searchParams.get('state')).toBe('st'); + // A method with no challenge is simply a request without PKCE. + const orphan = await signIn({ client_id, code_challenge_method: 'S256', login_hint: 'alice@acme.test' }); + expect(orphan.callback.searchParams.get('code')).toMatch(/^auth_code_/); + // Production's `missing_code_challenge_method`, mapped to invalid_request with this description. + const noMethod = await signIn({ client_id, code_challenge: challenge, state: 'kept' }); + expect(noMethod.callback.searchParams.get('error')).toBe('invalid_request'); + expect(noMethod.callback.searchParams.get('error_description')).toBe( + 'code_challenge_method is required when code_challenge is provided.', + ); + expect(noMethod.callback.searchParams.get('state')).toBe('kept'); + }); + + it('reports invalid_scope and invalid_target on the callback', async () => { + const { client_id } = await register({ scope: 'openid profile' }); + const { challenge } = pkce(); + const base = { client_id, code_challenge: challenge, code_challenge_method: 'S256', state: 'st' }; + // The standard scopes are open to every client whatever it registered; a custom scope the + // application was not assigned is production's invalid_scope, with no description. + const standard = await signIn({ ...base, scope: 'openid email', login_hint: 'alice@acme.test' }); + expect(standard.callback.searchParams.get('code')).toMatch(/^auth_code_/); + const scope = await signIn({ ...base, scope: 'openid mcp:tools' }); + expect(scope.callback.searchParams.get('error')).toBe('invalid_scope'); + expect(scope.callback.searchParams.has('error_description')).toBe(false); + expect(scope.callback.searchParams.get('state')).toBe('st'); + // Production's descriptions for a value its resource URI parser refuses, and none for one + // that parses but matches no registered resource. + const target = await signIn({ ...base, resource: 'https://mcp.example.test/#frag' }); + expect(target.callback.searchParams.get('error')).toBe('invalid_target'); + expect(target.callback.searchParams.get('error_description')).toBe( + 'Invalid target: resource URIs cannot contain a URL fragment.', + ); + const relative = await signIn({ ...base, resource: 'mcp/relative' }); + expect(relative.callback.searchParams.get('error')).toBe('invalid_target'); + expect(relative.callback.searchParams.get('error_description')).toBe( + 'Invalid target: resource URI is malformed.', + ); + const unregistered = await signIn({ ...base, resource: 'https://not-registered.example.test' }); + expect(unregistered.callback.searchParams.get('error')).toBe('invalid_target'); + expect(unregistered.callback.searchParams.get('error_description')).toBeNull(); + expect(unregistered.callback.searchParams.get('state')).toBe('st'); + }); + + it('ignores the resource parameter while the environment has registered no resources', async () => { + for (const resource of ws.authkitOauthResources.all()) ws.authkitOauthResources.delete(resource.id); + const { client_id } = await register(); + for (const resource of ['https://not-registered.example.test', 'mcp/relative', 'https://x.test/#frag']) { + const { code } = await authorizeCode(client_id, { resource }); + expect(ws.authCodes.findOneBy('code', code)!.connect!.resource).toBeNull(); + } + }); + + it('ignores the resource of a client that was not dynamically registered', async () => { + seedFromConfig(server.store, baseUrl, { + organizations: [{ name: 'Seeded' }], + connectApplications: [ + { name: 'Seeded', type: 'oauth', organization: 'Seeded', client_id: 'client_seeded', uses_pkce: true }, + ], + }); + const { code } = await authorizeCode('client_seeded', { resource: 'https://not-registered.example.test' }); + expect(ws.authCodes.findOneBy('code', code)!.connect!.resource).toBeNull(); + }); + + it('rejects a hosted sign-in whose request has expired or never existed', async () => { + const { client_id } = await register(); + const { challenge } = pkce(); + const authorize = await request( + authorizeUrl({ client_id, code_challenge: challenge, code_challenge_method: 'S256' }), + ); + const signInUrl = new URL(authorize.headers.get('location')!); + const key = `connect_authorize:${signInUrl.searchParams.get('connect_request')}`; + const stored = server.store.getData(key); + server.store.setData(key, { ...stored, expires_at: new Date(Date.now() - 1000).toISOString() }); + const expired = await request(`${signInUrl.pathname}${signInUrl.search}`); + expect(expired.status).toBe(400); + expect((await json(expired)).error).toBe('invalid_request'); + const unknown = await request('/user_management/authorize?connect_request=connect_req_nope'); + expect(unknown.status).toBe(400); + }); + + it('signs in the user named by login_hint and honors organization_id', async () => { + const { client_id } = await register(); + const { code } = await authorizeCode(client_id, { login_hint: 'bob@other.test' }); + expect(ws.authCodes.findOneBy('code', code)!.user_id).toBe(ws.users.findOneBy('email', 'bob@other.test')!.id); + const acme = ws.organizations.findOneBy('name', 'Acme')!; + const scoped = await authorizeCode(client_id, { organization_id: acme.id }); + expect(ws.authCodes.findOneBy('code', scoped.code)!.organization_id).toBe(acme.id); + // Not a member: the same refusal /user_management/authorize gives. + const { challenge } = pkce(); + const res = await request( + authorizeUrl({ + client_id, + code_challenge: challenge, + code_challenge_method: 'S256', + login_hint: 'bob@other.test', + organization_id: acme.id, + }), + ); + const followed = await request( + new URL(res.headers.get('location')!).pathname + new URL(res.headers.get('location')!).search, + ); + expect(followed.status).toBe(400); + }); + }); + + describe('token: authorization_code', () => { + it('lets a public client redeem a code with its verifier alone', async () => { + const { client_id } = await register(); + const res = await exchange( + client_id, + {}, + { scope: 'openid profile offline_access', resource: 'https://mcp.example.test' }, + ); + expect(res.status).toBe(200); + const body = await json(res); + // Production's authorization_code response: lowercase `bearer`, an `id_token`, no `scope`. + expect(Object.keys(body).sort()).toEqual([ + 'access_token', + 'expires_in', + 'id_token', + 'refresh_token', + 'token_type', + ]); + expect(body).toMatchObject({ token_type: 'bearer', expires_in: 3600 }); + expect(body.access_token.split('.')).toHaveLength(3); + // Sorted, as production's formatScope sorts it. + expect(server.jwt.verify(body.access_token).scope).toBe('offline_access openid profile'); + expect(body.refresh_token).toMatch(/^ref_/); + }); + + it('mints a Connect token: bare issuer, no email, the claims production carries', async () => { + const { client_id } = await register(); + const alice = ws.users.findOneBy('email', 'alice@acme.test')!; + const acme = ws.organizations.findOneBy('name', 'Acme')!; + const body = await json(await exchange(client_id, {}, { scope: 'openid email' })); + + const claims = server.jwt.verify(body.access_token); + expect(claims.iss).toBe(baseUrl); + expect(claims.sub).toBe(alice.id); + expect(claims.client_id).toBe(client_id); + expect(claims.org_id).toBe(acme.id); + expect(claims.scope).toBe('email openid'); + expect(claims.sid).toMatch(/^session_/); + expect(claims.jti).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect(claims.exp - claims.iat).toBe(3600); + // OIDC auth_time, which production emits on every code exchange: when the sign-in happened. + expect(claims.auth_time).toBe( + Math.floor(new Date(ws.sessions.get(claims.sid as string)!.created_at).getTime() / 1000), + ); + expect(claims.aud).toBe(client_id); + for (const absent of ['email', 'role', 'permissions', 'entitlements']) expect(claims).not.toHaveProperty(absent); + + // The session the token names is a real, active one for that user. + const session = ws.sessions.get(claims.sid as string)!; + expect(session).toMatchObject({ user_id: alice.id, status: 'active', organization_id: acme.id }); + }); + + it('omits org_id for a user with no organization', async () => { + const { client_id } = await register(); + const { code, verifier } = await authorizeCode(client_id, { login_hint: 'bob@other.test' }); + const body = await json( + await token({ + grant_type: 'authorization_code', + client_id, + code, + redirect_uri: callback, + code_verifier: verifier, + }), + ); + expect(server.jwt.verify(body.access_token)).not.toHaveProperty('org_id'); + }); + + it('grants nothing when scope is omitted, as production parses an absent scope', async () => { + // No standard scope reaches the consent, so the token has no `scope` claim, the response no + // `scope` field, and the client (dynamically registered, not in OIDC mode) a refresh token. + const { client_id } = await register({ scope: 'openid profile' }); + const body = await json(await exchange(client_id)); + expect(server.jwt.verify(body.access_token)).not.toHaveProperty('scope'); + expect(body).not.toHaveProperty('scope'); + expect(body.id_token.split('.')).toHaveLength(3); + expect(body.refresh_token).toMatch(/^ref_/); + const refreshed = await json( + await token({ grant_type: 'refresh_token', client_id, refresh_token: body.refresh_token }), + ); + expect(refreshed).not.toHaveProperty('scope'); + expect(server.jwt.verify(refreshed.access_token)).not.toHaveProperty('scope'); + }); + + it('never spends the code on a PKCE failure, and answers the bare invalid_grant production does', async () => { + // Production verifies the code_verifier before it exchanges the code, so a missing or wrong + // verifier leaves the code for a retry; and the body is `{ error }` alone, since the + // handler's "Invalid code verifier." is a log message, not an error_description. + const { client_id } = await register(); + const { code, verifier } = await authorizeCode(client_id); + const grant = { grant_type: 'authorization_code', client_id, code, redirect_uri: callback }; + + const missing = await token(grant); + expect(missing.status).toBe(400); + expect(await json(missing)).toEqual({ error: 'invalid_grant' }); + expect(ws.authCodes.findOneBy('code', code)).toBeDefined(); + const wrong = await token({ ...grant, code_verifier: pkce().verifier }); + expect(await json(wrong)).toEqual({ error: 'invalid_grant' }); + expect(ws.authCodes.findOneBy('code', code)).toBeDefined(); + expect((await token({ ...grant, code_verifier: verifier })).status).toBe(200); + expect(ws.authCodes.findOneBy('code', code)).toBeUndefined(); + }); + + it('holds the verifier to the challenge alone, with no shape check, as production does', async () => { + const { client_id } = await register(); + // Production hashes whatever verifier it is sent; RFC 7636 §4.1's 43-128 unreserved + // characters are not enforced, so a verifier outside them redeems the code it hashes to. + for (const verifier of [ + 'short', + 'a'.repeat(129), + `${'a'.repeat(42)}!`, + 'A'.repeat(43), + 'a-._~9'.repeat(21) + 'ab', + ]) { + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const { callback: url } = await signIn({ + client_id, + code_challenge: challenge, + code_challenge_method: 'S256', + login_hint: 'alice@acme.test', + }); + const res = await token({ + grant_type: 'authorization_code', + client_id, + code: url.searchParams.get('code')!, + redirect_uri: callback, + code_verifier: verifier, + }); + expect(res.status).toBe(200); + } + }); + + it('marks token responses uncacheable (RFC 6749 §5.1)', async () => { + const { client_id } = await register(); + const res = await exchange(client_id); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(res.headers.get('pragma')).toBe('no-cache'); + const refreshed = await token({ + grant_type: 'refresh_token', + client_id, + refresh_token: (await json(res)).refresh_token, + }); + expect(refreshed.headers.get('cache-control')).toBe('no-store'); + expect(refreshed.headers.get('pragma')).toBe('no-cache'); + }); + + it('rejects a reused code', async () => { + const { client_id } = await register(); + const { code, verifier } = await authorizeCode(client_id); + const grant = { + grant_type: 'authorization_code', + client_id, + code, + redirect_uri: callback, + code_verifier: verifier, + }; + expect((await token(grant)).status).toBe(200); + const again = await token(grant); + expect(again.status).toBe(400); + expect((await json(again)).error).toBe('invalid_grant'); + }); + + it('rejects a wrong redirect_uri, another client, an expired code and an unknown code', async () => { + const { client_id } = await register(); + const other = await register(); + const { code, verifier } = await authorizeCode(client_id); + const grant = { + grant_type: 'authorization_code', + client_id, + code, + redirect_uri: callback, + code_verifier: verifier, + }; + + for (const override of [ + { redirect_uri: 'http://localhost:33418/other' }, + { client_id: other.client_id }, + { code: 'auth_code_unknown' }, + ]) { + const res = await token({ ...grant, ...override }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + } + const stored = ws.authCodes.findOneBy('code', code)!; + ws.authCodes.update(stored.id, { expires_at: new Date(Date.now() - 1000).toISOString() }); + expect((await json(await token(grant))).error).toBe('invalid_grant'); + }); + + it('refuses a public client a code that carries no challenge', async () => { + const { client_id } = await register(); + const { code } = await authorizeCode(client_id); + const stored = ws.authCodes.findOneBy('code', code)!; + ws.authCodes.update(stored.id, { code_challenge: null, code_challenge_method: null }); + const res = await token({ grant_type: 'authorization_code', client_id, code, redirect_uri: callback }); + expect((await json(res)).error).toBe('invalid_grant'); + }); + + it('requires the secret of a confidential client, with production messages, and still checks PKCE', async () => { + const { client_id, client_secret } = await register({ token_endpoint_auth_method: 'client_secret_basic' }); + const { code, verifier } = await authorizeCode(client_id); + const grant = { grant_type: 'authorization_code', code, redirect_uri: callback, code_verifier: verifier }; + + // The two messages of production's token-endpoint guards. + const noSecret = await token({ ...grant, client_id }); + expect(noSecret.status).toBe(401); + expect(noSecret.headers.get('www-authenticate')).toBe('Basic realm="AuthKit"'); + expect((await json(noSecret)).error_description).toBe('Missing client_secret.'); + const wrong = await token({ ...grant, client_id, client_secret: 'secret_wrong' }); + expect(wrong.status).toBe(401); + expect(await json(wrong)).toEqual({ error: 'invalid_client', error_description: 'Application not found.' }); + const basic = Buffer.from(`${client_id}:${client_secret}`).toString('base64'); + const badVerifier = await token( + { ...grant, code_verifier: pkce().verifier }, + { Authorization: `Basic ${basic}` }, + ); + expect((await json(badVerifier)).error).toBe('invalid_grant'); + + // A bad verifier never spends the code, so the original one still redeems with Basic. + const ok = await token(grant, { Authorization: `Basic ${basic}` }); + expect(ok.status).toBe(200); + const stored = ws.clientSecrets.all().find((s) => s.value === client_secret)!; + expect(stored.last_used_at).not.toBeNull(); + }); + + it('accepts the secret in the body or as Basic whatever method the client registered', async () => { + // Production stores only public/confidential and reads credentials from either transport: + // a client registered for client_secret_basic may post its secret, and the reverse. + for (const method of ['client_secret_post', 'client_secret_basic']) { + const { client_id, client_secret } = await register({ token_endpoint_auth_method: method }); + const grant = { grant_type: 'authorization_code', redirect_uri: callback }; + const basic = Buffer.from(`${client_id}:${client_secret}`).toString('base64'); + const first = await authorizeCode(client_id); + const viaBasic = await token( + { ...grant, code: first.code, code_verifier: first.verifier }, + { Authorization: `Basic ${basic}` }, + ); + expect(viaBasic.status).toBe(200); + const second = await authorizeCode(client_id); + const viaPost = await token({ + ...grant, + client_id, + code: second.code, + code_verifier: second.verifier, + client_secret: client_secret!, + }); + expect(viaPost.status).toBe(200); + } + }); + + it("reads the credentials from the body alone when it names a client_id, as production's parseAuthorization does", async () => { + // With a client_id in the body, the Authorization header is never consulted, so a secret + // sent only there is a missing secret. + const { client_id, client_secret } = await register({ token_endpoint_auth_method: 'client_secret_basic' }); + const { code, verifier } = await authorizeCode(client_id); + const basic = Buffer.from(`${client_id}:${client_secret}`).toString('base64'); + const res = await token( + { grant_type: 'authorization_code', client_id, code, redirect_uri: callback, code_verifier: verifier }, + { Authorization: `Basic ${basic}` }, + ); + expect(res.status).toBe(401); + expect(await json(res)).toEqual({ error: 'invalid_client', error_description: 'Missing client_secret.' }); + // A Basic header that decodes to no client id at all is its own failure. + const empty = await token( + { grant_type: 'authorization_code', code, redirect_uri: callback }, + { Authorization: `Basic ${Buffer.from(`:${client_secret}`).toString('base64')}` }, + ); + expect(await json(empty)).toEqual({ error: 'invalid_client', error_description: 'Missing client_id.' }); + }); + + it('refuses a public client that presents a secret, with the production message', async () => { + const { client_id } = await register(); + const { code, verifier } = await authorizeCode(client_id); + const grant = { + grant_type: 'authorization_code', + client_id, + code, + redirect_uri: callback, + code_verifier: verifier, + }; + // A secret that exists for no client is refused by the lookup first. + const madeUp = await token({ ...grant, client_secret: 'secret_made_up' }); + expect(madeUp.status).toBe(401); + expect((await json(madeUp)).error_description).toBe('Application not found.'); + // One an admin added to the public client is a real pair, which a public client may not use. + const application = ws.connectApplications.findOneBy('client_id', client_id)!; + ws.clientSecrets.insert({ + object: 'connect_application_secret', + application_id: application.id, + value: 'secret_added', + secret_hint: 'dded', + last_used_at: null, + }); + const added = await token({ ...grant, client_secret: 'secret_added' }); + expect(added.status).toBe(401); + expect(await json(added)).toEqual({ error: 'invalid_client', error_description: 'Unsupported client_secret.' }); + }); + + it('answers a request with no client at all as production does', async () => { + const res = await token({ grant_type: 'authorization_code', code: 'x', redirect_uri: callback }); + expect(res.status).toBe(401); + expect(await json(res)).toEqual({ error: 'invalid_client', error_description: 'Missing authorization header.' }); + }); + + it('answers an unknown client_id with invalid_client, secret or not', async () => { + const res = await token({ + grant_type: 'authorization_code', + client_id: 'client_nope', + code: 'x', + redirect_uri: callback, + }); + expect(res.status).toBe(401); + expect((await json(res)).error).toBe('invalid_client'); + }); + + it('keeps the codes of the two token endpoints apart', async () => { + const { client_id } = await register(); + const { code, verifier } = await authorizeCode(client_id); + // A hosted-sign-in code is not redeemable by /user_management/authenticate… + const viaAuthenticate = await request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', client_id, code, code_verifier: verifier }), + }); + expect(viaAuthenticate.status).toBe(400); + expect((await json(viaAuthenticate)).error).toBe('invalid_grant'); + // …and was not spent by the attempt. + expect( + ( + await token({ + grant_type: 'authorization_code', + client_id, + code, + redirect_uri: callback, + code_verifier: verifier, + }) + ).status, + ).toBe(200); + + // An AuthKit code from /user_management/authorize is not redeemable here. + const authKit = await request( + `/user_management/authorize?${new URLSearchParams({ redirect_uri: callback, client_id })}`, + ); + const authKitCode = new URL(authKit.headers.get('location')!).searchParams.get('code')!; + const res = await token({ + grant_type: 'authorization_code', + client_id, + code: authKitCode, + redirect_uri: callback, + }); + expect((await json(res)).error).toBe('invalid_grant'); + }); + + it('does not let a public application redeem a Standalone Connect code without its secret', async () => { + seedFromConfig(server.store, baseUrl, { + connectApplications: [ + { + name: 'Standalone', + type: 'oauth', + uses_pkce: true, + client_id: 'client_standalone_pkce', + client_secret: 'secret_standalone_pkce', + login_url: 'http://localhost:3000/login', + redirect_uris: [callback], + }, + ], + }); + const alice = ws.users.findOneBy('email', 'alice@acme.test')!; + const code = ws.authCodes.insert({ + user_id: alice.id, + organization_id: null, + code: 'auth_code_standalone', + redirect_uri: callback, + client_id: 'client_standalone_pkce', + expires_at: new Date(Date.now() + 60_000).toISOString(), + auth_method: 'external_auth', + step_up_method: null, + code_challenge: null, + code_challenge_method: null, + }); + const grant = { + grant_type: 'authorization_code', + client_id: 'client_standalone_pkce', + code: code.code, + redirect_uri: callback, + }; + // Standalone Connect has no PKCE, so a public client has nothing to prove itself with: no + // secret is invalid_request, and a secret is refused of a public client as production does. + expect((await token(grant)).status).toBe(400); + const withSecret = await token({ ...grant, client_secret: 'secret_standalone_pkce' }); + expect(withSecret.status).toBe(401); + expect((await json(withSecret)).error_description).toBe('Unsupported client_secret.'); + }); + + it('refuses client_credentials to a public client without a secret, as an unauthorized_client', async () => { + // Production's public-client guard: the grant is not among those a public client may use. + const { client_id } = await register(); + const res = await token({ grant_type: 'client_credentials', client_id }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ + error: 'unauthorized_client', + error_description: 'Public clients are not authorized to use this grant type.', + }); + }); + + it("ignores a scope parameter on the code exchange, as production's grant body carries none", async () => { + const { client_id } = await register(); + const narrowed = await json(await exchange(client_id, { scope: 'openid' }, { scope: 'openid profile' })); + expect(server.jwt.verify(narrowed.access_token).scope).toBe('openid profile'); + const widened = await json(await exchange(client_id, { scope: 'openid email' }, { scope: 'openid profile' })); + expect(server.jwt.verify(widened.access_token).scope).toBe('openid profile'); + }); + + it('answers a body missing the grant type or its fields as production validates it', async () => { + const { client_id } = await register(); + const invalidBody = { error: 'invalid_request', error_description: 'Invalid token request body.' }; + expect(await json(await token({ client_id }))).toEqual(invalidBody); + expect(await json(await token({ grant_type: 'authorization_code', client_id, code: 'x' }))).toEqual(invalidBody); + expect(await json(await token({ grant_type: 'refresh_token', client_id }))).toEqual(invalidBody); + const unsupported = await token({ grant_type: 'password', client_id }); + expect(unsupported.status).toBe(400); + expect(await json(unsupported)).toEqual({ + error: 'unsupported_grant_type', + error_description: "Unsupported grant_type: 'password'.", + }); + }); + }); + + describe('resource indicators', () => { + it('binds aud to a registered resource', async () => { + const { client_id } = await register(); + const body = await json(await exchange(client_id, {}, { resource: 'https://mcp.example.test' })); + expect(server.jwt.verify(body.access_token).aud).toBe('https://mcp.example.test'); + }); + + it('falls back to the application audience, then the client_id, for an absent resource', async () => { + const { client_id } = await register(); + const absent = await json(await exchange(client_id)); + expect(server.jwt.verify(absent.access_token).aud).toBe(client_id); + + const application = ws.connectApplications.findOneBy('client_id', client_id)!; + ws.connectApplications.update(application.id, { audience: 'https://default-audience.example.test' }); + const withAudience = await json(await exchange(client_id)); + expect(server.jwt.verify(withAudience.access_token).aud).toBe('https://default-audience.example.test'); + // A registered resource still wins over the application's audience. + const registered = await json(await exchange(client_id, {}, { resource: 'https://mcp.example.test' })); + expect(server.jwt.verify(registered.access_token).aud).toBe('https://mcp.example.test'); + }); + + it('binds a dynamically registered client that names no resource to the default one', async () => { + // Production's authorize handler falls back to the environment's default resource for a + // dynamically registered client, since some MCP clients omit the parameter entirely. + const resource = ws.authkitOauthResources.findOneBy('uri', 'https://mcp.example.test')!; + ws.authkitOauthResources.update(resource.id, { default: true }); + const { client_id } = await register(); + const { code } = await authorizeCode(client_id); + expect(ws.authCodes.findOneBy('code', code)!.connect!.resource).toBe('https://mcp.example.test'); + const body = await json(await exchange(client_id)); + expect(server.jwt.verify(body.access_token).aud).toBe('https://mcp.example.test'); + // Bound to the default, the grant is held to it like any other. + const other = await exchange(client_id, { resource: 'https://other.example.test' }); + expect((await json(other)).error).toBe('invalid_target'); + }); + + it('lets the token request restate or omit a bound resource, and nothing else', async () => { + const { client_id } = await register(); + const restated = await exchange( + client_id, + { resource: 'https://mcp.example.test' }, + { resource: 'https://mcp.example.test' }, + ); + expect(server.jwt.verify((await json(restated)).access_token).aud).toBe('https://mcp.example.test'); + + const mismatch = await exchange( + client_id, + { resource: 'https://other.example.test' }, + { resource: 'https://mcp.example.test' }, + ); + expect(mismatch.status).toBe(400); + expect((await json(mismatch)).error).toBe('invalid_target'); + const malformed = await exchange(client_id, { resource: 'not a uri' }, { resource: 'https://mcp.example.test' }); + expect((await json(malformed)).error).toBe('invalid_target'); + }); + + it('lets a grant bound to no resource name a registered one at the token endpoint', async () => { + // Production's resource locator: a consent without a resource plus a request naming one + // resolves against the environment's registered resources. + const { client_id } = await register(); + const late = await json(await exchange(client_id, { resource: 'https://mcp.example.test' })); + expect(server.jwt.verify(late.access_token).aud).toBe('https://mcp.example.test'); + for (const resource of ['https://other.example.test', 'not a uri']) { + const refused = await exchange(client_id, { resource }); + expect(refused.status).toBe(400); + expect((await json(refused)).error).toBe('invalid_target'); + } + const plain = await json(await exchange(client_id)); + expect(server.jwt.verify(plain.access_token).aud).toBe(client_id); + + // With no resources registered at all, the parameter is ignored rather than refused. + for (const resource of ws.authkitOauthResources.all()) ws.authkitOauthResources.delete(resource.id); + const ignored = await json(await exchange(client_id, { resource: 'https://other.example.test' })); + expect(server.jwt.verify(ignored.access_token).aud).toBe(client_id); + }); + + it('matches a resource by normalized URL, as production compares resource URIs', async () => { + // `https://mcp.example.test/` and `https://mcp.example.test` are one resource to production's + // OauthResourceUri; the token carries the URI as registered. + const { client_id } = await register(); + const { code } = await authorizeCode(client_id, { resource: 'https://mcp.example.test/' }); + expect(ws.authCodes.findOneBy('code', code)!.connect!.resource).toBe('https://mcp.example.test'); + const body = await json( + await exchange(client_id, { resource: 'https://mcp.example.test/' }, { resource: 'https://mcp.example.test' }), + ); + expect(server.jwt.verify(body.access_token).aud).toBe('https://mcp.example.test'); + }); + + it('restates the authorized resource harmlessly at the token endpoint', async () => { + const { client_id } = await register(); + const res = await exchange( + client_id, + { resource: 'https://mcp.example.test' }, + { resource: 'https://mcp.example.test' }, + ); + expect(server.jwt.verify((await json(res)).access_token).aud).toBe('https://mcp.example.test'); + }); + }); + + describe('token: refresh_token', () => { + const refresh = (client_id: string, refresh_token: string, extra: Record = {}) => + token({ grant_type: 'refresh_token', client_id, refresh_token, ...extra }); + + it('rotates the token within the same session and carries scope and resource across', async () => { + const { client_id } = await register(); + const first = await json( + await exchange(client_id, {}, { scope: 'openid profile offline_access', resource: 'https://mcp.example.test' }), + ); + + const res = await refresh(client_id, first.refresh_token); + expect(res.status).toBe(200); + const second = await json(res); + // Production's refresh_token response: a rotated token, an `id_token` and the issued `scope`. + expect(Object.keys(second).sort()).toEqual([ + 'access_token', + 'expires_in', + 'id_token', + 'refresh_token', + 'scope', + 'token_type', + ]); + expect(second.token_type).toBe('bearer'); + expect(second.refresh_token).toMatch(/^ref_/); + expect(second.refresh_token).not.toBe(first.refresh_token); + expect(second.scope).toBe('offline_access openid profile'); + + const before = server.jwt.verify(first.access_token); + const after = server.jwt.verify(second.access_token); + expect(after.sid).toBe(before.sid); + expect(after.sub).toBe(before.sub); + expect(after.iss).toBe(baseUrl); + expect(after.client_id).toBe(client_id); + expect(after.aud).toBe('https://mcp.example.test'); + expect(after.org_id).toBe(before.org_id); + expect(after.jti).not.toBe(before.jti); + expect(ws.sessions.all().filter((s) => s.user_id === before.sub)).toHaveLength(1); + + // The old token is spent; the new one rotates again. + const reused = await refresh(client_id, first.refresh_token); + expect(reused.status).toBe(400); + expect((await json(reused)).error).toBe('invalid_grant'); + expect((await refresh(client_id, second.refresh_token)).status).toBe(200); + }); + + it("keeps a confidential client's refresh token in place of rotating it, as OAuth 2.1 §6.1 lets production", async () => { + const { client_id, client_secret } = await register({ token_endpoint_auth_method: 'client_secret_post' }); + const first = await json( + await exchange(client_id, { client_secret: client_secret! }, { scope: 'offline_access' }), + ); + const again = await json(await refresh(client_id, first.refresh_token, { client_secret: client_secret! })); + expect(again.refresh_token).toBe(first.refresh_token); + expect((await refresh(client_id, first.refresh_token, { client_secret: client_secret! })).status).toBe(200); + expect(ws.refreshTokens.all().filter((t) => t.client_id === client_id)).toHaveLength(1); + }); + + it('narrows scope on refresh for that token alone; the grant itself is unchanged', async () => { + // Production's RFC 6749 §6 narrowing filters the consent's scopes to the ones requested + // and never alters the consent, so a later refresh may issue the full grant again; a + // requested scope outside the grant is dropped, and only an empty result is invalid_scope, + // with production's description. + const { client_id } = await register(); + const first = await json(await exchange(client_id, {}, { scope: 'openid profile email offline_access' })); + const narrowed = await json(await refresh(client_id, first.refresh_token, { scope: 'openid' })); + expect(narrowed.scope).toBe('openid'); + expect(server.jwt.verify(narrowed.access_token).scope).toBe('openid'); + const full = await json(await refresh(client_id, narrowed.refresh_token, { scope: 'openid profile' })); + expect(full.scope).toBe('openid profile'); + const partial = await json(await refresh(client_id, full.refresh_token, { scope: 'openid admin' })); + expect(partial.scope).toBe('openid'); + const none = await refresh(client_id, partial.refresh_token, { scope: 'admin other' }); + expect(none.status).toBe(400); + expect(await json(none)).toEqual({ + error: 'invalid_scope', + error_description: + "The following scopes are invalid, unknown, or not granted to this application: 'admin', 'other'.", + }); + }); + + it('does not spend the token on a request it refuses', async () => { + const { client_id } = await register(); + const first = await json(await exchange(client_id, {}, { resource: 'https://mcp.example.test' })); + expect((await refresh(client_id, first.refresh_token, { scope: 'admin' })).status).toBe(400); + expect((await refresh(client_id, first.refresh_token, { resource: 'https://other.example.test' })).status).toBe( + 400, + ); + expect((await refresh(client_id, first.refresh_token)).status).toBe(200); + }); + + it('refuses the next refresh once the bound resource is deleted, as the resource locator does', async () => { + const { client_id } = await register(); + const first = await json(await exchange(client_id, {}, { resource: 'https://mcp.example.test' })); + const resource = ws.authkitOauthResources.findOneBy('uri', 'https://mcp.example.test')!; + ws.authkitOauthResources.delete(resource.id); + const second = await refresh(client_id, first.refresh_token); + expect(second.status).toBe(400); + expect((await json(second)).error).toBe('invalid_target'); + // Refused, not spent. + ws.authkitOauthResources.insert({ + object: 'authkit_oauth_resource', + uri: 'https://mcp.example.test', + default: false, + }); + expect((await refresh(client_id, first.refresh_token)).status).toBe(200); + }); + + it('is bound to the client it was issued to', async () => { + const { client_id } = await register(); + const other = await register(); + const first = await json(await exchange(client_id)); + const res = await refresh(other.client_id, first.refresh_token); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + // Refused, not spent. + expect((await refresh(client_id, first.refresh_token)).status).toBe(200); + }); + + it('rejects unknown, expired, and revoked-session refresh tokens', async () => { + const { client_id } = await register(); + expect((await json(await refresh(client_id, 'ref_unknown'))).error).toBe('invalid_grant'); + expect((await json(await token({ grant_type: 'refresh_token', client_id }))).error).toBe('invalid_request'); + + const first = await json(await exchange(client_id)); + const stored = ws.refreshTokens.findOneBy('token', first.refresh_token)!; + ws.refreshTokens.update(stored.id, { expires_at: new Date(Date.now() - 1000).toISOString() }); + const expired = await refresh(client_id, first.refresh_token); + // Every refresh failure is production's bare invalid_grant: its messages are log-only. + expect(await json(expired)).toEqual({ error: 'invalid_grant' }); + + const second = await json(await exchange(client_id)); + const sid = server.jwt.verify(second.access_token).sid as string; + ws.sessions.update(sid, { status: 'revoked', ended_at: new Date().toISOString() }); + expect((await json(await refresh(client_id, second.refresh_token))).error).toBe('invalid_grant'); + }); + + it('sweeps the refresh tokens of a session that is gone, as the AuthKit grant does', async () => { + const { client_id } = await register(); + const tokens = await json(await exchange(client_id)); + const sid = server.jwt.verify(tokens.access_token).sid as string; + // Logout and revoke delete the session row outright. + ws.sessions.delete(sid); + expect((await json(await refresh(client_id, tokens.refresh_token))).error).toBe('invalid_grant'); + expect(ws.refreshTokens.findBy('session_id', sid)).toHaveLength(0); + }); + + it('is not accepted by, and does not accept, the AuthKit refresh grant', async () => { + const { client_id } = await register(); + const first = await json(await exchange(client_id)); + const viaAuthenticate = await request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'refresh_token', client_id, refresh_token: first.refresh_token }), + }); + expect((await json(viaAuthenticate)).error).toBe('invalid_grant'); + expect((await refresh(client_id, first.refresh_token)).status).toBe(200); + + // A refresh token AuthKit's own grant issued carries no Connect marker, so this endpoint refuses it. + const alice = ws.users.findOneBy('email', 'alice@acme.test')!; + const stray = ws.refreshTokens.insert({ + token: 'ref_authkit', + user_id: alice.id, + organization_id: null, + session_id: 'session_x', + expires_at: new Date(Date.now() + 60_000).toISOString(), + client_id, + exchanged_at: null, + exchanged_for_id: null, + }); + expect(stray.connect).toBeUndefined(); + // Production's `UnsupportedRefreshToken`: an unauthorized_client pointed at the right endpoint. + const res = await refresh(client_id, 'ref_authkit'); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ + error: 'unauthorized_client', + error_description: `This refresh token should be exchanged at ${baseUrl}/user_management/authenticate`, + }); + }); + }); + + describe('surfaces that must not change', () => { + it('still serves client_credentials for a seeded m2m application', async () => { + seedFromConfig(server.store, baseUrl, { + organizations: [{ name: 'Svc' }], + connectApplications: [ + { + name: 'Svc', + type: 'm2m', + organization: 'Svc', + client_id: 'client_svc', + client_secret: 'secret_svc', + scopes: ['a'], + }, + ], + }); + const res = await token({ + grant_type: 'client_credentials', + client_id: 'client_svc', + client_secret: 'secret_svc', + }); + expect(res.status).toBe(200); + const body = await json(res); + expect(body).not.toHaveProperty('refresh_token'); + expect(server.jwt.verify(body.access_token)).toMatchObject({ sub: 'client_svc', aud: 'client_svc', scope: 'a' }); + }); + + it("leaves AuthKit's own per-client issuer and authorization_code grant unchanged", async () => { + const res = await request( + `/user_management/authorize?${new URLSearchParams({ redirect_uri: callback, client_id: 'client_authkit', login_hint: 'alice@acme.test' })}`, + ); + const code = new URL(res.headers.get('location')!).searchParams.get('code')!; + const auth = await json( + await request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', client_id: 'client_authkit', code }), + }), + ); + expect(server.jwt.verify(auth.access_token).iss).toBe(`${baseUrl}/user_management/client_authkit`); + }); + }); +}); + +describe('AuthKit OAuth server, interactive hosted sign-in', () => { + let emulator: Emulator | undefined; + + afterEach(async () => { + await emulator?.close(); + emulator = undefined; + }); + + it('shows the AuthKit sign-in page and finishes the request from what authorize validated', async () => { + emulator = await createEmulator({ + port: 0, + interactiveAuth: true, + seed: { users: [{ email: 'alice@acme.test' }] }, + }); + const reg = (await ( + await fetch(`${emulator.url}/oauth2/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Test client', + redirect_uris: [callback], + token_endpoint_auth_method: 'none', + }), + }) + ).json()) as { client_id: string }; + const { verifier, challenge } = pkce(); + + const authorize = await fetch( + `${emulator.url}/oauth2/authorize?${new URLSearchParams({ + response_type: 'code', + client_id: reg.client_id, + redirect_uri: callback, + code_challenge: challenge, + code_challenge_method: 'S256', + state: 'st', + resource: 'https://mcp.example.test', + })}`, + { redirect: 'manual' }, + ); + expect(authorize.status).toBe(302); + const page = await fetch(authorize.headers.get('location')!, { redirect: 'manual' }); + expect(page.status).toBe(200); + expect(page.headers.get('content-type')).toContain('text/html'); + const html = await page.text(); + expect(html).toContain('Sign In'); + expect(html).toContain('alice@acme.test'); + const requestId = new URL(authorize.headers.get('location')!).searchParams.get('connect_request')!; + expect(html).toContain(`name="connect_request" value="${requestId}"`); + + // A submit that tries to swap the redirect, client or challenge gets the validated ones. + const submit = await fetch(`${emulator.url}/user_management/authorize`, { + method: 'POST', + redirect: 'manual', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + connect_request: requestId, + email: 'alice@acme.test', + redirect_uri: 'http://localhost:9999/evil', + client_id: 'client_evil', + code_challenge: 'x'.repeat(43), + state: 'evil', + }), + }); + expect(submit.status).toBe(302); + const done = new URL(submit.headers.get('location')!); + expect(`${done.origin}${done.pathname}`).toBe(callback); + expect(done.searchParams.get('state')).toBe('st'); + + const res = await fetch(`${emulator.url}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: reg.client_id, + code: done.searchParams.get('code')!, + redirect_uri: callback, + code_verifier: verifier, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { access_token: string }; + expect(decode(body.access_token)).toMatchObject({ client_id: reg.client_id, iss: emulator.url }); + + // The request was spent by the code it minted: replaying the submit finds nothing. + const replay = await fetch(`${emulator.url}/user_management/authorize`, { + method: 'POST', + redirect: 'manual', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ connect_request: requestId, email: 'alice@acme.test' }), + }); + expect(replay.status).toBe(400); + }); +}); + +describe('AuthKit OAuth server, registered grant types and revoked grants', () => { + let server: ReturnType; + let ws: ReturnType; + + beforeEach(() => { + server = createTestApp(); + ws = getWorkOSStore(server.store); + }); + + const register = async (body: Record) => + (await ( + await server.app.request('/oauth2/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Test client', + redirect_uris: [callback], + token_endpoint_auth_method: 'none', + ...body, + }), + }) + ).json()) as { client_id: string }; + + const codeFor = async (client_id: string, login_hint = 'alice@acme.test', extra: Record = {}) => { + const { verifier, challenge } = pkce(); + const authorize = await server.app.request( + `/oauth2/authorize?${new URLSearchParams({ + response_type: 'code', + client_id, + redirect_uri: callback, + code_challenge: challenge, + code_challenge_method: 'S256', + login_hint, + ...extra, + })}`, + ); + const hosted = new URL(authorize.headers.get('location')!); + const done = await server.app.request(`${hosted.pathname}${hosted.search}`); + return { code: new URL(done.headers.get('location')!).searchParams.get('code')!, verifier }; + }; + + const redeem = (client_id: string, code: string, verifier: string) => + server.app.request('/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id, + code, + redirect_uri: callback, + code_verifier: verifier, + }).toString(), + }); + + it('issues a refresh token by the granted scopes, never by the registered grant_types', async () => { + // Production's shouldIssueRefreshToken: `offline_access` always earns one; without it a + // dynamically registered client gets one exactly when it did not ask for `openid`. + const { client_id } = await register({ grant_types: ['authorization_code'] }); + const cases: [string, boolean][] = [ + ['openid profile offline_access', true], + ['offline_access', true], + ['openid profile', false], + ['openid', false], + ['profile email', true], + ['email', true], + ]; + for (const [scope, expected] of cases) { + const { code, verifier } = await codeFor(client_id, 'alice@acme.test', { scope }); + const body = await json(await redeem(client_id, code, verifier)); + expect(body.access_token).toBeTruthy(); + expect(scope + (body.refresh_token ? ' -> refresh' : ' -> none')).toBe( + scope + (expected ? ' -> refresh' : ' -> none'), + ); + } + // The grant it was not registered for is still served: production reads grant_types nowhere here. + const { code, verifier } = await codeFor(client_id, 'alice@acme.test', { scope: 'offline_access' }); + const tokens = await json(await redeem(client_id, code, verifier)); + const refreshed = await server.app.request('/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id, + refresh_token: tokens.refresh_token, + }).toString(), + }); + expect(refreshed.status).toBe(200); + }); + + it('gives a client that was not dynamically registered a refresh token only for offline_access', async () => { + seedFromConfig(server.store, baseUrl, { + organizations: [{ name: 'Seeded' }], + connectApplications: [ + { name: 'Seeded', type: 'oauth', organization: 'Seeded', client_id: 'client_seeded', uses_pkce: true }, + ], + }); + for (const [scope, expected] of [ + ['profile email', false], + ['openid', false], + ['openid offline_access', true], + ] as [string, boolean][]) { + const { code, verifier } = await codeFor('client_seeded', 'alice@acme.test', { scope }); + const body = await json(await redeem('client_seeded', code, verifier)); + expect(body.access_token).toBeTruthy(); + expect(scope + (body.refresh_token ? ' -> refresh' : ' -> none')).toBe( + scope + (expected ? ' -> refresh' : ' -> none'), + ); + } + }); + + it('refuses to register a client for refresh_token alone', async () => { + const res = await server.app.request('/oauth2/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_name: 'Test client', redirect_uris: [callback], grant_types: ['refresh_token'] }), + }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_client_metadata'); + }); + + const apiHeaders = { Authorization: 'Bearer sk_test_default', 'Content-Type': 'application/json' }; + const both = ['authorization_code', 'refresh_token']; + const refresh = (client_id: string, refresh_token: string, extra: Record = {}) => + server.app.request('/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'refresh_token', client_id, refresh_token, ...extra }).toString(), + }); + const setScopes = async (client_id: string, scopes: string[]) => { + const app = ws.connectApplications.findOneBy('client_id', client_id)!; + const res = await server.app.request(`/connect/applications/${app.id}`, { + method: 'PUT', + headers: apiHeaders, + body: JSON.stringify({ scopes }), + }); + expect(res.status).toBe(200); + }; + + it('refuses a refresh for an organization the user has since left', async () => { + for (const leave of ['deactivate', 'delete'] as const) { + const { client_id } = await register({ grant_types: both }); + const acme = ws.organizations.findOneBy('name', 'Acme')!; + const { code, verifier } = await codeFor(client_id, 'alice@acme.test', { organization_id: acme.id }); + const tokens = await json(await redeem(client_id, code, verifier)); + expect(server.jwt.verify(tokens.access_token).org_id).toBe(acme.id); + + const membership = ws.organizationMemberships.findBy( + 'user_id', + ws.users.findOneBy('email', 'alice@acme.test')!.id, + )[0]; + if (leave === 'delete') ws.organizationMemberships.delete(membership.id); + else ws.organizationMemberships.update(membership.id, { status: 'inactive' }); + + const res = await refresh(client_id, tokens.refresh_token); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + if (leave === 'deactivate') ws.organizationMemberships.update(membership.id, { status: 'active' }); + } + }); + + /** A seeded (not dynamically registered) public OAuth application with custom scopes of its own. */ + const seedCustomScopes = (scopes: string[]) => + seedFromConfig(server.store, baseUrl, { + organizations: [{ name: 'Seeded' }], + connectApplications: [ + { name: 'Seeded', type: 'oauth', organization: 'Seeded', client_id: 'client_custom', uses_pkce: true, scopes }, + ], + }); + + it('drops a custom scope removed from the application at exchange and at refresh, and keeps the standard ones', async () => { + // Production reads a consent's scopes back as its standard scopes plus whichever of its + // permission scopes still exist, so only a custom scope can disappear from a grant. + seedCustomScopes(['mcp:tools', 'mcp:files']); + const scope = 'offline_access openid mcp:tools mcp:files'; + const first = await codeFor('client_custom', 'alice@acme.test', { scope }); + const tokens = await json(await redeem('client_custom', first.code, first.verifier)); + expect(server.jwt.verify(tokens.access_token).scope).toBe('mcp:files mcp:tools offline_access openid'); + + await setScopes('client_custom', ['mcp:files']); + const refreshed = await json(await refresh('client_custom', tokens.refresh_token)); + expect(refreshed.scope).toBe('mcp:files offline_access openid'); + expect(server.jwt.verify(refreshed.access_token).scope).toBe('mcp:files offline_access openid'); + + // At exchange: a code authorized before the removal. + await setScopes('client_custom', ['mcp:tools', 'mcp:files']); + const second = await codeFor('client_custom', 'alice@acme.test', { scope: 'openid mcp:tools mcp:files' }); + await setScopes('client_custom', []); + const narrowed = await json(await redeem('client_custom', second.code, second.verifier)); + expect(server.jwt.verify(narrowed.access_token).scope).toBe('openid'); + }); + + it('issues a token with no scope when every custom scope of a grant is gone, rather than refusing', async () => { + seedCustomScopes(['mcp:tools']); + const pending = await codeFor('client_custom', 'alice@acme.test', { scope: 'mcp:tools' }); + await setScopes('client_custom', []); + const res = await redeem('client_custom', pending.code, pending.verifier); + expect(res.status).toBe(200); + const body = await json(res); + expect(server.jwt.verify(body.access_token)).not.toHaveProperty('scope'); + // Not dynamically registered and no offline_access: no refresh token either. + expect(body).not.toHaveProperty('refresh_token'); + }); + + it("standard scopes are not the application's scopes: clearing a dynamic client's scopes changes nothing", async () => { + // Production serves an application's `scopes` from its assigned permissions; a dynamically + // registered client has none, and the standard set is open to it regardless. + const { client_id } = await register({ grant_types: both, scope: 'openid profile' }); + expect(ws.connectApplications.findOneBy('client_id', client_id)!.scopes).toEqual([]); + const first = await codeFor(client_id, 'alice@acme.test', { scope: 'email offline_access openid profile' }); + const tokens = await json(await redeem(client_id, first.code, first.verifier)); + expect(server.jwt.verify(tokens.access_token).scope).toBe('email offline_access openid profile'); + + const cleared = await server.app.request(`/connect/applications/${client_id}`, { + method: 'PUT', + headers: apiHeaders, + body: JSON.stringify({ scopes: [] }), + }); + expect(cleared.status).toBe(200); + expect((await json(cleared)).scopes).toEqual([]); + + const refreshed = await json(await refresh(client_id, tokens.refresh_token)); + expect(refreshed.scope).toBe('email offline_access openid profile'); + const again = await codeFor(client_id, 'alice@acme.test', { scope: 'openid' }); + expect( + server.jwt.verify((await json(await redeem(client_id, again.code, again.verifier))).access_token).scope, + ).toBe('openid'); + // And an omitted scope is a grant of nothing, not a refusal. + const none = await codeFor(client_id); + expect(none.code).toMatch(/^auth_code_/); + }); + + it('refuses a refresh once the session has expired', async () => { + const { client_id } = await register({ grant_types: both }); + const { code, verifier } = await codeFor(client_id); + const tokens = await json(await redeem(client_id, code, verifier)); + const sid = server.jwt.verify(tokens.access_token).sid as string; + ws.sessions.update(sid, { expires_at: new Date(Date.now() - 1000).toISOString() }); + const res = await refresh(client_id, tokens.refresh_token); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + }); + + it('never lets a refresh token outlive its session', async () => { + const { client_id } = await register({ grant_types: both }); + const { code, verifier } = await codeFor(client_id); + const tokens = await json(await redeem(client_id, code, verifier)); + const sid = server.jwt.verify(tokens.access_token).sid as string; + // The first token is issued with the session's own lifetime, so it is never longer. + const session = ws.sessions.get(sid)!; + expect(ws.refreshTokens.findOneBy('token', tokens.refresh_token)!.expires_at <= session.expires_at).toBe(true); + + const soon = new Date(Date.now() + 3600_000).toISOString(); + ws.sessions.update(sid, { expires_at: soon }); + const next = await json(await refresh(client_id, tokens.refresh_token)); + expect(ws.refreshTokens.findOneBy('token', next.refresh_token)!.expires_at).toBe(soon); + }); + + it('deleting the application removes its refresh tokens and parked authorize requests', async () => { + const { client_id } = await register({ grant_types: both }); + const other = await register({ grant_types: both }); + const { code, verifier } = await codeFor(client_id); + const tokens = await json(await redeem(client_id, code, verifier)); + const otherCode = await codeFor(other.client_id); + const otherTokens = await json(await redeem(other.client_id, otherCode.code, otherCode.verifier)); + // A request parked by authorize and never finished, for each client. + for (const id of [client_id, other.client_id]) { + const { challenge } = pkce(); + await server.app.request( + `/oauth2/authorize?${new URLSearchParams({ + response_type: 'code', + client_id: id, + redirect_uri: callback, + code_challenge: challenge, + code_challenge_method: 'S256', + })}`, + ); + } + const parked = (id: string) => { + let n = 0; + server.store.deleteDataByPrefix('connect_authorize:', (v: any) => { + if (v.client_id === id) n++; + return false; + }); + return n; + }; + expect(parked(client_id)).toBe(1); + // An AuthKit refresh token that /user_management/authenticate stored under the same client_id. + const authkit = ws.refreshTokens.insert({ + token: 'ref_authkit_same_client', + user_id: ws.users.findOneBy('email', 'alice@acme.test')!.id, + organization_id: null, + session_id: 'session_authkit', + expires_at: new Date(Date.now() + 60_000).toISOString(), + client_id, + exchanged_at: null, + exchanged_for_id: null, + }); + + const app = ws.connectApplications.findOneBy('client_id', client_id)!; + const del = await server.app.request(`/connect/applications/${app.id}`, { method: 'DELETE', headers: apiHeaders }); + expect(del.status).toBe(204); + + expect(ws.refreshTokens.findOneBy('token', tokens.refresh_token)).toBeUndefined(); + expect(parked(client_id)).toBe(0); + expect((await json(await refresh(client_id, tokens.refresh_token))).error).toBe('invalid_client'); + // Another client's are untouched, and so is an AuthKit token that only shares the id. + expect(ws.refreshTokens.findOneBy('token', otherTokens.refresh_token)).toBeDefined(); + expect(ws.refreshTokens.get(authkit.id)).toBeDefined(); + expect(parked(other.client_id)).toBe(1); + }); + + describe('id_token', () => { + const claimsOf = (jwt: string) => JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString()); + + beforeEach(() => { + const alice = ws.users.findOneBy('email', 'alice@acme.test')!; + ws.users.update(alice.id, { name: 'Alice Smith', last_name: 'Smith', email_verified: true }); + }); + + it('is issued at code exchange for the openid scope, signed under the JWKS key', async () => { + const { client_id } = await register({ grant_types: both }); + const alice = ws.users.findOneBy('email', 'alice@acme.test')!; + const first = await codeFor(client_id, 'alice@acme.test', { scope: 'openid', nonce: 'n-0S6_WzA2Mj' }); + const body = await json(await redeem(client_id, first.code, first.verifier)); + const header = JSON.parse(Buffer.from(body.id_token.split('.')[0], 'base64url').toString()); + expect(header.alg).toBe('RS256'); + expect(header.kid).toBe(server.jwt.getJWKS().keys[0].kid); + // verify() checks the signature against the same key the JWKS publishes. + const claims = server.jwt.verify(body.id_token); + expect(claims).toMatchObject({ iss: baseUrl, sub: alice.id, aud: client_id, nonce: 'n-0S6_WzA2Mj' }); + expect(typeof claims.exp).toBe('number'); + expect(typeof claims.iat).toBe('number'); + // auth_time rides the access token, not the ID token, as production mints them. + expect(claims).not.toHaveProperty('auth_time'); + // Only `openid` was granted: no email and no profile claims. + for (const absent of ['email', 'email_verified', 'name', 'given_name', 'family_name']) { + expect(claims).not.toHaveProperty(absent); + } + }); + + it('carries email claims only with the email scope and profile claims only with profile', async () => { + const { client_id } = await register({ grant_types: both }); + const email = await codeFor(client_id, 'alice@acme.test', { scope: 'openid email' }); + const withEmail = claimsOf((await json(await redeem(client_id, email.code, email.verifier))).id_token); + expect(withEmail).toMatchObject({ email: 'alice@acme.test', email_verified: true }); + expect(withEmail).not.toHaveProperty('name'); + + const profile = await codeFor(client_id, 'alice@acme.test', { scope: 'openid profile' }); + const withProfile = claimsOf((await json(await redeem(client_id, profile.code, profile.verifier))).id_token); + expect(withProfile).toMatchObject({ name: 'Alice Smith', given_name: 'Alice', family_name: 'Smith' }); + expect(withProfile).not.toHaveProperty('email'); + expect(withProfile).not.toHaveProperty('nonce'); + + // Production composes `name` from the two parts, so it is an empty string for a user with + // neither, while the parts themselves are omitted. + const bob = await codeFor(client_id, 'bob@other.test', { scope: 'openid profile' }); + const bobClaims = claimsOf((await json(await redeem(client_id, bob.code, bob.verifier))).id_token); + expect(bobClaims.name).toBe(''); + for (const absent of ['given_name', 'family_name']) expect(bobClaims).not.toHaveProperty(absent); + }); + + it('is issued without openid too, and on refresh, as production mints it unconditionally', async () => { + const { client_id } = await register({ grant_types: both }); + const alice = ws.users.findOneBy('email', 'alice@acme.test')!; + const plain = await codeFor(client_id, 'alice@acme.test', { scope: 'profile email' }); + const tokens = await json(await redeem(client_id, plain.code, plain.verifier)); + const claims = server.jwt.verify(tokens.id_token); + expect(claims).toMatchObject({ iss: baseUrl, sub: alice.id, aud: client_id, email: 'alice@acme.test' }); + + const refreshed = await json(await refresh(client_id, tokens.refresh_token)); + const again = server.jwt.verify(refreshed.id_token); + expect(again).toMatchObject({ iss: baseUrl, sub: alice.id, aud: client_id, email: 'alice@acme.test' }); + expect(again).not.toHaveProperty('nonce'); + }); + }); + + it('refuses a code whose membership was revoked between authorize and token', async () => { + const { client_id } = await register({ grant_types: ['authorization_code', 'refresh_token'] }); + const acme = ws.organizations.findOneBy('name', 'Acme')!; + const { code, verifier } = await codeFor(client_id, 'alice@acme.test', { organization_id: acme.id }); + const membership = ws.organizationMemberships.findBy( + 'user_id', + ws.users.findOneBy('email', 'alice@acme.test')!.id, + )[0]; + ws.organizationMemberships.delete(membership.id); + const res = await redeem(client_id, code, verifier); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + }); + + it('refuses a code whose user was deleted between authorize and token, and a refresh for one', async () => { + const { client_id } = await register({ grant_types: ['authorization_code', 'refresh_token'] }); + const first = await codeFor(client_id, 'bob@other.test'); + ws.users.delete(ws.users.findOneBy('email', 'bob@other.test')!.id); + expect((await json(await redeem(client_id, first.code, first.verifier))).error).toBe('invalid_grant'); + + const second = await codeFor(client_id, 'alice@acme.test'); + const tokens = await json(await redeem(client_id, second.code, second.verifier)); + ws.users.delete(ws.users.findOneBy('email', 'alice@acme.test')!.id); + const refresh = await server.app.request('/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id, + refresh_token: tokens.refresh_token, + }).toString(), + }); + expect((await json(refresh)).error).toBe('invalid_grant'); + }); +}); + +describe('AuthKit OAuth server, multi-step interactive sign-in', () => { + let emulator: Emulator; + + beforeEach(async () => { + emulator = await createEmulator({ + port: 0, + interactiveAuth: { password: true }, + seed: { + resourceIndicators: [{ uri: 'https://mcp.example.test' }], + users: [ + { email: 'plain@acme.test' }, + { email: 'pw@acme.test', password: 'correct-horse', email_verified: true }, + { email: 'multi@acme.test', password: 'correct-horse', email_verified: true }, + // Every gate at once: unverified mailbox, second factor, two organizations. + { email: 'gated@acme.test', password: 'correct-horse', totp: true }, + ], + organizations: [ + { name: 'Alpha', memberships: [{ email: 'multi@acme.test' }, { email: 'gated@acme.test' }] }, + { name: 'Beta', memberships: [{ email: 'multi@acme.test' }, { email: 'gated@acme.test' }] }, + ], + }, + }); + }); + + afterEach(async () => { + await emulator.close(); + }); + + const ews = () => getWorkOSStore(emulator.store); + const hiddenFields = (html: string) => + Object.fromEntries( + [...html.matchAll(/ [m[1], m[2]]), + ); + + /** Register a public client and start authorize; resolves to the first hosted page and the PKCE verifier. */ + async function begin() { + const reg = (await ( + await fetch(`${emulator.url}/oauth2/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Test client', + redirect_uris: [callback], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + }), + }) + ).json()) as { client_id: string }; + const { verifier, challenge } = pkce(); + const authorize = await fetch( + `${emulator.url}/oauth2/authorize?${new URLSearchParams({ + response_type: 'code', + client_id: reg.client_id, + redirect_uri: callback, + code_challenge: challenge, + code_challenge_method: 'S256', + resource: 'https://mcp.example.test', + })}`, + { redirect: 'manual' }, + ); + const page = await fetch(authorize.headers.get('location')!, { redirect: 'manual' }); + expect(page.status).toBe(200); + return { client_id: reg.client_id, verifier, html: await page.text() }; + } + + const submit = (fields: Record) => + fetch(`${emulator.url}/user_management/authorize`, { + method: 'POST', + redirect: 'manual', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(fields), + }); + + const exchange = async (client_id: string, verifier: string, callbackUrl: string) => { + const done = new URL(callbackUrl); + expect(`${done.origin}${done.pathname}`).toBe(callback); + const res = await fetch(`${emulator.url}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id, + code: done.searchParams.get('code')!, + redirect_uri: callback, + code_verifier: verifier, + }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { access_token: string; refresh_token: string }; + }; + + it('carries connect_request through the password page to a working exchange', async () => { + const { client_id, verifier, html } = await begin(); + const carried = hiddenFields(html); + expect(carried.connect_request).toMatch(/^connect_req_/); + + const passwordPage = await submit({ ...carried, email: 'pw@acme.test' }); + expect(passwordPage.status).toBe(200); + const passwordHtml = await passwordPage.text(); + expect(passwordHtml).toContain('name="password"'); + expect(hiddenFields(passwordHtml).connect_request).toBe(carried.connect_request); + + const wrong = await submit({ ...hiddenFields(passwordHtml), password: 'nope' }); + expect(wrong.status).toBe(401); + const ok = await submit({ ...hiddenFields(passwordHtml), password: 'correct-horse' }); + expect(ok.status).toBe(302); + const tokens = await exchange(client_id, verifier, ok.headers.get('location')!); + expect(decode(tokens.access_token)).toMatchObject({ + client_id, + aud: 'https://mcp.example.test', + sub: ews().users.findOneBy('email', 'pw@acme.test')!.id, + }); + expect(ews().sessions.all().at(-1)!.auth_method).toBe('password'); + }); + + it('carries connect_request through organization selection, and binds the chosen organization', async () => { + const { client_id, verifier, html } = await begin(); + const passwordHtml = await (await submit({ ...hiddenFields(html), email: 'multi@acme.test' })).text(); + const orgPage = await submit({ ...hiddenFields(passwordHtml), password: 'correct-horse' }); + expect(orgPage.status).toBe(200); + const orgHtml = await orgPage.text(); + expect(orgHtml).toContain('Select an organization'); + const fields = hiddenFields(orgHtml); + expect(fields.connect_request).toBeTruthy(); + const orgId = orgHtml.match(/name="organization_id" value="([^"]+)"/)![1]; + + const done = await submit({ ...fields, organization_id: orgId }); + expect(done.status).toBe(302); + const tokens = await exchange(client_id, verifier, done.headers.get('location')!); + expect(decode(tokens.access_token).org_id).toBe(orgId); + // The refresh carries the organization across. + const refreshed = await fetch(`${emulator.url}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'refresh_token', client_id, refresh_token: tokens.refresh_token }), + }); + expect(decode(((await refreshed.json()) as any).access_token).org_id).toBe(orgId); + }); + + it('carries connect_request through email verification and a second factor, then organization selection', async () => { + const { client_id, verifier, html } = await begin(); + let page = await (await submit({ ...hiddenFields(html), email: 'gated@acme.test' })).text(); + page = await (await submit({ ...hiddenFields(page), password: 'correct-horse' })).text(); + expect(page).toContain('Verify your email'); + const login = () => + emulator.store.getData<{ email_verification_id: string | null; challenge_id: string | null }>( + `interactive_login:${hiddenFields(page).pending_authentication_token}`, + )!; + const emailCode = ews().emailVerifications.get(login().email_verification_id!)!.code; + page = await (await submit({ ...hiddenFields(page), code: emailCode })).text(); + expect(page).toContain('one-time code'); + const totp = ews().authChallenges.get(login().challenge_id!)!.code!; + page = await (await submit({ ...hiddenFields(page), code: totp })).text(); + expect(page).toContain('Select an organization'); + const orgId = page.match(/name="organization_id" value="([^"]+)"/)![1]; + expect(hiddenFields(page).connect_request).toBeTruthy(); + + const done = await submit({ ...hiddenFields(page), organization_id: orgId }); + expect(done.status).toBe(302); + const tokens = await exchange(client_id, verifier, done.headers.get('location')!); + expect(decode(tokens.access_token).org_id).toBe(orgId); + // The gate that cleared last is what the session and event report, as for the API grants. + expect(ews().users.findOneBy('email', 'gated@acme.test')!.email_verified).toBe(true); + }); + + it('sends a submit that skips the password page back to it', async () => { + const first = await begin(); + const skipped = await submit({ ...hiddenFields(first.html), email: 'pw@acme.test' }); + expect(skipped.status).toBe(200); + expect(await skipped.text()).toContain('name="password"'); + }); +}); diff --git a/src/workos/routes/authkit-oauth.spec.ts b/src/workos/routes/authkit-oauth.spec.ts new file mode 100644 index 0000000..3460b40 --- /dev/null +++ b/src/workos/routes/authkit-oauth.spec.ts @@ -0,0 +1,551 @@ +/** + * The AuthKit domain's discovery documents, dynamic client registration (RFC 7591) and the + * resource-indicator API. The authorize/token behavior they lead to is in + * authkit-oauth-server.spec.ts. + */ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { createServer } from '../../core/index.js'; +import { seedFromConfig, workosPlugin } from '../index.js'; +import { validateSeedConfig } from '../config-validator.js'; +import { getWorkOSStore } from '../store.js'; + +const baseUrl = 'http://localhost:4100'; +const apiHeaders = { Authorization: 'Bearer sk_test_default', 'Content-Type': 'application/json' }; +const json = (res: Response) => res.json() as Promise; + +function createTestApp() { + return createServer(workosPlugin, { + port: 0, + baseUrl, + apiKeys: { sk_test_default: { environment: 'test' } }, + }); +} + +describe('AuthKit domain discovery', () => { + let server: ReturnType; + + beforeEach(() => { + server = createTestApp(); + }); + + it('serves the OAuth authorization server metadata without an API key', async () => { + const res = await server.app.request('http://issuer.test:4100/.well-known/oauth-authorization-server'); + expect(res.status).toBe(200); + // Endpoints from the origin the caller used, issuer from the configuration: see the route. + expect(await json(res)).toEqual({ + authorization_endpoint: 'http://issuer.test:4100/oauth2/authorize', + code_challenge_methods_supported: ['S256'], + grant_types_supported: ['authorization_code', 'refresh_token'], + issuer: baseUrl, + jwks_uri: 'http://issuer.test:4100/oauth2/jwks', + registration_endpoint: 'http://issuer.test:4100/oauth2/register', + scopes_supported: ['email', 'offline_access', 'openid', 'profile'], + response_modes_supported: ['query'], + response_types_supported: ['code'], + token_endpoint: 'http://issuer.test:4100/oauth2/token', + token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'], + }); + }); + + it('serves the OpenID configuration without an API key', async () => { + const res = await server.app.request('http://issuer.test:4100/.well-known/openid-configuration'); + expect(res.status).toBe(200); + expect(await json(res)).toEqual({ + issuer: baseUrl, + authorization_endpoint: 'http://issuer.test:4100/oauth2/authorize', + grant_types_supported: ['authorization_code', 'client_credentials', 'refresh_token'], + id_token_signing_alg_values_supported: ['RS256'], + jwks_uri: 'http://issuer.test:4100/oauth2/jwks', + response_types_supported: ['code'], + scopes_supported: ['email', 'offline_access', 'openid', 'profile'], + subject_types_supported: ['public'], + token_endpoint: 'http://issuer.test:4100/oauth2/token', + token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'], + }); + }); + + it('advertises no endpoint the emulator does not serve', async () => { + for (const path of ['oauth-authorization-server', 'openid-configuration']) { + const doc = await json(await server.app.request(`/.well-known/${path}`)); + for (const key of [ + 'device_authorization_endpoint', + 'introspection_endpoint', + 'userinfo_endpoint', + 'client_id_metadata_document_supported', + ]) { + expect(doc).not.toHaveProperty(key); + } + expect(doc.grant_types_supported).not.toContain('urn:ietf:params:oauth:grant-type:device_code'); + } + }); + + it('advertises only endpoints with a route registered for exactly that method and path', async () => { + // Registered routes, not responses: a 404 and a 401 look alike from outside, and a wildcard + // middleware or catch-all would otherwise make any path seem served. + const registered = new Set( + server.app.routes.filter((r) => r.path !== '/*' && !r.path.includes('*')).map((r) => `${r.method} ${r.path}`), + ); + const expected: Record = { + authorization_endpoint: 'GET', + token_endpoint: 'POST', + registration_endpoint: 'POST', + jwks_uri: 'GET', + }; + for (const path of ['oauth-authorization-server', 'openid-configuration']) { + const doc = await json(await server.app.request(`/.well-known/${path}`)); + const endpointKeys = Object.keys(doc).filter((k) => k.endsWith('_endpoint') || k === 'jwks_uri'); + expect(endpointKeys.length).toBeGreaterThan(0); + for (const key of endpointKeys) { + // Every advertised URL key must be one this test knows how to check. + expect(Object.keys(expected)).toContain(key); + expect(registered.has(`${expected[key]} ${new URL(doc[key]).pathname}`)).toBe(true); + } + } + expect((await server.app.request('/oauth2/jwks')).status).toBe(200); + }); + + it('does not serve an RFC 8414 path-inserted form', async () => { + // The issuer has no path, so production has no such document; it must not be invented. + const res = await server.app.request('/.well-known/oauth-authorization-server/user_management/client_x'); + expect(res.status).not.toBe(200); + }); + + it('uses a pinned issuer verbatim, without a trailing slash', async () => { + const pinned = createServer(workosPlugin, { port: 0, baseUrl, issuer: 'https://auth.example.test/' }); + const doc = await json(await pinned.app.request('/.well-known/oauth-authorization-server')); + expect(doc.issuer).toBe('https://auth.example.test'); + }); +}); + +describe('Dynamic client registration', () => { + let server: ReturnType; + let ws: ReturnType; + + beforeEach(() => { + server = createTestApp(); + ws = getWorkOSStore(server.store); + }); + + // A JSON object body is given the required `client_name` unless the test names one (or sets it + // to undefined, to leave it out). + const register = (body: unknown, headers: Record = { 'Content-Type': 'application/json' }) => + server.app.request('/oauth2/register', { + method: 'POST', + headers, + body: + typeof body === 'string' + ? body + : JSON.stringify( + body && typeof body === 'object' && !Array.isArray(body) ? { client_name: 'Test client', ...body } : body, + ), + }); + const callback = 'http://localhost:33418/callback'; + + it('registers a public client without an API key', async () => { + const res = await register({ + client_name: 'Claude Code', + redirect_uris: [callback], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'openid profile', + }); + expect(res.status).toBe(201); + const body = await json(res); + expect(body.client_id).toMatch(/^client_/); + expect(body).not.toHaveProperty('client_secret'); + expect(body).toMatchObject({ + client_name: 'Claude Code', + redirect_uris: [callback], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'openid profile', + }); + expect(typeof body.client_id_issued_at).toBe('number'); + + const application = ws.connectApplications.findOneBy('client_id', body.client_id)!; + expect(application).toMatchObject({ + application_type: 'oauth', + is_first_party: false, + was_dynamically_registered: true, + uses_pkce: true, + login_url: null, + redirect_uris: [callback], + }); + expect(ws.clientSecrets.findBy('application_id', application.id)).toHaveLength(0); + }); + + it('registers a confidential client with a generated secret', async () => { + const res = await register({ redirect_uris: [callback], token_endpoint_auth_method: 'client_secret_post' }); + expect(res.status).toBe(201); + expect(res.headers.get('cache-control')).toBe('no-store'); + const body = await json(res); + expect(body.client_secret).toMatch(/^secret_/); + expect(body.client_secret_expires_at).toBe(0); + expect(body.token_endpoint_auth_method).toBe('client_secret_post'); + const application = ws.connectApplications.findOneBy('client_id', body.client_id)!; + expect(application.uses_pkce).toBe(false); + expect(ws.clientSecrets.findBy('application_id', application.id).map((s) => s.value)).toEqual([body.client_secret]); + }); + + it('defaults as production does: web, client_secret_basic, authorization_code, code, and no scope echoed', async () => { + const body = await json(await register({ redirect_uris: [callback] })); + expect(body.application_type).toBe('web'); + expect(body.token_endpoint_auth_method).toBe('client_secret_basic'); + expect(body.client_secret).toMatch(/^secret_/); + expect(body.grant_types).toEqual(['authorization_code']); + expect(body.response_types).toEqual(['code']); + // No scope asked for: none echoed. The standard set is open to the client regardless, and + // none of it is an application scope: production serves those from assigned permissions, + // which a dynamically registered application has none of. + expect(body).not.toHaveProperty('scope'); + expect(ws.connectApplications.findOneBy('client_id', body.client_id)!.scopes).toEqual([]); + expect(Object.keys(body).sort()).toEqual([ + 'application_type', + 'client_id', + 'client_id_issued_at', + 'client_name', + 'client_secret', + 'client_secret_expires_at', + 'grant_types', + 'redirect_uris', + 'response_types', + 'token_endpoint_auth_method', + ]); + }); + + it('requires client_name, as production does', async () => { + for (const client_name of [undefined, 42, null]) { + const res = await register({ redirect_uris: [callback], client_name }); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ + error: 'invalid_client_metadata', + error_description: 'client_name is required.', + }); + } + expect(ws.connectApplications.all()).toHaveLength(0); + }); + + it('defaults a native application to a public client, and echoes application_type', async () => { + const native = await json(await register({ redirect_uris: [callback], application_type: 'native' })); + expect(native.application_type).toBe('native'); + expect(native.token_endpoint_auth_method).toBe('none'); + expect(native).not.toHaveProperty('client_secret'); + expect(ws.connectApplications.findOneBy('client_id', native.client_id)!.uses_pkce).toBe(true); + // An explicit method still wins. + const confidential = await json( + await register({ + redirect_uris: [callback], + application_type: 'native', + token_endpoint_auth_method: 'client_secret_post', + }), + ); + expect(confidential.token_endpoint_auth_method).toBe('client_secret_post'); + const bad = await register({ redirect_uris: [callback], application_type: 'desktop' }); + expect(bad.status).toBe(400); + expect((await json(bad)).error).toBe('invalid_client_metadata'); + }); + + it('shows dynamically registered applications only under registration_types=dynamic', async () => { + const { client_id } = await json(await register({ redirect_uris: [callback], token_endpoint_auth_method: 'none' })); + const list = async (query: string) => + (await json(await server.app.request(`/connect/applications${query}`, { headers: apiHeaders }))).data.map( + (a: any) => a.client_id, + ); + expect(await list('')).not.toContain(client_id); + expect(await list('?registration_types=authenticated')).not.toContain(client_id); + expect(await list('?registration_types=dynamic')).toEqual([client_id]); + expect(await list('?registration_types=dynamic,authenticated')).toContain(client_id); + + const application = await json( + await server.app.request(`/connect/applications/${client_id}`, { headers: apiHeaders }), + ); + expect(application).toMatchObject({ + application_type: 'oauth', + is_first_party: false, + was_dynamically_registered: true, + uses_pkce: true, + }); + }); + + it('rejects redirect_uris the redirect-host policy would refuse, as RFC 7591 errors', async () => { + for (const uris of [ + ['https://evil.example/cb'], + ['javascript:alert(1)'], + ['not a url'], + [callback, 'https://evil.example/cb'], + ]) { + const res = await register({ redirect_uris: uris }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_redirect_uri'); + } + for (const uris of [undefined, [], 'http://localhost/cb', [42], ['']]) { + const res = await register({ redirect_uris: uris }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_redirect_uri'); + } + expect(ws.connectApplications.all()).toHaveLength(0); + }); + + it('drops scopes a dynamically registered client cannot be granted and echoes the effective scope', async () => { + // Production's grantableForDynamicRegistration: a requested scope the client may not ask for + // is dropped rather than failing the registration, and the effective set is reported in the + // order requested, de-duplicated. The emulator has no environment-level permissions for + // dynamic registration, so the grantable set is the standard scopes. + const mixed = await register({ redirect_uris: [callback], scope: 'mcp:tools openid claudeai profile openid' }); + expect(mixed.status).toBe(201); + expect((await json(mixed)).scope).toBe('openid profile'); + // Nothing grantable: registered all the same, with an empty effective scope. + const none = await register({ redirect_uris: [callback], scope: 'claudeai' }); + expect(none.status).toBe(201); + expect((await json(none)).scope).toBe(''); + // An empty string is no request for a scope, and is not echoed. + const empty = await json(await register({ redirect_uris: [callback], scope: '' })); + expect(empty).not.toHaveProperty('scope'); + expect(ws.connectApplications.all()).toHaveLength(3); + }); + + it('stores what the client registered for, as metadata the token endpoint does not enforce', async () => { + const body = await json( + await register({ + redirect_uris: [callback], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code'], + }), + ); + expect(ws.connectApplications.findOneBy('client_id', body.client_id)).toMatchObject({ + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + }); + }); + + it('accepts http(s) display URLs and rejects any other scheme as invalid_client_metadata', async () => { + const ok = await register({ + redirect_uris: [callback], + logo_uri: 'https://client.example.test/logo.png', + client_uri: 'http://client.example.test', + policy_uri: 'https://client.example.test/policy', + tos_uri: 'https://client.example.test/tos', + }); + expect(ok.status).toBe(201); + for (const field of ['logo_uri', 'client_uri', 'policy_uri', 'tos_uri']) { + for (const value of ['javascript:alert(1)', 'data:text/html,x', 'ftp://x.test/a', 'not a url', 42]) { + const res = await register({ redirect_uris: [callback], [field]: value }); + expect(res.status).toBe(400); + const err = await json(res); + expect(err.error).toBe('invalid_client_metadata'); + expect(err.error_description).toContain(field); + } + } + }); + + it('rejects a registration whose grant_types cannot produce the code response type it names', async () => { + // RFC 7591 §2.1: `code` needs `authorization_code`. Refresh-only is such a client. + for (const grant_types of [['refresh_token']]) { + for (const response_types of [undefined, ['code']]) { + const res = await register({ redirect_uris: [callback], grant_types, response_types }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_client_metadata'); + } + } + // refresh_token stays optional. + const ok = await register({ redirect_uris: [callback], grant_types: ['authorization_code'] }); + expect(ok.status).toBe(201); + }); + + it('rejects an empty or unsupported response_types, and returns and stores exactly what was registered', async () => { + for (const response_types of [[], ['token'], ['code', 'token'], 'code', 7]) { + const res = await register({ redirect_uris: [callback], response_types }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_client_metadata'); + } + const body = await json(await register({ redirect_uris: [callback], response_types: ['code'] })); + expect(body.response_types).toEqual(['code']); + expect(ws.connectApplications.findOneBy('client_id', body.client_id)!.response_types).toEqual(['code']); + // Omitted means the RFC default, `code`, and that is what comes back. + const defaulted = await json(await register({ redirect_uris: [callback] })); + expect(defaulted.response_types).toEqual(['code']); + }); + + it('rejects metadata the emulator cannot honor as invalid_client_metadata', async () => { + for (const extra of [ + { token_endpoint_auth_method: 'private_key_jwt' }, + { grant_types: ['client_credentials'] }, + { grant_types: ['urn:ietf:params:oauth:grant-type:device_code'] }, + { response_types: ['token'] }, + { scope: 42 }, + ]) { + const res = await register({ redirect_uris: [callback], ...extra }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_client_metadata'); + } + for (const body of ['not json', '[]', '"x"']) { + const res = await register(body); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_client_metadata'); + } + expect(ws.connectApplications.all()).toHaveLength(0); + }); +}); + +describe('OAuth error page', () => { + const server = createTestApp(); + + it('serves 200 text/html and reflects the error_description', async () => { + const res = await server.app.request( + `/oauth2/error?${new URLSearchParams({ error: 'access_denied', error_description: 'The user said no' })}`, + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('access_denied: The user said no'); + }); + + it('serves the application_not_found explanation by default', async () => { + const html = await (await server.app.request('/oauth2/error?error=application_not_found')).text(); + expect(html).toContain('Application not found'); + expect(html).toContain('not registered with this environment'); + }); + + it('escapes hostile values in both parameters', async () => { + const hostile = '"\'&'; + const cases: Record[] = [ + { error: 'application_not_found', error_description: hostile }, + { error: hostile, error_description: hostile }, + { error: hostile }, + ]; + for (const params of cases) { + const html = await (await server.app.request(`/oauth2/error?${new URLSearchParams(params)}`)).text(); + expect(html).not.toContain('