feat(auth): honor and establish trusted devices on the SSO login paths #108
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
}, [])
|
||||
|
||||
@@ -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.'}
|
||||
</span>
|
||||
</label>
|
||||
{!ssoTotp && (
|
||||
{/* Offered on the SSO second factor too — the trust is on the device,
|
||||
not on how the first factor was proved. */}
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||
Trust this device for 30 days (skip the code next time)
|
||||
</label>
|
||||
)}
|
||||
{/* Recovery codes remain password-login only: the SSO second step
|
||||
verifies an authenticator code against the staged challenge. */}
|
||||
{!ssoTotp && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -108,15 +108,28 @@ export default function PlayerLogin() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (ssoTotp) {
|
||||
const { returnTo, redirect } = await ssoLoginTotp(code)
|
||||
// Trust works on the SSO second factor too. On the mobile bridge this page
|
||||
// is running inside the app's Custom Tab, so the cookie set here is what
|
||||
// lets the next app sign-in skip the code.
|
||||
const data = await ssoLoginTotp(code.trim(), { trustDevice })
|
||||
// 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
|
||||
// This wins over the trust-cap prompt: the sign-in itself succeeded and the
|
||||
// deep link is single-use, so stalling here to manage devices would strand
|
||||
// the app. An over-cap user simply isn't trusted and can prune the list
|
||||
// from Account → Trusted Devices.
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect
|
||||
return
|
||||
}
|
||||
navigate(returnTo || '/account', { replace: true })
|
||||
const to = data.returnTo || '/account'
|
||||
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, {
|
||||
@@ -206,14 +219,15 @@ export default function PlayerLogin() {
|
||||
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
|
||||
</span>
|
||||
</label>
|
||||
{/* Trust-this-device only applies to real authenticator/recovery login,
|
||||
not the SSO 2FA bounce (which has no trust cookie flow here). */}
|
||||
{!ssoTotp && (
|
||||
{/* 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. */}
|
||||
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||
Trust this device for 30 days (skip the code next time)
|
||||
</label>
|
||||
)}
|
||||
{/* Recovery codes remain password-login only: the SSO second step
|
||||
verifies an authenticator code against the staged challenge. */}
|
||||
{!ssoTotp && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -215,6 +215,9 @@ CREATE TABLE IF NOT EXISTS mobile_auth_sessions (
|
||||
state VARCHAR(255) NOT NULL, -- app-generated opaque CSRF value, echoed to the app
|
||||
status ENUM('pending','completed','consumed') NOT NULL DEFAULT 'pending',
|
||||
user_id INT NULL, -- set once SSO resolves the account
|
||||
trust_device TINYINT(1) NOT NULL DEFAULT 0, -- user ticked "trust this device" on the Custom Tab TOTP form;
|
||||
-- a BOOLEAN only — the trust token itself is minted at /exchange
|
||||
-- and returned over that app→server call, never stored here
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL, -- ~10 min (one redirect round-trip incl. TOTP)
|
||||
used_at DATETIME NULL, -- stamped at exchange
|
||||
@@ -1069,3 +1072,9 @@ ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NUL
|
||||
-- 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;
|
||||
|
||||
-- SSO trusted devices: records that the user ticked "trust this device" on the
|
||||
-- Custom Tab TOTP form, so /auth/mobile/sso/exchange knows to mint the app's own
|
||||
-- trust token. A boolean only — the token is returned over that app→server call
|
||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
@@ -1550,7 +1550,7 @@
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/auth/sso/totp",
|
||||
"handlers": 6,
|
||||
"handlers": 8,
|
||||
"gates": [
|
||||
"backoffGuard",
|
||||
"middleware",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
216
server/test/ssoTrustedDevice.test.js
Normal file
216
server/test/ssoTrustedDevice.test.js
Normal file
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user