Pretix–Discourse authentication specification¶
Status: Maintained technical specification Audience: Maintainers of the standalone Pretix authentication plugin and Meetup platform
1. Terminology¶
These terms are used consistently throughout this document.
| Term | Definition |
|---|---|
| Consumer | The application that delegates authentication to an external identity provider. In this system, pretix (via this plugin) is the consumer. Sometimes called "client" or "relying party." |
| Provider | The application that authenticates users and asserts their identity. In this system, Discourse (forum.ansible.com) is the provider. Sometimes called "identity provider" or "IdP." |
| DiscourseConnect | The SSO protocol used between consumer and provider. Formerly called "Discourse SSO." Uses HMAC-SHA256 signed payloads exchanged via browser redirects. |
| SSO payload | The base64-encoded, HMAC-signed query string exchanged between consumer and provider. Contains identity fields (external_id, email, groups, etc.). |
| Privileged user | A user with an exact meetup-organisers-[a-z]+ claim, membership in a city organiser team, membership in the Pretix team Ansible Meetup Staff, or Pretix-owned is_staff=true. DiscourseConnect must attest that the required 2FA challenge succeeded before Pretix creates the session. |
| Enrichment | A server-to-server call from pretix to Discourse's Admin API to retrieve security metadata (silenced, suspended status) not available in the SSO payload. |
| RTBF | Right To Be Forgotten. Refers to a Discourse user whose account has been anonymized. Discourse replaces the email with {username}@anonymized.invalid, changes the username to anon{digits}, and destroys all auth records. |
| Pretix staff | A Pretix account whose is_staff permission is granted by Pretix administrator configuration. The plugin never reads a Forum staff group or changes this flag. |
| Organiser group | A Discourse group whose name fully matches ^meetup-organisers-([a-z]+)$. Only this exact shape grants city-scoped team access. |
| Managed team | A Pretix Team named Ansible Meetup Organisers - {City}. The tooling provisioner creates and reconciles teams; the auth plugin only manages user membership. |
| Staff team | The Pretix-only team Ansible Meetup Staff. The provisioner creates it with access to all meetup events; Pretix admins alone manage its members. No Forum group maps to it. |
| Fail secure | When a security check cannot be completed (API error, missing config, ambiguous state), deny access rather than allow it. This is the default posture for all decisions in this system. |
Design principle: fail secure¶
This system prioritizes security over availability. When in doubt:
- Missing or invalid configuration blocks the auth backend from appearing (not a silent degradation).
- API errors during enrichment block login (not a silent pass-through).
- Unknown state (e.g., 2FA status unverifiable) is treated as failing the check.
- It is acceptable to block a legitimate user who must then retry or contact an admin. It is not acceptable to allow access due to misconfiguration, transient errors, or ambiguous state.
2. Executive Summary¶
What it does¶
A pretix plugin that replaces pretix's built-in username/password login for the pretix control panel (the admin/organizer interface) with Single Sign-On via a Discourse forum — specifically forum.ansible.com.
When an operator clicks "Log in with Discourse," they are redirected to Discourse, authenticate there, and are redirected back. The plugin then:
- Verifies the cryptographic SSO payload.
- Provisions or updates a pretix user account.
- Requires DiscourseConnect's signed
confirmed_2fa=trueassertion for privileged users and checks moderation status through the Discourse Admin API. - Syncs only exact organiser-group claims (e.g.,
meetup-organisers-berlin) to Pretix team assignments (e.g.,Ansible Meetup Organisers - Berlin).
Core value proposition¶
Any valid Forum user can sign in to Pretix as an attendee. Only users with an exact city organiser group claim receive membership in the matching Pretix team and its event permissions. Pretix staff access remains controlled by Pretix administrators.
Trust model¶
Discourse is the provider and source of truth for:
- User identity (email, username, display name)
- Group memberships (which cities an organiser manages)
- Moderation status (silenced, suspended)
- 2FA challenge completion (via DiscourseConnect's signed confirmed_2fa assertion)
Pretix is the consumer. It accepts any valid Forum identity as an attendee, uses exact organiser claims only for city-team membership, and uses the Admin API for moderation status. Pretix staff access is granted only by Pretix administrators; the plugin does not use Forum groups to grant or revoke is_staff.
3. Core Workflows¶
3.1 Login Initiation (pretix → Discourse)¶
Trigger: User clicks the "Log in with Discourse" button on the pretix control panel login page.
Steps:
- The plugin's auth backend generates a cryptographically random nonce (32 bytes, URL-safe base64).
- The nonce and a timestamp (
time.time()) are stored in the Django session under keysdiscourse_sso_nonceanddiscourse_sso_nonce_created. - A payload string is constructed:
nonce=<nonce>&return_sso_url=<callback_url>&require_2fa=true. - The payload is base64-encoded.
- An HMAC-SHA256 signature is computed over the base64 payload using the shared SSO secret.
- The user's browser is redirected to
{DISCOURSE_URL}/session/sso_provider?sso={payload_b64}&sig={sig}.
require_2fa=true: Always sent and is not configurable. For organiser claims and members of the Pretix staff team, Pretix requires the signed callback field confirmed_2fa=true. Missing or false is a fatal login rejection. DiscourseConnect is the sole authority for whether the challenge was completed; the Admin API is not used to infer 2FA from account configuration.
Callback URL format: https://{pretix_host}/_discourse/login/return/ (resolved via Django's reverse() using the named URL plugins:pretix_discourse_auth:return).
3.2 SSO Callback (Discourse → pretix)¶
Trigger: Discourse redirects the user's browser back to pretix's callback URL with ?sso=...&sig=... query parameters.
Steps (executed sequentially — each failure redirects to the login page with an error message):
All rejection scenarios are consolidated in the Rejection Matrix below.
Step 1: Parameter validation¶
- Verify both
ssoandsigGET parameters are present.
Step 2: Signature verification¶
- Compute HMAC-SHA256 over the raw
ssoparameter value using the shared secret. Do not strip whitespace — the value may contain a trailing newline that is part of the signed content (see Appendix A). - Compare against the provided
sigusing constant-time comparison (hmac.compare_digest).
Step 3: Payload decoding¶
- Base64-decode the
ssoparameter. - Parse as a URL query string into key-value pairs.
- If the payload contains
failed=true, reject (user cancelled or Discourse denied).
Step 4: Nonce verification (replay protection)¶
- Pop
discourse_sso_nonceanddiscourse_sso_nonce_createdfrom the session, then atomically claim a shared cache key for the nonce; a second or concurrent callback is rejected. - Compare the payload's
nonceagainst the saved nonce using constant-time comparison. - Verify the nonce timestamp is numeric, finite, not in the future, and less than 10 minutes old. Reject malformed or stale data.
Step 5: Identity extraction¶
Extract from the strictly base64-decoded and query-parsed payload. Duplicate parameters, invalid UTF-8, non-string claims, malformed email/username/ID fields, and invalid group claim shapes fail before any API or database writes.
| Payload field | Required | Processing | Maps to |
|---|---|---|---|
external_id |
Yes | Used as-is (Discourse user.id) |
Auth backend identifier |
email |
Yes | Lowercased | User email |
username |
No | Lowercased | RTBF heuristic input |
name |
No | Falls back to username if empty |
User fullname |
groups |
No | Comma-separated string | Exact organiser group claims only |
confirmed_2fa |
Required for privileged users | "true" only after the DiscourseConnect challenge succeeds |
Positive 2FA assertion |
The Discourse admin flag and all Forum staff groups are ignored. Pretix administrators own is_staff.
If external_id or email is missing/empty, reject.
Step 6: Group parsing¶
- Ignore all group claims except names fully matching
^meetup-organisers-([a-z]+)$; malformed names using the organiser prefix reject the callback. - The city slug must contain lowercase ASCII letters only; hyphens, spaces, and numbers are not accepted.
- Resolve the slug dynamically against existing city teams named
Ansible Meetup Organisers - {city}within the configured Pretix organizer. The prefix must match exactly. Normalize the display-name suffix with Unicode NFKD, discard every character except ASCII letters, then lowercase; the result must equal the claimed slug. For example,New Yorkresolves tonewyorkandSão Paulotosaopaulo. Require exactly one match, including across normalization collisions; missing or ambiguous teams reject the callback before user provisioning. - A user is privileged when they have a valid exact city claim, already belong to a city organiser team or
Ansible Meetup Staff, or already have Pretix-ownedis_staff=true. The Forum cannot grant staff-team membership or alter the Pretix staff flag.
Step 7: Positive 2FA assertion¶
For every organizer claim, existing city organiser-team member, Pretix staff-team member, or Pretix is_staff user, require confirmed_2fa=true from the HMAC-verified DiscourseConnect response. Reject when the field is missing or not exactly "true". Do not use no_2fa_methods, Pretix team configuration, or Admin API account configuration as substitutes for proof that the current authentication passed the challenge.
Step 8: RTBF detection¶
Constant: RTBF_EMAIL_SUFFIX = "@anonymized.invalid"
A user is flagged as RTBF if their email ends with @anonymized.invalid. This is the email suffix Discourse assigns during anonymization (verified in source: UserAnonymizer::EMAIL_SUFFIX = "@anonymized.invalid").
Username and display name heuristics are not used — they produce false positives for legitimate usernames. The email domain is sufficient: Discourse destroys all auth records during anonymization, so an anonymized user cannot reach this point in practice. This check is defense-in-depth.
Step 9: Security enrichment (Discourse Admin API)¶
Precondition: API_KEY must be configured. If not configured, the auth backend refuses to register at startup (see Section 5.1 Config Validation).
- Call
GET {DISCOURSE_URL}/admin/users/{external_id}.jsonwith headersApi-KeyandApi-Username. - Timeout: 10 seconds by default (
api_timeout, valid range 1–60). - Use a configured
requests.Sessionclient that stores the Discourse base URL, API headers, and timeout together. Disable redirects so the API key cannot be forwarded to another URL. - Set the User-Agent to
Ansible-pretix-discourse-auth/0.1.0 (+https://github.com/ansible-community/meetup-pretix-discourse-auth)on every outbound API request. - Parse response JSON once, store in a local variable. The Discourse Admin API returns the user object directly (
root: false), soapi_data = raw_data— noraw_data.get('user', raw_data)wrapper needed.
On HTTP 200, validate identity and extract moderation status: The response must contain the requested id and a non-empty username. silenced_till and suspended_till are optional; each must be a string or null when present. Missing moderation fields mean the user is not in that state, as Discourse omits them for ordinary users. An empty object, mismatched identity, or invalid field type is an API response failure and blocks login.
| API field | Type | Interpretation |
|---|---|---|
silenced_till |
datetime or absent | Present only when user is silenced. If the key exists and is non-null, the user is silenced. If absent, the user is not silenced. |
suspended_till |
datetime or absent | Present only when user is suspended. If the key exists and is non-null, the user is suspended. Note: suspended users cannot complete SSO (Discourse blocks login), so this is defense-in-depth. |
Critical implementation note: The Discourse Admin API does not return boolean silenced or suspended fields. It returns silenced_till and suspended_till as datetime values only when the user is silenced/suspended. Validate the response identity and field types first, then check the presence and non-null value of these keys:
is_silenced = bool(api_data.get('silenced_till'))
is_suspended = bool(api_data.get('suspended_till'))
On any error (network, timeout, non-200, invalid JSON): Fail secure — reject login with "Could not verify security status with Discourse." All API errors are treated identically: the enrichment check cannot be completed, so access is denied. There is no distinction between network errors and HTTP errors. See Rejection Matrix R9.
Step 10: Policy enforcement¶
Evaluated in order; first match blocks login:
| Condition | Error message |
|---|---|
| User is silenced (from enrichment) | "Account blocked: Moderation (silenced)." |
| User is suspended (from enrichment, defense-in-depth) | "Account blocked: Moderation (suspended)." |
| User is flagged RTBF (from Step 8) | "Account blocked: Anonymized account." |
2FA has already been decided from the signed DiscourseConnect assertion in Step 7. The Admin API is used only for moderation status.
Step 11: User provisioning¶
Before creating or updating a pretix user, resolve every claimed organiser group to exactly one Pretix team under the configured organizer. If the organizer configuration is missing/invalid or a claimed team is missing/ambiguous, reject that organiser login. Do not silently skip a city or grant partial organiser access. A valid attendee with no organiser claims may still log in.
- Call pretix's
User.objects.get_or_create_for_backend('discourse', external_id, email, set_always={'fullname': name}, set_on_creation={}). - The method matches on
('discourse', external_id), not email. - On every login: updates
fullnameand email (the email update is hardcoded in pretix'sget_or_create_for_backend— it always setsemailviaset_always). - On first creation: no additional fields set.
- On
EmailAddressTakenError: Reject with "Email conflict: Another user with this email exists on a different auth backend." This is raised when the user's Discourse email matches an existing pretix user linked to a different auth backend. The email update cannot be skipped — it is hardcoded in pretix'sget_or_create_for_backend. Admin intervention is required to resolve the conflict.
Step 12: Pretix staff permission ownership¶
The plugin never reads a Forum staff group and never writes User.is_staff. Pretix administrators grant and revoke site-wide staff access through Pretix configuration. Discourse group claims cannot grant or remove this permission.
Step 13: Team sync (within a database transaction)¶
All team membership changes happen inside transaction.atomic():
Important: Team is scoped per-organizer. Pretix's Team model has a ForeignKey to Organizer. Team names are not globally unique — two organizers can have identically-named teams. All team queries must be scoped to the configured ORGANIZER (see Section 4.4 Configuration):
Add to teams:
- For each regex-captured city slug, dynamically resolve exactly one matching city team from the existing Ansible Meetup Organisers - {city} teams within the configured organizer. Missing or ambiguous matches reject login.
- Reject the organizer login before user provisioning if the organizer setting is missing/invalid or any group has no unique Pretix team.
- Query Pretix for resolved team identities within the configured organizer.
- Add the user to each found team (idempotent — team.members is a simple M2M; add() is safe to call repeatedly).
- A missing claimed team rejects login before user provisioning; no partial city access is granted.
Remove from stale teams:
- Managed teams use the fixed name prefix Ansible Meetup Organisers -; membership in matching city teams is reconciled on login.
- Example: "Ansible Meetup Organisers - {city}" → prefix "Ansible Meetup Organisers -"
- Find all teams the user belongs to within the configured organizer whose name starts with that prefix.
- Remove the user from any of those teams that are NOT in the expected set.
Result: On each login, the user's pretix team membership is made consistent with their current Discourse group membership, scoped to managed teams within the configured organizer.
Step 14: Login¶
- Call pretix's
process_login(request, user, keep_logged_in=False). - The session cookie is browser-scoped (expires on browser close). Pretix enforces its configured relative idle timeout and absolute session limit independently.
- Post-login redirect:
process_logincallsbackend.get_next_url(request)which usesrequest.GET.get("next"). Pretix validates this againsturl_has_allowed_host_and_scheme(next_url, allowed_hosts=None), which restricts to the current host — no open redirect risk (verified in pretix source).
3.3 Backend Visibility¶
The "Log in with Discourse" button appears on pretix's login page only if all of the following are true:
- DISCOURSE_URL is configured and uses HTTPS, or HTTP for localhost/loopback development.
- DISCOURSE_SECRET is configured (non-empty) and at least 32 characters.
- API_KEY is configured (non-empty).
If any condition is not met, the backend is invisible — pretix falls through to its other configured auth backends. This is the fail-secure posture: misconfiguration results in the plugin being disabled, not silently degraded.
3.4 Rejection Matrix¶
Every rejection scenario in the callback flow, consolidated in one table. Authentication and policy rejections redirect to the login page with the specified user-facing message. Rate-limit responses use HTTP 429, and rate-limiter service failures use HTTP 503. Log fields never include secrets, nonces, or full SSO payloads.
| ID | Step | Condition | User message | Log level | Log fields |
|---|---|---|---|---|---|
| R1 | 1 | Missing sso or sig parameter |
"Invalid response from Discourse." | WARNING | Client IP |
| R2 | 2 | HMAC signature mismatch | "Signature mismatch. Authentication failed." | WARNING | Client IP, truncated sig |
| R3 | 3 | Invalid base64 or UTF-8 in payload | "Could not decode Discourse response." | WARNING | Client IP |
| R4 | 3 | Payload contains failed=true |
"Discourse authentication failed or was cancelled." | INFO | Client IP |
| R5a | 4 | Nonce missing from session (session expired or new browser) | "Session expired or invalid nonce. Please try again." | WARNING | Client IP, cause: "missing" |
| R5b | 4 | Nonce mismatch (possible replay or tampering) | "Session expired or invalid nonce. Please try again." | WARNING | Client IP, cause: "mismatch" |
| R5c | 4 | Nonce expired (age > 10 min) | "Session expired or invalid nonce. Please try again." | WARNING | Client IP, nonce age in seconds, max age |
| R6 | 5 | Missing external_id or email |
"Incomplete identity data received." | WARNING | Client IP |
| R7 | 7 | Privileged user without signed confirmed_2fa=true |
"Discourse did not confirm two-factor authentication." | WARNING | external_id |
| R8 | 8 | Email ends with @anonymized.invalid |
"Account blocked: Anonymized account detected." | WARNING | external_id |
| R9 | 9 | Enrichment API error (network, timeout, non-200, invalid JSON, malformed identity/field data) | "Could not verify security status with Discourse. Please try again later." | ERROR | Exception type, URL (no API key), HTTP status if available |
| R10 | 10 | User is silenced (silenced_till present and non-null) |
"Account blocked: Moderation (silenced)." | WARNING | external_id |
| R11 | 10 | User is suspended (suspended_till present and non-null) |
"Account blocked: Moderation (suspended)." | WARNING | external_id |
| R12 | 11 | Claimed organiser group has no matching team | "Could not verify organiser access with Pretix. Please contact an administrator." | ERROR | Missing team names, organizer slug |
| R13 | 11 | EmailAddressTakenError during user provisioning |
"Email conflict: Another user with this email exists. Please contact an administrator." | WARNING | external_id, email |
| R14 | 9 | API returns 401/403 (bad/revoked API key) | Same as R9 | ERROR | Additionally: "API key may be invalid or revoked" |
| R15 | 1 | More than 10 weighted callback attempts from one client IP in 60 seconds | HTTP 429: "Too many login attempts. Please try again later." | WARNING | No IP or authentication payload data |
| R16 | 1 | Shared cache rate limiter unavailable | HTTP 503: "Login is temporarily unavailable." | ERROR | Exception type |
Design principle: Every rejection is logged. Every API/config error fails secure (denies access). There is no "silently continue with defaults" path — if a security check cannot be completed, access is denied.
4. Technical Architecture¶
4.1 System context¶
┌──────────────────────┐
│ Discourse │
│ (forum.ansible.com) │
│ │
SSO │ /session/sso_provider│
┌────────────┐ redirect ◄─────┤ │
│ │──────────────────► │ /admin/users/:id │
│ Browser │ │ (Admin API) │
│ │◄──────────────────► └──────────────────────┘
└────────────┘ redirect
│
│ HTTPS
▼
┌────────────────────────────────┐
│ Pretix │
│ ┌──────────────────────────┐ │
│ │ pretix-discourse-auth │ │
│ │ - DiscourseAuthBackend │ │
│ │ - return_view │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────┐ │
│ │ Django ORM │ │
│ │ - User │ │
│ │ - Team │ │
│ │ - Session │ │
│ └──────────────────────────┘ │
└────────────────────────────────┘
4.2 Tech stack¶
| Component | Technology | Notes |
|---|---|---|
| Runtime | Python 3.9+ | Match pretix's supported Python versions |
| Web framework | Django (via pretix) | Plugin does not choose the Django version; pretix does |
| HTTP client | requests |
For Discourse Admin API calls |
| Cryptography | Python stdlib hmac, hashlib, secrets, base64 |
No third-party crypto dependencies |
| Plugin framework | pretix plugin system | BaseAuthBackend, pretix.cfg config, entry points |
| Build system | setuptools + pretix-plugin-build |
Via pyproject.toml |
4.3 Plugin structure¶
pretix_discourse_auth/
__init__.py # Version, default_app_config
apps.py # Django AppConfig + PretixPluginMeta
backend.py # DiscourseAuthBackend (login initiation)
views.py # return_view (SSO callback + all business logic)
urls.py # Single route: _discourse/login/return/
signals.py # Empty (placeholder for future signal receivers)
locale/ # i18n translation files (de, de_Informal)
static/ # Empty (placeholder)
templates/ # Empty (placeholder)
4.4 Configuration¶
All configuration is read from pretix's pretix.cfg file (INI format) under the [discourse_auth] section. Values are loaded once at module import time. Config changes require a process restart.
| Key | Required | Default | Description |
|---|---|---|---|
url |
Yes | — | Discourse instance base URL. Must be HTTPS (HTTP is allowed only for localhost/loopback development). |
sso_secret |
Yes | — | Shared secret for DiscourseConnect HMAC signing. Minimum 32 characters. |
api_key |
Yes | — | Discourse Admin API key for moderation enrichment. Required and must work — missing credentials hide the backend; API request failures reject login. |
api_username |
No | system |
Discourse username for API requests. |
organizer |
Required for organiser claims | — | Pretix organizer slug. Team queries are scoped to this organizer. Attendee login does not require organizer team configuration. |
api_timeout |
No | 10 |
Timeout in seconds for Discourse Admin API calls; must be an integer from 1 to 60. |
Example pretix.cfg section:
[discourse_auth]
url = https://forum.ansible.com
sso_secret = <shared-secret-from-discourse-admin-min-32-chars>
api_key = <discourse-admin-api-key>
api_username = system
organizer = ansible-meetups
The auth plugin is city agnostic: exact ^meetup-organisers-([a-z]+)$ claims resolve dynamically to existing city Pretix teams within the configured organizer. There is no city allowlist or duplicated CITIES registry in the plugin. Adding a city requires updating the tooling registry and provisioning its Forum group and Pretix team; it does not require edits or a release in pretix-discourse-auth. Missing or ambiguous team matches reject organiser login before user provisioning. The hardcoded Pretix-only team Ansible Meetup Staff is the other privileged class; its membership is never synchronized from Discourse. require_2fa=true is always sent and cannot be disabled.
4.5 Data model¶
The plugin does not define its own database models. It operates on pretix's existing models:
| Model | How used | Verified details |
|---|---|---|
User |
Created/updated via get_or_create_for_backend(). Fields: email, fullname, Pretix-owned is_staff, auth backend linkage (discourse + external_id). |
is_staff is never changed by this plugin. |
Team |
Looked up by city slug scoped to an organizer (ForeignKey to Organizer). User added/removed via M2M members relation. The tooling provisioner creates and reconciles teams. |
Team names are not globally unique. |
Organizer |
Looked up by slug from config organizer key for organizer claims. |
Missing/invalid configuration blocks organizer claims only. |
| Django Session/cache | The session stores nonce and timestamp; a shared Django cache backend atomically prevents nonce replay across concurrent callbacks. Production deployments must use a cache shared by all Pretix workers. |
4.6 URL routing¶
| Method | Path | View | Name |
|---|---|---|---|
| GET | /_discourse/login/return/ |
return_view |
return |
The URL is registered under the plugins:pretix_discourse_auth namespace.
4.7 External API calls¶
| Endpoint | Method | Auth headers | When called | Timeout |
|---|---|---|---|---|
{DISCOURSE_URL}/session/sso_provider |
GET | None (HMAC in query params) | Login initiation (browser redirect) | N/A |
{DISCOURSE_URL}/admin/users/{external_id}.json |
GET | Api-Key, Api-Username |
Every SSO callback | Configurable (api_timeout, default 10s) |
5. Non-Functional Requirements¶
These are requirements that the prototype either skipped entirely or handled minimally. The rebuild must address them.
5.1 Configuration validation¶
The backend reads one validated settings object at module import time. Invalid Discourse URL, SSO secret, API credentials, or API timeout hides the backend. Missing or invalid Pretix organizer configuration blocks only organizer claims; valid attendee logins remain available when the Discourse security check succeeds.
| Requirement | Behavior on failure |
|---|---|
url must be HTTPS (or loopback HTTP for local development) |
Backend invisible. Log ERROR with the invalid setting. |
sso_secret must be ≥ 32 characters |
Backend invisible. Log ERROR: "SSO secret too short (minimum 32 characters)." |
api_key missing |
Backend invisible and an error is logged. |
| Organizer setting missing/invalid or configured organizer does not exist | Reject organizer claims before user provisioning; attendee claims may proceed. |
api_timeout is not an integer from 1 through 60 |
Backend invisible. |
| Admin API credentials rejected or unavailable | Login rejected; no user provisioning or session creation occurs. |
Privileged callback lacks confirmed_2fa=true |
Login rejected before user provisioning. |
| Claimed organiser city has no matching Pretix team | Login rejected before user provisioning. |
5.2 Logging¶
| Event | Level | Required fields |
|---|---|---|
| Login initiated | INFO | Return URL |
| SSO callback received | DEBUG | (No PII — just "callback received") |
| Rejection (any R1-R14) | Per Rejection Matrix | Per matrix |
| Identity extracted | INFO | external_id, username, organizer group count |
| Enrichment API call | DEBUG | URL (without API key), response status code |
| User provisioned (new) | INFO | external_id, email |
| User updated (existing) | DEBUG | external_id, fields changed |
| Team added | INFO | external_id, team name |
| Team removed | INFO | external_id, team name |
| Team not found or ambiguous | ERROR | Claimed city slugs and configured organizer |
| Config validation | ERROR or WARNING | Per Section 5.1 |
PII handling: Log external_id and username freely (they are public Discourse identifiers). Log email only on user creation. Never log the SSO payload, nonces, secrets, or API keys.
Note: Rejection logging details are defined in the Rejection Matrix and are not duplicated here.
5.3 Testing¶
The rebuild must include:
Unit tests (mocked external dependencies):
- Signature verification: valid, tampered payload, tampered signature, empty, missing, trailing whitespace.
- Nonce: valid, missing from session, mismatched, already consumed (replay), expired (>10 min), and future-dated.
- Payload decoding: valid, invalid base64, invalid UTF-8, missing fields, failed=true.
- Group parsing: exact meetup-organisers-london, malformed suffixes, unrelated groups, mixed case, and automatic Discourse groups.
- Dynamic city/team resolution: lowercase slug resolves to exactly one provisioned city team within the configured organizer, including a newly provisioned city absent from plugin source. The managed prefix must match exactly; normalize the display-name suffix with Unicode NFKD, retain only ASCII letters, then lowercase (New York → newyork, São Paulo → saopaulo). Missing teams, duplicate teams, or normalization collisions reject organizer login before user provisioning; no .title() derivation or city allowlist.
- 2FA enforcement: exact positive confirmed_2fa=true for city organisers and existing Pretix staff-team members; missing/false claim rejected.
- RTBF detection: email ending with @anonymized.invalid, normal email, edge cases.
- Enrichment API: HTTP 200 with correct fields, 200 with missing fields, 401, 403, 500, timeout, invalid JSON.
- Enrichment field parsing: silenced_till present vs absent and suspended_till present vs absent; each moderation reason is shown to the user and recorded distinctly in logs; malformed field values fail closed.
- Policy enforcement: each condition independently, combined conditions, privileged vs non-privileged.
- Team sync: add to matching city team, idempotent re-add, remove stale city teams, missing team rejects organizer login, organizer scoping; Pretix staff-team membership remains admin-managed.
- Pretix is_staff remains unchanged for every Forum group claim; existing members of Ansible Meetup Staff require the signed 2FA assertion.
- Rate limiting: the callback allows at most 10 requests per client IP in a 60-second window; signature and nonce failures have double weight; cache errors fail closed.
- Session policy: callback login disables Pretix's persistent-session option and uses a browser-session cookie; Pretix's configured relative and absolute session limits remain in force.
- UTF-8: signed payloads with non-ASCII display names are preserved; invalid UTF-8 payloads are rejected.
- Email conflict handling.
- Config validation: each validation rule independently.
Integration tests (with a real or stubbed Discourse): - Full round-trip: initiation → Discourse redirect → callback → logged in. - Changed email between logins. - Changed groups between logins. - Concurrent logins (two tabs).
Test framework: pytest + pytest-django (matches pretix's own test setup). Use responses or requests-mock for HTTP mocking.
5.4 Rate limiting¶
- Apply a fixed-window rate limit to the SSO callback: at most 10 weighted attempts per client IP per 60 seconds.
- Every callback request counts once. Signature mismatch and invalid, expired, future-dated, or replayed nonce checks count twice in total.
- Obtain the client IP through Pretix's trusted proxy-aware
get_client_ip()helper; do not trust arbitrary forwarded headers. - Store counters in the shared Django cache used by Pretix workers. If the cache is unavailable, fail closed with a temporary service error. Production deployments must use a cache backend with atomic increment support shared by every worker.
- Return HTTP 429 with
Retry-Afterwhen the limit is exceeded.
5.5 Internationalization¶
- Wrap all user-facing error messages in
gettext_lazy()(_(...)). - Provide English strings as the base.
- Include translation stubs for German (
de) and informal German (de_Informal).
5.6 Dependency management¶
| Dependency | Required action |
|---|---|
requests |
Declared in pyproject.toml for the Discourse Admin API client. |
pretix |
Do not add — it's the host application. Document the minimum supported pretix version. |
6. Assumptions and Edge Cases¶
6.1 Verified assumptions¶
These assumptions have been verified against Discourse and pretix source code.
| Assumption | Verification |
|---|---|
external_id in SSO provider mode is the Discourse user's internal database ID. |
Confirmed: sso.external_id = current_user.id.to_s in discourse_connect_provider.rb:80. |
groups payload is comma-separated group names. |
Confirmed: current_user.groups.pluck(:name).join(",") in discourse_connect_provider.rb:83. |
groups includes automatic Discourse groups. |
Confirmed: includes trust_level_0, staff, everyone, admins, etc. The plugin retains only full regex matches for meetup-organisers-[a-z]+. |
| Suspended users cannot complete SSO. | Confirmed: current_user returns nil for suspended users in default_current_user_provider.rb. The SSO provider redirects to login — they never reach pretix. |
| Silenced users CAN complete SSO. | Confirmed: silencing restricts posting, not authentication. The enrichment API check for silenced_till is the only defense. |
| Anonymized users cannot complete SSO. | Confirmed: UserAnonymizer destroys single_sign_on_record, oauth2_user_infos, user_associated_accounts, api_keys, user_auth_tokens. The user cannot log in to Discourse at all. |
Anonymized email domain is @anonymized.invalid. |
Confirmed: EMAIL_SUFFIX = "@anonymized.invalid" in user_anonymizer.rb:6. |
Anonymized username pattern is anon + digits. |
Confirmed: anon#{(SecureRandom.random_number * 100_000_000).to_i} in user_anonymizer.rb:108. |
team.members is a simple M2M. |
Confirmed: members = models.ManyToManyField(User, related_name="teams") in pretix organizer.py:377. No through table. add()/remove() are idempotent. |
| Team is scoped per-organizer, NOT globally unique by name. | Confirmed: Team has ForeignKey to Organizer. No unique constraint on name. Two organizers can have identically-named teams. |
get_or_create_for_backend always force-updates email. |
Confirmed: set_always.update({'email': email}) is hardcoded in pretix auth.py:136. Cannot be skipped by the caller. |
is_staff grants site-wide admin access. |
Confirmed: gates the staff_member_required decorator in pretix permissions.py:153. |
process_login validates next parameter. |
Confirmed: uses url_has_allowed_host_and_scheme(next_url, allowed_hosts=None) which restricts to the current host. No open redirect risk. |
require_2fa=true works in SSO provider mode. |
Confirmed: DiscourseConnect returns confirmed_2fa=true after a successful challenge. Privileged pretix login requires that signed positive assertion. |
6.2 Remaining assumptions (not yet verified)¶
| Assumption | Risk if wrong |
|---|---|
| The provisioner owns group/team creation. | The tooling script creates and reconciles them from the registered city list; the auth plugin only validates and synchronizes membership. |
pretix.cfg is the only config source. Module-level config.get() reads from the INI file. |
Environment variable overrides or secrets managers are not supported. |
| One Discourse instance per pretix deployment. Config is global. | Multi-tenant deployments are not supported (by design — see Q4). |
The return_sso_url in the SSO payload is trusted by Discourse. |
Discourse must be configured to accept redirects to the pretix domain. |
6.3 Edge cases¶
| Edge case | Behavior | Risk level |
|---|---|---|
| User changes email in Discourse between logins. | get_or_create_for_backend matches on ('discourse', external_id), not email. Email is force-updated. If the new email is already claimed by another pretix user, EmailAddressTakenError blocks login (R13). |
MEDIUM — user is locked out until an admin resolves the conflict. Cannot be changed without patching pretix. |
| User's display name is empty and username is also empty. | name falls back to username, which is ''. User is created with empty fullname. |
LOW — cosmetic. |
| Discourse returns duplicate group names. | Groups are collected into a set; duplicates are eliminated. |
NONE — handled. |
| Two users log in simultaneously, both added to the same team. | team.members.add() is idempotent. No conflict. |
NONE — handled. |
Privileged SSO response omits confirmed_2fa. |
Login is rejected before user provisioning. | NONE — fail-closed. |
Discourse API returns 200 but silenced_till or suspended_till is absent. |
Missing moderation fields mean the user is not in that moderation state. The response must still include a matching user ID and a non-empty username; malformed identity or field types block login. | NONE — follows the Admin API response shape while rejecting malformed responses. |
| Team is renamed in pretix after user was added. | On next login, old team name won't match expected; removal happens for the old name. The renamed team (no longer matching managed prefix) keeps the user as orphaned membership. | LOW — edge case during team administration. |
| Team with same name exists in a different organizer. | Team queries are scoped to the configured organizer. No cross-organizer match. |
NONE — handled by organizer scoping. |
| A matching organiser group exists without a Pretix team. | Login is rejected before creating/updating the Pretix user. | NONE — fail-closed. |
6.4 Known limitations¶
6.4.1 No session invalidation on status change¶
If a user is silenced in Discourse, their existing pretix sessions remain valid. The block only applies on next login attempt. A silenced user with an active session continues to have full access until the session expires.
Future improvement: On each enrichment check that detects silenced status, invalidate the user's active sessions via Django's session framework. Alternatively, implement a middleware that periodically re-checks status.
6.4.2 Login-time-only sync¶
Group → team sync only happens at login. If a user is removed from a Discourse group, they retain pretix team membership until their next login.
Future improvement: Subscribe to Discourse webhook events for user/group changes, or implement a periodic sync management command.
6.4.3 Email conflict requires admin intervention¶
When get_or_create_for_backend raises EmailAddressTakenError, the user is locked out. The email update is hardcoded in pretix and cannot be skipped by the plugin. Resolution requires an admin to either change the conflicting user's email in pretix or merge accounts.
7. Risks¶
7.1 Operational risks¶
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Discourse outage blocks all pretix logins | Medium | High — no organizer can log in | Both the SSO flow and the enrichment API depend on Discourse. Document this dependency. Consider a break-glass local admin account. |
| Discourse API key is rotated without updating pretix config | Medium | High — enrichment fails, all users fail-closed (R9) | Monitoring/alerting on enrichment API errors (R14 specifically). Document the key rotation procedure. |
| SSO secret mismatch after rotation | Low | High — all logins fail with signature mismatch (R2) | Coordinate secret rotation procedure. Consider supporting dual secrets during rotation. |
| Pretix team missing or duplicated for a claimed city | High | High — login is rejected before user provisioning | Provisioning creates/reconciles every registered city team. The auth plugin fails closed if an organizer claim has no unique matching team. |
| Config changes require process restart | Guaranteed | Low | Document clearly. |
7.2 Security risks¶
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| SSO payload logged in web server access logs (GET params contain PII) | High | Medium — email/username in access logs | Inherent to the DiscourseConnect protocol (GET-based). Configure web server to avoid logging query strings for the callback URL. Ensure log rotation and access controls. |
| Unapproved organiser group claim | Low | Medium — access to a matching city team | Forum admins manage organiser membership; the plugin requires an exact group name and a unique corresponding team. |
| Silenced user accesses pretix between silencing and next login | Medium | Low — silenced users have limited Discourse permissions but may still have pretix team access | Acceptable given login-time-only sync. Future: session invalidation or webhook-driven sync. |
8. Design Decisions¶
All design decisions are resolved. No open questions remain.
Q1: Pretix staff permissions — DECIDED¶
Pretix administrators alone grant or revoke is_staff through Pretix configuration and manage membership of the Pretix-only Ansible Meetup Staff team. The Discourse auth plugin does not consume a Forum staff group, does not inspect Discourse's admin flag, and does not mutate Pretix staff status. Members of the staff team must pass DiscourseConnect's signed 2FA assertion. Create a Pretix break-glass administrator before enabling the SSO backend.
Q2: API failure mode — DECIDED¶
Decision: Fail secure for all users (option a).
When the Discourse Admin API enrichment call fails for any reason (network error, timeout, non-200 HTTP status, invalid JSON, or malformed identity/field data), reject the login for all users. This API check verifies silenced and suspended status; privileged 2FA is independently enforced through DiscourseConnect's signed confirmed_2fa=true assertion.
Rationale: The "fail secure" principle takes precedence over availability. With require_2fa handled at the protocol layer (Step 7), the enrichment call's primary remaining purpose is the silenced/suspended check. A silenced user who gains pretix access during an API outage could access personal data (names, emails) of event attendees. This risk outweighs the inconvenience of temporary login unavailability during a Discourse API outage.
Mitigation for availability: Maintain a break-glass local admin account (created via python manage.py createsuperuser) that does not use Discourse SSO. This provides emergency access during Discourse outages.
Q3: Team provisioning — DECIDED¶
The tooling provisioning script creates and reconciles Forum groups, categories, city teams, and the Pretix-only Ansible Meetup Staff team. For a new city, operators update the tooling repo's explicit CITIES record, deploy that change, then run provisioning. The auth plugin resolves city teams dynamically and requires no city-specific edits or release. The script owns team permissions and event scope; Pretix admins alone manage staff-team members. The auth plugin rejects organizer claims unless the corresponding city team exists under the configured Pretix organizer. Attendee logins do not require a city team.
Q4: Multi-tenancy — DECIDED¶
Decision: Single pretix instance, single Discourse instance.
All configuration is global in pretix.cfg. Team queries are scoped to a single configured organizer. Per-organizer configuration is out of scope.
Q5: RTBF detection — DECIDED¶
Decision: Email-domain heuristic only, as defense-in-depth.
Constant: RTBF_EMAIL_SUFFIX = "@anonymized.invalid"
RTBF detection checks only whether the user's email ends with @anonymized.invalid — the email suffix Discourse assigns during anonymization (verified in source: UserAnonymizer::EMAIL_SUFFIX).
Username and display name heuristics are not used — they produce false positives for legitimate usernames (anonym, anondale, users named "Anonymous").
Rationale: Discourse's anonymization process destroys all auth records (SSO records, OAuth tokens, API keys, auth tokens — verified in user_anonymizer.rb). An anonymized user cannot log in to Discourse at all, which means they cannot complete the SSO flow. The email-domain check is therefore pure defense-in-depth — it catches the theoretically impossible case where an anonymized user somehow reaches pretix's callback. The single strongest signal (email domain) is sufficient for this purpose. Zero false positives for legitimate users.
9. Deployment Checklist (Human-in-the-Loop)¶
These items require human decisions or actions before the plugin can be deployed. They cannot be resolved by code alone.
9.1 Discourse configuration (forum admin must complete)¶
- [ ] Enable DiscourseConnect Provider in Discourse admin → Settings → Login. The site setting
enable_discourse_connect_providermust betrue. - [ ] Set the SSO secret in Discourse to match the
sso_secretinpretix.cfg. Minimum 32 characters, cryptographically random. - [ ] Restrict group creation to Discourse admins. A user who can create a matching organiser group for an existing Pretix team can claim that team's access.
- [ ] Run the tooling provisioner to create Forum groups and Pretix teams from its explicit city registry.
- [ ] Generate a Discourse Admin API key with "All Users" scope. Record it for
pretix.cfg. - [ ] Verify
groupsis in the DiscourseConnect provider claims. In Discourse admin → Settings → DiscourseConnect, check thatdiscourse_connect_provider_claimsincludesgroups. Without this, the SSO payload will not include group memberships and team sync will silently produce no matches.
9.2 Pretix configuration (pretix admin must complete)¶
- [ ] Create a break-glass local admin account via
python manage.py createsuperuser. This provides emergency access if Discourse is unavailable. - [ ] Determine the organizer slug — run
Organizer.objects.values_list('slug', flat=True)in the pretix shell to list available organizers. Set this asorganizerinpretix.cfg. - [ ] Confirm the tooling provisioner created the city teams and scoped their permissions.
- [ ] Configure
pretix.cfgwith all required values. See Appendix C. - [ ] Restart pretix after config changes (config is loaded at module import time).
9.3 Verification steps¶
- [ ] Confirm the "Log in with Discourse" button appears on the pretix login page. If it doesn't, check
pretix.logfor ERROR-level config validation messages. - [ ] Confirm Pretix admin configuration controls
is_staffand only Pretix admins manage membership inAnsible Meetup Staff. - [ ] Test login with a user in
meetup-organisers-london— verify team membership. - [ ] Test a matching organiser group without a Pretix team — login must fail before user provisioning.
- [ ] Test a privileged response without
confirmed_2fa=true— login must be rejected. - [ ] Verify the Discourse Admin API key works by checking for ERROR-level "API key may be invalid" messages in logs.
9.4 Web server configuration¶
- [ ] Configure the web server to suppress query string logging for the callback URL (
/_discourse/login/return/). ThessoGET parameter contains base64-encoded PII (email, username, group memberships). This is inherent to the DiscourseConnect protocol and cannot be changed. - [ ] Ensure HTTPS is enforced for the pretix domain (TLS termination at the proxy/load balancer).
9.5 Open security considerations for operator review¶
These are security tradeoffs the operator should understand and accept:
-
Fail-secure posture: If the Discourse Admin API is unreachable (network issues, Discourse downtime, API key rotation), all logins are blocked. This is by design — security over availability. The break-glass local admin account provides emergency access. If this tradeoff is unacceptable, the code must be modified.
-
Pretix staff ownership:
is_staffis controlled in Pretix configuration only. This plugin never grants or revokes it based on Forum claims. -
Email conflict lockout: If a user changes their Discourse email to one already claimed by another pretix user on a different auth backend, they are locked out (
EmailAddressTakenError). This is a pretix limitation — the email update is hardcoded and cannot be skipped by the plugin. Resolution requires admin intervention. -
Login-time-only sync: Organizer team membership syncs at login. A user removed from an organizer group retains team access until their next login or manual removal in Pretix. Pretix staff-team and
is_staffmembership remain administrator-managed. -
Session lifetime: Discourse SSO sessions use a three-hour idle timeout and disable Pretix's "keep me logged in" behavior. The current Pretix version also enforces a 12-hour absolute limit in Pretix core. The organizer API used by
provision_environment.pycannot change that server-side limit; changing it requires a Pretix server override. -
Silenced users with active sessions: A user silenced in Discourse after their last pretix login retains their pretix session. The silenced check only runs during the enrichment call at login time. Same future improvement as item 4.
Appendix A: DiscourseConnect Protocol Reference¶
This plugin implements the consumer side of the DiscourseConnect protocol. Discourse is the provider (identity provider). See Section 1 (Terminology) for definitions.
Protocol flow:
1. The consumer (pretix) constructs a nonce + return_sso_url payload, base64-encodes it, HMAC-signs it, and redirects the user's browser to Discourse.
2. The provider (Discourse) authenticates the user, constructs a response payload with identity data, base64-encodes it, HMAC-signs it, and redirects the user's browser back to return_sso_url.
3. The consumer verifies the signature, decodes the payload, and extracts the identity.
Request payload fields (consumer → provider):
| Field | Description |
|---|---|
nonce |
Single-use random token for replay protection |
return_sso_url |
Callback URL for Discourse to redirect to |
require_2fa |
If true, Discourse prompts the user for 2FA before redirecting back |
Response payload fields (provider → consumer):
| Field | Type | Description |
|---|---|---|
nonce |
string | Echoed from request |
external_id |
string | Discourse internal user ID (user.id). Verified in source: sso.external_id = current_user.id.to_s |
email |
string | User's email |
username |
string | User's handle |
name |
string | Display name |
admin |
string | "true" if Discourse admin (not used by this plugin) |
moderator |
string | "true" if Discourse moderator |
groups |
string | Comma-separated group names. Includes all groups — custom groups AND automatic groups (trust_level_0, staff, everyone, etc.). Verified in source: current_user.groups.pluck(:name).join(",") |
confirmed_2fa |
string | "true" if user passed 2FA challenge (only present when require_2fa=true was sent) |
no_2fa_methods |
string | "true" if user has no 2FA methods configured (only present when require_2fa=true was sent) |
avatar_url |
string | CDN URL for avatar |
Key protocol properties: - All data transits via browser redirects (GET parameters). - Integrity is protected by HMAC-SHA256 with a pre-shared secret. - Confidentiality depends on HTTPS — the payload is base64-encoded, not encrypted. - Replay protection depends on the consumer implementing nonce verification. - Discourse expires nonces after 30 minutes on its side.
Implementation note: The sso query parameter value received by Django may contain a trailing newline. Do not strip whitespace from request.GET.get('sso') before HMAC computation — doing so breaks signature verification.
References: - DiscourseConnect protocol: https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045 - Discourse as provider: https://meta.discourse.org/t/use-discourse-as-an-identity-provider-sso-discourseconnect/32974
Appendix B: Pretix Plugin System Reference¶
Verified against pretix source code.
Registration: Plugins are registered via pyproject.toml entry points under pretix.plugin. The entry point value points to the PretixPluginMeta class.
Auth backends (pretix.base.auth.BaseAuthBackend): Required overrides: identifier (property/string), verbose_name (property/string). Optional: visible (property, default True), login_form_fields, form_authenticate, request_authenticate, authentication_url(request) (returns redirect URL or None), get_next_url(request).
Config: pretix.settings.config is a ConfigParser instance reading from pretix.cfg.
User creation (pretix.base.models.auth):
User.objects.get_or_create_for_backend(backend, identifier, email, set_always={}, set_on_creation={}) — matches on (backend, identifier). Always force-updates email via set_always (hardcoded at line 136). Raises EmailAddressTakenError if the new email collides with a user on a different backend.
Team model (pretix.base.models.organizer):
Team has a ForeignKey to Organizer (line 375). name is CharField(max_length=190) with no uniqueness constraint. members = ManyToManyField(User, related_name="teams") — simple M2M, no through table.
Login (pretix.control.views.auth):
process_login(request, user, keep_logged_in=bool) — establishes Django session. Redirects to backend.get_next_url(request), which defaults to request.GET.get("next"), validated by url_has_allowed_host_and_scheme(next_url, allowed_hosts=None).
Staff access (pretix.base.models.auth):
is_staff = BooleanField(default=False, verbose_name='Is site admin'). Gates staff_member_required decorator (line 153 in permissions.py). Grants access to site-wide admin views across all organizers.
Appendix C: Config quick-reference¶
[discourse_auth]
# REQUIRED — backend will not appear on login page if any are missing
url = https://forum.ansible.com
sso_secret = <shared-secret-from-discourse-admin-minimum-32-chars>
api_key = <discourse-admin-api-key>
organizer = ansible-meetups
# OPTIONAL (defaults shown)
api_username = system
api_timeout = 10
Canonical documentation location¶
This specification is maintained in the Ansible Meetup documentation repository. The plugin remains an independent Python package in ansible-community/meetup-pretix-discourse-auth; its code and release process are not part of this documentation project.