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
1919 const { publicKey, privateKey } = await generateKeyPair("RS256");
2020 const jwk = await exportJWK(publicKey);
2121 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 };
2325 const sign = (claims: Record<string, unknown>, expiration: string | number = "5m") =>
2426 new SignJWT(claims)
2527 .setProtectedHeader({ alg: "RS256", kid: "test-key" })
apps/cloud/src/auth/api-jwt-bearer.ts+15−19
@@ -1,20 +1,20 @@
11 // ---------------------------------------------------------------------------
2−// The `/api/*` plane's WorkOS JWT bearer config.
2+// The `/api/*` plane's WorkOS device-login (user_management) JWT bearer config.
33 //
44 // 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
66 // `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.
99 //
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.
1818 // ---------------------------------------------------------------------------
1919
2020 import { env } from "cloudflare:workers";
@@ -22,14 +22,10 @@ import { env } from "cloudflare:workers"; · 1 unmodified line
2222 import { createCachedRemoteJWKSet } from "./jwks-cache";
2323 import type { JwtBearerConfig } from "./workos-auth-provider";
2424
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(/\/+$/, "");
2626
2727 // 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.
3129 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}`)),
3531 };
apps/cloud/src/auth/workos-auth-provider.ts+15−18
@@ -51,19 +51,19 @@ import { UserStoreService } from "./context"; · 50 unmodified lines
5151 import { sealedSessionDisplayName } from "./middleware";
5252 import type { UserStoreError, WorkOSError } from "./errors";
5353 import { WorkOSClient } from "./workos";
54−import { verifyWorkOSMcpAccessToken } from "../mcp/jwt";
54+import { verifyWorkosUserManagementToken } from "../mcp/jwt";
5555
5656 /**
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}.
6264 */
6365 export interface JwtBearerConfig {
6466 readonly jwks: JWTVerifyGetKey;
65− readonly issuer: string;
66− readonly audience: string;
6767 }
6868
6969 // The exact machine codes + messages each rejected path has always emitted.
@@ -105,20 +105,17 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 35 unmodified lines
105105 const looksLikeJwt = (token: string): boolean => token.split(".").length === 3;
106106
107107 /**
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
111111 * `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.
115115 */
116116 const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>
117117 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(
122119 Effect.catchTag("McpJwtVerificationError", (error) =>
123120 Effect.fail(
124121 error.reason === "system"
apps/cloud/src/mcp/jwt.ts+22−0
@@ -123,3 +123,25 @@ export const verifyWorkOSMcpAccessToken = ( · 122 unmodified lines
123123 });
124124 return verified;
125125 });
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+ });