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¶
(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)¶
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.
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).