feat(auth): native SSO authorization bridge for the Android app (M9 Part 1) #80

Merged
whitlocktech merged 2 commits from feature/mobile-sso-bridge into main 2026-07-20 22:27:24 +00:00
Member

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/login shape). Discovery reuses GET /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

  • Schema: 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_uri by exact allowlist match (never prefix), seed a bridge session, reuse the SSO redirect tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow).
  • Callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use, hashed, PKCE-bound code and 302 to the fixed app callback (code + echoed state, 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.
  • Rate limits (/start per-IP+provider, /exchange per-IP); boot-time + opportunistic prune of both tables (no cron — mirrors revoked_sessions).
  • Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed runicgateway://auth/callback); HTTPS App Link URIs can be appended per shard later.

Two PKCE layers (do not conflate): Layer A (website↔IdP, existing sso_tx cookie) is untouched; Layer B (app↔website) is new and verified at /exchange. The app-generated state is a CSRF guard the app verifies before exchange.

Commit 2 — Active Devices (view/revoke mobile sessions)

  • device_name + last_used_at columns on mobile_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).
  • Optional device_name on /auth/mobile/login and /auth/mobile/sso/exchange.
  • React "Active Devices" panel on the player account page + the PlayerLogin change to honor the bridge's { redirect } deep link on a 2FA completion.

How it was tested

  • cd server && npm test274 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 swagger regenerated and committed; new endpoints + MobileSsoExchangeRequest / DeviceSession schemas present.
  • cd client && npm run build → builds clean.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr

## 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/login` shape). Discovery reuses `GET /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 - **Schema:** `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_uri` by **exact allowlist match** (never prefix), seed a bridge session, reuse the SSO redirect tagged `mode:'mobile'` (new `redirectToIdp` helper extracted from `beginFlow`). - **Callback + `finishSsoTotp`** gain a `mode:'mobile'` branch: mint a single-use, hashed, PKCE-bound code and 302 to the fixed app callback (`code` + echoed `state`, **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`. - Rate limits (`/start` per-IP+provider, `/exchange` per-IP); boot-time + opportunistic prune of both tables (no cron — mirrors `revoked_sessions`). - Redirect allowlist is `MOBILE_AUTH_REDIRECT_URIS` (default the one fixed `runicgateway://auth/callback`); HTTPS App Link URIs can be appended per shard later. **Two PKCE layers (do not conflate):** Layer A (website↔IdP, existing `sso_tx` cookie) is untouched; Layer B (app↔website) is new and verified at `/exchange`. The app-generated `state` is a CSRF guard the app verifies before exchange. ### Commit 2 — Active Devices (view/revoke mobile sessions) - `device_name` + `last_used_at` columns on `mobile_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). - Optional `device_name` on `/auth/mobile/login` and `/auth/mobile/sso/exchange`. - React "Active Devices" panel on the player account page + the `PlayerLogin` change 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 swagger` regenerated and committed; new endpoints + `MobileSsoExchangeRequest` / `DeviceSession` schemas present. - `cd client && npm run build` → builds clean. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
wtclaude added 2 commits 2026-07-20 22:07:45 +00:00
Add a Mobile SSO Authorization Bridge so the native app can "Sign in with
Google/Discord" without shipping any OAuth secret. It EXTENDS the existing
/auth/sso/* redirect flow (same PKCE-vs-IdP, link-only + opt-in provisioning,
TOTP gate) and terminates in the existing mobile bearer tokens — not a parallel
auth path.

- Schema: 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_uri by EXACT
  allowlist match (never prefix), seed a bridge session, reuse the SSO redirect
  tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow).
- SSO callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use,
  hashed, PKCE-bound code and redirect to the fixed app callback (code + echoed
  state, 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 pair as /auth/mobile/login.
- Discovery reuses GET /auth/providers; refresh/logout reuse /auth/mobile/*.
- Rate limits: /start per-IP+provider, /exchange per-IP. Boot-time +
  opportunistic prune of both tables (no cron, mirrors revoked_sessions).
- Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed
  runicgateway://auth/callback); App Link URIs can be appended per shard later.
- Swagger regenerated; 39 tests (model single-use/gating + full controller
  matrix: bad/expired/reused code, PKCE mismatch, disabled provider, redirect
  allowlist, TOTP-through-bridge). Full suite green (271).

Refs docs/website/BACKEND_DESIGN.md, docs/android/PLAN.md §9 (M9).

Co-Authored-By: Claude <noreply@anthropic.com>
feat(auth): Active Devices — view/revoke mobile sessions
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m16s
PR Checks / bot-install (pull_request) Successful in 9m17s
e3dd5358b6
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>
whitlocktech approved these changes 2026-07-20 22:11:59 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-20 22:12:04 +00:00
whitlocktech merged commit d37c3a46a9 into main 2026-07-20 22:27:24 +00:00
whitlocktech deleted branch feature/mobile-sso-bridge 2026-07-20 22:27:25 +00:00
Sign in to join this conversation.
No description provided.