From ecaf7a8f638d81099da67fb7053c83bdb9957f0e Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 4 Aug 2026 14:33:48 -0400 Subject: [PATCH 1/3] feat(jwt): apply JWT templates and support a pinned signing key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer evaluating the emulator for API integration tests hit two blockers. `PUT /user_management/jwt_template` accepted a template and echoed it back, but the claims never reached a signed token — the endpoint looked implemented while doing nothing, which is worse than not offering it at all. The stored shape was invented too: the real API takes `content`, a template string, and has no `custom_claims` field anywhere in the spec. They also asked for a per-environment test signing key. The emulator generated a fresh keypair on every boot, so a restart invalidated the published JWKS along with every token already issued, and `iss` moved with the port. Pinning the key and the issuer lets a verifier treat both as constants and keeps tokens valid across a restart, so the service under test needs no environment-specific auth branch — only its usual JWKS fetch. Templates are validated when set and when seeded, so a bad one fails the boot rather than the first sign-in, and a template that renders past the 3072-byte limit fails the authenticate call by name instead of silently dropping its claims. BREAKING CHANGE: `PUT /user_management/jwt_template` now requires `content` and rejects `custom_claims` with a 422 naming the replacement. `custom_claims` was emulator-only and never reached a token, so no working setup depends on it. `GET` returns the spec shape (`content`, `created_at`, `updated_at`) and 404s before a template is set. --- README.md | 133 ++++++++ src/cli.ts | 45 +++ src/core/index.ts | 2 +- src/core/jwt.spec.ts | 82 +++++ src/core/jwt.ts | 105 ++++++- src/core/server.ts | 12 +- src/index.ts | 23 +- src/workos/config-validator.ts | 17 + src/workos/entities.ts | 12 + src/workos/index.ts | 37 ++- src/workos/jwt-template-integration.spec.ts | 240 ++++++++++++++ src/workos/jwt-template.spec.ts | 137 ++++++++ src/workos/jwt-template.ts | 329 ++++++++++++++++++++ src/workos/routes/auth.ts | 26 +- src/workos/routes/config.spec.ts | 78 ++++- src/workos/routes/config.ts | 35 ++- 16 files changed, 1269 insertions(+), 44 deletions(-) create mode 100644 src/workos/jwt-template-integration.spec.ts create mode 100644 src/workos/jwt-template.spec.ts create mode 100644 src/workos/jwt-template.ts diff --git a/README.md b/README.md index fdb247f..b31d75f 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ workos-emulate workos-emulate --port 9100 --json workos-emulate --seed workos-emulate.config.yaml workos-emulate --interactive # serve login pages for E2E browser testing +workos-emulate --signing-key ci-key.pem --issuer https://api.workos.com # stable JWKS and iss workos-emulate --version ``` @@ -510,6 +511,138 @@ All events are also queryable at `GET /events` (filter with `?events[]=user.crea - Resources defined in a seed file record events (visible at `GET /events`) but are not delivered to webhook endpoints from the same seed file — endpoints are registered last, mirroring real WorkOS, where pre-existing data never replays. Register endpoints via the API if you want deliveries for setup data. - `dsync.group.user_added` / `dsync.group.user_removed` are catalogued but never emitted: the emulator has no directory group membership mutation surface. +## JWT Templates (custom claims) + +A JWT template adds your own claims to every access token the emulator mints, so authorization +code that reads a custom claim runs against the emulator unchanged. Seed it to have the claims +present from the first sign-in, with no setup call: + +```yaml +jwtTemplate: + content: >- + {"urn:myapp:name": "{{ user.first_name }} {{ user.last_name }}", + "urn:myapp:tenant": "{{ organization.metadata.tenant_id }}", + "urn:myapp:role": "{{ organization_membership.role }}"} +``` + +Or set it at runtime, matching the WorkOS API — `content` is a template string that renders to a +JSON object: + +```bash +curl -X PUT http://localhost:4100/user_management/jwt_template \ + -H "Authorization: Bearer sk_test_default" \ + -H "Content-Type: application/json" \ + -d '{"content": "{\"urn:myapp:tenant\": \"{{ organization.metadata.tenant_id }}\"}"}' +``` + +Either way the rendered claims land in the token: + +```json +{ + "sub": "user_01...", + "org_id": "org_01...", + "role": "admin", + "urn:myapp:name": "Alice Smith", + "urn:myapp:tenant": "tenant_123" +} +``` + +### Template syntax + +WorkOS uses a small interpolation syntax, not full Liquid. The emulator implements that subset: + +| Form | Meaning | +| --------------------------------------- | ------------------------------------------------------ | +| `{{ user.email }}` | Interpolate a value by dotted path | +| `{{ user.nickname \|\| user.email }}` | Fallback chain; the first non-null value wins | +| `{{ user.nickname \|\| 'anonymous' }}` | Single-quoted literal as the last resort | +| `"{{ user.first_name }} {{ user.id }}"` | Concatenation inside a JSON string; null becomes `""` | +| `{"meta": {{ user.metadata }}}` | A whole object or array, interpolated outside a string | +| `organization.domains.0.domain` | Array index as a path segment | + +Filters, conditionals, and loops are not part of the syntax and are not supported. + +Available variables are `user.*`, `organization.*`, and `organization_membership.*`. `organization` +and `organization_membership` are only populated for an org-scoped session; in a session with no +organization they resolve to null, so use a fallback if a claim must always be present. + +Templates apply to AuthKit session tokens — every grant on `POST /user_management/authenticate`, +including `refresh_token`, so claims survive a refresh. They do not apply to M2M +(`client_credentials`) tokens, widget tokens, or the profile-based `POST /sso/token`, none of which +resolve a user and membership to render against. + +### What is rejected + +Templates are validated when set — over the API, and at startup for a seeded one, so a bad template +fails the boot rather than the first sign-in. `--validate-config` checks it too. + +- **Reserved claims.** A template may not set `iss`, `sub`, `exp`, `iat`, `nbf`, or `jti`. Note that + `aud`, `sid`, `org_id`, `role`, `roles`, and `permissions` are _not_ reserved: a template may + deliberately override those, and the rendered value wins over what the emulator resolved. +- **Unknown variables.** An unrecognized root (`{{ usr.email }}`) is a typo and is rejected. A path + _below_ a known root that the emulator does not model resolves to null instead — including + `organization.allow_profiles_outside_organization` and + `organization_membership.custom_attributes`, which the emulator has no data for and will not + invent. +- **Anything that is not a JSON object** with at least one key. + +WorkOS caps rendered claims at 3072 bytes, because the session cookie carrying them has to fit in a +browser. That depends on the data, so it is enforced when the token is signed: a template that +renders too large fails the authenticate call with a 422 naming the size, rather than quietly +handing back a token missing its claims. + +> Earlier versions accepted a `custom_claims` object on this endpoint and stored it without ever +> putting it in a token. That field is gone; `content` is what works. Sending `custom_claims` now +> returns a 422 pointing at `content`. + +## Stable Signing Key and Issuer + +By default the emulator generates an RSA keypair at startup and mints its own URL as `iss`. That is +fine for a single run, but it means a restart invalidates every token already issued and changes the +published JWKS — and the issuer moves with the port. + +Pin either or both to make tokens outlive a restart: + +```bash +# Generate a key once and keep it with your test fixtures +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ci-key.pem + +workos-emulate \ + --signing-key ci-key.pem \ + --kid ci_key \ + --issuer https://api.workos.com +``` + +Each flag has an environment equivalent — `WORKOS_EMULATE_SIGNING_KEY`, `WORKOS_EMULATE_KID`, +`WORKOS_EMULATE_ISSUER` — so a compose file can set them once. Flags win over the environment. + +Programmatically: + +```ts +const emulator = await createEmulator({ + signingKey: { privateKey: readFileSync('ci-key.pem', 'utf-8'), kid: 'ci_key' }, + issuer: 'https://api.workos.com', +}); +``` + +What this buys you: + +- **JWKS stable across restarts.** `/sso/jwks/:client_id` publishes the same key every boot, so a + token minted before a restart still verifies after it. Without a pinned key, a verifier that + cached the JWKS must refetch. +- **A constant `iss`.** A verifier comparing `iss` against a hardcoded string needs no test-only + branch. It must still fetch JWKS from the emulator — pinning the issuer does not make WorkOS's + real keys apply. +- **One key across several emulators**, or tokens pre-signed offline with the same key the emulator + verifies. + +The key must be a PEM-encoded RSA private key, since tokens are signed RS256; anything else fails at +startup with a message saying why. Omit `--kid` and the `kid` is derived from the key itself, so it +is stable for a pinned key without being pinned separately. + +> A pinned signing key is a test fixture, not a secret to reuse anywhere real. Never point the +> emulator at a key your production environment trusts. + ## Error Hooks Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.). diff --git a/src/cli.ts b/src/cli.ts index 92e9ad0..e43d35e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,9 @@ interface CliArgs { port: number; host?: string; seed?: string; + signingKey?: string; + kid?: string; + issuer?: string; json: boolean; help: boolean; version: boolean; @@ -22,6 +25,13 @@ interface CliArgs { const DEFAULT_PORT = 4100; const SEED_CANDIDATES = ['workos-emulate.config.yaml', 'workos-emulate.config.yml', 'workos-emulate.config.json']; +/** Flags taking a string value, accepted as either `--flag value` or `--flag=value`. */ +const VALUE_FLAGS = [ + ['--signing-key', 'signingKey'], + ['--kid', 'kid'], + ['--issuer', 'issuer'], +] as const satisfies ReadonlyArray; + function printHelp(): void { console.log(`Usage: workos-emulate [options] @@ -32,6 +42,12 @@ Options: --host Interface to bind to (default: localhost). Use 0.0.0.0 to intentionally expose the emulator to other hosts. --seed, -s Path to seed config file (YAML or JSON) + --signing-key + Path to a PEM-encoded RSA private key to sign tokens with. Without + it a key is generated at startup, so the published JWKS changes on + every restart. Pin it to keep the JWKS stable. + --kid Key id to advertise in the JWKS (default: derived from the key) + --issuer Value to mint as the "iss" claim (default: the emulator's own URL) --interactive, -i Show login pages for SSO/AuthKit (for E2E browser testing) --validate-config Validate seed config file without starting server --json Print startup details as JSON @@ -39,6 +55,9 @@ Options: --help, -h Show this help message Environment: + WORKOS_EMULATE_SIGNING_KEY= Same as --signing-key + WORKOS_EMULATE_KID= Same as --kid + WORKOS_EMULATE_ISSUER= Same as --issuer NO_UPDATE_NOTIFIER=1 Disable update checks WORKOS_EMULATE_DISABLE_UPDATE_CHECK=1 Disable update checks `); @@ -122,6 +141,15 @@ function parseArgs(argv: string[]): CliArgs { continue; } + const valueFlag = VALUE_FLAGS.find(([flag]) => arg === flag || arg.startsWith(`${flag}=`)); + if (valueFlag) { + const [flag, field] = valueFlag; + const value = arg === flag ? argv[++i] : arg.slice(flag.length + 1); + if (!value) throw new Error(`${flag} requires a value`); + parsed[field] = value; + continue; + } + throw new Error(`Unknown option: ${arg}`); } @@ -149,6 +177,16 @@ function loadSeedFile(filePath: string): EmulatorSeedConfig { return parseYaml(content) as EmulatorSeedConfig; } +/** Read a pinned signing key from disk. Undefined path means "generate one", not an error. */ +function readSigningKey(filePath?: string): string | undefined { + if (!filePath) return undefined; + const resolved = resolve(filePath); + if (!existsSync(resolved)) { + throw new Error(`Signing key file not found: ${resolved}`); + } + return readFileSync(resolved, 'utf-8'); +} + function autoDetectSeedFile(): EmulatorSeedConfig | undefined { for (const name of SEED_CANDIDATES) { const filePath = resolve(name); @@ -200,10 +238,17 @@ async function main(): Promise { } } + // Flags win over environment, so a compose file can set a default a command can override. + const signingKeyPath = argv.signingKey ?? process.env.WORKOS_EMULATE_SIGNING_KEY; + const kid = argv.kid ?? process.env.WORKOS_EMULATE_KID; + const issuer = argv.issuer ?? process.env.WORKOS_EMULATE_ISSUER; + const emulator = await createEmulator({ port: argv.port, hostname: argv.host, seed: seedConfig, + issuer, + signingKey: signingKeyPath || kid ? { privateKey: readSigningKey(signingKeyPath), kid } : undefined, interactiveAuth: argv.interactive, }); diff --git a/src/core/index.ts b/src/core/index.ts index efa68f5..bfea445 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -14,7 +14,7 @@ export { type CursorPaginationOptions, type CursorPaginatedResult, } from './pagination.js'; -export { JWTManager, type JWTPayload } from './jwt.js'; +export { JWTManager, type JWTPayload, type SigningKeyOptions } from './jwt.js'; export { createServer, type ServerOptions } from './server.js'; export { type ServicePlugin, type RouteContext } from './plugin.js'; export { diff --git a/src/core/jwt.spec.ts b/src/core/jwt.spec.ts index e33e47e..3adca47 100644 --- a/src/core/jwt.spec.ts +++ b/src/core/jwt.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, setSystemTime, afterEach } from 'bun:test'; +import { generateKeyPairSync } from 'node:crypto'; import { JWTManager } from './jwt.js'; describe('JWTManager', () => { @@ -87,4 +88,85 @@ describe('JWTManager', () => { const pem = jwt.getPublicKeyPem(); expect(pem).toContain('-----BEGIN PUBLIC KEY-----'); }); + + describe('template claims', () => { + it('merges claims into the token', () => { + const token = jwt.sign( + { sub: 'user_01ABC', aud: 'client_01XYZ' }, + { claims: { 'urn:myapp:tenant': 'tenant_123', 'urn:myapp:seats': 5 } }, + ); + const payload = jwt.verify(token); + expect(payload['urn:myapp:tenant']).toBe('tenant_123'); + expect(payload['urn:myapp:seats']).toBe(5); + }); + + it('lets a claim override a resolved, non-reserved claim', () => { + const token = jwt.sign({ sub: 'user_01ABC', aud: 'client_01XYZ', role: 'member' }, { claims: { role: 'admin' } }); + expect(jwt.verify(token).role).toBe('admin'); + }); + + it('never lets a claim override the token identity', () => { + const token = jwt.sign( + { sub: 'user_01ABC', aud: 'client_01XYZ' }, + { claims: { sub: 'user_01SPOOFED', iss: 'https://evil.test', exp: 1, iat: 1, jti: 'x', nbf: 1 } }, + ); + const payload = jwt.verify(token); + expect(payload.sub).toBe('user_01ABC'); + expect(payload.iss).toBe('https://api.workos.test'); + expect(payload.jti).toBeUndefined(); + expect(payload.nbf).toBeUndefined(); + expect(payload.exp).toBe(payload.iat + 3600); + }); + }); + + describe('pinned signing key', () => { + // A fresh keypair per run, so the fixture is never a checked-in private key. + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + + it('survives a restart: a new manager verifies the old token', () => { + const before = new JWTManager('https://api.workos.test', { privateKey: pem }); + const token = before.sign({ sub: 'user_01ABC', aud: 'client_01XYZ' }); + + const after = new JWTManager('https://api.workos.test', { privateKey: pem }); + expect(after.verify(token).sub).toBe('user_01ABC'); + }); + + it('publishes the same JWKS and kid for the same key', () => { + const a = new JWTManager('https://api.workos.test', { privateKey: pem }); + const b = new JWTManager('https://api.workos.test', { privateKey: pem }); + expect(a.getJWKS()).toEqual(b.getJWKS()); + }); + + it('derives a different kid for a different key', () => { + const other = new JWTManager('https://api.workos.test'); + expect(other.getJWKS().keys[0].kid).not.toBe( + new JWTManager('https://api.workos.test', { privateKey: pem }).getJWKS().keys[0].kid, + ); + }); + + it('honors an explicit kid in the JWKS and the token header', () => { + const pinned = new JWTManager('https://api.workos.test', { privateKey: pem, kid: 'my_kid' }); + expect(pinned.getJWKS().keys[0].kid).toBe('my_kid'); + + const header = JSON.parse( + Buffer.from(pinned.sign({ sub: 'user_01ABC', aud: 'c' }).split('.')[0], 'base64url').toString('utf-8'), + ); + expect(header.kid).toBe('my_kid'); + }); + + it('rejects a key that is not a PEM private key', () => { + expect(() => new JWTManager('https://api.workos.test', { privateKey: 'not a key' })).toThrow( + 'could not parse as a PEM private key', + ); + }); + + it('rejects a non-RSA key, since tokens are signed RS256', () => { + const ed25519 = generateKeyPairSync('ed25519').privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + expect(() => new JWTManager('https://api.workos.test', { privateKey: ed25519 })).toThrow('expected an RSA key'); + }); + }); }); diff --git a/src/core/jwt.ts b/src/core/jwt.ts index d960f41..305cbba 100644 --- a/src/core/jwt.ts +++ b/src/core/jwt.ts @@ -1,6 +1,15 @@ -import { createSign, createVerify, generateKeyPairSync, type KeyObject } from 'node:crypto'; - -export interface JWTPayload { +import { + createHash, + createPrivateKey, + createPublicKey, + createSign, + createVerify, + generateKeyPairSync, + type KeyObject, +} from 'node:crypto'; + +/** The claims the emulator itself resolves and mints. */ +export interface JWTClaims { sub: string; sid?: string; org_id?: string; @@ -27,10 +36,39 @@ export interface JWTPayload { iat: number; } +/** + * A decoded token: the minted claims, plus whatever else a JWT template added. The index + * signature is what makes template claims readable off a verified token. + */ +export type JWTPayload = JWTClaims & { [claim: string]: unknown }; + interface SignOptions { expiresIn?: number; + /** + * Extra claims to merge into the token, as rendered from a JWT template. Reserved + * claims (`iss`, `sub`, `exp`, `iat`, `nbf`, `jti`) are dropped: the token's own identity + * is not something a template gets to restate. + */ + claims?: Record; +} + +export interface SigningKeyOptions { + /** + * PEM-encoded RSA private key to sign with. Omit and a fresh key is generated at + * startup, which means a JWKS consumer must refetch after every restart. Pin it to keep + * the JWKS — and therefore any token minted against it — stable across restarts. + */ + privateKey?: string; + /** + * `kid` to advertise in the JWKS and in token headers. Defaults to a value derived from + * the key itself, so a pinned key yields a stable `kid` without setting this. + */ + kid?: string; } +/** Claims a JWT template may not set; see RESERVED_JWT_CLAIMS in workos/jwt-template.ts. */ +const TEMPLATE_RESERVED_CLAIMS = new Set(['iss', 'sub', 'exp', 'iat', 'nbf', 'jti']); + function base64url(input: Buffer | string): string { const buf = typeof input === 'string' ? Buffer.from(input) : input; return buf.toString('base64url'); @@ -40,28 +78,73 @@ function base64urlDecode(input: string): Buffer { return Buffer.from(input, 'base64url'); } +/** + * Parse a pinned PEM private key, rejecting anything that cannot sign RS256 tokens with a + * message that names the problem — a bad key is a config mistake worth failing loudly on. + */ +function loadPrivateKey(pem: string): KeyObject { + let key: KeyObject; + try { + key = createPrivateKey(pem); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid signing key: could not parse as a PEM private key (${detail})`); + } + + if (key.asymmetricKeyType !== 'rsa') { + throw new Error(`Invalid signing key: expected an RSA key (tokens are signed RS256), got ${key.asymmetricKeyType}`); + } + + return key; +} + +/** RFC 7638 JWK thumbprint, used to derive a `kid` that is stable for a given key. */ +function jwkThumbprint(publicKey: KeyObject): string { + const jwk = publicKey.export({ format: 'jwk' }) as { e?: string; kty?: string; n?: string }; + const canonical = JSON.stringify({ e: jwk.e, kty: jwk.kty, n: jwk.n }); + return createHash('sha256').update(canonical).digest('base64url'); +} + export class JWTManager { private privateKey: KeyObject; private publicKey: KeyObject; private kid: string; issuer: string; - constructor(issuer = 'https://api.workos.com') { + constructor(issuer = 'https://api.workos.com', signingKey?: SigningKeyOptions) { this.issuer = issuer; - const { privateKey, publicKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - }); - this.privateKey = privateKey; - this.publicKey = publicKey; - this.kid = `workos_emulate_${Date.now()}`; + + if (signingKey?.privateKey) { + this.privateKey = loadPrivateKey(signingKey.privateKey); + this.publicKey = createPublicKey(this.privateKey); + } else { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + this.privateKey = privateKey; + this.publicKey = publicKey; + } + + // Derived from the key rather than the clock, so pinning the key pins the `kid` too. + this.kid = signingKey?.kid ?? `workos_emulate_${jwkThumbprint(this.publicKey).slice(0, 16)}`; } - sign(payload: Omit, options?: SignOptions): string { + sign(payload: Omit, options?: SignOptions): string { const now = Math.floor(Date.now() / 1000); const expiresIn = options?.expiresIn ?? 3600; + const templateClaims: Record = {}; + for (const [key, value] of Object.entries(options?.claims ?? {})) { + if (TEMPLATE_RESERVED_CLAIMS.has(key)) continue; + templateClaims[key] = value; + } + const fullPayload: JWTPayload = { ...payload, + // Template claims win over the claims the emulator resolves, matching WorkOS: only the + // reserved claims below are off-limits, so a template may deliberately restate `role`, + // `permissions`, or `org_id`. + ...templateClaims, iss: this.issuer, iat: now, exp: now + expiresIn, diff --git a/src/core/server.ts b/src/core/server.ts index 6e7876c..5d8cc4a 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { Store } from './store.js'; -import { JWTManager } from './jwt.js'; +import { JWTManager, type SigningKeyOptions } from './jwt.js'; import { createApiErrorHandler, requestIdMiddleware } from './middleware/error-handler.js'; import { authMiddleware, type ApiKeyMap, type WorkOSAppEnv } from './middleware/auth.js'; import { errorHooksMiddleware } from './error-hooks.js'; @@ -11,6 +11,14 @@ export interface ServerOptions { port?: number; baseUrl?: string; apiKeys?: ApiKeyMap; + /** + * `iss` to mint into tokens. Defaults to the emulator's own base URL. Pin it to match + * what your real WorkOS environment emits, so a verifier that checks `iss` against a + * constant accepts emulator tokens unchanged. + */ + issuer?: string; + /** Pinned RSA signing key, keeping the JWKS stable across restarts. */ + signingKey?: SigningKeyOptions; } export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) { @@ -19,7 +27,7 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) const app = new Hono(); const store = new Store(); - const jwt = new JWTManager(baseUrl); + const jwt = new JWTManager(options.issuer ?? baseUrl, options.signingKey); const apiKeys: ApiKeyMap = options.apiKeys ?? { sk_test_default: { environment: 'test' }, diff --git a/src/index.ts b/src/index.ts index 6010651..81b6a2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { type ErrorHook, type ErrorHookInput, type Store, + type SigningKeyOptions, } from './core/index.js'; import { workosPlugin, seedFromConfig, type WorkOSSeedConfig } from './workos/index.js'; import { STORE_KEYS } from './workos/constants.js'; @@ -35,6 +36,7 @@ export interface EmulatorSeedConfig { permissions?: WorkOSSeedConfig['permissions']; webhookEndpoints?: WorkOSSeedConfig['webhookEndpoints']; connectApplications?: WorkOSSeedConfig['connectApplications']; + jwtTemplate?: WorkOSSeedConfig['jwtTemplate']; errorHooks?: ErrorHookSeedConfig[]; } @@ -48,6 +50,20 @@ export interface EmulatorOptions { */ hostname?: string; seed?: EmulatorSeedConfig; + /** + * `iss` to mint into access tokens, and to advertise as the OIDC issuer. Defaults to the + * emulator's own URL, which changes with the port. Pin it to the issuer your real WorkOS + * environment emits so a verifier comparing `iss` against a constant needs no test-only + * branch. The verifier must still fetch JWKS from the emulator. + */ + issuer?: string; + /** + * Pinned RSA signing key. By default the emulator generates one at startup, so its JWKS + * — and every token signed against it — is invalidated by a restart. Pin the key to keep + * the JWKS stable across restarts, to share one key between several emulator instances, + * or to pre-sign tokens offline with the same key the emulator verifies. + */ + signingKey?: SigningKeyOptions; interactiveAuth?: boolean; webhookRetryConfig?: { maxRetries?: number; @@ -95,6 +111,8 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise undefined) : undefined; - // Update JWT issuer to reflect the actual bound URL (matters when port: 0) - jwt.issuer = url; + // Update JWT issuer to reflect the actual bound URL (matters when port: 0). A pinned + // issuer is left alone — the whole point is that it does not move with the port. + if (!options.issuer) jwt.issuer = url; const primaryApiKey = Object.keys(apiKeys)[0]; diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index b6be467..c40458f 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -2,6 +2,7 @@ * Configuration validation for seed config files */ import type { WorkOSSeedConfig } from './index.js'; +import { validateJwtTemplateContent } from './jwt-template.js'; /** * A pinned id is addressed as a single path segment (`/organizations/:id`, @@ -525,6 +526,22 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe }); } + // Validating the template here means `--validate-config` catches a broken one, rather + // than leaving it to fail at the first sign-in. + if (config.jwtTemplate !== undefined) { + if (typeof config.jwtTemplate !== 'object' || config.jwtTemplate === null) { + errors.push({ + path: 'jwtTemplate', + message: 'must be an object with a content field', + value: config.jwtTemplate, + }); + } else { + for (const problem of validateJwtTemplateContent(config.jwtTemplate.content)) { + errors.push({ path: 'jwtTemplate.content', message: problem, value: config.jwtTemplate.content }); + } + } + } + return { valid: errors.length === 0, errors, diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 0f75cf9..1731658 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -1,5 +1,17 @@ import type { Entity } from '../core/index.js'; +/** + * The environment's JWT template. Environment-scoped rather than a collection resource, so + * it carries no id and lives in store data instead of a collection. + */ +export interface WorkOSJwtTemplate { + object: 'jwt_template'; + /** Template string rendering to a JSON object of claims. See workos/jwt-template.ts. */ + content: string; + created_at: string; + updated_at: string; +} + export interface WorkOSOrganization extends Entity { object: 'organization'; name: string; diff --git a/src/workos/index.ts b/src/workos/index.ts index f724c66..019fe7b 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -40,6 +40,7 @@ import { eventRoutes } from './routes/events.js'; import { EventBus } from './event-bus.js'; import { STORE_KEYS, EVENTS } from './constants.js'; import { validateSeedConfig, formatValidationErrors } from './config-validator.js'; +import { validateJwtTemplateContent } from './jwt-template.js'; import { generateVerificationToken, hashPassword, @@ -63,7 +64,13 @@ import { formatFeatureFlag, generateClientId, } from './helpers.js'; -import type { WorkOSConnectionType, PipeProvider, PipeConnectionStatus, WorkOSApiKeyOwner } from './entities.js'; +import type { + WorkOSConnectionType, + PipeProvider, + PipeConnectionStatus, + WorkOSApiKeyOwner, + WorkOSJwtTemplate, +} from './entities.js'; export { getWorkOSStore, type WorkOSStore } from './store.js'; export * from './entities.js'; @@ -218,6 +225,15 @@ export interface WorkOSSeedApiKey { /** Legacy auth allow-list: maps a raw API key value to its environment. */ export type WorkOSSeedApiKeyAuthMap = Record; +export interface WorkOSSeedJwtTemplate { + /** + * Template string rendering to a JSON object of claims, e.g. + * `'{"urn:myapp:tenant": "{{ organization.metadata.tenant_id }}"}'`. Validated at + * startup, so a malformed template fails the boot rather than the first sign-in. + */ + content: string; +} + export interface WorkOSSeedConfig { organizations?: WorkOSSeedOrganization[]; users?: WorkOSSeedUser[]; @@ -234,6 +250,11 @@ export interface WorkOSSeedConfig { * value in the auth allow-list so the seeded key authenticates requests. */ apiKeys?: WorkOSSeedApiKeyAuthMap | WorkOSSeedApiKey[]; + /** + * The environment's JWT template, whose claims are merged into every access token the + * emulator mints. Seeding it means a test suite gets custom claims without a setup call. + */ + jwtTemplate?: WorkOSSeedJwtTemplate; } export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSeedConfig): void { @@ -515,6 +536,20 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee } store.setData(STORE_KEYS.apiKeyMap, authMap); } + + if (config.jwtTemplate) { + const problems = validateJwtTemplateContent(config.jwtTemplate.content); + if (problems.length > 0) { + throw new Error(`workos seed config: jwtTemplate.content is invalid: ${problems.join('; ')}`); + } + const now = new Date().toISOString(); + store.setData(STORE_KEYS.jwtTemplate, { + object: 'jwt_template', + content: config.jwtTemplate.content, + created_at: now, + updated_at: now, + } satisfies WorkOSJwtTemplate); + } } export const workosPlugin: ServicePlugin = { diff --git a/src/workos/jwt-template-integration.spec.ts b/src/workos/jwt-template-integration.spec.ts new file mode 100644 index 0000000..9d69292 --- /dev/null +++ b/src/workos/jwt-template-integration.spec.ts @@ -0,0 +1,240 @@ +/** + * A configured JWT template has to reach the token. The emulator previously stored a + * template and returned it from the API while signing tokens that never carried its + * claims, so these tests assert on the decoded token rather than on the stored template. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { generateKeyPairSync } from 'node:crypto'; +import { createEmulator, type Emulator } from '../index.js'; + +describe('JWT templates end to end', () => { + let emulator: Emulator | undefined; + + afterEach(async () => { + await emulator?.close(); + emulator = undefined; + }); + + const seed = { + users: [{ email: 'alice@acme.com', first_name: 'Alice', last_name: 'Smith', password: 'test123' }], + organizations: [ + { + name: 'Acme Corp', + metadata: { tenant_id: 'tenant_123' }, + memberships: [{ email: 'alice@acme.com', role: 'admin', status: 'active' as const }], + }, + ], + roles: [{ slug: 'admin', name: 'Admin', permissions: ['posts:write'] }], + permissions: [{ slug: 'posts:write', name: 'Write Posts' }], + }; + + const decode = (token: string) => + JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf-8')) as Record; + + const login = async (url: string) => { + const res = await fetch(`${url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'password', + email: 'alice@acme.com', + password: 'test123', + client_id: 'client_test', + client_secret: 'sk_test_default', + }), + }); + return { status: res.status, body: (await res.json()) as any }; + }; + + it('mints seeded template claims into the access token', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + ...seed, + jwtTemplate: { + content: + '{"urn:myapp:name": "{{ user.first_name }} {{ user.last_name }}", "urn:myapp:tenant": "{{ organization.metadata.tenant_id }}", "urn:myapp:verified": {{ user.email_verified }}}', + }, + }, + }); + + const { status, body } = await login(emulator.url); + expect(status).toBe(200); + + const claims = decode(body.access_token); + expect(claims['urn:myapp:name']).toBe('Alice Smith'); + expect(claims['urn:myapp:tenant']).toBe('tenant_123'); + expect(claims['urn:myapp:verified']).toBe(false); + // The claims the emulator resolves are still there. + expect(claims.sub).toBeString(); + expect(claims.role).toBe('admin'); + expect(claims.permissions).toEqual(['posts:write']); + }); + + it('applies a template set over the API, with no restart', async () => { + emulator = await createEmulator({ port: 0, seed }); + + const put = await fetch(`${emulator.url}/user_management/jwt_template`, { + method: 'PUT', + headers: { Authorization: `Bearer ${emulator.apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: '{"urn:myapp:email": "{{ user.email }}"}' }), + }); + expect(put.status).toBe(200); + + const { body } = await login(emulator.url); + expect(decode(body.access_token)['urn:myapp:email']).toBe('alice@acme.com'); + }); + + it('carries template claims through a refresh', async () => { + emulator = await createEmulator({ + port: 0, + seed: { ...seed, jwtTemplate: { content: '{"urn:myapp:email": "{{ user.email }}"}' } }, + }); + + const { body } = await login(emulator.url); + const res = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: body.refresh_token, + client_id: 'client_test', + client_secret: 'sk_test_default', + }), + }); + expect(res.status).toBe(200); + const refreshed = (await res.json()) as any; + expect(decode(refreshed.access_token)['urn:myapp:email']).toBe('alice@acme.com'); + }); + + it('lets a template override a resolved claim', async () => { + emulator = await createEmulator({ + port: 0, + seed: { ...seed, jwtTemplate: { content: '{"role": "{{ organization_membership.role }}-scoped"}' } }, + }); + + const { body } = await login(emulator.url); + expect(decode(body.access_token).role).toBe('admin-scoped'); + }); + + it('fails the boot when a seeded template is invalid', async () => { + await expect( + createEmulator({ port: 0, seed: { ...seed, jwtTemplate: { content: '{"sub": "{{ user.id }}"}' } } }), + ).rejects.toThrow('reserved claims: sub'); + }); + + it('fails the sign-in loudly when a template cannot render', async () => { + emulator = await createEmulator({ + port: 0, + // Valid against the probe values, but renders past the byte limit for this user. + seed: { + ...seed, + users: [{ email: 'alice@acme.com', first_name: 'x'.repeat(4000), password: 'test123' }], + jwtTemplate: { content: '{"urn:myapp:name": "{{ user.first_name }}"}' }, + }, + }); + + const { status, body } = await login(emulator.url); + expect(status).toBe(422); + expect(body.message).toContain('over the 3072-byte limit'); + }); + + it('signs nothing extra when no template is configured', async () => { + emulator = await createEmulator({ port: 0, seed }); + const { body } = await login(emulator.url); + expect(Object.keys(decode(body.access_token)).sort()).toEqual([ + 'aud', + 'exp', + 'iat', + 'iss', + 'org_id', + 'permissions', + 'role', + 'roles', + 'sid', + 'sub', + ]); + }); +}); + +describe('Pinned signing key and issuer', () => { + let emulator: Emulator | undefined; + + afterEach(async () => { + await emulator?.close(); + emulator = undefined; + }); + + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + + const jwks = async (url: string) => (await fetch(`${url}/sso/jwks/client_test`)).json() as Promise; + + it('publishes a JWKS that survives a restart', async () => { + emulator = await createEmulator({ port: 0, signingKey: { privateKey: pem } }); + const first = await jwks(emulator.url); + await emulator.close(); + + emulator = await createEmulator({ port: 0, signingKey: { privateKey: pem } }); + expect(await jwks(emulator.url)).toEqual(first); + }); + + it('publishes a different JWKS on restart without a pinned key', async () => { + emulator = await createEmulator({ port: 0 }); + const first = await jwks(emulator.url); + await emulator.close(); + + emulator = await createEmulator({ port: 0 }); + expect((await jwks(emulator.url)).keys[0].n).not.toBe(first.keys[0].n); + }); + + it('advertises a pinned kid', async () => { + emulator = await createEmulator({ port: 0, signingKey: { privateKey: pem, kid: 'ci_key' } }); + expect((await jwks(emulator.url)).keys[0].kid).toBe('ci_key'); + }); + + it('mints a pinned issuer instead of the emulator URL', async () => { + emulator = await createEmulator({ + port: 0, + issuer: 'https://api.workos.com', + seed: { users: [{ email: 'alice@acme.com', password: 'test123' }] }, + }); + + const res = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'password', + email: 'alice@acme.com', + password: 'test123', + client_id: 'client_test', + client_secret: 'sk_test_default', + }), + }); + const body = (await res.json()) as any; + const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString('utf-8')); + expect(claims.iss).toBe('https://api.workos.com'); + }); + + it('defaults the issuer to the emulator URL', async () => { + emulator = await createEmulator({ + port: 0, + seed: { users: [{ email: 'alice@acme.com', password: 'test123' }] }, + }); + + const res = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'password', + email: 'alice@acme.com', + password: 'test123', + client_id: 'client_test', + client_secret: 'sk_test_default', + }), + }); + const body = (await res.json()) as any; + const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString('utf-8')); + expect(claims.iss).toBe(emulator.url); + }); +}); diff --git a/src/workos/jwt-template.spec.ts b/src/workos/jwt-template.spec.ts new file mode 100644 index 0000000..024c320 --- /dev/null +++ b/src/workos/jwt-template.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'bun:test'; +import { + renderJwtTemplate, + validateJwtTemplateContent, + JwtTemplateError, + MAX_RENDERED_CLAIMS_BYTES, + type JwtTemplateContext, +} from './jwt-template.js'; + +const context: JwtTemplateContext = { + user: { + id: 'user_01ABC', + email: 'alice@acme.com', + first_name: 'Alice', + last_name: null, + email_verified: true, + metadata: { tenant_id: 'tenant_123' }, + }, + organization: { + id: 'org_01XYZ', + name: 'Acme Corp', + domains: [{ domain: 'acme.com' }, { domain: 'acme.dev' }], + metadata: {}, + }, + organization_membership: { + id: 'om_01DEF', + role: 'admin', + roles: ['admin'], + }, +}; + +const render = (content: string) => renderJwtTemplate(content, context); + +describe('renderJwtTemplate', () => { + it('interpolates a variable into a string claim', () => { + expect(render('{"urn:myapp:email": "{{ user.email }}"}')).toEqual({ + 'urn:myapp:email': 'alice@acme.com', + }); + }); + + it('concatenates several variables inside one string', () => { + expect(render('{"name": "{{ user.first_name }} {{ user.last_name }}"}')).toEqual({ name: 'Alice ' }); + }); + + it('falls back to the next alternative when a value is missing', () => { + expect(render('{"n": "{{ user.nickname || user.email }}"}')).toEqual({ n: 'alice@acme.com' }); + }); + + it('falls back to a single-quoted literal', () => { + expect(render('{"n": "{{ user.nickname || \'anonymous\' }}"}')).toEqual({ n: 'anonymous' }); + }); + + it('interpolates an object outside a string', () => { + expect(render('{"meta": {{ user.metadata }}}')).toEqual({ meta: { tenant_id: 'tenant_123' } }); + }); + + it('reads a nested path and an array index', () => { + expect(render('{"t": "{{ user.metadata.tenant_id }}", "d": "{{ organization.domains.0.domain }}"}')).toEqual({ + t: 'tenant_123', + d: 'acme.com', + }); + }); + + it('preserves non-string JSON types', () => { + expect(render('{"verified": {{ user.email_verified }}, "roles": {{ organization_membership.roles }}}')).toEqual({ + verified: true, + roles: ['admin'], + }); + }); + + it('drops top-level null claims', () => { + expect(render('{"kept": "{{ user.email }}", "dropped": {{ user.last_name }}}')).toEqual({ + kept: 'alice@acme.com', + }); + }); + + it('escapes interpolated values that would break the JSON', () => { + const quoted: JwtTemplateContext = { user: { first_name: 'A"B\\C' } }; + expect(renderJwtTemplate('{"n": "{{ user.first_name }}"}', quoted)).toEqual({ n: 'A"B\\C' }); + }); + + it('drops reserved claims as a backstop', () => { + expect(render('{"sub": "spoofed", "keep": "{{ user.id }}"}')).toEqual({ keep: 'user_01ABC' }); + }); + + it('resolves an unmodelled path below a known root to null', () => { + expect(render('{"x": "{{ organization.allow_profiles_outside_organization || \'unset\' }}"}')).toEqual({ + x: 'unset', + }); + }); + + it('throws on an unknown root variable', () => { + expect(() => render('{"x": "{{ usr.email }}"}')).toThrow(JwtTemplateError); + }); + + it('throws on an unterminated expression', () => { + expect(() => render('{"x": "{{ user.email"}')).toThrow('unterminated `{{` expression'); + }); + + it('throws when the result is not a JSON object', () => { + expect(() => render('["{{ user.email }}"]')).toThrow('must render to a JSON object'); + }); + + it('throws when the rendered claims exceed the byte limit', () => { + const big: JwtTemplateContext = { user: { first_name: 'x'.repeat(MAX_RENDERED_CLAIMS_BYTES) } }; + expect(() => renderJwtTemplate('{"big": "{{ user.first_name }}"}', big)).toThrow('over the 3072-byte limit'); + }); +}); + +describe('validateJwtTemplateContent', () => { + it('accepts a well-formed template', () => { + expect(validateJwtTemplateContent('{"urn:myapp:email": "{{ user.email }}"}')).toEqual([]); + }); + + it('requires a non-empty string', () => { + expect(validateJwtTemplateContent(undefined)[0]).toContain('content is required'); + expect(validateJwtTemplateContent(' ')[0]).toContain('content is required'); + }); + + it('rejects reserved claims, naming each one', () => { + expect(validateJwtTemplateContent('{"iat": 1, "jti": "x"}')[0]).toContain('reserved claims: iat, jti'); + }); + + // Null-valued claims are stripped before signing, so the reserved check has to look at the + // template's keys — otherwise `{"sub": null}` would slip past it. + it('rejects a reserved claim whose value renders to null', () => { + expect(validateJwtTemplateContent('{"sub": {{ user.nickname }}}')[0]).toContain('reserved claims: sub'); + }); + + it('rejects an empty object', () => { + expect(validateJwtTemplateContent('{}')[0]).toContain('at least one key'); + }); + + it('rejects an unknown variable', () => { + expect(validateJwtTemplateContent('{"x": "{{ nope.field }}"}')[0]).toContain('unknown template variable `nope`'); + }); +}); diff --git a/src/workos/jwt-template.ts b/src/workos/jwt-template.ts new file mode 100644 index 0000000..5069334 --- /dev/null +++ b/src/workos/jwt-template.ts @@ -0,0 +1,329 @@ +/** + * JWT template rendering. + * + * A JWT template is a string in `content` that renders to a JSON object whose keys are + * merged into the access tokens the emulator mints. WorkOS documents a small custom + * interpolation syntax — not full Liquid — so this implements exactly that subset: + * + * - `{{ user.email }}` — variable interpolation over a dotted path + * - `{{ user.nickname || user.email }}` — fallback chain, first non-null wins + * - `{{ user.nickname || 'anonymous' }}` — single-quoted string literal as a fallback + * - `"{{ user.first_name }} {{ user.last_name }}"` — concatenation inside a JSON string + * - `{"meta": {{ user.metadata }}}` — whole objects and arrays, interpolated outside a string + * + * Filters, conditionals, and loops are not part of the syntax and are not supported. + */ + +import { type Store, WorkOSApiError } from '../core/index.js'; +import type { WorkOSStore } from './store.js'; +import type { WorkOSJwtTemplate, WorkOSUser } from './entities.js'; +import { STORE_KEYS } from './constants.js'; + +/** + * Claims a template may not set. WorkOS rejects these at template-update time, so the + * emulator does too rather than letting a template quietly shadow the identity of the + * token. Notably `aud`, `sid`, `org_id`, `role`, `roles`, and `permissions` are *not* + * reserved: a template may override those, and the rendered value wins. + */ +export const RESERVED_JWT_CLAIMS = ['iss', 'sub', 'exp', 'iat', 'nbf', 'jti'] as const; + +/** + * WorkOS caps the rendered claim set at 3072 bytes, because the session cookie that + * carries it has to fit in a browser. Enforced at sign time, since the rendered size + * depends on the data the claims are drawn from. + */ +export const MAX_RENDERED_CLAIMS_BYTES = 3072; + +/** Roots a template may reference. An unknown root is a typo, and fails validation. */ +const TEMPLATE_ROOTS = ['user', 'organization', 'organization_membership'] as const; + +export interface JwtTemplateContext { + user?: Record; + organization?: Record; + organization_membership?: Record; +} + +/** A template that cannot be rendered: bad syntax, unknown root, or an oversized result. */ +export class JwtTemplateError extends Error { + constructor(message: string) { + super(message); + this.name = 'JwtTemplateError'; + } +} + +function isStringLiteral(token: string): boolean { + return token.length >= 2 && token.startsWith("'") && token.endsWith("'"); +} + +function resolvePath(path: string, context: JwtTemplateContext): unknown { + if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$/.test(path)) { + throw new JwtTemplateError(`invalid variable path \`${path}\``); + } + + const segments = path.split('.'); + const root = segments[0]; + if (!(TEMPLATE_ROOTS as readonly string[]).includes(root)) { + throw new JwtTemplateError(`unknown template variable \`${root}\` (available: ${TEMPLATE_ROOTS.join(', ')})`); + } + + // A path below a known root that the emulator does not model resolves to null, so a + // fallback can cover it. Only the root is checked, which is what catches typos. + let current: unknown = context[root as keyof JwtTemplateContext]; + for (const segment of segments.slice(1)) { + if (current === null || current === undefined) return null; + if (typeof current !== 'object') return null; + current = (current as Record)[segment]; + } + return current ?? null; +} + +/** Evaluate one `{{ … }}` expression: a fallback chain of paths and string literals. */ +function evaluateExpression(expression: string, context: JwtTemplateContext): unknown { + const alternatives = expression + .split('||') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (alternatives.length === 0) { + throw new JwtTemplateError('empty `{{ }}` expression'); + } + + for (const alternative of alternatives) { + if (isStringLiteral(alternative)) return alternative.slice(1, -1); + const value = resolvePath(alternative, context); + if (value !== null && value !== undefined) return value; + } + + return null; +} + +/** Interpolated inside a JSON string: coerced to text, with null becoming empty. */ +function toStringFragment(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return JSON.stringify(value).slice(1, -1); + if (typeof value === 'object') return JSON.stringify(JSON.stringify(value)).slice(1, -1); + return String(value); +} + +/** Interpolated outside a JSON string: emitted as a JSON value. */ +function toJsonFragment(value: unknown): string { + if (value === undefined) return 'null'; + return JSON.stringify(value); +} + +/** + * Substitute every `{{ … }}` expression, tracking whether the cursor sits inside a JSON + * string literal — that is what decides between text and JSON-value interpolation. + */ +function interpolate(content: string, context: JwtTemplateContext): string { + let out = ''; + let inString = false; + let i = 0; + + while (i < content.length) { + const char = content[i]; + + if (char === '{' && content[i + 1] === '{') { + const end = content.indexOf('}}', i + 2); + if (end === -1) throw new JwtTemplateError('unterminated `{{` expression'); + const value = evaluateExpression(content.slice(i + 2, end), context); + out += inString ? toStringFragment(value) : toJsonFragment(value); + i = end + 2; + continue; + } + + if (inString && char === '\\') { + out += char + (content[i + 1] ?? ''); + i += 2; + continue; + } + + if (char === '"') inString = !inString; + out += char; + i++; + } + + if (inString) throw new JwtTemplateError('unterminated string literal'); + return out; +} + +/** Render a template to the claim object it produces, before reserved-claim stripping. */ +function renderToObject(content: string, context: JwtTemplateContext): Record { + const rendered = interpolate(content, context); + + let parsed: unknown; + try { + parsed = JSON.parse(rendered); + } catch { + throw new JwtTemplateError('template did not render to valid JSON'); + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new JwtTemplateError('template must render to a JSON object'); + } + + return parsed as Record; +} + +/** + * Render a template into claims ready to merge into a token. Top-level nulls are dropped + * (a claim WorkOS would omit rather than emit as null), as are reserved claims — those are + * rejected when the template is set, so this is only a backstop. + */ +export function renderJwtTemplate(content: string, context: JwtTemplateContext): Record { + const rendered = renderToObject(content, context); + + const claims: Record = {}; + for (const [key, value] of Object.entries(rendered)) { + if (value === null || value === undefined) continue; + if ((RESERVED_JWT_CLAIMS as readonly string[]).includes(key)) continue; + claims[key] = value; + } + + const size = Buffer.byteLength(JSON.stringify(claims), 'utf-8'); + if (size > MAX_RENDERED_CLAIMS_BYTES) { + throw new JwtTemplateError(`template rendered to ${size} bytes, over the ${MAX_RENDERED_CLAIMS_BYTES}-byte limit`); + } + + return claims; +} + +/** + * Representative values used to render a template at validation time, so syntax errors, + * unknown variables, and reserved claims surface when the template is set rather than at + * the next sign-in. Only the shape matters — these values never reach a token. + */ +const PROBE_CONTEXT: JwtTemplateContext = { + user: { + id: 'user_01PROBE', + email: 'probe@example.com', + first_name: 'Probe', + last_name: 'User', + email_verified: true, + profile_picture_url: null, + external_id: null, + metadata: {}, + }, + organization: { + id: 'org_01PROBE', + name: 'Probe Org', + domains: [{ domain: 'example.com' }], + stripe_customer_id: null, + external_id: null, + metadata: {}, + }, + organization_membership: { + id: 'om_01PROBE', + role: 'member', + roles: ['member'], + }, +}; + +/** + * Validate template content the way WorkOS does when it is set: it has to render to a JSON + * object with at least one key, reference only known variables, and stay off the reserved + * claims. Returns human-readable problems; empty means valid. + */ +export function validateJwtTemplateContent(content: unknown): string[] { + if (typeof content !== 'string' || content.trim().length === 0) { + return ['content is required and must be a non-empty string']; + } + + let rendered: Record; + try { + rendered = renderToObject(content, PROBE_CONTEXT); + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } + + const keys = Object.keys(rendered); + if (keys.length === 0) { + return ['template must render to a JSON object with at least one key']; + } + + const reserved = keys.filter((key) => (RESERVED_JWT_CLAIMS as readonly string[]).includes(key)); + if (reserved.length > 0) { + return [`template may not set reserved claims: ${reserved.join(', ')}`]; + } + + return []; +} + +/** + * Assemble the variables a template can read for one sign-in. Fields the emulator does not + * model — `organization.allow_profiles_outside_organization` and + * `organization_membership.custom_attributes` among them — are left out rather than filled + * with a plausible value, so a template referencing them resolves to null. + */ +export function buildJwtTemplateContext( + ws: WorkOSStore, + user: WorkOSUser, + organizationId?: string | null, +): JwtTemplateContext { + const context: JwtTemplateContext = { + user: { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + email_verified: user.email_verified, + profile_picture_url: user.profile_picture_url, + external_id: user.external_id, + metadata: user.metadata, + }, + }; + + if (!organizationId) return context; + + const organization = ws.organizations.get(organizationId); + if (organization) { + context.organization = { + id: organization.id, + name: organization.name, + domains: ws.organizationDomains + .findBy('organization_id', organization.id) + .map((domain) => ({ id: domain.id, domain: domain.domain, state: domain.state })), + stripe_customer_id: organization.stripe_customer_id, + external_id: organization.external_id, + metadata: organization.metadata, + }; + } + + const membership = ws.organizationMemberships + .findBy('organization_id', organizationId) + .find((m) => m.user_id === user.id); + if (membership) { + context.organization_membership = { + id: membership.id, + role: membership.role.slug, + roles: [membership.role.slug], + external_id: membership.external_id, + metadata: membership.metadata, + }; + } + + return context; +} + +/** + * Render the environment's configured template, if one is set, into claims for a token. + * Returns undefined when no template is configured. + */ +export function renderConfiguredJwtTemplate( + store: Store, + ws: WorkOSStore, + user: WorkOSUser, + organizationId?: string | null, +): Record | undefined { + const template = store.getData(STORE_KEYS.jwtTemplate); + if (!template?.content) return undefined; + + try { + return renderJwtTemplate(template.content, buildJwtTemplateContext(ws, user, organizationId)); + } catch (error) { + // A template that will not render is a configuration error. Failing the sign-in puts it + // where a test can see it, instead of handing back a token quietly missing its claims. + const detail = error instanceof Error ? error.message : String(error); + throw new WorkOSApiError(422, `JWT template could not be rendered: ${detail}`, 'unprocessable_entity'); + } +} diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 49bd023..a84b5fe 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -17,6 +17,7 @@ import { formatAuthChallenge, acceptInvitation, } from '../helpers.js'; +import { renderConfiguredJwtTemplate } from '../jwt-template.js'; import type { EventBus } from '../event-bus.js'; import type { WorkOSInvitation } from '../entities.js'; import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; @@ -689,17 +690,20 @@ export function authRoutes(ctx: RouteContext): void { } } - const accessToken = jwt.sign({ - sub: user.id, - sid: session.id, - org_id: organizationId ?? undefined, - role: roleSlug, - // Production emits the plural `roles` alongside `role`; the emulator models one role per - // membership, so it is that role as a single-element array. - roles: roleSlug ? [roleSlug] : undefined, - permissions: permissionSlugs, - aud: clientId ?? 'workos-emulate', - }); + const accessToken = jwt.sign( + { + sub: user.id, + sid: session.id, + org_id: organizationId ?? undefined, + role: roleSlug, + // Production emits the plural `roles` alongside `role`; the emulator models one role per + // membership, so it is that role as a single-element array. + roles: roleSlug ? [roleSlug] : undefined, + permissions: permissionSlugs, + aud: clientId ?? 'workos-emulate', + }, + { claims: renderConfiguredJwtTemplate(store, ws, updatedUser, organizationId) }, + ); // Store a real refresh token const newRefreshToken = ws.refreshTokens.insert({ diff --git a/src/workos/routes/config.spec.ts b/src/workos/routes/config.spec.ts index 1906045..2b86c35 100644 --- a/src/workos/routes/config.spec.ts +++ b/src/workos/routes/config.spec.ts @@ -74,26 +74,84 @@ describe('Config routes', () => { }); describe('JWT Template', () => { - it('gets default JWT template', async () => { + const content = '{"urn:myapp:email": "{{ user.email }}"}'; + + it('404s before a template is set', async () => { const res = await req('/user_management/jwt_template'); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.object).toBe('jwt_template'); - expect(data.custom_claims).toEqual({}); + expect(res.status).toBe(404); }); - it('updates JWT template', async () => { + it('updates and persists the template in the spec shape', async () => { const res = await req('/user_management/jwt_template', { method: 'PUT', - body: JSON.stringify({ custom_claims: { role: '{{user.role}}' } }), + body: JSON.stringify({ content }), }); expect(res.status).toBe(200); const data = await json(res); - expect(data.custom_claims).toEqual({ role: '{{user.role}}' }); + expect(data.object).toBe('jwt_template'); + expect(data.content).toBe(content); + expect(data.created_at).toBeString(); + expect(data.updated_at).toBeString(); - // Verify persistence const getRes = await req('/user_management/jwt_template'); - expect((await json(getRes)).custom_claims).toEqual({ role: '{{user.role}}' }); + expect((await json(getRes)).content).toBe(content); + }); + + it('keeps created_at across updates', async () => { + const first = await json( + await req('/user_management/jwt_template', { method: 'PUT', body: JSON.stringify({ content }) }), + ); + const second = await json( + await req('/user_management/jwt_template', { + method: 'PUT', + body: JSON.stringify({ content: '{"a": "b"}' }), + }), + ); + expect(second.created_at).toBe(first.created_at); + }); + + it('rejects a template that sets a reserved claim', async () => { + const res = await req('/user_management/jwt_template', { + method: 'PUT', + body: JSON.stringify({ content: '{"sub": "{{ user.id }}", "iss": "me"}' }), + }); + expect(res.status).toBe(422); + expect((await json(res)).message).toContain('reserved claims: sub, iss'); + }); + + it('rejects a template referencing an unknown variable', async () => { + const res = await req('/user_management/jwt_template', { + method: 'PUT', + body: JSON.stringify({ content: '{"x": "{{ usr.email }}"}' }), + }); + expect(res.status).toBe(422); + expect((await json(res)).message).toContain('unknown template variable `usr`'); + }); + + it('rejects a template that does not render to JSON', async () => { + const res = await req('/user_management/jwt_template', { + method: 'PUT', + body: JSON.stringify({ content: 'not json' }), + }); + expect(res.status).toBe(422); + expect((await json(res)).message).toContain('did not render to valid JSON'); + }); + + it('rejects a missing content field', async () => { + const res = await req('/user_management/jwt_template', { method: 'PUT', body: JSON.stringify({}) }); + expect(res.status).toBe(422); + expect((await json(res)).message).toContain('content is required'); + }); + + // The emulator used to accept `custom_claims` and silently drop it from the token. Point + // anyone still sending it at the field that works instead of accepting it as a no-op. + it('names `content` when handed the old custom_claims field', async () => { + const res = await req('/user_management/jwt_template', { + method: 'PUT', + body: JSON.stringify({ custom_claims: { tenant: 'acme' } }), + }); + expect(res.status).toBe(422); + expect((await json(res)).message).toContain('custom_claims is not a JWT template field'); }); }); }); diff --git a/src/workos/routes/config.ts b/src/workos/routes/config.ts index 4487d92..f7167a7 100644 --- a/src/workos/routes/config.ts +++ b/src/workos/routes/config.ts @@ -2,6 +2,8 @@ import { type RouteContext, parseJsonBody, WorkOSApiError, validationError } fro import { getWorkOSStore } from '../store.js'; import { formatRedirectUri, formatCorsOrigin } from '../helpers.js'; import { STORE_KEYS } from '../constants.js'; +import { validateJwtTemplateContent } from '../jwt-template.js'; +import type { WorkOSJwtTemplate } from '../entities.js'; export function configRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -47,19 +49,40 @@ export function configRoutes(ctx: RouteContext): void { return c.json(formatCorsOrigin(corsOrigin), 201); }); + // A GET before any template is set is a 404, per the spec: an environment either has a + // template or it does not, and an empty one would render to no claims anyway. app.get('/user_management/jwt_template', (c) => { - const template = store.getData>(STORE_KEYS.jwtTemplate) ?? { - object: 'jwt_template', - custom_claims: {}, - }; + const template = store.getData(STORE_KEYS.jwtTemplate); + if (!template) { + throw new WorkOSApiError(404, 'JWT template not found', 'not_found'); + } return c.json(template); }); app.put('/user_management/jwt_template', async (c) => { const body = await parseJsonBody(c); - const template = { + + // `custom_claims` was an emulator-only field that never reached a token. Name the + // replacement rather than accepting it and silently doing nothing. + if (body.custom_claims !== undefined && body.content === undefined) { + throw validationError( + 'custom_claims is not a JWT template field; pass `content` as a template string, e.g. {"content": "{\\"claim\\": \\"{{ user.email }}\\"}"}', + [{ field: 'content', code: 'required' }], + ); + } + + const problems = validateJwtTemplateContent(body.content); + if (problems.length > 0) { + throw validationError(problems.join('; '), [{ field: 'content', code: 'invalid' }]); + } + + const now = new Date().toISOString(); + const existing = store.getData(STORE_KEYS.jwtTemplate); + const template: WorkOSJwtTemplate = { object: 'jwt_template', - custom_claims: (body.custom_claims as Record) ?? {}, + content: body.content as string, + created_at: existing?.created_at ?? now, + updated_at: now, }; store.setData(STORE_KEYS.jwtTemplate, template); return c.json(template); From 47c2c11939b33f089feb05ae19ac07cbb3133ac1 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 4 Aug 2026 14:57:53 -0400 Subject: [PATCH 2/3] fix(auth): render template claims before persisting a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A template that could not render returned its 422 after the session had already been inserted and last_sign_in_at bumped, so a sign-in that never handed back a token still left an orphaned session behind — along with the session.created and user.updated webhooks that imply the login succeeded. Rendering first makes the failure leave no trace. The render has to stay after acceptInvitation, whose membership the template context reads for organization_membership.role. Reading the pre-update user record is equivalent, since no template variable exposes last_sign_in_at. --- src/workos/jwt-template-integration.spec.ts | 22 +++++++++++++++++++++ src/workos/routes/auth.ts | 9 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/workos/jwt-template-integration.spec.ts b/src/workos/jwt-template-integration.spec.ts index 9d69292..773a615 100644 --- a/src/workos/jwt-template-integration.spec.ts +++ b/src/workos/jwt-template-integration.spec.ts @@ -6,6 +6,7 @@ import { describe, it, expect, afterEach } from 'bun:test'; import { generateKeyPairSync } from 'node:crypto'; import { createEmulator, type Emulator } from '../index.js'; +import { getWorkOSStore } from './store.js'; describe('JWT templates end to end', () => { let emulator: Emulator | undefined; @@ -139,6 +140,27 @@ describe('JWT templates end to end', () => { expect(body.message).toContain('over the 3072-byte limit'); }); + // The 422 must arrive before any session state is written. Rendering after the session insert + // left an orphaned session — plus a bumped last_sign_in_at and the webhooks that go with them — + // for a sign-in that never returned a token. + it('persists nothing when a template cannot render', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + ...seed, + users: [{ email: 'alice@acme.com', first_name: 'x'.repeat(4000), password: 'test123' }], + jwtTemplate: { content: '{"urn:myapp:name": "{{ user.first_name }}"}' }, + }, + }); + + const ws = getWorkOSStore(emulator.store); + expect(await login(emulator.url).then((r) => r.status)).toBe(422); + + expect(ws.sessions.all()).toHaveLength(0); + expect(ws.refreshTokens.all()).toHaveLength(0); + expect(ws.users.findOneBy('email', 'alice@acme.com')?.last_sign_in_at).toBeNull(); + }); + it('signs nothing extra when no template is configured', async () => { emulator = await createEmulator({ port: 0, seed }); const { body } = await login(emulator.url); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index a84b5fe..1f847d0 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -645,6 +645,13 @@ export function authRoutes(ctx: RouteContext): void { acceptInvitation(invitation, user, ws, store.getData(STORE_KEYS.eventBus)); } + // Render template claims before anything is persisted. A template that cannot render fails the + // request, and rendering here means that failure leaves no orphaned session, no bumped + // last_sign_in_at, and no session.created/user.updated webhook implying a login that never + // completed. It has to follow acceptInvitation above, whose membership the context reads; the + // pre-update `user` record is equivalent, since no template variable exposes last_sign_in_at. + const templateClaims = renderConfiguredJwtTemplate(store, ws, user, organizationId); + // A fresh login creates a new session (firing session.created); a refresh_token rotation // reuses the existing session, so it emits neither session.created nor an auth event. let session; @@ -702,7 +709,7 @@ export function authRoutes(ctx: RouteContext): void { permissions: permissionSlugs, aud: clientId ?? 'workos-emulate', }, - { claims: renderConfiguredJwtTemplate(store, ws, updatedUser, organizationId) }, + { claims: templateClaims }, ); // Store a real refresh token From 08db57f3d6757ea2acc544bd3ab519cc9edbd67d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 4 Aug 2026 15:40:03 -0400 Subject: [PATCH 3/3] docs(jwt): note that a failed template render consumes an invitation --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b31d75f..4af9661 100644 --- a/README.md +++ b/README.md @@ -589,7 +589,11 @@ fails the boot rather than the first sign-in. `--validate-config` checks it too. WorkOS caps rendered claims at 3072 bytes, because the session cookie carrying them has to fit in a browser. That depends on the data, so it is enforced when the token is signed: a template that renders too large fails the authenticate call with a 422 naming the size, rather than quietly -handing back a token missing its claims. +handing back a token missing its claims. Nothing is persisted when that happens — no session, no +refresh token, no bumped `last_sign_in_at` — with one exception: a login that passed +`invitation_token` has already consumed the invitation by then, and the membership it created +stands. Retrying with the same token returns `invitation_invalid`, so fix the template and re-seed +rather than replaying the login. > Earlier versions accepted a `custom_claims` object on this endpoint and stored it without ever > putting it in a token. That field is gone; `content` is what works. Sending `custom_claims` now