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

@@ -3,16 +3,41 @@ const { query } = require('../../utils/db')
// SQL for the mobile_refresh_tokens table. Tokens are stored only as sha256
// hashes (token_hash); the raw refresh token never touches the database.
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch).
async function insert({ userId, tokenHash, deviceHash = null, userAgent = null, expiresAt }) {
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch). last_used_at
// is seeded to now: with single-use rotation each login/refresh inserts a fresh
// row, so the current active row's timestamp IS the session's last activity.
async function insert({ userId, tokenHash, deviceHash = null, deviceName = null, userAgent = null, expiresAt }) {
const res = await query(
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, user_agent, expires_at)
VALUES (?, ?, ?, ?, ?)`,
[userId, tokenHash, deviceHash, userAgent, new Date(expiresAt)],
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, device_name, user_agent, expires_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())`,
[userId, tokenHash, deviceHash, deviceName, userAgent, new Date(expiresAt)],
)
return res.insertId
}
// List a user's currently-active (unrevoked, unexpired) sessions — one row per
// live device, newest first. Never returns the token hash. For the "Active
// Devices" self-service surface.
async function listActiveForUser(userId) {
return query(
`SELECT id, device_name, device_hash, user_agent, created_at, last_used_at, expires_at
FROM mobile_refresh_tokens
WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY last_used_at DESC, created_at DESC`,
[userId],
)
}
// Revoke one of a user's sessions by row id (ownership-scoped so a user can only
// revoke their own). Idempotent; returns rows changed.
async function revokeByIdForUser(id, userId) {
const res = await query(
'UPDATE mobile_refresh_tokens SET revoked_at = NOW() WHERE id = ? AND user_id = ? AND revoked_at IS NULL',
[id, userId],
)
return Number(res.affectedRows || 0)
}
// Look up a token by hash only if it is still usable: not revoked and not past
// its expiry. Returns the row (incl. user_id) or null.
async function findValidByHash(tokenHash) {
@@ -55,6 +80,8 @@ async function pruneExpired() {
module.exports = {
insert,
listActiveForUser,
revokeByIdForUser,
findValidByHash,
revokeByHash,
revokeAllForUser,

View File

@@ -6,8 +6,19 @@
const db = require('./mobileSessions.db')
// Persist a newly issued refresh token (by hash). Returns the row id.
async function store({ userId, tokenHash, deviceHash, userAgent, expiresAt }) {
return db.insert({ userId, tokenHash, deviceHash, userAgent, expiresAt })
async function store({ userId, tokenHash, deviceHash, deviceName, userAgent, expiresAt }) {
return db.insert({ userId, tokenHash, deviceHash, deviceName, userAgent, expiresAt })
}
// List a user's active sessions (one per live device) for the Active Devices UI.
async function listActiveForUser(userId) {
return db.listActiveForUser(userId)
}
// Revoke one of a user's own sessions by row id. Returns rows changed (0 if it
// wasn't theirs / already gone — callers treat this idempotently).
async function revokeByIdForUser(id, userId) {
return db.revokeByIdForUser(id, userId)
}
// Return the stored row for a still-valid (unrevoked, unexpired) token, else null.
@@ -33,6 +44,8 @@ async function pruneExpired() {
module.exports = {
store,
listActiveForUser,
revokeByIdForUser,
findValidByHash,
revokeByHash,
revokeAllForUser,