From e414a726afad6a63fbb0965ac19085306fa64306 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:21:57 -0700 Subject: [PATCH 1/6] feat: allow keyless PKCE public-client configuration Model AuthKitConfig as a union of a confidential config (apiKey) and a public config (no apiKey), drop apiKey from the required keys while still reading it from env, and build the WorkOS client with new WorkOS({ apiKey, clientId, ...options }) so the SDK runs in public-client mode when no key is configured. --- src/core/client/workos.ts | 44 ++++++++++++----------- src/core/config/ConfigurationProvider.ts | 4 ++- src/core/config/types.ts | 46 +++++++++++++++++++----- 3 files changed, 65 insertions(+), 29 deletions(-) diff --git a/src/core/client/workos.ts b/src/core/client/workos.ts index b23ea17..2ce11c5 100644 --- a/src/core/client/workos.ts +++ b/src/core/client/workos.ts @@ -4,35 +4,39 @@ import { once } from '../../utils.js'; import pkg from '../../../package.json' with { type: 'json' }; /** - * Create a WorkOS instance with the provided API key and optional settings. + * Create a WorkOS instance from the AuthKit configuration. + * + * With an API key this is a confidential client. Without one it is a PKCE + * public client: sign-in, callback, refresh and sign-out work, but WorkOS + * management APIs throw an `ApiKeyRequiredException` before any request. + * + * Note: when no API key is configured, the WorkOS SDK itself falls back to + * `process.env.WORKOS_API_KEY`. So if that variable is set in the process + * environment, a key is always in play, even when a custom value source + * passed to `configure()` doesn't provide one. */ export function createWorkOSInstance() { - // Get required API key from config - const apiKey = getConfig('apiKey'); - - // Get optional settings - const apiHostname = getConfig('apiHostname'); - const apiHttps = getConfig('apiHttps'); - const apiPort = getConfig('apiPort'); - - const options = { - apiHostname, - https: apiHttps, - port: apiPort, + return new WorkOS({ + // Optional: absent means public-client (keyless) mode + apiKey: getConfig('apiKey'), + clientId: getConfig('clientId'), + apiHostname: getConfig('apiHostname'), + https: getConfig('apiHttps'), + port: getConfig('apiPort'), appInfo: { name: 'authkit-session', version: pkg.version, }, - }; - - // Initialize the WorkOS client with config values - const workos = new WorkOS(apiKey, options); - - return workos; + }); } /** - * Create a WorkOS instance with the provided API key and optional settings. + * Returns the shared WorkOS client used by AuthKit. * This function is lazy loaded to avoid loading the WorkOS SDK when it's not needed. + * + * Calling WorkOS management APIs directly (e.g. `getWorkOS().organizations`, + * `getWorkOS().userManagement.getUser`) requires an API key + * (`WORKOS_API_KEY` or `configure({ apiKey })`). In public-client (keyless) + * mode those calls throw an `ApiKeyRequiredException` before any request. */ export const getWorkOS = once(createWorkOSInstance); diff --git a/src/core/config/ConfigurationProvider.ts b/src/core/config/ConfigurationProvider.ts index 00bc160..54dc1c4 100644 --- a/src/core/config/ConfigurationProvider.ts +++ b/src/core/config/ConfigurationProvider.ts @@ -33,14 +33,16 @@ export class ConfigurationProvider { private valueSource: ValueSource = defaultSource; + // `apiKey` is intentionally optional: without it AuthKit runs as a PKCE + // public client (sign-in only). See `AuthKitPublicConfig`. private readonly requiredKeys: (keyof AuthKitConfig)[] = [ 'clientId', - 'apiKey', 'redirectUri', 'cookiePassword', ]; private readonly optionalKeys: (keyof AuthKitConfig)[] = [ + 'apiKey', 'apiPort', 'cookieSameSite', 'cookieDomain', diff --git a/src/core/config/types.ts b/src/core/config/types.ts index fa196ca..99e86a9 100644 --- a/src/core/config/types.ts +++ b/src/core/config/types.ts @@ -1,19 +1,13 @@ /** - * AuthKit Configuration Options + * Configuration shared by both client modes. */ -export interface AuthKitConfig { +interface AuthKitBaseConfig { /** * The WorkOS Client ID * Equivalent to the WORKOS_CLIENT_ID environment variable */ clientId: string; - /** - * The WorkOS API Key - * Equivalent to the WORKOS_API_KEY environment variable - */ - apiKey: string; - /** * The redirect URI for the authentication callback * Equivalent to the WORKOS_REDIRECT_URI environment variable @@ -71,5 +65,41 @@ export interface AuthKitConfig { cookieDomain?: string; } +/** + * Confidential client: the server holds a WorkOS API key. + * + * The key is sent as the client secret on code exchange and refresh + * (alongside PKCE) and is required for WorkOS management APIs called through + * `getWorkOS()`, such as `organizations.*` or `userManagement.getUser`. + */ +export interface AuthKitConfidentialConfig extends AuthKitBaseConfig { + /** + * The WorkOS API Key + * Equivalent to the WORKOS_API_KEY environment variable + */ + apiKey: string; +} + +/** + * Public client (keyless): the server holds no WorkOS API key. + * + * Sign-in, callback, session refresh, organization switching and sign-out all + * work; PKCE protects the code exchange and the refresh token is bound to the + * client ID. WorkOS management APIs are unavailable. + */ +export interface AuthKitPublicConfig extends AuthKitBaseConfig { + apiKey?: never; +} + +/** + * AuthKit Configuration Options + * + * Discriminated on the presence of `apiKey`: {@link AuthKitConfidentialConfig} + * when set, {@link AuthKitPublicConfig} when not. Configuration is resolved at + * runtime from environment variables and `configure()`, so + * `getConfig('apiKey')` is typed `string | undefined`. + */ +export type AuthKitConfig = AuthKitConfidentialConfig | AuthKitPublicConfig; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ValueSource = Record | ((key: string) => any); From 2ed107a1ed3373f65c6b998bc1fb829c977bf58a Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:21:58 -0700 Subject: [PATCH 2/6] test: cover public-client config validation and AuthKitConfig narrowing --- src/core/config.spec.ts | 2 +- src/core/config/ConfigurationProvider.spec.ts | 165 +++++++++++++++++- 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts index 0f212ce..9d7f425 100644 --- a/src/core/config.spec.ts +++ b/src/core/config.spec.ts @@ -131,7 +131,7 @@ describe('config', () => { it('shows all missing fields at once', () => { expect(() => validateConfig()).toThrow(/WORKOS_CLIENT_ID is required/); - expect(() => validateConfig()).toThrow(/WORKOS_API_KEY is required/); + expect(() => validateConfig()).not.toThrow(/WORKOS_API_KEY/); expect(() => validateConfig()).toThrow(/WORKOS_REDIRECT_URI is required/); expect(() => validateConfig()).toThrow( /WORKOS_COOKIE_PASSWORD is required/, diff --git a/src/core/config/ConfigurationProvider.spec.ts b/src/core/config/ConfigurationProvider.spec.ts index c69db23..1dbc12e 100644 --- a/src/core/config/ConfigurationProvider.spec.ts +++ b/src/core/config/ConfigurationProvider.spec.ts @@ -1,5 +1,10 @@ import { vi } from 'vitest'; import { ConfigurationProvider } from './ConfigurationProvider.js'; +import type { + AuthKitConfidentialConfig, + AuthKitConfig, + AuthKitPublicConfig, +} from './types.js'; describe('ConfigurationProvider', () => { let provider: ConfigurationProvider; @@ -163,7 +168,7 @@ describe('ConfigurationProvider', () => { it('throws with all missing required fields at once', () => { expect(() => provider.validate()).toThrow( - /AuthKit configuration error\. Missing or invalid environment variables:\n\n • WORKOS_CLIENT_ID is required\n • WORKOS_API_KEY is required\n • WORKOS_REDIRECT_URI is required\n • WORKOS_COOKIE_PASSWORD is required/, + /AuthKit configuration error\. Missing or invalid environment variables:\n\n • WORKOS_CLIENT_ID is required\n • WORKOS_REDIRECT_URI is required\n • WORKOS_COOKIE_PASSWORD is required/, ); }); @@ -206,7 +211,7 @@ describe('ConfigurationProvider', () => { }); const error = () => provider.validate(); - expect(error).toThrow(/WORKOS_API_KEY is required/); + expect(error).not.toThrow(/WORKOS_API_KEY/); expect(error).toThrow(/WORKOS_REDIRECT_URI is required/); expect(error).toThrow( /WORKOS_COOKIE_PASSWORD must be at least 32 characters/, @@ -227,4 +232,160 @@ describe('ConfigurationProvider', () => { expect(() => provider.validate()).not.toThrow(); }); }); + + describe('public client (no API key)', () => { + const publicEnv: Record = { + WORKOS_CLIENT_ID: 'client_public', + WORKOS_REDIRECT_URI: 'http://localhost:3000/callback', + WORKOS_COOKIE_PASSWORD: 'a'.repeat(32), + }; + let savedApiKey: string | undefined; + + beforeEach(() => { + // Make sure a key in the test runner's environment can't leak in. + savedApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + }); + + afterEach(() => { + if (savedApiKey !== undefined) process.env.WORKOS_API_KEY = savedApiKey; + }); + + it('resolves apiKey to undefined instead of throwing', () => { + expect(provider.getValue('apiKey')).toBeUndefined(); + }); + + it('validates without an apiKey', () => { + provider.setValueSource(publicEnv); + + expect(() => provider.validate()).not.toThrow(); + }); + + it('builds a full config from an env-only value source', () => { + provider.setValueSource(publicEnv); + + const config = provider.getConfig(); + expect(config).not.toHaveProperty('apiKey'); + expect(config).toMatchObject({ + clientId: 'client_public', + redirectUri: 'http://localhost:3000/callback', + cookiePassword: publicEnv.WORKOS_COOKIE_PASSWORD, + }); + }); + + it('builds a full config from process.env with no WORKOS_API_KEY', () => { + const fresh = new ConfigurationProvider(); // default process.env source + const saved = { ...process.env }; + Object.assign(process.env, publicEnv); + try { + expect(() => fresh.validate()).not.toThrow(); + const config = fresh.getConfig(); + expect(config.apiKey).toBeUndefined(); + expect(config.clientId).toBe('client_public'); + } finally { + for (const key of Object.keys(publicEnv)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } + }); + + it('accepts a programmatic config without apiKey', () => { + provider.configure({ + clientId: 'client_public', + redirectUri: 'http://localhost:3000/callback', + cookiePassword: 'a'.repeat(32), + }); + + expect(() => provider.validate()).not.toThrow(); + expect(provider.getConfig().apiKey).toBeUndefined(); + }); + + it('still reads apiKey from an env-only source (confidential mode)', () => { + provider.setValueSource({ ...publicEnv, WORKOS_API_KEY: 'sk_test_env' }); + + expect(provider.getConfig().apiKey).toBe('sk_test_env'); + }); + + it.each([ + ['clientId', 'WORKOS_CLIENT_ID'], + ['redirectUri', 'WORKOS_REDIRECT_URI'], + ['cookiePassword', 'WORKOS_COOKIE_PASSWORD'], + ] as const)('still requires %s', (key, envKey) => { + const rest = { ...publicEnv }; + delete rest[envKey]; + provider.setValueSource(rest); + + expect(() => provider.getValue(key)).toThrow( + `Missing required configuration value for ${key} (${envKey}).`, + ); + expect(() => provider.getConfig()).toThrow(envKey); + expect(() => provider.validate()).toThrow(`${envKey} is required`); + }); + + it('still validates the cookie password length', () => { + provider.setValueSource({ + ...publicEnv, + WORKOS_COOKIE_PASSWORD: 'short', + }); + + expect(() => provider.validate()).toThrow( + /WORKOS_COOKIE_PASSWORD must be at least 32 characters/, + ); + }); + }); + + describe('AuthKitConfig types', () => { + const base = { + clientId: 'client_123', + redirectUri: 'http://localhost:3000/callback', + cookiePassword: 'a'.repeat(32), + apiHttps: true, + cookieMaxAge: 60, + cookieName: 'wos-session', + }; + + it('narrows on the presence of apiKey', () => { + const narrow = (config: AuthKitConfig) => { + if (config.apiKey !== undefined) { + expectTypeOf(config).toEqualTypeOf(); + expectTypeOf(config.apiKey).toEqualTypeOf(); + } else { + expectTypeOf(config).toEqualTypeOf(); + } + if ('apiKey' in config) { + expectTypeOf(config.apiKey).toEqualTypeOf(); + } + }; + narrow({ ...base, apiKey: 'sk_test' }); + narrow(base); + }); + + it('types a runtime-resolved apiKey as optional', () => { + expectTypeOf().toEqualTypeOf< + string | undefined + >(); + }); + + it('accepts both client shapes and rejects invalid ones', () => { + const confidential: AuthKitConfig = { ...base, apiKey: 'sk_test' }; + const publicConfig: AuthKitConfig = base; + // @ts-expect-error a confidential config must carry a key + const missingKey: AuthKitConfidentialConfig = base; + // @ts-expect-error a public config cannot carry a key + const keyedPublic: AuthKitPublicConfig = { ...base, apiKey: 'sk_test' }; + // @ts-expect-error clientId is required in both modes + const missingClientId: AuthKitPublicConfig = { + ...base, + clientId: undefined, + }; + expect([ + confidential, + publicConfig, + missingKey, + keyedPublic, + missingClientId, + ]).toHaveLength(5); + }); + }); }); From 94e5636fe9851ab6345fb5d7d61a13cf248923e1 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:21:58 -0700 Subject: [PATCH 3/6] test: assert WorkOS client construction in public and confidential modes --- src/core/client/workos.spec.ts | 99 +++++++++++++++++++ src/core/config/ConfigurationProvider.spec.ts | 6 +- 2 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 src/core/client/workos.spec.ts diff --git a/src/core/client/workos.spec.ts b/src/core/client/workos.spec.ts new file mode 100644 index 0000000..eab02f1 --- /dev/null +++ b/src/core/client/workos.spec.ts @@ -0,0 +1,99 @@ +import { vi } from 'vitest'; + +const env = { + WORKOS_CLIENT_ID: 'client_123', + WORKOS_REDIRECT_URI: 'http://localhost:3000/callback', + WORKOS_COOKIE_PASSWORD: 'a'.repeat(32), +}; + +// Fresh module graph per test: the config provider and getWorkOS() are +// process-wide singletons. +async function load(source: Record) { + const { configure } = await import('../config.js'); + configure(key => source[key]); + return import('./workos.js'); +} + +describe('WorkOS client', () => { + let savedApiKey: string | undefined; + + beforeEach(() => { + vi.resetModules(); + savedApiKey = process.env.WORKOS_API_KEY; + // The SDK falls back to process.env.WORKOS_API_KEY; keep it out unless a + // test sets it on purpose. + delete process.env.WORKOS_API_KEY; + }); + + afterEach(() => { + vi.doUnmock('@workos-inc/node'); + if (savedApiKey !== undefined) process.env.WORKOS_API_KEY = savedApiKey; + else delete process.env.WORKOS_API_KEY; + }); + + describe('constructor arguments', () => { + let WorkOSMock: ReturnType; + + beforeEach(() => { + WorkOSMock = vi.fn(); + vi.doMock('@workos-inc/node', () => ({ WorkOS: WorkOSMock })); + }); + + it('passes apiKey and clientId in confidential mode', async () => { + const { createWorkOSInstance } = await load({ + ...env, + WORKOS_API_KEY: 'sk_test_confidential', + }); + createWorkOSInstance(); + + expect(WorkOSMock).toHaveBeenCalledTimes(1); + expect(WorkOSMock.mock.calls[0]).toEqual([ + { + apiKey: 'sk_test_confidential', + clientId: 'client_123', + apiHostname: 'api.workos.com', + https: true, + port: undefined, + appInfo: { name: 'authkit-session', version: expect.any(String) }, + }, + ]); + }); + + it('passes clientId and no apiKey in public mode', async () => { + const { createWorkOSInstance } = await load(env); + createWorkOSInstance(); + + expect(WorkOSMock).toHaveBeenCalledTimes(1); + expect(WorkOSMock.mock.calls[0]).toHaveLength(1); + const [options] = WorkOSMock.mock.calls[0]!; + expect(options.apiKey).toBeUndefined(); + expect(options.clientId).toBe('client_123'); + }); + }); + + it('builds a real keyless client in public mode', async () => { + const { getWorkOS } = await load(env); + const workos = getWorkOS(); + + expect(workos.key).toBeUndefined(); + expect(workos.clientId).toBe('client_123'); + }); + + it('builds a real confidential client with a key', async () => { + const { getWorkOS } = await load({ + ...env, + WORKOS_API_KEY: 'sk_test_confidential', + }); + + expect(getWorkOS().key).toBe('sk_test_confidential'); + }); + + it('picks up WORKOS_API_KEY from process.env even when a custom value source omits it', async () => { + // Documented behavior: the WorkOS SDK falls back to process.env, so a key + // in the process environment always ends up on the client. + process.env.WORKOS_API_KEY = 'sk_test_from_process_env'; + const { getWorkOS } = await load(env); + + expect(getWorkOS().key).toBe('sk_test_from_process_env'); + }); +}); diff --git a/src/core/config/ConfigurationProvider.spec.ts b/src/core/config/ConfigurationProvider.spec.ts index 1dbc12e..8d7d46b 100644 --- a/src/core/config/ConfigurationProvider.spec.ts +++ b/src/core/config/ConfigurationProvider.spec.ts @@ -374,11 +374,9 @@ describe('ConfigurationProvider', () => { const missingKey: AuthKitConfidentialConfig = base; // @ts-expect-error a public config cannot carry a key const keyedPublic: AuthKitPublicConfig = { ...base, apiKey: 'sk_test' }; + const { clientId: _clientId, ...noClientId } = base; // @ts-expect-error clientId is required in both modes - const missingClientId: AuthKitPublicConfig = { - ...base, - clientId: undefined, - }; + const missingClientId: AuthKitPublicConfig = noClientId; expect([ confidential, publicConfig, From 6de251f0c6dfd31b83304c1b7ebc73c9b25a1be9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:21:58 -0700 Subject: [PATCH 4/6] test: assert keyless code exchange and refresh omit client_secret --- src/service/publicClient.spec.ts | 253 +++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 src/service/publicClient.spec.ts diff --git a/src/service/publicClient.spec.ts b/src/service/publicClient.spec.ts new file mode 100644 index 0000000..f8aca65 --- /dev/null +++ b/src/service/publicClient.spec.ts @@ -0,0 +1,253 @@ +/** + * Wire-level tests for public-client (keyless) mode. + * + * These drive the real sign-in, callback and refresh paths through + * `createAuthService` and the real WorkOS SDK, with only `fetch` mocked, and + * assert on the request the SDK actually sends to + * `/user_management/authenticate`. + */ +import { vi } from 'vitest'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import sessionEncryption from '../core/encryption/ironWebcryptoEncryption.js'; +import { CookieSessionStorage } from '../core/session/CookieSessionStorage.js'; +import type { AuthKitConfig } from '../core/config/types.js'; + +const API_KEY = 'sk_test_confidential'; +const CLIENT_ID = 'client_123'; +const env: Record = { + WORKOS_CLIENT_ID: CLIENT_ID, + WORKOS_REDIRECT_URI: 'http://localhost:3000/callback', + WORKOS_COOKIE_PASSWORD: 'a'.repeat(32), +}; + +class RequestStorage extends CookieSessionStorage { + async getCookie(request: Request, name: string): Promise { + for (const part of (request.headers.get('cookie') ?? '').split(';')) { + const [k, ...rest] = part.trim().split('='); + if (k === name) return decodeURIComponent(rest.join('=')); + } + return null; + } +} + +function cookiePair(setCookie: string | string[] | undefined) { + const first = Array.isArray(setCookie) ? setCookie[0] : setCookie; + return first!.split(';')[0]!; +} + +// One signing key per run; the mocked JWKS endpoint serves its public half so +// `withAuth` verifies tokens exactly as it does in production. +const signingKey = generateKeyPair('RS256'); +async function signAccessToken(expiresAt: number) { + const { privateKey } = await signingKey; + return new SignJWT({ sid: 'session_123' }) + .setProtectedHeader({ alg: 'RS256', kid: 'key_1' }) + .setIssuedAt(expiresAt - 300) + .setExpirationTime(expiresAt) + .sign(privateKey); +} + +function authenticateResponse(accessToken: string, refreshToken: string) { + return Response.json({ + user: { + object: 'user', + id: 'user_123', + email: 'test@example.com', + email_verified: true, + first_name: 'Test', + last_name: 'User', + profile_picture_url: null, + last_sign_in_at: null, + locale: null, + external_id: null, + metadata: {}, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + access_token: accessToken, + refresh_token: refreshToken, + }); +} + +describe.each([ + ['public client (no API key)', undefined], + ['confidential client (API key)', API_KEY], +] as const)('%s', (_label, apiKey) => { + let savedApiKey: string | undefined; + let fetchMock: ReturnType; + let authenticateCalls: { body: any; authorization: string | null }[]; + let nextAuthenticate: () => Promise; + + beforeEach(async () => { + vi.resetModules(); + // The SDK falls back to process.env.WORKOS_API_KEY, so a key in the test + // runner's environment would silently turn the public case confidential. + savedApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + + const jwk = await exportJWK((await signingKey).publicKey); + authenticateCalls = []; + fetchMock = vi.fn(async (input: string | URL | Request, init?: any) => { + const url = String(input instanceof Request ? input.url : input); + if (url === `https://api.workos.com/sso/jwks/${CLIENT_ID}`) { + return Response.json({ + keys: [{ ...jwk, kid: 'key_1', alg: 'RS256' }], + }); + } + if (url === 'https://api.workos.com/user_management/authenticate') { + authenticateCalls.push({ + body: JSON.parse(init.body), + authorization: new Headers(init.headers).get('Authorization'), + }); + return nextAuthenticate(); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + // The SDK binds fetch when the client is constructed (lazily, below). + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (savedApiKey !== undefined) process.env.WORKOS_API_KEY = savedApiKey; + }); + + async function createService() { + const { configure } = await import('../core/config.js'); + const { createAuthService } = await import('./factory.js'); + const source = apiKey ? { ...env, WORKOS_API_KEY: apiKey } : env; + configure(key => source[key]); + return createAuthService({ + sessionStorageFactory: (config: AuthKitConfig) => + new RequestStorage(config), + }); + } + + function expectClientCredentials(call: { + body: any; + authorization: string | null; + }) { + if (apiKey) { + expect(call.body.client_secret).toBe(apiKey); + expect(call.authorization).toBe(`Bearer ${apiKey}`); + } else { + expect(call.body).not.toHaveProperty('client_secret'); + expect(call.authorization).toBeNull(); + } + } + + it('builds the client in the expected mode', async () => { + const service = await createService(); + expect(service.getWorkOS().key).toBe(apiKey); + }); + + it('signs in with PKCE, exchanges the code and refreshes the session', async () => { + const service = await createService(); + const now = Math.floor(Date.now() / 1000); + + // 1. Sign in: PKCE challenge in the URL, verifier sealed into a cookie. + const signIn = await service.createSignIn(undefined); + const authorizationUrl = new URL(signIn.url); + expect(authorizationUrl.searchParams.get('client_id')).toBe(CLIENT_ID); + expect(authorizationUrl.searchParams.get('code_challenge')).toBeTruthy(); + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe( + 'S256', + ); + const state = authorizationUrl.searchParams.get('state')!; + + // 2. Callback: exchange the code with the verifier. + nextAuthenticate = async () => + authenticateResponse(await signAccessToken(now + 300), 'refresh_1'); + const callback = await service.handleCallback( + new Request(`${env.WORKOS_REDIRECT_URI}?code=code_123&state=${state}`, { + headers: { cookie: cookiePair(signIn.headers?.['Set-Cookie']) }, + }), + undefined, + { code: 'code_123', state }, + ); + expect(callback.authResponse.user.id).toBe('user_123'); + + expect(authenticateCalls).toHaveLength(1); + const exchange = authenticateCalls[0]!; + expect(exchange.body).toMatchObject({ + grant_type: 'authorization_code', + client_id: CLIENT_ID, + code: 'code_123', + code_verifier: expect.any(String), + }); + expectClientCredentials(exchange); + + // 3. Explicit refresh of the saved session. + const sessionCookie = cookiePair(callback.headers?.['Set-Cookie']); + expect(sessionCookie).toMatch(/^wos-session=/); + const session = await service.getSession( + new Request('http://localhost:3000/', { + headers: { cookie: sessionCookie }, + }), + ); + nextAuthenticate = async () => + authenticateResponse(await signAccessToken(now + 600), 'refresh_2'); + const refreshed = await service.refreshSession(session!); + if (!refreshed.auth.user) throw new Error('expected a signed-in user'); + expect(refreshed.auth.user.id).toBe('user_123'); + expect(refreshed.auth.refreshToken).toBe('refresh_2'); + + expect(authenticateCalls).toHaveLength(2); + const refresh = authenticateCalls[1]!; + expect(refresh.body).toMatchObject({ + grant_type: 'refresh_token', + client_id: CLIENT_ID, + refresh_token: 'refresh_1', + }); + expectClientCredentials(refresh); + }); + + it('refreshes an expired session automatically in withAuth', async () => { + const service = await createService(); + const now = Math.floor(Date.now() / 1000); + const sealed = await sessionEncryption.sealData( + { + accessToken: await signAccessToken(now - 60), // expired + refreshToken: 'refresh_1', + user: { id: 'user_123' }, + }, + { password: env.WORKOS_COOKIE_PASSWORD!, ttl: 0 }, + ); + const { headers } = await service.saveSession(undefined, sealed); + + nextAuthenticate = async () => + authenticateResponse(await signAccessToken(now + 300), 'refresh_2'); + const { auth, refreshedSessionData } = await service.withAuth( + new Request('http://localhost:3000/', { + headers: { cookie: cookiePair(headers?.['Set-Cookie']) }, + }), + ); + + expect(auth.user?.id).toBe('user_123'); + expect(refreshedSessionData).toEqual(expect.any(String)); + expect(authenticateCalls).toHaveLength(1); + expect(authenticateCalls[0]!.body).toMatchObject({ + grant_type: 'refresh_token', + client_id: CLIENT_ID, + refresh_token: 'refresh_1', + }); + expectClientCredentials(authenticateCalls[0]!); + }); + + it.runIf(!apiKey)( + 'rejects WorkOS management calls before any network request', + async () => { + const { ApiKeyRequiredException } = await import('@workos-inc/node'); + const service = await createService(); + const workos = service.getWorkOS(); + + await expect(workos.userManagement.getUser('user_123')).rejects.toThrow( + ApiKeyRequiredException, + ); + await expect(workos.organizations.listOrganizations()).rejects.toThrow( + ApiKeyRequiredException, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); +}); From 6977fa5e044ab64e95bc0eb810ceab0cada0fff1 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:21:58 -0700 Subject: [PATCH 5/6] docs: document public client (keyless) mode --- README.md | 35 ++++++++++++++++++++++++++++++----- src/service/AuthService.ts | 5 +++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f368dd2..72e8d1e 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,13 @@ pnpm add @workos/authkit-session ```bash WORKOS_CLIENT_ID=your-client-id -WORKOS_API_KEY=your-api-key WORKOS_REDIRECT_URI=https://yourdomain.com/auth/callback WORKOS_COOKIE_PASSWORD=must-be-at-least-32-characters-long-secret +WORKOS_API_KEY=your-api-key # optional; needed for WorkOS management APIs ``` +Without `WORKOS_API_KEY`, AuthKit runs as a PKCE public client. See [Public client (keyless) mode](#public-client-keyless-mode). + Or programmatically: ```typescript @@ -40,9 +42,9 @@ import { configure } from '@workos/authkit-session'; configure({ clientId: 'your-client-id', - apiKey: 'your-api-key', redirectUri: 'https://yourdomain.com/auth/callback', cookiePassword: 'must-be-at-least-32-characters-long-secret', + apiKey: 'your-api-key', // optional: omit for public client (keyless) mode }); ``` @@ -167,7 +169,7 @@ auth.claims.sid; // string | Environment Variable | Config Key | Description | | ------------------------- | ---------------- | ------------------------------------ | | `WORKOS_CLIENT_ID` | `clientId` | WorkOS client ID | -| `WORKOS_API_KEY` | `apiKey` | WorkOS API key | +| `WORKOS_API_KEY` | `apiKey` | WorkOS API key (optional, see below) | | `WORKOS_REDIRECT_URI` | `redirectUri` | OAuth callback URL | | `WORKOS_COOKIE_PASSWORD` | `cookiePassword` | 32+ char encryption key | | `WORKOS_COOKIE_NAME` | `cookieName` | Cookie name (default: `wos-session`) | @@ -175,7 +177,30 @@ auth.claims.sid; // string | `WORKOS_COOKIE_DOMAIN` | `cookieDomain` | Cookie domain | | `WORKOS_COOKIE_SAME_SITE` | `cookieSameSite` | `lax`, `strict`, or `none` | -Environment variables override programmatic config. +Environment variables override programmatic config. `WORKOS_CLIENT_ID`, `WORKOS_REDIRECT_URI` and `WORKOS_COOKIE_PASSWORD` are required; everything else is optional. + +### Public client (keyless) mode + +If your service only signs users in and shouldn't hold a WorkOS secret key, leave `WORKOS_API_KEY` unset. AuthKit then runs as an OAuth public client and needs only three variables: + +```bash +WORKOS_CLIENT_ID=your-client-id +WORKOS_REDIRECT_URI=https://yourdomain.com/auth/callback +WORKOS_COOKIE_PASSWORD=must-be-at-least-32-characters-long-secret +``` + +Every sign-in already uses PKCE: the code verifier is sealed into an HttpOnly cookie on the browser that started the flow, so only that browser can complete the code exchange, and no client secret is sent. Session refresh sends the refresh token with your client ID and no secret. + +What works without a key: `createSignIn` / `createSignUp` / `createAuthorization`, `handleCallback`, `withAuth` (including automatic refresh), `refreshSession`, `switchOrganization`, `signOut`, and the session storage helpers. + +What needs a key: WorkOS management APIs called directly through `getWorkOS()` or `authService.getWorkOS()`, such as `organizations.*` or `userManagement.getUser`. Without a key the WorkOS SDK rejects them with an `ApiKeyRequiredException` before sending a request. + +Keyless mode needs `@workos-inc/node` 8.0.0 or later, which every version in this package's peer range (`^8.0.0 || ^9.0.0 || ^10.0.0`) satisfies. + +In TypeScript, `AuthKitConfig` is a union of `AuthKitConfidentialConfig` (with `apiKey`) and `AuthKitPublicConfig` (without). Because configuration is resolved from the environment at runtime, `getConfig('apiKey')` is typed `string | undefined`. + +> [!IMPORTANT] +> The WorkOS Node SDK falls back to `process.env.WORKOS_API_KEY` when no key is configured. If that variable is set in the server's environment, a key is in play, even if you use a custom value source with `configure()`. To run keyless, make sure `WORKOS_API_KEY` is absent from the process environment. ## API Overview @@ -275,7 +300,7 @@ If the callback URL has no `state` (malformed callback), skip this call — the ### Direct Access (Advanced) -For maximum control, use the primitives directly: +For maximum control, use the primitives directly. WorkOS management APIs on the client from `getWorkOS()` need an API key; in [public client mode](#public-client-keyless-mode) they throw an `ApiKeyRequiredException`. ```typescript import { diff --git a/src/service/AuthService.ts b/src/service/AuthService.ts index a97b799..4b1bae2 100644 --- a/src/service/AuthService.ts +++ b/src/service/AuthService.ts @@ -325,6 +325,11 @@ export class AuthService { /** * Get the WorkOS client instance. * Useful for direct API calls not covered by AuthKit. + * + * WorkOS management APIs (e.g. `organizations.*`, + * `userManagement.getUser`) require an API key (`WORKOS_API_KEY` or + * `configure({ apiKey })`). In public-client (keyless) mode they throw an + * `ApiKeyRequiredException` before any request. */ getWorkOS(): WorkOS { return this.client; From f8bd3ba51fea846adb1f91d0601ae448bee52c7e Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:29:35 -0700 Subject: [PATCH 6/6] test: check PKCE verifier against challenge and pin API-key error message --- src/service/publicClient.spec.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/service/publicClient.spec.ts b/src/service/publicClient.spec.ts index f8aca65..cb30601 100644 --- a/src/service/publicClient.spec.ts +++ b/src/service/publicClient.spec.ts @@ -6,6 +6,7 @@ * assert on the request the SDK actually sends to * `/user_management/authenticate`. */ +import { createHash } from 'node:crypto'; import { vi } from 'vitest'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import sessionEncryption from '../core/encryption/ironWebcryptoEncryption.js'; @@ -175,6 +176,12 @@ describe.each([ code: 'code_123', code_verifier: expect.any(String), }); + // The verifier sent must be the one behind the URL's S256 challenge. + expect( + createHash('sha256') + .update(exchange.body.code_verifier) + .digest('base64url'), + ).toBe(authorizationUrl.searchParams.get('code_challenge')); expectClientCredentials(exchange); // 3. Explicit refresh of the saved session. @@ -241,12 +248,23 @@ describe.each([ const service = await createService(); const workos = service.getWorkOS(); - await expect(workos.userManagement.getUser('user_123')).rejects.toThrow( - ApiKeyRequiredException, - ); - await expect(workos.organizations.listOrganizations()).rejects.toThrow( - ApiKeyRequiredException, - ); + // The SDK owns this error (AuthKit doesn't wrap the client); pin that it + // is the actionable API-key error for the called path, not a 401. + for (const [call, path] of [ + [ + () => workos.userManagement.getUser('user_123'), + '/user_management/users/user_123', + ], + [() => workos.organizations.listOrganizations(), '/organizations'], + ] as const) { + const error = await call().catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiKeyRequiredException); + expect(error).toMatchObject({ status: 403, path }); + expect((error as Error).message).toContain( + `API key required for "${path}"`, + ); + expect((error as Error).message).toContain('new WorkOS("sk_...")'); + } expect(fetchMock).not.toHaveBeenCalled(); }, );