feat: let the chat widget forward end-user JWTs (#14968)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Evan LohnandCursor authored and GitHub committed 2026-09-22 19:39:12 +00:00
1 parent 08a8ff0613
commit 5f2aed65ab
8 files changed
+413 -58

No files matched your search

+6
View File
@@ -36,6 +36,12 @@ jobs:
- name: Install node dependencies
working-directory: ./web
run: bun install --frozen-lockfile
# web/ has its own lockfile; widget/, examples/widget/ and desktop/ live in
# the root bun workspace. The hooks that type-check them run `tsgo` from
# the root node_modules, and prek never runs the post-checkout hook that
# would install it.
- name: Install root workspace dependencies
run: bun install --frozen-lockfile
- uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece
env:
SKIP: ty,desktop-rust-fmt,desktop-rust-clippy
+182
View File
@@ -0,0 +1,182 @@
# Chat Widget JWT Passthrough
The Onyx chat widget runs in the browser, so any credential you put in the page
is readable by the visitor. With an `api-key`, every visitor shares one service
account. With **JWT passthrough**, each visitor authenticates as themselves.
Use this guide when the page that hosts the widget already signs the visitor in
with the same identity provider (IdP) that Onyx uses.
## Scope: single-tenant deployments
JWT passthrough works on a single-tenant deployment, which means self-hosted
Onyx. It does not work on multi-tenant Onyx Cloud.
A multi-tenant deployment reads the workspace from the credential itself. See
`_get_tenant_id_from_request` in
`backend/ee/onyx/server/middleware/tenant_tracking.py`: it accepts an Onyx API
key or PAT prefix, a server-issued session token, or the anonymous-user cookie.
An external IdP token carries none of these, so the request falls back to the
default schema, which owns no workspace data. Use an API key on multi-tenant
Onyx until the backend binds a tenant to these tokens.
## What you get
| | `api-key` | JWT passthrough |
|---|---|---|
| Credential in the page | Long-lived shared secret | The visitor's own short-lived token |
| Onyx identity | One service account for all visitors | One Onyx user per visitor |
| Document permissions | Whatever the service account can read | Per visitor |
| Usage attribution | All traffic on one account | Per visitor |
The token still reaches the browser, but it is the visitor's own token. It
expires, it is scoped to that person, and a stolen copy grants no more than the
portal session it came from.
## How Onyx verifies the token
Onyx checks the `Authorization: Bearer <jwt>` header on every request that has
no session cookie. See `_check_for_saml_and_jwt` in `backend/onyx/auth/users.py`
and `verify_jwt_token` in `backend/onyx/auth/jwt.py`.
The sequence is:
1. Onyx downloads the verification key from the URL you configure. The endpoint
can serve a JWKS document or a PEM public key. Onyx caches the result and
refetches once if verification fails, so key rotation needs no restart.
2. Onyx verifies the RS256 signature and the `exp` claim. It also enforces
`aud` and `iss` when you configure them.
3. Onyx reads the identity from the first valid email in the `email`,
`preferred_username`, or `upn` claim.
4. Onyx applies the invite allowlist and the email-domain policy, the same
rules every other login path uses.
5. Onyx finds the matching user, or creates one.
Onyx accepts **RS256 only**. Tokens signed with HS256 or ES256 are rejected.
## Configure Onyx
### 1. Point Onyx at the IdP verification key
Set the JWKS URL with an environment variable:
```bash
JWT_PUBLIC_KEY_URL=https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
```
You can instead write `jwt_public_key_url` through the security settings API.
The environment variable wins when both are present.
The two paths differ in trust. An environment variable is operator
config-as-code, and Onyx fetches it as given. A value set through the API is
admin-supplied, so Onyx validates it against the outbound SSRF policy first and
requires HTTPS across every redirect hop.
### 2. Restrict which tokens Onyx accepts
```bash
JWT_EXPECTED_AUDIENCE=<the client id you registered for Onyx>
JWT_EXPECTED_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
```
**Set the audience.** Both settings are optional, and audience enforcement is
off while `JWT_EXPECTED_AUDIENCE` is unset. Without it, Onyx accepts any token
that IdP signed for any application. Because the portal and Onyx share an IdP,
that includes tokens minted for unrelated services.
### 3. Control who gets an Onyx account
Onyx creates a user on first request if the email is new. Limit this with the
invite list, or with the email-domain policy:
```bash
VALID_EMAIL_DOMAINS=yourcompany.com
```
Watch your seat count after you turn the widget on. Every portal user who opens
the widget becomes an Onyx user.
### 4. Allow the portal origin through CORS
The browser calls Onyx from the portal's origin, not from the Onyx origin:
```bash
CORS_ALLOWED_ORIGIN=https://portal.yourcompany.com
```
## Configure the widget
A function cannot ride an HTML attribute, so the host page assigns
`tokenProvider` as a JavaScript property. Drop the `api-key` attribute.
```html
<script type="module" src="https://cdn.onyx.app/widget/1.0/dist/onyx-widget.js"></script>
<onyx-chat-widget
id="onyx-widget"
backend-url="https://onyx.yourcompany.com/api"
agent-id="42"
mode="launcher"
></onyx-chat-widget>
<script type="module">
const widget = document.getElementById("onyx-widget");
widget.tokenProvider = () => fetchAccessTokenForOnyx();
</script>
```
Onyx calls `tokenProvider` before every request. Return a string, or a promise
that resolves to one. Assign it any time — the widget reads the property when it
sends a request, not when it mounts.
### Return a fresh token
Your function owns expiry. Return a token that is valid now:
```js
widget.tokenProvider = async () => {
const token = await msalInstance.acquireTokenSilent({
scopes: ["api://onyx/.default"],
});
return token.accessToken;
};
```
Most IdP SDKs cache the token and refresh it only when it is near expiry, so
calling the SDK per request is cheap. The widget also calls the provider again
for each retry attempt, so a token that expires during a backoff is replaced
rather than resent. The widget does not retry a 401, so a provider that returns
an already-expired token surfaces as an error in the chat panel.
### Conversations are scoped to the signed-in user
The widget keeps the transcript in `sessionStorage`. It tags each stored
transcript with the token subject (the `sub` claim), and it discards a stored
transcript whose subject does not match the current one. A second person who
signs in on the same tab therefore starts a fresh conversation and never sees
the first person's messages.
You can still clear the conversation on demand, for example on sign-out:
```js
widget.resetConversation();
```
## Verify the setup
1. Open the portal as a signed-in user and send a message in the widget.
2. Confirm the answer only cites documents that user may read.
3. Check the Onyx user list. The visitor's email should appear.
4. Send a request with an expired token. Onyx must reject it.
5. Send a token minted for a different audience. Onyx must reject it once
`JWT_EXPECTED_AUDIENCE` is set.
## Troubleshooting
| Symptom | Cause |
|---|---|
| Every request is unauthorized | `JWT_PUBLIC_KEY_URL` is unset, so Onyx never looks at the header. |
| `no email claim found` in the logs | The token carries none of `email`, `preferred_username`, `upn`. Add the claim at the IdP. |
| The browser blocks the request | The portal origin is missing from `CORS_ALLOWED_ORIGIN`. |
| `Invalid JWT token` in the logs | The signature, the algorithm, or `exp` failed. Confirm the IdP signs with RS256. |
| The widget reports a missing credential | Neither `api-key` nor `tokenProvider` is set on the element. |
+49 -2
View File
@@ -4,7 +4,10 @@ An embeddable, lightweight chat widget that brings AI-powered conversations to a
## Security Note
⚠️ **Always use a limited-scope API key for the widget.** The API key is visible in client-side code, so it should have restricted permissions and rate limits. Never use admin or full-access keys.
The widget runs in the browser, so whatever credential it holds is visible to the visitor. Pick one of the two modes below.
- **JWT passthrough** — the host page supplies the visitor's own identity-provider token, and each visitor acts as their own Onyx user. No shared secret goes in the page. Use this when the host page already signs the visitor in with the same IdP as Onyx. Single-tenant (self-hosted) deployments only. See [docs/WIDGET_JWT_PASSTHROUGH.md](../docs/WIDGET_JWT_PASSTHROUGH.md).
- **API key** — ⚠️ **always use a limited-scope API key.** The key is visible in client-side code, so it should have restricted permissions and rate limits. Never use admin or full-access keys.
## Features
@@ -104,7 +107,7 @@ That's it! The widget will appear as a floating button in the bottom-right corne
| Attribute | Type | Description |
| ------------- | ------ | -------------------------------------------------------------------- |
| `backend-url` | string | Your Onyx backend API URL (or set `VITE_WIDGET_BACKEND_URL` in .env) |
| `api-key` | string | API key for authentication (or set `VITE_WIDGET_API_KEY` in .env) |
| `api-key` | string | API key for authentication (or set `VITE_WIDGET_API_KEY` in .env). Omit it when you use a `tokenProvider` — see [Authentication](#authentication). |
**Note**: For cloud deployment, these must be provided as HTML attributes. For self-hosted deployment, they can be set in `.env` file during build and will be baked into the bundle.
@@ -123,6 +126,15 @@ That's it! The widget will appear as a floating button in the bottom-right corne
**Note**: These attributes must be provided as HTML attributes. Only `backend-url` and `api-key` can optionally be set via environment variables for self-hosted builds.
**Note on `include-citations`**: the attribute is a boolean flag, so its presence alone means `true`. `include-citations="false"` still turns citations **on**. Omit the attribute to turn them off.
Citations are off by default. While they are off, Onyx strips the citation markers from the answer text and sends no citation data, so the widget renders no source links. Add the bare attribute to get inline `[n]` markers and clickable source badges:
```html
<onyx-chat-widget backend-url="https://onyx.example.com/api" include-citations>
</onyx-chat-widget>
```
### Configuration Examples
**Basic Setup:**
@@ -162,6 +174,41 @@ That's it! The widget will appear as a floating button in the bottom-right corne
</div>
```
## Authentication
The widget sends a bearer credential on every backend call. It gets that credential one of two ways.
### API key
Set the `api-key` attribute. The same key is used for every visitor, so all conversations run as one Onyx service account.
### JWT passthrough (`tokenProvider`)
Assign a `tokenProvider` function and leave `api-key` off. The widget calls it before every request attempt, retries included, so the host controls expiry and refresh. Each visitor is a separate Onyx user, and per-user document permissions apply.
The stored transcript is scoped to the token subject, so a second person signing in on the same tab starts a fresh conversation instead of seeing the previous one.
```html
<onyx-chat-widget id="onyx-widget" backend-url="https://onyx.example.com/api">
</onyx-chat-widget>
<script type="module">
const widget = document.getElementById("onyx-widget");
widget.tokenProvider = async () => {
const result = await msalInstance.acquireTokenSilent({
scopes: ["api://onyx/.default"],
});
return result.accessToken;
};
</script>
```
`tokenProvider` is a JavaScript property, not an HTML attribute, because attributes cannot hold functions. Assign it at any time — the widget reads the property when it sends a request, not when it mounts.
Onyx must be configured to accept these tokens. The setup, the required claims, and the CORS and provisioning caveats are in [docs/WIDGET_JWT_PASSTHROUGH.md](../docs/WIDGET_JWT_PASSTHROUGH.md).
**Precedence**: when both are present, `tokenProvider` wins. When neither resolves to a credential, the widget shows an error instead of sending the request.
## Display Modes
### Launcher Mode (Default)
+60 -5
View File
@@ -1,4 +1,10 @@
import { WidgetConfig } from "@/types/widget-types";
import { TokenProvider, WidgetConfig } from "@/types/widget-types";
const SHARED_CREDENTIAL_IDENTITY = "shared-credential";
const MISSING_CREDENTIAL_MESSAGE =
"No Onyx credential available. Set the `api-key` attribute, or assign a " +
"`tokenProvider` function on the <onyx-chat-widget> element.";
/**
* Resolve widget configuration from attributes and environment variables
@@ -19,11 +25,60 @@ export function resolveConfig(attributes: Partial<WidgetConfig>): WidgetConfig {
includeCitations: attributes.includeCitations ?? false,
};
if (!config.backendUrl || !config.apiKey) {
throw new Error(
"backendUrl and apiKey are required for the widget to function"
);
if (!config.backendUrl) {
throw new Error("backendUrl is required for the widget to function");
}
return config;
}
/**
* Pick the credential for one request. A `tokenProvider` wins over `apiKey`.
* Credentials are resolved here rather than at mount because a host usually
* assigns `tokenProvider` after the element has already been parsed.
*/
export function resolveAuthToken(
tokenProvider: TokenProvider | undefined,
apiKey: string | undefined
): string | Promise<string> {
if (tokenProvider) {
return tokenProvider();
}
if (apiKey) {
return apiKey;
}
throw new Error(MISSING_CREDENTIAL_MESSAGE);
}
/**
* Stable identifier for whoever the credential represents, used to scope the
* stored transcript. A JWT yields its subject, so a second person signing in
* on the same tab never sees the first person's messages. Anything else is a
* shared credential, which every visitor already has in common.
*/
export function deriveCredentialIdentity(token: string): string {
const claims = decodeJwtClaims(token);
const subject = claims?.sub ?? claims?.email;
return typeof subject === "string" && subject
? subject
: SHARED_CREDENTIAL_IDENTITY;
}
function decodeJwtClaims(token: string): Record<string, unknown> | null {
const segments = token.split(".");
if (segments.length !== 3) {
return null;
}
try {
const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"="
);
const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
} catch {
return null;
}
}
+25 -15
View File
@@ -8,6 +8,7 @@ import {
CreateSessionResponse,
SendMessageRequest,
} from "@/types/api-types";
import { TokenProvider } from "@/types/widget-types";
export class ApiService {
private maxRetries = 3;
@@ -15,7 +16,7 @@ export class ApiService {
constructor(
private backendUrl: string,
private apiKey: string
private resolveToken: TokenProvider
) {}
/**
@@ -29,11 +30,11 @@ export class ApiService {
const response = await this.fetchWithRetry(
`${this.backendUrl}/chat/create-chat-session`,
{
async () => ({
method: "POST",
headers: this.getHeaders(),
headers: await this.getHeaders(),
body: JSON.stringify(request),
}
})
);
if (!response.ok) {
@@ -74,12 +75,12 @@ export class ApiService {
const response = await this.fetchWithRetry(
`${this.backendUrl}/chat/send-chat-message`,
{
async () => ({
method: "POST",
headers: this.getHeaders(),
headers: await this.getHeaders(),
body: JSON.stringify(request),
signal: params.signal,
}
})
);
if (!response.ok) {
@@ -182,22 +183,24 @@ export class ApiService {
}
/**
* Fetch with retry logic for network failures and 5xx errors
* Fetch with retry logic for network failures and 5xx errors.
* Options are rebuilt per attempt so each retry carries a freshly resolved
* credential; a short-lived token can otherwise expire during the backoff.
*/
private async fetchWithRetry(
url: string,
options: RequestInit,
buildOptions: () => Promise<RequestInit>,
retries = 0
): Promise<Response> {
try {
const response = await fetch(url, options);
const response = await fetch(url, await buildOptions());
// Retry on 5xx errors only (not 4xx — those are permanent)
if (!response.ok && retries < this.maxRetries) {
if (response.status >= 500) {
const delay = this.retryDelay * Math.pow(2, retries);
await new Promise((resolve) => setTimeout(resolve, delay));
return this.fetchWithRetry(url, options, retries + 1);
return this.fetchWithRetry(url, buildOptions, retries + 1);
}
}
@@ -212,19 +215,26 @@ export class ApiService {
if (retries < this.maxRetries) {
const delay = this.retryDelay * Math.pow(2, retries);
await new Promise((resolve) => setTimeout(resolve, delay));
return this.fetchWithRetry(url, options, retries + 1);
return this.fetchWithRetry(url, buildOptions, retries + 1);
}
throw error;
}
}
/**
* Get common headers for API requests
* Get common headers for API requests.
* The credential is resolved per call so an expiring token can be refreshed
* by the host between requests.
*/
private getHeaders(): Record<string, string> {
private async getHeaders(): Promise<Record<string, string>> {
const token = await this.resolveToken();
if (!token) {
throw new Error("Onyx credential resolved to an empty value");
}
return {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
Authorization: `Bearer ${token}`,
};
}
}
+9 -1
View File
@@ -4,10 +4,18 @@
import { ResolvedCitation } from "@/types/api-types";
/**
* Returns the bearer credential for a single request. Called before every
* backend call, so a host can hand back a freshly minted short-lived token.
*/
export type TokenProvider = () => string | Promise<string>;
export interface WidgetConfig {
// Required
backendUrl: string;
apiKey: string;
// Static credential. Omitted when the host supplies a `tokenProvider`.
apiKey?: string;
// Optional - Assistant
agentId?: number;
+20 -21
View File
@@ -11,17 +11,24 @@ export interface StoredSession {
sessionId: string;
messages: ChatMessage[];
timestamp: number;
// Who the transcript belongs to. See `deriveCredentialIdentity`.
identity: string;
}
/**
* Save session to sessionStorage
*/
export function saveSession(sessionId: string, messages: ChatMessage[]): void {
export function saveSession(
sessionId: string,
messages: ChatMessage[],
identity: string
): void {
try {
const session: StoredSession = {
sessionId,
messages,
timestamp: Date.now(),
identity,
};
sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
} catch (e) {
@@ -31,9 +38,11 @@ export function saveSession(sessionId: string, messages: ChatMessage[]): void {
/**
* Load session from sessionStorage
* Returns null if session doesn't exist or has expired
* Returns null if the session is missing, has expired, or belongs to someone
* else. A stored session for another identity is discarded, so a shared tab
* never shows the previous person's messages.
*/
export function loadSession(): StoredSession | null {
export function loadSession(identity: string): StoredSession | null {
try {
const data = sessionStorage.getItem(SESSION_KEY);
if (!data) return null;
@@ -46,6 +55,11 @@ export function loadSession(): StoredSession | null {
return null;
}
if (session.identity !== identity) {
clearSession();
return null;
}
return session;
} catch (e) {
console.warn("Failed to load session:", e);
@@ -65,23 +79,8 @@ export function clearSession(): void {
}
/**
* Check if a session exists
* Check if a usable session exists for this identity
*/
export function hasSession(): boolean {
try {
const data = sessionStorage.getItem(SESSION_KEY);
if (!data) return false;
const session: StoredSession = JSON.parse(data);
// Check if session has expired
if (Date.now() - session.timestamp > SESSION_TTL) {
clearSession();
return false;
}
return true;
} catch (e) {
return false;
}
export function hasSession(identity: string): boolean {
return loadSession(identity) !== null;
}
+62 -14
View File
@@ -8,9 +8,13 @@ import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { WidgetConfig, ChatMessage } from "./types/widget-types";
import { WidgetConfig, ChatMessage, TokenProvider } from "./types/widget-types";
import { SearchDocument, ResolvedCitation } from "./types/api-types";
import { resolveConfig } from "./config/config";
import {
resolveConfig,
resolveAuthToken,
deriveCredentialIdentity,
} from "./config/config";
import { theme } from "./styles/theme";
import { widgetStyles } from "./styles/widget-styles";
import { ApiService } from "./services/api-service";
@@ -35,6 +39,10 @@ export class OnyxChatWidget extends LitElement {
@property({ attribute: "include-citations", type: Boolean })
includeCitations?: boolean;
// Assigned as a JS property, since a function cannot ride an HTML attribute.
// Takes precedence over `api-key` and keeps the credential out of the markup.
@property({ attribute: false }) tokenProvider?: TokenProvider;
// Internal state
@state() private isOpen = false;
@state() private chatSessionId?: string;
@@ -47,6 +55,7 @@ export class OnyxChatWidget extends LitElement {
private config!: WidgetConfig;
private apiService!: ApiService;
private sessionRestored = false;
private abortController?: AbortController;
// Citation state — plain fields (not @state) since Map mutations don't trigger Lit re-renders
private documentMap = new Map<string, SearchDocument>();
@@ -64,6 +73,12 @@ export class OnyxChatWidget extends LitElement {
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
// A host that assigns `tokenProvider` after mount only becomes able to
// identify the stored transcript now.
if (changedProperties.has("tokenProvider")) {
this.restoreSession();
}
// Auto-scroll when messages change or streaming status changes
if (
changedProperties.has("messages") ||
@@ -103,23 +118,52 @@ export class OnyxChatWidget extends LitElement {
// Apply custom colors
this.applyCustomColors();
// Initialize API service
this.apiService = new ApiService(
this.config.backendUrl,
this.config.apiKey
// Initialize API service. Auth is read at request time so a `tokenProvider`
// assigned after the element mounts is still picked up.
this.apiService = new ApiService(this.config.backendUrl, () =>
resolveAuthToken(this.tokenProvider, this.config.apiKey)
);
// Load persisted session
const stored = loadSession();
if (stored) {
this.chatSessionId = stored.sessionId;
this.messages = stored.messages;
}
// Auto-open if inline mode
if (this.config.mode === "inline") {
this.isOpen = true;
}
this.restoreSession();
}
/**
* Restore the persisted transcript, once a credential identifies who it
* belongs to. This waits rather than restoring at mount because a host
* normally assigns `tokenProvider` after the element is parsed, and a
* transcript must never be shown to a different signed-in user.
*/
private restoreSession(): void {
if (this.sessionRestored) return;
void (async () => {
let identity: string;
try {
identity = await this.currentIdentity();
} catch {
// No credential yet. A later `tokenProvider` assignment retries.
return;
}
this.sessionRestored = true;
const stored = loadSession(identity);
if (!stored) return;
this.chatSessionId = stored.sessionId;
this.messages = stored.messages;
})();
}
/** Who the current credential represents; scopes the stored transcript. */
private async currentIdentity(): Promise<string> {
return deriveCredentialIdentity(
await resolveAuthToken(this.tokenProvider, this.config.apiKey)
);
}
private applyCustomColors() {
@@ -349,6 +393,10 @@ export class OnyxChatWidget extends LitElement {
this.isLoading = false;
}
// Resolved once per send, so every persisted transcript is tagged with
// the user who actually produced it.
const identity = await this.currentIdentity();
// Get parent message ID (last assistant message with a numeric ID from backend)
const parentMessage = [...this.messages]
.reverse()
@@ -470,7 +518,7 @@ export class OnyxChatWidget extends LitElement {
if (!currentMessage.isStreaming) {
this.isStreaming = false;
this.streamingStatus = "";
saveSession(this.chatSessionId, this.messages);
saveSession(this.chatSessionId, this.messages, identity);
}
}
}