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:
@@ -5,6 +5,7 @@
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const { setAuthCookie } = require('../../../auth/token')
|
||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||
@@ -207,6 +208,44 @@ async function unlinkIdentity(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// List the current user's active mobile device sessions (the "Active Devices"
|
||||
// surface). Never exposes the token hash — only labels + timestamps.
|
||||
async function listSessions(req, res) {
|
||||
try {
|
||||
const rows = await mobileSessions.listActiveForUser(req.user.id)
|
||||
return res.json(
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
deviceName: r.device_name || null,
|
||||
userAgent: r.user_agent || null,
|
||||
createdAt: r.created_at,
|
||||
lastUsedAt: r.last_used_at || r.created_at,
|
||||
expiresAt: r.expires_at,
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('listSessions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke one of the current user's mobile device sessions by id (ownership-scoped
|
||||
// in the query so a user can only revoke their own). Idempotent.
|
||||
async function revokeSession(req, res) {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const n = await mobileSessions.revokeByIdForUser(id, req.user.id)
|
||||
if (n) {
|
||||
await activity.log({ req, action: 'auth.mobile.session.revoke', detail: { sessionRowId: id } })
|
||||
log.info('mobile session revoked (self)', { id, userId: req.user.id })
|
||||
}
|
||||
return res.json({ revoked: n > 0 })
|
||||
} catch (err) {
|
||||
log.error('revokeSession', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccount,
|
||||
changeUsername,
|
||||
@@ -216,4 +255,6 @@ module.exports = {
|
||||
totpDisable,
|
||||
listIdentities,
|
||||
unlinkIdentity,
|
||||
listSessions,
|
||||
revokeSession,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user