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
75 changes: 67 additions & 8 deletions src/agent_manager/api/static/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -52143,8 +52143,8 @@ var AgentChatClient = class {
}
/** A 401 usually means the token expired: renew once and retry. The rejected
* attempt changed nothing, so replaying is safe. */
async request(path2, init) {
let response = await this.send(path2, init, await this.tokens.current());
async request(path2, init, options) {
let response = await this.send(path2, init, await this.tokens.current({ forceCheck: options?.forceCheck }));
if (response.status === 401) {
response = await this.send(path2, init, await this.tokens.renew());
}
Expand All @@ -52166,7 +52166,7 @@ var AgentChatClient = class {
async listConversations(limit = 20, cursor) {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
const response = await this.request(`/conversations?${params.toString()}`);
const response = await this.request(`/conversations?${params.toString()}`, void 0, { forceCheck: true });
const data = await response.json();
const rawItems = Array.isArray(data.items) ? data.items : [];
const items = rawItems.map((thread) => ({
Expand Down Expand Up @@ -52299,19 +52299,45 @@ var TokenSource = class {
/** Bumped by `reset()` so a resolution already in flight, once it lands, can
* tell it is answering a question nobody is asking anymore. */
this.generation = 0;
/** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */
this.lastClaimAttemptPass = null;
this.lastCookieSnapshot = typeof document !== "undefined" ? document.cookie : "";
this.identityCheckDirty = true;
this.tokenUrl = options.tokenUrl ?? "";
this.provider = options.provider ?? null;
this.storage = options.storage ?? localStorage;
this.requireIdentity = options.requireIdentity ?? false;
this.onIdentityFailure = options.onIdentityFailure ?? (() => {
});
if (typeof window !== "undefined") {
const markDirty = () => {
this.identityCheckDirty = true;
};
try {
window.addEventListener("focus", markDirty);
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", markDirty);
}
window.addEventListener("storage", markDirty);
} catch {
}
}
}
async current() {
if (!this.cached) await this.resolve(() => this.storedPass());
async current(options) {
const pass = this.storedPass();
const cookieChanged = this.cookieSnapshotChanged();
const force = options?.forceCheck ?? false;
const shouldClaim = this.isCookieMode() && pass !== null && (force || this.identityCheckDirty || cookieChanged || pass !== this.lastClaimAttemptPass);
if (!this.cached || shouldClaim) {
await this.resolve(() => this.storedPass());
this.identityCheckDirty = false;
this.updateCookieSnapshot();
}
return this.cached;
}
/** After a 401: whatever we sent is no good, so get another. */
async renew() {
this.identityCheckDirty = true;
return this.resolve(() => this.issuePass());
}
/** Forget the token in hand, so the next request works out who the caller is
Expand All @@ -52322,12 +52348,24 @@ var TokenSource = class {
this.generation += 1;
this.cached = null;
this.pending = null;
this.lastClaimAttemptPass = null;
this.identityCheckDirty = true;
this.updateCookieSnapshot();
}
/** Drop this browser's identity entirely — a host app signing its user out. */
forget() {
this.reset();
this.clearPass();
}
cookieSnapshotChanged() {
if (typeof document === "undefined") return false;
return document.cookie !== this.lastCookieSnapshot;
}
updateCookieSnapshot() {
if (typeof document !== "undefined") {
this.lastCookieSnapshot = document.cookie;
}
}
/** Concurrent callers share one resolution. Without this, parallel requests
* each fetch a token and each hand over the visitor pass. */
resolve(fallback) {
Expand All @@ -52344,23 +52382,44 @@ var TokenSource = class {
})());
return this.pending;
}
isCookieMode() {
return !this.tokenUrl && !this.provider;
}
/** A host token, plus the one-time hand-off of whatever this browser chatted
* about before signing in. */
async hostToken() {
const token = await this.fromHost();
if (token) await this.claimVisitorHistory(token);
if (token || this.isCookieMode() && this.storedPass()) {
await this.claimVisitorHistory(token);
}
return token;
}
async claimVisitorHistory(hostToken) {
const pass = this.storedPass();
if (!pass) return;
try {
const headers = { "Content-Type": "application/json" };
if (hostToken) headers.Authorization = `Bearer ${hostToken}`;
const response = await fetch(`${this.endpoint}${LINK_ENDPOINT}`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hostToken}` },
headers,
credentials: "include",
body: JSON.stringify({ anonymous_token: pass })
});
if (response.status < 500) this.clearPass();
if (response.ok) {
const data = await response.json().catch(() => null);
if (hostToken !== null || (data?.conversations_moved ?? 0) > 0) {
this.clearPass();
this.lastClaimAttemptPass = null;
this.identityCheckDirty = true;
} else {
this.lastClaimAttemptPass = pass;
}
} else if (response.status === 401) {
this.lastClaimAttemptPass = pass;
} else if (hostToken !== null && response.status >= 400 && response.status < 500) {
this.clearPass();
}
} catch {
}
}
Expand Down
44 changes: 44 additions & 0 deletions src/agent_manager/api/static/widget.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,50 @@ assert.equal(mintedPass, false, "never asks for a visitor pass");
);
}

// In cookie mode (host_token), a visitor chats anonymously, then signs in via cookie in the same SPA.
// Calling client methods re-evaluates identity, claims history via /auth/link, and sends requests with cookie.
{
resetPage();
localStorage.setItem(visitorPassKey("https://api.example"), "old-cookie-pass");
let loggedInViaCookie = false;
const calls = [];
globalThis.fetch = async (url, options = {}) => {
calls.push({ url, auth: options.headers?.Authorization });
if (url.endsWith("/auth/link")) {
return loggedInViaCookie ? jsonResponse({ conversations_moved: 1 }) : jsonResponse({}, false, 401);
}
return jsonResponse({ conversation_id: "c1" });
};
const tokens = new TokenSource("https://api.example");
const client = new AgentChatClient("https://api.example", tokens);

// Before login: claimVisitorHistory gets 401, pass is kept, bearer old-cookie-pass sent
await client.createConversation();
const sentWhileSignedOut = calls.filter((c) => c.url.endsWith("/conversations")).pop().auth;
assert.equal(sentWhileSignedOut, "Bearer old-cookie-pass", "visitor pass used while signed out");

// 1. Multiple consecutive anonymous requests in cookie mode do NOT repeat /auth/link on every request
const initialLinkCalls = calls.filter((c) => c.url.endsWith("/auth/link")).length;
assert.equal(initialLinkCalls, 1, "/auth/link called once on first request");

await client.createConversation();
await client.createConversation();
await client.createConversation();
const linkCallsAfter5Turn = calls.filter((c) => c.url.endsWith("/auth/link")).length;
assert.equal(linkCallsAfter5Turn, 1, "/auth/link is throttled and not repeated on every anonymous request");

// 2. User logs in via cookie on the host site (zero-code: no tokens.reset() or refreshIdentity() called)
loggedInViaCookie = true;
await client.listConversations();
const sentAfterCookieLogin = calls.filter((c) => c.url.includes("/conversations")).pop().auth;
assert.equal(sentAfterCookieLogin, undefined, "no bearer sent after zero-code cookie login");
assert.equal(
localStorage.getItem(visitorPassKey("https://api.example")),
null,
"visitor pass is cleared after successful zero-code cookie merge",
);
}

// reset() keeps the visitor pass; only forget() discards it. Getting this
// backwards silently throws away the conversations the merge exists to rescue.
{
Expand Down
6 changes: 3 additions & 3 deletions src/agent_manager/api/static/widget/api/AgentChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export class AgentChatClient {

/** A 401 usually means the token expired: renew once and retry. The rejected
* attempt changed nothing, so replaying is safe. */
private async request(path: string, init?: RequestInit): Promise<Response> {
let response = await this.send(path, init, await this.tokens.current());
private async request(path: string, init?: RequestInit, options?: { forceCheck?: boolean }): Promise<Response> {
let response = await this.send(path, init, await this.tokens.current({ forceCheck: options?.forceCheck }));
if (response.status === 401) {
response = await this.send(path, init, await this.tokens.renew());
}
Expand All @@ -71,7 +71,7 @@ export class AgentChatClient {
async listConversations(limit = 20, cursor?: string | null): Promise<PaginatedThreads> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
const response = await this.request(`/conversations?${params.toString()}`);
const response = await this.request(`/conversations?${params.toString()}`, undefined, { forceCheck: true });

const data = await response.json();
const rawItems: Array<{ conversation_id: string; title?: string | null; last_message_at?: string | null }> =
Expand Down
82 changes: 74 additions & 8 deletions src/agent_manager/api/static/widget/auth/tokenSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export class TokenSource {
/** Bumped by `reset()` so a resolution already in flight, once it lands, can
* tell it is answering a question nobody is asking anymore. */
private generation = 0;
/** Avoid repeating unauthenticated claim attempts for the same pass in cookie mode. */
private lastClaimAttemptPass: string | null = null;
private lastCookieSnapshot = typeof document !== "undefined" ? document.cookie : "";
private identityCheckDirty = true;
private readonly tokenUrl: string;
private readonly provider: TokenProvider | null;
private readonly storage: Storage;
Expand All @@ -61,15 +65,43 @@ export class TokenSource {
this.storage = options.storage ?? localStorage;
this.requireIdentity = options.requireIdentity ?? false;
this.onIdentityFailure = options.onIdentityFailure ?? (() => {});

if (typeof window !== "undefined") {
const markDirty = () => {
this.identityCheckDirty = true;
};
try {
window.addEventListener("focus", markDirty);
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", markDirty);
}
window.addEventListener("storage", markDirty);
} catch {
// Non-browser or custom environment ignore
}
}
}

async current(): Promise<string | null> {
if (!this.cached) await this.resolve(() => this.storedPass());
async current(options?: { forceCheck?: boolean }): Promise<string | null> {
const pass = this.storedPass();
const cookieChanged = this.cookieSnapshotChanged();
const force = options?.forceCheck ?? false;
const shouldClaim =
this.isCookieMode() &&
pass !== null &&
(force || this.identityCheckDirty || cookieChanged || pass !== this.lastClaimAttemptPass);

if (!this.cached || shouldClaim) {
await this.resolve(() => this.storedPass());
this.identityCheckDirty = false;
this.updateCookieSnapshot();
}
return this.cached;
}

/** After a 401: whatever we sent is no good, so get another. */
async renew(): Promise<string | null> {
this.identityCheckDirty = true;
return this.resolve(() => this.issuePass());
}

Expand All @@ -84,6 +116,9 @@ export class TokenSource {
// next call starts a fresh one instead of awaiting an answer to a question
// that no longer applies (e.g. the old tokenProvider).
this.pending = null;
this.lastClaimAttemptPass = null;
this.identityCheckDirty = true;
this.updateCookieSnapshot();
}

/** Drop this browser's identity entirely — a host app signing its user out. */
Expand All @@ -92,6 +127,17 @@ export class TokenSource {
this.clearPass();
}

private cookieSnapshotChanged(): boolean {
if (typeof document === "undefined") return false;
return document.cookie !== this.lastCookieSnapshot;
}

private updateCookieSnapshot(): void {
if (typeof document !== "undefined") {
this.lastCookieSnapshot = document.cookie;
}
}

/** Concurrent callers share one resolution. Without this, parallel requests
* each fetch a token and each hand over the visitor pass. */
private resolve(fallback: () => string | null | Promise<string | null>): Promise<string | null> {
Expand All @@ -114,26 +160,46 @@ export class TokenSource {
return this.pending;
}

private isCookieMode(): boolean {
return !this.tokenUrl && !this.provider;
}

/** A host token, plus the one-time hand-off of whatever this browser chatted
* about before signing in. */
private async hostToken(): Promise<string | null> {
const token = await this.fromHost();
if (token) await this.claimVisitorHistory(token);
if (token || (this.isCookieMode() && this.storedPass())) {
await this.claimVisitorHistory(token);
}
return token;
}

private async claimVisitorHistory(hostToken: string): Promise<void> {
private async claimVisitorHistory(hostToken: string | null): Promise<void> {
const pass = this.storedPass();
if (!pass) return;
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (hostToken) headers.Authorization = `Bearer ${hostToken}`;
const response = await fetch(`${this.endpoint}${LINK_ENDPOINT}`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hostToken}` },
headers,
credentials: "include",
body: JSON.stringify({ anonymous_token: pass }),
});
// Drop the pass on any verdict, including a refusal — only a server that
// never answered is worth asking again.
if (response.status < 500) this.clearPass();
if (response.ok) {
const data = (await response.json().catch(() => null)) as { conversations_moved?: number } | null;
if (hostToken !== null || (data?.conversations_moved ?? 0) > 0) {
this.clearPass();
this.lastClaimAttemptPass = null;
this.identityCheckDirty = true;
} else {
this.lastClaimAttemptPass = pass;
}
} else if (response.status === 401) {
this.lastClaimAttemptPass = pass;
} else if (hostToken !== null && response.status >= 400 && response.status < 500) {
this.clearPass();
}
} catch {
// Offline: keep the pass so the next page load retries the hand-off.
}
Expand Down
43 changes: 43 additions & 0 deletions tests/agent_manager/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,49 @@ def test_signing_in_adopts_the_conversations_a_visitor_already_started() -> None
assert client.get(f"/conversations/{cid}/messages", headers=visitor).status_code == 403


def test_linking_via_cookie_authentication() -> None:
"""In host_token (cookie) mode, /auth/link is called with session cookies."""
app = build_test_app(
ConversationService(RecordingEngine(), MemoryRepository()),
extra_auth_mode=AuthMode.HOST_TOKEN,
extra_auth_cookie=HOST_COOKIE,
extra_auth_claim_user_id="id",
)
anon_client = TestClient(app)
pass_token = anon_client.post("/auth/anonymous").json()["token"]
visitor = {"Authorization": f"Bearer {pass_token}"}
cid = anon_client.post("/conversations", headers=visitor).json()["conversation_id"]
anon_client.post(
f"/conversations/{cid}/messages", json={"message": "hi from visitor"}, headers=visitor
)

alice_client = TestClient(app, cookies=session_cookie(id="alice"))
linked = alice_client.post("/auth/link", json={"anonymous_token": pass_token})

assert linked.status_code == 200
assert linked.json() == {"conversations_moved": 1}
assert [t["conversation_id"] for t in alice_client.get("/conversations").json()["items"]] == [
cid
]
assert alice_client.get(f"/conversations/{cid}/messages").status_code == 200


def test_linking_via_cookie_returns_401_when_not_logged_in() -> None:
"""Without a session cookie the server must reject the link request, not silently succeed."""
app = build_test_app(
ConversationService(RecordingEngine(), MemoryRepository()),
extra_auth_mode=AuthMode.HOST_TOKEN,
extra_auth_cookie=HOST_COOKIE,
extra_auth_claim_user_id="id",
)
client = TestClient(app)
pass_token = client.post("/auth/anonymous").json()["token"]

# No cookie, no bearer — the server has no way to identify the adopting caller.
result = client.post("/auth/link", json={"anonymous_token": pass_token})
assert result.status_code == 401


def test_linking_refuses_a_pass_that_is_not_ours_or_already_spent() -> None:
app = build_test_app(ConversationService(RecordingEngine(), MemoryRepository()))
client = TestClient(app)
Expand Down
Loading
Loading