feat(auth): native SSO authorization bridge for the Android app (M9 Part 1) #80
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/mobile-sso-bridge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Native "Sign in with Google/Discord" for the Android app without shipping any OAuth secret in the app. The website stays the identity authority — each shard owner's provider credentials remain in
auth_providers(encrypted at rest) and are only ever used server-side.This is the backend half of milestone M9 (mirrors M7's backend-first split; the app client is Part 2). It extends the existing
/auth/sso/*redirect flow rather than adding a parallel auth path — same PKCE-vs-IdP, same link-only + opt-in-provisioning policy, same TOTP gate, and it terminates in the existing mobile bearer tokens (/auth/mobile/loginshape). Discovery reusesGET /auth/providers; refresh/logout reuse the existing/auth/mobile/*.Companion docs PR: RunicGateway/docs#23 (
docs/mobile-sso-bridge). Design contract:docs/website/BACKEND_DESIGN.md§3/§4,docs/android/PLAN.md§9 (M9),docs/android/APP_LINKS.md.Commit 1 — the SSO bridge
mobile_auth_sessions+mobile_auth_codes(short-lived, self-pruning; authorization code stored hash-only, PKCE challenge is a hash by construction).GET /auth/mobile/sso/start— validate provider enabled +redirect_uriby exact allowlist match (never prefix), seed a bridge session, reuse the SSO redirect taggedmode:'mobile'(newredirectToIdphelper extracted frombeginFlow).finishSsoTotpgain amode:'mobile'branch: mint a single-use, hashed, PKCE-bound code and 302 to the fixed app callback (code+ echoedstate, never a token) instead of setting a cookie. 2FA keeps full parity via the existing web TOTP form (now carrying the bridge session).POST /auth/mobile/sso/exchange— verify Layer-B PKCE before burning the code, single-use consume, then issue the same access + refresh pair as/auth/mobile/login./startper-IP+provider,/exchangeper-IP); boot-time + opportunistic prune of both tables (no cron — mirrorsrevoked_sessions).MOBILE_AUTH_REDIRECT_URIS(default the one fixedrunicgateway://auth/callback); HTTPS App Link URIs can be appended per shard later.Two PKCE layers (do not conflate): Layer A (website↔IdP, existing
sso_txcookie) is untouched; Layer B (app↔website) is new and verified at/exchange. The app-generatedstateis a CSRF guard the app verifies before exchange.Commit 2 — Active Devices (view/revoke mobile sessions)
device_name+last_used_atcolumns onmobile_refresh_tokens(nullable, additive; label carried across rotation).mobileSessions.listActiveForUser/revokeByIdForUser;GET+DELETE /auth/me/sessions/:id(role-agnostic; distinct from/auth/me/devices, which is push).device_nameon/auth/mobile/loginand/auth/mobile/sso/exchange.PlayerLoginchange to honor the bridge's{ redirect }deep link on a 2FA completion.How it was tested
cd server && npm test→ 274 pass / 0 fail (39 new: model single-use/gating + full controller matrix — bad/expired/reused code, PKCE mismatch, disabled provider, redirect allowlist incl. prefix-attack rejection, TOTP-through-bridge, device revoke).npm run swaggerregenerated and committed; new endpoints +MobileSsoExchangeRequest/DeviceSessionschemas present.cd client && npm run build→ builds clean.Checklist
AI-assisted contributions (required)
Claude Code. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with aCo-Authored-Bytrailer.License
🤖 Generated with Claude Code
https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Adds the self-service device-session surface the mobile-SSO spec requires, on top of the existing mobile_refresh_tokens store. - Schema: device_name + last_used_at columns on mobile_refresh_tokens (nullable, additive via the ALTER section; seeded to now on insert). With single-use rotation each login/refresh inserts a fresh row, so the active row's timestamp is the session's last activity, and the label is carried forward on refresh. - Model: listActiveForUser (one row per live device, no token hash) + revokeByIdForUser (ownership-scoped, idempotent). - GET /auth/me/sessions + DELETE /auth/me/sessions/:id (role-agnostic, behind requireAuth). Named distinctly from /auth/me/devices (push endpoints). - device_name is an optional field on /auth/mobile/login and /auth/mobile/sso/exchange so the app can label a device. - Client: an "Active Devices" panel on the player account page (list + sign a device out), plus the PlayerLogin change to honor the mobile SSO bridge's { redirect } deep link on a 2FA completion. - Swagger DeviceSession schema + regenerated spec; 3 controller tests. Full server suite green (274); client builds. Co-Authored-By: Claude <noreply@anthropic.com>