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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user