Skip to content

Commit 289d6f0

Browse files
authored
Merge pull request #4409 from dimagi/sk/oauth-widget-adrs
Extract chat API admission ADRs from the OAuth widget design doc
2 parents 46856dc + e7be851 commit 289d6f0

11 files changed

Lines changed: 286 additions & 1051 deletions

‎CONTEXT.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,14 @@ So "this Chatbot has no Chat API Channel" means "no outside embedder can reach i
5454

5555
A Chat API Channel carries two settings that are easy to confuse, deliberately kept apart:
5656

57-
- **Credential Mode** — the column exists (`ExperimentChannel.credential_mode`) but nothing reads it yet, so it is not admin-selectable and every Channel sits at `embed_key`. Once the OAuth path lands ([oauth-chat-widget.md](docs/design/oauth-chat-widget.md) D1) it is the *admin's* choice of what an *external* caller must present: the **Embed Key**, or an OAuth token. (A signed-in team member reaches an in-app embed through membership, presenting neither.) Under the OAuth mode an Embed Key is *ignored rather than rejected*, so an existing snippet needs no edit beyond adding the token — but the token is required, and the key alone no longer admits anyone. Whether that mode serves a browser or a server integration is told by the Channel's allowed domains, not by a separate setting: a blank list means server-only.
57+
- **Credential Mode** — the *admin's* choice of what an *external* caller must present: the **Embed Key** (`embed_key`, the default), or an OAuth token (`oauth`) (ADR-0059). (A signed-in team member reaches an in-app embed through membership, presenting neither.) Under the OAuth mode an Embed Key is *ignored rather than rejected*, so an existing snippet needs no edit beyond adding the token — but the token is required, and the key alone no longer admits anyone. Whether that mode serves a browser or a server integration is told by the Channel's allowed domains, not by a separate setting: a blank list means server-only.
5858
- **Widget Auth Level** — a *version floor*, raised automatically as the deployed widget is upgraded (ADR-0045). It describes what old widgets on the page are capable of, never what the admin requires.
5959

6060
An admin's policy must never be switched on by a widget upgrade, which is why these are two separate settings and not rungs of one ladder.
6161

62-
Once the OAuth mode exists, exposure will take **two** admin acts that must agree: the Channel says *this Chatbot is reachable over OAuth*, and the **OAuth Application** separately names the Chatbots it may reach. Neither alone admits anyone.
62+
Under the OAuth mode, exposure takes **two** admin acts that must agree (ADR-0059, ADR-0063): the Channel says *this Chatbot is reachable over OAuth*, and the **OAuth Application** separately names the Chatbots it may reach. Neither alone admits anyone.
6363
_Backed by_: `ChannelPlatform.EMBEDDED_WIDGET`, labelled **Chat Widget & API** — the stored value stays `embedded_widget` because it is also a `Participant.platform` value. `Credential Mode` is `ExperimentChannel.credential_mode`; `Widget Auth Level` is `ExperimentChannel.required_auth_level`.
64-
_Planned_: the OAuth path that gives `credential_mode` its meaning (`oauth-chat-widget.md` D1–D4) — until it lands the column is inert.
64+
_Decided in_: ADR-0059 (the mode), ADR-0060 (origin rule per credential), ADR-0061–0063 (how a token is admitted).
6565
_Avoid_: "the Embedded Widget channel" (the old label) when the channel is serving a server integration — say "Chat API Channel". "The widget channel" is fine when a widget really is the client.
6666

