ADR-0066: Session tokens carry their expiry and are renewed in place
ACCEPTED
Context
ADR-0054 expires a session's token a fixed time after the session was created. The time is computed server-side from the session, and the token carries only the session id. ADR-0064 lets a channel shorten the lifetime, and on an oauth channel expiry sends the widget back through chat/start/ for a new session. Every expiry therefore ends the conversation: history stays on the old session, and a bound page cannot restart at all. ADR-0061 keeps the bearer token off the session-bound endpoints, so nothing after session start can re-check the host's admission without starting over.
Because the deadline belongs to the session, a token cannot be reissued: every token for the session dies at the same instant.
Decision
We will stamp the expiry into the token and measure the lifetime from issuance.
- The signed payload gains an
expclaim, set at issuance to now plus the channel'ssession_token_lifetimeorCHAT_SESSION_TOKEN_LIFETIME(ADR-0064). The claim alone decides expiry. A token without the claim, issued before this decision, falls back to ADR-0054's rule. POST /api/chat/start/returnsexpires_atbeside the token.POST /api/chat/<session_id>/token/issues a fresh token for an existing session and returns it withexpires_at. Admission is that of session start (ADR-0060, ADR-0062, ADR-0063): a client-credentials token withchat:startfor the session's chatbot, a channel inoauthmode, and the channel's origin rule. No other credential is accepted, so a session token cannot renew itself.- Renewal re-reads the channel from the database rather than the cached session, so a deleted, disabled or reconfigured channel takes effect at the next renewal.
- Renewal requests are throttled per OAuth application, not per session.
- On an
oauthchannel the widget renews when its token expires, using a fresh bearer token from the host. It starts a new session only when renewal is refused.
Consequences
- The lifetime bounds a token, not a conversation. A conversation lasts as long as the host keeps renewing, and each renewal re-checks admission.
- Tightening a channel's lifetime applies to tokens issued after the change; sessions already running keep their current token until it expires. Disabling the channel or removing the chatbot from the application's allowlist stops renewals immediately.
- Bound pages and "Continue chat" mint a token on render, so an old session opened by its owner works again. ADR-0054's follow-up for those pages is closed.
- A second endpoint carries the bearer authenticator, which ADR-0061 restricted to session start. Its reasons still hold: the chatbot is resolved from the session in the URL, and
request.authholds the access token rather than a channel, so the legacy-access check cannot read it as an embed key. - ADR-0061 deferred throttling per OAuth application. It now exists for renewals only; session start stays per channel.
- On a browser-facing channel the renewal must come from a listed origin (ADR-0060), so the widget makes the call, not the host's backend.
embed_keychannels have no credential to renew with, so they still restart on expiry as ADR-0054 describes.- Tokens without the claim keep working under the old rule until they expire. Nothing is migrated.
Alternatives considered
- Store a renewable deadline on the session → rejected: it makes the token stateful, and every bound page relies on re-deriving it (ADR-0040).
- Let the session token renew itself → rejected: a leaked token could keep itself alive, and renewal exists to re-check the host's admission.
- Renew by calling
chat/start/again → rejected: that creates a new session and participant, which is the restart this decision removes. - Require the bearer token on every session-bound request → rejected in ADR-0061.
- Take the channel from the cached session at renewal → rejected: the snapshot can be a cache TTL old, and a token minted from a stale lifetime extends the change by a full token lifetime.