ADR 39: AT Protocol OIDC Bridge for Discourse Support SSO
Status: Proposed
Date: 2026-06-09
Last Updated: 2026-06-10 (Mattermost → self-hosted Discourse)
Terms (this ADR)
| ID | Term | Meaning |
|---|---|---|
| Rn | Functional requirement | Numbered obligation (R1–R13 in this ADR). |
| NRn | Non-functional requirement | Quality attribute (NR1–NR6). |
| Cn | Constraint | Non-negotiable boundary (C1–C9). |
| CCn | Cross-cutting challenge | Risk spanning components (CC1–CC6). |
| OIDC bridge | Identity adapter | Substratum service that acts as an OpenID Connect Provider toward Discourse and completes AT Protocol OAuth toward pds.substratum.cloud on the upstream leg. |
| Bridge session | Transient auth state | Server-side session between AT Proto OAuth completion and OIDC authorization-code issuance; not a customer substratum_session. |
| Support entitlement gate | AuthZ check | Entitlement lookup proving the did has an active support seat (Substratum Once or base membership per ADR 37 CC7) before the bridge mints OIDC tokens. |
| Discourse OIDC client | Relying party | Self-hosted Discourse on support.* with bundled OpenID Connect login enabled; discovery endpoint points at the bridge. |
| External support login | UX boundary | Discourse + OIDC bridge auth happens on support.* / id.support.* — not inside apps/file-explorer (ADR 33 C1). |
Canonical product vocabulary: Glossary.
Context
ADR 38 places paid customer support on self-hosted Discourse at support.*. Discourse does not implement AT Protocol OAuth. Without a bridge, customers would need a separate Discourse identity (email/password or email invite), weakening the product story that Substratum login is one portable identity (Business model).
Substratum already implements AT Protocol OAuth in crates/auth for app.* (customer) and admin.* (staff company SSO, ADR 38 R17). Discourse ships a bundled OpenID Connect plugin (authorization code + PKCE): it consumes a standard OIDC discovery document, authorization endpoint, token endpoint, and ID token claims (Discourse OIDC docs).
Decision: Operate a small OIDC bridge that dogfoods the same AT Proto tooling, gates login on entitlement state, and lets Discourse treat Substratum as its identity provider — without exposing Discourse admin API keys to browsers (ADR 38 C3).
Support login is external to the file explorer: Customer app.* auth (substratum_session) and support auth (Discourse OIDC → bridge → PDS) are different origins, OAuth clients, and cookies. The dashboard MAY link to support.* (e.g. from /account) but MUST NOT embed Discourse, host the OIDC bridge, or funnel support SSO through the file-explorer bundle (ADR 33).
Requirements
Functional requirements
| ID | Requirement |
|---|---|
| R1 | Substratum SHALL operate an OIDC bridge exposing /.well-known/openid-configuration, /oauth/authorize, /oauth/token, /oauth/userinfo, and JWKS for Discourse SSO. |
| R2 | The bridge upstream leg SHALL authenticate users via AT Protocol OAuth against Substratum-operated PDS (pds.substratum.cloud), reusing crates/auth (same profile as ADR 12). |
| R3 | The bridge SHALL use a dedicated OAuth client metadata URL / redirect set for the OIDC bridge origin — MUST NOT reuse app.* or admin.* client ids or cookies. |
| R4 | Before issuing an OIDC authorization code, the bridge SHALL enforce the support entitlement gate: the authenticated did MUST have an active paid-login entitlement granting a support seat (Once or base membership). Unpaid or lapsed DIDs MUST be denied with a user-safe error and stable code (e.g. substratum.support.not_entitled). |
| R5 | OIDC sub claim SHALL be the customer did. preferred_username SHOULD be the Substratum handle when resolvable; email SHOULD be populated when known on the entitlement or PDS profile. |
| R6 | Discourse on support.* SHALL enable bundled OpenID Connect: discovery endpoint https://{bridge-host}/.well-known/openid-configuration, redirect URI https://{support-host}/auth/oidc/callback, scopes including openid, profile, email; PKCE enabled when client secret is omitted. |
| R7 | On successful OIDC login, the bridge or a server-side hook SHALL record discourse_user_id (or link via did) on the support_seat / entitlement row when Discourse provisioning completes — idempotent per did. |
| R8 | Lapse / revoke: When entitlement lapses (ADR 38 R9), the Discourse adapter SHALL suspend or deactivate the user in addition to OIDC gate denial on next login. OIDC gate alone is insufficient once a Discourse session exists. |
| R9 | Grant flow: Operator grant (ADR 38 R8) sets entitlement capabilities first; customer self-serves Discourse access by signing in with “Substratum” OIDC — email invite MAY remain as optional fallback for customers without Substratum PDS accounts yet (ADR 38 CC1). |
| R10 | Staff operators on Discourse (if any) MUST NOT use the customer OIDC bridge for admin powers; staff use admin.* PDS SSO + operator_role (ADR 38 R17–R18). The bridge is customer support only in v1. |
| R11 | The bridge SHALL NOT issue OIDC tokens to staff_did rows unless a future ADR explicitly extends support-bridge scope; staff Discourse moderation access is out of v1 scope. |
| R12 | OpenAPI or HTTP handler DTOs for bridge debugging endpoints (if any) SHALL live in ingress models only when exposed via gateway; the bridge MAY run as a sibling binary/service (ADR 02). |
| R13 | apps/file-explorer (ADR 33) SHALL not implement Discourse login, OIDC bridge callbacks, or Discourse embeds. When the customer has a support seat, /account (or equivalent) MAY show an external link to https://support.*/ (new tab / same browser); GET /api/v1/me/limits MAY expose support_url and support_seat_allowed for copy and visibility only. |
Non-functional requirements
| ID | Requirement |
|---|---|
| NR1 | Security: Bridge signing keys and Discourse OIDC client secret (when used) MUST be env-configured (ADR 13); rotate without code deploy. |
| NR2 | Latency: Entitlement gate lookup on authorize path SHALL be Postgres-local or same-region; target < 50 ms excluding AT Proto OAuth redirects. |
| NR3 | Session isolation: Bridge cookies/sessions MUST NOT be interchangeable with substratum_session or substratum_ops_session. |
| NR4 | Observability: Log did, entitlement denial reason, and OIDC client_id — never AT Proto refresh tokens or Discourse admin API keys. |
| NR5 | Ports: Entitlement gate SHALL use the same entitlement service port as ADR 37 — no duplicate support policy store. |
| NR6 | Lean v1: Prefer a single bridge service colocated with gateway or id.support.*; no third-party IdP (Auth0, Okta) in the hot path. |
Constraints
| ID | Constraint |
|---|---|
| C1 | Discourse admin API keys MUST remain server-side (ADR 38 C3); the bridge handles login only, not group membership API from browsers. |
| C2 | The bridge MUST NOT expose AT Protocol DPoP refresh tokens to Discourse or the browser OIDC client. |
| C3 | OIDC sub MUST be did — not email — so support seats reconcile with entitlement rows and audit. |
| C4 | BYO-login customers without Substratum PDS accounts MUST NOT use the bridge until they have a did on Substratum PDS with support entitlement (or pending-entitlement flow ships, ADR 38 post-v1 E). |
| C5 | Bridge issuer URL MUST be HTTPS with stable id.support.* (or equivalent) hostname; Discourse discovery depends on it. |
| C6 | v1 bridge MUST implement authorization code + PKCE flow Discourse expects; implicit-only flows are forbidden. |
| C7 | Entitlement enforcement MUST NOT call payment providers on the OIDC hot path (ADR 37 C7). |
| C8 | Customer OIDC bridge MUST NOT authorize staff_did operator registry rows (R11). |
| C9 | apps/file-explorer MUST NOT register app.* OAuth redirect URIs for Discourse or the OIDC bridge; MUST NOT iframe support.*; support SSO stays on support.* + id.support.* (R13). |
Cross-cutting challenges
| ID | Challenge | Mitigation |
|---|---|---|
| CC1 | AT Proto OAuth ≠ OIDC wire format | Bridge translates: AT Proto session → OIDC ID token claims; two-legged internal session store. |
| CC2 | User granted Once but never created Substratum PDS account | Grant sets entitlement; customer must complete PDS signup before bridge login succeeds; ops runbook + optional email invite (ADR 38 CC1). |
| CC3 | Discourse username vs handle | Map preferred_username from handle; document rename policy; Discourse may append suffix on collision. |
| CC4 | Lapsed user with live Discourse session | Server-side suspend on lapse (R8) + OIDC deny on re-auth; document session TTL in ops runbook. |
| CC5 | Three OAuth clients (app, admin, bridge) | Separate client metadata URLs, redirects, and cookies; shared crates/auth internals only. |
| CC6 | User expects “one login” for app and support | Same PDS identity, different surfaces: explain on /account that support opens support.*; optional deep link only — no in-app forum v1. |
Decision
1. Topology
| Host | Role |
|---|---|
app.* | File explorer + gateway API; substratum_session only |
support.* | Self-hosted Discourse (OIDC relying party) |
id.support.* | OIDC bridge issuer (e.g. id.support.substratum.cloud) |
pds.substratum.cloud | AT Protocol authorization server (upstream) |
The bridge MAY run as a gateway router module or a small sidecar behind the same Caddy edge as Discourse; issuer URL MUST match the public id.support.* hostname.
2. OIDC claims (normative)
| Claim | Source |
|---|---|
sub | Customer did |
preferred_username | Substratum handle (local part or FQDN) |
email | Entitlement / checkout email when present |
name | Display name from PDS profile when present |
email_verified | true when email sourced from trusted checkout/entitlement row |
Scopes: openid, profile, email (Discourse minimum).
3. Entitlement gate rule
| Entitlement state | Bridge authorize |
|---|---|
| Active Once or base (support seat) | Allow |
| Grace period (base) | Allow (configurable; default allow until grace ends) |
| Lapsed (no support seat) | Deny substratum.support.not_entitled |
| No entitlement row | Deny |
staff_did without customer entitlement | Deny (use admin.*) |
4. Relationship to ADR 38 Discourse adapter
| Concern | Owner |
|---|---|
| Login / SSO | OIDC bridge (this ADR) |
| Lapse revoke / suspend | Discourse adapter on billing events (ADR 38 R9) |
| Optional email invite | Discourse adapter fallback for CC2 edge cases |
| Audit | Entitlement audit log on grant/lapse; bridge logs auth denials |
Garage v1 step C (Garage v1 rollout) includes: deploy Discourse at support.*, deploy OIDC bridge at id.support.*, wire Discourse OpenID Connect, entitlement gate, lapse suspend hook.
5. Implementation sketch
| Component | Location (proposed) |
|---|---|
| AT Proto OAuth leg | crates/auth (existing) |
| OIDC provider (discovery, authorize, token, userinfo, JWKS) | New crates/oidc-bridge or apps/oidc-bridge |
| Entitlement gate | EntitlementPolicyPort / support-seat helper |
| Discourse suspend / group revoke | crates/support-seat Discourse adapter (ADR 38) |
| Config | OIDC_BRIDGE_ISSUER, OIDC_BRIDGE_SIGNING_KEY, DISCOURSE_OIDC_CLIENT_ID, DISCOURSE_OIDC_CLIENT_SECRET, DISCOURSE_API_KEY, DISCOURSE_API_USERNAME |
6. File explorer boundary (ADR 33)
In apps/file-explorer | External (not in file-explorer) |
|---|---|
/account shows support seat status from /me/limits | Discourse UI on support.* |
Link/CTA → support_url (config or limits payload) | OIDC bridge on id.support.* |
| Customer AT Proto login for drives/files | Bridge AT Proto leg + entitlement gate |
substratum_session cookie on app.* | Discourse session cookie on support.* |
apps/admin on admin.*: isolated app, shared @substratum/ui-kit (ADR 33 §8) | Operator pages not in file-explorer bundle |
Implement bridge and Discourse SSO in gateway / crates/oidc-bridge (and Discourse site settings) — not in the file-explorer Vite bundle. Operator UI lives in apps/admin — reuse ui-kit, do not import explorer routes.
Rejected alternatives
| Alternative | Why rejected |
|---|---|
| Email/password Discourse accounts only | Second identity; weak “Substratum login” story; ADR 38 CC2 friction. |
| Discourse native OAuth to Bluesky/PDS | Not supported; would require upstream Discourse feature. |
| Auth0/Okta brokering AT Proto | Extra vendor, cost, and identity plane; violates dogfooding NR6. |
Use app.* OAuth client for Discourse | Cookie and redirect collision; violates NR3, CC5, and C9. |
| In-app Discourse embed or SSO in file-explorer | Violates R13, C9; OAuth origin and session isolation (ADR 33). |
| OIDC gate only (no Discourse suspend on lapse) | Live Discourse sessions survive lapse (CC4). |
| Mattermost (prior ADR) | Real-time chat stack; team prefers async forum (topics, search, email digests) for lean support at Garage scale. |
Consequences
Positive
- Customers sign into
support.*with the same Substratum PDS identity asapp.*. - Reuses
crates/auth— one AT Proto stack for app, admin, and support. - Entitlement gate ties forum access to billing state without Discourse knowing about Postgres.
- Forum threads are searchable and durable — better fit than chat for documented troubleshooting.
Negative
- Substratum must operate and secure a custom OIDC provider (signing keys, token lifetimes, OIDC compliance testing).
- Three OAuth/OIDC clients to maintain (app, admin, bridge).
- Self-hosted Discourse (Ruby, Postgres, Redis) is a heavier ops footprint than Mattermost.
- Discourse username immutability and discovery caching require ops runbook attention.
Neutral
- Email invite path can remain for onboarding edge cases until pending-entitlement-by-email ships.
- Bridge issuer on
id.support.*keepssupport.*dedicated to Discourse UI.
Verification
| Scenario | Expected |
|---|---|
| Entitled DID completes AT Proto OAuth on bridge | Discourse session; sub = did |
| Lapsed DID attempts bridge authorize | Denied before code issued; stable error code |
| Unpaid DID attempts bridge authorize | Denied |
| Discourse discovery fetch | Valid openid-configuration JSON |
| Lapse webhook/adapter after grace | Discourse user suspended; re-login denied |
| Staff DID on bridge | Denied (R11) |
Customer with substratum_session on app.* | Independent; bridge uses own session cookie |
File-explorer /account support link | Opens support.* externally; no bridge routes in SPA |
Related
- Glossary
- Business model — Discourse support channel
- Garage v1 rollout
- ADR 02: Ports and Adapters
- ADR 12: AT Protocol Rust Ecosystem
- ADR 13: Twelve-Factor App
- ADR 37: PDS Entitlement Proxy and Billing Adapter
- ADR 33: Frontend Modular Monolith — file-explorer one origin; support login external
- ADR 38: Support and Entitlement Admin Tooling