diff --git a/README.md b/README.md index 793373e..61ad36e 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,16 @@ Make sure the following values are present in your `.env.local` environment vari ```sh WORKOS_CLIENT_ID="client_..." # retrieved from the WorkOS dashboard WORKOS_API_KEY="sk_test_..." # retrieved from the WorkOS dashboard -WORKOS_COOKIE_PASSWORD="" # generate a secure password here NEXT_PUBLIC_WORKOS_REDIRECT_URI="http://localhost:3000/callback" # configured in the WorkOS dashboard ``` -`WORKOS_COOKIE_PASSWORD` is the private key used to encrypt the session cookie. It has to be at least 32 characters long. You can use the [1Password generator](https://1password.com/password-generator/) or the `openssl` library to generate a strong password via the command line: +`WORKOS_COOKIE_PASSWORD` is optional. When not set, a password is automatically derived from your `WORKOS_API_KEY` and `WORKOS_CLIENT_ID`. You can set it explicitly if you need cookie continuity across API key rotations or want to share sessions across apps/domains: + +```sh +WORKOS_COOKIE_PASSWORD="" # optional, must be at least 32 characters +``` + +You can use the [1Password generator](https://1password.com/password-generator/) or the `openssl` library to generate a strong password via the command line: ``` openssl rand -base64 24 @@ -68,7 +73,7 @@ WORKOS_COOKIE_NAME='my-auth-cookie' > [!WARNING] > Setting `WORKOS_COOKIE_SAMESITE='none'` allows cookies to be sent in cross-origin contexts (like iframes), but reduces protection against CSRF attacks. This setting forces cookies to be secure (HTTPS only) and should only be used when absolutely necessary for your application architecture. -> [!TIP] >`WORKOS_COOKIE_DOMAIN` can be used to share WorkOS sessions between apps/domains. Note: The `WORKOS_COOKIE_PASSWORD` would need to be the same across apps/domains. Not needed for most use cases. +> [!TIP] >`WORKOS_COOKIE_DOMAIN` can be used to share WorkOS sessions between apps/domains. When sharing sessions, set `WORKOS_COOKIE_PASSWORD` explicitly to the same value across apps. Not needed for most use cases. ## Setup diff --git a/renovate.json b/renovate.json index bd76507..9416b9f 100644 --- a/renovate.json +++ b/renovate.json @@ -3,9 +3,7 @@ "extends": ["github>workos/renovate-config"], "enabledManagers": ["github-actions"], "dependencyDashboard": false, - "schedule": [ - "on the 15th day of the month before 12pm" - ], + "schedule": ["on the 15th day of the month before 12pm"], "timezone": "UTC", - "rebaseWhen": "conflicted", + "rebaseWhen": "conflicted" } diff --git a/src/env-variables.ts b/src/env-variables.ts index 3b62722..b072d4c 100644 --- a/src/env-variables.ts +++ b/src/env-variables.ts @@ -17,9 +17,15 @@ 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_COOKIE_PASSWORD = + getEnvVariable('WORKOS_COOKIE_PASSWORD') || deriveCookiePassword(WORKOS_API_KEY, WORKOS_CLIENT_ID); const WORKOS_REDIRECT_URI = process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI ?? ''; +function deriveCookiePassword(apiKey: string, clientId: string): string { + if (!apiKey || !clientId) return ''; + return `wos-ck:${apiKey}:${clientId}`; +} + export { WORKOS_API_HOSTNAME, WORKOS_API_HTTPS, diff --git a/src/session.spec.ts b/src/session.spec.ts index 72cb2c7..4d9fd08 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -202,7 +202,7 @@ describe('session.ts', () => { [], ); }).rejects.toThrow( - 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', + 'Cookie password must be at least 32 characters. Either set WORKOS_COOKIE_PASSWORD or ensure WORKOS_API_KEY and WORKOS_CLIENT_ID are configured.', ); setEnvVar(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword); @@ -225,7 +225,7 @@ describe('session.ts', () => { [], ); }).rejects.toThrow( - 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', + 'Cookie password must be at least 32 characters. Either set WORKOS_COOKIE_PASSWORD or ensure WORKOS_API_KEY and WORKOS_CLIENT_ID are configured.', ); setEnvVar(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword); @@ -1170,4 +1170,129 @@ describe('session.ts', () => { }); }); }); + + describe('session sealing migration', () => { + it('should write session cookies as plain JSON, not iron-sealed', async () => { + const { saveSession } = await import('./session.js'); + + const sessionData = { + accessToken: await generateTestToken(), + refreshToken: 'test-refresh-token', + user: mockSession.user, + }; + + await saveSession(sessionData, 'https://example.com/callback'); + + const nextCookies = await cookies(); + const cookie = nextCookies.get('wos-session'); + expect(cookie).toBeDefined(); + + // Should be valid JSON, not an iron-sealed blob + expect(cookie!.value).not.toMatch(/^Fe26\./); + const parsed = JSON.parse(cookie!.value); + expect(parsed.accessToken).toBe(sessionData.accessToken); + expect(parsed.refreshToken).toBe(sessionData.refreshToken); + expect(parsed.user).toEqual(sessionData.user); + }); + + it('should read legacy iron-sealed session cookies', async () => { + mockSession.accessToken = await generateTestToken(); + + (jwtVerify as Mock).mockImplementation(() => true); + + const request = new NextRequest(new URL('http://example.com')); + request.cookies.set( + 'wos-session', + await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }), + ); + + const result = await updateSession(request); + + expect(result.session).toBeDefined(); + expect(result.session.user).toEqual(mockSession.user); + }); + + it('should read plain JSON session cookies', async () => { + mockSession.accessToken = await generateTestToken(); + + (jwtVerify as Mock).mockImplementation(() => true); + + const request = new NextRequest(new URL('http://example.com')); + request.cookies.set('wos-session', JSON.stringify(mockSession)); + + const result = await updateSession(request); + + expect(result.session).toBeDefined(); + expect(result.session.user).toEqual(mockSession.user); + }); + + it('should read legacy iron-sealed session from header', async () => { + mockSession.accessToken = await generateTestToken(); + + const nextHeaders = await headers(); + nextHeaders.set( + 'x-workos-session', + await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }), + ); + + const result = await withAuth(); + expect(result.user).toEqual(mockSession.user); + }); + + it('should read plain JSON session from header', async () => { + mockSession.accessToken = await generateTestToken(); + + const nextHeaders = await headers(); + nextHeaders.set('x-workos-session', JSON.stringify(mockSession)); + + const result = await withAuth(); + expect(result.user).toEqual(mockSession.user); + }); + + it('should rewrite iron-sealed session as plain JSON on refresh', async () => { + mockSession.accessToken = await generateTestToken({}, true); + + (jwtVerify as Mock).mockImplementation(() => { + throw new Error('Invalid token'); + }); + + const newAccessToken = await generateTestToken(); + vi.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({ + accessToken: newAccessToken, + refreshToken: 'new-refresh-token', + user: mockSession.user, + }); + + const request = new NextRequest(new URL('http://example.com')); + + // Plant an iron-sealed cookie (legacy format) + request.cookies.set( + 'wos-session', + await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }), + ); + + const result = await updateSession(request); + + // After refresh, the Set-Cookie should contain plain JSON, not iron-sealed + const setCookieHeader = result.headers.get('Set-Cookie'); + expect(setCookieHeader).toBeDefined(); + + const cookieValue = setCookieHeader!.split('wos-session=')[1].split(';')[0]; + expect(cookieValue).not.toMatch(/^Fe26\./); + const parsed = JSON.parse(decodeURIComponent(cookieValue)); + expect(parsed.accessToken).toBe(newAccessToken); + }); + + it('should gracefully handle malformed cookie values', async () => { + const request = new NextRequest(new URL('http://example.com')); + request.cookies.set('wos-session', 'not-json-and-not-iron'); + + const result = await updateSession(request, { + debug: true, + }); + + expect(result.session.user).toBeNull(); + expect(console.log).toHaveBeenCalledWith('No session found from cookie'); + }); + }); }); diff --git a/src/session.ts b/src/session.ts index 059f0a6..7dcbe16 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,6 +1,6 @@ 'use server'; -import { sealData, unsealData } from 'iron-session'; +import { unsealData } from 'iron-session'; import { JWTPayload, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -108,11 +108,17 @@ function isInitialDocumentRequest(request: NextRequest): boolean { return isDocumentRequest && !isRSCRequest && !isPrefetch; } +const IRON_SEAL_PREFIX = 'Fe26.'; + async function encryptSession(session: Session) { - return sealData(session, { - password: WORKOS_COOKIE_PASSWORD, - ttl: 0, - }); + return JSON.stringify(session); +} + +async function decryptSession(data: string): Promise { + if (data.startsWith(IRON_SEAL_PREFIX)) { + return unsealData(data, { password: WORKOS_COOKIE_PASSWORD }); + } + return JSON.parse(data) as T; } async function updateSessionMiddleware( @@ -129,7 +135,7 @@ async function updateSessionMiddleware( if (!WORKOS_COOKIE_PASSWORD || WORKOS_COOKIE_PASSWORD.length < 32) { throw new Error( - 'You must provide a valid cookie password that is at least 32 characters in the environment variables.', + 'Cookie password must be at least 32 characters. Either set WORKOS_COOKIE_PASSWORD or ensure WORKOS_API_KEY and WORKOS_CLIENT_ID are configured.', ); } @@ -310,7 +316,10 @@ async function updateSession( authenticationMethod, }); - newRequestHeaders.append('Set-Cookie', `${cookieName}=${encryptedSession}; ${getCookieOptions(request.url, true)}`); + newRequestHeaders.append( + 'Set-Cookie', + `${cookieName}=${encodeURIComponent(encryptedSession)}; ${getCookieOptions(request.url, true)}`, + ); newRequestHeaders.set(sessionHeaderName, encryptedSession); // Set JWT cookie if eagerAuth is enabled @@ -553,9 +562,11 @@ export async function getSessionFromCookie(request?: NextRequest) { } if (cookie) { - return unsealData(cookie.value, { - password: WORKOS_COOKIE_PASSWORD, - }); + try { + return await decryptSession(cookie.value); + } catch { + return undefined; + } } } @@ -573,7 +584,7 @@ async function getSessionFromHeader(): Promise { const authHeader = headersList.get(sessionHeaderName); if (!authHeader) return; - return unsealData(authHeader, { password: WORKOS_COOKIE_PASSWORD }); + return decryptSession(authHeader); } function getReturnPathname(url: string): string { diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 267dca3..b007928 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -54,7 +54,43 @@ export async function generateSession(overrides: Partial = {}) { org_id: 'org_123', }); - // Create and set a session cookie + const sessionData = JSON.stringify({ + accessToken, + refreshToken: 'refresh_token_123', + user: mockUser, + }); + + const cookieName = WORKOS_COOKIE_NAME || 'wos-session'; + const nextCookies = await cookies(); + nextCookies.set(cookieName, sessionData); +} + +/** + * Create a legacy iron-sealed session cookie for backward compatibility tests. + */ +export async function generateSealedSession(overrides: Partial = {}) { + const mockUser = { + id: 'user_123', + email: 'test@example.com', + emailVerified: true, + profilePictureUrl: null, + firstName: 'Test', + lastName: 'User', + object: 'user', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + lastSignInAt: '2024-01-01T00:00:00Z', + externalId: null, + metadata: {}, + locale: null, + ...overrides, + } satisfies User; + + const accessToken = await generateTestToken({ + sid: 'session_123', + org_id: 'org_123', + }); + const encryptedSession = await sealData( { accessToken,