feat(auth): honor and establish trusted devices on the SSO login paths #108

Merged
whitlocktech merged 1 commits from feat/sso-trusted-device into main 2026-07-28 06:13:00 +00:00
Member

What & why

"Trust this device" did nothing if you sign in with Google or Discord.

sso.controller went straight from needsTotp(user) to staging a pending-TOTP challenge and never called resolveTrustedDevice, so an SSO user was asked for a code on every sign-in no matter how many times they ticked the box. And POST /auth/sso/totp accepted only code, so that step couldn't 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 belonging 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 } at the cap (the sign-in still completes). Recovery codes stay password-login only — this step verifies an authenticator code against the staged challenge.

How this reaches the Android app — without a secret in 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, with no app change.

I deliberately did not pass the app's stored trust token into the /auth/mobile/sso/start URL — the app can't set headers on a tab it doesn't control, and a query param would put a 256-bit secret into 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 simply omits it rather than failing a good sign-in.

One tick therefore yields two independently-revocable rows (browser + app) — honest, since they're two distinct credentials on one device.

Client

The trust checkbox is no longer hidden on the SSO second step, on both 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.

Companion PRs: Android-app feat/sso-trusted-device, docs feat/sso-trusted-device.

How it was tested

  • 8 new cases in server/test/ssoTrustedDevice.test.js — trusted device skips the code; a trust bound to a different user is ignored; a store error falls back to the challenge; the cookie is set (and not set) as appropriate; the cap still completes the sign-in; the bridge session is flagged (and not flagged). I reverted the fix and confirmed the key case fails without it, so the test is not vacuous.
  • Full suites green: server 445, client 43. Clean client build.
  • routes.manifest.json is a zero-line diff — no URL moved. Only routes.guards.json changed: +2 handlers on /auth/sso/totp for the two new validators. Swagger regenerated.
  • Live, against the running server and real MariaDB (not mocks): POST /auth/sso/totp with trustDevice returned 200, issued rg_trust, and persisted a real trusted_devices row (platform=web); a subsequent SSO callback carrying that cookie skipped the code and redirected to /admin; a callback with an invalid trust was still bounced to ?sso_totp=1. Audit log shows auth.login.trusted_device · provider: google, sso: true.
  • The schema migration applied itself on boot (ALTER TABLE … IF NOT EXISTS), confirmed against the existing dev database.
  • Browser-checked the SSO TOTP step: checkbox present, "use a recovery code" correctly still absent.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why **"Trust this device" did nothing if you sign in with Google or Discord.** `sso.controller` went straight from `needsTotp(user)` to staging a pending-TOTP challenge and never called `resolveTrustedDevice`, so an SSO user was asked for a code on **every** sign-in no matter how many times they ticked the box. And `POST /auth/sso/totp` accepted only `code`, so that step couldn't 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 belonging 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 }` at the cap (the sign-in still completes). Recovery codes stay password-login only — this step verifies an authenticator code against the staged challenge. ### How this reaches the Android app — without a secret in 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, with no app change. I deliberately did **not** pass the app's stored trust token into the `/auth/mobile/sso/start` URL — the app can't set headers on a tab it doesn't control, and a query param would put a 256-bit secret into 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 simply omits it rather than failing a good sign-in. One tick therefore yields two independently-revocable rows (browser + app) — honest, since they're two distinct credentials on one device. ### Client The trust checkbox is no longer hidden on the SSO second step, on both 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. Companion PRs: **Android-app** `feat/sso-trusted-device`, **docs** `feat/sso-trusted-device`. ## How it was tested - **8 new cases** in `server/test/ssoTrustedDevice.test.js` — trusted device skips the code; a trust bound to a **different** user is ignored; a store error falls back to the challenge; the cookie is set (and not set) as appropriate; the cap still completes the sign-in; the bridge session is flagged (and not flagged). I reverted the fix and confirmed the key case **fails** without it, so the test is not vacuous. - **Full suites green: server 445, client 43.** Clean client build. - **`routes.manifest.json` is a zero-line diff** — no URL moved. Only `routes.guards.json` changed: `+2 handlers` on `/auth/sso/totp` for the two new validators. Swagger regenerated. - **Live, against the running server and real MariaDB** (not mocks): `POST /auth/sso/totp` with `trustDevice` returned 200, issued `rg_trust`, and persisted a real `trusted_devices` row (`platform=web`); a subsequent SSO callback carrying that cookie **skipped the code** and redirected to `/admin`; a callback with an invalid trust was still bounced to `?sso_totp=1`. Audit log shows `auth.login.trusted_device · provider: google, sso: true`. - The schema migration applied itself on boot (`ALTER TABLE … IF NOT EXISTS`), confirmed against the existing dev database. - Browser-checked the SSO TOTP step: checkbox present, "use a recovery code" correctly still absent. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-07-28 06:02:55 +00:00
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
620781b7bc
"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>
whitlocktech approved these changes 2026-07-28 06:04:54 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-28 06:05:38 +00:00
whitlocktech merged commit a3407ae654 into main 2026-07-28 06:13:00 +00:00
whitlocktech deleted branch feat/sso-trusted-device 2026-07-28 06:13:01 +00:00
Sign in to join this conversation.
No description provided.