Skip to content

DiscourseConnect (SSO)

Discourse is the identity provider. SAE Books is the consumer (relying party). When a user clicks "Log in with Discourse" on the SAE Books login page, their browser is sent to Discourse's /session/sso_provider endpoint, where Discourse authenticates them and signs a response redirecting them back to SAE Books.

Architecture direction

This is the opposite of the old design (pre-2026-04-29) where SAE Books acted as the provider. The old flow used /forum/session/sso_provider on SAE Books — that path is gone. The current flow originates on SAE Books and delegates authentication to Discourse.

How the flow works

sequenceDiagram actor User participant App as SAE Books (/auth/discourse/login) participant Disc as Discourse (/session/sso_provider) User->>App: Click "Log in with Discourse" App->>App: Generate nonce; sign nonce cookie
(HMAC-SHA256 with session secret) App-->>User: 303 → discourse.../session/sso_provider?sso=…&sig=… User->>Disc: GET /session/sso_provider?sso=…&sig=… Disc->>Disc: Verify HMAC-SHA256 signature
Authenticate user (existing session or login prompt) Disc-->>User: 303 → app.saebooks.com.au/auth/discourse/callback?sso=…&sig=… User->>App: GET /auth/discourse/callback?sso=…&sig=… App->>App: Verify HMAC-SHA256 signature
Verify nonce cookie (replay protection) App->>App: POST /api/v1/auth/oauth-handoff
(provider=discourse, external_id, email, display_name) App->>App: Set session cookie (api_token) App-->>User: 303 → /

Endpoints (saebooks-web)

GET https://app.saebooks.com.au/auth/discourse/login

Initiates the flow. Generates a nonce, stores it in an HMAC-signed cookie (sae_discourse_nonce, TTL 10 minutes), and redirects the browser to Discourse's sso_provider endpoint.

GET https://app.saebooks.com.au/auth/discourse/callback?sso=<payload>&sig=<hmac>

Handles the Discourse callback. Both are handled by saebooks_web/discourse_sso.py. Neither is an API endpoint — both are browser redirect handlers.

Payload — inbound (Discourse → SAE Books callback)

Discourse builds and signs this payload before redirecting the user back.

Field Notes
nonce Echoed back from the outbound payload; checked against cookie.
external_id Discourse user ID — stable key used as provider_user_id.
email User's verified email on Discourse.
username Discourse username handle.
name Display name (falls back to username then email).

The raw payload is nonce=…&external_id=…&email=…&…, base64-encoded and HMAC-SHA256 signed with the shared secret.

Payload — outbound (SAE Books → Discourse login request)

SAE Books builds and signs this payload before redirecting the user to Discourse.

Field Notes
nonce Random; echoed back to prevent replay.
return_sso_url https://app.saebooks.com.au/auth/discourse/callback

Shared secret

Both sides hold the same HMAC-SHA256 secret. On Discourse it is the discourse_connect_secret site setting. On SAE Books it is the DISCOURSE_SSO_SECRET environment variable (set on saebooks-web).

Rotate the secret by updating both sides simultaneously and restarting saebooks-web. Any in-flight login attempt during rotation will fail cleanly (the user gets an error page and can retry).

Session handoff to saebooks-api

After verifying the Discourse payload, saebooks-web does not create a session directly. Instead it calls the internal oauth-handoff endpoint:

POST /api/v1/auth/oauth-handoff
X-OAuth-Handoff-Secret: <SAEBOOKS_OAUTH_HANDOFF_SECRET>

{
  "provider": "discourse",
  "provider_user_id": "<discourse external_id>",
  "email": "<user email>",
  "display_name": "<name or username>"
}

On success the API returns {"access_token": "<bearer>"}. The web layer stores this in the session cookie (session["api_token"]) and fetches /api/v1/auth/me to populate session["username"] and session["user_role"].

Both DISCOURSE_SSO_SECRET and SAEBOOKS_OAUTH_HANDOFF_SECRET must be set on saebooks-web. Missing either disables the flow or causes a 500 error with a clear message logged.

Discourse settings

These site settings are set via the Discourse admin panel and held in the database (not in the compose env vars):

Setting Value
enable_discourse_connect true (SAE Books is an SSO consumer)
discourse_connect_url https://app.saebooks.com.au/auth/discourse/login
discourse_connect_secret (shared secret, see above)

openid_connect_enabled must be false — it and DiscourseConnect are mutually exclusive. Setting the DISCOURSE_OPENID_CONNECT_* env vars in the Discourse compose file forces OIDC on at startup and overrides the database setting; those env vars must be absent.

Environment variables (saebooks-web)

Variable Description
DISCOURSE_SSO_SECRET Shared HMAC-SHA256 secret; must match discourse_connect_secret in Discourse.
DISCOURSE_BASE_URL Discourse instance base URL (default: https://discourse.saebooks.com.au).
SAEBOOKS_WEB_PUBLIC_BASE_URL Public base URL of saebooks-web (default: https://app.saebooks.com.au).
SAEBOOKS_OAUTH_HANDOFF_SECRET Shared secret for the internal oauth-handoff API call.

Discourse login is disabled (button hidden) when DISCOURSE_SSO_SECRET is unset or empty.

SAE staff flag

After a successful Discourse login, the session's is_sae_staff flag is set based on the SAE_STAFF_USERNAMES env var (comma-separated list of usernames or emails). This gate controls access to internal tooling routes (e.g. /admin/inspect, agent routes) and is distinct from the user's role field on the account.

Testing locally

Set DISCOURSE_SSO_SECRET to any 32+ char value and exercise the callback endpoint directly by building a test payload:

import base64, hashlib, hmac, urllib.parse, time, secrets

secret = b"your-test-secret"
nonce = secrets.token_urlsafe(16)
# Build the payload Discourse would return
payload = urllib.parse.urlencode({
    "nonce": nonce,
    "external_id": "42",
    "email": "[email protected]",
    "username": "testuser",
    "name": "Test User",
})
b64 = base64.b64encode(payload.encode()).decode()
sig = hmac.new(secret, b64.encode(), hashlib.sha256).hexdigest()
print(f"http://localhost:8043/auth/discourse/callback?sso={urllib.parse.quote(b64)}&sig={sig}")

Navigate to the printed URL (you will need a valid nonce cookie, which the /auth/discourse/login endpoint sets — run that first, then substitute the nonce from the cookie into the payload above).