From a60d4139a9c353792bd2ffabee7544946d5accf8 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Sat, 5 Sep 2026 03:12:23 +0000 Subject: [PATCH] fix: Derive expected access token issuer from apiHostname Make the issuer used to validate the access token `iss` claim configurable via the new `issuer` option (WORKOS_ISSUER), defaulting to https://${apiHostname} instead of a hardcoded https://api.workos.com. Fixes #83 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 23 ++++++++++++----------- src/interfaces.ts | 7 +++++++ src/session.spec.ts | 34 +++++++++++++++++++++++++++++++++- src/session.ts | 14 ++++++++------ 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 1071065..b9e57da 100644 --- a/README.md +++ b/README.md @@ -83,17 +83,18 @@ When retrieving configuration values, AuthKit follows this priority order: > > To print out the entire config, a `getFullConfig` function is provided for debugging purposes. -| Option | Environment Variable | Default | Required | Description | -| ---------------- | ------------------------ | --------------------- | -------- | --------------------------------------------- | -| `clientId` | `WORKOS_CLIENT_ID` | - | Yes | Your WorkOS Client ID | -| `apiKey` | `WORKOS_API_KEY` | - | Yes | Your WorkOS API Key | -| `redirectUri` | `WORKOS_REDIRECT_URI` | - | Yes | The callback URL configured in WorkOS | -| `cookiePassword` | `WORKOS_COOKIE_PASSWORD` | - | Yes | Password for cookie encryption (min 32 chars) | -| `cookieName` | `WORKOS_COOKIE_NAME` | `wos-session` | No | Name of the session cookie | -| `apiHttps` | `WORKOS_API_HTTPS` | `true` | No | Whether to use HTTPS for API calls | -| `cookieMaxAge` | `WORKOS_COOKIE_MAX_AGE` | `34560000` (400 days) | No | Maximum age of cookie in seconds | -| `apiHostname` | `WORKOS_API_HOSTNAME` | `api.workos.com` | No | WorkOS API hostname | -| `apiPort` | `WORKOS_API_PORT` | - | No | Port to use for API calls | +| Option | Environment Variable | Default | Required | Description | +| ---------------- | ------------------------ | ----------------------- | -------- | --------------------------------------------- | +| `clientId` | `WORKOS_CLIENT_ID` | - | Yes | Your WorkOS Client ID | +| `apiKey` | `WORKOS_API_KEY` | - | Yes | Your WorkOS API Key | +| `redirectUri` | `WORKOS_REDIRECT_URI` | - | Yes | The callback URL configured in WorkOS | +| `cookiePassword` | `WORKOS_COOKIE_PASSWORD` | - | Yes | Password for cookie encryption (min 32 chars) | +| `cookieName` | `WORKOS_COOKIE_NAME` | `wos-session` | No | Name of the session cookie | +| `apiHttps` | `WORKOS_API_HTTPS` | `true` | No | Whether to use HTTPS for API calls | +| `cookieMaxAge` | `WORKOS_COOKIE_MAX_AGE` | `34560000` (400 days) | No | Maximum age of cookie in seconds | +| `apiHostname` | `WORKOS_API_HOSTNAME` | `api.workos.com` | No | WorkOS API hostname | +| `apiPort` | `WORKOS_API_PORT` | - | No | Port to use for API calls | +| `issuer` | `WORKOS_ISSUER` | `https://{apiHostname}` | No | Expected `iss` claim of access tokens | > [!NOTE] > diff --git a/src/interfaces.ts b/src/interfaces.ts index 607aaaf..9808701 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -258,6 +258,13 @@ export interface AuthKitConfig { */ apiPort?: number; + /** + * The expected `iss` claim of WorkOS access tokens + * Equivalent to the WORKOS_ISSUER environment variable + * Defaults to `https://${apiHostname}` + */ + issuer?: string; + /** * The maximum age of the session cookie in seconds * Equivalent to the WORKOS_COOKIE_MAX_AGE environment variable diff --git a/src/session.spec.ts b/src/session.spec.ts index af1fae3..ba92527 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -460,7 +460,7 @@ describe('session', () => { jsonSpy.mockRestore(); }); - it('validates the access token issuer claim against https://api.workos.com', async () => { + it('validates the access token issuer claim against https://api.workos.com by default', async () => { await authkitLoader(createLoaderArgs(createMockRequest())); expect(jwtVerify).toHaveBeenCalled(); @@ -470,6 +470,38 @@ describe('session', () => { } }); + it('derives the expected issuer from apiHostname', async () => { + jwtVerify.mockClear(); + process.env.WORKOS_API_HOSTNAME = 'api.workos-test.com'; + try { + await authkitLoader(createLoaderArgs(createMockRequest())); + } finally { + delete process.env.WORKOS_API_HOSTNAME; + } + + expect(jwtVerify).toHaveBeenCalled(); + for (const call of jwtVerify.mock.calls) { + expect(call[2]).toEqual({ issuer: 'https://api.workos-test.com' }); + } + }); + + it('prefers an explicitly configured issuer over apiHostname', async () => { + jwtVerify.mockClear(); + process.env.WORKOS_API_HOSTNAME = 'api.workos-test.com'; + process.env.WORKOS_ISSUER = 'https://auth.example.com'; + try { + await authkitLoader(createLoaderArgs(createMockRequest())); + } finally { + delete process.env.WORKOS_API_HOSTNAME; + delete process.env.WORKOS_ISSUER; + } + + expect(jwtVerify).toHaveBeenCalled(); + for (const call of jwtVerify.mock.calls) { + expect(call[2]).toEqual({ issuer: 'https://auth.example.com' }); + } + }); + it('should return authorized data with session claims', async () => { const { data } = await authkitLoader(createLoaderArgs(createMockRequest())); diff --git a/src/session.ts b/src/session.ts index cbab1d1..5ea4a64 100644 --- a/src/session.ts +++ b/src/session.ts @@ -771,22 +771,24 @@ function getJWKS(): ReturnType { } return cachedJWKS; } -// WorkOS access tokens carry a fixed `iss` claim regardless of environment -// or client id; see +// The `iss` claim on WorkOS access tokens is the API host that minted the +// token (e.g. `https://api.workos.com`), or a custom issuer when the +// environment has one configured; see // https://workos.com/docs/reference/user-management/session-tokens/access-token. // Validating it defends against tokens signed by a different WorkOS project -// whose JWKS happens to resolve to the same keys, and matches the team's -// "always validate iss" JWT rule. +// whose JWKS happens to resolve to the same keys. // // WorkOS access tokens do not carry a standard `aud` claim — the target // client is encoded as `client_id` instead — so we do not pass `audience` // to jwtVerify here; doing so would reject every token. -const WORKOS_JWT_ISSUER = 'https://api.workos.com'; +function getExpectedIssuer(): string { + return getConfig('issuer') ?? `https://${getConfig('apiHostname')}`; +} async function verifyAccessToken(accessToken: string) { const JWKS = getJWKS(); try { - await jwtVerify(accessToken, JWKS, { issuer: WORKOS_JWT_ISSUER }); + await jwtVerify(accessToken, JWKS, { issuer: getExpectedIssuer() }); return true; } catch (e) { return false;