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/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/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.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..8d7d46b 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,158 @@ 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' }; + const { clientId: _clientId, ...noClientId } = base; + // @ts-expect-error clientId is required in both modes + const missingClientId: AuthKitPublicConfig = noClientId; + expect([ + confidential, + publicConfig, + missingKey, + keyedPublic, + missingClientId, + ]).toHaveLength(5); + }); + }); }); 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); 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; diff --git a/src/service/publicClient.spec.ts b/src/service/publicClient.spec.ts new file mode 100644 index 0000000..cb30601 --- /dev/null +++ b/src/service/publicClient.spec.ts @@ -0,0 +1,271 @@ +/** + * 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 { createHash } from 'node:crypto'; +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), + }); + // 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. + 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(); + + // 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(); + }, + ); +});