6767
**Embed Key**:
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# ADR-0059: The Chat API Channel's credential mode determines which credential admits a caller
2+
3+
<span class="adr-status adr-status-accepted">ACCEPTED</span>
4+
5+
<p class="adr-meta">Author: Simon Kelly · Created: 2026-09-02</p>
6+
7+
<p class="adr-meta">Extends: <a href="0044-durable-per-channel-widget-auth-policy.md">ADR-0044</a>, <a href="0053-chat-session-start-requires-membership-or-embed-key.md">ADR-0053</a></p>
8+
9+
## Context
10+
11+
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.
12+
13+
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.
14+
15+
The widget serves two platforms: `EMBEDDED_WIDGET`, and `PUBLIC`, an OCS-hosted link enabled by `flag_public_channel`.
16+
17+
## Decision
18+
19+
We will record the admin's choice in `ExperimentChannel.credential_mode`, a `CredentialMode` column on the Chat API Channel (platform `embedded_widget`).
20+
21+
- Two values: `embed_key` (the default; every existing channel was migrated to it) and `oauth`. The column is audited.
22+
- The mode applies to `EMBEDDED_WIDGET` channels only. A `PUBLIC` channel always admits by embed key.
23+
- In `oauth` mode the embed key is ignored, not rejected. A snippet that sends `X-Embed-Key` continues to work if it also presents a valid token. The key alone does not admit a caller.
24+
- The mode determines which external credential admits an anonymous caller: the embed key in `embed_key` mode, a bearer token in `oauth` mode. It does not affect team membership: ADR-0053's membership path is unchanged in both modes.
25+
- The mode applies to `/api/chat/*` only. The `chatbots:interact` endpoints do not resolve a channel and are bounded by the ADR-0056 allowlist alone.
26+
- When the channel form omits `credential_mode`, the stored mode is kept. A partial save cannot change the required credential to a weaker one.
27+
- `oauth` mode sets `required_auth_level` to `SESSION_TOKEN`. The check constraint `oauth_credential_mode_requires_session_token` rejects any other combination, and the ratchet skips these channels.
28+
- The platform value `embedded_widget` is unchanged; its label becomes "Chat Widget & API". The value is also used as `Participant.platform`, so renaming it would create a second participant record for every existing participant.
29+
- In `oauth` mode the channel reports the first widget release that supports the `authTokenProvider` property 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.
30+
31+
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).
32+
33+
## Consequences
34+
35+
- Existing channels are unaffected; `embed_key` mode is the previous behaviour.
36+
- An `oauth` channel with `required_auth_level` below `SESSION_TOKEN` would issue no session token, and every subsequent call would fail the legacy-access check. The constraint prevents this state.
37+
- All modes produce `platform=embedded_widget` participants.
38+
- A chatbot has one Chat API Channel, so a single embed cannot be both anonymous and token-gated. A `PUBLIC` channel on the same chatbot provides an anonymous OCS-hosted link alongside a token-gated embed.
39+
- 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.
40+
- Switching an existing channel to `oauth` requires no snippet change other than installing an `authTokenProvider` function on the element.
41+
42+
## Alternatives considered
43+
44+
- **A new platform for OAuth callers** → rejected: it creates a second `Participant.platform` value for every existing participant and duplicates the widget-specific code paths.
45+
- **`require_oauth` in `extra_data`** → rejected: ADR-0044 stored per-channel policy in a real column, and this field is the same kind of setting.
46+
- **A boolean on `Experiment`** → rejected: the chatbot is versioned, and the session needs a channel to be attributed to.
47+
- **A third `WidgetAuthLevel` value** → rejected: that field is version-ratcheted (see Context).
48+
- **Embed key and token together** → rejected, see Decision.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# ADR-0060: Each credential validates its own origin
2+
3+
<span class="adr-status adr-status-accepted">ACCEPTED</span>
4+
5+
<p class="adr-meta">Author: Simon Kelly · Created: 2026-09-02</p>
6+
7+
<p class="adr-meta">Extends: <a href="0053-chat-session-start-requires-membership-or-embed-key.md">ADR-0053</a>, <a href="0059-chat-api-channel-credential-mode.md">ADR-0059</a></p>
8+
9+
## Context
10+
11+
ADR-0053 made the domain check part of embed-key validation: a key is accepted only together with an `Origin` or `Referer` header that matches the channel's `allowed_domains`. A request with no origin header is refused, because browsers always send one and a request without one did not come from an embed.
12+
13+
A bearer token needs a different rule. A server integration sends no origin header and should not be required to. A browser embed in `oauth` mode does send one, and a token copied from that page should be accepted from no more origins than the embed key would be. The channel needs a way to distinguish the two deployments. The `PUBLIC` platform (ADR-0059) is served from the OCS host only and has no domain list.
14+
15+
## Decision
16+
17+
We will apply the origin rule of whichever credential admits the caller, and use the channel's domain list to distinguish a browser-facing `oauth` channel from a server-only one.
18+
19+
| Mode | `allowed_domains` | `Origin` or `Referer` present | Neither present |
20+
|---|---|---|---|
21+
| `embed_key` | required | must match | reject |
22+
| `oauth`, list non-blank | optional | must match | reject |
23+
| `oauth`, list blank | optional | reject | admit |
24+
25+
- A blank list on an `oauth` channel means server-only. Every request with an `Origin` or `Referer` header is refused, regardless of the token. `allowed_domains` is required in `embed_key` mode and optional in `oauth` mode. The form's help text states that a blank list means server-only.
26+
- The origin rule is applied once, by the credential that resolves the channel. No later check re-evaluates a token-resolved channel, because the blank-list "admit" row cannot be expressed as a domain match.
27+
- A `PUBLIC` channel's origin must equal the canonical Site hostname. The comparison is hostname to hostname, so ports are ignored.
28+
- After session start, the rule follows the credential presented. A session token has no origin semantics (ADR-0039). An embed key sent with a session-bound request is checked against the domain list in the usual way.
29+
30+
## Consequences
31+
32+
- A browser-facing `oauth` channel refuses requests with no origin header or an unlisted origin. This is the protection the rejected key-and-token mode would have provided (ADR-0059). Browsers set `Origin` and scripts can set it to any value, so this limits replay from browsers on other sites, not replay from a script.
33+
- A non-browser client that sends a `Referer` header to a blank-list `oauth` channel is refused. The integration documentation must state this.
34+
- Admins may read "optional" as "not set", so the form must explain that a blank list means server-only.
35+
- Applying the domain list to session-token requests is possible later hardening. It would change behaviour for existing widget sessions.
36+
37+
## Alternatives considered
38+
39+
- **A separate server-integration setting on the channel** → rejected: the domain list already carries that information.
40+
- **Require "allow all domains" for a server integration** → rejected: it misdescribes the deployment and admits requests from any browser.
41+
- **Decide originless handling by mode alone** → rejected: an `oauth` channel with a domain list must still refuse originless requests.
42+
- **Apply the domain list to session-token requests** → deferred: the session token is the credential for those requests and carries no origin.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ADR-0061: The bearer token is resolved by an authenticator, first in the list, on session start only
2+
3+
<span class="adr-status adr-status-accepted">ACCEPTED</span>
4+
5+
<p class="adr-meta">Author: Simon Kelly · Created: 2026-09-02</p>
6+
7+
<p class="adr-meta">Extends: <a href="0039-require-proof-of-possession-for-chat-session-access.md">ADR-0039</a>, <a href="0052-app-layer-rate-limiting-mechanism.md">ADR-0052</a>, <a href="0053-chat-session-start-requires-membership-or-embed-key.md">ADR-0053</a>, <a href="0059-chat-api-channel-credential-mode.md">ADR-0059</a></p>
8+
9+
## Context
10+
11+
ADR-0053 placed the start-session authorization rule in one function. OAuth could be added to that function or checked separately, and the choice depends on two DRF behaviours. DRF stops at the first authenticator that returns a result. The chat API throttle (mechanism in ADR-0052) keys its bucket on the channel it finds in `request.auth`. If the token is resolved anywhere other than an authentication class, `request.auth` stays empty and all machine callers behind one egress IP share the anonymous IP bucket.
12+
13+
Snippets switched to `oauth` mode continue to send `X-Embed-Key`. If the embed-key authenticator ran first, it would match and the token would not be read.
14+
15+
## Decision
16+
17+
We will resolve the bearer token in a dedicated authentication class placed first in the authentication classes of `POST /api/chat/start/`. No other endpoint uses it.
18+
19+
- It returns an anonymous user and the chatbot's `oauth`-mode channel, the same result shape as the embed-key authenticator. This puts the channel in `request.auth`.
20+
- With no `Authorization` header, or an unknown chatbot id, it returns nothing and the session and embed-key authenticators run. If a header is present for a known chatbot and any check fails, it raises. An invalid token is never treated as an absent token.
21+
- It resolves working versions only, so a version's `public_id` is treated as unknown. A request with no `X-Embed-Key` then receives the view's 404. A request that also carries an embed key is refused by the embed-key authenticator instead (ADR-0062).
22+
- The ADR-0053 authorization function gains an OAuth branch. Its embed-key branch refuses a key that resolves an `oauth`-mode channel when no token is present (ADR-0059). A token does not permit version selection; `version_number` remains member-only (ADR-0053).
23+
- The channel the token resolved owns the session. Widget version recording remains conditional on widget requests, so a server caller is not recorded with a placeholder version.
24+
- The four session-bound endpoints keep their authentication classes and ADR-0039's rules. The bearer token is not required after session start.
25+
26+
## Consequences
27+
28+
- OAuth traffic is throttled per channel with no change to the throttle. Two integrations on one chatbot share an allowance. A `PUBLIC` channel throttles session starts per visitor IP instead.
29+
- The 401 status results from the authenticator's position (ADR-0062).
30+
- Reordering the authenticators has two effects. If the embed-key authenticator ran first, a request carrying both `Authorization` and `X-Embed-Key` would be admitted by the key and the token would not be validated. If session authentication ran first, refusals would return 403 instead of 401, because it supplies no `WWW-Authenticate` value.
31+
- A session created with a token can be continued with the session token alone. A short-lived OAuth token therefore cannot expire mid-conversation.
32+
- An `Authorization` header on session start was previously ignored. It now produces a 401 when it is not a valid `chat:start` token.
33+
- A rejected token is not counted by the credentials throttle, because DRF authenticates before it throttles. A per-IP fail-closed bucket would also count ADR-0054's legitimate re-admission requests, so this is left to ADR-0052.
34+
35+
## Alternatives considered
36+
37+
- **A resolver called from the view** → rejected: `request.auth` stays empty, OAuth traffic falls into the IP bucket, and the 401 requires a workaround.
38+
- **Place it after the embed-key authenticator** → rejected: snippets still send the key, so the token would never be validated.
39+
- **Treat an invalid token as no token** → rejected: a revoked token would fall through to the keyless path and be admitted.
40+
- **Require the bearer token on session-bound requests** → rejected: the host would have to keep a valid token in page JavaScript for the whole conversation, and the legacy-access check would treat a token-resolved channel as an embed key.
41+
- **Throttle per OAuth application** → deferred: per-channel matches how widget traffic is throttled; per-application is an additional identity case for ADR-0052.

0 commit comments

Comments
 (0)