From b63f18b4dee68851841f01f783562454fecb37cf Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 1 Jul 2026 13:56:17 +0100 Subject: [PATCH] let user init authkit with secrets outside of process.env --- src/auth.ts | 4 +- src/authkit-callback-route.ts | 4 +- src/config.spec.ts | 116 ++++++++++++++++++++++++++++++++++ src/config.ts | 106 +++++++++++++++++++++++++++++++ src/cookie.spec.ts | 89 ++++++++++++-------------- src/cookie.ts | 30 ++++----- src/env-variables.ts | 36 ----------- src/get-authorization-url.ts | 14 ++-- src/index.ts | 3 + src/middleware.ts | 4 +- src/pkce.spec.ts | 18 +++--- src/pkce.ts | 4 +- src/session.spec.ts | 26 +++----- src/session.ts | 34 +++++----- src/test-helpers.ts | 8 +-- src/workos.spec.ts | 1 + src/workos.ts | 32 +++++----- 17 files changed, 348 insertions(+), 181 deletions(-) create mode 100644 src/config.spec.ts create mode 100644 src/config.ts delete mode 100644 src/env-variables.ts diff --git a/src/auth.ts b/src/auth.ts index 3ce9f57..c549377 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,7 +4,7 @@ import { decodeJwt } from 'jose'; import { revalidatePath, revalidateTag } from 'next/cache'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { WORKOS_COOKIE_NAME } from './env-variables.js'; +import { config } from './config.js'; import { getCookieOptions, getPKCECookieOptions } from './cookie.js'; import { getAuthorizationUrl } from './get-authorization-url.js'; import type { AccessToken, GetAuthURLOptions, SwitchToOrganizationOptions, UserInfo } from './interfaces.js'; @@ -71,7 +71,7 @@ export async function signOut({ returnTo }: { returnTo?: string } = {}) { } } finally { const nextCookies = await cookies(); - const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + const cookieName = config.cookieName || 'wos-session'; const { domain, path, sameSite, secure } = getCookieOptions(); try { nextCookies.delete({ name: cookieName, domain, path, sameSite, secure }); diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts index b2b8e9c..751a58b 100644 --- a/src/authkit-callback-route.ts +++ b/src/authkit-callback-route.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import { getPKCECookieOptions } from './cookie.js'; -import { WORKOS_CLIENT_ID } from './env-variables.js'; +import { config } from './config.js'; import { HandleAuthOptions } from './interfaces.js'; import { PKCE_COOKIE_NAME, getPKCECookieNameForState, getStateFromPKCECookieValue } from './pkce.js'; import { saveSession } from './session.js'; @@ -66,7 +66,7 @@ export function handleAuth(options: HandleAuthOptions = {}) { // Use the code returned to us by AuthKit and authenticate the user with WorkOS const { accessToken, refreshToken, user, impersonator, oauthTokens, authenticationMethod, organizationId } = await getWorkOS().userManagement.authenticateWithCode({ - clientId: WORKOS_CLIENT_ID, + clientId: config.clientId, code, codeVerifier, }); diff --git a/src/config.spec.ts b/src/config.spec.ts new file mode 100644 index 0000000..281cd29 --- /dev/null +++ b/src/config.spec.ts @@ -0,0 +1,116 @@ +import { config, initAuthKit } from './config.js'; + +describe('config', () => { + beforeEach(() => { + vi.resetModules(); + delete (globalThis as Record)[Symbol.for('workos.authkit.overrides')]; + }); + + describe('env variable fallbacks', () => { + it('reads apiKey from WORKOS_API_KEY', () => { + expect(config.apiKey).toBe(process.env.WORKOS_API_KEY); + }); + + it('reads clientId from WORKOS_CLIENT_ID', () => { + expect(config.clientId).toBe(process.env.WORKOS_CLIENT_ID); + }); + + it('reads cookiePassword from WORKOS_COOKIE_PASSWORD', () => { + expect(config.cookiePassword).toBe(process.env.WORKOS_COOKIE_PASSWORD); + }); + + it('reads redirectUri from NEXT_PUBLIC_WORKOS_REDIRECT_URI', () => { + expect(config.redirectUri).toBe(process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI); + }); + + it('reads cookieDomain from WORKOS_COOKIE_DOMAIN', () => { + expect(config.cookieDomain).toBe(process.env.WORKOS_COOKIE_DOMAIN); + }); + + it('returns undefined for unset optional values', () => { + expect(config.cookieName).toBeUndefined(); + expect(config.cookieMaxAge).toBeUndefined(); + expect(config.cookieSameSite).toBeUndefined(); + expect(config.claimToken).toBeUndefined(); + expect(config.apiHostname).toBeUndefined(); + expect(config.apiPort).toBeUndefined(); + }); + + it('defaults apiHttps to true when WORKOS_API_HTTPS is not set', () => { + expect(config.apiHttps).toBe(true); + }); + + it('parses WORKOS_API_HTTPS from env', () => { + process.env.WORKOS_API_HTTPS = 'false'; + expect(config.apiHttps).toBe(false); + delete process.env.WORKOS_API_HTTPS; + }); + + it('parses WORKOS_COOKIE_MAX_AGE from env', () => { + process.env.WORKOS_COOKIE_MAX_AGE = '3600'; + expect(config.cookieMaxAge).toBe(3600); + delete process.env.WORKOS_COOKIE_MAX_AGE; + }); + + it('returns undefined for an invalid WORKOS_COOKIE_MAX_AGE', () => { + process.env.WORKOS_COOKIE_MAX_AGE = 'not-a-number'; + expect(config.cookieMaxAge).toBeUndefined(); + delete process.env.WORKOS_COOKIE_MAX_AGE; + }); + + it('parses WORKOS_API_PORT from env', () => { + process.env.WORKOS_API_PORT = '8080'; + expect(config.apiPort).toBe(8080); + delete process.env.WORKOS_API_PORT; + }); + }); + + describe('initAuthKit overrides', () => { + it('takes precedence over env variables', async () => { + const { config: freshConfig, initAuthKit: freshInit } = await import('./config.js'); + freshInit({ apiKey: 'override-key', cookiePassword: 'override-password-with-enough-length' }); + + expect(freshConfig.apiKey).toBe('override-key'); + expect(freshConfig.cookiePassword).toBe('override-password-with-enough-length'); + }); + + it('falls back to env for keys not in the override', async () => { + const { config: freshConfig, initAuthKit: freshInit } = await import('./config.js'); + freshInit({ apiKey: 'custom-key' }); + + expect(freshConfig.clientId).toBe(process.env.WORKOS_CLIENT_ID); + }); + + it('merges successive calls', async () => { + const { config: freshConfig, initAuthKit: freshInit } = await import('./config.js'); + freshInit({ apiKey: 'key1' }); + freshInit({ cookiePassword: 'password-long-enough-to-pass-32-chars' }); + + expect(freshConfig.apiKey).toBe('key1'); + expect(freshConfig.cookiePassword).toBe('password-long-enough-to-pass-32-chars'); + }); + + it('overrides cookieMaxAge as a number directly', async () => { + const { config: freshConfig, initAuthKit: freshInit } = await import('./config.js'); + freshInit({ cookieMaxAge: 7200 }); + + expect(freshConfig.cookieMaxAge).toBe(7200); + }); + + it('overrides apiHttps as a boolean directly', async () => { + const { config: freshConfig, initAuthKit: freshInit } = await import('./config.js'); + freshInit({ apiHttps: false }); + + expect(freshConfig.apiHttps).toBe(false); + }); + + it('restores env fallback when override is cleared with undefined', async () => { + const { config: freshConfig, initAuthKit: freshInit } = await import('./config.js'); + freshInit({ apiKey: 'custom' }); + expect(freshConfig.apiKey).toBe('custom'); + + freshInit({ apiKey: undefined }); + expect(freshConfig.apiKey).toBe(process.env.WORKOS_API_KEY); + }); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..db67c04 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,106 @@ +export interface AuthKitConfig { + /** WorkOS API key. Overrides `WORKOS_API_KEY`. Applied once at client creation. */ + apiKey?: string; + /** WorkOS client ID. Overrides `WORKOS_CLIENT_ID`. */ + clientId?: string; + /** Secret used to seal/unseal session cookies. At least 32 characters. Overrides `WORKOS_COOKIE_PASSWORD`. */ + cookiePassword?: string; + /** OAuth redirect URI. Overrides `NEXT_PUBLIC_WORKOS_REDIRECT_URI`. */ + redirectUri?: string; + /** Session cookie name. Defaults to `wos-session`. Overrides `WORKOS_COOKIE_NAME`. */ + cookieName?: string; + /** Cookie `Domain` attribute. Overrides `WORKOS_COOKIE_DOMAIN`. */ + cookieDomain?: string; + /** Cookie `Max-Age` in seconds. Defaults to 400 days. Overrides `WORKOS_COOKIE_MAX_AGE`. */ + cookieMaxAge?: number; + /** Cookie `SameSite` attribute. Defaults to `lax`. Overrides `WORKOS_COOKIE_SAMESITE`. */ + cookieSameSite?: 'lax' | 'strict' | 'none'; + /** One-shot environment claim token. Overrides `WORKOS_CLAIM_TOKEN`. */ + claimToken?: string; + /** Custom WorkOS API hostname. Overrides `WORKOS_API_HOSTNAME`. Applied once at client creation. */ + apiHostname?: string; + /** Whether to use HTTPS for the WorkOS API. Defaults to `true`. Overrides `WORKOS_API_HTTPS`. Applied once at client creation. */ + apiHttps?: boolean; + /** Custom WorkOS API port. Overrides `WORKOS_API_PORT`. Applied once at client creation. */ + apiPort?: number; +} + +const OVERRIDES_KEY = Symbol.for('workos.authkit.overrides'); + +const _overrides: AuthKitConfig = ((globalThis as Record)[OVERRIDES_KEY] ??= {}); + +/** + * Configure AuthKit with values from any source (secrets manager, vault, etc.) + * as an alternative to environment variables. Successive calls are merged, so + * you can call this multiple times to set different groups of values. + * + * **Call this before any other AuthKit function.** API connection settings + * (`apiKey`, `apiHostname`, `apiHttps`, `apiPort`) are applied when the WorkOS + * client is first created and cannot be updated afterwards. All other settings + * are read on every request, so they can technically be set later, but + * calling this once at startup is the intended usage. + * + * @example + * ```ts + * import { initAuthKit } from '@workos-inc/authkit-nextjs'; + * + * initAuthKit({ + * apiKey: await secrets.get('WORKOS_API_KEY'), + * clientId: await secrets.get('WORKOS_CLIENT_ID'), + * cookiePassword: await secrets.get('WORKOS_COOKIE_PASSWORD'), + * redirectUri: 'https://myapp.com/callback', + * }); + * ``` + */ +export function initAuthKit(overrides: AuthKitConfig): void { + Object.assign(_overrides, overrides); +} + +export const config = { + get apiKey(): string { + return _overrides.apiKey ?? process.env.WORKOS_API_KEY ?? ''; + }, + get clientId(): string { + return _overrides.clientId ?? process.env.WORKOS_CLIENT_ID ?? ''; + }, + get cookiePassword(): string { + return _overrides.cookiePassword ?? process.env.WORKOS_COOKIE_PASSWORD ?? ''; + }, + get redirectUri(): string { + return _overrides.redirectUri ?? process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI ?? ''; + }, + get cookieName(): string | undefined { + return _overrides.cookieName ?? process.env.WORKOS_COOKIE_NAME; + }, + get cookieDomain(): string | undefined { + return _overrides.cookieDomain ?? process.env.WORKOS_COOKIE_DOMAIN; + }, + get cookieMaxAge(): number | undefined { + if (_overrides.cookieMaxAge !== undefined) return _overrides.cookieMaxAge; + const raw = process.env.WORKOS_COOKIE_MAX_AGE; + if (!raw) return undefined; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : undefined; + }, + get cookieSameSite(): 'lax' | 'strict' | 'none' | undefined { + return _overrides.cookieSameSite ?? (process.env.WORKOS_COOKIE_SAMESITE as 'lax' | 'strict' | 'none' | undefined); + }, + get claimToken(): string | undefined { + return _overrides.claimToken ?? process.env.WORKOS_CLAIM_TOKEN; + }, + get apiHostname(): string | undefined { + return _overrides.apiHostname ?? process.env.WORKOS_API_HOSTNAME; + }, + get apiHttps(): boolean { + if (_overrides.apiHttps !== undefined) return _overrides.apiHttps; + const raw = process.env.WORKOS_API_HTTPS; + return raw ? raw === 'true' : true; + }, + get apiPort(): number | undefined { + if (_overrides.apiPort !== undefined) return _overrides.apiPort; + const raw = process.env.WORKOS_API_PORT; + if (!raw) return undefined; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : undefined; + }, +}; diff --git a/src/cookie.spec.ts b/src/cookie.spec.ts index 82d545e..142eea0 100644 --- a/src/cookie.spec.ts +++ b/src/cookie.spec.ts @@ -2,12 +2,9 @@ describe('cookie.ts', () => { beforeEach(() => { // Clear all mocks before each test vi.clearAllMocks(); - // Reset modules to ensure fresh imports + // Reset modules and clear shared overrides singleton to ensure fresh config state vi.resetModules(); - // Re-mock env-variables with a fresh copy each time - vi.doMock('./env-variables', async (importOriginal) => { - return { ...(await importOriginal()) }; - }); + delete (globalThis as Record)[Symbol.for('workos.authkit.overrides')]; }); describe('getCookieOptions', () => { @@ -28,12 +25,8 @@ describe('cookie.ts', () => { }); it('should return the cookie options with custom values', async () => { - // Import the mocked module - const envVars = await import('./env-variables'); - - // Set the mock values - Object.defineProperty(envVars, 'WORKOS_COOKIE_MAX_AGE', { value: '1000' }); - Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: 'foobar.com' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ cookieMaxAge: 1000, cookieDomain: 'foobar.com' }); const { getCookieOptions } = await import('./cookie'); const options = getCookieOptions('http://example.com'); @@ -46,7 +39,7 @@ describe('cookie.ts', () => { }), ); - Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: '' }); + initAuthKit({ cookieDomain: '' }); const options2 = getCookieOptions('http://example.com'); expect(options2).toEqual( @@ -58,7 +51,7 @@ describe('cookie.ts', () => { ); const options3 = getCookieOptions('https://example.com', true); - // Domain should not be included when WORKOS_COOKIE_DOMAIN is empty + // Domain should not be included when cookieDomain is empty expect(options3).toEqual(expect.not.stringContaining('Domain=')); }); @@ -82,9 +75,9 @@ describe('cookie.ts', () => { expect(options2).toEqual(expect.stringContaining('Domain=example.com')); }); - it('allows the sameSite config to be set by the WORKOS_COOKIE_SAMESITE env variable', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'none' }); + it('allows the sameSite config to be set by the cookieSameSite config option', async () => { + const { initAuthKit } = await import('./config'); + initAuthKit({ cookieSameSite: 'none' }); const { getCookieOptions } = await import('./cookie'); const options = getCookieOptions('http://example.com'); @@ -92,16 +85,17 @@ describe('cookie.ts', () => { }); it('throws an error if the sameSite value is invalid', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'invalid' }); + process.env.WORKOS_COOKIE_SAMESITE = 'invalid'; const { getCookieOptions } = await import('./cookie'); expect(() => getCookieOptions('http://example.com')).toThrow('Invalid SameSite value: invalid'); + + delete process.env.WORKOS_COOKIE_SAMESITE; }); it('defaults to secure=true when no URL is available', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: undefined }); + const { initAuthKit } = await import('./config'); + initAuthKit({ redirectUri: '' }); const { getCookieOptions } = await import('./cookie'); const options = getCookieOptions(); @@ -109,9 +103,8 @@ describe('cookie.ts', () => { }); it('defaults to secure=true when no URL is available with lax sameSite', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: undefined }); - Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'lax' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ redirectUri: '', cookieSameSite: 'lax' }); const { getCookieOptions } = await import('./cookie'); const options = getCookieOptions(); @@ -125,17 +118,18 @@ describe('cookie.ts', () => { }); it('handles invalid WORKOS_COOKIE_MAX_AGE gracefully', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_COOKIE_MAX_AGE', { value: 'invalid-number' }); + process.env.WORKOS_COOKIE_MAX_AGE = 'invalid-number'; const { getCookieOptions } = await import('./cookie'); const options = getCookieOptions(); expect(options).toEqual(expect.objectContaining({ maxAge: 34560000 })); // Falls back to default + + delete process.env.WORKOS_COOKIE_MAX_AGE; }); it('properly formats cookie string without Domain when not set', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: '' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ cookieDomain: '' }); const { getCookieOptions } = await import('./cookie'); const cookieString = getCookieOptions('https://example.com', true); @@ -190,9 +184,8 @@ describe('cookie.ts', () => { it('should handle invalid URLs with no fallback URL', async () => { process.env.NODE_ENV = 'production'; - // Mock no WORKOS_REDIRECT_URI - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: '' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ redirectUri: '' }); const { getJwtCookie } = await import('./cookie'); @@ -201,9 +194,9 @@ describe('cookie.ts', () => { expect(cookie).toContain('Secure'); // Should default to secure in production when no fallback }); - it('should fall back to WORKOS_REDIRECT_URI when invalid URL provided', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'https://app.workos.com/callback' }); + it('should fall back to redirectUri config when invalid URL provided', async () => { + const { initAuthKit } = await import('./config'); + initAuthKit({ redirectUri: 'https://app.workos.com/callback' }); const { getJwtCookie } = await import('./cookie'); @@ -212,40 +205,40 @@ describe('cookie.ts', () => { expect(cookie).toContain('Secure'); // Should use HTTPS from fallback URL }); - it('should set secure to false when WORKOS_REDIRECT_URI parsing fails', async () => { + it('should set secure to false when redirectUri parsing fails', async () => { process.env.NODE_ENV = 'development'; // Not production - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'also-invalid-url' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ redirectUri: 'also-invalid-url' }); const { getJwtCookie } = await import('./cookie'); - const cookie = getJwtCookie('token', null); // This triggers the WORKOS_REDIRECT_URI path + const cookie = getJwtCookie('token', null); // This triggers the redirectUri fallback path - expect(cookie).not.toContain('Secure'); // Should be false when URL parsing fails (line 128) + expect(cookie).not.toContain('Secure'); // Should be false when URL parsing fails }); it('should handle both main URL and fallback URL parsing failures', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'invalid-fallback-url' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ redirectUri: 'invalid-fallback-url' }); const { getJwtCookie } = await import('./cookie'); - // Invalid main URL with invalid fallback URL - should hit line 118 + // Invalid main URL with invalid fallback URL const cookie = getJwtCookie('token', 'invalid-main-url'); - expect(cookie).not.toContain('Secure'); // Line 118: secure = false when fallback parsing fails + expect(cookie).not.toContain('Secure'); // secure = false when fallback parsing fails }); - it('should use WORKOS_REDIRECT_URI when no URL provided', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'https://secure.example.com' }); + it('should use redirectUri config when no URL provided', async () => { + const { initAuthKit } = await import('./config'); + initAuthKit({ redirectUri: 'https://secure.example.com' }); const { getJwtCookie } = await import('./cookie'); const cookie = getJwtCookie('token', null); - expect(cookie).toContain('Secure'); // Should use HTTPS from WORKOS_REDIRECT_URI + expect(cookie).toContain('Secure'); // Should use HTTPS from redirectUri }); it('should create expired JWT cookie for deletion', async () => { @@ -314,8 +307,8 @@ describe('cookie.ts', () => { }); it('should downgrade SameSite=Strict to Lax', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'strict' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ cookieSameSite: 'strict' }); const { getPKCECookieOptions } = await import('./cookie'); diff --git a/src/cookie.ts b/src/cookie.ts index 75e1cf5..4b51500 100644 --- a/src/cookie.ts +++ b/src/cookie.ts @@ -1,9 +1,4 @@ -import { - WORKOS_REDIRECT_URI, - WORKOS_COOKIE_MAX_AGE, - WORKOS_COOKIE_DOMAIN, - WORKOS_COOKIE_SAMESITE, -} from './env-variables.js'; +import { config } from './config.js'; import { CookieOptions } from './interfaces.js'; type ValidSameSite = CookieOptions['sameSite']; @@ -35,10 +30,10 @@ export function getCookieOptions( asString: boolean = false, expired: boolean = false, ): CookieOptions | string { - const sameSite = WORKOS_COOKIE_SAMESITE || 'lax'; + const sameSite = config.cookieSameSite || 'lax'; assertValidSamSite(sameSite); - const urlString = redirectUri || WORKOS_REDIRECT_URI; + const urlString = redirectUri || config.redirectUri; // Default to secure=true when no URL available (production default) // Developers should set WORKOS_REDIRECT_URI for proper local dev let secure: boolean; @@ -59,9 +54,8 @@ export function getCookieOptions( let maxAge: number; if (expired) { maxAge = 0; - } else if (WORKOS_COOKIE_MAX_AGE) { - const parsed = parseInt(WORKOS_COOKIE_MAX_AGE, 10); - maxAge = Number.isFinite(parsed) ? parsed : 60 * 60 * 24 * 400; + } else if (config.cookieMaxAge !== undefined) { + maxAge = config.cookieMaxAge; } else { maxAge = 60 * 60 * 24 * 400; } @@ -69,8 +63,8 @@ export function getCookieOptions( if (asString) { const capitalizedSameSite = sameSite.charAt(0).toUpperCase() + sameSite.slice(1).toLowerCase(); const parts = ['Path=/', 'HttpOnly', `SameSite=${capitalizedSameSite}`, `Max-Age=${maxAge}`]; - if (WORKOS_COOKIE_DOMAIN) { - parts.push(`Domain=${WORKOS_COOKIE_DOMAIN}`); + if (config.cookieDomain) { + parts.push(`Domain=${config.cookieDomain}`); } if (secure) { parts.push('Secure'); @@ -88,7 +82,7 @@ export function getCookieOptions( // It's fine to have a long cookie expiry date as the access/refresh tokens // act as the actual time-limited aspects of the session. maxAge, - domain: WORKOS_COOKIE_DOMAIN || '', + domain: config.cookieDomain || '', }; } @@ -144,7 +138,7 @@ export function getJwtCookie(body: string | null, requestUrlOrRedirectUri?: stri // If URL parsing fails, default to secure in production secure = isProduction; // If it's not a valid URL, fall back to WORKOS_REDIRECT_URI - const fallbackUrl = WORKOS_REDIRECT_URI; + const fallbackUrl = config.redirectUri; if (fallbackUrl) { try { const url = new URL(fallbackUrl); @@ -154,10 +148,10 @@ export function getJwtCookie(body: string | null, requestUrlOrRedirectUri?: stri } } } - } else if (WORKOS_REDIRECT_URI) { - // No URL provided, check WORKOS_REDIRECT_URI + } else if (config.redirectUri) { + // No URL provided, check redirectUri config try { - const url = new URL(WORKOS_REDIRECT_URI); + const url = new URL(config.redirectUri); secure = url.protocol === 'https:'; } catch { secure = false; diff --git a/src/env-variables.ts b/src/env-variables.ts deleted file mode 100644 index 3b62722..0000000 --- a/src/env-variables.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* istanbul ignore file */ - -function getEnvVariable(name: string): string | undefined { - return process.env[name]; -} - -// Optional env variables -const WORKOS_API_HOSTNAME = getEnvVariable('WORKOS_API_HOSTNAME'); -const WORKOS_API_HTTPS = getEnvVariable('WORKOS_API_HTTPS'); -const WORKOS_API_PORT = getEnvVariable('WORKOS_API_PORT'); -const WORKOS_COOKIE_DOMAIN = getEnvVariable('WORKOS_COOKIE_DOMAIN'); -const WORKOS_COOKIE_MAX_AGE = getEnvVariable('WORKOS_COOKIE_MAX_AGE'); -const WORKOS_COOKIE_NAME = getEnvVariable('WORKOS_COOKIE_NAME'); -const WORKOS_COOKIE_SAMESITE = getEnvVariable('WORKOS_COOKIE_SAMESITE') as 'lax' | 'strict' | 'none' | undefined; -const WORKOS_CLAIM_TOKEN = getEnvVariable('WORKOS_CLAIM_TOKEN'); - -// Required env variables -const WORKOS_API_KEY = getEnvVariable('WORKOS_API_KEY') ?? ''; -const WORKOS_CLIENT_ID = getEnvVariable('WORKOS_CLIENT_ID') ?? ''; -const WORKOS_COOKIE_PASSWORD = getEnvVariable('WORKOS_COOKIE_PASSWORD') ?? ''; -const WORKOS_REDIRECT_URI = process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI ?? ''; - -export { - WORKOS_API_HOSTNAME, - WORKOS_API_HTTPS, - WORKOS_API_KEY, - WORKOS_API_PORT, - WORKOS_CLAIM_TOKEN, - WORKOS_CLIENT_ID, - WORKOS_COOKIE_DOMAIN, - WORKOS_COOKIE_MAX_AGE, - WORKOS_COOKIE_NAME, - WORKOS_COOKIE_PASSWORD, - WORKOS_REDIRECT_URI, - WORKOS_COOKIE_SAMESITE, -}; diff --git a/src/get-authorization-url.ts b/src/get-authorization-url.ts index 3c6e423..b5593f4 100644 --- a/src/get-authorization-url.ts +++ b/src/get-authorization-url.ts @@ -1,6 +1,6 @@ import { sealData } from 'iron-session'; import { headers } from 'next/headers'; -import { WORKOS_CLAIM_TOKEN, WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js'; +import { config } from './config.js'; import { GetAuthURLOptions, GetAuthURLResult, State } from './interfaces.js'; import { getWorkOS } from './workos.js'; @@ -10,8 +10,8 @@ async function fetchClaimNonce(baseURL: string): Promise { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - client_id: WORKOS_CLIENT_ID, - claim_token: WORKOS_CLAIM_TOKEN, + client_id: config.clientId, + claim_token: config.claimToken, }), }); if (!response.ok) { @@ -53,7 +53,7 @@ async function getAuthorizationUrl({ })(); const pkce = await getWorkOS().pkce.generate(); - const claimNonce = WORKOS_CLAIM_TOKEN ? await fetchClaimNonce(getWorkOS().baseURL) : null; + const claimNonce = config.claimToken ? await fetchClaimNonce(getWorkOS().baseURL) : null; const state = { nonce: crypto.randomUUID(), @@ -62,12 +62,12 @@ async function getAuthorizationUrl({ returnPathname, } satisfies State; - const sealedState = await sealData(state, { password: WORKOS_COOKIE_PASSWORD, ttl: 600 }); + const sealedState = await sealData(state, { password: config.cookiePassword, ttl: 600 }); const url = getWorkOS().userManagement.getAuthorizationUrl({ provider: 'authkit' as const, - clientId: WORKOS_CLIENT_ID, - redirectUri: redirectUriToUse ?? WORKOS_REDIRECT_URI, + clientId: config.clientId, + redirectUri: redirectUriToUse ?? config.redirectUri, screenHint, organizationId, loginHint, diff --git a/src/index.ts b/src/index.ts index 7578dc3..8d25c05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,8 @@ import { checkRecentAuth, getTokenClaims, refreshSession, saveSession, withAuth import { validateApiKey } from './validate-api-key.js'; import { getFeatureFlagsRuntimeClient } from './feature-flags.js'; import { getWorkOS } from './workos.js'; +import { initAuthKit } from './config.js'; +export type { AuthKitConfig } from './config.js'; export * from './interfaces.js'; @@ -33,6 +35,7 @@ export { getFeatureFlagsRuntimeClient, getTokenClaims, getWorkOS, + initAuthKit, handleAuth, refreshSession, saveSession, diff --git a/src/middleware.ts b/src/middleware.ts index 6a2b4bc..b5d59ca 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,12 +1,12 @@ import { NextMiddleware, NextRequest } from 'next/server'; import { updateSessionMiddleware, updateSession } from './session.js'; import { AuthkitMiddlewareOptions, AuthkitOptions, AuthkitResponse } from './interfaces.js'; -import { WORKOS_REDIRECT_URI } from './env-variables.js'; +import { config } from './config.js'; export function authkitProxy({ debug = false, middlewareAuth = { enabled: false, unauthenticatedPaths: [] }, - redirectUri = WORKOS_REDIRECT_URI, + redirectUri = config.redirectUri, signUpPaths = [], eagerAuth = false, }: AuthkitMiddlewareOptions = {}): NextMiddleware { diff --git a/src/pkce.spec.ts b/src/pkce.spec.ts index 42b79d3..2ed6b2c 100644 --- a/src/pkce.spec.ts +++ b/src/pkce.spec.ts @@ -30,18 +30,16 @@ describe('setPKCECookie SameSite override', () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + delete (globalThis as Record)[Symbol.for('workos.authkit.overrides')]; vi.doMock('next/headers', () => ({ cookies: async () => ({ set: mockSet, get: vi.fn(), getAll: vi.fn(), delete: vi.fn() }), headers: async () => ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }), })); - vi.doMock('./env-variables', async (importOriginal) => { - return { ...(await importOriginal()) }; - }); }); it('should downgrade strict to lax', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'strict' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ cookieSameSite: 'strict' }); const { setPKCECookie } = await import('./pkce'); await setPKCECookie('sealed-state'); @@ -54,8 +52,8 @@ describe('setPKCECookie SameSite override', () => { }); it('should preserve none for iframe/cross-origin flows', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'none' }); + const { initAuthKit } = await import('./config'); + initAuthKit({ cookieSameSite: 'none' }); const { setPKCECookie } = await import('./pkce'); await setPKCECookie('sealed-state'); @@ -68,12 +66,14 @@ describe('setPKCECookie SameSite override', () => { }); it('should downgrade mixed-case Strict to lax', async () => { - const envVars = await import('./env-variables'); - Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'Strict' }); + // 'Strict' isn't in AuthKitConfig's union type, so use process.env to test mixed-case handling + process.env.WORKOS_COOKIE_SAMESITE = 'Strict'; const { setPKCECookie } = await import('./pkce'); await setPKCECookie('sealed-state'); + delete process.env.WORKOS_COOKIE_SAMESITE; + expect(mockSet).toHaveBeenCalledWith( getPKCECookieNameForState('sealed-state'), 'sealed-state', diff --git a/src/pkce.ts b/src/pkce.ts index 52afa3c..3dbb456 100644 --- a/src/pkce.ts +++ b/src/pkce.ts @@ -4,7 +4,7 @@ import { cookies } from 'next/headers'; import { NextRequest } from 'next/server'; import * as v from 'valibot'; import { getPKCECookieOptions } from './cookie.js'; -import { WORKOS_COOKIE_PASSWORD } from './env-variables.js'; +import { config } from './config.js'; import { State, StateSchema } from './interfaces.js'; export const PKCE_COOKIE_NAME = 'wos-auth-verifier'; @@ -132,7 +132,7 @@ export async function getStateFromPKCECookieValue(cookieValue: string): Promise< // Also, this function is not in a critically-high-performance path, so runtime validation // is an acceptable tradeoff for increased security and type-safety const unsealed = await unsealData(cookieValue, { - password: WORKOS_COOKIE_PASSWORD, + password: config.cookiePassword, }); return v.parse(StateSchema, unsealed); diff --git a/src/session.spec.ts b/src/session.spec.ts index 77d65d2..6eb84e6 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -12,14 +12,9 @@ import { checkRecentAuth, } from './session.js'; import { getWorkOS } from './workos.js'; -import * as envVariables from './env-variables.js'; +import { initAuthKit } from './config.js'; import { jwtVerify } from 'jose'; - -// Helper to override env variable exports without triggering no-import-assign on the import binding -function setEnvVar(mod: Record, key: string, value: unknown) { - Object.defineProperty(mod, key, { value, configurable: true }); -} import { sealData } from 'iron-session'; import { User } from '@workos-inc/node'; import { getStateFromPKCECookieValue } from './pkce.js'; @@ -93,6 +88,7 @@ describe('session.ts', () => { afterEach(() => { consoleLogSpy.mockRestore(); vi.resetModules(); + delete (globalThis as Record)[Symbol.for('workos.authkit.overrides')]; }); describe('withAuth', () => { @@ -173,9 +169,7 @@ describe('session.ts', () => { describe('updateSessionMiddleware', () => { it('should throw an error if the redirect URI is not set', async () => { - const originalWorkosRedirectUri = envVariables.WORKOS_REDIRECT_URI; - - setEnvVar(envVariables, 'WORKOS_REDIRECT_URI', ''); + initAuthKit({ redirectUri: '' }); await expect(async () => { await updateSessionMiddleware( @@ -190,13 +184,11 @@ describe('session.ts', () => { ); }).rejects.toThrow('You must provide a redirect URI in the AuthKit middleware or in the environment variables.'); - setEnvVar(envVariables, 'WORKOS_REDIRECT_URI', originalWorkosRedirectUri); + initAuthKit({ redirectUri: undefined }); }); it('should throw an error if the cookie password is not set', async () => { - const originalWorkosCookiePassword = envVariables.WORKOS_COOKIE_PASSWORD; - - setEnvVar(envVariables, 'WORKOS_COOKIE_PASSWORD', ''); + initAuthKit({ cookiePassword: '' }); await expect(async () => { await updateSessionMiddleware( @@ -213,13 +205,11 @@ describe('session.ts', () => { 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', ); - setEnvVar(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword); + initAuthKit({ cookiePassword: undefined }); }); it('should throw an error if the cookie password is less than 32 characters', async () => { - const originalWorkosCookiePassword = envVariables.WORKOS_COOKIE_PASSWORD; - - setEnvVar(envVariables, 'WORKOS_COOKIE_PASSWORD', 'short'); + initAuthKit({ cookiePassword: 'short' }); await expect(async () => { await updateSessionMiddleware( @@ -236,7 +226,7 @@ describe('session.ts', () => { 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', ); - setEnvVar(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword); + initAuthKit({ cookiePassword: undefined }); }); it('should return early if there is no session', async () => { diff --git a/src/session.ts b/src/session.ts index 92b1e40..ad67caa 100644 --- a/src/session.ts +++ b/src/session.ts @@ -6,7 +6,7 @@ import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { NextRequest } from 'next/server'; import { getCookieOptions, getJwtCookie } from './cookie.js'; -import { WORKOS_CLIENT_ID, WORKOS_COOKIE_NAME, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js'; +import { config } from './config.js'; import { TokenRefreshError, getSessionErrorContext } from './errors.js'; import { getAuthorizationUrl } from './get-authorization-url.js'; import { @@ -36,7 +36,7 @@ const middlewareHeaderName = 'x-workos-middleware'; const signUpPathsHeaderName = 'x-sign-up-paths'; const jwtCookieName = 'workos-access-token'; -const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(WORKOS_CLIENT_ID)))); +const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(config.clientId)))); /** * Applies cache security headers with Vary header deduplication. @@ -51,7 +51,7 @@ function applyCacheSecurityHeaders( request: NextRequest, sessionData?: { accessToken?: string } | Session, ): void { - const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + const cookieName = config.cookieName || 'wos-session'; // Only apply cache headers for authenticated requests if (!sessionData?.accessToken && !request.cookies.has(cookieName) && !request.headers.has('authorization')) { @@ -83,7 +83,7 @@ function applyCacheSecurityHeaders( async function encryptSession(session: Session) { return sealData(session, { - password: WORKOS_COOKIE_PASSWORD, + password: config.cookiePassword, ttl: 0, }); } @@ -96,11 +96,11 @@ async function updateSessionMiddleware( signUpPaths: string[], eagerAuth = false, ) { - if (!redirectUri && !WORKOS_REDIRECT_URI) { + if (!redirectUri && !config.redirectUri) { throw new Error('You must provide a redirect URI in the AuthKit middleware or in the environment variables.'); } - if (!WORKOS_COOKIE_PASSWORD || WORKOS_COOKIE_PASSWORD.length < 32) { + if (!config.cookiePassword || config.cookiePassword.length < 32) { throw new Error( 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', ); @@ -111,7 +111,7 @@ async function updateSessionMiddleware( if (redirectUri) { url = new URL(redirectUri); } else { - url = new URL(WORKOS_REDIRECT_URI); + url = new URL(config.redirectUri); } if ( @@ -195,7 +195,7 @@ async function updateSession( const { url: authorizationUrl, sealedState } = await getAuthorizationUrl({ returnPathname: getReturnPathname(request.url), - redirectUri: options.redirectUri || WORKOS_REDIRECT_URI, + redirectUri: options.redirectUri || config.redirectUri, screenHint: options.screenHint, }); @@ -211,7 +211,7 @@ async function updateSession( const hasValidSession = await verifyAccessToken(session.accessToken); - const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + const cookieName = config.cookieName || 'wos-session'; applyCacheSecurityHeaders(newRequestHeaders, request, session); @@ -267,7 +267,7 @@ async function updateSession( const { accessToken, refreshToken, user, impersonator, authenticationMethod } = await getWorkOS().userManagement.authenticateWithRefreshToken({ - clientId: WORKOS_CLIENT_ID, + clientId: config.clientId, refreshToken: session.refreshToken, organizationId: organizationIdFromAccessToken, }); @@ -339,7 +339,7 @@ async function updateSession( const { url: authorizationUrl, sealedState } = await getAuthorizationUrl({ returnPathname: getReturnPathname(request.url), - redirectUri: options.redirectUri || WORKOS_REDIRECT_URI, + redirectUri: options.redirectUri || config.redirectUri, }); setPendingPKCERedirectHeaders(newRequestHeaders, authorizationUrl, sealedState); @@ -379,7 +379,7 @@ async function refreshSession({ try { refreshResult = await getWorkOS().userManagement.authenticateWithRefreshToken({ - clientId: WORKOS_CLIENT_ID, + clientId: config.clientId, refreshToken: session.refreshToken, organizationId: nextOrganizationId ?? organizationIdFromAccessToken, }); @@ -394,7 +394,7 @@ async function refreshSession({ const headersList = await headers(); const url = headersList.get('x-url'); - await saveSession(refreshResult, url || WORKOS_REDIRECT_URI); + await saveSession(refreshResult, url || config.redirectUri); const { accessToken, user, impersonator } = refreshResult; @@ -545,7 +545,7 @@ async function verifyAccessToken(accessToken: string) { } export async function getSessionFromCookie(request?: NextRequest) { - const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + const cookieName = config.cookieName || 'wos-session'; let cookie; if (request) { @@ -557,7 +557,7 @@ export async function getSessionFromCookie(request?: NextRequest) { if (cookie) { return unsealData(cookie.value, { - password: WORKOS_COOKIE_PASSWORD, + password: config.cookiePassword, }); } } @@ -576,7 +576,7 @@ async function getSessionFromHeader(): Promise { const authHeader = headersList.get(sessionHeaderName); if (!authHeader) return; - return unsealData(authHeader, { password: WORKOS_COOKIE_PASSWORD }); + return unsealData(authHeader, { password: config.cookiePassword }); } function getReturnPathname(url: string): string { @@ -628,7 +628,7 @@ export async function saveSession( sessionOrResponse: Session | AuthenticationResponse, request: NextRequest | string, ): Promise { - const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + const cookieName = config.cookieName || 'wos-session'; const encryptedSession = await encryptSession(sessionOrResponse); const nextCookies = await cookies(); const url = typeof request === 'string' ? request : request.url; diff --git a/src/test-helpers.ts b/src/test-helpers.ts index df2e5ca..3d773b8 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -2,7 +2,7 @@ import { sealData } from 'iron-session'; import { SignJWT } from 'jose'; -import { WORKOS_COOKIE_NAME, WORKOS_COOKIE_PASSWORD } from './env-variables.js'; +import { config } from './config.js'; import { cookies } from 'next/headers'; import { User } from '@workos-inc/node'; @@ -19,7 +19,7 @@ export async function generateTestToken(payload = {}, expired = false) { const mergedPayload = { ...defaultPayload, ...payload }; - const secret = new TextEncoder().encode(process.env.WORKOS_COOKIE_PASSWORD as string); + const secret = new TextEncoder().encode(config.cookiePassword); const token = await new SignJWT(mergedPayload) .setProtectedHeader({ alg: 'HS256' }) @@ -63,11 +63,11 @@ export async function generateSession(overrides: Partial = {}) { user: mockUser, }, { - password: WORKOS_COOKIE_PASSWORD as string, + password: config.cookiePassword, }, ); - const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + const cookieName = config.cookieName || 'wos-session'; const nextCookies = await cookies(); nextCookies.set(cookieName, encryptedSession); } diff --git a/src/workos.spec.ts b/src/workos.spec.ts index da44f31..aede1d2 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -63,5 +63,6 @@ describe('workos', () => { expect(customWorkos().options.port).toEqual(8080); }); + }); }); diff --git a/src/workos.ts b/src/workos.ts index 104269a..7d61d99 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -1,22 +1,22 @@ import { WorkOS } from '@workos-inc/node'; -import { WORKOS_API_HOSTNAME, WORKOS_API_KEY, WORKOS_API_HTTPS, WORKOS_API_PORT } from './env-variables.js'; +import { config } from './config.js'; import { lazy } from './utils.js'; export const VERSION = '2.14.0'; -const options = { - apiHostname: WORKOS_API_HOSTNAME, - https: WORKOS_API_HTTPS ? WORKOS_API_HTTPS === 'true' : true, - port: WORKOS_API_PORT ? parseInt(WORKOS_API_PORT) : undefined, - appInfo: { - name: 'authkit/nextjs', - version: VERSION, - }, -}; +const _workosClient = lazy( + () => + new WorkOS(config.apiKey, { + apiHostname: config.apiHostname, + https: config.apiHttps, + port: config.apiPort, + appInfo: { + name: 'authkit/nextjs', + version: VERSION, + }, + }), +); -/** - * Create a WorkOS instance with the provided API key and options. - * If an instance already exists, it returns the existing instance. - * @returns The WorkOS instance. - */ -export const getWorkOS = lazy(() => new WorkOS(WORKOS_API_KEY, options)); +export function getWorkOS(): WorkOS { + return _workosClient(); +}