11 KiB
Repixelizer Hosted Auth Architecture
Summary
The hosted Repixelizer demo should support both:
- Patreon-backed access for supporters who do not use Discord
- Discord role-backed access for supporters or community members who do
Access should be granted if either entitlement source says yes.
That means we do not build two separate protected apps, two separate queue paths, or two separate user models. We build one local account system with multiple linked identities and one access policy.
Goals
- Let a user sign in with either Discord or Patreon
- Grant access if the user:
- has one of the allowed Discord roles in the GameCult server, or
- has an allowed Patreon entitlement
- Allow optional account linking so one human can attach both providers to one local account
- Keep queue ownership, job cancellation, and hosted limits tied to one local user/session model
- Avoid duplicated entitlement logic spread across unrelated login handlers
Non-goals
- Turning Repixelizer into a general identity provider
- Requiring every Patreon supporter to join Discord
- Requiring every Discord member to have Patreon connected
- Complex billing state history or subscription analytics
- Multi-tenant auth machinery
External providers
Discord
Use Discord OAuth2 authorization code flow for login.
Minimum use:
- identify the user
Recommended access pattern:
- log the user in with Discord OAuth
- use the existing GameCult bot token server-side to inspect that user's guild membership and role ids
Why this path:
- role truth already lives in Discord
- the Patreon bot already manages supporter roles there
- role checks stay anchored to the same operational source of truth
- the web app does not need to ask the user for broader guild scopes unless we later decide it is necessary
If needed, we can still support the OAuth guilds.members.read path later. It is not the preferred first cut.
Patreon
Use Patreon OAuth2 authorization code flow for login.
After login:
- fetch the logged-in user's identity and membership data
- inspect membership to the GameCult campaign
- inspect current entitled tiers or equivalent supporter status fields
This path exists specifically for the "I want access to Repixelizer and I do not care about Discord" crowd.
Core model
Local user
Repixelizer should have one internal user record that is independent of any provider.
Suggested shape:
usersidcreated_atlast_seen_atdisplay_nameprimary_emailnullableaccess_statecached summary, optional
Linked identities
Each local user may have zero or more linked external identities.
linked_identitiesiduser_idproviderenum:discord,patreonprovider_user_idusernameaccess_token_encryptedrefresh_token_encryptedtoken_expires_atprofile_json- unique on
(provider, provider_user_id)
Important invariant:
- one provider identity can belong to only one local user
Entitlement snapshots
Do not make every page hit go out to Discord or Patreon. Cache the last evaluated entitlement state locally.
entitlement_snapshotsiduser_idproviderevaluated_atis_allowedreason_codereason_detailraw_summary_json
Examples:
- Discord provider says
is_allowed = true,reason_code = allowed_role - Patreon provider says
is_allowed = false,reason_code = no_active_membership
Sessions
Hosted GUI sessions should be local application sessions, not raw provider tokens sprayed into the browser.
sessionsiduser_idcreated_atlast_seen_atexpires_ataccess_grantedaccess_reason
Store the session id in an HttpOnly secure cookie.
Access policy
This is the heart of it.
Authentication answers:
- who is this user
Entitlement answers:
- should this user get through the gate
Those are different questions and should stay different in code.
Provider-level entitlement checks
Implement two providers:
DiscordRoleEntitlementProviderPatreonEntitlementProvider
Each provider returns something like:
EntitlementResult {
provider: "discord" | "patreon"
allowed: boolean
reason_code: string
reason_detail: string
checked_at: datetime
}
Unified policy
Implement one access policy:
access = discord_allowed || patreon_allowed
That is the only rule that should matter to the hosted demo.
Everything else is explanation and caching.
Allowed sources
Discord allowed
Grant access if the user has at least one configured allowed role in the configured guild.
Examples:
- Patreon supporter role
- founder role
- moderator role
- special alpha-tester role
Keep the role list environment-configured, not hardcoded in some grimy branch.
Patreon allowed
Grant access if the user has at least one allowed Patreon entitlement.
Examples:
- any paid membership
- one or more specific tiers
Keep this configurable too.
Login and linking flows
Flow A: Login with Discord
- User clicks
Log in with Discord - Complete Discord OAuth
- Find or create linked identity
(discord, discord_user_id) - Attach to existing local user if already linked
- Otherwise create a new local user and attach the identity
- Evaluate entitlements
- Create local session with access granted or denied
Flow B: Login with Patreon
- User clicks
Log in with Patreon - Complete Patreon OAuth
- Find or create linked identity
(patreon, patreon_user_id) - Attach to existing local user if already linked
- Otherwise create a new local user and attach the identity
- Evaluate entitlements
- Create local session with access granted or denied
Flow C: Link second provider
Once logged in, the user may choose Link Discord or Link Patreon.
Rules:
- linking requires an authenticated local session
- linking a provider already attached to another local user is rejected
- after linking, re-run unified entitlement evaluation
This turns "Patreon-only supporter later joins Discord" and "Discord member later subscribes on Patreon" into boring account maintenance instead of support tickets from hell.
Queue and job integration
The hosted queue should attach jobs to local sessions and local users, not directly to provider ids.
Suggested additions:
jobs.user_idjobs.session_idjobs.entitlement_checked_at
Rules:
- only authenticated sessions with
access_granted = truemay enqueue jobs - queue position and active job state are scoped to the local user session
- heartbeat and cancel behavior stay exactly as already designed
If a user loses entitlement mid-session:
- do not allow new jobs
- queued jobs may be canceled on next entitlement refresh
- active job policy can be either:
- let current job finish, block future jobs
- or cancel active job immediately
Recommended first cut:
- let current active job finish
- reject future submissions
- cancel queued-but-not-started jobs after entitlement loss
That is less spiteful and avoids turning transient API issues into ruined runs.
Entitlement refresh strategy
Do not ask Discord and Patreon on every request like a lunatic.
Use a cached snapshot with refresh triggers.
Refresh on:
- successful login
- provider link
- explicit
refresh accessaction - first protected request after snapshot TTL expires
Recommended TTL:
- 10 to 15 minutes
Discord refresh
Fetch guild membership and roles using the bot token and configured guild id.
If the user is not in the guild, the Discord entitlement is false.
Patreon refresh
Refresh via Patreon API using the stored Patreon identity and tokens.
If the membership lookup fails because tokens are stale, attempt refresh.
If refresh fails, mark Patreon entitlement unknown or false depending on the failure mode.
Recommended behavior:
- provider outage should not instantly hard-lock already active users if a recent positive snapshot exists
- use a short grace window for transient provider failures
Configuration
Suggested environment variables:
-
REPIXELIZER_AUTH_ENABLED=1 -
REPIXELIZER_SESSION_SECRET=... -
REPIXELIZER_BASE_URL=https://repixelizer.gamecult.org -
REPIXELIZER_DISCORD_CLIENT_ID=... -
REPIXELIZER_DISCORD_CLIENT_SECRET=... -
REPIXELIZER_DISCORD_BOT_TOKEN=... -
REPIXELIZER_DISCORD_GUILD_ID=... -
REPIXELIZER_DISCORD_ALLOWED_ROLE_IDS=role1,role2,role3 -
REPIXELIZER_PATREON_CLIENT_ID=... -
REPIXELIZER_PATREON_CLIENT_SECRET=... -
REPIXELIZER_PATREON_CAMPAIGN_ID=... -
REPIXELIZER_PATREON_ALLOWED_TIER_IDS=tier1,tier2 -
REPIXELIZER_ENTITLEMENT_CACHE_TTL_SECONDS=900 -
REPIXELIZER_PROVIDER_FAILURE_GRACE_SECONDS=3600
UI behavior
The hosted landing state should show:
Log in with DiscordLog in with Patreon
If access is denied after login:
- say why in plain English
- show what qualifies
- offer linking the other provider if the user is logged in locally
Examples:
Your Discord account is signed in, but you do not have one of the allowed GameCult roles.Your Patreon account is signed in, but no active Repixelizer-eligible membership was found.Already a supporter on the other platform? Link it here.
Once allowed:
- the queue UI and hosted GUI behave normally
Security notes
- Use authorization code flow for both providers
- Use
stateproperly for both OAuth flows - Store provider tokens server-side only
- Encrypt stored refresh/access tokens at rest
- Use secure, HttpOnly session cookies
- Keep local session invalidation separate from provider logout
- Audit log link/unlink, login, entitlement refresh, and submission denial events
Recommended implementation order
Phase 1: Local auth scaffolding
- add local user, linked identity, session, and entitlement snapshot models
- add session cookie handling
- add protected-session middleware/helpers
Phase 2: Discord login and entitlement
- implement Discord OAuth login
- implement bot-backed guild role entitlement check
- gate the hosted queue behind Discord-based access
Phase 3: Patreon login and entitlement
- implement Patreon OAuth login
- implement Patreon membership/tier entitlement check
- unify access policy across both providers
Phase 4: Identity linking
- allow linking the second provider
- re-run entitlement evaluation on link/unlink
Phase 5: Operational hardening
- add entitlement refresh TTLs and provider-failure grace behavior
- add UI for denied states and manual refresh
- add audit logs and admin diagnostics
Invariants
Keep these sacred:
- login provider is not the same thing as entitlement provider
- the queue only knows local users and local sessions
- access is granted if either provider says yes
- provider-specific logic stays isolated behind entitlement providers
- linking identities must never merge two existing local users implicitly
If we violate those rules, the auth system will become a damp little pit of edge cases almost immediately.