"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>
57 lines
5.0 KiB
JavaScript
57 lines
5.0 KiB
JavaScript
const express = require('express')
|
|
const { body, query } = require('express-validator')
|
|
|
|
const { start, exchange } = require('./mobileSso.controller')
|
|
const { mobileSsoStartLimiter, mobileSsoExchangeLimiter } = require('../../../middleware/rateLimit')
|
|
const validate = require('../../../middleware/validate')
|
|
|
|
// Mobile SSO authorization bridge (M9). Mounted at /auth/mobile/sso. Native
|
|
// "Sign in with Google/Discord" that reuses the website's SSO flow and terminates
|
|
// in the existing mobile bearer tokens — no OAuth secret ever ships in the app.
|
|
// Provider discovery reuses GET /auth/providers; refresh/logout reuse the existing
|
|
// /auth/mobile/{refresh,logout}. See docs BACKEND_DESIGN §4 + docs/android/PLAN.md §9.
|
|
const mobileSsoRouter = express.Router()
|
|
|
|
// GET /auth/mobile/sso/start — opened by the app in a Custom Tab; 302s to the IdP.
|
|
mobileSsoRouter.get(
|
|
'/start',
|
|
// #swagger.tags = ['Auth · Mobile']
|
|
// #swagger.summary = 'Begin native SSO login (redirect to the IdP)'
|
|
// #swagger.description = 'Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.'
|
|
// #swagger.parameters['provider'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'Provider id from GET /auth/providers (e.g. google, discord).' }
|
|
// #swagger.parameters['code_challenge'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'App-generated PKCE S256 challenge (base64url).' }
|
|
// #swagger.parameters['state'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'App-generated opaque CSRF value, echoed on the callback for the app to verify.' }
|
|
// #swagger.parameters['redirect_uri'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback).' }
|
|
/* #swagger.responses[302] = { description: 'Redirect to the identity provider (or back to the app callback on error)' } */
|
|
/* #swagger.responses[400] = { description: 'Unrecognized redirect URI or validation error', 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" } } } } */
|
|
mobileSsoStartLimiter,
|
|
query('provider').isString().trim().isLength({ min: 1, max: 40 }),
|
|
query('code_challenge').isString().trim().isLength({ min: 20, max: 255 }),
|
|
query('state').isString().trim().isLength({ min: 8, max: 255 }),
|
|
query('redirect_uri').isString().trim().isLength({ min: 1, max: 255 }),
|
|
validate,
|
|
start,
|
|
)
|
|
|
|
// POST /auth/mobile/sso/exchange — code + PKCE verifier → mobile bearer tokens.
|
|
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. 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 (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" } } } } */
|
|
mobileSsoExchangeLimiter,
|
|
body('code').isString().trim().isLength({ min: 20, max: 255 }),
|
|
body('code_verifier').isString().trim().isLength({ min: 20, max: 255 }),
|
|
body('device_name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
|
|
validate,
|
|
exchange,
|
|
)
|
|
|
|
module.exports = mobileSsoRouter
|