feat(auth): honor and establish trusted devices on the SSO login paths
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m21s

"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:
2026-07-28 01:01:12 -05:00
parent f6611231c4
commit 620781b7bc
14 changed files with 419 additions and 44 deletions

View File

@@ -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"
}
}
}