diff --git a/README.md b/README.md index fdb247f..4af9661 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,142 @@ 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. 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 +> 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..773a615 --- /dev/null +++ b/src/workos/jwt-template-integration.spec.ts @@ -0,0 +1,262 @@ +/** + * 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'; +import { getWorkOSStore } from './store.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'); + }); + + // 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); + 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..1f847d0 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'; @@ -644,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; @@ -689,17 +697,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: templateClaims }, + ); // 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);