diff --git a/e2e/app/pages/identity.vue b/e2e/app/pages/identity.vue new file mode 100644 index 0000000..2e0158b --- /dev/null +++ b/e2e/app/pages/identity.vue @@ -0,0 +1,47 @@ + + + diff --git a/e2e/app/pages/index.vue b/e2e/app/pages/index.vue index 1c2a800..e566ab4 100644 --- a/e2e/app/pages/index.vue +++ b/e2e/app/pages/index.vue @@ -27,6 +27,9 @@
  • page context
  • +
  • + identity +
  • CSR

    diff --git a/e2e/app/server/api/identity.get.ts b/e2e/app/server/api/identity.get.ts new file mode 100644 index 0000000..8faaea6 --- /dev/null +++ b/e2e/app/server/api/identity.get.ts @@ -0,0 +1,12 @@ +export default defineEventHandler(async () => { + // Evaluated and fetched through the server composables, outside a page render + const [evaluated, fetched] = await Promise.all([ + evaluate('identity'), + fetchContent('identity-echo'), + ]); + + return { + evaluated: evaluated, + fetched: fetched.content, + }; +}) diff --git a/e2e/constants.ts b/e2e/constants.ts index aa928f5..2227d7a 100644 --- a/e2e/constants.ts +++ b/e2e/constants.ts @@ -33,3 +33,8 @@ export const TENANT_CREDENTIALS = { // specs can assert which page an evaluation or fetch was based on. export const CONTEXT_ECHO_QUERY = 'context'; export const CONTEXT_ECHO_SLOT = 'context-echo'; + +// Query and slot the mock server answers with the identity of the caller, so the +// specs can assert that both sides of the SDK evaluate as the same visitor. +export const IDENTITY_ECHO_QUERY = 'identity'; +export const IDENTITY_ECHO_SLOT = 'identity-echo'; diff --git a/e2e/mock-server.ts b/e2e/mock-server.ts index 7a7c2fa..96d1905 100644 --- a/e2e/mock-server.ts +++ b/e2e/mock-server.ts @@ -1,5 +1,13 @@ import {createServer, type IncomingMessage, type ServerResponse} from 'http'; -import {MOCK_SERVER_PORT, TENANT_NAME, TENANT_CREDENTIALS, CONTEXT_ECHO_QUERY, CONTEXT_ECHO_SLOT} from './constants'; +import { + MOCK_SERVER_PORT, + TENANT_NAME, + TENANT_CREDENTIALS, + CONTEXT_ECHO_QUERY, + CONTEXT_ECHO_SLOT, + IDENTITY_ECHO_QUERY, + IDENTITY_ECHO_SLOT, +} from './constants'; const PORT = MOCK_SERVER_PORT; @@ -33,6 +41,8 @@ const LOCALIZED_SLOT_CONTENT: Record> = { }, }; +const MOCK_CLIENT_ID = '11111111111111111111111111111111'; + const EVALUATION_RESULTS: Record = { now: '2026-01-01T00:00:00.000000', }; @@ -43,10 +53,21 @@ const CREDENTIALS_BY_TENANT: Record = { [TENANT_NAME]: TENANT_CREDENTIALS, }; -type Route = (body: Record, url: URL) => {status: number, data: unknown}; +type Headers = Record; + +type Route = (body: Record, url: URL, headers: Headers) => {status: number, data: unknown}; + +function identityOf(headers: Headers): {token: string | null, clientId: string | null, preview: boolean} { + return { + token: (headers['x-token'] as string | undefined) ?? null, + clientId: (headers['x-client-id'] as string | undefined) ?? null, + preview: headers['x-preview-token'] !== undefined, + }; +} const routes: Record = { track: () => ({status: 200, data: {}}), + cid: () => ({status: 200, data: MOCK_CLIENT_ID}), credentials: (_, url) => { const tenant = url.searchParams.get('tenant') ?? ''; const credentials = CREDENTIALS_BY_TENANT[tenant]; @@ -57,9 +78,14 @@ const routes: Record = { return {status: 404, data: {error: `Unknown tenant "${tenant}".`}}; }, - evaluate: body => { + evaluate: (body, _, headers) => { const query = String(body.query ?? ''); + // Echoes the identity of the caller, to compare both sides of the SDK + if (query === IDENTITY_ECHO_QUERY) { + return {status: 200, data: identityOf(headers)}; + } + // Echoes the received context so the specs can assert which page // the evaluation was based on. if (query === CONTEXT_ECHO_QUERY) { @@ -82,10 +108,24 @@ const routes: Record = { }, }; }, - content: (body, url) => { + content: (body, url, headers) => { const slotId = String(body.slotId ?? url.searchParams.get('slotId') ?? ''); const baseSlotId = slotId.split('@')[0]; + // Echoes the identity of the caller, to compare both sides of the SDK + if (baseSlotId === IDENTITY_ECHO_SLOT) { + return { + status: 200, + data: { + content: { + _component: null, + ...identityOf(headers), + preview: body.previewToken !== undefined, + }, + }, + }; + } + // Echoes the received context so the specs can assert which page // the fetch was based on. if (baseSlotId === CONTEXT_ECHO_SLOT) { @@ -127,10 +167,19 @@ function handleRequest(request: IncomingMessage, response: ServerResponse): void request.on('data', (chunk: Buffer) => body.push(chunk)); request.on('end', () => { + const {origin} = request.headers; + response.setHeader('Content-Type', 'application/json'); - response.setHeader('Access-Control-Allow-Origin', '*'); response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - response.setHeader('Access-Control-Allow-Headers', '*'); + response.setHeader('Access-Control-Allow-Headers', request.headers['access-control-request-headers'] ?? '*'); + + if (origin === undefined) { + response.setHeader('Access-Control-Allow-Origin', '*'); + } else { + // The SDK sends credentials, which forbids a wildcard origin + response.setHeader('Access-Control-Allow-Origin', origin); + response.setHeader('Access-Control-Allow-Credentials', 'true'); + } if (request.method === 'OPTIONS') { response.writeHead(204); @@ -151,7 +200,7 @@ function handleRequest(request: IncomingMessage, response: ServerResponse): void return; } - const {status, data} = route(requestBody, url); + const {status, data} = route(requestBody, url, request.headers); response.writeHead(status); response.end(JSON.stringify(data)); diff --git a/e2e/specs/middleware.spec.ts b/e2e/specs/middleware.spec.ts index 4572924..5ba54ee 100644 --- a/e2e/specs/middleware.spec.ts +++ b/e2e/specs/middleware.spec.ts @@ -1,5 +1,6 @@ import {test, expect} from '@playwright/test'; import {Token} from '@croct/sdk/token'; +import {DEFAULT_CREDENTIALS} from '../constants'; test.describe('server middleware', () => { test.describe('cookies', () => { @@ -52,10 +53,10 @@ test.describe('server middleware', () => { test.describe('preview tokens', () => { function createValidPreviewToken(): string { - const header = Buffer.from('{"alg":"none"}').toString('base64url'); - const payload = Buffer.from('{"exp":9999999999}').toString('base64url'); - - return `${header}.${payload}.`; + // A token the SDK also accepts, as it manages the cookie in the browser + return Token.issue(DEFAULT_CREDENTIALS.appId) + .withDuration(3600) + .toString(); } test('should set preview cookie from a valid query parameter', async ({page}) => { @@ -70,6 +71,17 @@ test.describe('server middleware', () => { expect(previewCookie!.value).toBe(previewToken); }); + test('should expose the preview token to the SDK', async ({page}) => { + const previewToken = createValidPreviewToken(); + + await page.goto(`/?croct-preview=${previewToken}`); + + // The SDK renders the preview widget and manages the token in the browser + const cookies = await page.evaluate(() => document.cookie); + + expect(cookies).toContain(`ct.preview_token=${previewToken}`); + }); + test('should persist preview cookie across navigations', async ({page}) => { const previewToken = createValidPreviewToken(); @@ -111,9 +123,13 @@ test.describe('server middleware', () => { await page.goto('/?croct-preview=exit'); - const cookiesAfterExit = await page.context().cookies(); - - expect(cookiesAfterExit.find(cookie => cookie.name === 'ct.preview_token')).toBeUndefined(); + // The SDK also manages the cookie, so the exit settles asynchronously + await expect + .poll( + async () => (await page.context().cookies()) + .find(cookie => cookie.name === 'ct.preview_token'), + ) + .toBeUndefined(); }); }); diff --git a/e2e/specs/ssr/identity.spec.ts b/e2e/specs/ssr/identity.spec.ts new file mode 100644 index 0000000..1ddc35d --- /dev/null +++ b/e2e/specs/ssr/identity.spec.ts @@ -0,0 +1,131 @@ +import {test, expect} from '@playwright/test'; +import type {Page} from '@playwright/test'; +import {Token} from '@croct/sdk/token'; +import {DEFAULT_CREDENTIALS} from '../../constants'; + +type Identity = { + clientId: string, + userToken: string, +}; + +test.describe('identity', () => { + async function getIdentity(page: Page): Promise { + const cookies = await page.context().cookies(); + + return { + clientId: cookies.find(cookie => cookie.name === 'ct.client_id')!.value, + userToken: cookies.find(cookie => cookie.name === 'ct.user_token')!.value, + }; + } + + async function fetchIdentity(page: Page): Promise<{token: string, preview: boolean}> { + const {fetched} = await (await page.request.get('/api/identity')).json(); + + return fetched; + } + + test('should expose the identity to the SDK', async ({page}) => { + await page.goto('/identity'); + + const cookies = await page.evaluate(() => document.cookie); + + expect(cookies).toContain('ct.client_id='); + expect(cookies).toContain('ct.user_token='); + }); + + test('should evaluate and fetch as the identified visitor while rendering a first visit', async ({page}) => { + // No cookies yet, so the middleware issues the identity while rendering + await page.goto('/identity'); + + const {clientId, userToken} = await getIdentity(page); + + await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken); + await expect(page.getByTestId('server-evaluation-client-id')).toHaveText(clientId); + await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken); + await expect(page.getByTestId('server-fetch-client-id')).toHaveText(clientId); + }); + + test('should evaluate and fetch as the identified visitor while rendering a later visit', async ({page}) => { + await page.goto('/identity'); + + const {clientId, userToken} = await getIdentity(page); + + await page.goto('/identity'); + + await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken); + await expect(page.getByTestId('server-evaluation-client-id')).toHaveText(clientId); + await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken); + }); + + test('should evaluate and fetch as the identified visitor when navigating on the client', async ({page}) => { + await page.goto('/'); + + const {clientId, userToken} = await getIdentity(page); + + await page.getByRole('link', {name: 'identity'}).click(); + + await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken); + await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken); + await expect(page.getByTestId('server-fetch-client-id')).toHaveText(clientId); + }); + + test('should evaluate and fetch as the identified visitor in the browser', async ({page}) => { + await page.goto('/identity'); + + const {userToken} = await getIdentity(page); + + await expect(page.getByTestId('browser-evaluation-token')).toHaveText(userToken, {timeout: 10000}); + await expect(page.getByTestId('browser-fetch-token')).toHaveText(userToken, {timeout: 10000}); + }); + + test('should evaluate and fetch as the identified visitor in an application route', async ({page}) => { + await page.goto('/identity'); + + const {userToken} = await getIdentity(page); + + // The request shares the cookies of the page + const response = await page.request.get('/api/identity'); + + expect(response.ok()).toBe(true); + + const {evaluated, fetched} = await response.json(); + + expect(evaluated.token).toBe(userToken); + expect(fetched.token).toBe(userToken); + }); + + test('should stop previewing once the preview is exited', async ({page}) => { + const previewToken = Token.issue(DEFAULT_CREDENTIALS.appId) + .withDuration(3600) + .toString(); + + await page.goto(`/identity?croct-preview=${previewToken}`); + + expect((await fetchIdentity(page)).preview).toBe(true); + + await page.goto('/identity?croct-preview=exit'); + + // The SDK also manages the preview cookie, so exiting settles asynchronously + await expect + .poll(async () => (await fetchIdentity(page)).preview) + .toBe(false); + }); + + test('should keep the identity of an identified user', async ({page}) => { + // The application resolver identifies the user from a session cookie + await page.context().addCookies([{ + name: 'app.session_user', + value: 'user-42', + url: 'http://localhost:3200', + }]); + + await page.goto('/identity'); + + const {userToken} = await getIdentity(page); + + await expect(page.getByTestId('server-evaluation-token')).toHaveText(userToken); + await expect(page.getByTestId('server-fetch-token')).toHaveText(userToken); + await expect(page.getByTestId('browser-evaluation-token')).toHaveText(userToken, {timeout: 10000}); + await expect(page.getByTestId('browser-fetch-token')).toHaveText(userToken, {timeout: 10000}); + }); +}); diff --git a/src/runtime/server/middleware/croct.ts b/src/runtime/server/middleware/croct.ts index bc7c212..31cb5b8 100644 --- a/src/runtime/server/middleware/croct.ts +++ b/src/runtime/server/middleware/croct.ts @@ -9,7 +9,6 @@ import { } from 'h3'; import type {H3Event} from 'h3'; import {Token} from '@croct/sdk/token'; -import {base64UrlDecode} from '@croct/sdk/base64Url'; import {useRuntimeConfig} from '#imports'; import {credentialsResolver, localeResolver, userIdResolver} from '#croct/resolvers'; import {setUserTokenCookie, getProductionDefaults} from '../utils/cookie'; @@ -31,6 +30,9 @@ function shouldSkip(pathname: string): boolean { } export default defineEventHandler(async event => { + // Requests issued while rendering a page share the context of the request + // being rendered, so the identity is resolved once and reused, rather than + // issuing a new client ID and user token for every internal request. const url = getRequestURL(event); if (shouldSkip(url.pathname)) { @@ -81,7 +83,8 @@ export default defineEventHandler(async event => { path: '/', ...(cookie.previewToken.domain !== '' ? {domain: cookie.previewToken.domain} : {}), ...productionDefaults, - httpOnly: true, + // The SDK manages the preview mode in the browser + httpOnly: false, }); } @@ -92,10 +95,35 @@ export default defineEventHandler(async event => { path: '/', ...(cookie.clientId.domain !== '' ? {domain: cookie.clientId.domain} : {}), ...productionDefaults, - httpOnly: true, + // The SDK reads the client ID in the browser + httpOnly: false, + }); + + // Requests issued while rendering forward the cookies of the request being + // rendered, which still carry the previous identity, if any. Reflecting the + // issued cookies keeps the identity the same across all of them. + updateRequestCookies(event, { + [cookie.clientId.name]: clientId, + [cookie.userToken.name]: userToken.toString(), + ...(previewToken !== null && previewToken !== 'exit' ? {[cookie.previewToken.name]: previewToken} : {}), }); }); +function updateRequestCookies(event: H3Event, values: Record): void { + const names = new Set(Object.keys(values)); + const header = event.node.req.headers.cookie ?? ''; + + const cookies = header.split(';') + .map(entry => entry.trim()) + .filter(entry => entry !== '' && !names.has(entry.split('=')[0])); + + for (const [name, value] of Object.entries(values)) { + cookies.push(`${name}=${value}`); + } + + event.node.req.headers.cookie = cookies.join('; '); +} + function resolveClientId(cookieValue: string | undefined): string { if (cookieValue !== undefined && CLIENT_ID_PATTERN.test(cookieValue)) { return cookieValue; @@ -201,12 +229,10 @@ function isPreviewTokenValid(token: unknown): token is string { return false; } - const now = Math.floor(Date.now() / 1000); - + // The SDK manages the token in the browser, so both sides must agree + // on what a valid token is. try { - const payload = JSON.parse(base64UrlDecode(token.split('.')[1]).toString()); - - return Number.isInteger(payload.exp) && payload.exp > now; + return Token.parse(token).isValidNow(); } catch { return false; } diff --git a/src/runtime/server/utils/cookie.ts b/src/runtime/server/utils/cookie.ts index f076eba..6c893f1 100644 --- a/src/runtime/server/utils/cookie.ts +++ b/src/runtime/server/utils/cookie.ts @@ -19,7 +19,8 @@ export function setUserTokenCookie(event: H3Event, token: Token): void { path: '/', ...(config.domain !== '' ? {domain: config.domain} : {}), ...getProductionDefaults(), - httpOnly: true, + // The SDK reads the token in the browser + httpOnly: false, }); } diff --git a/test/runtime/server/middleware/croct.test.ts b/test/runtime/server/middleware/croct.test.ts index 25c7497..616d1be 100644 --- a/test/runtime/server/middleware/croct.test.ts +++ b/test/runtime/server/middleware/croct.test.ts @@ -85,10 +85,10 @@ describe('middleware', () => { } function createValidPreviewToken(): string { - const header = Buffer.from('{"alg":"none"}').toString('base64url'); - const payload = Buffer.from('{"exp":9999999999}').toString('base64url'); - - return `${header}.${payload}.`; + // The SDK parses the token in the browser, so both sides must accept it + return Token.issue(appId) + .withDuration(3600) + .toString(); } describe('route skipping', () => { @@ -164,6 +164,50 @@ describe('middleware', () => { expect(event.context.croct!.clientId).not.toBe('not-a-uuid'); }); + it('should reflect the issued client ID on the request being handled', async () => { + const event = createMockEvent(); + + await handleRequest(event); + + // Requests issued while rendering forward the cookies of this request + expect(event.node.req.headers.cookie) + .toContain(`ct.client_id=${event.context.croct!.clientId}`); + }); + + it('should keep cookies named after object properties on the request', async () => { + const event = createMockEvent(); + + event.node.req.headers.cookie = 'toString=value; constructor=value'; + + await handleRequest(event); + + expect(event.node.req.headers.cookie).toContain('toString=value'); + expect(event.node.req.headers.cookie).toContain('constructor=value'); + }); + + it('should replace an invalid client ID on the request being handled', async () => { + const event = createMockEvent(); + + event.node.req.headers.cookie = 'ct.client_id=not-a-uuid; foo=bar'; + + await handleRequest(event); + + expect(event.node.req.headers.cookie).not.toContain('not-a-uuid'); + expect(event.node.req.headers.cookie).toContain('foo=bar'); + expect(event.node.req.headers.cookie) + .toContain(`ct.client_id=${event.context.croct!.clientId}`); + }); + + it('should let the SDK read the client ID cookie', async () => { + const event = createMockEvent(); + + await handleRequest(event); + + const cookie = getSetCookies(event).find(entry => entry.startsWith('ct.client_id=')); + + expect(cookie).not.toContain('HttpOnly'); + }); + it('should set the client ID cookie in the response', async () => { const event = createMockEvent(); @@ -184,6 +228,15 @@ describe('middleware', () => { expect(event.context.croct!.userToken).toBeTruthy(); }); + it('should reflect the issued token on the request being handled', async () => { + const event = createMockEvent(); + + await handleRequest(event); + + expect(event.node.req.headers.cookie) + .toContain(`ct.user_token=${event.context.croct!.userToken}`); + }); + it('should issue a new token when cookie is invalid', async () => { const event = createMockEvent(); @@ -604,6 +657,18 @@ describe('middleware', () => { expect(event.context.croct!.previewToken).toBe(previewToken); }); + it('should not set preview token for an expired token', async () => { + const expiredToken = Token.issue(appId) + .withDuration(-3600) + .toString(); + + const event = createMockEvent(`http://localhost:3000/?croct-preview=${expiredToken}`); + + await handleRequest(event); + + expect(event.context.croct!.previewToken).toBeUndefined(); + }); + it('should not set preview token for an invalid token', async () => { const event = createMockEvent('http://localhost:3000/?croct-preview=garbage'); diff --git a/test/runtime/server/utils/cookie.test.ts b/test/runtime/server/utils/cookie.test.ts index be44417..dd4edc9 100644 --- a/test/runtime/server/utils/cookie.test.ts +++ b/test/runtime/server/utils/cookie.test.ts @@ -64,7 +64,7 @@ describe('cookie', () => { expect(cookies.some(cookie => cookie.startsWith(`ct.user_token=${token.toString()}`))).toBe(true); }); - it('should set the HttpOnly flag', () => { + it('should let the SDK read the cookie', () => { const event = createMockEvent(); const token = Token.issue(appId).withDuration(3600); @@ -72,7 +72,7 @@ describe('cookie', () => { const cookies = getSetCookies(event); - expect(cookies.some(cookie => cookie.includes('HttpOnly'))).toBe(true); + expect(cookies.some(cookie => cookie.includes('HttpOnly'))).toBe(false); }); it('should set the cookie max-age from config', () => {