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>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user