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 | const { publicKey, privateKey } = await generateKeyPair("RS256"); | 19 | const { publicKey, privateKey } = await generateKeyPair("RS256"); |
| 20 | const jwk = await exportJWK(publicKey); | 20 | const jwk = await exportJWK(publicKey); |
| 21 | const jwks = createLocalJWKSet({ keys: [{ ...jwk, kid: "test-key" }] }); | 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 | const sign = (claims: Record<string, unknown>, expiration: string | number = "5m") => | 25 | const sign = (claims: Record<string, unknown>, expiration: string | number = "5m") => |
| 24 | new SignJWT(claims) | 26 | new SignJWT(claims) |
| 25 | .setProtectedHeader({ alg: "RS256", kid: "test-key" }) | 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 | // The protected HTTP API accepts THREE credential forms on the `Authorization` | 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 | // `executor login`), then a WorkOS API key, then, with no header, the | 6 | // `executor login`), then a WorkOS API key, then, with no header, the |
| 7 | // sealed-session cookie. This module supplies the JWT-verification config | 7 | // sealed-session cookie. This module supplies the JWKS the JWT branch verifies |
| 8 | // (JWKS + issuer + audience) the JWT branch needs. | 8 | // against. |
| 9 | // | 9 | // |
| 10 | // It mirrors the MCP plane's setup (`mcp/auth.ts`): the SAME AuthKit JWKS, | 10 | // CRITICAL: a device-login token is a WorkOS *user_management* access token, |
| 11 | // issuer (`MCP_AUTHKIT_DOMAIN`), and audience (`WORKOS_CLIENT_ID`). A device | 11 | // signed by the SSO keyset served at `<workos-api>/sso/jwks/<clientId>` with NO |
| 12 | // access token minted by AuthKit is byte-for-byte the same kind of token the | 12 | // audience and a user_management issuer. That is a DIFFERENT key domain than the |
| 13 | // MCP plane already verifies, so a CLI can hold ONE credential for both planes. | 13 | // MCP `/oauth2` tokens (verified via the AuthKit `/oauth2/jwks`): the device |
| 14 | // | 14 | // token's key is not in the oauth2 JWKS, so we must verify it against the |
| 15 | // This is the `cloudflare:workers`-reading leaf; `workos-auth-provider.ts` | 15 | // client-scoped SSO JWKS instead (signature + expiry; see |
| 16 | // stays env-free and receives this config as a plain value, so its node-pool | 16 | // `verifyWorkosUserManagementToken`). `WORKOS_API_URL` is api.workos.com in |
| 17 | // resolver tests can inject a local JWKS instead. | 17 | // production and the WorkOS emulator in tests. |
| 18 | // --------------------------------------------------------------------------- | 18 | // --------------------------------------------------------------------------- |
| 19 | 19 | ||
| 20 | import { env } from "cloudflare:workers"; | 20 | import { env } from "cloudflare:workers"; |
| @@ -22,14 +22,10 @@ import { env } from "cloudflare:workers"; · 1 unmodified line | |||
| 22 | import { createCachedRemoteJWKSet } from "./jwks-cache"; | 22 | import { createCachedRemoteJWKSet } from "./jwks-cache"; |
| 23 | import type { JwtBearerConfig } from "./workos-auth-provider"; | 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 | // Module-scope cache, same rationale as `mcp/auth.ts`: one JWKS fetch per | 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) | 28 | // isolate-hour rather than one per cold isolate. |
| 29 | // are independent module scopes hitting the same upstream; the 1h TTL keeps | ||
| 30 | // the duplication cheap. | ||
| 31 | export const workosApiJwtBearerConfig: JwtBearerConfig = { | 29 | export const workosApiJwtBearerConfig: JwtBearerConfig = { |
| 32 | jwks: createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)), | 30 | jwks: createCachedRemoteJWKSet(new URL(`${WORKOS_API_BASE}/sso/jwks/${env.WORKOS_CLIENT_ID}`)), |
| 33 | issuer: AUTHKIT_DOMAIN, | ||
| 34 | audience: env.WORKOS_CLIENT_ID, | ||
| 35 | }; | 31 | }; |
| @@ -51,19 +51,19 @@ import { UserStoreService } from "./context"; · 50 unmodified lines | |||
| 51 | import { sealedSessionDisplayName } from "./middleware"; | 51 | import { sealedSessionDisplayName } from "./middleware"; |
| 52 | import type { UserStoreError, WorkOSError } from "./errors"; | 52 | import type { UserStoreError, WorkOSError } from "./errors"; |
| 53 | import { WorkOSClient } from "./workos"; | 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: | 57 | * The config the bearer-JWT branch needs to verify a WorkOS device-login |
| 58 | * the AuthKit JWKS resolver plus the issuer + audience to assert. Passed in | 58 | * (user_management) access token: the client-scoped SSO JWKS resolver. Issuer |
| 59 | * as a plain value so this module stays `cloudflare:workers`-free and the | 59 | * and audience are NOT pinned (the client-scoped JWKS binds the token to this |
| 60 | * node-pool resolver tests can inject a local JWKS. Production supplies | 60 | * app; user_management tokens carry no audience and an app-specific issuer) and |
| 61 | * {@link workosApiJwtBearerConfig} (built from `cloudflare:workers` env). | 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 | export interface JwtBearerConfig { | 65 | export interface JwtBearerConfig { |
| 64 | readonly jwks: JWTVerifyGetKey; | 66 | readonly jwks: JWTVerifyGetKey; |
| 65 | readonly issuer: string; | ||
| 66 | readonly audience: string; | ||
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | // The exact machine codes + messages each rejected path has always emitted. | 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 | const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; | 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 | 108 | * Resolve a WorkOS device-login (user_management) access token into a protected |
| 109 | * `Principal`. Verifies the token against AuthKit's JWKS (issuer + audience), | 109 | * `Principal`. Verifies the token's signature + expiry against the client-scoped |
| 110 | * then live-checks org membership, exactly like the api-key path. The | 110 | * SSO JWKS, then live-checks org membership, exactly like the api-key path. The |
| 111 | * `org_id` claim must be present (a token with no org context is rejected as | 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 | 112 | * `NoOrganization`). NOTE: this is a different WorkOS token domain than the MCP |
| 113 | * `cloudflare:workers`-free verifier so a CLI holds ONE credential for both | 113 | * `/oauth2` tokens (different keyset, no audience), so it does NOT reuse the MCP |
| 114 | * the `/api/*` and `/mcp` planes. | 114 | * verifier or its JWKS, audience, and issuer. |
| 115 | */ | 115 | */ |
| 116 | const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => | 116 | const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => |
| 117 | Effect.gen(function* () { | 117 | Effect.gen(function* () { |
| 118 | const verified = yield* verifyWorkOSMcpAccessToken(token, jwt.jwks, { | 118 | const verified = yield* verifyWorkosUserManagementToken(token, jwt.jwks).pipe( |
| 119 | issuer: jwt.issuer, | ||
| 120 | audience: jwt.audience, | ||
| 121 | }).pipe( | ||
| 122 | Effect.catchTag("McpJwtVerificationError", (error) => | 119 | Effect.catchTag("McpJwtVerificationError", (error) => |
| 123 | Effect.fail( | 120 | Effect.fail( |
| 124 | error.reason === "system" | 121 | error.reason === "system" |
| @@ -123,3 +123,25 @@ export const verifyWorkOSMcpAccessToken = ( · 122 unmodified lines | |||
| 123 | }); | 123 | }); |
| 124 | return verified; | 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 | }); | ||