diff --git a/client/src/api/client.js b/client/src/api/client.js
index 2942301..6f522a6 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -71,8 +71,10 @@ export const api = {
resetPassword: (token, password) =>
req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
- // the callback, so only the code is sent). Returns { user, returnTo }.
- ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
+ // the callback, so only the code is sent). `extra` carries the trustDevice/
+ // deviceName opt-in, same as the password path. Returns { user, returnTo } — plus
+ // { trustLimitReached, devices } when trust was asked for but the cap is reached.
+ ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }),
logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'),
diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx
index c92f164..5b6dce6 100644
--- a/client/src/contexts/AuthContext.jsx
+++ b/client/src/contexts/AuthContext.jsx
@@ -49,9 +49,11 @@ export function AuthProvider({ children }) {
}, [])
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
- // an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
- const ssoLoginTotp = useCallback(async (code) => {
- const data = await api.ssoLoginTotp(code)
+ // an httpOnly cookie, so only the code is sent. `extra` carries the trustDevice/
+ // deviceName opt-in. Returns the full payload ({ user, returnTo,
+ // trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
+ const ssoLoginTotp = useCallback(async (code, extra) => {
+ const data = await api.ssoLoginTotp(code, extra)
setUser(data.user)
return data
}, [])
diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx
index bea6c35..bd71039 100644
--- a/client/src/routes/admin/AdminLogin.jsx
+++ b/client/src/routes/admin/AdminLogin.jsx
@@ -123,8 +123,16 @@ export default function AdminLogin() {
setBusy(true)
try {
if (ssoTotp) {
- const { returnTo } = await ssoLoginTotp(code)
- navigate(returnTo || '/admin', { replace: true })
+ // Trust works on the SSO second factor exactly as it does on the password
+ // one — the IdP already proved the first factor.
+ const data = await ssoLoginTotp(code.trim(), { trustDevice })
+ const to = data.returnTo || '/admin'
+ if (data.trustLimitReached) {
+ setTrustLimit({ devices: data.devices || [], dest: to })
+ setBusy(false)
+ return
+ }
+ navigate(to, { replace: true })
} else {
const entered = code.trim()
const data = await loginTotp(challenge, useRecovery ? '' : entered, {
@@ -251,12 +259,14 @@ export default function AdminLogin() {
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
- {!ssoTotp && (
-
- setTrustDevice(e.target.checked)} />
- Trust this device for 30 days (skip the code next time)
-
- )}
+ {/* Offered on the SSO second factor too — the trust is on the device,
+ not on how the first factor was proved. */}
+
+ setTrustDevice(e.target.checked)} />
+ Trust this device for 30 days (skip the code next time)
+
+ {/* Recovery codes remain password-login only: the SSO second step
+ verifies an authenticator code against the staged challenge. */}
{!ssoTotp && (
- {/* Trust-this-device only applies to real authenticator/recovery login,
- not the SSO 2FA bounce (which has no trust cookie flow here). */}
- {!ssoTotp && (
-
- setTrustDevice(e.target.checked)} />
- Trust this device for 30 days (skip the code next time)
-
- )}
+ {/* Offered on the SSO second factor too — the trust is on the device,
+ not on how the first factor was proved. Inside the app's Custom Tab
+ this is also what trusts the device for future native sign-ins. */}
+
+ setTrustDevice(e.target.checked)} />
+ Trust this device for 30 days (skip the code next time)
+
+ {/* Recovery codes remain password-login only: the SSO second step
+ verifies an authenticator code against the staged challenge. */}
{!ssoTotp && (
NOW()`,
+ [sessionId],
+ )
+ return Number(res.affectedRows || 0)
+}
+
// Mark a session `consumed` after a successful token exchange (stamps used_at).
async function consumeSession(sessionId) {
const res = await query(
@@ -93,6 +107,7 @@ module.exports = {
insertSession,
getSession,
completeSession,
+ setTrustDevice,
consumeSession,
insertCode,
findValidCode,
diff --git a/server/src/model/mobileAuthBridge/mobileAuthBridge.model.js b/server/src/model/mobileAuthBridge/mobileAuthBridge.model.js
index 136f914..d04dc8f 100644
--- a/server/src/model/mobileAuthBridge/mobileAuthBridge.model.js
+++ b/server/src/model/mobileAuthBridge/mobileAuthBridge.model.js
@@ -79,6 +79,14 @@ async function consumeCode(rawCode) {
return changed > 0
}
+// Flag that the user ticked "trust this device" on the Custom Tab TOTP form. The
+// exchange step reads this to decide whether to mint the app's own trust token.
+// Returns true iff the session was still eligible to be flagged.
+async function markTrustRequested(sessionId) {
+ if (!sessionId) return false
+ return (await db.setTrustDevice(sessionId)) > 0
+}
+
// Mark a session fully consumed after a successful exchange.
async function finishSession(sessionId) {
return db.consumeSession(sessionId)
@@ -97,6 +105,7 @@ module.exports = {
issueAuthCode,
findRedeemableCode,
consumeCode,
+ markTrustRequested,
finishSession,
pruneExpired,
}
diff --git a/server/src/router/v1/auth/mobileSso.controller.js b/server/src/router/v1/auth/mobileSso.controller.js
index e8dbe5c..064b99e 100644
--- a/server/src/router/v1/auth/mobileSso.controller.js
+++ b/server/src/router/v1/auth/mobileSso.controller.js
@@ -22,6 +22,7 @@ const settings = require('../../../model/settings/settings.model')
const sessionService = require('../../../auth/session.service')
const ssoState = require('../../../auth/ssoState')
const ssoController = require('./sso.controller')
+const { establishTrust } = require('./trustDevice.helper')
const log = require('../../../utils/logger')('auth-mobile-sso')
@@ -163,12 +164,36 @@ async function exchange(req, res) {
expiresAt: out.refreshExpiresAt,
})
await activity.log({ req, userId: user.id, action: 'auth.mobile.login', detail: { sso: sess.provider } })
+
+ // The user ticked "trust this device" on the Custom Tab TOTP form. That already
+ // set the browser's rg_trust cookie (which is what lets the NEXT Custom Tab SSO
+ // sign-in skip the code); mint the app its OWN trust token here so a native
+ // password login on the same device skips the code too. Minting at this point
+ // — an authenticated app→server call — is deliberate: the token reaches the app
+ // in a JSON body and never travels in the deep-link URL or sits in the bridge
+ // row. Best-effort: a device at the trust cap just gets no token, never a failed
+ // sign-in, so this can't turn a good login into an error.
+ let trustToken = null
+ if (sess.trust_device) {
+ try {
+ const trust = await establishTrust(req, user, {
+ platform: 'mobile',
+ deviceName: req.body.device_name || null,
+ })
+ if (trust.ok) trustToken = trust.trustToken
+ else if (trust.capReached) log.info('mobile sso: trust refused, device cap reached', { id: user.id })
+ } catch (err) {
+ log.error('mobile sso: could not establish trust (continuing, sign-in already succeeded)', err)
+ }
+ }
+
log.info('mobile sso exchange success', { id: user.id, provider: sess.provider, ip: req.ip })
return res.json({
accessToken: out.accessToken,
refreshToken: out.refreshToken,
expiresIn: out.expiresIn,
user: { id: user.id, username: user.username, role: user.role },
+ ...(trustToken ? { trustToken } : {}),
})
} catch (err) {
log.error('mobile sso exchange', err)
diff --git a/server/src/router/v1/auth/mobileSso.routes.js b/server/src/router/v1/auth/mobileSso.routes.js
index e33b8e6..b24843c 100644
--- a/server/src/router/v1/auth/mobileSso.routes.js
+++ b/server/src/router/v1/auth/mobileSso.routes.js
@@ -39,9 +39,9 @@ mobileSsoRouter.post(
'/exchange',
// #swagger.tags = ['Auth · Mobile']
// #swagger.summary = 'Exchange an SSO authorization code for mobile tokens'
- // #swagger.description = 'Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.'
+ // #swagger.description = 'Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401. If the user ticked "trust this device" on the TOTP form during this flow, the response also carries { trustToken } for the app to store and replay via X-Trust-Token — minted here rather than passed through the deep link so it never appears in a URL.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MobileSsoExchangeRequest" } } } } */
- /* #swagger.responses[200] = { description: 'Access + refresh tokens', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
+ /* #swagger.responses[200] = { description: 'Access + refresh tokens (optionally with a trustToken to persist)', content: { "application/json": { schema: { $ref: "#/components/schemas/MobileTokenResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Invalid/expired/used code or failed PKCE verification', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
diff --git a/server/src/router/v1/auth/sso.controller.js b/server/src/router/v1/auth/sso.controller.js
index 1697d56..cb51f18 100644
--- a/server/src/router/v1/auth/sso.controller.js
+++ b/server/src/router/v1/auth/sso.controller.js
@@ -26,6 +26,7 @@ const usernamePolicy = require('../../../auth/usernamePolicy')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
const { needsTotp } = require('./auth.controller')
+const { establishTrust } = require('./trustDevice.helper')
const log = require('../../../utils/logger')('sso')
@@ -226,6 +227,29 @@ async function provisionSsoPlayer(req, providerId, profile) {
return null
}
+// Trusted-device skip for the SSO paths — the exact analogue of the check in
+// auth.controller.login, and the reason SSO used to demand a code on every single
+// sign-in even from a browser the user had explicitly trusted.
+//
+// The first factor here is the IdP authentication that just succeeded, so skipping
+// the SECOND factor on a device the user deliberately trusted is the same posture
+// as the password path. The trust must belong to THIS user (a trust token is
+// scoped to the account that minted it), and any store hiccup falls through to the
+// normal TOTP challenge — fail closed to asking for the code.
+async function trustedDeviceSkips(req, user, providerId) {
+ try {
+ const device = await sessionService.resolveTrustedDevice(req)
+ if (!device || device.user_id !== user.id) return false
+ await sessionService.honorTrustedDevice(device.id)
+ await activity.log({ req, userId: user.id, action: 'auth.login.trusted_device', detail: { provider: providerId, sso: true } })
+ log.info('sso login via trusted device (TOTP skipped)', { provider: providerId, id: user.id, ip: req.ip })
+ return true
+ } catch (err) {
+ log.error('sso trusted-device check failed; falling back to TOTP', err)
+ return false
+ }
+}
+
// SSO login. Normally link-only: a login succeeds only if the external identity
// is already linked. The one setting-gated relaxation is auto-provisioning a
// player when player_registration ∈ {sso, both} (see provisionSsoPlayer).
@@ -257,10 +281,11 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
// 2FA parity with the local login (auth.controller): if the account has TOTP
- // enabled, an SSO sign-in must NOT bypass the second factor. Stage a signed,
- // httpOnly challenge and route the browser through the TOTP form instead of
- // minting a session here. See issue #31.
- if (needsTotp(user)) {
+ // enabled, an SSO sign-in must NOT bypass the second factor — unless this browser
+ // is a trusted device, which skips the second factor exactly as it does for a
+ // password login. Otherwise stage a signed, httpOnly challenge and route the
+ // browser through the TOTP form instead of minting a session here. See issue #31.
+ if (needsTotp(user) && !(await trustedDeviceSkips(req, user, providerId))) {
const pending = ssoState.createTotpPending({
userId: user.id,
provider: providerId,
@@ -381,10 +406,17 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
- if (needsTotp(user)) {
- // Same second-factor gate as web: stage a signed pending-TOTP cookie (now
- // carrying the bridge session) and route the Custom Tab through the player
- // TOTP form. finishSsoTotp completes the mobile flow on a correct code.
+ // Same second-factor gate as web, including the trusted-device skip. This request
+ // is the IdP redirect landing in the app's Custom Tab, which shares the system
+ // browser's cookie jar — so an rg_trust cookie set by a previous SSO sign-in from
+ // this app IS presented here, and the app gets the same "don't ask me again"
+ // behaviour as the website without having to inject a header into a tab it does
+ // not control. (Putting the token in the start URL instead would leak a secret
+ // into query strings and logs.)
+ if (needsTotp(user) && !(await trustedDeviceSkips(req, user, providerId))) {
+ // Stage a signed pending-TOTP cookie (now carrying the bridge session) and route
+ // the Custom Tab through the player TOTP form. finishSsoTotp completes the
+ // mobile flow on a correct code.
const pending = ssoState.createTotpPending({
userId: user.id,
provider: providerId,
@@ -432,6 +464,19 @@ async function finishSsoTotp(req, res) {
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
loginProtection.recordSuccess(req.ip)
+ // Optionally remember this browser, exactly as the password path does. On the
+ // mobile flow this browser IS the Custom Tab, so the cookie set here is what
+ // lets the NEXT app sign-in skip the code.
+ let trustLimit = null
+ if (req.body.trustDevice) {
+ const result = await establishTrust(req, user, {
+ platform: 'web',
+ deviceName: req.body.deviceName || null,
+ })
+ if (result.ok) token.setTrustCookie(req, res, result.trustToken)
+ else if (result.capReached) trustLimit = result.devices
+ }
+
// Mobile SSO bridge: instead of a web session, mint the one-time auth code and
// return a deep link for the app to redeem. The second factor is now complete,
// so the code is issued no earlier than an ordinary web session would be.
@@ -440,11 +485,18 @@ async function finishSsoTotp(req, res) {
if (!sess || sess.status !== 'pending' || new Date(sess.expires_at).getTime() <= Date.now()) {
return res.status(401).json({ message: 'Your sign-in session expired. Please sign in again from the app.' })
}
+ // Record the user's choice on the bridge session (a boolean — never the token)
+ // so /auth/mobile/sso/exchange can mint the APP's own trust token and hand it
+ // back over that authenticated app→server call. The token therefore never
+ // travels in the deep-link URL.
+ if (req.body.trustDevice && !trustLimit) {
+ await mobileBridge.markTrustRequested(sess.session_id)
+ }
const link = await mintMobileAuthLink(req, sess, user, pending.provider, true)
if (!link) {
return res.status(409).json({ message: 'This sign-in session was already used. Please sign in again from the app.' })
}
- return res.json({ redirect: link })
+ return res.json({ redirect: link, ...(trustLimit ? { trustLimitReached: true, devices: trustLimit } : {}) })
}
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
@@ -456,6 +508,7 @@ async function finishSsoTotp(req, res) {
return res.json({
user: { id: user.id, username: user.username, role: user.role },
returnTo: sanitizeReturn(pending.returnTo) || homePath(portalFor(pending.returnTo)),
+ ...(trustLimit ? { trustLimitReached: true, devices: trustLimit } : {}),
})
} catch (err) {
log.error('sso totp error', err)
diff --git a/server/src/router/v1/auth/sso.routes.js b/server/src/router/v1/auth/sso.routes.js
index 6665b79..07ebf08 100644
--- a/server/src/router/v1/auth/sso.routes.js
+++ b/server/src/router/v1/auth/sso.routes.js
@@ -67,13 +67,15 @@ ssoRouter.post(
'/sso/totp',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'Complete an SSO login with a TOTP code'
- // #swagger.description = 'Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.'
- /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["code"], properties: { code: { type: "string" } } } } } } */
- /* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/SafeUser" }, returnTo: { type: "string" } } } } } } */
+ // #swagger.description = 'Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Set trustDevice to remember this browser and skip TOTP on future SSO sign-ins (30 days) — on the mobile flow this browser is the app Custom Tab, and the app additionally receives its own trustToken at /auth/mobile/sso/exchange. If the trusted-device limit is reached the sign-in still completes and the response carries { trustLimitReached, devices }. Rate limited and behind bot/backoff guards.'
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["code"], properties: { code: { type: "string" }, trustDevice: { type: "boolean" }, deviceName: { type: "string" } } } } } } */
+ /* #swagger.responses[200] = { description: 'Session issued (web), or a deep link to redeem (mobile bridge); optionally with a trusted-device-limit prompt', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/SafeUser" }, returnTo: { type: "string" }, redirect: { type: "string" }, trustLimitReached: { type: "boolean" }, devices: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } } } */
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
body('code').isString().trim().isLength({ min: 6, max: 8 }),
+ body('trustDevice').optional().isBoolean(),
+ body('deviceName').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
validate,
ctrl.finishSsoTotp,
)
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 9e08dd0..72b23d2 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -8626,10 +8626,10 @@
"Auth · Mobile"
],
"summary": "Exchange an SSO authorization code for mobile tokens",
- "description": "Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.",
+ "description": "Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401. If the user ticked \"trust this device\" on the TOTP form during this flow, the response also carries { trustToken } for the app to store and replay via X-Trust-Token — minted here rather than passed through the deep link so it never appears in a URL.",
"responses": {
"200": {
- "description": "Access + refresh tokens",
+ "description": "Access + refresh tokens (optionally with a trustToken to persist)",
"content": {
"application/json": {
"schema": {
@@ -9057,10 +9057,10 @@
"Auth · SSO"
],
"summary": "Complete an SSO login with a TOTP code",
- "description": "Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.",
+ "description": "Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Set trustDevice to remember this browser and skip TOTP on future SSO sign-ins (30 days) — on the mobile flow this browser is the app Custom Tab, and the app additionally receives its own trustToken at /auth/mobile/sso/exchange. If the trusted-device limit is reached the sign-in still completes and the response carries { trustLimitReached, devices }. Rate limited and behind bot/backoff guards.",
"responses": {
"200": {
- "description": "Session issued",
+ "description": "Session issued (web), or a deep link to redeem (mobile bridge); optionally with a trusted-device-limit prompt",
"content": {
"application/json": {
"schema": {
@@ -9071,6 +9071,18 @@
},
"returnTo": {
"type": "string"
+ },
+ "redirect": {
+ "type": "string"
+ },
+ "trustLimitReached": {
+ "type": "boolean"
+ },
+ "devices": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TrustedDevice"
+ }
}
}
}
@@ -9122,6 +9134,12 @@
"properties": {
"code": {
"type": "string"
+ },
+ "trustDevice": {
+ "type": "boolean"
+ },
+ "deviceName": {
+ "type": "string"
}
}
}
diff --git a/server/test/ssoTrustedDevice.test.js b/server/test/ssoTrustedDevice.test.js
new file mode 100644
index 0000000..644e587
--- /dev/null
+++ b/server/test/ssoTrustedDevice.test.js
@@ -0,0 +1,216 @@
+// Point the DB at a closed port BEFORE requiring the controller (its models build
+// the pool). Every collaborator is monkeypatched, so no query runs.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, after, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Trusted devices on the SSO paths. The gap these lock down: SSO used to jump
+// straight from needsTotp() to staging a challenge, so a browser the user had
+// explicitly trusted was still asked for a code on EVERY Google/Discord sign-in,
+// and the SSO second step had no way to establish trust at all.
+//
+// Covered here:
+// - a trusted device (bound to THEM) skips the SSO second factor;
+// - a trust bound to a DIFFERENT user is ignored — challenge as usual;
+// - a store error falls back to the challenge (fail closed to asking);
+// - the mobile bridge (Custom Tab) gets the same skip;
+// - POST /auth/sso/totp with trustDevice sets the trust cookie, and flags the
+// bridge session so /exchange can mint the app's own token;
+// - at the device cap the sign-in still completes, with a trustLimitReached prompt.
+const ssoCtrl = require('../src/router/v1/auth/sso.controller')
+const users = require('../src/model/users/users.model')
+const activity = require('../src/model/activity/activity.model')
+const userIdentities = require('../src/model/userIdentities/userIdentities.model')
+const sessionService = require('../src/auth/session.service')
+const trustedDevices = require('../src/model/trustedDevices/trustedDevices.model')
+const mobileBridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
+const ssoState = require('../src/auth/ssoState')
+const totp = require('../src/utils/totp')
+const botScore = require('../src/middleware/botScore')
+const loginProtection = require('../src/middleware/loginProtection')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ cookies: {},
+ redirectedTo: null,
+ status(c) { this.statusCode = c; return this },
+ json(b) { this.body = b; return this },
+ cookie(name, val) { this.cookies[name] = val; return this },
+ clearCookie(name) { this.cookies[name] = undefined; return this },
+ redirect(to) { this.redirectedTo = to; return this },
+ }
+}
+
+const TOTP_USER = { id: 5, username: 'gwen', role: 'player', status: 'active', totp_enabled: 1, totp_secret: 'S' }
+
+let sessionsCreated
+const orig = {}
+beforeEach(() => {
+ sessionsCreated = []
+ for (const [mod, name] of [
+ [users, 'getRawById'], [users, 'getById'], [users, 'recordLogin'],
+ [activity, 'log'],
+ [userIdentities, 'findByProviderSubject'],
+ [sessionService, 'createSession'],
+ [sessionService, 'resolveTrustedDevice'], [sessionService, 'honorTrustedDevice'],
+ [sessionService, 'trustDeviceCapReached'], [sessionService, 'mintTrustToken'], [sessionService, 'sessionMeta'],
+ [trustedDevices, 'store'], [trustedDevices, 'listActiveForUser'],
+ [mobileBridge, 'getSession'], [mobileBridge, 'markTrustRequested'], [mobileBridge, 'issueAuthCode'],
+ [ssoState, 'createTotpPending'], [ssoState, 'verifyTotpPending'],
+ [totp, 'verifyCode'],
+ [botScore, 'recordLoginFailure'], [loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'],
+ ]) {
+ orig[name] = orig[name] || { mod, val: mod[name] }
+ }
+ users.recordLogin = async () => {}
+ activity.log = async () => {}
+ botScore.recordLoginFailure = () => {}
+ loginProtection.recordFailure = () => {}
+ loginProtection.recordSuccess = () => {}
+ sessionService.createSession = (user, authMethod) => {
+ sessionsCreated.push({ user, authMethod })
+ return { token: 'session-token' }
+ }
+ sessionService.honorTrustedDevice = async () => true
+ sessionService.sessionMeta = () => ({ deviceHash: 'dh', userAgent: 'UA' })
+ sessionService.trustDeviceCapReached = async () => false
+ sessionService.mintTrustToken = () => ({
+ trustToken: 'raw-trust', trustHash: 'hash', deviceHash: 'dh', userAgent: 'UA', expiresAt: new Date(Date.now() + 1e6),
+ })
+ trustedDevices.store = async () => 1
+ ssoState.createTotpPending = () => 'pending-jwt'
+ userIdentities.findByProviderSubject = async () => ({ user_id: 5 })
+ users.getById = async () => TOTP_USER
+})
+afterEach(() => {
+ for (const key of Object.keys(orig)) { orig[key].mod[key] = orig[key].val; delete orig[key] }
+})
+
+const baseReq = (body = {}) => ({ body, ip: '10.0.0.1', headers: {}, cookies: {} })
+const tx = { returnTo: '/account' }
+const profile = { subject: 'sub-1', email: 'g@example.test' }
+
+// ── finishLogin(): trusted device skips the SSO second factor ──────────────
+test('sso login: a TOTP user on a device trusted by THEM skips the code', async () => {
+ users.getRawById = async () => TOTP_USER
+ sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 5 })
+ let honored = null
+ sessionService.honorTrustedDevice = async (id) => { honored = id }
+
+ const res = mockRes()
+ await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)
+
+ assert.equal(sessionsCreated.length, 1, 'session issued without a TOTP bounce')
+ assert.equal(honored, 11, 'the trusted device was stamped as used')
+ assert.ok(!String(res.redirectedTo).includes('sso_totp'), `did not bounce to the code form (got ${res.redirectedTo})`)
+})
+
+test('sso login: a trust bound to a DIFFERENT user is ignored (code still required)', async () => {
+ sessionService.resolveTrustedDevice = async () => ({ id: 11, user_id: 999 })
+
+ const res = mockRes()
+ await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)
+
+ assert.match(String(res.redirectedTo), /sso_totp=1/)
+ assert.equal(sessionsCreated.length, 0)
+})
+
+test('sso login: a trusted-device lookup error falls back to the code (fail closed)', async () => {
+ sessionService.resolveTrustedDevice = async () => { throw new Error('store down') }
+
+ const res = mockRes()
+ await ssoCtrl.finishLogin(baseReq(), res, 'google', 'sso', tx, profile)
+
+ assert.match(String(res.redirectedTo), /sso_totp=1/)
+ assert.equal(sessionsCreated.length, 0)
+})
+
+// ── finishSsoTotp(): the second step can now establish trust ───────────────
+test('sso totp: trustDevice sets the trust cookie and completes the sign-in', async () => {
+ ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
+ users.getRawById = async () => TOTP_USER
+ totp.verifyCode = () => true
+
+ const res = mockRes()
+ await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true, deviceName: 'Kitchen laptop' }), res)
+
+ assert.equal(res.statusCode, 200)
+ assert.equal(res.cookies.rg_trust, 'raw-trust', 'trust cookie issued')
+ assert.equal(res.body.user.id, 5)
+ assert.equal(res.body.trustLimitReached, undefined)
+})
+
+test('sso totp: without trustDevice no trust cookie is set', async () => {
+ ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
+ users.getRawById = async () => TOTP_USER
+ totp.verifyCode = () => true
+
+ const res = mockRes()
+ await ssoCtrl.finishSsoTotp(baseReq({ code: '123456' }), res)
+
+ assert.equal(res.statusCode, 200)
+ assert.equal(res.cookies.rg_trust, undefined)
+})
+
+test('sso totp: at the device cap the sign-in still succeeds, with a trustLimitReached prompt', async () => {
+ ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', returnTo: '/account' })
+ users.getRawById = async () => TOTP_USER
+ totp.verifyCode = () => true
+ sessionService.trustDeviceCapReached = async () => true
+ trustedDevices.listActiveForUser = async () => [{ id: 1, platform: 'web' }]
+
+ const res = mockRes()
+ await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true }), res)
+
+ assert.equal(res.statusCode, 200)
+ assert.equal(res.body.trustLimitReached, true)
+ assert.equal(res.cookies.rg_trust, undefined, 'no cookie when the cap refused the trust')
+ assert.equal(sessionsCreated.length, 1, 'the session is still issued')
+})
+
+// ── mobile bridge: the Custom Tab gets the same treatment ──────────────────
+test('sso totp (mobile bridge): trustDevice flags the session so /exchange can mint the app token', async () => {
+ ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', mobileSessionId: 'sess-1' })
+ users.getRawById = async () => TOTP_USER
+ totp.verifyCode = () => true
+ mobileBridge.getSession = async () => ({
+ session_id: 'sess-1', status: 'pending', expires_at: new Date(Date.now() + 60000),
+ redirect_uri: 'runicgateway://auth/callback', state: 'st',
+ })
+ mobileBridge.issueAuthCode = async () => ({ code: 'authcode' })
+ let flagged = null
+ mobileBridge.markTrustRequested = async (id) => { flagged = id; return true }
+
+ const res = mockRes()
+ await ssoCtrl.finishSsoTotp(baseReq({ code: '123456', trustDevice: true }), res)
+
+ assert.equal(flagged, 'sess-1', 'bridge session flagged for the app-side trust token')
+ assert.equal(res.cookies.rg_trust, 'raw-trust', 'the Custom Tab browser is trusted too')
+ assert.match(String(res.body.redirect), /^runicgateway:\/\/auth\/callback/)
+})
+
+test('sso totp (mobile bridge): without trustDevice the session is not flagged', async () => {
+ ssoState.verifyTotpPending = () => ({ id: 5, provider: 'google', authMethod: 'sso', mobileSessionId: 'sess-1' })
+ users.getRawById = async () => TOTP_USER
+ totp.verifyCode = () => true
+ mobileBridge.getSession = async () => ({
+ session_id: 'sess-1', status: 'pending', expires_at: new Date(Date.now() + 60000),
+ redirect_uri: 'runicgateway://auth/callback', state: 'st',
+ })
+ mobileBridge.issueAuthCode = async () => ({ code: 'authcode' })
+ let flagged = null
+ mobileBridge.markTrustRequested = async (id) => { flagged = id; return true }
+
+ const res = mockRes()
+ await ssoCtrl.finishSsoTotp(baseReq({ code: '123456' }), res)
+
+ assert.equal(flagged, null)
+ assert.equal(res.cookies.rg_trust, undefined)
+})