diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..d4fc386 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,16 @@ +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ + dir: './', +}); + +const customJestConfig = { + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'jest-environment-jsdom', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + testPathIgnorePatterns: ['/node_modules/', '/.next/'], +}; + +module.exports = createJestConfig(customJestConfig); diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/jest.setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/package.json b/package.json index 8523fbe..1b754f0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "jest", + "test:watch": "jest --watch" }, "dependencies": { "@workos-inc/authkit-nextjs": "0.4.2", @@ -17,11 +19,17 @@ "react-dom": "^18" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/jest": "^30.0.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.1.4", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "ts-jest": "^29.4.6", "typescript": "^5" } } diff --git a/src/app/api/test-invitation-token/route.ts b/src/app/api/test-invitation-token/route.ts new file mode 100644 index 0000000..ee32880 --- /dev/null +++ b/src/app/api/test-invitation-token/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + storeInvitationToken, + getStoredInvitationToken, + consumeInvitationToken, + clearInvitationToken, +} from '@/lib/invitation-token'; + +export async function GET(request: NextRequest) { + const action = request.nextUrl.searchParams.get('action'); + const token = request.nextUrl.searchParams.get('token'); + + try { + switch (action) { + case 'store': + if (!token) { + return NextResponse.json({ error: 'Token required for store action' }, { status: 400 }); + } + await storeInvitationToken(token); + return NextResponse.json({ success: true, action: 'stored', token }); + + case 'get': + const storedToken = await getStoredInvitationToken(); + return NextResponse.json({ success: true, action: 'get', token: storedToken || null }); + + case 'consume': + const consumedToken = await consumeInvitationToken(); + return NextResponse.json({ success: true, action: 'consumed', token: consumedToken || null }); + + case 'clear': + await clearInvitationToken(); + return NextResponse.json({ success: true, action: 'cleared' }); + + default: + return NextResponse.json({ + error: 'Invalid action. Use: store, get, consume, or clear', + usage: { + store: '/api/test-invitation-token?action=store&token=YOUR_TOKEN', + get: '/api/test-invitation-token?action=get', + consume: '/api/test-invitation-token?action=consume', + clear: '/api/test-invitation-token?action=clear', + }, + }, { status: 400 }); + } + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a678df0..6f01670 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; +import { Suspense } from 'react'; import './globals.css'; import BackLink from './back-link'; +import { InvitationTokenCapture } from '@/components/InvitationTokenCapture'; const inter = Inter({ subsets: ['latin'] }); @@ -15,6 +17,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( + + + {children} diff --git a/src/app/using-hosted-authkit/basic/callback/route.ts b/src/app/using-hosted-authkit/basic/callback/route.ts index 38ec436..4e9793a 100644 --- a/src/app/using-hosted-authkit/basic/callback/route.ts +++ b/src/app/using-hosted-authkit/basic/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/app/using-hosted-authkit/with-session/callback/route.ts b/src/app/using-hosted-authkit/with-session/callback/route.ts index 8fe57b1..d4fdc13 100644 --- a/src/app/using-hosted-authkit/with-session/callback/route.ts +++ b/src/app/using-hosted-authkit/with-session/callback/route.ts @@ -2,6 +2,7 @@ import { WorkOS } from '@workos-inc/node'; import { NextResponse } from 'next/server'; import { SignJWT } from 'jose'; import { getJwtSecretKey } from '../auth'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -15,10 +16,14 @@ export async function GET(request: Request) { const url = new URL(request.url); const code = url.searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + try { const { user } = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); // Create a JWT with the user's information diff --git a/src/app/using-your-own-ui/sign-in/email-password/email-password.ts b/src/app/using-your-own-ui/sign-in/email-password/email-password.ts index 337029c..b75f996 100644 --- a/src/app/using-your-own-ui/sign-in/email-password/email-password.ts +++ b/src/app/using-your-own-ui/sign-in/email-password/email-password.ts @@ -11,11 +11,15 @@ // to the client for security reasons. import { WorkOS } from '@workos-inc/node'; +import { consumeInvitationToken } from '@/lib/invitation-token'; const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function signIn(prevState: any, formData: FormData) { try { + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + // For the sake of simplicity, we directly return the user here. // In a real application, you would probably store the user in a token (JWT) // and store that token in your DB or use cookies. @@ -23,6 +27,7 @@ export async function signIn(prevState: any, formData: FormData) { clientId: process.env.WORKOS_CLIENT_ID || '', email: String(formData.get('email')), password: String(formData.get('password')), + invitationToken, }); } catch (error) { return { error: JSON.parse(JSON.stringify(error)) }; diff --git a/src/app/using-your-own-ui/sign-in/github-oauth/callback/route.ts b/src/app/using-your-own-ui/sign-in/github-oauth/callback/route.ts index 2741617..1ff5eca 100644 --- a/src/app/using-your-own-ui/sign-in/github-oauth/callback/route.ts +++ b/src/app/using-your-own-ui/sign-in/github-oauth/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/app/using-your-own-ui/sign-in/google-oauth/callback/route.ts b/src/app/using-your-own-ui/sign-in/google-oauth/callback/route.ts index 762ebc9..f300ce3 100644 --- a/src/app/using-your-own-ui/sign-in/google-oauth/callback/route.ts +++ b/src/app/using-your-own-ui/sign-in/google-oauth/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/app/using-your-own-ui/sign-in/magic-auth/magic-auth.ts b/src/app/using-your-own-ui/sign-in/magic-auth/magic-auth.ts index 98f5cd6..b106635 100644 --- a/src/app/using-your-own-ui/sign-in/magic-auth/magic-auth.ts +++ b/src/app/using-your-own-ui/sign-in/magic-auth/magic-auth.ts @@ -11,6 +11,7 @@ // to the client for security reasons. import { WorkOS } from '@workos-inc/node'; +import { consumeInvitationToken } from '@/lib/invitation-token'; const workos = new WorkOS(process.env.WORKOS_API_KEY); @@ -26,6 +27,9 @@ export async function sendCode(prevState: any, formData: FormData) { export async function signIn(prevState: any, formData: FormData) { try { + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + // For the sake of simplicity, we directly return the user here. // In a real application, you would probably store the user in a token (JWT) // and store that token in your DB or use cookies. @@ -33,6 +37,7 @@ export async function signIn(prevState: any, formData: FormData) { clientId: process.env.WORKOS_CLIENT_ID || '', code: String(formData.get('code')), email: String(formData.get('email')), + invitationToken, }); } catch (error) { return { error: JSON.parse(JSON.stringify(error)) }; diff --git a/src/app/using-your-own-ui/sign-in/microsoft-oauth/callback/route.ts b/src/app/using-your-own-ui/sign-in/microsoft-oauth/callback/route.ts index d65cbd2..ba0d48a 100644 --- a/src/app/using-your-own-ui/sign-in/microsoft-oauth/callback/route.ts +++ b/src/app/using-your-own-ui/sign-in/microsoft-oauth/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/app/using-your-own-ui/sign-in/sso/callback/route.ts b/src/app/using-your-own-ui/sign-in/sso/callback/route.ts index 550e07a..43b4339 100644 --- a/src/app/using-your-own-ui/sign-in/sso/callback/route.ts +++ b/src/app/using-your-own-ui/sign-in/sso/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/components/InvitationTokenCapture.tsx b/src/components/InvitationTokenCapture.tsx new file mode 100644 index 0000000..1dd8769 --- /dev/null +++ b/src/components/InvitationTokenCapture.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { storeInvitationToken } from '@/lib/invitation-token'; + +/** + * Client component that captures invitation_token from URL parameters + * and stores it in a cookie for persistence across auth flows. + * + * This ensures that if a user: + * 1. Receives an invitation + * 2. Clicks to accept but forgets their password + * 3. Resets their password + * + * The invitation token will still be available after password reset + * to complete the invitation acceptance. + */ +export function InvitationTokenCapture() { + const searchParams = useSearchParams(); + + useEffect(() => { + const invitationToken = searchParams.get('invitation_token'); + if (invitationToken) { + storeInvitationToken(invitationToken); + } + }, [searchParams]); + + return null; +} diff --git a/src/components/__tests__/InvitationTokenCapture.test.tsx b/src/components/__tests__/InvitationTokenCapture.test.tsx new file mode 100644 index 0000000..f996071 --- /dev/null +++ b/src/components/__tests__/InvitationTokenCapture.test.tsx @@ -0,0 +1,64 @@ +import { render, waitFor } from '@testing-library/react'; +import { InvitationTokenCapture } from '../InvitationTokenCapture'; + +const mockStoreInvitationToken = jest.fn(); + +jest.mock('@/lib/invitation-token', () => ({ + storeInvitationToken: (...args: any[]) => mockStoreInvitationToken(...args), +})); + +const mockSearchParams = new Map(); + +jest.mock('next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => mockSearchParams.get(key), + }), +})); + +describe('InvitationTokenCapture', () => { + beforeEach(() => { + mockSearchParams.clear(); + mockStoreInvitationToken.mockClear(); + }); + + it('should store invitation token when present in URL', async () => { + const token = 'test_invitation_token_xyz'; + mockSearchParams.set('invitation_token', token); + + render(); + + await waitFor(() => { + expect(mockStoreInvitationToken).toHaveBeenCalledWith(token); + }); + }); + + it('should not call storeInvitationToken when no token in URL', async () => { + render(); + + await waitFor(() => { + expect(mockStoreInvitationToken).not.toHaveBeenCalled(); + }); + }); + + it('should handle different token values', async () => { + const tokens = ['Z1uX3RbwcIl5fIGJJJCXXisdI', 'abc123', 'invitation_test_456']; + + for (const token of tokens) { + mockSearchParams.set('invitation_token', token); + mockStoreInvitationToken.mockClear(); + + const { unmount } = render(); + + await waitFor(() => { + expect(mockStoreInvitationToken).toHaveBeenCalledWith(token); + }); + + unmount(); + } + }); + + it('should render nothing (null)', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/lib/__tests__/invitation-token.test.ts b/src/lib/__tests__/invitation-token.test.ts new file mode 100644 index 0000000..5b85d6c --- /dev/null +++ b/src/lib/__tests__/invitation-token.test.ts @@ -0,0 +1,152 @@ +import { + storeInvitationToken, + getStoredInvitationToken, + clearInvitationToken, + consumeInvitationToken, +} from '../invitation-token'; + +const mockCookieStore = new Map(); + +const mockCookies = { + get: jest.fn((name: string) => mockCookieStore.get(name)), + set: jest.fn((name: string, value: string, options: any) => { + mockCookieStore.set(name, { value }); + }), + delete: jest.fn((name: string) => { + mockCookieStore.delete(name); + }), +}; + +jest.mock('next/headers', () => ({ + cookies: () => mockCookies, +})); + +describe('invitation-token', () => { + beforeEach(() => { + mockCookieStore.clear(); + jest.clearAllMocks(); + }); + + describe('storeInvitationToken', () => { + it('should store the invitation token in a cookie', async () => { + const token = 'test_invitation_token_123'; + await storeInvitationToken(token); + + expect(mockCookies.set).toHaveBeenCalledWith( + 'workos_invitation_token', + token, + expect.objectContaining({ + httpOnly: true, + sameSite: 'lax', + path: '/', + }) + ); + }); + + it('should set a 7-day expiration', async () => { + const token = 'test_token'; + await storeInvitationToken(token); + + expect(mockCookies.set).toHaveBeenCalledWith( + 'workos_invitation_token', + token, + expect.objectContaining({ + maxAge: 60 * 60 * 24 * 7, + }) + ); + }); + }); + + describe('getStoredInvitationToken', () => { + it('should return the stored token', async () => { + mockCookieStore.set('workos_invitation_token', { value: 'stored_token' }); + + const result = await getStoredInvitationToken(); + expect(result).toBe('stored_token'); + }); + + it('should return undefined if no token is stored', async () => { + const result = await getStoredInvitationToken(); + expect(result).toBeUndefined(); + }); + }); + + describe('clearInvitationToken', () => { + it('should delete the cookie', async () => { + mockCookieStore.set('workos_invitation_token', { value: 'to_delete' }); + + await clearInvitationToken(); + + expect(mockCookies.delete).toHaveBeenCalledWith('workos_invitation_token'); + }); + }); + + describe('consumeInvitationToken', () => { + it('should return the token and clear it', async () => { + mockCookieStore.set('workos_invitation_token', { value: 'consume_me' }); + + const result = await consumeInvitationToken(); + + expect(result).toBe('consume_me'); + expect(mockCookies.delete).toHaveBeenCalledWith('workos_invitation_token'); + }); + + it('should return undefined if no token exists', async () => { + const result = await consumeInvitationToken(); + + expect(result).toBeUndefined(); + expect(mockCookies.delete).not.toHaveBeenCalled(); + }); + }); +}); + +describe('invitation token flow simulation', () => { + beforeEach(() => { + mockCookieStore.clear(); + jest.clearAllMocks(); + }); + + it('should persist token through multiple page navigations', async () => { + const invitationToken = 'Z1uX3RbwcIl5fIGJJJCXXisdI'; + + // User lands on page with invitation_token + await storeInvitationToken(invitationToken); + + // Simulate navigation to password reset page + // Token should still be retrievable + const tokenAfterNavigation = await getStoredInvitationToken(); + expect(tokenAfterNavigation).toBe(invitationToken); + + // User completes password reset and signs in + // Token is consumed during authentication + const tokenForAuth = await consumeInvitationToken(); + expect(tokenForAuth).toBe(invitationToken); + + // Token should be cleared after consumption + const tokenAfterAuth = await getStoredInvitationToken(); + expect(tokenAfterAuth).toBeUndefined(); + }); + + it('should handle the case where user lands without invitation token', async () => { + // User lands on sign-in page without invitation token + const token = await getStoredInvitationToken(); + expect(token).toBeUndefined(); + + // Authentication should work without invitation token + const tokenForAuth = await consumeInvitationToken(); + expect(tokenForAuth).toBeUndefined(); + }); + + it('should overwrite existing token if user receives new invitation', async () => { + const firstToken = 'first_invitation_token'; + const secondToken = 'second_invitation_token'; + + // First invitation + await storeInvitationToken(firstToken); + expect(await getStoredInvitationToken()).toBe(firstToken); + + // Second invitation (overwrites first) + await storeInvitationToken(secondToken); + expect(await getStoredInvitationToken()).toBe(secondToken); + }); +}); diff --git a/src/lib/invitation-token.ts b/src/lib/invitation-token.ts new file mode 100644 index 0000000..2a94188 --- /dev/null +++ b/src/lib/invitation-token.ts @@ -0,0 +1,49 @@ +'use server'; + +import { cookies } from 'next/headers'; + +const INVITATION_TOKEN_COOKIE = 'workos_invitation_token'; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days (invitation tokens expire in 6 days) + +/** + * Store the invitation token in a cookie. + * This allows the token to persist across page navigations and auth flows + * (e.g., when a user starts an invitation flow but then resets their password). + */ +export async function storeInvitationToken(token: string): Promise { + cookies().set(INVITATION_TOKEN_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: COOKIE_MAX_AGE, + path: '/', + }); +} + +/** + * Retrieve the stored invitation token from the cookie. + * Returns undefined if no token is stored. + */ +export async function getStoredInvitationToken(): Promise { + return cookies().get(INVITATION_TOKEN_COOKIE)?.value; +} + +/** + * Clear the stored invitation token cookie. + * Should be called after the invitation has been accepted or is no longer needed. + */ +export async function clearInvitationToken(): Promise { + cookies().delete(INVITATION_TOKEN_COOKIE); +} + +/** + * Get and clear the invitation token in one operation. + * This is useful for consuming the token during authentication. + */ +export async function consumeInvitationToken(): Promise { + const token = await getStoredInvitationToken(); + if (token) { + await clearInvitationToken(); + } + return token; +}