RFC: Unified Authentication & Authorization System #14
Replies: 1 comment
-
14. Open Questions
15. Risk Assessment
16. Decision Log
|
Beta Was this translation helpful? Give feedback.
-
14. Open Questions
15. Risk Assessment
16. Decision Log
|
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Unified Authentication System for WXYC
Status: Draft
Author: Jake
Date: 2026-02-27
1. Problem Statement
WXYC's authentication is fragmented across four dimensions:
editor,webmaster) are defined as TypeScript types in@wxyc/sharedbut have no database table, no JWT claim, and no assignment mechanism.Goals
Non-Goals
2. Current State
Auth Infrastructure
Backend-Service is the identity provider for first-party services. It uses better-auth with email+password authentication, JWT access tokens, and a JWKS endpoint at
/auth/jwks.graph TB subgraph "Auth Service (port 8082)" BA[better-auth] JWKS["/auth/jwks"] BA --> JWKS end subgraph "Consumers" DJ[dj-site] AR[archive] end DJ -- "session cookie" --> BA AR -- "JWT via JWKS" --> JWKS subgraph "No Auth" ROM[request-o-matic] LML[library-metadata-lookup] WAS[wxyc-archive-search] WEB[website] IOS[wxyc-ios-64] AND[WXYC-Android] end subgraph "Third-Party (isolated accounts)" PH[PostHog] SE[Sentry] CF[Cloudflare] AC[AzuraCast] end subgraph "Third-Party (shared passwords)" FB[Facebook / Instagram] BSKY[Bluesky] end style ROM fill:#f96,stroke:#c00 style LML fill:#f96,stroke:#c00 style WAS fill:#f96,stroke:#c00 style WEB fill:#f96,stroke:#c00 style IOS fill:#eee,stroke:#999 style AND fill:#eee,stroke:#999 style PH fill:#eee,stroke:#999 style SE fill:#eee,stroke:#999 style CF fill:#eee,stroke:#999 style AC fill:#eee,stroke:#999 style FB fill:#c00,stroke:#900,color:#fff style BSKY fill:#c00,stroke:#900,color:#fffGaps
@wxyc/shared/auth-clientJWT Payload (current)
{ "sub": "user-id", "id": "user-id", "email": "dj@wxyc.org", "role": "dj", "iat": 1739600000, "exp": 1739603600 }JWT Verification (current)
Implemented ad hoc in
archive/lib/jwt-utils.ts(~50 lines ofjosecalls). Every new TypeScript consumer would duplicate this. Python services have no JWT verification at all.Role Model (current)
The current role hierarchy conflates organizational leadership with technical access:
Two cross-cutting capabilities (
editor,webmaster) are defined inwxyc-shared/src/auth-client/capabilities.tsas pure types with a delegation chain, but none of this is persisted or enforceable.In-Progress Work:
adminRoleThe
fix/add-admin-role-to-wxyc-rolesbranch addsadminas a peer tostationManagerwith identical permissions. It fixes a 403 regression where users withrole="admin"(assigned by better-auth's organization hooks) were rejected byrequirePermissionsmiddleware. The branch also removes thenormalizeRolefunction in favor of direct role lookup. This proposal'ssuperAdminrole subsumes the branch'sadmin— see Section 3 for the reconciliation path.3. Proposed Role Model
Core Change: Separate Organizational Authority from Technical Authority
The station manager is always a student, typically a humanities major, elected by the board. They should manage people and programming — not infrastructure. A new
superAdminrole handles all technical operations. Per-service named identities in a separateServiceRolesconstant handle machine-to-machine authentication.Role Hierarchy
graph TD SA[superAdmin] SM[stationManager] MD[musicDirector] DJ[dj] M[member] SA -->|inherits all of| SM SM -->|inherits all of| MD MD -->|inherits all of| DJ DJ -->|inherits all of| M SA -.- SAD["iddqd mode<br/>All permissions<br/>Infrastructure access<br/>Create/modify roles<br/>Assign any role or capability"] SM -.- SMD["Account management<br/>Assign roles: dj, musicDirector<br/>Transfer stationManager<br/>Assign capabilities: editor, webmaster<br/>Roster management<br/>No infrastructure access"] MD -.- MDD["Catalog write<br/>Bin write<br/>Flowsheet write"] DJ -.- DJD["Flowsheet write<br/>Catalog read<br/>Bin read/write"] M -.- MD2["Catalog read<br/>Flowsheet read<br/>Bin read/write"] subgraph SVC["Service Identities (not in hierarchy)"] ROM_R["request-o-matic"] LML_R["library-metadata-lookup"] end ROM_R -.- SVCD["No resource permissions<br/>Identity only<br/>Bypasses role hierarchy<br/>Per-service named identity"] style SA fill:#b91c1c,color:#fff style SM fill:#7c3aed,color:#fff style MD fill:#2563eb,color:#fff style DJ fill:#059669,color:#fff style M fill:#6b7280,color:#fff style ROM_R fill:#d97706,color:#fff style LML_R fill:#d97706,color:#fffService identities are not part of the hierarchy. Each service gets its own named role (e.g.,
request-o-matic,library-metadata-lookup) in a separateServiceRolesconstant. Their presence in the JWT identifies the caller as a specific trusted internal service, not an arbitrary internet client.stationManager Mutual Exclusivity
The
stationManagerrole is mutually exclusive — only one user can hold it at a time. This reflects the real-world structure: there is one station manager at WXYC at any given time, elected by the staff.Transfer rules:
stationManagerto a new user atomically revokes it from the current holder.stationManager(self-transfer) andsuperAdmincan initiate the transfer.Implementation: The role assignment endpoint enforces this constraint:
Role Permissions Matrix
Service identities have no resource permissions and are not included in this matrix. They are identity assertions, not authorization grants. Services that need to perform authorized operations on behalf of a user forward the user's context (see Section 8).
Capabilities (Cross-Cutting)
Capabilities are independent of the role hierarchy. Any user with any role can be granted a capability.
editorwebmasterAssignment Rules
graph LR SA[superAdmin] -->|can assign| ANY[any role + any capability] SM[stationManager] -->|can assign| R1[dj] SM -->|can assign| R2[musicDirector] SM -->|can transfer| R3[stationManager] SM -->|can assign| C1[editor] SM -->|can assign| C2[webmaster] SM -.-x|cannot assign| SA2[superAdmin] WM[user with webmaster] -->|can assign| C3[editor] style SA fill:#b91c1c,color:#fff style SM fill:#7c3aed,color:#fff style SA2 fill:#b91c1c,color:#fff,stroke-dasharray: 5 5The
superAdminrole is reserved for the tech team. It is never assigned through the dj-site UI; it is assigned directly in the database or via an admin CLI.Adding
superAdminto WXYCRoles and Defining ServiceRolesThe middleware resolves roles from both objects:
Human roles go through
authorize()for permission checks. Service roles bypass it — they are identity assertions, not authorization grants.Reconciliation with
fix/add-admin-role-to-wxyc-rolesBranchThe existing branch adds
adminas a peer tostationManagerwith identical permissions. To reconcile:normalizeRole, direct role lookup inWXYCRoles.adminwithsuperAdmininWXYCRoles—superAdminhas the sameadminAc.statementsbase but addsinfrastructureandrolesresources.adminRolessync inauth.definition.ts— mapsuperAdmintouser.role = 'admin'for better-auth's admin plugin.creatorRoleto"member"(not the default"owner") and add anonMemberRoleUpdatehook that rejects any role not inWXYCRoles. This preventsadmin/ownerfrom ever being written to themember.rolecolumn. Add a contract test asserting that everymember.rolevalue in the DB is a validWXYCRole.stationManageras the org-management role without infrastructure access.4. JWT Claims
User JWT Payload (proposed)
{ "sub": "user-id", "id": "user-id", "email": "dj@wxyc.org", "role": "dj", "capabilities": ["editor"], "org": "wxyc", "iat": 1739600000, "exp": 1739603600 }Service JWT Payload
{ "sub": "service-request-o-matic", "id": "service-request-o-matic", "email": "request-o-matic@services.wxyc.org", "role": "request-o-matic", "capabilities": [], "org": "wxyc", "iat": 1739600000, "exp": 1739603600 }Claims Reference
subidsub. Deprecated; retained for backwards compatibility with existing consumers. Will be removed in a future version.email<service-name>@services.wxyc.orgrolecapabilitiesorg"wxyc"for now.expiatWhat Stays Out of the JWT
ROLE_PERMISSIONSmatrix in@wxyc/sharedresolvesrole -> permissionslocally. The JWT carries coarse authorization; the shared library resolves the details.5. Capability Storage and Admin Endpoints
Database Schema
A new table in Backend-Service's PostgreSQL database stores capability assignments:
The
granted_bycolumn enables audit trails. The unique constraint prevents duplicate grants.JWT Payload Extension
Modify
definePayloadinBackend-Service/shared/authentication/src/auth.definition.ts:Service accounts are regular better-auth accounts (email+password) but are not members of the WXYC organization. The
@services.wxyc.orgemail domain convention separates the two paths. TheorganizationPlugin({ roles: WXYCRoles })configuration only needs human roles — service accounts never enter the org member table.Admin Endpoints
Three new routes in Backend-Service, protected by the delegation chain defined in
@wxyc/shared:POST /roster/:userId/capabilities{ "capability": "editor" | "webmaster" }canAssignCapability(callerUser, capability)from@wxyc/sharedauth_user_capabilitywithgranted_by= caller's user IDDELETE /roster/:userId/capabilities/:capabilityauth_user_capabilityGET /roster/:userId/capabilitiesstationManager+ or the user themselvesCapability Delegation Chain
Already defined in
wxyc-shared/src/auth-client/capabilities.ts:graph TD A[superAdmin / stationManager] -->|can assign| W[webmaster] A -->|can assign| E[editor] W -->|can assign| E E -->|cannot assign| X[nothing] style A fill:#7c3aed,color:#fff style W fill:#f39c12,color:#fff style E fill:#2ecc71,color:#fff style X fill:#eee,stroke:#cccScalability
Adding a new capability (e.g.,
archivistfor extended archive management):"archivist"toCAPABILITIESinwxyc-sharedCAPABILITY_ASSIGNERSCHECKconstraint onauth_user_capability.capability(migration)6. Shared JWT Verification Libraries
JWT verification is currently implemented ad hoc in
archive/lib/jwt-utils.ts(~50 lines of rawjosecalls). Every new consumer — TypeScript or Python — would duplicate this. This section defines reusable, tested libraries for both ecosystems.TypeScript:
@wxyc/shared/auth-serverA new entry point in
wxyc-shared, distinct from the existingauth-client(which is browser-side and depends onbetter-auth/client). Server-side verification has different dependencies (josefor JWKS) and no browser assumptions.The library is structured as a framework-agnostic core with thin framework-specific adapters. JWT verification logic lives in the core; each adapter wraps it into the target framework's middleware signature.
Core exports (
@wxyc/shared/auth-server):verifyToken(token, config)AuthPayloadextractBearerToken(header)Authorization: Bearer <token>, returnsstring | nullAuthPayloadrole,capabilities,email, etc.JWTConfig{ jwksUrl, issuer?, audience? }Hono adapter (
@wxyc/shared/auth-server/hono):honoOptionalAuth(config)c.get('auth')toAuthPayload | null. No token =null. Bad token = 401.honoRequiredAuth(config)c.get('auth')toAuthPayload. No token or bad token = 401.Express adapter (
@wxyc/shared/auth-server/express):expressOptionalAuth(config)req.authtoAuthPayload | undefined. No token =undefined. Bad token = 401.expressRequiredAuth(config)req.authtoAuthPayload. No token or bad token = 401.Next.js adapter (
@wxyc/shared/auth-server/next):nextOptionalAuth(config)Request, returnsAuthPayload | null. No token =null. Bad token = 401Response.nextRequiredAuth(config)Request, returnsAuthPayload. No token or bad token = 401Response.withAuth(config, handler)authinto the handler context.The Next.js adapter targets the Web API
Request/Responseinterface used by Next.js Route Handlers and Middleware. The website uses Next.js for editor/webmaster capability gating on/admin/**routes.Why framework adapters?
Hono stores context via
c.set()/c.get(). Express extendsreq. Next.js Route Handlers use the Web APIRequest/Responseinterface. A generic middleware can't serve all three without awkward abstractions. The adapters are thin (~30 lines each) and keep the core dependency-free of any framework.hono,express, andnextare peer dependencies — you only pull in what you use.Why a separate entry point from
auth-client?auth-clientimportsbetter-auth/clientand is designed for browser environments (cookie credentials, fetch-based token retrieval). Keeping them separate avoids bundlingjoseinto client builds andbetter-auth/clientinto server builds.Package exports configuration:
{ "exports": { "./auth-server": "./dist/auth-server/index.js", "./auth-server/hono": "./dist/auth-server/hono.js", "./auth-server/express": "./dist/auth-server/express.js", "./auth-server/next": "./dist/auth-server/next.js" }, "peerDependencies": { "hono": "^4.0.0", "express": "^5.0.0", "next": ">=14.0.0" } }Python:
wxyc-authA minimal shared package for FastAPI services.
FastAPI dependency usage:
The package also exports
optional_auth(verify if present, passNoneif absent) for future consumers that need tiered access without requiring authentication. No current Python service uses this — the archive-search equivalent is handled by the Hono adapter in TypeScript.Caller Identity Types (Python)
The verification dependency returns a discriminated union:
Slack Webhook Verification
request-o-matic receives Slack webhooks signed with HMAC (
X-Slack-Signature+X-Slack-Request-Timestamp). This is a separate auth mechanism from JWT. Thewxyc-authpackage provides a dedicated dependency:Implementation: Verify the
X-Slack-Signatureheader againstHMAC-SHA256(signing_secret, "v0:{timestamp}:{body}"). Reject if timestamp is older than 5 minutes (replay protection).Cross-Language Contract Tests
A shared JSON file of test vectors ensures both TS and Python verify tokens identically:
[ { "name": "valid_dj_token", "token": "...", "expected": "valid", "role": "dj" }, { "name": "expired_token", "token": "...", "expected": "expired" }, { "name": "wrong_audience", "token": "...", "expected": "invalid" }, { "name": "bad_signature", "token": "...", "expected": "invalid" }, { "name": "missing_role", "token": "...", "expected": "invalid" }, { "name": "service_token_rom", "token": "...", "expected": "valid", "role": "request-o-matic" }, { "name": "service_token_lml", "token": "...", "expected": "valid", "role": "library-metadata-lookup" }, { "name": "token_with_caps", "token": "...", "expected": "valid", "capabilities": ["editor"] }, { "name": "superAdmin_token", "token": "...", "expected": "valid", "role": "superAdmin" } ]Both test suites load this file and assert identical results. Test vectors are generated by a shared script using a test RS256 keypair committed to the repo.
7. First-Party Service Integration
7.1 wxyc-archive-search (Role-Tier Gating)
Currently hard-filters to 2 weeks of results. With the shared verification library:
sequenceDiagram participant App as Mobile App / Browser participant WAS as wxyc-archive-search participant JWKS as Auth JWKS Endpoint App->>WAS: GET /search?q=radiohead Note over WAS: Extract Authorization header alt No token present WAS->>WAS: days_allowed = 14 else Token present WAS->>JWKS: Fetch public keys (cached) JWKS-->>WAS: RSA public keys WAS->>WAS: Verify JWT signature + expiry alt Valid token, role >= DJ WAS->>WAS: days_allowed = 90 else Valid token, role < DJ WAS->>WAS: days_allowed = 14 end end WAS->>WAS: Filter results to days_allowed window WAS-->>App: Search resultsChanges: Add
@wxyc/shared/auth-server/honodependency. UsehonoOptionalAuthmiddleware on/search. Check role tier for extended access window. ~100 lines.7.2 website (Capability-Gated TinaCMS)
The website currently has no auth. Integration gates TinaCMS admin routes behind capability checks:
graph TD subgraph "website (Next.js)" LP[Login Page] MW[Auth Middleware] TA[TinaCMS Admin /admin] API[API Routes /api/tina/*] end subgraph "Backend-Service" AUTH[Auth Service] JWKS[JWKS Endpoint] end LP -- "sign in" --> AUTH AUTH -- "JWT (with capabilities)" --> LP LP -- "store session" --> MW MW -- "verify JWT via JWKS" --> JWKS MW -- "check 'editor' capability" --> TA TA -- "content operations" --> API API -- "verify JWT, check capability" --> API API -- "proxy to TinaCMS" --> TINA[TinaCMS Backend]Changes:
@wxyc/shared/auth-clientfor the login page (browser-side session)@wxyc/shared/auth-serverfor API route verification (server-side JWKS)/admin/**routes checks for valid JWT witheditororwebmastercapabilityDeployment consideration: The website is currently a static Next.js export to GitHub Pages. Adding auth middleware requires server-side rendering or an API layer. It should move to Cloudflare Pages (like dj-site) or adopt client-side-only auth checks with a serverless function backend. This is an architectural decision to resolve during Phase 2 implementation.
7.3 Mobile Apps (User JWT for Extended Features)
The iOS and Android apps currently call
api.wxyc.orgwithout authentication. Adding auth enables:Approach:
Authorizationheader. Archive-search returns the default 14-day window. request-o-matic rejects unauthenticated requests (users must sign in to submit song requests).better-auth's
anonymous()plugin was considered for frictionless initial access (auto-creating a session without credentials) but is not needed here. The archive-searchhonoOptionalAuthmiddleware already handles the no-token case gracefully, and request-o-matic requires real identity for audit. Anonymous sessions would add complexity (what role? what capabilities?) without a clear benefit.7.4 dj-site and archive (Existing)
No changes to auth verification. The JWT format and JWKS verification remain identical.
8. Service-to-Service Authentication
Problem
request-o-matic and library-metadata-lookup accept all traffic unconditionally. They are meant to be called by apps and internal services, not exposed publicly. There is no way to identify callers, enforce access policies, or audit usage.
Solution: Service Accounts with JWT + User Context Forwarding
Use better-auth accounts for service identity, verified through the same JWKS infrastructure. User context flows through the service chain when applicable.
Service Account Setup
Each service gets a better-auth account with a reserved email domain and its own named role in
ServiceRoles:request-o-matic@services.wxyc.orgrequest-o-maticlibrary-metadata-lookup@services.wxyc.orglibrary-metadata-lookupAuth Flow: Service Startup
sequenceDiagram participant SVC as request-o-matic participant AUTH as Auth Service Note over SVC: Application startup SVC->>AUTH: POST /auth/sign-in/credential<br/>{ email, password } AUTH-->>SVC: 200 OK + JWT SVC->>SVC: Cache JWT, schedule refresh before expiry Note over SVC: Periodic refresh loop Every N minutes (before expiry) SVC->>AUTH: POST /auth/sign-in/credential AUTH-->>SVC: Fresh JWT SVC->>SVC: Replace cached JWT endEnvironment variables per service:
Auth Flow: User-Initiated Request Through Service Chain
When a user action (e.g., song request from a mobile app) flows through services:
sequenceDiagram participant App as Mobile App participant ROM as request-o-matic participant LML as library-metadata-lookup participant JWKS as Auth JWKS App->>ROM: POST /api/v1/request<br/>Authorization: Bearer <user-jwt> ROM->>JWKS: Verify user JWT (cached keys) JWKS-->>ROM: Valid ROM->>ROM: Extract user identity for audit/analytics ROM->>LML: POST /api/v1/lookup<br/>Authorization: Bearer <service-jwt><br/>X-Forwarded-User: <user-id> LML->>JWKS: Verify service JWT (cached keys) JWKS-->>LML: Valid, role=request-o-matic LML->>LML: Process lookup LML-->>ROM: Lookup results ROM->>ROM: Compose response ROM-->>App: Request resultWhy the service JWT on the internal hop instead of forwarding the user JWT?
X-Forwarded-User) for audit logging, not for access control.Auth Flow: Slack Webhook
Slack calls request-o-matic directly with HMAC-signed webhooks. This is a separate auth path from JWT:
sequenceDiagram participant Slack participant ROM as request-o-matic Slack->>ROM: POST /api/v1/slack/events<br/>X-Slack-Signature: v0=...<br/>X-Slack-Request-Timestamp: ... ROM->>ROM: Verify HMAC-SHA256 signature<br/>Check timestamp freshness (< 5 min) alt Valid signature ROM->>ROM: Process event ROM-->>Slack: 200 OK else Invalid signature ROM-->>Slack: 401 Unauthorized endService Roles in Middleware
Service identities bypass the human role hierarchy. The middleware distinguishes callers by type:
The verification layer resolves per-service roles into
ServiceCallerinstances:Endpoint Access Matrix (Python Services)
GET /healthPOST /api/v1/parsePOST /api/v1/requestPOST /api/v1/artworkGET /api/v1/library/searchPOST /api/v1/discogs/searchPOST /api/v1/slack/eventsPOST /api/v1/slack/commandsAll endpoints except health and Slack webhooks require JWT authentication. Slack endpoints require HMAC signature verification. The auth boundary is "is this a known caller?" not "what level of DJ are you?"
9. Infrastructure Architecture
Approach: Cloudflare Access + GitHub (No Dedicated IDP)
Instead of deploying a dedicated identity provider (Authentik, Keycloak, etc.), this proposal uses Cloudflare Access with GitHub as the identity provider for infrastructure gating. This eliminates an entire infrastructure layer while achieving the same access control.
Why not Authentik:
Why Cloudflare Access + GitHub:
Target Architecture
flowchart TD BS["Backend-Service<br/>(better-auth + JWKS)"] BS -->|JWKS| PY["wxyc-auth<br/>(PyJWT)"] BS -->|JWKS| TS["@wxyc/shared/auth-server<br/>(jose)"] BS -.->|webhook| SYNC[Sync Service] PY --> ROM PY --> LML TS --> DS TS --> ARC TS --> WEB TS --> ARCS subgraph consumers["Auth-Protected Services"] subgraph pyApps["Python (via wxyc-auth)"] direction LR ROM[request-o-matic] LML[library-metadata-lookup] end subgraph tsApps["TypeScript (via auth-server)"] direction LR DS[dj-site] ARC[archive] WEB[website] ARCS[archive-search] end end ROM -- "service JWT" --> LML subgraph mobile["Mobile Apps"] direction LR IOS[wxyc-ios-64] AND[WXYC-Android] end mobile -->|user JWT| consumers subgraph external["Sync Targets"] direction LR AC[AzuraCast] PH[PostHog] META["Facebook /<br/>Instagram"] BSKY[Bluesky] end SYNC -.-> AC SYNC -.-> PH SYNC -.-> META SYNC -.-> BSKY CA["Cloudflare Access<br/>(GitHub IDP)"] -->|gate access| AC style BS fill:#2563eb,color:#fff style PY fill:#4a9,stroke:#287 style TS fill:#4a9,stroke:#287 style CA fill:#f38020,color:#fff style PH fill:#1d4aff,color:#fff style SYNC fill:#6b7280,color:#fff,stroke-dasharray: 5 5 style META fill:#1877f2,color:#fff style BSKY fill:#0085ff,color:#fffNot shown: Cloudflare Pages hosts dj-site and website. GitHub provides IDP for Cloudflare Access and social SSO for PostHog. Sentry uses manual accounts (no API integration). Mobile apps send user JWTs to archive-search and request-o-matic for authenticated features. AzuraCast and Backend-Service run on EC2.
Authentication Flows
First-Party Login
No change from today. Users authenticate directly with better-auth (email+password). better-auth issues JWTs verified via JWKS.
sequenceDiagram actor User participant App as dj-site / archive / website participant BS as Backend-Service<br/>(better-auth) User->>App: Click "Sign in" App->>BS: POST /auth/sign-in/credential BS->>BS: Verify credentials<br/>Issue JWT with role + capabilities BS->>App: JWT + session cookie App->>User: AuthenticatedInfrastructure Access (Gated via Cloudflare Access)
sequenceDiagram actor User participant CF as Cloudflare Access participant GH as GitHub participant Svc as AzuraCast User->>CF: Navigate to service URL CF->>CF: Check Access policy CF->>GH: GitHub OAuth redirect GH->>User: GitHub login page User->>GH: Credentials / 2FA GH->>CF: OAuth callback CF->>CF: Check GitHub org/team membership<br/>against Access policy CF->>Svc: Proxy request (authenticated) Svc->>User: Service UI10. Third-Party Service Integration
10.1 Cloudflare Access (Free Tier)
IDP: GitHub (org + team membership)
Limit: 50 users
Session: Team-wide session sharing — authenticate once, access all gated services
Documentation: Cloudflare + GitHub IDP
Cloudflare Access gates self-hosted infrastructure services. Policies use GitHub org and team membership.
WXYC-Radio/tech-teamORWXYC-Radio/music-directorsSetup requirements:
10.2 Sentry (Cloud, Free Tier)
SSO: Not available on free tier.
Approach: Manual account management. Tech team members create Sentry accounts with their work email.
Self-hosting Sentry was considered for SAML SSO but rejected: the Docker Compose stack is resource-intensive (~2-4 GB RAM), and the operational burden of maintaining self-hosted Sentry outweighs the SSO benefit for a team of <10 people. Sentry is used across the stack (Backend-Service, dj-site, Python services, mobile apps) which makes reliable uptime critical.
10.3 AzuraCast
Protocol: None (API-based sync + Cloudflare Access gate)
Cost: Free (self-hosted)
AzuraCast is the station's auto DJ and digital music library platform. It has no native SSO. The maintainer has explicitly declined SSO feature requests. Integration is two-layered:
AzuraCast separates web users (admin interface) from streamers (broadcast connections via Icecast/Shoutcast). The sync service manages web users; streamer account provisioning is out of scope (see Open Questions).
AzuraCast uses an ACL permission model, not preset roles. The sync service creates WXYC-specific roles:
administer allmanage station media,view station management10.4 PostHog (Cloud, Free Tier)
Protocol: GitHub social SSO
Cost: Free
Limitation: No SAML/OIDC (requires $750/mo Scale plan).
Tech team members sign in with GitHub SSO (all tech team members already have GitHub accounts). The sync service sends PostHog invites via the Invites API when a user is granted
superAdmin. Manual revocation when someone leaves the tech team.Only the tech team (
superAdmin) needs PostHog access.Email mapping note: PostHog invites are sent to the user's better-auth email. Since PostHog login uses GitHub SSO, the invitee's GitHub account must be associated with the same email, or they'll need to manually link the invite to their GitHub login. Tech team members should use the same email for both.
10.5 Facebook / Instagram (Meta Business Suite)
Protocol: Meta Business API (user invite/revoke)
Cost: Free
Prerequisite: Meta App Review with
business_managementpermission (one-time)Current state: A single shared username and password, handed to the next person without rotation. Departed members retain access indefinitely.
Target state: WXYC's Facebook Page and Instagram account are claimed under a Meta Business Suite account. Individual team members are invited by email with granular roles. The sync service automates invite/revoke via the Meta Business API.
Role mapping:
superAdminstationManagereditorcapabilitySync service actions:
role.changedto stationManager/superAdmincapability.grantededitorcapability.revokededitorrole.changedaway from stationManagereditorcapability) or revokeEmail mapping note: Meta invites are sent to the user's better-auth email. The user must accept the invite using a personal Facebook account — they do not need to use the same email for both, but the invite flow is email-initiated. Users without a personal Facebook account cannot be provisioned (a reasonable assumption for social media managers).
Meta App Review: The sync service requires a Meta App with
business_managementpermission. This is a one-time App Review submission explaining the use case. The review takes days to weeks. If rejected, the fallback is manual management via the Meta Business Suite web UI (same overhead as Sentry).10.6 Bluesky (AT Protocol)
Protocol: AT Protocol API (app password management)
Cost: Free (open protocol)
Prerequisite: None
Current state: Same as Facebook/Instagram — single shared password, no rotation, no individual access control.
Target state: Each team member gets a named app password, created and revoked by the sync service. The main account password is stored in secrets management, known only to
superAdmin.Role mapping:
superAdminstationManagereditorcapabilitySync service actions:
com.atproto.server.createAppPasswordwith user's name as labelcom.atproto.server.revokeAppPasswordThe sync service delivers the generated app password to the user via a notification channel (Slack DM or email). On revocation, the app password is revoked server-side — no user action required.
Limitation: App passwords work in third-party Bluesky clients and for API-based posting, but not for logging into the bsky.app web UI. For a station account where posting is the primary action, this is acceptable. If web UI access is needed, users can request the main account password from a
superAdmin, and the password should be rotated when that access is revoked.11. Sync Service
A lightweight service that keeps AzuraCast accounts, PostHog invites, Meta Business Suite access, and Bluesky app passwords in sync with Backend-Service's role data, triggered by webhooks from Backend-Service on role and capability changes.
flowchart TD subgraph Trigger["Trigger"] WH[Backend-Service webhook<br/>on role/capability change] end subgraph Sync["Sync Service (FastAPI)"] RCV[Receive webhook event] DIFF[Diff current state<br/>vs. desired state] AC_SYNC[Provision/deprovision<br/>AzuraCast users + roles] PH_SYNC[Send/revoke<br/>PostHog invites] META_SYNC[Invite/revoke<br/>Meta Business Suite users] BSKY_SYNC[Create/revoke<br/>Bluesky app passwords] end subgraph Sources["External APIs"] BS_API[Backend-Service API<br/>roster + capabilities] AC_API[AzuraCast API] PH_API[PostHog API] META_API[Meta Business API] BSKY_API[Bluesky AT Protocol API] end WH --> RCV RCV --> DIFF DIFF --> BS_API DIFF --> AC_SYNC DIFF --> PH_SYNC DIFF --> META_SYNC DIFF --> BSKY_SYNC AC_SYNC --> AC_API PH_SYNC --> PH_API META_SYNC --> META_API BSKY_SYNC --> BSKY_APIImplementation: Python (FastAPI). Runs on the same EC2 instance. Backend-Service emits webhooks when roles or capabilities change (e.g., after a role assignment, stationManager transfer, or capability grant/revoke). The sync service receives the event, fetches full user state from Backend-Service's roster API (not the DB directly — avoids schema coupling), diffs against each external service (AzuraCast, PostHog, Meta Business Suite, Bluesky), and reconciles.
Webhook events:
role.changeduserId,oldRole,newRolerole.transferredgrantedUserId,revokedUserId,rolecapability.granteduserId,capability,grantedBycapability.revokeduserId,capability,revokedBy{ "event": "role.changed", "userId": "user-id", "oldRole": "dj", "newRole": "superAdmin", "timestamp": "2026-03-15T12:00:00Z" }Backend-Service needs a webhook emission layer in its role/capability assignment endpoints. The sync service authenticates incoming webhooks using a shared secret (HMAC signature, same pattern as Slack webhooks).
AzuraCast password handling: The sync service generates random passwords for AzuraCast accounts. Since Cloudflare Access is the real authentication gate, AzuraCast passwords are a secondary concern. The sync service can store them encrypted or simply regenerate on each sync.
12. Test Strategy
Unit Tests
graph TD subgraph "Unit Tests (both TS and Python)" U1[Generate test JWT<br/>with RS256 test keypair] U2[Mock JWKS endpoint returning test public key] U3[Parameterize: valid/expired/wrong-aud/bad-sig/missing-role] U4[Verify each role tier: member/dj/md/sm/superAdmin + per-service roles] U5[Verify capability claim extraction] U6[Verify stationManager mutual exclusivity] U1 --> U3 U2 --> U3 U3 --> U4 U3 --> U5 U3 --> U6 endShared verification libraries (
@wxyc/shared/auth-server,wxyc-auth):ServiceCallerbypasses role hierarchy butUserCallerdoes notWXYCRolesorServiceRoles= 403)Capability delegation (
@wxyc/shared):capabilities.test.tsalready coverscanAssignCapabilityrequireCapabilitymiddleware with mocked JWTsBackend-Service admin endpoints:
webmastercan granteditor; DJ withoutwebmastercannotContract Tests
graph TD C1["TS and Python verify the same JWT identically"] C2["Same parameterized test matrix in both languages"] C3["Shared test vectors<br/>(JSON fixtures)"] C3 --> C1 C3 --> C2Shared test vectors (JSON file of pre-generated test cases) loaded by both TS and Python test suites. Enforces identical behavior across implementations.
Integration Tests
editorcapability, obtain JWT, verifycapabilitiesclaim is presentE2E Tests
editorcapability, navigate to/admin, verify access; without capability, verify 403X-Forwarded-Userreaches library-metadata-lookup13. Implementation Phases
Dependency Graph
graph TD A["Step 1: TS auth-server module<br/>(@wxyc/shared/auth-server)"] --> C A --> D B["Step 2: Capabilities schema +<br/>JWT enrichment +<br/>superAdmin role"] --> D B --> F C["Step 3: wxyc-archive-search<br/>(role-tier gating)"] D["Step 4: Website TinaCMS<br/>(capability-gated)"] E["Step 5: Python wxyc-auth<br/>package"] --> H B --> G F["Step 6: Capability admin<br/>endpoints + stationManager<br/>transfer"] --> D G["Step 7: Service accounts<br/>(better-auth)"] --> H H["Step 8: request-o-matic +<br/>library-metadata-lookup<br/>(service + Slack auth)"] I["Step 9: Mobile app auth<br/>(iOS + Android)"] J["Step 10: Cloudflare Access +<br/>GitHub IDP"] --> K K["Step 11: Gate AzuraCast<br/>via CF Access"] L["Step 12: Sync service<br/>(AzuraCast + PostHog +<br/>Meta + Bluesky +<br/>webhook emission)"] C --> I H --> I B --> L K --> L style A fill:#2ecc71,color:#fff style B fill:#2ecc71,color:#fff style E fill:#2ecc71,color:#fff style C fill:#3498db,color:#fff style F fill:#3498db,color:#fff style G fill:#3498db,color:#fff style D fill:#e67e22,color:#fff style H fill:#e67e22,color:#fff style I fill:#e67e22,color:#fff style J fill:#9b59b6,color:#fff style K fill:#9b59b6,color:#fff style L fill:#9b59b6,color:#fffGantt Chart
gantt title Implementation Phases dateFormat YYYY-MM-DD axisFormat %b %d section Phase 1 - Foundation Step 1 - TS auth-server module :a1, 2026-03-15, 5d Step 2 - Capabilities + JWT + superAdmin :a2, 2026-03-15, 5d Step 5 - Python wxyc-auth package :a5, 2026-03-15, 8d section Phase 2 - First-Party Consumers Step 3 - archive-search (role gating) :a3, after a1, 4d Step 6 - Capability endpoints + SM xfer :a6, after a2, 5d Step 7 - Service accounts (better-auth) :a7, after a2, 3d Step 8 - ROM + LML (service + Slack) :a8, after a5, 6d Step 4 - Website TinaCMS (capability) :a4, after a6, 8d Step 9 - Mobile app auth (iOS + Android) :a9, after a8, 5d section Phase 3 - Infrastructure Gating Step 10 - Cloudflare Access + GitHub :p3a, after a4, 3d Step 10b - Meta Business Suite setup :p3m, after a4, 5d Step 11 - Gate AzuraCast via CF Access :p3b, after p3a, 2d section Phase 4 - Sync Service Step 12a - Webhook emission + scaffold :p4a, after p3b, 5d Step 12b - AzuraCast user/role sync :p4b, after p4a, 5d Step 12c - PostHog invite sync :p4c, after p4b, 3d Step 12d - Meta Business Suite sync :p4d, after p4c, 4d Step 12e - Bluesky app password sync :p4e, after p4c, 3dPhase 1: Foundation (No Consumer Changes)
No new infrastructure. Builds reusable libraries and schema.
auth-serverentry point from archive'sjwt-utils.ts. Framework-agnostic core (verifyToken,extractBearerToken) plus Hono, Express, and Next.js adapters (optionalAuth,requiredAuth). Unit tests with test keypair.fix/add-admin-role-to-wxyc-rolesimprovements, renameadmintosuperAdmin, addinfrastructureandrolesresources. DefineServiceRoleswith per-service named roles. Update middleware to resolve from bothWXYCRolesandServiceRoles. Drizzle migration forauth_user_capabilitytable. ExtenddefinePayloadto includecapabilities,org, deprecatedidalias, and service account email domain branching. Add stationManager mutual exclusivity constraint.PyJWKClientwrapper, FastAPI dependencies,CallerIdentitytypes, Slack HMAC verification. Contract tests matching TS test vectors.Steps 1, 2, and 5 are independent and can be developed in parallel.
Phase 2: First-Party Consumers
@wxyc/shared/auth-server/hono.honoOptionalAuthon/search. Extended window for DJ+.POST/DELETE/GET/roster/:userId/capabilities. Delegation chain enforcement. stationManager transfer endpoint with atomicity.wxyc-authdependency.require_authon all endpoints except/health. Slack HMAC verification on/slack/*endpoints. Token refresh at startup./admin/**, capability check for TinaCMS writes. Resolve deployment (static export vs Cloudflare Pages).Steps 3 and 6 are independent. Step 7 depends on 2 (definePayload service account branching + ServiceRoles). Step 8 depends on 5 and 7. Step 4 depends on 1, 2, and 6 (auth-server library, capability table + definePayload, capability endpoints). Step 9 depends on 3 and 8 (archive-search optional auth, ROM/LML required auth).
Phase 3: Infrastructure Gating
New infrastructure: Cloudflare Access configuration (no servers to deploy). Meta Business Suite set-up.
business_managementpermission. (Can run in parallel with Steps 10-11; review takes days to weeks.)Phase 4: Sync Service
New infrastructure: webhook emission in Backend-Service + small Python service on EC2.
superAdminusers via PostHog Invites API.Beta Was this translation helpful? Give feedback.
All reactions