Files
website/server/src/router/v1/auth/mobileSso.routes.js
wtclaude e3dd5358b6
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
feat(auth): Active Devices — view/revoke mobile sessions
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>
2026-07-20 17:01:47 -05:00

57 lines
4.7 KiB
JavaScript

const express = require('express')
const { body, query } = require('express-validator')
const { start, exchange } = require('./mobileSso.controller')
const { mobileSsoStartLimiter, mobileSsoExchangeLimiter } = require('../../../middleware/rateLimit')
const validate = require('../../../middleware/validate')
// Mobile SSO authorization bridge (M9). Mounted at /auth/mobile/sso. Native
// "Sign in with Google/Discord" that reuses the website's SSO flow and terminates
// in the existing mobile bearer tokens — no OAuth secret ever ships in the app.
// Provider discovery reuses GET /auth/providers; refresh/logout reuse the existing
// /auth/mobile/{refresh,logout}. See docs BACKEND_DESIGN §4 + docs/android/PLAN.md §9.
const mobileSsoRouter = express.Router()
// GET /auth/mobile/sso/start — opened by the app in a Custom Tab; 302s to the IdP.
mobileSsoRouter.get(
'/start',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Begin native SSO login (redirect to the IdP)'
// #swagger.description = 'Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.'
// #swagger.parameters['provider'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'Provider id from GET /auth/providers (e.g. google, discord).' }
// #swagger.parameters['code_challenge'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'App-generated PKCE S256 challenge (base64url).' }
// #swagger.parameters['state'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'App-generated opaque CSRF value, echoed on the callback for the app to verify.' }
// #swagger.parameters['redirect_uri'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback).' }
/* #swagger.responses[302] = { description: 'Redirect to the identity provider (or back to the app callback on error)' } */
/* #swagger.responses[400] = { description: 'Unrecognized redirect URI or validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
mobileSsoStartLimiter,
query('provider').isString().trim().isLength({ min: 1, max: 40 }),
query('code_challenge').isString().trim().isLength({ min: 20, max: 255 }),
query('state').isString().trim().isLength({ min: 8, max: 255 }),
query('redirect_uri').isString().trim().isLength({ min: 1, max: 255 }),
validate,
start,
)
// POST /auth/mobile/sso/exchange — code + PKCE verifier → mobile bearer tokens.
mobileSsoRouter.post(
'/exchange',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Exchange an SSO authorization code for mobile tokens'
// #swagger.description = 'Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileSsoExchangeRequest" } } } } */
/* #swagger.responses[200] = { description: 'Access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid/expired/used code or failed PKCE verification', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
mobileSsoExchangeLimiter,
body('code').isString().trim().isLength({ min: 20, max: 255 }),
body('code_verifier').isString().trim().isLength({ min: 20, max: 255 }),
body('device_name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
validate,
exchange,
)
module.exports = mobileSsoRouter