Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/widget-user-token-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

`userToken` can be a function (sync or async) that returns a fresh signed token. The widget calls it once each time it asks the server for a session, not per message. If it throws, rejects or takes over 5 seconds, the session starts without the visitor and a warning is logged. A string still works as before.
4 changes: 3 additions & 1 deletion api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export interface ThemeConfig {

export type ThemeMode = 'light' | 'dark' | 'auto';

export type UserToken = string | (() => string | undefined | Promise<string | undefined>);

export declare class Widget {
get isInline(): boolean;
constructor(config: WidgetConfig);
Expand Down Expand Up @@ -114,7 +116,7 @@ export interface WidgetConfig {
nonce?: string;
timeout?: number;
turnstileSiteKey?: string;
userToken?: string;
userToken?: UserToken;
mode?: 'popup' | 'inline';
siteId?: string;
container?: string | HTMLElement;
Expand Down
6 changes: 6 additions & 0 deletions e2e/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ const endpoints = {
'/api/chat': chatEndpoint(),
// Session tokens on, for the session spec.
'/api/secure-chat': chatEndpoint({ sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'], allowAnonymous: false }),
// Signed-in visitors: the userToken spec signs a token with this secret.
'/api/identity-chat': chatEndpoint({
sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'],
identity: { secret: 'an-e2e-identity-secret-of-32-bytes-or-more' },
allowAnonymous: false,
}),
};

/** Runs a Fetch API handler for a Node request. */
Expand Down
52 changes: 52 additions & 0 deletions e2e/tests/real-server.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHmac } from 'node:crypto';
import { expect, test } from '@playwright/test';

// These run against the real @chatcops/server handler (see e2e/server.mjs).
Expand Down Expand Up @@ -57,3 +58,54 @@ test('with session tokens on, the widget gets a token and sends it', async ({ pa
expect(requests[2].auth).toMatch(/^Bearer /);
expect(requests[3].auth).toBe(requests[2].auth);
});

const IDENTITY_SECRET = 'an-e2e-identity-secret-of-32-bytes-or-more';
const signedToken = (userId: string, lifetimeSeconds: number) => {
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
const body = `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode({ sub: userId, exp: Math.floor(Date.now() / 1000) + lifetimeSeconds })}`;
return `${body}.${createHmac('sha256', IDENTITY_SECRET).update(body).digest('base64url')}`;
};

test('a userToken function gives the server a fresh token when the session is requested', async ({ page }) => {
// A token is signed when the session is requested, not when the page loads, so it cannot have expired by then.
let calls = 0;
await page.exposeFunction('signToken', () => {
calls++;
return signedToken('visitor-1', 300);
});
await page.goto('/sessions.html');
await page.evaluate(() => {
const w = window as unknown as { signToken(): Promise<string>; ChatCops: { init(config: object): void } };
w.ChatCops.init({ apiUrl: '/api/identity-chat', userToken: () => w.signToken() });
});
const sessions: Array<{ status: number }> = [];
page.on('response', (res) => {
if (res.url().endsWith('/api/identity-chat/session')) sessions.push({ status: res.status() });
});

await page.locator(fab).click();
expect(calls).toBe(0);
await page.locator(input).fill('Hi there');
await page.locator(send).click();
await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.');
await page.locator(input).fill('Again');
await page.locator(send).click();
await expect(page.locator('.cc-message-assistant', { hasText: 'Hello from the test server.' })).toHaveCount(2);

expect(sessions).toEqual([{ status: 200 }]);
expect(calls).toBe(1);
});

test('with a userToken that is not a valid token, the server refuses the session', async ({ page }) => {
await page.goto('/sessions.html');
await page.evaluate(() => {
(window as unknown as { ChatCops: { init(config: object): void } }).ChatCops.init({
apiUrl: '/api/identity-chat',
userToken: () => 'not.a.token',
});
});
await page.locator(fab).click();
await page.locator(input).fill('Hi there');
await page.locator(send).click();
await expect(page.locator('.cc-message-assistant').last()).toContainText('Something went wrong');
});
41 changes: 35 additions & 6 deletions packages/widget/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ import type { WirePart } from './protocol.js';
import { keyStore, type KeyStore } from '../storage.js';
import type { WidgetChatRequest, WidgetChatChunk } from './types.js';

/** A signed token for the visitor, or a function that returns one (or none) each time a session is requested. */
export type UserToken = string | (() => string | undefined | Promise<string | undefined>);

/** How long a `userToken` function may take before the session starts without an identity. */
const USER_TOKEN_TIMEOUT = 5000;

export interface ChatClientOptions {
apiUrl: string;
/** Inactivity timeout in milliseconds. Defaults to 30000. */
timeout?: number;
/** Cloudflare Turnstile site key, when the server checks Turnstile before issuing a session. */
turnstileSiteKey?: string;
/** The host site's signed token for a signed-in visitor, sent when asking for a session. */
userToken?: string;
/**
* The host site's signed token for a signed-in visitor, sent when asking for a session.
* A function is called for a fresh one each time, and may be async.
*/
userToken?: UserToken;
/** Where the session token is kept. Defaults to localStorage; the widget passes its own store. */
store?: KeyStore;
}
Expand All @@ -34,7 +43,7 @@ export class ChatClient {
private saved?: Partial<Session>;
private store: KeyStore;
private turnstileSiteKey?: string;
private userToken?: string;
private userToken?: UserToken;

constructor(options: ChatClientOptions) {
this.apiUrl = options.apiUrl;
Expand All @@ -52,7 +61,7 @@ export class ChatClient {
* different user). `forget: false` leaves the stored token alone, for a widget that
* may not use storage yet.
*/
setUserToken(token: string | undefined, forget = true): void {
setUserToken(token: UserToken | undefined, forget = true): void {
if (token === this.userToken) return;
this.userToken = token;
if (forget) this.forgetSession();
Expand All @@ -65,10 +74,30 @@ export class ChatClient {
this.store.remove(this.sessionKey);
}

get identity(): string | undefined {
get identity(): UserToken | undefined {
return this.userToken;
}

/** The token to send with a session request. A function that fails, or is too slow, means no identity. */
private async resolveUserToken(): Promise<string | undefined> {
const source = this.userToken;
if (typeof source !== 'function') return source;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const token = await Promise.race([
source(),
new Promise<never>((_, reject) => {
timer = setTimeout(reject, USER_TOKEN_TIMEOUT, new Error('timed out'));
}),
]);
return typeof token === 'string' && token ? token : undefined;
} catch (error) {
console.warn('ChatCops: userToken() failed, so the session starts without the visitor', error);
} finally {
clearTimeout(timer);
}
}

async *sendMessage(
request: WidgetChatRequest,
options: SendMessageOptions = {}
Expand Down Expand Up @@ -145,7 +174,7 @@ export class ChatClient {
const minted = await post(this.apiUrl.replace(/\/?(\?.*)?$/, '/session$1'), {
conversationId: request.conversationId,
previousToken: saved.token,
userToken: this.userToken,
userToken: await this.resolveUserToken(),
// Its own chunk: only sites with a Turnstile key download it.
turnstileToken:
this.turnstileSiteKey && !resumable
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
export type { MessageData, MessageErrorType, MessageStatus } from './dom/messages.js';
export type { ThemeConfig, ThemeMode } from './theme.js';
export type { Persistence } from './storage.js';
export type { UserToken } from './api/client.js';
export type { WidgetLocaleStrings } from './i18n.js';
export { parseAutoOpen } from './script-config.js';

Expand Down
7 changes: 5 additions & 2 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createShadowRoot, destroyShadowRoot } from './dom/shadow.js';
import { FAB } from './dom/fab.js';
import { Panel, type PanelLabels } from './dom/panel.js';
import { BUBBLE_SHOWN_KEY, WelcomeBubble } from './dom/bubble.js';
import { ChatClient } from './api/client.js';
import { ChatClient, type UserToken } from './api/client.js';
import { ConversationStorage, type Persistence } from './storage.js';
import { claimNamespace, type ClaimedNamespace } from './namespace.js';
import { applyTheme, type ThemeConfig } from './theme.js';
Expand Down Expand Up @@ -53,8 +53,11 @@ export interface WidgetConfig {
/**
* For a visitor your site has signed in: an HS256 JWT your server signs
* with the chat server's `identity.secret` (`sub`, `exp`, optional `tier`).
* Give a function (it may be async) to get a fresh token each time the
* widget asks for a session; if it fails or takes over 5 seconds, the
* session starts without the visitor.
*/
userToken?: string;
userToken?: UserToken;
mode?: 'popup' | 'inline';
/**
* Names this widget's saved conversation. Set a different one on each widget
Expand Down
10 changes: 10 additions & 0 deletions packages/widget/tests/api.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ChatCops, {
type PreChatFormConfig,
type ThemeConfig,
type ThemeMode,
type UserToken,
type WidgetConfig,
type WidgetLocaleStrings,
type WidgetUpdate,
Expand Down Expand Up @@ -41,6 +42,15 @@ describe('the widget API types', () => {
ChatCops.setConsent('yes');
});

it('takes a string or a function for userToken', () => {
expectTypeOf<WidgetConfig['userToken']>().toEqualTypeOf<UserToken | undefined>();
expectTypeOf<UserToken>().toEqualTypeOf<string | (() => string | undefined | Promise<string | undefined>)>();
const config: WidgetConfig = { apiUrl: '/api/chat', userToken: async () => 'signed.token' };
ChatCops.init({ ...config, userToken: () => undefined });
// @ts-expect-error: a function has to return a string
ChatCops.init({ apiUrl: '/api/chat', userToken: () => 42 });
});

it('types update() the same in both builds, without position', () => {
expectTypeOf(ChatCops.update).parameters.toEqualTypeOf<[WidgetUpdate]>();
expectTypeOf<ChatCopsLoader['update']>().parameters.toEqualTypeOf<[WidgetUpdate]>();
Expand Down
145 changes: 145 additions & 0 deletions packages/widget/tests/api/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,148 @@ describe('ChatClient with session tokens', () => {
expect(String(fetch.mock.calls[1][0])).toBe(sessionUrl);
});
});

describe('ChatClient with a userToken provider', () => {
const sessionFlow = () =>
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(unauthorized())
.mockResolvedValueOnce(minted('c1'))
.mockResolvedValueOnce(v1Reply());
const sessionBody = (fetch: ReturnType<typeof vi.fn>) => calls(fetch)[1].body;

it('asks for a token when it requests a session, and sends what the provider returns', async () => {
const fetch = sessionFlow();
const userToken = vi.fn(async () => 'fresh.signed.token');
await collect(new ChatClient({ apiUrl: API, userToken }));
expect(userToken).toHaveBeenCalledTimes(1);
expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'fresh.signed.token' });
});

it('accepts a provider that answers at once', async () => {
const fetch = sessionFlow();
await collect(new ChatClient({ apiUrl: API, userToken: () => 'sync.token' }));
expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'sync.token' });
});

it('does not call the provider when the server has no sessions', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(v1Reply());
const userToken = vi.fn(() => 'token');
await collect(new ChatClient({ apiUrl: API, userToken }));
expect(userToken).not.toHaveBeenCalled();
});

it('calls it once per session request, not once per message', async () => {
const fetch = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(unauthorized())
.mockResolvedValueOnce(minted('c1'))
.mockResolvedValueOnce(v1Reply())
.mockResolvedValueOnce(v1Reply())
.mockResolvedValueOnce(v1Reply());
const userToken = vi.fn(() => 'token');
const client = new ChatClient({ apiUrl: API, userToken });
await collect(client);
await collect(client);
await collect(client);
expect(userToken).toHaveBeenCalledTimes(1);
expect(calls(fetch as never).filter((c) => c.url.endsWith('/session'))).toHaveLength(1);
});

it('gets a new token for the next session, once the session token has expired', async () => {
const fetch = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(unauthorized())
.mockResolvedValueOnce(minted('c1', 'tok-1'))
.mockResolvedValueOnce(v1Reply())
.mockResolvedValueOnce(unauthorized('token_expired'))
.mockResolvedValueOnce(minted('c1', 'tok-2'))
.mockResolvedValueOnce(v1Reply());
let n = 0;
const client = new ChatClient({ apiUrl: API, userToken: async () => `signed-${++n}` });
await collect(client);
await collect(client);
const sessions = calls(fetch as never).filter((c) => c.url.endsWith('/session'));
expect(sessions.map((s) => s.body.userToken)).toEqual(['signed-1', 'signed-2']);
});

it('sends no identity when the provider has none (a signed-out visitor)', async () => {
const fetch = sessionFlow();
await collect(new ChatClient({ apiUrl: API, userToken: async () => undefined }));
expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1' });
});

it.each([
['throws', 1, () => { throw new Error('signed out'); }],
['rejects', 1, () => Promise.reject(new Error('network'))],
['returns something that is not a string', 0, () => 42 as never],
['returns an empty string', 0, () => ''],
])('starts the session without an identity when the provider %s', async (_, logged, userToken) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const fetch = sessionFlow();
const chunks = await collect(new ChatClient({ apiUrl: API, userToken }));
expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1' });
expect(chunks).toContainEqual({ done: true });
expect(warn).toHaveBeenCalledTimes(logged);
});

it('logs why the provider failed', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
sessionFlow();
const error = new Error('token endpoint is down');
await collect(new ChatClient({ apiUrl: API, userToken: () => Promise.reject(error) }));
expect(warn.mock.calls[0]).toContain(error);
});

it('starts the session without an identity when the provider takes too long', async () => {
vi.useFakeTimers();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const fetch = sessionFlow();
const done = collect(new ChatClient({ apiUrl: API, userToken: () => new Promise<string>(() => undefined), timeout: 60_000 }));
await vi.advanceTimersByTimeAsync(4_900);
expect(calls(fetch as never)).toHaveLength(1);
await vi.advanceTimersByTimeAsync(200);
await done;
expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1' });
expect(warn).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});

it('leaves no timer behind when the provider answers', async () => {
vi.useFakeTimers();
try {
sessionFlow();
const done = collect(new ChatClient({ apiUrl: API, userToken: async () => 'token', timeout: 60_000 }));
await vi.advanceTimersByTimeAsync(0);
await done;
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});

it('still sends a fixed string as before', async () => {
const fetch = sessionFlow();
await collect(new ChatClient({ apiUrl: API, userToken: 'host.signed.token' }));
expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'host.signed.token' });
});

it('can be replaced by a fixed string, or by none, with setUserToken', async () => {
const userToken = vi.fn(() => 'from-provider');
const client = new ChatClient({ apiUrl: API, userToken });
client.setUserToken('fixed');
expect(client.identity).toBe('fixed');
const fetch = sessionFlow();
await collect(client);
expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'fixed' });
expect(userToken).not.toHaveBeenCalled();
});

it('can be set after construction with setUserToken', async () => {
const client = new ChatClient({ apiUrl: API });
client.setUserToken(async () => 'late-provider');
const fetch = sessionFlow();
await collect(client);
expect(sessionBody(fetch as never)).toEqual({ conversationId: 'c1', userToken: 'late-provider' });
});
});
Loading
Loading