Skip to content

Commit 5f08d68

Browse files
authored
Fix: accept WorkOS device-login tokens on the /api plane (wrong JWKS) (#1079)
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).
1 parent c8eefaf commit 5f08d68

4 files changed

Lines changed: 55 additions & 38 deletions

File tree

apps/cloud/src/api/protected-jwt-auth.node.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ const makeJwt = async () => {
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" })
Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,31 @@
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";
2121

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

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,19 @@ import { UserStoreService } from "./context";
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 = {
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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,25 @@ export const verifyWorkOSMcpAccessToken = (
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+
});

0 commit comments

Comments
 (0)