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 @@
+
+
+
+ Identity
+
+
+ {{ evaluated.token ?? 'none' }}
+ {{ evaluated.clientId ?? 'none' }}
+
+
+
+ {{ fetched.content.token ?? 'none' }}
+ {{ fetched.content.clientId ?? 'none' }}
+
+
+
+ {{ browserEvaluated.token ?? 'none' }}
+
+
+
+ {{ browserFetched.token ?? 'none' }}
+
+
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', () => {