feat(auth): honor and establish trusted devices on the SSO login paths
"Trust this device" did nothing for anyone who signs in with Google or Discord.
sso.controller went straight from needsTotp(user) to staging a pending-TOTP
challenge and never consulted resolveTrustedDevice, so an SSO user was asked for
a code on EVERY sign-in no matter how many times they had ticked the box — and
POST /auth/sso/totp accepted only `code`, so that step could not establish a
trust either. The password paths (web + native) were unaffected and already
worked; this closes the gap for SSO, on the website AND in the Android app.
Server:
- finishLogin and finishMobileLogin now run the same trusted-device check as
auth.controller.login, via one shared helper: honor a trust that belongs to
THIS user, stamp last_used_at, log auth.login.trusted_device. A store error
falls through to the challenge — fail closed to asking for the code.
- POST /auth/sso/totp gains optional trustDevice + deviceName, sets the rg_trust
cookie, and mirrors the password path's { trustLimitReached, devices } response
at the cap (the sign-in still completes). Recovery codes stay password-only.
Android coverage, without leaking a secret into a URL:
- The app opens SSO in a Custom Tab, which shares the system browser's cookie
jar, so the rg_trust cookie set on that TOTP form is presented back on the next
app sign-in. That alone makes native SSO skip the code. Passing the app's token
into the start URL was rejected — it would put a 256-bit secret in query
strings, Referer headers and access logs.
- To also cover the app's NATIVE password login, ticking the box sets
mobile_auth_sessions.trust_device (a boolean; never the token), and
/auth/mobile/sso/exchange mints a platform:'mobile' trust and returns
{ trustToken }. Minting there keeps the raw token on an authenticated
app→server call, out of the deep link and out of the bridge row. Best-effort:
at the cap the response just omits it rather than failing a good sign-in.
Client: the trust checkbox is no longer hidden on the SSO second step, on both
the admin and player login screens. On the mobile bridge the deep-link redirect
takes priority over the cap prompt — the sign-in succeeded and the link is
single-use, so stalling there would strand the app.
Tests: 8 new cases in server/test/ssoTrustedDevice.test.js (verified to fail
against the pre-fix controller). Full suites green — server 445, client 43 —
and routes.manifest.json is a zero-line diff: no URL moved, only +2 handlers on
/auth/sso/totp in routes.guards.json for the two new validators. Swagger
regenerated. Verified live against the running server and real MariaDB: the TOTP
step issues rg_trust and persists the row, a subsequent SSO callback carrying it
skips the code, and an invalid trust is still challenged.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,20 @@ async function completeSession(sessionId, userId) {
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Record that the user asked to trust this device on the Custom Tab TOTP form.
|
||||
// Guarded on status + expiry for the same reason completeSession is: a replayed
|
||||
// TOTP post must not re-arm a session that has already been consumed. Stores a
|
||||
// boolean only — the trust token is minted at /exchange and never lands here.
|
||||
async function setTrustDevice(sessionId) {
|
||||
const res = await query(
|
||||
`UPDATE mobile_auth_sessions
|
||||
SET trust_device = 1
|
||||
WHERE session_id = ? AND status = 'pending' AND expires_at > 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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" } } } } */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user