From cc9ca2d0002cf49bc13a5b0e563842e2837a292a Mon Sep 17 00:00:00 2001 From: Alan Scherger Date: Wed, 30 Sep 2026 10:53:53 -0500 Subject: [PATCH 1/5] feat(oauth)!: serve the AuthKit OAuth server MCP clients sign in through An MCP client signs in to a WorkOS environment through its AuthKit domain, which acts as an OAuth 2.1 authorization server. The emulator had the pieces on either side of that flow (the hosted sign-in under /user_management, and Connect applications with /oauth2/token) but not the flow itself, so an MCP resource server could not be exercised against it. GET /.well-known/oauth-authorization-server GET /.well-known/openid-configuration POST /oauth2/register GET /oauth2/error POST /user_management/authkit_oauth_resources GET /user_management/authkit_oauth_resources DELETE /user_management/authkit_oauth_resources/:id /oauth2/authorize sends an application with no `login_url` to the hosted sign-in, carrying PKCE, `state`, `scope` and an RFC 8707 `resource`. /oauth2/token exchanges the code for a public client on its `code_verifier` alone, and gains the `refresh_token` grant. Registration creates a real Connect application, so `was_dynamically_registered` and the list route's `registration_types` filter now have something to report. The discovery documents advertise only what is served. A production AuthKit domain also lists device authorization, introspection, userinfo and client ID metadata documents; those are omitted rather than stubbed, so a client never follows discovery into a 404. A registered resource indicator decides the token's `aud`. A request naming one that is not registered, or none, keeps the existing fallback to the application's `audience` and then its `client_id`. The resource is bound at authorize: the token request may restate it or omit it, never introduce one. Three answers on existing routes change, each to what a production AuthKit domain returns. /oauth2/token answers a missing or unknown client with `401 invalid_client` and `WWW-Authenticate: Basic realm="AuthKit"` under every grant type, where it answered 400. /oauth2/authorize redirects an unknown client to /oauth2/error, where it answered 400. An OAuth application with no `login_url` reaches the hosted sign-in, where it was refused. The claim set beyond `iss`, `aud`, `sub`, `client_id` and `exp` is the emulator's choice, not a captured production token, and the README says so. --- README.md | 111 +- SUPPORTED.md | 6 +- scripts/gen-supported-lib.ts | 5 +- src/core/id.ts | 1 + src/core/middleware/error-handler.ts | 12 +- src/core/server.ts | 7 +- src/index.ts | 4 +- src/workos/authkit-oauth-mcp-client.spec.ts | 174 +++ src/workos/authkit-oauth.ts | 80 ++ src/workos/config-validator.ts | 53 + src/workos/constants.ts | 2 + src/workos/entities.ts | 38 + src/workos/helpers.ts | 70 + src/workos/index.ts | 31 +- src/workos/login-page.ts | 19 + src/workos/routes/auth.ts | 118 +- .../routes/authkit-oauth-server.spec.ts | 1211 +++++++++++++++++ src/workos/routes/authkit-oauth.spec.ts | 447 ++++++ src/workos/routes/authkit-oauth.ts | 301 ++++ src/workos/routes/oauth.spec.ts | 81 ++ src/workos/routes/oauth.ts | 522 ++++++- src/workos/routes/standalone-connect.spec.ts | 19 +- src/workos/store.ts | 7 + 23 files changed, 3173 insertions(+), 146 deletions(-) create mode 100644 src/workos/authkit-oauth-mcp-client.spec.ts create mode 100644 src/workos/authkit-oauth.ts create mode 100644 src/workos/routes/authkit-oauth-server.spec.ts create mode 100644 src/workos/routes/authkit-oauth.spec.ts create mode 100644 src/workos/routes/authkit-oauth.ts diff --git a/README.md b/README.md index ba57238..2e11b41 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,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: the hosted sign-in requires PKCE of it ``` Each seeded application is provisioned with a client secret. Pin `client_secret` to bake a known @@ -438,8 +438,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. @@ -552,6 +552,107 @@ 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`. `token_endpoint_auth_method: none` makes a public client (PKCE + only, no secret); anything else, `client_secret_basic` by default, a confidential one with a + generated `client_secret`. The `grant_types` (default `["authorization_code"]`, as RFC 7591 says) + and `token_endpoint_auth_method` a client registers are stored and enforced at `/oauth2/token`: + no `refresh_token` grant type means no refresh token is issued and the grant is refused, and a + `client_secret_basic` client cannot send its secret in the body (nor the reverse). `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); + `scope` is limited to `openid profile email offline_access` (no custom scopes). +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; **required of + a public client**), `state`, `scope`, RFC 8707 `resource`, plus `login_hint` and + `organization_id`. Problems found after the callback is trusted come back on it as + `error`/`error_description`/`state`. An unknown `client_id` redirects to + `/oauth2/error?error=application_not_found` (observed against a production AuthKit domain, + 2026-09-30); that page is `200 text/html` and reflects the `error_description` it is given, + escaped. Applications with a + `login_url` keep the [Standalone Connect](#standalone-connect) behavior. +4. **Token.** `POST /oauth2/token` with `authorization_code`: a public client authenticates by its + `code_verifier` with no secret, a confidential one by its secret (post or Basic) and, if the + code carries a challenge, the verifier too. The response is `access_token`, `token_type`, + `expires_in`, `refresh_token` and `scope`. `grant_type=refresh_token` rotates the token within + the same session (the old one is spent) and can narrow, never widen, the scope. A request with no + client at all is `401 invalid_client` / `Missing authorization header.`; an unknown `client_id` + is `401 invalid_client` / `Application not found.` under every grant type, checked before the + grant type is validated; both carry `WWW-Authenticate: Basic realm="AuthKit"` (observed against + a production AuthKit domain, 2026-09-30). Token responses carry `Cache-Control: no-store` and + `Pragma: no-cache`. The `code_verifier` must be 43 to 128 unreserved characters (RFC 7636 + §4.1), and a wrong one spends the code. + `client_credentials` keeps its behavior and claims, with one deliberate change to its client + errors: a request naming no client, or a `client_id` that does not exist, is now the `401` above + rather than the earlier `400 invalid_request` / `401 Invalid client ID or secret.`, and a known + client that presents no secret is `401 Missing authorization header.` (the last is an + assumption; only the unknown-client cases were observed). +5. **Claims.** Required of a Connect token: `iss` is the bare issuer, signed by the key at + `/oauth2/jwks`, and it carries `aud`, `sub` (the user id), `client_id` and `exp`. Beyond that the + set is an emulator choice, since production's full claim set was not captured: `sid`, `jti`, a + space-delimited `scope`, `org_id` when the user has an organization context, and 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`). When the + `resource` on authorize or token matches one, `aud` is that resource; otherwise `aud` falls back + to the application's `audience`, then its `client_id`. The resource is bound to the code and + carried across every refresh; the token request may restate it or omit it, and anything else + (including a resource on a grant authorized for none) is `invalid_target`. + +```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&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_… +``` + +**Assumptions and gaps.** Not implemented: Client ID Metadata Documents, the device grant on this +surface, token introspection, `userinfo`, and `id_token` issuance (`openid` is accepted, but no ID +token is returned even though the OIDC document lists `id_token_signing_alg_values_supported`). +A refresh token is issued regardless of `offline_access`. The registered resource is matched +exactly, without wildcards, and the indicator flagged `default` is stored but is not used as a +fallback audience. 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 @@ -1243,8 +1344,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 2ac265e..3f2aa68 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **184 of 261** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.98.0`) (**70.5%**). +The emulator implements **187 of 261** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.98.0`) (**71.6%**). 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/17 | ✅ 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, which must use PKCE at `/oauth2/authorize`. `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`; wildcard patterns are refused, and the resource flagged `default` is not used as a fallback audience. | | 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-supported-lib.ts b/scripts/gen-supported-lib.ts index e691f03..9da778e 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -207,7 +207,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, which must use PKCE at `/oauth2/authorize`. `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', @@ -232,8 +232,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`; wildcard patterns are refused, and the resource flagged `default` is not used as a fallback audience.', }, { name: 'Admin Portal', diff --git a/src/core/id.ts b/src/core/id.ts index 39c357d..3f668ce 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -79,6 +79,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', audit_log_action: 'audit_action', diff --git a/src/core/middleware/error-handler.ts b/src/core/middleware/error-handler.ts index de15222..e900811 100644 --- a/src/core/middleware/error-handler.ts +++ b/src/core/middleware/error-handler.ts @@ -20,16 +20,24 @@ 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) { + /** Response headers the failure carries, e.g. the `WWW-Authenticate` a 401 invalid_client needs (RFC 6749 §5.2). */ + readonly headers?: Record; + + 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, 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 2732f9e..aaa2e9b 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -25,7 +25,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. */ @@ -78,6 +79,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 5e7a191..e367c33 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']; @@ -62,7 +63,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..2e37740 --- /dev/null +++ b/src/workos/authkit-oauth-mcp-client.spec.ts @@ -0,0 +1,174 @@ +/** + * 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; + expect(tokens).toMatchObject({ token_type: 'Bearer', expires_in: 3600, scope: 'openid profile offline_access' }); + + // 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, + scope: 'openid profile offline_access', + }); + 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..1914422 --- /dev/null +++ b/src/workos/authkit-oauth.ts @@ -0,0 +1,80 @@ +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 scopes a production AuthKit domain advertises. A dynamically registered client is limited to these. */ +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; + +/** + * 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, 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. Constant-time, because the verifier is a secret until the exchange. */ +export function pkceMatches(codeVerifier: string, codeChallenge: string): boolean { + const computed = Buffer.from(createHash('sha256').update(codeVerifier).digest('base64url')); + const expected = Buffer.from(codeChallenge); + return computed.length === expected.length && timingSafeEqual(computed, expected); +} + +/** RFC 7636 §4.1: 43 to 128 unreserved characters. */ +export function isValidCodeVerifier(value: string): boolean { + return /^[A-Za-z0-9\-._~]{43,128}$/.test(value); +} + +/** + * RFC 8707 §2: a resource is an absolute URI with no fragment. Checked wherever a client hands one + * over, so a malformed value is refused as `invalid_target` instead of being carried on a code. + */ +export function isValidResourceUri(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.hash === '' && !value.includes('#'); + } catch { + return false; + } +} + +/** + * The `aud` for a token bound to `resource`. It is the resource only when the environment has + * registered it as an indicator; anything else, including no resource at all, falls back to the + * application's `audience` and then its `client_id`, which is what this surface minted before + * indicators existed. + */ +export function resolveAudience( + ws: WorkOSStore, + application: WorkOSConnectApplication, + resource: string | null | undefined, +): string { + if (resource && ws.authkitOauthResources.findOneBy('uri', resource)) return resource; + return application.audience ?? application.client_id; +} + +/** A `/oauth2/authorize` request that passed validation, 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; + expires_at: string; +} diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 431c0f5..0110636 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 } 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 @@ -1007,6 +1008,58 @@ 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.has(entry.uri)) { + 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/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 512d111..0956a6f 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -151,6 +151,22 @@ 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; +} + export interface WorkOSAuthorizationCode extends Entity { user_id: string; organization_id: string | null; @@ -161,6 +177,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 @@ -262,6 +280,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; @@ -321,6 +351,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; } export interface WorkOSAuthenticationChallenge extends Entity { @@ -500,6 +532,12 @@ 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): what the client registered for. Undefined on a + * seeded or dashboard-created application, which is not restricted on either. + */ + grant_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/helpers.ts b/src/workos/helpers.ts index 97f6585..5453075 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -30,6 +30,7 @@ import type { WorkOSGroup, WorkOSUser, WorkOSSession, + WorkOSAuthkitOauthResource, WorkOSEmailVerification, WorkOSPasswordReset, WorkOSMagicAuth, @@ -647,6 +648,75 @@ export function formatRedirectUri(r: WorkOSRedirectUri): 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 45262b4..d5736e1 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -35,6 +35,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'; @@ -328,10 +329,21 @@ export interface WorkOSSeedConnectApplication { is_first_party?: boolean; /** `oauth` only. Reported on the application; the emulator does not enforce PKCE from 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. */ @@ -444,6 +456,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 @@ -920,6 +938,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)) { @@ -1101,6 +1129,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/routes/auth.ts b/src/workos/routes/auth.ts index 58063d7..d311377 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,14 @@ import { findUserByEmail, requireEmailString, emailsMatch, + activeOrganizationsFor as activeOrganizationsForUser, + startLoginSession, } from '../helpers.js'; import { renderConfiguredJwtTemplate } from '../jwt-template.js'; import type { EventBus } from '../event-bus.js'; import type { WorkOSInvitation, WorkOSSSOAuthorization, WorkOSUser } from '../entities.js'; import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; +import type { ConnectAuthorizeRequest } from '../authkit-oauth.js'; import { renderLoginPage, renderDeviceVerifyPage, @@ -97,25 +99,19 @@ 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; - } + const activeOrganizationsFor = (userId: string) => activeOrganizationsForUser(ws, userId); /** * The authorize parameters a page carries through its form, so the POST that follows finishes @@ -128,9 +124,24 @@ 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 key = `${STORE_KEY_PREFIXES.connectAuthorize}${token}`; + const request = store.getData(key); + if (request && !isExpired(request.expires_at)) return request; + if (request) store.deleteData(key); + throw new OauthApiError(400, 'invalid_request', 'The authorization request has expired or is invalid.'); + } + function resolveAndRedirect(c: any, params: AuthorizeParams) { const { redirectUri, @@ -418,6 +429,7 @@ export function authRoutes(ctx: RouteContext): void { } } + const connectRequest = params.connectRequest ? loadConnectRequest(params.connectRequest) : null; const authCode = ws.authCodes.insert({ user_id: user.id, organization_id: organizationId, @@ -427,9 +439,12 @@ 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 } : undefined, auth_method: login?.auth_method ?? null, step_up_method: login?.step_up_method ?? null, }); + if (params.connectRequest) store.deleteData(`${STORE_KEY_PREFIXES.connectAuthorize}${params.connectRequest}`); // One code per verified login: the token is spent once it has minted something. Deleted // rather than overwritten, so a long-lived emulator does not keep one entry per login. if (loginToken) store.deleteData(`${STORE_KEY_PREFIXES.interactiveLogin}${loginToken}`); @@ -442,12 +457,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) { @@ -469,6 +488,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({ @@ -496,28 +516,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, }); }); @@ -835,9 +859,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 }, @@ -1064,7 +1089,9 @@ export function authRoutes(ctx: RouteContext): void { } const refreshToken = ws.refreshTokens.findOneBy('token', token); - if (!refreshToken) { + // 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 (!refreshToken || refreshToken.connect) { throw new OauthApiError(400, 'invalid_grant', 'Invalid refresh token.'); } if (isExpired(refreshToken.expires_at)) { @@ -1331,34 +1358,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..fed826b --- /dev/null +++ b/src/workos/routes/authkit-oauth-server.spec.ts @@ -0,0 +1,1211 @@ +/** + * 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({ + 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('keeps the plain errors for what it cannot report on a callback', async () => { + const { client_id } = await register(); + const invalid: Record[] = [ + { client_id: '' }, + { client_id, redirect_uri: '' }, + { client_id, response_type: 'token' }, + { client_id, redirect_uri: 'http://localhost:33418/other' }, + ]; + for (const params of invalid) { + const res = await request(authorizeUrl(params)); + expect(res.status).toBe(400); + } + 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'); + expect((await request(authorizeUrl({ client_id: 'client_m2m' }))).status).toBe(400); + }); + + it('requires PKCE of a public client, reporting it on the callback with the state', async () => { + const { client_id } = await register(); + const { callback: url } = await signIn({ client_id, state: 'keep' }); + expect(`${url.origin}${url.pathname}`).toBe(callback); + expect(url.searchParams.get('error')).toBe('invalid_request'); + expect(url.searchParams.get('error_description')).toContain('code_challenge is required'); + expect(url.searchParams.get('state')).toBe('keep'); + }); + + it('does not require PKCE of a confidential client, but validates it when sent', async () => { + const { client_id } = 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_/); + const bad = await signIn({ client_id, code_challenge: 'short', code_challenge_method: 'S256' }); + expect(bad.callback.searchParams.get('error')).toBe('invalid_request'); + }); + + it('supports only S256', async () => { + const { client_id } = await register(); + const { challenge } = pkce(); + const plain = await signIn({ client_id, code_challenge: challenge, code_challenge_method: 'plain' }); + expect(plain.callback.searchParams.get('error')).toBe('invalid_request'); + const orphan = await signIn({ client_id, code_challenge_method: 'S256' }); + expect(orphan.callback.searchParams.get('error')).toBe('invalid_request'); + }); + + 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' }; + const scope = await signIn({ ...base, scope: 'openid email' }); + expect(scope.callback.searchParams.get('error')).toBe('invalid_scope'); + const target = await signIn({ ...base, resource: 'https://mcp.example.test/#frag' }); + expect(target.callback.searchParams.get('error')).toBe('invalid_target'); + const relative = await signIn({ ...base, resource: 'mcp/relative' }); + expect(relative.callback.searchParams.get('error')).toBe('invalid_target'); + }); + + 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 })); + 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, 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', resource: 'https://mcp.example.test' }); + expect(res.status).toBe(200); + const body = await json(res); + expect(body).toMatchObject({ token_type: 'Bearer', expires_in: 3600, scope: 'openid profile' }); + expect(body.access_token.split('.')).toHaveLength(3); + 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('openid email'); + expect(claims.sid).toMatch(/^session_/); + expect(claims.jti).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect(claims.exp - claims.iat).toBe(3600); + 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('defaults the scope to the application scopes', async () => { + const { client_id } = await register({ scope: 'openid profile' }); + const body = await json(await exchange(client_id)); + expect(body.scope).toBe('openid profile'); + }); + + it('leaves the code for a retry when no verifier is sent, but spends it on a wrong one', async () => { + const { client_id } = await register(); + const first = await authorizeCode(client_id); + const grant = { grant_type: 'authorization_code', client_id, code: first.code, redirect_uri: callback }; + + const missing = await token(grant); + expect(missing.status).toBe(400); + expect((await json(missing)).error).toBe('invalid_request'); + expect((await token({ ...grant, code_verifier: first.verifier })).status).toBe(200); + + // A wrong verifier is a failed attempt, and the code is gone even for the right one afterwards. + const second = await authorizeCode(client_id); + const attempt = { ...grant, code: second.code }; + const wrong = await token({ ...attempt, code_verifier: pkce().verifier }); + expect(wrong.status).toBe(400); + expect((await json(wrong)).error).toBe('invalid_grant'); + expect((await json(await token({ ...attempt, code_verifier: second.verifier }))).error).toBe('invalid_grant'); + }); + + it('enforces the RFC 7636 §4.1 verifier shape, spending the code when it is malformed', async () => { + const { client_id } = await register(); + for (const bad of ['short', 'a'.repeat(129), `${'a'.repeat(42)}!`, `${'a'.repeat(42)} `]) { + const { code } = await authorizeCode(client_id); + const res = await token({ + grant_type: 'authorization_code', + client_id, + code, + redirect_uri: callback, + code_verifier: bad, + }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + expect(ws.authCodes.findOneBy('code', code)).toBeUndefined(); + } + // The boundaries are valid: 43 and 128 characters of the unreserved set. + for (const verifier of ['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, in the way it registered, 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 }; + + const noSecret = await token({ ...grant, client_id }); + expect(noSecret.status).toBe(401); + expect((await json(noSecret)).error_description).toBe('Missing authorization header.'); + const wrong = await token({ ...grant, client_id, client_secret: 'secret_wrong' }); + expect(wrong.status).toBe(401); + expect((await json(wrong)).error).toBe('invalid_client'); + // Registered for Basic, so the right secret in the body is refused too. + const viaPost = await token({ ...grant, client_id, client_secret: client_secret! }); + expect(viaPost.status).toBe(401); + expect(viaPost.headers.get('www-authenticate')).toBe('Basic realm="AuthKit"'); + 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'); + + // That failed attempt spent the code; a fresh one redeems with Basic. + const fresh = await authorizeCode(client_id); + const ok = await token( + { ...grant, code: fresh.code, code_verifier: fresh.verifier }, + { 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 client_secret_post only for a client registered for it', async () => { + const { client_id, client_secret } = await register({ token_endpoint_auth_method: 'client_secret_post' }); + const first = await authorizeCode(client_id); + const grant = { grant_type: 'authorization_code', client_id, redirect_uri: callback }; + const basic = Buffer.from(`${client_id}:${client_secret}`).toString('base64'); + const viaBasic = await token( + { ...grant, code: first.code, code_verifier: first.verifier }, + { Authorization: `Basic ${basic}` }, + ); + expect(viaBasic.status).toBe(401); + const viaPost = await token({ + ...grant, + code: first.code, + code_verifier: first.verifier, + client_secret: client_secret!, + }); + expect(viaPost.status).toBe(200); + }); + + it('holds a public client that presents a secret to it', async () => { + const { client_id } = await register(); + const { code, verifier } = await authorizeCode(client_id); + const res = await token({ + grant_type: 'authorization_code', + client_id, + client_secret: 'secret_made_up', + code, + redirect_uri: callback, + code_verifier: verifier, + }); + expect(res.status).toBe(401); + }); + + 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, + }; + expect((await token(grant)).status).toBe(400); + expect((await token({ ...grant, client_secret: 'secret_standalone_pkce' })).status).toBe(200); + }); + + it('refuses client_credentials to a public client without a secret', async () => { + const { client_id } = await register(); + const res = await token({ grant_type: 'client_credentials', client_id }); + expect(res.status).toBe(401); + expect((await json(res)).error).toBe('invalid_client'); + }); + + it('narrows scope at exchange but never widens it', async () => { + const { client_id } = await register(); + const narrowed = await json(await exchange(client_id, { scope: 'openid' }, { scope: 'openid profile' })); + expect(narrowed.scope).toBe('openid'); + const widened = await exchange(client_id, { scope: 'openid email' }, { scope: 'openid profile' }); + expect(widened.status).toBe(400); + expect((await json(widened)).error).toBe('invalid_scope'); + }); + }); + + 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 unregistered or absent resource', async () => { + const { client_id } = await register(); + const unregistered = await json( + await exchange(client_id, {}, { resource: 'https://not-registered.example.test' }), + ); + expect(server.jwt.verify(unregistered.access_token).aud).toBe(client_id); + 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, {}, { resource: 'https://not-registered.example.test' }), + ); + 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('lets the token request restate or omit the authorized 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' }); + expect((await json(malformed)).error).toBe('invalid_target'); + + // A grant authorized for no resource cannot pick one up at the token endpoint, registered or not. + for (const resource of ['https://mcp.example.test', 'https://other.example.test']) { + const late = await exchange(client_id, { resource }); + expect(late.status).toBe(400); + expect((await json(late)).error).toBe('invalid_target'); + } + const plain = await json(await exchange(client_id)); + expect(server.jwt.verify(plain.access_token).aud).toBe(client_id); + }); + + 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', resource: 'https://mcp.example.test' }), + ); + + const res = await refresh(client_id, first.refresh_token); + expect(res.status).toBe(200); + const second = await json(res); + expect(second.refresh_token).toMatch(/^ref_/); + expect(second.refresh_token).not.toBe(first.refresh_token); + expect(second.scope).toBe('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('narrows scope on refresh, and the narrowing sticks', async () => { + const { client_id } = await register(); + const first = await json(await exchange(client_id, {}, { scope: 'openid profile email' })); + const narrowed = await json(await refresh(client_id, first.refresh_token, { scope: 'openid' })); + expect(narrowed.scope).toBe('openid'); + const widened = await refresh(client_id, narrowed.refresh_token, { scope: 'openid profile' }); + expect(widened.status).toBe(400); + expect((await json(widened)).error).toBe('invalid_scope'); + }); + + 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('falls back on the next mint when the bound resource is deleted', 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 json(await refresh(client_id, first.refresh_token)); + expect(server.jwt.verify(second.access_token).aud).toBe(client_id); + }); + + 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); + expect((await json(expired)).error_description).toBe('Refresh token has expired.'); + + 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('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, + }); + expect(stray.connect).toBeUndefined(); + expect((await json(await refresh(client_id, 'ref_authkit'))).error).toBe('invalid_grant'); + }); + }); + + 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({ 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({ 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('gives a client registered without refresh_token no refresh token, and refuses the grant', async () => { + const { client_id } = await register({ grant_types: ['authorization_code'] }); + const { code, verifier } = await codeFor(client_id); + const body = await json(await redeem(client_id, code, verifier)); + expect(body.access_token).toBeTruthy(); + expect(body).not.toHaveProperty('refresh_token'); + + 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: 'ref_any' }).toString(), + }); + expect(refresh.status).toBe(400); + expect((await json(refresh)).error).toBe('unauthorized_client'); + }); + + it('refuses authorization_code to a client registered only for refresh_token', async () => { + const { client_id } = await register({ grant_types: ['refresh_token'] }); + const { code, verifier } = await codeFor(client_id); + const res = await redeem(client_id, code, verifier); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('unauthorized_client'); + }); + + 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({ + 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..84f2aa0 --- /dev/null +++ b/src/workos/routes/authkit-oauth.spec.ts @@ -0,0 +1,447 @@ +/** + * 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); + }); + + 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), + }); + 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 to client_secret_basic, as RFC 7591 §2 does', async () => { + const body = await json(await register({ redirect_uris: [callback] })); + expect(body.token_endpoint_auth_method).toBe('client_secret_basic'); + expect(body.client_secret).toMatch(/^secret_/); + expect(body.grant_types).toEqual(['authorization_code']); + // No scope asked for: the whole standard set. + expect(body.scope).toBe('email offline_access openid profile'); + }); + + 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('stores what the client registered for, so the token endpoint can hold it to it', 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 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: 'openid admin:everything' }, + { 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('