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

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>
This commit is contained in:
2026-07-20 17:01:47 -05:00
parent 61f4591a6b
commit e3dd5358b6
15 changed files with 535 additions and 11 deletions

View File

@@ -137,4 +137,31 @@ meRouter.delete(
account.unlinkIdentity,
)
// Active mobile device sessions (self-service). Distinct from /auth/me/devices,
// which is push-notification endpoints — these are login sessions (M9). List the
// active ones and revoke a single device without "log out everywhere".
meRouter.get(
'/sessions',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List active mobile device sessions (self)'
// #swagger.description = 'Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Active device sessions', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/DeviceSession" } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.listSessions,
)
meRouter.delete(
'/sessions/:id',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Revoke one mobile device session (self)'
// #swagger.description = 'Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The session row id from GET /auth/me/sessions.' }
/* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
account.revokeSession,
)
module.exports = meRouter