identity — Keycloak SSO
Single sign-on for everything that isn't an on-chain action. A Keycloak instance (https://identity.bitview.club) running on the BitView Kubernetes cluster issues the OIDC tokens that bitview-admin, bitview-app, and bitview-bot use to identify humans and services.
It is not the on-chain authorization layer. Every token transfer is still signed by the user's Solana wallet — the merkle distributor program does not look at OIDC. What Keycloak gates is the off-chain surface: the admin console, the streamer dashboard, the bot's admin REST endpoints, and (optionally) the consumer login flow.
Why a dedicated identity service
Before this layer, three different auth models coexisted:
bitview-app— Twitch OAuth handoff for wallet ↔ login binding, no session of its own.bitview-admin— Auth.js v5 with a custom SIWS provider and a pubkey allowlist inADMIN_PUBKEYS.bitview-bot—X-Admin-Api-Keystatic header for admin endpoints, ed25519 wallet signatures + Twitch OAuth for user-facing endpoints.
That worked at MVP scale, but it left the operator console without real RBAC, made onboarding a new admin a code change, and gave the bot no way to tell "admin A finalized this distribution" from "admin B did". Keycloak gives us:
- One user directory across admin, app, and bot.
- Roles, groups, and per-resource permissions instead of an env-var allowlist.
- OIDC + OAuth2 tokens the bot can verify offline by JWKS.
- Federation with Twitch, GitHub, and (later) Google as identity providers, so a streamer's Twitch identity flows naturally into their BitView session without us reimplementing the handshake.
- Optional MFA (TOTP, WebAuthn) for admin and treasury operators.
The wallet stays as the on-chain auth root. Keycloak is the human auth root for off-chain surfaces.
Responsibilities
| Area | Responsibility |
|---|---|
| Realms | Two realms: bitview (end users + streamers) and bitview-ops (BitView staff + admin). Separated so a compromise of one doesn't escalate to the other. |
| Identity providers | Twitch (primary for streamers + viewers), GitHub (staff convenience), email/password (fallback). Federation maps the external sub to a local Keycloak user. |
| Clients | One OIDC client per component: bitview-app (public, PKCE), bitview-admin (confidential, server-side), bitview-bot (bearer-only resource server), bitview-bot-service (service account for cron / accrual). |
| Roles | Coarse roles (admin, ops, streamer, viewer, support) plus per-resource fine-grained scopes (distribution.publish, treasury.read, fraud.slash, sponsorship.approve). |
| Token issuance | Short-lived access tokens (default 5 min), refresh tokens with offline access for service accounts. Tokens carry roles, groups, and wallet (where bound) as claims. |
| Wallet binding | Custom Keycloak attribute solana_wallet stores the SIWS-verified pubkey for users who have linked a wallet. The bot trusts it because Keycloak only writes it after a server-verified ed25519 signature. |
| MFA | TOTP required for ops and admin roles. WebAuthn supported as a stronger second factor for treasury operators. |
| Audit | Keycloak's event store records every login, role change, and admin action. Forwarded to the BitView audit pipeline alongside the bot's own audit log. |
Tech stack
| Layer | Choice | Why |
|---|---|---|
| IdP server | Keycloak (latest LTS) | Battle-tested OIDC + SAML server, mature RBAC, large operator community, no SaaS bill. |
| Database | PostgreSQL | Keycloak's recommended store; sized for ~10k users at MVP. |
| Deployment | Kubernetes (BitView cluster) | High-availability via the official keycloak Helm chart, horizontal pod autoscaling, automatic restart on node loss. |
| TLS | cert-manager + Let's Encrypt | Renewed automatically; terminated at the ingress. |
| Ingress | nginx-ingress | Same controller used for the rest of the cluster; routes identity.bitview.club to the Keycloak service. |
| Backups | Velero + pg_dump | Daily PostgreSQL dumps to off-site object-lock storage, plus Velero snapshots for the whole namespace. |
| Token format | JWT signed with RS256 | Lets resource servers (bitview-bot) verify offline via JWKS without a Keycloak round-trip per request. |
Topology
https://identity.bitview.club
│
▼
┌─────────────────┐
│ nginx-ingress │ TLS termination
└────────┬────────┘
│
▼
┌──────────────────────────┐
│ Keycloak (Helm) │
│ replicas: 2 (HA) │
│ realm: bitview │
│ realm: bitview-ops │
└──────────┬───────────────┘
│
▼
┌────────────────┐
│ PostgreSQL │ user store, sessions
│ (StatefulSet) │ daily pg_dump → S3
└────────────────┘
Two Keycloak pods sit behind the ingress for failover; the PostgreSQL
StatefulSet uses a persistent volume with snapshot-based backups. The
cluster runs cert-manager so the identity.bitview.club certificate
renews itself.
Realms
bitview — end users
The realm consumer-facing surfaces (bitview-app) authenticate
against. Federated to Twitch, GitHub, and Google. Allows
self-registration with email confirmation. Users get the viewer
role by default; they earn the streamer role once they complete the
streamer onboarding (KYC for Identity tier).
bitview-ops — staff
The realm bitview-admin and the bot's admin endpoints authenticate
against. No self-registration — accounts created only by realm
admins. MFA is required. Groups: engineering, treasury,
anti-fraud, support. Each group maps to a coarse role plus a set
of fine-grained scopes.
Separating staff into a different realm means a phishing campaign that compromises a viewer account cannot pivot to admin even if the attacker reuses the password.
Clients
| Client ID | Realm | Type | Used by | Notes |
|---|---|---|---|---|
bitview-app | bitview | public, PKCE | bitview-app (browser) | Authorization Code + PKCE. Redirect URIs scoped to bitview.club and localhost. |
bitview-admin | bitview-ops | confidential | bitview-admin (Next.js server) | Server-side Authorization Code; client secret in the admin's .env (rotated quarterly). |
bitview-bot | both | bearer-only | bitview-bot | Resource server; doesn't initiate logins. Verifies access tokens via JWKS. |
bitview-bot-service | bitview-ops | confidential, service account | bitview-bot (cron, accrual) | OAuth2 Client Credentials grant. Used when the bot calls Keycloak admin API to mint short-lived service tokens for internal jobs. |
Token contents
Access tokens include the standard OIDC claims plus a small BitView-specific block:
{
"sub": "9c2b…-f3a4",
"iss": "https://identity.bitview.club/realms/bitview",
"aud": ["bitview-app", "bitview-bot"],
"exp": 1715600000,
"iat": 1715599700,
"preferred_username": "alice",
"email": "alice@example.com",
"email_verified": true,
"realm_access": { "roles": ["viewer", "streamer"] },
"resource_access": {
"bitview-bot": { "roles": ["distribution.publish"] }
},
"wallet": "Bz3…", // populated only after SIWS link
"twitch_login": "alice" // populated only after Twitch federation
}
The bot's middleware verifies the signature with the cached JWKS,
checks exp, iss, and aud, and rejects tokens missing the
required role for the endpoint.
Folder layout
There is no application source under a single repo for this component — the source of truth is the Kubernetes manifests in ops/k8s/identity/ (private repo) plus the exported realm configurations checked in alongside.
ops/k8s/identity/
├── helm/
│ └── values.yaml — keycloak chart values (replicas, resources, ingress)
├── manifests/
│ ├── namespace.yaml
│ ├── postgres-statefulset.yaml
│ ├── postgres-backup-cronjob.yaml
│ ├── ingress.yaml — identity.bitview.club + TLS via cert-manager
│ └── network-policy.yaml — only the admin / bot namespaces can reach Keycloak
├── realms/
│ ├── bitview-realm.json — exported realm config (users excluded)
│ └── bitview-ops-realm.json — exported realm config (users excluded)
└── README.md
Realm JSON exports are the configuration source. CI applies them on merge so the realms are reproducible from git, not from clicks in the admin UI.
Integration points
| Component | Flow | Token use |
|---|---|---|
| bitview-admin | Authorization Code (server-side, confidential client) | Session cookie stores the access + refresh tokens; server actions forward the bearer to bitview-bot. SIWS allowlist removed once role-based auth is wired. |
| bitview-app | Authorization Code + PKCE (public client) | Browser receives access token; included as Authorization: Bearer on calls to bitview-bot. The Twitch-OAuth → wallet-link flow becomes "Twitch federation through Keycloak → SIWS attribute on the user". |
| bitview-bot | Bearer-only resource server | Replaces X-Admin-Api-Key for admin endpoints. The middleware enforces required realm_access.roles or resource_access.bitview-bot.roles per route. Service-to-service jobs use the bitview-bot-service client credentials grant. |
The migration is intentionally additive: the bot accepts both the
legacy X-Admin-Api-Key and a valid Keycloak bearer during the
transition window. Once the admin and app are switched over, the
legacy header is removed.
Status
| Surface | State | Notes |
|---|---|---|
| Kubernetes cluster | ✅ shipped | Multi-node cluster up; nginx-ingress, cert-manager, and Velero installed. |
| Keycloak deployment | ✅ shipped | Helm chart deployed in HA (2 replicas); reachable at https://identity.bitview.club. |
| PostgreSQL backend | ✅ shipped | StatefulSet with daily pg_dump to off-site storage. |
| TLS via cert-manager | ✅ shipped | Let's Encrypt certificate auto-renewing. |
Realm bitview | ✅ shipped | Direct Twitch OAuth (not federation) is currently used by public-web to work around a Keycloak JSON-array scope parsing bug. Federation pending. |
Realm bitview-ops | ✅ shipped | Staff accounts active; admin-web requires admin / ops / support realm role to enter. |
Clients (bitview-app, bitview-admin, bitview-bot) | ✅ shipped | All three clients live; bitview-bot-service (client-credentials) still planned for cron / accrual service tokens. |
| Role + group model | 🟡 in progress | Coarse roles (viewer, streamer, admin, ops, support) wired. Stripe webhook on the bot promotes streamers to the streamer role. Fine-grained per-resource scopes still planned. |
bitview-admin integration | ✅ shipped | Authorization Code + PKCE against bitview-ops. iron-session cookie holds access + refresh; middleware checks realm roles. Legacy SIWS path dormant (ADMIN_PUBKEYS empty in production). |
bitview-app integration | ✅ shipped | Authorization Code + PKCE against bitview. iron-session cookie holds the access + refresh tokens. Direct Twitch OAuth runs alongside for wallet ↔ Twitch linking. |
bitview-bot integration | ✅ shipped | Bearer middleware (JWKS-cached, role-gated) accepts Keycloak tokens; X-Admin-Api-Key is the legacy fallback still accepted while the consumer-web migration completes. |
| MFA for staff | 🟡 in progress | TOTP optional today; required-by-policy switch is still pending the realm policy export. |
| Twitch / GitHub / Google IdP federation through Keycloak | 🔴 planned | Today's Twitch link bypasses Keycloak; will move to federation once the scope parsing fix lands. |
| Audit forwarding | 🔴 planned | Keycloak events → BitView audit pipeline. |
Removal of the legacy X-Admin-Api-Key fallback | 🔴 planned | Kept while consumer-web's KYC submission proxy still depends on it. |
For the sprint-level breakdown, Helm values, realm exports, and the
migration plan for the three integration points, see the internal
_internal/technical/identity-detail.md doc.
Cross-references
- bitview-admin — first consumer of the new auth model
- bitview-app — second consumer; replaces the standalone Twitch OAuth handoff
- bitview-bot — bearer-token resource server
- Architecture — overall layering
- Operations — deployment, env vars, and Kubernetes notes