History

fix: verify device-login tokens against the WorkOS user_management keyset

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).

Rhys Sullivan committed 4 months ago

Commit 95de8a21 parent c8eefaf4 files · +55 −38Browse files

Unified · Split

4 files
  1. apps/cloud/src/api/protected-jwt-auth.node.test.ts +3 −1
  2. apps/cloud/src/auth/api-jwt-bearer.ts +15 −19
  3. apps/cloud/src/auth/workos-auth-provider.ts +15 −18
  4. apps/cloud/src/mcp/jwt.ts +22 −0
apps/cloud/src/api/protected-jwt-auth.node.test.ts+3−1
@@ -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" })
apps/cloud/src/auth/api-jwt-bearer.ts+15−19
@@ -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, the6// `executor login`), then a WorkOS API key, then, with no header, the
7// sealed-session cookie. This module supplies the JWT-verification config7// 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 device11// 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 the12// 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-pool16// `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
20import { env } from "cloudflare:workers";20import { env } from "cloudflare:workers";
@@ -22,14 +22,10 @@ import { env } from "cloudflare:workers"; · 1 unmodified line
22import { createCachedRemoteJWKSet } from "./jwks-cache";22import { createCachedRemoteJWKSet } from "./jwks-cache";
23import type { JwtBearerConfig } from "./workos-auth-provider";23import type { JwtBearerConfig } from "./workos-auth-provider";
24 24
25const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh";25const 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 per27// 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.
31export const workosApiJwtBearerConfig: JwtBearerConfig = {29export 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};
apps/cloud/src/auth/workos-auth-provider.ts+15−18
@@ -51,19 +51,19 @@ import { UserStoreService } from "./context"; · 50 unmodified lines
51import { sealedSessionDisplayName } from "./middleware";51import { sealedSessionDisplayName } from "./middleware";
52import type { UserStoreError, WorkOSError } from "./errors";52import type { UserStoreError, WorkOSError } from "./errors";
53import { WorkOSClient } from "./workos";53import { WorkOSClient } from "./workos";
54import { verifyWorkOSMcpAccessToken } from "../mcp/jwt";54import { 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 in58 * (user_management) access token: the client-scoped SSO JWKS resolver. Issuer
59 * as a plain value so this module stays `cloudflare:workers`-free and the59 * 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 supplies60 * 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 */
63export interface JwtBearerConfig {65export 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
105const looksLikeJwt = (token: string): boolean => token.split(".").length === 3;105const looksLikeJwt = (token: string): boolean => token.split(".").length === 3;
106 106
107/**107/**
108 * Resolve a WorkOS access-token JWT (CLI `executor login`) into a protected108 * 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. The110 * 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 as111 * `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 same112 * `NoOrganization`). NOTE: this is a different WorkOS token domain than the MCP
113 * `cloudflare:workers`-free verifier so a CLI holds ONE credential for both113 * `/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 */
116const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>116const 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"
apps/cloud/src/mcp/jwt.ts+22−0
@@ -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.
134export 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 });