diff --git a/client/src/api/client.js b/client/src/api/client.js index 90c8845..d227a3b 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -68,6 +68,10 @@ export const api = { logout: () => req('/auth/logout', { method: 'POST' }), // Public SSO provider discovery — drives the login-page provider buttons. authProviders: () => req('/auth/providers'), + // Active mobile device sessions (role-agnostic self-service under /auth/me). + // List the active ones and revoke a single device by its session id. + mySessions: () => req('/auth/me/sessions'), + revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }), // ----- public ----- publicSettings: () => req('/public/settings'), diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx index e9984cb..6bb6fc5 100644 --- a/client/src/routes/player/PlayerAccount.jsx +++ b/client/src/routes/player/PlayerAccount.jsx @@ -295,6 +295,73 @@ function LinkedAccounts() { ) } +// ── Active mobile device sessions ────────────────────────────────────────── +function ActiveDevices() { + const [sessions, setSessions] = useState(null) + const [error, setError] = useState('') + const [busyId, setBusyId] = useState(null) + + const load = useCallback(async () => { + try { + setSessions(await api.mySessions()) + } catch { + setError('Could not load your devices.') + } + }, []) + useEffect(() => { load() }, [load]) + + async function revoke(id) { + if (!window.confirm('Sign this device out? It will need to sign in again.')) return + setBusyId(id) + try { + await api.revokeMySession(id) + await load() + } catch (err) { + setError(err.message || 'Could not sign that device out.') + } finally { + setBusyId(null) + } + } + + const fmt = (d) => { + const t = d ? new Date(d) : null + return t && !Number.isNaN(t.getTime()) ? t.toLocaleString() : '—' + } + + if (error) return ( + + ) + if (!sessions) return null + + return ( + + + Devices signed in to the mobile app. Sign one out to revoke its access — it may keep working for + a few minutes until its current token expires. + + {sessions.length === 0 ? ( + No mobile devices are signed in. + ) : ( + + {sessions.map((s) => ( + + + + {s.deviceName || s.userAgent || 'Mobile device'} + + Last active {fmt(s.lastUsedAt)} + + revoke(s.id)} disabled={busyId === s.id} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}> + {busyId === s.id ? 'Signing out…' : 'Sign out'} + + + ))} + + )} + + ) +} + // ── Shared bits ──────────────────────────────────────────────────────────── function Section({ title, children }) { return ( @@ -347,6 +414,7 @@ export default function PlayerAccount() { + > )} diff --git a/client/src/routes/player/PlayerLogin.jsx b/client/src/routes/player/PlayerLogin.jsx index 5e898d6..870385b 100644 --- a/client/src/routes/player/PlayerLogin.jsx +++ b/client/src/routes/player/PlayerLogin.jsx @@ -101,7 +101,14 @@ export default function PlayerLogin() { setBusy(true) try { if (ssoTotp) { - const { returnTo } = await ssoLoginTotp(code) + const { returnTo, redirect } = await ssoLoginTotp(code) + // Native SSO bridge (M9): a mobile 2FA completion returns an absolute + // deep link (e.g. runicgateway://…) to hand the app its one-time code. + // React Router can't navigate a custom scheme, so leave the SPA for it. + if (redirect) { + window.location.href = redirect + return + } navigate(returnTo || '/account', { replace: true }) } else { const u = await loginTotp(challenge, code) diff --git a/server/db/schema.sql b/server/db/schema.sql index d46e57b..7b3c50b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -188,8 +188,10 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens ( user_id INT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort) + device_name VARCHAR(100) NULL, -- friendly label the app may send (M9) user_agent VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME NULL, -- last time this session token was issued/used (M9) expires_at DATETIME NOT NULL, revoked_at DATETIME NULL, CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, @@ -1016,3 +1018,9 @@ ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL; -- so the public Houses browser can list registered houses without pulling in rows -- we only ever saw an IDOC transition for. ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0; + +-- Mobile device sessions (M9): a friendly label the app may send at login, and +-- the last time this session token was issued/used, for the "Active Devices" +-- self-service list. Both nullable and additive; existing rows get them here. +ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS device_name VARCHAR(100) NULL; +ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME NULL; diff --git a/server/src/model/mobileSessions/mobileSessions.db.js b/server/src/model/mobileSessions/mobileSessions.db.js index 26271d4..7bd5e09 100644 --- a/server/src/model/mobileSessions/mobileSessions.db.js +++ b/server/src/model/mobileSessions/mobileSessions.db.js @@ -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, diff --git a/server/src/model/mobileSessions/mobileSessions.model.js b/server/src/model/mobileSessions/mobileSessions.model.js index 6e58c1f..70c9429 100644 --- a/server/src/model/mobileSessions/mobileSessions.model.js +++ b/server/src/model/mobileSessions/mobileSessions.model.js @@ -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, diff --git a/server/src/router/v1/admin/account.controller.js b/server/src/router/v1/admin/account.controller.js index 4bd4b38..a2cf5ab 100644 --- a/server/src/router/v1/admin/account.controller.js +++ b/server/src/router/v1/admin/account.controller.js @@ -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, } diff --git a/server/src/router/v1/auth/me.routes.js b/server/src/router/v1/auth/me.routes.js index 357f3a3..212dbe5 100644 --- a/server/src/router/v1/auth/me.routes.js +++ b/server/src/router/v1/auth/me.routes.js @@ -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 diff --git a/server/src/router/v1/auth/mobile.controller.js b/server/src/router/v1/auth/mobile.controller.js index bf6c905..5651fb0 100644 --- a/server/src/router/v1/auth/mobile.controller.js +++ b/server/src/router/v1/auth/mobile.controller.js @@ -37,11 +37,14 @@ function tokenResponse(out, user) { // Persist a freshly minted refresh token (by hash) and record the login. Shared // by login and refresh so the storage/side-effect logic lives in one place. -async function persistAndFinish(req, user, out, action) { +// `deviceName` is the friendly label for the Active Devices list — supplied by +// the client at login and carried forward across rotations on refresh. +async function persistAndFinish(req, user, out, action, deviceName = null) { await mobileSessions.store({ userId: user.id, tokenHash: out.refreshHash, deviceHash: out.deviceHash, + deviceName, userAgent: out.userAgent, expiresAt: out.refreshExpiresAt, }) @@ -79,7 +82,7 @@ async function login(req, res) { loginProtection.recordSuccess(req.ip) const meta = sessionService.sessionMeta(req) const out = sessionService.createMobileSession(user, meta) - await persistAndFinish(req, user, out, 'auth.mobile.login') + await persistAndFinish(req, user, out, 'auth.mobile.login', req.body.device_name || null) log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip }) return res.json(tokenResponse(out, user)) } catch (err) { @@ -111,7 +114,9 @@ async function refresh(req, res) { await mobileSessions.revokeByHash(hash) // rotate: old token is now dead const meta = sessionService.sessionMeta(req) const out = sessionService.refreshMobileSession(user, meta) - await persistAndFinish(req, user, out, 'auth.mobile.refresh') + // Carry the device label forward across rotation so the Active Devices list + // stays labeled for the life of the session. + await persistAndFinish(req, user, out, 'auth.mobile.refresh', row.device_name || null) log.info('mobile session refreshed', { id: user.id, ip: req.ip }) return res.json(tokenResponse(out, user)) } catch (err) { diff --git a/server/src/router/v1/auth/mobile.routes.js b/server/src/router/v1/auth/mobile.routes.js index d5459f0..c530051 100644 --- a/server/src/router/v1/auth/mobile.routes.js +++ b/server/src/router/v1/auth/mobile.routes.js @@ -36,6 +36,8 @@ mobileRouter.post( body('password').isString().notEmpty(), // Optional TOTP code (single-request 2FA); only checked when the account has 2FA on. body('code').optional().isString().trim().isLength({ min: 6, max: 8 }), + // Optional friendly device label for the Active Devices list. + body('device_name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }), validate, login, ) diff --git a/server/src/router/v1/auth/mobileSso.controller.js b/server/src/router/v1/auth/mobileSso.controller.js index 06f1a41..fdb1299 100644 --- a/server/src/router/v1/auth/mobileSso.controller.js +++ b/server/src/router/v1/auth/mobileSso.controller.js @@ -131,6 +131,7 @@ async function exchange(req, res) { userId: user.id, tokenHash: out.refreshHash, deviceHash: out.deviceHash, + deviceName: req.body.device_name || null, userAgent: out.userAgent, expiresAt: out.refreshExpiresAt, }) diff --git a/server/src/router/v1/auth/mobileSso.routes.js b/server/src/router/v1/auth/mobileSso.routes.js index 8f76ec0..e33b8e6 100644 --- a/server/src/router/v1/auth/mobileSso.routes.js +++ b/server/src/router/v1/auth/mobileSso.routes.js @@ -48,6 +48,7 @@ mobileSsoRouter.post( 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, ) diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index e743f9a..2388067 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1770,6 +1770,118 @@ ] } }, + "/api/v1/auth/me/sessions": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "List active mobile device sessions (self)", + "description": "Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.", + "responses": { + "200": { + "description": "Active device sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceSession" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/sessions/{id}": { + "delete": { + "tags": [ + "Auth · Me" + ], + "summary": "Revoke one mobile device session (self)", + "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).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The session row id from GET /auth/me/sessions." + } + ], + "responses": { + "200": { + "description": "Revoked (idempotent)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "revoked": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/auth/me/devices": { "post": { "tags": [ @@ -11113,6 +11225,23 @@ "example": "123456" } } + }, + "device_name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Optional friendly device label for Active Devices." + }, + "example": { + "type": "string", + "example": "Pixel 8" + } + } } } } @@ -11299,6 +11428,119 @@ "example": "The PKCE verifier for the challenge sent to /auth/mobile/sso/start." } } + }, + "device_name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Optional friendly device label for Active Devices." + }, + "example": { + "type": "string", + "example": "Pixel 8" + } + } + } + } + } + } + }, + "DeviceSession": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Session row id (pass to DELETE /auth/me/sessions/:id)." + } + } + }, + "deviceName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Pixel 8" + } + } + }, + "userAgent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "lastUsedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "expiresAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 870a2fd..4fbfde1 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -158,6 +158,7 @@ const doc = { username: { type: 'string', example: 'admin' }, password: { type: 'string', format: 'password', example: 'super-secret' }, code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' }, + device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' }, }, }, MobileTokenResponse: { @@ -197,6 +198,18 @@ const doc = { type: 'string', description: 'The PKCE verifier for the challenge sent to /auth/mobile/sso/start.', }, + device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' }, + }, + }, + DeviceSession: { + type: 'object', + properties: { + id: { type: 'integer', description: 'Session row id (pass to DELETE /auth/me/sessions/:id).' }, + deviceName: { type: 'string', nullable: true, example: 'Pixel 8' }, + userAgent: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + lastUsedAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: 'string', format: 'date-time' }, }, }, Message: { diff --git a/server/test/mobileDeviceSessions.test.js b/server/test/mobileDeviceSessions.test.js new file mode 100644 index 0000000..773ac51 --- /dev/null +++ b/server/test/mobileDeviceSessions.test.js @@ -0,0 +1,65 @@ +// Active Devices (mobile session view/revoke) — controller tests. The +// mobileSessions model is stubbed, so these are DB-free. +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret' +process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key' +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const account = require('../src/router/v1/admin/account.controller') +const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model') +const activity = require('../src/model/activity/activity.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +let logged +beforeEach(() => { + logged = [] + activity.log = async (e) => { logged.push(e) } +}) + +function res() { + return { + statusCode: 200, body: null, + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, + } +} + +test('listSessions returns active devices without any token material', async () => { + mobileSessions.listActiveForUser = async (uid) => { + assert.equal(uid, 7) + return [ + { id: 3, device_name: 'Pixel 8', user_agent: 'okhttp', created_at: 'c3', last_used_at: 'u3', expires_at: 'e3', token_hash: 'SECRET' }, + { id: 1, device_name: null, user_agent: null, created_at: 'c1', last_used_at: null, expires_at: 'e1' }, + ] + } + const r = res() + await account.listSessions({ user: { id: 7 } }, r) + assert.equal(r.body.length, 2) + assert.deepEqual(r.body[0], { id: 3, deviceName: 'Pixel 8', userAgent: 'okhttp', createdAt: 'c3', lastUsedAt: 'u3', expiresAt: 'e3' }) + // last_used_at falls back to created_at when null; no token/hash leaks. + assert.equal(r.body[1].lastUsedAt, 'c1') + assert.equal(JSON.stringify(r.body).includes('SECRET'), false) +}) + +test('revokeSession is ownership-scoped and audits a real revoke', async () => { + let args = null + mobileSessions.revokeByIdForUser = async (id, uid) => { args = [id, uid]; return 1 } + const r = res() + await account.revokeSession({ user: { id: 7 }, params: { id: '3' } }, r) + assert.deepEqual(args, [3, 7], 'revokes by id scoped to the caller') + assert.deepEqual(r.body, { revoked: true }) + assert.equal(logged.at(-1).action, 'auth.mobile.session.revoke') +}) + +test('revokeSession on a non-owned/absent id is idempotent and not audited', async () => { + mobileSessions.revokeByIdForUser = async () => 0 + const r = res() + await account.revokeSession({ user: { id: 7 }, params: { id: '999' } }, r) + assert.deepEqual(r.body, { revoked: false }) + assert.equal(logged.length, 0, 'nothing revoked → nothing logged') +})
+ Devices signed in to the mobile app. Sign one out to revoke its access — it may keep working for + a few minutes until its current token expires. +
No mobile devices are signed in.