The /api JWT branch reused the MCP /oauth2 verification (AuthKit /oauth2/jwks, issuer + audience pinned), but a device-login token is a WorkOS user_management access token: signed by the SSO keyset (<workos-api>/sso/jwks/<clientId>), with no audience and an app-specific issuer. So real-WorkOS device tokens 401'd on /api (key not in the oauth2 JWKS) while login + whoami worked. Verify the user_management token against the client-scoped SSO JWKS by signature + expiry only (no issuer/audience pin); live org-membership is still re-checked. Caught by a real executor.sh login; the e2e missed it because the emulator signed all tokens with one keypair (fixed in the emulate fork to split the keysets).
Unified · Split
| @@ -19,7 +19,9 @@ const makeJwt = async () => { · 18 unmodified lines | ||
| 19 | 19 | const { publicKey, privateKey } = await generateKeyPair("RS256"); |
| 20 | 20 | const jwk = await exportJWK(publicKey); |
| 21 | 21 | const jwks = createLocalJWKSet({ keys: [{ ...jwk, kid: "test-key" }] }); |
| 22 | − const config: JwtBearerConfig = { jwks, issuer, audience }; | |
| 22 | + // Device-login tokens are verified by signature alone (client-scoped SSO | |
| 23 | + // JWKS); issuer/audience are not pinned. | |
| 24 | + const config: JwtBearerConfig = { jwks }; | |
| 23 | 25 | const sign = (claims: Record<string, unknown>, expiration: string | number = "5m") => |
| 24 | 26 | new SignJWT(claims) |
| 25 | 27 | .setProtectedHeader({ alg: "RS256", kid: "test-key" }) |
| @@ -1,20 +1,20 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | −// The `/api/*` plane's WorkOS JWT bearer config. | |
| 2 | +// The `/api/*` plane's WorkOS device-login (user_management) JWT bearer config. | |
| 3 | 3 | // |
| 4 | 4 | // The protected HTTP API accepts THREE credential forms on the `Authorization` |
| 5 | −// header, in this precedence: a WorkOS access-token JWT (CLI device-login, | |
| 5 | +// header, in this precedence: a WorkOS device-login access token (JWT, from | |
| 6 | 6 | // `executor login`), then a WorkOS API key, then, with no header, the |
| 7 | −// sealed-session cookie. This module supplies the JWT-verification config | |
| 8 | −// (JWKS + issuer + audience) the JWT branch needs. | |
| 7 | +// sealed-session cookie. This module supplies the JWKS the JWT branch verifies | |
| 8 | +// against. | |
| 9 | 9 | // |
| 10 | −// It mirrors the MCP plane's setup (`mcp/auth.ts`): the SAME AuthKit JWKS, | |
| 11 | −// issuer (`MCP_AUTHKIT_DOMAIN`), and audience (`WORKOS_CLIENT_ID`). A device | |
| 12 | −// access token minted by AuthKit is byte-for-byte the same kind of token the | |
| 13 | −// MCP plane already verifies, so a CLI can hold ONE credential for both planes. | |
| 14 | −// | |
| 15 | −// This is the `cloudflare:workers`-reading leaf; `workos-auth-provider.ts` | |
| 16 | −// stays env-free and receives this config as a plain value, so its node-pool | |
| 17 | −// resolver tests can inject a local JWKS instead. | |
| 10 | +// CRITICAL: a device-login token is a WorkOS *user_management* access token, | |
| 11 | +// signed by the SSO keyset served at `<workos-api>/sso/jwks/<clientId>` with NO | |
| 12 | +// audience and a user_management issuer. That is a DIFFERENT key domain than the | |
| 13 | +// MCP `/oauth2` tokens (verified via the AuthKit `/oauth2/jwks`): the device | |
| 14 | +// token's key is not in the oauth2 JWKS, so we must verify it against the | |
| 15 | +// client-scoped SSO JWKS instead (signature + expiry; see | |
| 16 | +// `verifyWorkosUserManagementToken`). `WORKOS_API_URL` is api.workos.com in | |
| 17 | +// production and the WorkOS emulator in tests. | |
| 18 | 18 | // --------------------------------------------------------------------------- |
| 19 | 19 | |
| 20 | 20 | import { env } from "cloudflare:workers"; |
| @@ -22,14 +22,10 @@ import { env } from "cloudflare:workers"; · 1 unmodified line | ||
| 22 | 22 | import { createCachedRemoteJWKSet } from "./jwks-cache"; |
| 23 | 23 | import type { JwtBearerConfig } from "./workos-auth-provider"; |
| 24 | 24 | |
| 25 | −const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh"; | |
| 25 | +const WORKOS_API_BASE = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, ""); | |
| 26 | 26 | |
| 27 | 27 | // Module-scope cache, same rationale as `mcp/auth.ts`: one JWKS fetch per |
| 28 | −// isolate-hour rather than one per cold isolate. The two caches (here + MCP) | |
| 29 | −// are independent module scopes hitting the same upstream; the 1h TTL keeps | |
| 30 | −// the duplication cheap. | |
| 28 | +// isolate-hour rather than one per cold isolate. | |
| 31 | 29 | export const workosApiJwtBearerConfig: JwtBearerConfig = { |
| 32 | − jwks: createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)), | |
| 33 | − issuer: AUTHKIT_DOMAIN, | |
| 34 | − audience: env.WORKOS_CLIENT_ID, | |
| 30 | + jwks: createCachedRemoteJWKSet(new URL(`${WORKOS_API_BASE}/sso/jwks/${env.WORKOS_CLIENT_ID}`)), | |
| 35 | 31 | }; |
| @@ -51,19 +51,19 @@ import { UserStoreService } from "./context"; · 50 unmodified lines | ||
| 51 | 51 | import { sealedSessionDisplayName } from "./middleware"; |
| 52 | 52 | import type { UserStoreError, WorkOSError } from "./errors"; |
| 53 | 53 | import { WorkOSClient } from "./workos"; |
| 54 | −import { verifyWorkOSMcpAccessToken } from "../mcp/jwt"; | |
| 54 | +import { verifyWorkosUserManagementToken } from "../mcp/jwt"; | |
| 55 | 55 | |
| 56 | 56 | /** |
| 57 | − * The config the bearer-JWT branch needs to verify a WorkOS access token: | |
| 58 | − * the AuthKit JWKS resolver plus the issuer + audience to assert. Passed in | |
| 59 | − * as a plain value so this module stays `cloudflare:workers`-free and the | |
| 60 | − * node-pool resolver tests can inject a local JWKS. Production supplies | |
| 61 | − * {@link workosApiJwtBearerConfig} (built from `cloudflare:workers` env). | |
| 57 | + * The config the bearer-JWT branch needs to verify a WorkOS device-login | |
| 58 | + * (user_management) access token: the client-scoped SSO JWKS resolver. Issuer | |
| 59 | + * and audience are NOT pinned (the client-scoped JWKS binds the token to this | |
| 60 | + * app; user_management tokens carry no audience and an app-specific issuer) and | |
| 61 | + * org membership is re-checked live downstream. Passed in as a plain value so | |
| 62 | + * this module stays `cloudflare:workers`-free and the node-pool resolver tests | |
| 63 | + * can inject a local JWKS. Production supplies {@link workosApiJwtBearerConfig}. | |
| 62 | 64 | */ |
| 63 | 65 | export interface JwtBearerConfig { |
| 64 | 66 | readonly jwks: JWTVerifyGetKey; |
| 65 | − readonly issuer: string; | |
| 66 | − readonly audience: string; | |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | 69 | // The exact machine codes + messages each rejected path has always emitted. |
| @@ -105,20 +105,17 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 35 unmodified lines | ||
| 105 | 105 | const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; |
| 106 | 106 | |
| 107 | 107 | /** |
| 108 | − * Resolve a WorkOS access-token JWT (CLI `executor login`) into a protected | |
| 109 | − * `Principal`. Verifies the token against AuthKit's JWKS (issuer + audience), | |
| 110 | − * then live-checks org membership, exactly like the api-key path. The | |
| 108 | + * Resolve a WorkOS device-login (user_management) access token into a protected | |
| 109 | + * `Principal`. Verifies the token's signature + expiry against the client-scoped | |
| 110 | + * SSO JWKS, then live-checks org membership, exactly like the api-key path. The | |
| 111 | 111 | * `org_id` claim must be present (a token with no org context is rejected as |
| 112 | − * `NoOrganization`). Mirrors the MCP plane's JWT verify, reusing the same | |
| 113 | − * `cloudflare:workers`-free verifier so a CLI holds ONE credential for both | |
| 114 | − * the `/api/*` and `/mcp` planes. | |
| 112 | + * `NoOrganization`). NOTE: this is a different WorkOS token domain than the MCP | |
| 113 | + * `/oauth2` tokens (different keyset, no audience), so it does NOT reuse the MCP | |
| 114 | + * verifier or its JWKS, audience, and issuer. | |
| 115 | 115 | */ |
| 116 | 116 | const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => |
| 117 | 117 | Effect.gen(function* () { |
| 118 | − const verified = yield* verifyWorkOSMcpAccessToken(token, jwt.jwks, { | |
| 119 | − issuer: jwt.issuer, | |
| 120 | − audience: jwt.audience, | |
| 121 | − }).pipe( | |
| 118 | + const verified = yield* verifyWorkosUserManagementToken(token, jwt.jwks).pipe( | |
| 122 | 119 | Effect.catchTag("McpJwtVerificationError", (error) => |
| 123 | 120 | Effect.fail( |
| 124 | 121 | error.reason === "system" |
| @@ -123,3 +123,25 @@ export const verifyWorkOSMcpAccessToken = ( · 122 unmodified lines | ||
| 123 | 123 | }); |
| 124 | 124 | return verified; |
| 125 | 125 | }); |
| 126 | + | |
| 127 | +// Verify a WorkOS user_management access token (the CLI `executor login` device | |
| 128 | +// flow). Unlike the MCP /oauth2 tokens, these are signed by the SSO keyset | |
| 129 | +// (`/sso/jwks/<clientId>`), carry NO audience, and their issuer is the | |
| 130 | +// AuthKit user_management URL (which varies by app), so we don't pin issuer or | |
| 131 | +// audience: the client-scoped JWKS already binds the token to this application, | |
| 132 | +// and live org-membership is re-checked downstream. Signature + expiry are | |
| 133 | +// enforced by jose. | |
| 134 | +export const verifyWorkosUserManagementToken = (token: string, jwks: JWTVerifyGetKey) => | |
| 135 | + Effect.gen(function* () { | |
| 136 | + const { payload } = yield* Effect.tryPromise({ | |
| 137 | + try: () => jwtVerify(token, jwks), | |
| 138 | + catch: classifyJwtVerificationError, | |
| 139 | + }).pipe(withJwtVerificationSpan); | |
| 140 | + | |
| 141 | + if (!payload.sub) return null; | |
| 142 | + | |
| 143 | + return { | |
| 144 | + accountId: payload.sub, | |
| 145 | + organizationId: (payload.org_id as string | undefined) ?? null, | |
| 146 | + } satisfies VerifiedToken; | |
| 147 | + }); | |