ADR-0059: The Chat API Channel's credential mode determines which credential admits a caller
ACCEPTED
Context
After ADR-0053, an anonymous caller can start a chat session with the chatbot's embed key and a logged-in caller can start one through team membership. Issue #3893 requested a third option: a host wants its embedded widget, or a server integration, to reach a chatbot only when it presents an OAuth token, so that the embed key alone is not sufficient to use the chatbot from another site.
This requires a per-chatbot setting that records the admin's choice. The channel already has required_auth_level (ADR-0044), but that field records the capability of the deployed widget and is raised automatically by the ADR-0045 ratchet. An admin's access policy must not change as a side effect of a widget upgrade, so it cannot be stored in that field.
The widget serves two platforms: EMBEDDED_WIDGET, and PUBLIC, an OCS-hosted link enabled by flag_public_channel.
Decision
We will record the admin's choice in ExperimentChannel.credential_mode, a CredentialMode column on the Chat API Channel (platform embedded_widget).
- Two values:
embed_key(the default; every existing channel was migrated to it) andoauth. The column is audited. - The mode applies to
EMBEDDED_WIDGETchannels only. APUBLICchannel always admits by embed key. - In
oauthmode the embed key is ignored, not rejected. A snippet that sendsX-Embed-Keycontinues to work if it also presents a valid token. The key alone does not admit a caller. - The mode determines which external credential admits an anonymous caller: the embed key in
embed_keymode, a bearer token inoauthmode. It does not affect team membership: ADR-0053's membership path is unchanged in both modes. - The mode applies to
/api/chat/*only. Thechatbots:interactendpoints do not resolve a channel and are bounded by the ADR-0056 allowlist alone. - When the channel form omits
credential_mode, the stored mode is kept. A partial save cannot change the required credential to a weaker one. oauthmode setsrequired_auth_leveltoSESSION_TOKEN. The check constraintoauth_credential_mode_requires_session_tokenrejects any other combination, and the ratchet skips these channels.- The platform value
embedded_widgetis unchanged; its label becomes "Chat Widget & API". The value is also used asParticipant.platform, so renaming it would create a second participant record for every existing participant. - In
oauthmode the channel reports the first widget release that supports theauthTokenProviderproperty as its minimum widget version. The minimum is advisory: the form warns when a browser-facing channel's last-reported widget version is lower, but no request is rejected because of it.
There is no third mode requiring both key and token. In a browser embed the key is in page source and the token is available to page JavaScript, so an attacker who can obtain the token from the page can also obtain the key, and requiring both adds no protection against that attacker. The per-chatbot restriction that such a mode would provide is provided instead by the application allowlist (ADR-0056; applied to chat:start in ADR-0063).
Consequences
- Existing channels are unaffected;
embed_keymode is the previous behaviour. - An
oauthchannel withrequired_auth_levelbelowSESSION_TOKENwould issue no session token, and every subsequent call would fail the legacy-access check. The constraint prevents this state. - All modes produce
platform=embedded_widgetparticipants. - A chatbot has one Chat API Channel, so a single embed cannot be both anonymous and token-gated. A
PUBLICchannel on the same chatbot provides an anonymous OCS-hosted link alongside a token-gated embed. - One channel per platform is enforced only by the add-channel dropdown, not by a database constraint. Allowing two Chat API Channels per chatbot would require no migration.
- Switching an existing channel to
oauthrequires no snippet change other than installing anauthTokenProviderfunction on the element.
Alternatives considered
- A new platform for OAuth callers → rejected: it creates a second
Participant.platformvalue for every existing participant and duplicates the widget-specific code paths. require_oauthinextra_data→ rejected: ADR-0044 stored per-channel policy in a real column, and this field is the same kind of setting.- A boolean on
Experiment→ rejected: the chatbot is versioned, and the session needs a channel to be attributed to. - A third
WidgetAuthLevelvalue → rejected: that field is version-ratcheted (see Context). - Embed key and token together → rejected, see Decision.