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:
65
server/test/mobileDeviceSessions.test.js
Normal file
65
server/test/mobileDeviceSessions.test.js
Normal file
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user