diff --git a/README.md b/README.md index 6cb6115..504a22b 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,13 @@ The simplest way is to set environment variables in your `.env.local` file: ```bash WORKOS_CLIENT_ID="client_..." # retrieved from the WorkOS dashboard -WORKOS_API_KEY="sk_test_..." # retrieved from the WorkOS dashboard WORKOS_REDIRECT_URI="http://localhost:5173/callback" # configured in the WorkOS dashboard WORKOS_COOKIE_PASSWORD="" # generate a secure password here +WORKOS_API_KEY="sk_test_..." # optional: retrieved from the WorkOS dashboard; needed for feature flags and WorkOS management APIs ``` +Without `WORKOS_API_KEY`, AuthKit runs as a PKCE public client. See [Public client (keyless) mode](#public-client-keyless-mode). + ### 2. Programmatic Configuration You can also configure AuthKit programmatically by importing the `configure` function: @@ -57,10 +59,10 @@ import { configure } from '@workos-inc/authkit-react-router'; // In your root or entry file configure({ clientId: 'client_1234567890', - apiKey: 'sk_test_1234567890', redirectUri: 'http://localhost:5173/callback', cookiePassword: 'your-secure-cookie-password', // Optional settings + apiKey: 'sk_test_1234567890', // omit for public client (keyless) mode cookieName: 'my-custom-cookie-name', apiHttps: true, cookieMaxAge: 60 * 60 * 24 * 30, // 30 days @@ -100,7 +102,7 @@ When retrieving configuration values, AuthKit follows this priority order: | Option | Environment Variable | Default | Required | Description | | ---------------- | ------------------------ | --------------------- | -------- | --------------------------------------------- | | `clientId` | `WORKOS_CLIENT_ID` | - | Yes | Your WorkOS Client ID | -| `apiKey` | `WORKOS_API_KEY` | - | Yes | Your WorkOS API Key | +| `apiKey` | `WORKOS_API_KEY` | - | No | Your WorkOS API Key. Needed for feature flags and WorkOS management APIs; omit for [public client mode](#public-client-keyless-mode) | | `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 | @@ -114,6 +116,35 @@ When retrieving configuration values, AuthKit follows this priority order: > > The `cookiePassword` must be at least 32 characters long for security reasons. +### 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="client_..." +WORKOS_REDIRECT_URI="http://localhost:5173/callback" +WORKOS_COOKIE_PASSWORD="" +``` + +Every sign-in already uses PKCE: the code verifier stays in an HttpOnly cookie on the browser that started the flow, so the authorization code can only be exchanged by that browser, without a client secret. Session refresh sends the refresh token with your client ID and no secret. + +What works without a key: sign-in and sign-up URLs, the callback (`authLoader`), `authkitLoader` (including automatic refresh), `withAuth` (without automatic refresh), `refreshSession`, `switchToOrganization`, `saveSession`, and `signOut`. + +What needs a key: + +- Feature flags. `getFeatureFlagsRuntimeClient()` throws `Feature flags require a WorkOS API key; ...` in keyless mode. +- Direct WorkOS management calls through `getWorkOS()`, such as `organizations.*` or `userManagement.getUser`. The WorkOS SDK rejects them with an `ApiKeyRequiredException`. + +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. + +> [!NOTE] +> +> To verify: whether your WorkOS environment must allow public clients for this client ID. If the code exchange fails in keyless mode with a client authentication error, check your client's settings in the WorkOS dashboard. + ## Setup ### Callback route @@ -172,6 +203,10 @@ export function App() { ### Evaluate feature flags +> [!NOTE] +> +> Feature flags require a WorkOS API key (`WORKOS_API_KEY` or `configure({ apiKey })`). They aren't available in [public client mode](#public-client-keyless-mode). + By default, `authkitLoader` reads `featureFlags` from the `feature_flags` claim in the WorkOS access token. This is convenient for small flag sets, but changes are reflected only after the user's access token refreshes. @@ -487,7 +522,7 @@ export const loader = async (args: LoaderFunctionArgs) => { ### Advanced: Direct access to the WorkOS client -For advanced use cases or functionality not covered by the helper methods, you can access the underlying WorkOS client directly: +For advanced use cases or functionality not covered by the helper methods, you can access the underlying WorkOS client directly. WorkOS management APIs need an API key; in [public client mode](#public-client-keyless-mode) they throw an `ApiKeyRequiredException`. ```ts import { getWorkOS } from '@workos-inc/authkit-react-router'; diff --git a/src/config.spec.ts b/src/config.spec.ts index 8b5cc27..076a22c 100644 --- a/src/config.spec.ts +++ b/src/config.spec.ts @@ -128,7 +128,76 @@ describe('config', () => { it('throws an error if required values are missing', () => { expect(() => { configure(() => undefined); - getConfig('apiKey'); - }).toThrow(); + getConfig('clientId'); + }).toThrow('Missing required configuration value for clientId (WORKOS_CLIENT_ID).'); + }); + + describe('public client (no API key)', () => { + const publicEnv = { + WORKOS_CLIENT_ID: 'client_public', + WORKOS_REDIRECT_URI: 'http://localhost:5173/callback', + WORKOS_COOKIE_PASSWORD: 'kR620keEzOIzPThfnMEAba8XYgKdQ5vg', + }; + let savedApiKey: string | undefined; + + beforeEach(() => { + 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', () => { + configure(() => undefined); + expect(getConfig('apiKey')).toBeUndefined(); + }); + + it('works with env-only configuration from process.env', () => { + // process.env from jest.setup minus WORKOS_API_KEY + expect(getConfig('apiKey')).toBeUndefined(); + expect(getConfig('clientId')).toBe(process.env.WORKOS_CLIENT_ID); + expect(getConfig('redirectUri')).toBe(process.env.WORKOS_REDIRECT_URI); + expect(getConfig('cookiePassword')).toBe(process.env.WORKOS_COOKIE_PASSWORD); + }); + + it('works with an env-only value source', () => { + configure({}, publicEnv); + expect(getConfig('apiKey')).toBeUndefined(); + expect(getConfig('clientId')).toBe('client_public'); + expect(getConfig('redirectUri')).toBe('http://localhost:5173/callback'); + expect(getConfig('cookiePassword')).toBe(publicEnv.WORKOS_COOKIE_PASSWORD); + }); + + it('accepts a programmatic config without apiKey', () => { + configure( + { + clientId: 'client_public', + redirectUri: 'http://localhost:5173/callback', + cookiePassword: publicEnv.WORKOS_COOKIE_PASSWORD, + }, + () => undefined, + ); + expect(getConfig('apiKey')).toBeUndefined(); + expect(getConfig('clientId')).toBe('client_public'); + }); + + it.each([ + ['clientId', 'WORKOS_CLIENT_ID'], + ['redirectUri', 'WORKOS_REDIRECT_URI'], + ['cookiePassword', 'WORKOS_COOKIE_PASSWORD'], + ] as const)('still requires %s', (key, envKey) => { + const rest: Record = { ...publicEnv }; + delete rest[envKey]; + configure({}, rest); + expect(() => getConfig(key)).toThrow(`Missing required configuration value for ${key} (${envKey}).`); + }); + + it('still validates the cookie password length', () => { + expect(() => configure({ clientId: 'client_public', cookiePassword: 'short' })).toThrow( + 'cookiePassword must be at least 32 characters long', + ); + }); }); }); diff --git a/src/config.ts b/src/config.ts index 741031e..ddd2b0b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,7 +34,9 @@ export class Configuration { private valueSource: ValueSource = defaultSource; - private readonly requiredKeys: (keyof AuthKitConfig)[] = ['clientId', 'apiKey', 'redirectUri', 'cookiePassword']; + // `apiKey` is intentionally optional: without it AuthKit runs as a PKCE + // public client (sign-in only). See `AuthKitPublicConfig`. + private readonly requiredKeys: (keyof AuthKitConfig)[] = ['clientId', 'redirectUri', 'cookiePassword']; /** * Convert a camelCase string to an uppercase, underscore-separated environment variable name. @@ -133,12 +135,21 @@ export function configure(source: ValueSource): void; * @param config The configuration values * * @example + * // Confidential client (API key enables feature flags and management APIs) * configure({ * clientId: 'your-client-id', * redirectUri: 'https://your-app.com/auth/callback', * apiKey: 'your-api-key', * cookiePassword: 'your-cookie-password', * }); + * + * @example + * // Public client (keyless, sign-in only) + * configure({ + * clientId: 'your-client-id', + * redirectUri: 'https://your-app.com/auth/callback', + * cookiePassword: 'your-cookie-password', + * }); */ export function configure(config: Partial): void; /** diff --git a/src/feature-flags.spec.ts b/src/feature-flags.spec.ts index 887abbf..55e71d0 100644 --- a/src/feature-flags.spec.ts +++ b/src/feature-flags.spec.ts @@ -26,4 +26,34 @@ describe('feature flags', () => { expect(createRuntimeClient).toHaveBeenCalledTimes(1); expect(createRuntimeClient).toHaveBeenCalledWith({ pollingIntervalMs: 5000 }); }); + + describe('without an API key', () => { + let savedApiKey: string | undefined; + + beforeEach(() => { + savedApiKey = process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_KEY; + jest.resetModules(); + }); + + afterEach(() => { + if (savedApiKey !== undefined) process.env.WORKOS_API_KEY = savedApiKey; + }); + + it('throws an actionable error before creating a client or calling the network', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch'); + const { getWorkOS: getKeylessWorkOS } = await import('./workos.js'); + const workos = getKeylessWorkOS(); + expect(workos.key).toBeUndefined(); + const createRuntimeClient = jest.spyOn(workos.featureFlags, 'createRuntimeClient'); + const { getFeatureFlagsRuntimeClient } = await import('./feature-flags.js'); + + expect(() => getFeatureFlagsRuntimeClient()).toThrow( + 'Feature flags require a WorkOS API key; set WORKOS_API_KEY or configure({ apiKey }). ' + + 'Public-client (keyless) mode supports sign-in only.', + ); + expect(createRuntimeClient).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/feature-flags.ts b/src/feature-flags.ts index 935c93d..8b74b89 100644 --- a/src/feature-flags.ts +++ b/src/feature-flags.ts @@ -2,13 +2,26 @@ import type { FeatureFlagsRuntimeClient, RuntimeClientOptions } from '@workos-in import { lazy } from './utils.js'; import { getWorkOS } from './workos.js'; +export const FEATURE_FLAGS_API_KEY_REQUIRED = + 'Feature flags require a WorkOS API key; set WORKOS_API_KEY or configure({ apiKey }). ' + + 'Public-client (keyless) mode supports sign-in only.'; + /** * Returns a shared WorkOS Feature Flags runtime client. * * The runtime client keeps feature flag state in sync in the background, so it * should be created once per server process instead of once per request. * Options are only used when the client is created for the first time. + * + * Requires a WorkOS API key (`WORKOS_API_KEY` or `configure({ apiKey })`). + * Throws in public-client (keyless) mode. */ -export const getFeatureFlagsRuntimeClient = lazy( - (options?: RuntimeClientOptions): FeatureFlagsRuntimeClient => getWorkOS().featureFlags.createRuntimeClient(options), -); +export const getFeatureFlagsRuntimeClient = lazy((options?: RuntimeClientOptions): FeatureFlagsRuntimeClient => { + const workos = getWorkOS(); + // Check the client's effective key (which includes the SDK's own + // WORKOS_API_KEY env fallback) so this matches what requests would send. + if (!workos.key) { + throw new Error(FEATURE_FLAGS_API_KEY_REQUIRED); + } + return workos.featureFlags.createRuntimeClient(options); +}); diff --git a/src/index.ts b/src/index.ts index 4688010..2267f7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import { getFeatureFlagsRuntimeClient } from './feature-flags.js'; import { authkitLoader, refreshSession, saveSession } from './session.js'; import { getWorkOS } from './workos.js'; +export type { AuthKitConfig, AuthKitConfidentialConfig, AuthKitPublicConfig } from './interfaces.js'; + export { authLoader, authkitLoader, diff --git a/src/interfaces.ts b/src/interfaces.ts index 9a1b753..e257f37 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -225,21 +225,15 @@ export interface UnauthorizedData { } /** - * 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 @@ -291,3 +285,40 @@ export interface AuthKitConfig { */ cookieName: 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 unlocks features that call WorkOS management APIs: feature flags + * (`getFeatureFlagsRuntimeClient`) and direct `getWorkOS()` calls 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. Feature flags and 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; diff --git a/src/public-client.spec.ts b/src/public-client.spec.ts new file mode 100644 index 0000000..df9b675 --- /dev/null +++ b/src/public-client.spec.ts @@ -0,0 +1,223 @@ +/** + * Wire-level tests for public-client (keyless) mode. + * + * These drive the real sign-in callback and refresh paths against the real + * WorkOS SDK, with only `fetch` mocked, and assert on the request body the SDK + * actually sends to `/user_management/authenticate`. + */ +import type { LoaderFunctionArgs } from 'react-router'; +import type * as Jose from 'jose'; + +// Keep jose real, but serve the JWKS locally so `authkitLoader` can verify +// access tokens without a network call. Everything else hits the real code. +jest.mock('jose', () => ({ ...jest.requireActual('jose'), createRemoteJWKSet: jest.fn() })); + +const API_KEY = 'sk_test_confidential'; + +function base64url(value: object) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +// Decoded (never verified) by the refresh path to read claims. +const accessToken = `${base64url({ alg: 'none' })}.${base64url({ + sid: 'session_123', + exp: Math.floor(Date.now() / 1000) + 300, +})}.sig`; + +function authenticateResponse(refreshToken: string, token = accessToken) { + return new Response( + JSON.stringify({ + 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: token, + refresh_token: refreshToken, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +function cookiePair(setCookie: string) { + return setCookie.split(';')[0]; +} + +describe.each([ + ['public client (no API key)', undefined], + ['confidential client (API key)', API_KEY], +] as const)('%s', (_label, apiKey) => { + let savedApiKey: string | undefined; + let originalFetch: typeof fetch; + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.resetModules(); + savedApiKey = process.env.WORKOS_API_KEY; + if (apiKey) { + process.env.WORKOS_API_KEY = apiKey; + } else { + delete process.env.WORKOS_API_KEY; + } + // The SDK binds globalThis.fetch when the client is constructed. + originalFetch = globalThis.fetch; + fetchMock = jest.fn(); + globalThis.fetch = fetchMock; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (savedApiKey !== undefined) { + process.env.WORKOS_API_KEY = savedApiKey; + } else { + delete process.env.WORKOS_API_KEY; + } + }); + + function expectClientCredentials(call: { body: Record; 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(); + } + } + + function lastAuthenticateCall() { + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://api.workos.com/user_management/authenticate'); + const headers = new Headers(init.headers); + return { body: JSON.parse(init.body), authorization: headers.get('Authorization') }; + } + + it('exchanges the code and refreshes the session with the expected client credentials', async () => { + const { getWorkOS } = await import('./workos.js'); + expect(getWorkOS().key).toBe(apiKey); + + const { configureSessionStorage } = await import('./sessionStorage.js'); + const { getAuthorizationUrl } = await import('./get-authorization-url.js'); + const { authLoader } = await import('./authkit-callback-route.js'); + const { refreshSession } = await import('./session.js'); + await configureSessionStorage(); + + // 1. Start sign-in: PKCE challenge in the URL, verifier in a cookie. + const { url, headers } = await getAuthorizationUrl(); + const authorizationUrl = new URL(url); + 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. + fetchMock.mockResolvedValueOnce(authenticateResponse('refresh_1')); + const callbackUrl = new URL(process.env.WORKOS_REDIRECT_URI!); + callbackUrl.searchParams.set('code', 'code_123'); + callbackUrl.searchParams.set('state', state); + const callbackResponse = (await authLoader()({ + request: new Request(callbackUrl, { headers: { Cookie: cookiePair(headers['Set-Cookie']) } }), + params: {}, + context: {}, + } as LoaderFunctionArgs)) as Response; + expect(callbackResponse.status).toBe(302); + + const exchange = lastAuthenticateCall(); + expect(exchange.body).toMatchObject({ + grant_type: 'authorization_code', + client_id: process.env.WORKOS_CLIENT_ID, + code: 'code_123', + code_verifier: expect.any(String), + }); + expectClientCredentials(exchange); + + // 3. Refresh the session using the sealed session cookie. + fetchMock.mockClear(); + fetchMock.mockResolvedValueOnce(authenticateResponse('refresh_2')); + const sessionCookie = callbackResponse.headers.getSetCookie().find((c) => c.startsWith('wos-session='))!; + const refreshed = await refreshSession( + new Request('http://localhost:5173/', { headers: { Cookie: cookiePair(sessionCookie) } }), + ); + expect(refreshed.user?.id).toBe('user_123'); + expect(refreshed.sessionId).toBe('session_123'); + + const refresh = lastAuthenticateCall(); + expect(refresh.body).toMatchObject({ + grant_type: 'refresh_token', + client_id: process.env.WORKOS_CLIENT_ID, + refresh_token: 'refresh_1', + }); + expectClientCredentials(refresh); + }); + it('refreshes an expired session automatically in authkitLoader', async () => { + // Same (CJS, mocked) module instance that session.ts requires. + const jose = jest.requireMock>('jose'); + const { publicKey, privateKey } = await jose.generateKeyPair('RS256'); + const jwks = jose.createLocalJWKSet({ + keys: [{ ...(await jose.exportJWK(publicKey)), kid: 'key_1', alg: 'RS256' }], + }); + jose.createRemoteJWKSet.mockReturnValue(jwks as unknown as ReturnType); + const now = Math.floor(Date.now() / 1000); + const sign = (exp: number) => + new jose.SignJWT({ sid: 'session_123' }) + .setProtectedHeader({ alg: 'RS256', kid: 'key_1' }) + .setIssuer('https://api.workos.com') + .setIssuedAt(now - 600) + .setExpirationTime(exp) + .sign(privateKey); + const expiredToken = await sign(now - 60); + const freshToken = await sign(now + 300); + + const { configureSessionStorage } = await import('./sessionStorage.js'); + const { authkitLoader, saveSession } = await import('./session.js'); + const { getConfig } = await import('./config.js'); + await configureSessionStorage(); + + // A signed-in session whose access token has expired. + const { headers } = await saveSession( + { + accessToken: expiredToken, + refreshToken: 'refresh_1', + user: { id: 'user_123', email: 'test@example.com' } as never, + headers: {}, + }, + new Request('http://localhost:5173/'), + ); + + fetchMock.mockResolvedValueOnce(authenticateResponse('refresh_2', freshToken)); + const result = await authkitLoader({ + request: new Request('http://localhost:5173/account', { headers: { Cookie: cookiePair(headers['Set-Cookie']) } }), + params: {}, + context: {}, + } as LoaderFunctionArgs); + + // The loader looked up the JWKS for this client and rejected the expired token... + expect(jose.createRemoteJWKSet).toHaveBeenCalledWith( + new URL(`https://api.workos.com/sso/jwks/${getConfig('clientId')}`), + ); + + // ...then refreshed with the expected client credentials... + const refresh = lastAuthenticateCall(); + expect(refresh.body).toMatchObject({ + grant_type: 'refresh_token', + client_id: process.env.WORKOS_CLIENT_ID, + refresh_token: 'refresh_1', + }); + expectClientCredentials(refresh); + + // ...and returned the user with a replacement session cookie. + expect(result.data).toMatchObject({ user: { id: 'user_123' }, sessionId: 'session_123' }); + const setCookie = new Headers(result.init?.headers).get('Set-Cookie'); + expect(setCookie).toMatch(/^wos-session=/); + expect(cookiePair(setCookie!)).not.toBe(cookiePair(headers['Set-Cookie'])); + }); +}); diff --git a/src/workos.spec.ts b/src/workos.spec.ts index 9ac6381..abca657 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -40,6 +40,80 @@ describe('workos', () => { expect(workos.options.https).toBe(false); }); + describe('client construction', () => { + const publicConfig = { + clientId: config.clientId, + cookiePassword: config.cookiePassword, + redirectUri: config.redirectUri, + apiHostname: config.apiHostname, + }; + let savedApiKey: string | undefined; + let WorkOSMock: jest.Mock; + + beforeEach(() => { + savedApiKey = process.env.WORKOS_API_KEY; + WorkOSMock = jest.fn(); + jest.doMock('@workos-inc/node', () => ({ WorkOS: WorkOSMock })); + }); + + afterEach(() => { + jest.dontMock('@workos-inc/node'); + if (savedApiKey !== undefined) process.env.WORKOS_API_KEY = savedApiKey; + }); + + it('passes apiKey and clientId in confidential mode', async () => { + configure({ ...config }); + const { createWorkOSInstance } = await import('./workos.js'); + createWorkOSInstance(); + + expect(WorkOSMock).toHaveBeenCalledTimes(1); + expect(WorkOSMock).toHaveBeenCalledWith({ + apiKey: process.env.WORKOS_API_KEY, + clientId: process.env.WORKOS_CLIENT_ID, + apiHostname: config.apiHostname, + https: true, + port: undefined, + appInfo: { name: 'authkit-react-router', version: expect.any(String) }, + }); + expect(WorkOSMock.mock.calls[0]).toHaveLength(1); + }); + + it('passes clientId and no apiKey in public mode', async () => { + delete process.env.WORKOS_API_KEY; + configure({ ...publicConfig }); + const { createWorkOSInstance } = await import('./workos.js'); + createWorkOSInstance(); + + expect(WorkOSMock).toHaveBeenCalledTimes(1); + const [options] = WorkOSMock.mock.calls[0]; + expect(options.apiKey).toBeUndefined(); + expect(options.clientId).toBe(process.env.WORKOS_CLIENT_ID); + }); + + it('builds a real keyless client in public mode', async () => { + jest.dontMock('@workos-inc/node'); + delete process.env.WORKOS_API_KEY; + configure({ ...publicConfig }); + const { getWorkOS } = await import('./workos.js'); + const workos = getWorkOS(); + + expect(workos.key).toBeUndefined(); + expect(workos.clientId).toBe(process.env.WORKOS_CLIENT_ID); + }); + + 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. + jest.dontMock('@workos-inc/node'); + process.env.WORKOS_API_KEY = 'sk_test_from_process_env'; + const { configure: configureSource } = await import('./config.js'); + configureSource((key: string) => (key === 'WORKOS_API_KEY' ? undefined : process.env[key])); + const { getWorkOS } = await import('./workos.js'); + + expect(getWorkOS().key).toBe('sk_test_from_process_env'); + }); + }); + it('sets the port when provided', async () => { configure({ ...config, apiPort: 3000 }); const { getWorkOS } = await import('./workos.js'); diff --git a/src/workos.ts b/src/workos.ts index 24dc955..9f3127b 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -5,18 +5,31 @@ import { lazy } from './utils.js'; const VERSION = '0.10.0'; /** - * 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 (and feature flags) reject the 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 + // Optional: absent means public-client (keyless) mode const apiKey = getConfig('apiKey'); + const clientId = getConfig('clientId'); // Get optional settings const apiHostname = getConfig('apiHostname'); const apiHttps = getConfig('apiHttps'); const apiPort = getConfig('apiPort'); - const options = { + // Initialize the WorkOS client with config values + const workos = new WorkOS({ + apiKey, + clientId, apiHostname, https: apiHttps, port: apiPort, @@ -24,16 +37,18 @@ export function createWorkOSInstance() { name: 'authkit-react-router', version: 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`. */ export const getWorkOS = lazy(createWorkOSInstance);