Skip to content

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)

IDTermMeaning
RnFunctional requirementNumbered obligation (R1–R13 in this ADR).
NRnNon-functional requirementQuality attribute (NR1–NR6).
CnConstraintNon-negotiable boundary (C1–C9).
CCnCross-cutting challengeRisk spanning components (CC1–CC6).
OIDC bridgeIdentity adapterSubstratum service that acts as an OpenID Connect Provider toward Discourse and completes AT Protocol OAuth toward pds.substratum.cloud on the upstream leg.
Bridge sessionTransient auth stateServer-side session between AT Proto OAuth completion and OIDC authorization-code issuance; not a customer substratum_session.
Support entitlement gateAuthZ checkEntitlement 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 clientRelying partySelf-hosted Discourse on support.* with bundled OpenID Connect login enabled; discovery endpoint points at the bridge.
External support loginUX boundaryDiscourse + 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

IDRequirement
R1Substratum SHALL operate an OIDC bridge exposing /.well-known/openid-configuration, /oauth/authorize, /oauth/token, /oauth/userinfo, and JWKS for Discourse SSO.
R2The 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).
R3The 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.
R4Before 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).
R5OIDC 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.
R6Discourse 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.
R7On 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.
R8Lapse / 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.
R9Grant 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).
R10Staff 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.
R11The 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.
R12OpenAPI 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).
R13apps/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

IDRequirement
NR1Security: Bridge signing keys and Discourse OIDC client secret (when used) MUST be env-configured (ADR 13); rotate without code deploy.
NR2Latency: Entitlement gate lookup on authorize path SHALL be Postgres-local or same-region; target < 50 ms excluding AT Proto OAuth redirects.
NR3Session isolation: Bridge cookies/sessions MUST NOT be interchangeable with substratum_session or substratum_ops_session.
NR4Observability: Log did, entitlement denial reason, and OIDC client_id — never AT Proto refresh tokens or Discourse admin API keys.
NR5Ports: Entitlement gate SHALL use the same entitlement service port as ADR 37 — no duplicate support policy store.
NR6Lean v1: Prefer a single bridge service colocated with gateway or id.support.*; no third-party IdP (Auth0, Okta) in the hot path.

Constraints

IDConstraint
C1Discourse admin API keys MUST remain server-side (ADR 38 C3); the bridge handles login only, not group membership API from browsers.
C2The bridge MUST NOT expose AT Protocol DPoP refresh tokens to Discourse or the browser OIDC client.
C3OIDC sub MUST be did — not email — so support seats reconcile with entitlement rows and audit.
C4BYO-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).
C5Bridge issuer URL MUST be HTTPS with stable id.support.* (or equivalent) hostname; Discourse discovery depends on it.
C6v1 bridge MUST implement authorization code + PKCE flow Discourse expects; implicit-only flows are forbidden.
C7Entitlement enforcement MUST NOT call payment providers on the OIDC hot path (ADR 37 C7).
C8Customer OIDC bridge MUST NOT authorize staff_did operator registry rows (R11).
C9apps/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

IDChallengeMitigation
CC1AT Proto OAuth ≠ OIDC wire formatBridge translates: AT Proto session → OIDC ID token claims; two-legged internal session store.
CC2User granted Once but never created Substratum PDS accountGrant sets entitlement; customer must complete PDS signup before bridge login succeeds; ops runbook + optional email invite (ADR 38 CC1).
CC3Discourse username vs handleMap preferred_username from handle; document rename policy; Discourse may append suffix on collision.
CC4Lapsed user with live Discourse sessionServer-side suspend on lapse (R8) + OIDC deny on re-auth; document session TTL in ops runbook.
CC5Three OAuth clients (app, admin, bridge)Separate client metadata URLs, redirects, and cookies; shared crates/auth internals only.
CC6User expects “one login” for app and supportSame PDS identity, different surfaces: explain on /account that support opens support.*; optional deep link only — no in-app forum v1.

Decision

1. Topology

HostRole
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.cloudAT 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)

ClaimSource
subCustomer did
preferred_usernameSubstratum handle (local part or FQDN)
emailEntitlement / checkout email when present
nameDisplay name from PDS profile when present
email_verifiedtrue when email sourced from trusted checkout/entitlement row

Scopes: openid, profile, email (Discourse minimum).

3. Entitlement gate rule

Entitlement stateBridge 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 rowDeny
staff_did without customer entitlementDeny (use admin.*)

4. Relationship to ADR 38 Discourse adapter

ConcernOwner
Login / SSOOIDC bridge (this ADR)
Lapse revoke / suspendDiscourse adapter on billing events (ADR 38 R9)
Optional email inviteDiscourse adapter fallback for CC2 edge cases
AuditEntitlement 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

ComponentLocation (proposed)
AT Proto OAuth legcrates/auth (existing)
OIDC provider (discovery, authorize, token, userinfo, JWKS)New crates/oidc-bridge or apps/oidc-bridge
Entitlement gateEntitlementPolicyPort / support-seat helper
Discourse suspend / group revokecrates/support-seat Discourse adapter (ADR 38)
ConfigOIDC_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-explorerExternal (not in file-explorer)
/account shows support seat status from /me/limitsDiscourse UI on support.*
Link/CTA → support_url (config or limits payload)OIDC bridge on id.support.*
Customer AT Proto login for drives/filesBridge 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

AlternativeWhy rejected
Email/password Discourse accounts onlySecond identity; weak “Substratum login” story; ADR 38 CC2 friction.
Discourse native OAuth to Bluesky/PDSNot supported; would require upstream Discourse feature.
Auth0/Okta brokering AT ProtoExtra vendor, cost, and identity plane; violates dogfooding NR6.
Use app.* OAuth client for DiscourseCookie and redirect collision; violates NR3, CC5, and C9.
In-app Discourse embed or SSO in file-explorerViolates 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 as app.*.
  • 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.* keeps support.* dedicated to Discourse UI.

Verification

ScenarioExpected
Entitled DID completes AT Proto OAuth on bridgeDiscourse session; sub = did
Lapsed DID attempts bridge authorizeDenied before code issued; stable error code
Unpaid DID attempts bridge authorizeDenied
Discourse discovery fetchValid openid-configuration JSON
Lapse webhook/adapter after graceDiscourse user suspended; re-login denied
Staff DID on bridgeDenied (R11)
Customer with substratum_session on app.*Independent; bridge uses own session cookie
File-explorer /account support linkOpens support.* externally; no bridge routes in SPA