Merge pull request 'feat(auth): native SSO authorization bridge for the Android app (M9 Part 1)' (#80) from feature/mobile-sso-bridge into main
Reviewed-on: #80 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -68,6 +68,10 @@ export const api = {
|
|||||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||||
// Public SSO provider discovery — drives the login-page provider buttons.
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||||
authProviders: () => req('/auth/providers'),
|
authProviders: () => req('/auth/providers'),
|
||||||
|
// Active mobile device sessions (role-agnostic self-service under /auth/me).
|
||||||
|
// List the active ones and revoke a single device by its session id.
|
||||||
|
mySessions: () => req('/auth/me/sessions'),
|
||||||
|
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// ----- public -----
|
// ----- public -----
|
||||||
publicSettings: () => req('/public/settings'),
|
publicSettings: () => req('/public/settings'),
|
||||||
|
|||||||
@@ -295,6 +295,73 @@ function LinkedAccounts() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Active mobile device sessions ──────────────────────────────────────────
|
||||||
|
function ActiveDevices() {
|
||||||
|
const [sessions, setSessions] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busyId, setBusyId] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSessions(await api.mySessions())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your devices.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function revoke(id) {
|
||||||
|
if (!window.confirm('Sign this device out? It will need to sign in again.')) return
|
||||||
|
setBusyId(id)
|
||||||
|
try {
|
||||||
|
await api.revokeMySession(id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not sign that device out.')
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (d) => {
|
||||||
|
const t = d ? new Date(d) : null
|
||||||
|
return t && !Number.isNaN(t.getTime()) ? t.toLocaleString() : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<Section title="Active devices"><ErrorState message={error} /></Section>
|
||||||
|
)
|
||||||
|
if (!sessions) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Active devices">
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Devices signed in to the mobile app. Sign one out to revoke its access — it may keep working for
|
||||||
|
a few minutes until its current token expires.
|
||||||
|
</p>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.86rem' }}>No mobile devices are signed in.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||||
|
{s.deviceName || s.userAgent || 'Mobile device'}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.78rem' }}>Last active {fmt(s.lastUsedAt)}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => revoke(s.id)} disabled={busyId === s.id} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||||
|
{busyId === s.id ? 'Signing out…' : 'Sign out'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared bits ────────────────────────────────────────────────────────────
|
// ── Shared bits ────────────────────────────────────────────────────────────
|
||||||
function Section({ title, children }) {
|
function Section({ title, children }) {
|
||||||
return (
|
return (
|
||||||
@@ -347,6 +414,7 @@ export default function PlayerAccount() {
|
|||||||
<ChangePassword account={account} />
|
<ChangePassword account={account} />
|
||||||
<TwoFactor account={account} reload={load} />
|
<TwoFactor account={account} reload={load} />
|
||||||
<LinkedAccounts />
|
<LinkedAccounts />
|
||||||
|
<ActiveDevices />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,14 @@ export default function PlayerLogin() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (ssoTotp) {
|
if (ssoTotp) {
|
||||||
const { returnTo } = await ssoLoginTotp(code)
|
const { returnTo, redirect } = await ssoLoginTotp(code)
|
||||||
|
// 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
|
||||||
|
return
|
||||||
|
}
|
||||||
navigate(returnTo || '/account', { replace: true })
|
navigate(returnTo || '/account', { replace: true })
|
||||||
} else {
|
} else {
|
||||||
const u = await loginTotp(challenge, code)
|
const u = await loginTotp(challenge, code)
|
||||||
|
|||||||
@@ -188,8 +188,10 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
||||||
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
||||||
|
device_name VARCHAR(100) NULL, -- friendly label the app may send (M9)
|
||||||
user_agent VARCHAR(255) NULL,
|
user_agent VARCHAR(255) NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at DATETIME NULL, -- last time this session token was issued/used (M9)
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
revoked_at DATETIME NULL,
|
revoked_at DATETIME NULL,
|
||||||
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -197,6 +199,41 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|||||||
INDEX idx_mrt_expires (expires_at)
|
INDEX idx_mrt_expires (expires_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Mobile SSO authorization bridge (M9). Two short-lived, self-pruning tables that
|
||||||
|
-- bridge a browser SSO redirect flow to the native app. They carry the app↔website
|
||||||
|
-- PKCE + CSRF state (a SECOND PKCE layer, distinct from the website↔IdP PKCE the
|
||||||
|
-- sso_tx cookie already carries) and the one-time code the app trades for bearer
|
||||||
|
-- tokens. No secret is stored in the clear: code_challenge is a hash by construction
|
||||||
|
-- and the authorization code is stored as a sha256 hash only (same pattern as
|
||||||
|
-- mobile_refresh_tokens / user_invites / password_resets). See docs BACKEND_DESIGN §3/§4.
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile_auth_sessions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
session_id CHAR(36) NOT NULL UNIQUE, -- uuid; carried inside the signed sso_tx (mode 'mobile')
|
||||||
|
provider VARCHAR(40) NOT NULL, -- provider id, validated enabled at /start
|
||||||
|
code_challenge VARCHAR(255) NOT NULL, -- app-supplied PKCE S256 challenge (base64url)
|
||||||
|
redirect_uri VARCHAR(255) NOT NULL, -- app callback; EXACT-match against the allowlist
|
||||||
|
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
|
||||||
|
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
|
||||||
|
CONSTRAINT fk_mas_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_mas_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile_auth_codes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque >=128-bit code
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
session_id CHAR(36) NOT NULL, -- owning mobile_auth_sessions.session_id (ties code→PKCE challenge)
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL, -- very short (~5 min)
|
||||||
|
used_at DATETIME NULL, -- set on first successful exchange (single use)
|
||||||
|
CONSTRAINT fk_mac_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_mac_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
|
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
|
||||||
-- per session in createSession. A single logout adds this session's jti here;
|
-- per session in createSession. A single logout adds this session's jti here;
|
||||||
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
|
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
|
||||||
@@ -981,3 +1018,9 @@ ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
|||||||
-- so the public Houses browser can list registered houses without pulling in rows
|
-- so the public Houses browser can list registered houses without pulling in rows
|
||||||
-- we only ever saw an IDOC transition for.
|
-- we only ever saw an IDOC transition for.
|
||||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Mobile device sessions (M9): a friendly label the app may send at login, and
|
||||||
|
-- the last time this session token was issued/used, for the "Active Devices"
|
||||||
|
-- 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;
|
||||||
|
|||||||
@@ -70,10 +70,15 @@ function verifyTx(txToken, stateNonce) {
|
|||||||
// session: `stage: 'totp'` makes session validation reject it (same marker the
|
// session: `stage: 'totp'` makes session validation reject it (same marker the
|
||||||
// local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and
|
// local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and
|
||||||
// scopes it to the SSO completion endpoint.
|
// scopes it to the SSO completion endpoint.
|
||||||
function createTotpPending({ userId, provider, authMethod, returnTo }) {
|
//
|
||||||
|
// `mobileSessionId` is present only for a mobile SSO bridge flow (mode 'mobile'):
|
||||||
|
// it threads the bridge session through the TOTP form so that, on a correct code,
|
||||||
|
// the completion mints an authorization code + deep-links back to the app instead
|
||||||
|
// of setting a web session cookie. Absent for ordinary web SSO.
|
||||||
|
function createTotpPending({ userId, provider, authMethod, returnTo, mobileSessionId }) {
|
||||||
return token.signToken(
|
return token.signToken(
|
||||||
{ id: userId }, // subject only; identity is re-loaded fresh when the code is verified
|
{ id: userId }, // subject only; identity is re-loaded fresh when the code is verified
|
||||||
{ stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo },
|
{ stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo, mobileSessionId },
|
||||||
{ expiresIn: TOTP_TTL },
|
{ expiresIn: TOTP_TTL },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ const rateLimit = require('express-rate-limit')
|
|||||||
|
|
||||||
const log = require('../utils/logger')('ratelimit')
|
const log = require('../utils/logger')('ratelimit')
|
||||||
|
|
||||||
function makeLimiter({ windowMs, max, label, message }) {
|
function makeLimiter({ windowMs, max, label, message, keyGenerator, validate }) {
|
||||||
return rateLimit({
|
return rateLimit({
|
||||||
windowMs,
|
windowMs,
|
||||||
max,
|
max,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: { message },
|
message: { message },
|
||||||
|
// Default key is the client IP; callers can widen it (e.g. IP + provider).
|
||||||
|
...(keyGenerator ? { keyGenerator } : {}),
|
||||||
|
// Custom keyGenerators that fold in req.ip trip v7's IPv6 fallback validator;
|
||||||
|
// callers pass `validate` to scope that off just for their limiter.
|
||||||
|
...(validate !== undefined ? { validate } : {}),
|
||||||
handler: (req, res, next, options) => {
|
handler: (req, res, next, options) => {
|
||||||
log.warn(`${label} rate limit exceeded`, { ip: req.ip, path: req.originalUrl })
|
log.warn(`${label} rate limit exceeded`, { ip: req.ip, path: req.originalUrl })
|
||||||
res.status(options.statusCode).json(options.message)
|
res.status(options.statusCode).json(options.message)
|
||||||
@@ -71,6 +76,29 @@ const ssoStartLimiter = makeLimiter({
|
|||||||
message: 'Too many sign-in attempts. Please try again later.',
|
message: 'Too many sign-in attempts. Please try again later.',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mobile SSO bridge — throttle /start per IP AND per provider: each call spawns a
|
||||||
|
// mobile_auth_sessions row, so without a per-provider dimension /start is a cheap
|
||||||
|
// way to spam rows for one provider from many-but-few IPs. Generous for real users
|
||||||
|
// (a login is a handful of taps). `validate:{ip:false}` scopes off v7's IPv6
|
||||||
|
// fallback check, which fires only because our key folds in req.ip.
|
||||||
|
const mobileSsoStartLimiter = makeLimiter({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
label: 'mobile-sso-start',
|
||||||
|
message: 'Too many sign-in attempts. Please try again later.',
|
||||||
|
keyGenerator: (req) => `${req.ip}:${req.query && req.query.provider ? req.query.provider : ''}`,
|
||||||
|
validate: { ip: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mobile SSO bridge — throttle /exchange per IP. The code is single-use, PKCE-bound
|
||||||
|
// and short-lived, but cap redemption attempts anyway to blunt guessing.
|
||||||
|
const mobileSsoExchangeLimiter = makeLimiter({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
label: 'mobile-sso-exchange',
|
||||||
|
message: 'Too many attempts. Please try again later.',
|
||||||
|
})
|
||||||
|
|
||||||
// Password-reset requests per IP. Each one can send email, so cap tighter than
|
// Password-reset requests per IP. Each one can send email, so cap tighter than
|
||||||
// login to blunt email-bombing and enumeration timing probes. The endpoint always
|
// login to blunt email-bombing and enumeration timing probes. The endpoint always
|
||||||
// returns a generic success regardless of match, so honest users never see this.
|
// returns a generic success regardless of match, so honest users never see this.
|
||||||
@@ -97,6 +125,8 @@ module.exports = {
|
|||||||
contactLimiter,
|
contactLimiter,
|
||||||
mobileRefreshLimiter,
|
mobileRefreshLimiter,
|
||||||
ssoStartLimiter,
|
ssoStartLimiter,
|
||||||
|
mobileSsoStartLimiter,
|
||||||
|
mobileSsoExchangeLimiter,
|
||||||
passwordResetRequestLimiter,
|
passwordResetRequestLimiter,
|
||||||
passwordResetConfirmLimiter,
|
passwordResetConfirmLimiter,
|
||||||
}
|
}
|
||||||
|
|||||||
101
server/src/model/mobileAuthBridge/mobileAuthBridge.db.js
Normal file
101
server/src/model/mobileAuthBridge/mobileAuthBridge.db.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
// SQL for the mobile SSO authorization bridge (mobile_auth_sessions +
|
||||||
|
// mobile_auth_codes). The authorization code is stored as a sha256 hash only
|
||||||
|
// (hashing is done in the .model layer, mirroring mobileSessions); the PKCE
|
||||||
|
// code_challenge is a hash by construction and is stored as-supplied. Both
|
||||||
|
// tables self-prune on expiry (pruneExpired). See docs BACKEND_DESIGN §3/§4.
|
||||||
|
|
||||||
|
// ── mobile_auth_sessions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Insert a pending bridge session. expiresAt is a JS Date (or ms epoch).
|
||||||
|
async function insertSession({ sessionId, provider, codeChallenge, redirectUri, state, expiresAt }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO mobile_auth_sessions (session_id, provider, code_challenge, redirect_uri, state, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[sessionId, provider, codeChallenge, redirectUri, state, new Date(expiresAt)],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up a bridge session by its opaque session_id. Returns the row or null.
|
||||||
|
async function getSession(sessionId) {
|
||||||
|
const rows = await query('SELECT * FROM mobile_auth_sessions WHERE session_id = ? LIMIT 1', [sessionId])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark a still-pending, unexpired session `completed` and attach the resolved
|
||||||
|
// user. Guards on status + expiry so a replayed callback can't re-complete a
|
||||||
|
// consumed/expired session. Returns rows changed (0 = not eligible).
|
||||||
|
async function completeSession(sessionId, userId) {
|
||||||
|
const res = await query(
|
||||||
|
`UPDATE mobile_auth_sessions
|
||||||
|
SET status = 'completed', user_id = ?
|
||||||
|
WHERE session_id = ? AND status = 'pending' AND expires_at > NOW()`,
|
||||||
|
[userId, 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(
|
||||||
|
"UPDATE mobile_auth_sessions SET status = 'consumed', used_at = NOW() WHERE session_id = ?",
|
||||||
|
[sessionId],
|
||||||
|
)
|
||||||
|
return Number(res.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mobile_auth_codes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Insert a freshly minted authorization code (by hash).
|
||||||
|
async function insertCode({ codeHash, userId, sessionId, expiresAt }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO mobile_auth_codes (code_hash, user_id, session_id, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[codeHash, userId, sessionId, new Date(expiresAt)],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return an authorization-code row only if it is still redeemable: not used and
|
||||||
|
// not expired. Returns the row (incl. user_id, session_id) or null.
|
||||||
|
async function findValidCode(codeHash) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT * FROM mobile_auth_codes
|
||||||
|
WHERE code_hash = ? AND used_at IS NULL AND expires_at > NOW()
|
||||||
|
LIMIT 1`,
|
||||||
|
[codeHash],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically mark a code used (single-use gate). Only affects a not-yet-used row,
|
||||||
|
// so a concurrent double-redeem sees affectedRows = 0 on the loser. Returns rows
|
||||||
|
// changed.
|
||||||
|
async function markCodeUsed(codeHash) {
|
||||||
|
const res = await query(
|
||||||
|
'UPDATE mobile_auth_codes SET used_at = NOW() WHERE code_hash = ? AND used_at IS NULL',
|
||||||
|
[codeHash],
|
||||||
|
)
|
||||||
|
return Number(res.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Housekeeping: drop long-dead rows from both bridge tables (expired, or codes
|
||||||
|
// already used). Keeps the tables from growing without bound. Returns rows removed.
|
||||||
|
async function pruneExpired() {
|
||||||
|
const a = await query('DELETE FROM mobile_auth_codes WHERE expires_at < NOW() OR used_at IS NOT NULL')
|
||||||
|
const b = await query("DELETE FROM mobile_auth_sessions WHERE expires_at < NOW() OR status = 'consumed'")
|
||||||
|
return Number(a.affectedRows || 0) + Number(b.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
insertSession,
|
||||||
|
getSession,
|
||||||
|
completeSession,
|
||||||
|
consumeSession,
|
||||||
|
insertCode,
|
||||||
|
findValidCode,
|
||||||
|
markCodeUsed,
|
||||||
|
pruneExpired,
|
||||||
|
}
|
||||||
102
server/src/model/mobileAuthBridge/mobileAuthBridge.model.js
Normal file
102
server/src/model/mobileAuthBridge/mobileAuthBridge.model.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// ── Mobile SSO authorization bridge — logic layer ──────────────────────────
|
||||||
|
//
|
||||||
|
// Bridges the browser SSO redirect flow to the native app. A `/start` seeds a
|
||||||
|
// `mobile_auth_sessions` row carrying the app-supplied PKCE challenge, the app's
|
||||||
|
// CSRF `state`, and the exact callback URI. The SSO callback (once the account is
|
||||||
|
// resolved) mints a single-use authorization CODE tied to that session; the app
|
||||||
|
// redeems the code + its PKCE verifier at `/exchange` for bearer tokens.
|
||||||
|
//
|
||||||
|
// Secrets: the raw authorization code is generated here and returned ONCE to the
|
||||||
|
// caller (to place in the redirect URL); only its sha256 hash is persisted — the
|
||||||
|
// raw code never touches the DB. The PKCE code_challenge is already a hash.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
const db = require('./mobileAuthBridge.db')
|
||||||
|
|
||||||
|
// TTLs. A start→callback→exchange round-trip is quick, but the callback may route
|
||||||
|
// through the TOTP form, so give the session a little room; the code itself is
|
||||||
|
// very short-lived.
|
||||||
|
const SESSION_TTL_MS = 10 * 60 * 1000 // 10 min — one redirect round-trip incl. TOTP
|
||||||
|
const CODE_TTL_MS = 5 * 60 * 1000 // 5 min — the app redeems immediately
|
||||||
|
|
||||||
|
// sha256 hex of a raw token — the persisted representation of an authorization code.
|
||||||
|
function hashCode(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a pending bridge session. Returns { sessionId } (opaque uuid) which the
|
||||||
|
// caller embeds in the signed sso_tx so the callback can find this row. Best-effort
|
||||||
|
// prune keeps the table trimmed without a cron (same approach as revoked_sessions).
|
||||||
|
async function startSession({ provider, codeChallenge, redirectUri, state, now = Date.now() }) {
|
||||||
|
const sessionId = crypto.randomUUID()
|
||||||
|
await db.insertSession({
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
|
codeChallenge,
|
||||||
|
redirectUri,
|
||||||
|
state,
|
||||||
|
expiresAt: new Date(now + SESSION_TTL_MS),
|
||||||
|
})
|
||||||
|
db.pruneExpired().catch(() => {}) // opportunistic; never blocks the flow
|
||||||
|
return { sessionId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a bridge session row by session_id (or null).
|
||||||
|
async function getSession(sessionId) {
|
||||||
|
if (!sessionId) return null
|
||||||
|
return db.getSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint the one-time authorization code for a resolved account. Marks the owning
|
||||||
|
// session `completed`; returns { code } (the RAW code, to place in the redirect
|
||||||
|
// URL) or null if the session isn't pending/eligible. 256 bits of entropy — well
|
||||||
|
// above the ≥128-bit floor — url-safe and opaque.
|
||||||
|
async function issueAuthCode({ sessionId, userId, now = Date.now() }) {
|
||||||
|
const completed = await db.completeSession(sessionId, userId)
|
||||||
|
if (!completed) return null // not pending / expired / already used
|
||||||
|
const code = crypto.randomBytes(32).toString('base64url')
|
||||||
|
await db.insertCode({
|
||||||
|
codeHash: hashCode(code),
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
expiresAt: new Date(now + CODE_TTL_MS),
|
||||||
|
})
|
||||||
|
db.pruneExpired().catch(() => {})
|
||||||
|
return { code }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up a redeemable code row (unused + unexpired) for a raw code, or null.
|
||||||
|
async function findRedeemableCode(rawCode) {
|
||||||
|
if (!rawCode) return null
|
||||||
|
return db.findValidCode(hashCode(rawCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically burn a code (single-use). Returns true iff THIS call was the one that
|
||||||
|
// marked it used — a concurrent double-redeem gets false on the loser.
|
||||||
|
async function consumeCode(rawCode) {
|
||||||
|
const changed = await db.markCodeUsed(hashCode(rawCode))
|
||||||
|
return changed > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark a session fully consumed after a successful exchange.
|
||||||
|
async function finishSession(sessionId) {
|
||||||
|
return db.consumeSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop expired/used rows from both tables.
|
||||||
|
async function pruneExpired() {
|
||||||
|
return db.pruneExpired()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SESSION_TTL_MS,
|
||||||
|
CODE_TTL_MS,
|
||||||
|
startSession,
|
||||||
|
getSession,
|
||||||
|
issueAuthCode,
|
||||||
|
findRedeemableCode,
|
||||||
|
consumeCode,
|
||||||
|
finishSession,
|
||||||
|
pruneExpired,
|
||||||
|
}
|
||||||
@@ -3,16 +3,41 @@ const { query } = require('../../utils/db')
|
|||||||
// SQL for the mobile_refresh_tokens table. Tokens are stored only as sha256
|
// SQL for the mobile_refresh_tokens table. Tokens are stored only as sha256
|
||||||
// hashes (token_hash); the raw refresh token never touches the database.
|
// hashes (token_hash); the raw refresh token never touches the database.
|
||||||
|
|
||||||
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch).
|
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch). last_used_at
|
||||||
async function insert({ userId, tokenHash, deviceHash = null, userAgent = null, expiresAt }) {
|
// is seeded to now: with single-use rotation each login/refresh inserts a fresh
|
||||||
|
// row, so the current active row's timestamp IS the session's last activity.
|
||||||
|
async function insert({ userId, tokenHash, deviceHash = null, deviceName = null, userAgent = null, expiresAt }) {
|
||||||
const res = await query(
|
const res = await query(
|
||||||
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, user_agent, expires_at)
|
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, device_name, user_agent, expires_at, last_used_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, NOW())`,
|
||||||
[userId, tokenHash, deviceHash, userAgent, new Date(expiresAt)],
|
[userId, tokenHash, deviceHash, deviceName, userAgent, new Date(expiresAt)],
|
||||||
)
|
)
|
||||||
return res.insertId
|
return res.insertId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List a user's currently-active (unrevoked, unexpired) sessions — one row per
|
||||||
|
// live device, newest first. Never returns the token hash. For the "Active
|
||||||
|
// Devices" self-service surface.
|
||||||
|
async function listActiveForUser(userId) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, device_name, device_hash, user_agent, created_at, last_used_at, expires_at
|
||||||
|
FROM mobile_refresh_tokens
|
||||||
|
WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
|
||||||
|
ORDER BY last_used_at DESC, created_at DESC`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke one of a user's sessions by row id (ownership-scoped so a user can only
|
||||||
|
// revoke their own). Idempotent; returns rows changed.
|
||||||
|
async function revokeByIdForUser(id, userId) {
|
||||||
|
const res = await query(
|
||||||
|
'UPDATE mobile_refresh_tokens SET revoked_at = NOW() WHERE id = ? AND user_id = ? AND revoked_at IS NULL',
|
||||||
|
[id, userId],
|
||||||
|
)
|
||||||
|
return Number(res.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Look up a token by hash only if it is still usable: not revoked and not past
|
// Look up a token by hash only if it is still usable: not revoked and not past
|
||||||
// its expiry. Returns the row (incl. user_id) or null.
|
// its expiry. Returns the row (incl. user_id) or null.
|
||||||
async function findValidByHash(tokenHash) {
|
async function findValidByHash(tokenHash) {
|
||||||
@@ -55,6 +80,8 @@ async function pruneExpired() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
insert,
|
insert,
|
||||||
|
listActiveForUser,
|
||||||
|
revokeByIdForUser,
|
||||||
findValidByHash,
|
findValidByHash,
|
||||||
revokeByHash,
|
revokeByHash,
|
||||||
revokeAllForUser,
|
revokeAllForUser,
|
||||||
|
|||||||
@@ -6,8 +6,19 @@
|
|||||||
const db = require('./mobileSessions.db')
|
const db = require('./mobileSessions.db')
|
||||||
|
|
||||||
// Persist a newly issued refresh token (by hash). Returns the row id.
|
// Persist a newly issued refresh token (by hash). Returns the row id.
|
||||||
async function store({ userId, tokenHash, deviceHash, userAgent, expiresAt }) {
|
async function store({ userId, tokenHash, deviceHash, deviceName, userAgent, expiresAt }) {
|
||||||
return db.insert({ userId, tokenHash, deviceHash, userAgent, expiresAt })
|
return db.insert({ userId, tokenHash, deviceHash, deviceName, userAgent, expiresAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
// List a user's active sessions (one per live device) for the Active Devices UI.
|
||||||
|
async function listActiveForUser(userId) {
|
||||||
|
return db.listActiveForUser(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke one of a user's own sessions by row id. Returns rows changed (0 if it
|
||||||
|
// wasn't theirs / already gone — callers treat this idempotently).
|
||||||
|
async function revokeByIdForUser(id, userId) {
|
||||||
|
return db.revokeByIdForUser(id, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the stored row for a still-valid (unrevoked, unexpired) token, else null.
|
// Return the stored row for a still-valid (unrevoked, unexpired) token, else null.
|
||||||
@@ -33,6 +44,8 @@ async function pruneExpired() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
store,
|
store,
|
||||||
|
listActiveForUser,
|
||||||
|
revokeByIdForUser,
|
||||||
findValidByHash,
|
findValidByHash,
|
||||||
revokeByHash,
|
revokeByHash,
|
||||||
revokeAllForUser,
|
revokeAllForUser,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
const users = require('../../../model/users/users.model')
|
const users = require('../../../model/users/users.model')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||||
|
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||||
const sessionService = require('../../../auth/session.service')
|
const sessionService = require('../../../auth/session.service')
|
||||||
const { setAuthCookie } = require('../../../auth/token')
|
const { setAuthCookie } = require('../../../auth/token')
|
||||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||||
@@ -207,6 +208,44 @@ async function unlinkIdentity(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List the current user's active mobile device sessions (the "Active Devices"
|
||||||
|
// surface). Never exposes the token hash — only labels + timestamps.
|
||||||
|
async function listSessions(req, res) {
|
||||||
|
try {
|
||||||
|
const rows = await mobileSessions.listActiveForUser(req.user.id)
|
||||||
|
return res.json(
|
||||||
|
rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
deviceName: r.device_name || null,
|
||||||
|
userAgent: r.user_agent || null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
lastUsedAt: r.last_used_at || r.created_at,
|
||||||
|
expiresAt: r.expires_at,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('listSessions', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke one of the current user's mobile device sessions by id (ownership-scoped
|
||||||
|
// in the query so a user can only revoke their own). Idempotent.
|
||||||
|
async function revokeSession(req, res) {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
try {
|
||||||
|
const n = await mobileSessions.revokeByIdForUser(id, req.user.id)
|
||||||
|
if (n) {
|
||||||
|
await activity.log({ req, action: 'auth.mobile.session.revoke', detail: { sessionRowId: id } })
|
||||||
|
log.info('mobile session revoked (self)', { id, userId: req.user.id })
|
||||||
|
}
|
||||||
|
return res.json({ revoked: n > 0 })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('revokeSession', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAccount,
|
getAccount,
|
||||||
changeUsername,
|
changeUsername,
|
||||||
@@ -216,4 +255,6 @@ module.exports = {
|
|||||||
totpDisable,
|
totpDisable,
|
||||||
listIdentities,
|
listIdentities,
|
||||||
unlinkIdentity,
|
unlinkIdentity,
|
||||||
|
listSessions,
|
||||||
|
revokeSession,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,4 +137,31 @@ meRouter.delete(
|
|||||||
account.unlinkIdentity,
|
account.unlinkIdentity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Active mobile device sessions (self-service). Distinct from /auth/me/devices,
|
||||||
|
// which is push-notification endpoints — these are login sessions (M9). List the
|
||||||
|
// active ones and revoke a single device without "log out everywhere".
|
||||||
|
meRouter.get(
|
||||||
|
'/sessions',
|
||||||
|
// #swagger.tags = ['Auth · Me']
|
||||||
|
// #swagger.summary = 'List active mobile device sessions (self)'
|
||||||
|
// #swagger.description = 'Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Active device sessions', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/DeviceSession" } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
account.listSessions,
|
||||||
|
)
|
||||||
|
meRouter.delete(
|
||||||
|
'/sessions/:id',
|
||||||
|
// #swagger.tags = ['Auth · Me']
|
||||||
|
// #swagger.summary = 'Revoke one mobile device session (self)'
|
||||||
|
// #swagger.description = 'Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The session row id from GET /auth/me/sessions.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
validate,
|
||||||
|
account.revokeSession,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = meRouter
|
module.exports = meRouter
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ function tokenResponse(out, user) {
|
|||||||
|
|
||||||
// Persist a freshly minted refresh token (by hash) and record the login. Shared
|
// Persist a freshly minted refresh token (by hash) and record the login. Shared
|
||||||
// by login and refresh so the storage/side-effect logic lives in one place.
|
// by login and refresh so the storage/side-effect logic lives in one place.
|
||||||
async function persistAndFinish(req, user, out, action) {
|
// `deviceName` is the friendly label for the Active Devices list — supplied by
|
||||||
|
// the client at login and carried forward across rotations on refresh.
|
||||||
|
async function persistAndFinish(req, user, out, action, deviceName = null) {
|
||||||
await mobileSessions.store({
|
await mobileSessions.store({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
tokenHash: out.refreshHash,
|
tokenHash: out.refreshHash,
|
||||||
deviceHash: out.deviceHash,
|
deviceHash: out.deviceHash,
|
||||||
|
deviceName,
|
||||||
userAgent: out.userAgent,
|
userAgent: out.userAgent,
|
||||||
expiresAt: out.refreshExpiresAt,
|
expiresAt: out.refreshExpiresAt,
|
||||||
})
|
})
|
||||||
@@ -79,7 +82,7 @@ async function login(req, res) {
|
|||||||
loginProtection.recordSuccess(req.ip)
|
loginProtection.recordSuccess(req.ip)
|
||||||
const meta = sessionService.sessionMeta(req)
|
const meta = sessionService.sessionMeta(req)
|
||||||
const out = sessionService.createMobileSession(user, meta)
|
const out = sessionService.createMobileSession(user, meta)
|
||||||
await persistAndFinish(req, user, out, 'auth.mobile.login')
|
await persistAndFinish(req, user, out, 'auth.mobile.login', req.body.device_name || null)
|
||||||
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
|
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
|
||||||
return res.json(tokenResponse(out, user))
|
return res.json(tokenResponse(out, user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -111,7 +114,9 @@ async function refresh(req, res) {
|
|||||||
await mobileSessions.revokeByHash(hash) // rotate: old token is now dead
|
await mobileSessions.revokeByHash(hash) // rotate: old token is now dead
|
||||||
const meta = sessionService.sessionMeta(req)
|
const meta = sessionService.sessionMeta(req)
|
||||||
const out = sessionService.refreshMobileSession(user, meta)
|
const out = sessionService.refreshMobileSession(user, meta)
|
||||||
await persistAndFinish(req, user, out, 'auth.mobile.refresh')
|
// Carry the device label forward across rotation so the Active Devices list
|
||||||
|
// stays labeled for the life of the session.
|
||||||
|
await persistAndFinish(req, user, out, 'auth.mobile.refresh', row.device_name || null)
|
||||||
log.info('mobile session refreshed', { id: user.id, ip: req.ip })
|
log.info('mobile session refreshed', { id: user.id, ip: req.ip })
|
||||||
return res.json(tokenResponse(out, user))
|
return res.json(tokenResponse(out, user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ const { requireAuth } = require('../../../auth/session.middleware')
|
|||||||
const { loginLimiter, mobileRefreshLimiter } = require('../../../middleware/rateLimit')
|
const { loginLimiter, mobileRefreshLimiter } = require('../../../middleware/rateLimit')
|
||||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
|
const mobileSsoRouter = require('./mobileSso.routes')
|
||||||
|
|
||||||
const mobileRouter = express.Router()
|
const mobileRouter = express.Router()
|
||||||
|
|
||||||
|
// Native SSO authorization bridge (M9) — /auth/mobile/sso/{start,exchange}.
|
||||||
|
// Additive alongside the credential login below; reuses the website SSO flow and
|
||||||
|
// terminates in the same bearer tokens.
|
||||||
|
mobileRouter.use('/sso', mobileSsoRouter)
|
||||||
|
|
||||||
// Mobile login is a credential surface too, so it sits behind the SAME guards as
|
// Mobile login is a credential surface too, so it sits behind the SAME guards as
|
||||||
// web login (cheapest rejection first): per-IP backoff → progressive slowdown →
|
// web login (cheapest rejection first): per-IP backoff → progressive slowdown →
|
||||||
// hard rate cap.
|
// hard rate cap.
|
||||||
@@ -30,6 +36,8 @@ mobileRouter.post(
|
|||||||
body('password').isString().notEmpty(),
|
body('password').isString().notEmpty(),
|
||||||
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
|
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
|
||||||
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
|
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
|
||||||
|
// Optional friendly device label for the Active Devices list.
|
||||||
|
body('device_name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
|
||||||
validate,
|
validate,
|
||||||
login,
|
login,
|
||||||
)
|
)
|
||||||
|
|||||||
152
server/src/router/v1/auth/mobileSso.controller.js
Normal file
152
server/src/router/v1/auth/mobileSso.controller.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// ── Mobile SSO authorization bridge — start + exchange ─────────────────────
|
||||||
|
//
|
||||||
|
// The native app's "Sign in with Google/Discord" without shipping any OAuth
|
||||||
|
// secret. This controller owns the two app-facing endpoints; the SSO redirect
|
||||||
|
// mechanics and the callback branch live in sso.controller (reused, not
|
||||||
|
// duplicated), and the bridge state lives in the mobileAuthBridge model.
|
||||||
|
//
|
||||||
|
// GET /auth/mobile/sso/start → seed a bridge session, reuse the SSO redirect
|
||||||
|
// POST /auth/mobile/sso/exchange → code + PKCE verifier → mobile bearer tokens
|
||||||
|
//
|
||||||
|
// Two PKCE layers are in play (see docs BACKEND_DESIGN §4): Layer A (website↔IdP,
|
||||||
|
// handled entirely inside the reused SSO flow) and Layer B (app↔website, verified
|
||||||
|
// here at /exchange). Do not conflate them.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
const users = require('../../../model/users/users.model')
|
||||||
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||||
|
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
|
||||||
|
const sessionService = require('../../../auth/session.service')
|
||||||
|
const ssoState = require('../../../auth/ssoState')
|
||||||
|
const ssoController = require('./sso.controller')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('auth-mobile-sso')
|
||||||
|
|
||||||
|
// Exact-match allowlist of app callback URIs. Default is the one fixed
|
||||||
|
// application-owned custom scheme; HTTPS App Link URIs can be appended per shard
|
||||||
|
// later (see docs/android/APP_LINKS.md). EXACT match only — never a prefix match
|
||||||
|
// (prefix matching on custom schemes is a known open-redirect vector on mobile).
|
||||||
|
const REDIRECT_ALLOWLIST = new Set(
|
||||||
|
(process.env.MOBILE_AUTH_REDIRECT_URIS || 'runicgateway://auth/callback')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
|
||||||
|
const PROVIDER_ID_RE = /^[a-z0-9-]+$/
|
||||||
|
|
||||||
|
// Append query params to an (already-allowlisted) app callback URI.
|
||||||
|
function appDeepLink(redirectUri, params) {
|
||||||
|
const sep = redirectUri.includes('?') ? '&' : '?'
|
||||||
|
const qs = Object.entries(params)
|
||||||
|
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||||
|
.join('&')
|
||||||
|
return `${redirectUri}${sep}${qs}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time string compare (equal-length guard first).
|
||||||
|
function safeEqual(a, b) {
|
||||||
|
const bufA = Buffer.from(String(a))
|
||||||
|
const bufB = Buffer.from(String(b))
|
||||||
|
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /auth/mobile/sso/start?provider&code_challenge&state&redirect_uri
|
||||||
|
// Opened by the app in a Custom Tab. Validates the request, seeds a bridge
|
||||||
|
// session carrying the app's PKCE challenge + state + callback, then reuses the
|
||||||
|
// existing SSO redirect (tagged mode:'mobile') to hand off to the IdP.
|
||||||
|
async function start(req, res) {
|
||||||
|
const { provider, code_challenge: codeChallenge, state, redirect_uri: redirectUri } = req.query
|
||||||
|
try {
|
||||||
|
// redirect_uri must be exactly one of the registered app callbacks. Validate
|
||||||
|
// it FIRST — everything else can only be surfaced to the app by redirecting
|
||||||
|
// to a trusted callback, so an untrusted one is a hard 400 (no redirect).
|
||||||
|
if (!REDIRECT_ALLOWLIST.has(redirectUri)) {
|
||||||
|
log.warn('mobile sso start: redirect_uri not in allowlist', { ip: req.ip })
|
||||||
|
return res.status(400).json({ message: 'Unrecognized redirect URI.' })
|
||||||
|
}
|
||||||
|
if (!PROVIDER_ID_RE.test(provider || '')) {
|
||||||
|
return res.redirect(appDeepLink(redirectUri, { error: 'invalid_provider', state }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await mobileBridge.startSession({ provider, codeChallenge, redirectUri, state })
|
||||||
|
const ok = await ssoController.redirectToIdp(req, res, provider, {
|
||||||
|
mode: 'mobile',
|
||||||
|
mobileSessionId: sessionId,
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
|
log.warn('mobile sso start: provider unavailable', { provider })
|
||||||
|
return res.redirect(appDeepLink(redirectUri, { error: 'provider_unavailable', state }))
|
||||||
|
}
|
||||||
|
// redirectToIdp already issued the 302 on success.
|
||||||
|
} catch (err) {
|
||||||
|
log.error('mobile sso start', err)
|
||||||
|
// We validated redirect_uri above, so it's safe to bounce the error to the app.
|
||||||
|
return res.redirect(appDeepLink(redirectUri, { error: 'server_error', state }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/mobile/sso/exchange { code, code_verifier }
|
||||||
|
// Trades the one-time authorization code (+ its PKCE verifier) for the SAME mobile
|
||||||
|
// access + refresh pair as /auth/mobile/login. Single-use, PKCE-bound.
|
||||||
|
async function exchange(req, res) {
|
||||||
|
const { code, code_verifier: codeVerifier } = req.body
|
||||||
|
try {
|
||||||
|
const codeRow = await mobileBridge.findRedeemableCode(code)
|
||||||
|
if (!codeRow) {
|
||||||
|
log.warn('mobile sso exchange: code unknown/expired/used', { ip: req.ip })
|
||||||
|
return res.status(401).json({ message: 'Invalid or expired authorization code.' })
|
||||||
|
}
|
||||||
|
const sess = await mobileBridge.getSession(codeRow.session_id)
|
||||||
|
if (!sess) {
|
||||||
|
return res.status(401).json({ message: 'Invalid or expired authorization code.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKCE Layer B: the app proves it holds the verifier for the challenge it
|
||||||
|
// registered at /start. Check BEFORE burning the code so a caller lacking the
|
||||||
|
// verifier (e.g. a callback interceptor) can't consume a legitimate code.
|
||||||
|
if (!safeEqual(ssoState.codeChallengeFor(codeVerifier || ''), sess.code_challenge)) {
|
||||||
|
log.warn('mobile sso exchange: PKCE verifier mismatch', { ip: req.ip })
|
||||||
|
return res.status(401).json({ message: 'PKCE verification failed.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic single-use gate: only the winner of a concurrent double-redeem
|
||||||
|
// proceeds; a losing/replayed attempt sees false here.
|
||||||
|
if (!(await mobileBridge.consumeCode(code))) {
|
||||||
|
log.warn('mobile sso exchange: code already used', { ip: req.ip })
|
||||||
|
return res.status(401).json({ message: 'Invalid or expired authorization code.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await users.getById(codeRow.user_id) // fresh row; 401 if the account vanished
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ message: 'Invalid or expired authorization code.' })
|
||||||
|
}
|
||||||
|
await mobileBridge.finishSession(codeRow.session_id)
|
||||||
|
|
||||||
|
const meta = sessionService.sessionMeta(req)
|
||||||
|
const out = sessionService.createMobileSession(user, meta)
|
||||||
|
await mobileSessions.store({
|
||||||
|
userId: user.id,
|
||||||
|
tokenHash: out.refreshHash,
|
||||||
|
deviceHash: out.deviceHash,
|
||||||
|
deviceName: req.body.device_name || null,
|
||||||
|
userAgent: out.userAgent,
|
||||||
|
expiresAt: out.refreshExpiresAt,
|
||||||
|
})
|
||||||
|
await activity.log({ req, userId: user.id, action: 'auth.mobile.login', detail: { sso: sess.provider } })
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.error('mobile sso exchange', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, exchange }
|
||||||
56
server/src/router/v1/auth/mobileSso.routes.js
Normal file
56
server/src/router/v1/auth/mobileSso.routes.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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.'
|
||||||
|
/* #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[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
|
||||||
@@ -18,6 +18,7 @@ const userIdentities = require('../../../model/userIdentities/userIdentities.mod
|
|||||||
const settings = require('../../../model/settings/settings.model')
|
const settings = require('../../../model/settings/settings.model')
|
||||||
const registry = require('../../../auth/providers/registry')
|
const registry = require('../../../auth/providers/registry')
|
||||||
const sessionService = require('../../../auth/session.service')
|
const sessionService = require('../../../auth/session.service')
|
||||||
|
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
|
||||||
const ssoState = require('../../../auth/ssoState')
|
const ssoState = require('../../../auth/ssoState')
|
||||||
const token = require('../../../auth/token')
|
const token = require('../../../auth/token')
|
||||||
const totp = require('../../../utils/totp')
|
const totp = require('../../../utils/totp')
|
||||||
@@ -96,6 +97,27 @@ async function listProviders(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared IdP redirect: validate the provider is usable, mint the SSO tx (carrying
|
||||||
|
// any extra `txData`, e.g. mode/linkUserId/returnTo, or the mobile bridge's
|
||||||
|
// mode:'mobile' + mobileSessionId), set the httpOnly tx cookie, and 302 to the
|
||||||
|
// provider authorize URL. Returns true on redirect; false means the provider is
|
||||||
|
// unavailable and the caller renders its own failure (web pages redirect to an
|
||||||
|
// error; the mobile bridge surfaces it to the app). Used by both web start
|
||||||
|
// (beginFlow) and the mobile bridge start (routes/mobileSso.controller).
|
||||||
|
async function redirectToIdp(req, res, providerId, txData = {}) {
|
||||||
|
const row = await authProviders.getWithSecret(providerId)
|
||||||
|
if (!row || !row.enabled || !registry.validateConfig(row).valid) return false
|
||||||
|
const provider = registry.instantiate(row)
|
||||||
|
const tx = ssoState.createTx({ provider: providerId, ...txData })
|
||||||
|
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
|
||||||
|
const url = provider.getAuthorizationUrl(tx.nonce, {
|
||||||
|
redirectUri: redirectUriFor(req, providerId),
|
||||||
|
codeChallenge: tx.codeChallenge,
|
||||||
|
})
|
||||||
|
res.redirect(url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Shared start for both login and link. `mode` ∈ 'login' | 'link'. For link,
|
// Shared start for both login and link. `mode` ∈ 'login' | 'link'. For link,
|
||||||
// requireAuth has already run so req.user is the account to attach the identity to.
|
// requireAuth has already run so req.user is the account to attach the identity to.
|
||||||
async function beginFlow(req, res, mode) {
|
async function beginFlow(req, res, mode) {
|
||||||
@@ -105,26 +127,17 @@ async function beginFlow(req, res, mode) {
|
|||||||
const failUrl = mode === 'link' ? accountError('error', portal) : loginError('error', portal)
|
const failUrl = mode === 'link' ? accountError('error', portal) : loginError('error', portal)
|
||||||
try {
|
try {
|
||||||
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
|
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
|
||||||
const row = await authProviders.getWithSecret(providerId)
|
const ok = await redirectToIdp(req, res, providerId, {
|
||||||
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
|
mode,
|
||||||
|
linkUserId: mode === 'link' ? req.user.id : undefined,
|
||||||
|
returnTo: returnTo || undefined,
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
log.warn('sso start: provider unavailable', { provider: providerId, mode })
|
log.warn('sso start: provider unavailable', { provider: providerId, mode })
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
mode === 'link' ? accountError('unavailable', portal) : loginError('unavailable', portal),
|
mode === 'link' ? accountError('unavailable', portal) : loginError('unavailable', portal),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const provider = registry.instantiate(row)
|
|
||||||
const tx = ssoState.createTx({
|
|
||||||
provider: providerId,
|
|
||||||
mode,
|
|
||||||
linkUserId: mode === 'link' ? req.user.id : undefined,
|
|
||||||
returnTo: returnTo || undefined,
|
|
||||||
})
|
|
||||||
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
|
|
||||||
const url = provider.getAuthorizationUrl(tx.nonce, {
|
|
||||||
redirectUri: redirectUriFor(req, providerId),
|
|
||||||
codeChallenge: tx.codeChallenge,
|
|
||||||
})
|
|
||||||
return res.redirect(url)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('sso start', err)
|
log.error('sso start', err)
|
||||||
return res.redirect(failUrl)
|
return res.redirect(failUrl)
|
||||||
@@ -167,6 +180,7 @@ async function callback(req, res) {
|
|||||||
codeVerifier: tx.verifier,
|
codeVerifier: tx.verifier,
|
||||||
})
|
})
|
||||||
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
|
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
|
||||||
|
if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile)
|
||||||
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('sso callback', err)
|
log.error('sso callback', err)
|
||||||
@@ -266,6 +280,100 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
|||||||
return res.redirect(sanitizeReturn(tx.returnTo) || homePath(portal))
|
return res.redirect(sanitizeReturn(tx.returnTo) || homePath(portal))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mobile SSO bridge (mode 'mobile') ──────────────────────────────────────
|
||||||
|
// The mobile flow ends by handing the app a deep link carrying a one-time
|
||||||
|
// authorization code (never a token) + the app's original `state`. redirect_uri
|
||||||
|
// came from the exact-match allowlist at /start, so appending our params is safe.
|
||||||
|
|
||||||
|
function appDeepLink(redirectUri, params) {
|
||||||
|
const sep = redirectUri.includes('?') ? '&' : '?'
|
||||||
|
const qs = Object.entries(params)
|
||||||
|
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||||
|
.join('&')
|
||||||
|
return `${redirectUri}${sep}${qs}`
|
||||||
|
}
|
||||||
|
const appError = (sess, code) => appDeepLink(sess.redirect_uri, { error: code, state: sess.state })
|
||||||
|
|
||||||
|
// Mint the one-time auth code for a resolved account and return the app success
|
||||||
|
// deep link (or null if the bridge session was no longer pending — e.g. expired
|
||||||
|
// or already used). Shared by the direct callback and the TOTP-completion path so
|
||||||
|
// the "issue code + record login + audit" logic lives in one place. Does not touch
|
||||||
|
// res, so either caller can 302 (callback) or JSON-wrap it (TOTP fetch).
|
||||||
|
async function mintMobileAuthLink(req, sess, user, providerId, viaTotp) {
|
||||||
|
const issued = await mobileBridge.issueAuthCode({ sessionId: sess.session_id, userId: user.id })
|
||||||
|
if (!issued) {
|
||||||
|
log.warn('mobile sso: auth code not issued (session not pending)', { provider: providerId, id: user.id })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
await users.recordLogin(user.id, req.ip)
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
userId: user.id,
|
||||||
|
action: 'auth.sso.login',
|
||||||
|
detail: { provider: providerId, mobile: true, totp: viaTotp || undefined },
|
||||||
|
})
|
||||||
|
log.info('mobile sso login success', { provider: providerId, id: user.id, ip: req.ip, totp: !!viaTotp })
|
||||||
|
return appDeepLink(sess.redirect_uri, { code: issued.code, state: sess.state })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile variant of finishLogin: identical account-resolution policy (link-only
|
||||||
|
// with opt-in provisioning, status gate, TOTP), but a success mints a one-time
|
||||||
|
// code and 302s to the app callback instead of setting a session cookie. A 2FA
|
||||||
|
// account is routed through the same web TOTP form (carrying the bridge session)
|
||||||
|
// and completes in finishSsoTotp — the second factor is never bypassed.
|
||||||
|
async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
||||||
|
const sess = await mobileBridge.getSession(tx.mobileSessionId)
|
||||||
|
if (!sess || sess.status !== 'pending' || new Date(sess.expires_at).getTime() <= Date.now()) {
|
||||||
|
log.warn('mobile sso callback: bridge session invalid/expired', { provider: providerId })
|
||||||
|
// Without a valid session we can't trust a redirect_uri — fail generically.
|
||||||
|
if (sess) return res.redirect(appError(sess, 'session_expired'))
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: 'This sign-in session is invalid or has expired. Please try again from the app.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let user
|
||||||
|
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||||
|
if (identity) {
|
||||||
|
user = await users.getById(identity.user_id)
|
||||||
|
if (!user) return res.redirect(appError(sess, 'not_linked'))
|
||||||
|
} else {
|
||||||
|
const mode = await settings.getRegistrationMode()
|
||||||
|
if (mode !== 'sso' && mode !== 'both') {
|
||||||
|
log.warn('mobile sso login refused: no linked account', { provider: providerId })
|
||||||
|
return res.redirect(appError(sess, 'not_linked'))
|
||||||
|
}
|
||||||
|
user = await provisionSsoPlayer(req, providerId, profile)
|
||||||
|
if (!user) return res.redirect(appError(sess, 'error'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status && user.status !== 'active') {
|
||||||
|
log.warn('mobile sso login refused: inactive account', { provider: providerId, id: user.id, status: user.status })
|
||||||
|
return res.redirect(appError(sess, 'disabled'))
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
const pending = ssoState.createTotpPending({
|
||||||
|
userId: user.id,
|
||||||
|
provider: providerId,
|
||||||
|
authMethod,
|
||||||
|
returnTo: '/account',
|
||||||
|
mobileSessionId: sess.session_id,
|
||||||
|
})
|
||||||
|
res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req))
|
||||||
|
log.info('mobile sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip })
|
||||||
|
return res.redirect(`${loginPath('account')}?sso_totp=1`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = await mintMobileAuthLink(req, sess, user, providerId, false)
|
||||||
|
return res.redirect(link || appError(sess, 'error'))
|
||||||
|
}
|
||||||
|
|
||||||
// POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on.
|
// POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on.
|
||||||
// Reads the staged pending-TOTP cookie, verifies the authenticator code, then
|
// Reads the staged pending-TOTP cookie, verifies the authenticator code, then
|
||||||
// mints the full session. Mirrors auth.controller.loginTotp: a wrong code is a
|
// mints the full session. Mirrors auth.controller.loginTotp: a wrong code is a
|
||||||
@@ -293,9 +401,25 @@ async function finishSsoTotp(req, res) {
|
|||||||
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
|
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second factor satisfied — clear the staged cookie and issue the real session.
|
// Second factor satisfied — clear the staged cookie.
|
||||||
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
|
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
|
||||||
loginProtection.recordSuccess(req.ip)
|
loginProtection.recordSuccess(req.ip)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if (pending.mobileSessionId) {
|
||||||
|
const sess = await mobileBridge.getSession(pending.mobileSessionId)
|
||||||
|
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.' })
|
||||||
|
}
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
|
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
|
||||||
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
||||||
token.setAuthCookie(req, res, sessionToken)
|
token.setAuthCookie(req, res, sessionToken)
|
||||||
@@ -330,4 +454,15 @@ async function finishLink(req, res, providerId, tx, profile) {
|
|||||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink }
|
module.exports = {
|
||||||
|
listProviders,
|
||||||
|
start,
|
||||||
|
linkStart,
|
||||||
|
callback,
|
||||||
|
beginFlow,
|
||||||
|
redirectToIdp,
|
||||||
|
finishLogin,
|
||||||
|
finishMobileLogin,
|
||||||
|
finishSsoTotp,
|
||||||
|
finishLink,
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const { ensureSchema, close } = require('./utils/db')
|
|||||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||||
const settings = require('./model/settings/settings.model')
|
const settings = require('./model/settings/settings.model')
|
||||||
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||||
|
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||||
const brand = require('./config/brand')
|
const brand = require('./config/brand')
|
||||||
@@ -67,6 +68,15 @@ async function start() {
|
|||||||
log.warn('revoked-session prune failed', { error: err.message })
|
log.warn('revoked-session prune failed', { error: err.message })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same treatment for the mobile SSO bridge tables (also pruned opportunistically
|
||||||
|
// on each bridge write). Boot-time sweep catches rows orphaned by a crash.
|
||||||
|
try {
|
||||||
|
const pruned = await mobileAuthBridge.pruneExpired()
|
||||||
|
if (pruned) log.info(`pruned ${pruned} expired mobile-auth-bridge row(s)`)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
const mode = await settings.get('site_mode')
|
const mode = await settings.get('site_mode')
|
||||||
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
||||||
|
|
||||||
|
|||||||
@@ -894,6 +894,142 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/auth/mobile/sso/start": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Auth · Mobile"
|
||||||
|
],
|
||||||
|
"summary": "Begin native SSO login (redirect to the IdP)",
|
||||||
|
"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=…`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "provider",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Provider id from GET /auth/providers (e.g. google, discord)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "code_challenge",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "App-generated PKCE S256 challenge (base64url)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "App-generated opaque CSRF value, echoed on the callback for the app to verify."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "redirect_uri",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback)."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"302": {
|
||||||
|
"description": "Redirect to the identity provider (or back to the app callback on error)"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Unrecognized redirect URI or validation error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too many attempts (rate limited)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/mobile/sso/exchange": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"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.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Access + refresh tokens",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MobileTokenResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Invalid/expired/used code or failed PKCE verification",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too many attempts (rate limited)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MobileSsoExchangeRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/auth/providers": {
|
"/api/v1/auth/providers": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1096,6 +1232,9 @@
|
|||||||
"403": {
|
"403": {
|
||||||
"description": "Forbidden"
|
"description": "Forbidden"
|
||||||
},
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict"
|
||||||
|
},
|
||||||
"429": {
|
"429": {
|
||||||
"description": "Too many attempts (rate limited / backoff)",
|
"description": "Too many attempts (rate limited / backoff)",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -1631,6 +1770,118 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/auth/me/sessions": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Auth · Me"
|
||||||
|
],
|
||||||
|
"summary": "List active mobile device sessions (self)",
|
||||||
|
"description": "Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Active device sessions",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DeviceSession"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/me/sessions/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Auth · Me"
|
||||||
|
],
|
||||||
|
"summary": "Revoke one mobile device session (self)",
|
||||||
|
"description": "Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "The session row id from GET /auth/me/sessions."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Revoked (idempotent)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"revoked": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/auth/me/devices": {
|
"/api/v1/auth/me/devices": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -10974,6 +11225,23 @@
|
|||||||
"example": "123456"
|
"example": "123456"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"device_name": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Optional friendly device label for Active Devices."
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Pixel 8"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11115,6 +11383,169 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MobileSsoExchangeRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "array",
|
||||||
|
"example": [
|
||||||
|
"code",
|
||||||
|
"code_verifier"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "The single-use authorization code returned to the app callback."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"code_verifier": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "The PKCE verifier for the challenge sent to /auth/mobile/sso/start."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"device_name": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Optional friendly device label for Active Devices."
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Pixel 8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DeviceSession": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Session row id (pass to DELETE /auth/me/sessions/:id)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deviceName": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Pixel 8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"userAgent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lastUsedAt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Message": {
|
"Message": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ const doc = {
|
|||||||
username: { type: 'string', example: 'admin' },
|
username: { type: 'string', example: 'admin' },
|
||||||
password: { type: 'string', format: 'password', example: 'super-secret' },
|
password: { type: 'string', format: 'password', example: 'super-secret' },
|
||||||
code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
|
code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
|
||||||
|
device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MobileTokenResponse: {
|
MobileTokenResponse: {
|
||||||
@@ -185,6 +186,32 @@ const doc = {
|
|||||||
all: { type: 'boolean', description: 'Revoke every session for the user.', example: false },
|
all: { type: 'boolean', description: 'Revoke every session for the user.', example: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
MobileSsoExchangeRequest: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['code', 'code_verifier'],
|
||||||
|
properties: {
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The single-use authorization code returned to the app callback.',
|
||||||
|
},
|
||||||
|
code_verifier: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The PKCE verifier for the challenge sent to /auth/mobile/sso/start.',
|
||||||
|
},
|
||||||
|
device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DeviceSession: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer', description: 'Session row id (pass to DELETE /auth/me/sessions/:id).' },
|
||||||
|
deviceName: { type: 'string', nullable: true, example: 'Pixel 8' },
|
||||||
|
userAgent: { type: 'string', nullable: true },
|
||||||
|
createdAt: { type: 'string', format: 'date-time' },
|
||||||
|
lastUsedAt: { type: 'string', format: 'date-time' },
|
||||||
|
expiresAt: { type: 'string', format: 'date-time' },
|
||||||
|
},
|
||||||
|
},
|
||||||
Message: {
|
Message: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { message: { type: 'string', example: 'Logged out.' } },
|
properties: { message: { type: 'string', example: 'Logged out.' } },
|
||||||
|
|||||||
83
server/test/mobileAuthBridge.model.test.js
Normal file
83
server/test/mobileAuthBridge.model.test.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Pure-logic tests for the mobile SSO bridge MODEL. The .db layer is stubbed
|
||||||
|
// (plain object exports → mutable in-process), so these are DB-free and only
|
||||||
|
// exercise the model's gating, hashing, and code-generation logic.
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
const bridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
|
||||||
|
const bridgeDb = require('../src/model/mobileAuthBridge/mobileAuthBridge.db')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
|
||||||
|
|
||||||
|
let calls
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = { insertSession: [], insertCode: [], completeSession: [], markCodeUsed: [], findValidCode: [] }
|
||||||
|
bridgeDb.insertSession = async (a) => { calls.insertSession.push(a); return 1 }
|
||||||
|
bridgeDb.insertCode = async (a) => { calls.insertCode.push(a); return 1 }
|
||||||
|
bridgeDb.completeSession = async (sid, uid) => { calls.completeSession.push([sid, uid]); return 1 }
|
||||||
|
bridgeDb.consumeSession = async () => 1
|
||||||
|
bridgeDb.getSession = async () => null
|
||||||
|
bridgeDb.findValidCode = async (h) => { calls.findValidCode.push(h); return { code_hash: h, user_id: 9, session_id: 's' } }
|
||||||
|
bridgeDb.markCodeUsed = async () => 1
|
||||||
|
bridgeDb.pruneExpired = async () => 0
|
||||||
|
})
|
||||||
|
|
||||||
|
test('startSession generates a uuid session_id and persists the row', async () => {
|
||||||
|
const { sessionId } = await bridge.startSession({
|
||||||
|
provider: 'google', codeChallenge: 'chal', redirectUri: 'runicgateway://auth/callback', state: 'st',
|
||||||
|
})
|
||||||
|
assert.match(sessionId, /^[0-9a-f-]{36}$/)
|
||||||
|
assert.equal(calls.insertSession.length, 1)
|
||||||
|
assert.equal(calls.insertSession[0].sessionId, sessionId)
|
||||||
|
assert.equal(calls.insertSession[0].provider, 'google')
|
||||||
|
assert.ok(calls.insertSession[0].expiresAt instanceof Date)
|
||||||
|
assert.ok(calls.insertSession[0].expiresAt.getTime() > Date.now())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('issueAuthCode: completes the session, stores only the code HASH, returns the raw code once', async () => {
|
||||||
|
const out = await bridge.issueAuthCode({ sessionId: 's-1', userId: 42 })
|
||||||
|
assert.ok(out && typeof out.code === 'string')
|
||||||
|
// >=128-bit, url-safe, opaque.
|
||||||
|
assert.ok(out.code.length >= 22, 'at least 128 bits of base64url entropy')
|
||||||
|
assert.match(out.code, /^[A-Za-z0-9_-]+$/)
|
||||||
|
// Session was marked completed for THIS user.
|
||||||
|
assert.deepEqual(calls.completeSession[0], ['s-1', 42])
|
||||||
|
// Only the sha256 hash of the code is persisted; the raw code never is.
|
||||||
|
assert.equal(calls.insertCode[0].codeHash, sha256(out.code))
|
||||||
|
assert.equal(calls.insertCode[0].userId, 42)
|
||||||
|
assert.equal(calls.insertCode[0].sessionId, 's-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('issueAuthCode returns null (mints no code) when the session is not pending/eligible', async () => {
|
||||||
|
bridgeDb.completeSession = async () => 0 // not pending / expired / already used
|
||||||
|
const out = await bridge.issueAuthCode({ sessionId: 's-1', userId: 42 })
|
||||||
|
assert.equal(out, null)
|
||||||
|
assert.equal(calls.insertCode.length, 0, 'no code row when the session could not be completed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('findRedeemableCode looks up by the code HASH, not the raw code', async () => {
|
||||||
|
const row = await bridge.findRedeemableCode('raw-code-value')
|
||||||
|
assert.equal(calls.findValidCode[0], sha256('raw-code-value'))
|
||||||
|
assert.equal(row.user_id, 9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('consumeCode is single-use: true only when a row was actually flipped', async () => {
|
||||||
|
bridgeDb.markCodeUsed = async () => 1
|
||||||
|
assert.equal(await bridge.consumeCode('c'), true)
|
||||||
|
bridgeDb.markCodeUsed = async () => 0 // already used / raced
|
||||||
|
assert.equal(await bridge.consumeCode('c'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two issued codes are distinct (fresh entropy each time)', async () => {
|
||||||
|
const a = await bridge.issueAuthCode({ sessionId: 's', userId: 1 })
|
||||||
|
const b = await bridge.issueAuthCode({ sessionId: 's', userId: 1 })
|
||||||
|
assert.notEqual(a.code, b.code)
|
||||||
|
})
|
||||||
65
server/test/mobileDeviceSessions.test.js
Normal file
65
server/test/mobileDeviceSessions.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Active Devices (mobile session view/revoke) — controller tests. The
|
||||||
|
// mobileSessions model is stubbed, so these are DB-free.
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||||
|
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const account = require('../src/router/v1/admin/account.controller')
|
||||||
|
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
|
||||||
|
const activity = require('../src/model/activity/activity.model')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
let logged
|
||||||
|
beforeEach(() => {
|
||||||
|
logged = []
|
||||||
|
activity.log = async (e) => { logged.push(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
function res() {
|
||||||
|
return {
|
||||||
|
statusCode: 200, body: null,
|
||||||
|
status(c) { this.statusCode = c; return this },
|
||||||
|
json(b) { this.body = b; return this },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('listSessions returns active devices without any token material', async () => {
|
||||||
|
mobileSessions.listActiveForUser = async (uid) => {
|
||||||
|
assert.equal(uid, 7)
|
||||||
|
return [
|
||||||
|
{ id: 3, device_name: 'Pixel 8', user_agent: 'okhttp', created_at: 'c3', last_used_at: 'u3', expires_at: 'e3', token_hash: 'SECRET' },
|
||||||
|
{ id: 1, device_name: null, user_agent: null, created_at: 'c1', last_used_at: null, expires_at: 'e1' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const r = res()
|
||||||
|
await account.listSessions({ user: { id: 7 } }, r)
|
||||||
|
assert.equal(r.body.length, 2)
|
||||||
|
assert.deepEqual(r.body[0], { id: 3, deviceName: 'Pixel 8', userAgent: 'okhttp', createdAt: 'c3', lastUsedAt: 'u3', expiresAt: 'e3' })
|
||||||
|
// last_used_at falls back to created_at when null; no token/hash leaks.
|
||||||
|
assert.equal(r.body[1].lastUsedAt, 'c1')
|
||||||
|
assert.equal(JSON.stringify(r.body).includes('SECRET'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revokeSession is ownership-scoped and audits a real revoke', async () => {
|
||||||
|
let args = null
|
||||||
|
mobileSessions.revokeByIdForUser = async (id, uid) => { args = [id, uid]; return 1 }
|
||||||
|
const r = res()
|
||||||
|
await account.revokeSession({ user: { id: 7 }, params: { id: '3' } }, r)
|
||||||
|
assert.deepEqual(args, [3, 7], 'revokes by id scoped to the caller')
|
||||||
|
assert.deepEqual(r.body, { revoked: true })
|
||||||
|
assert.equal(logged.at(-1).action, 'auth.mobile.session.revoke')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revokeSession on a non-owned/absent id is idempotent and not audited', async () => {
|
||||||
|
mobileSessions.revokeByIdForUser = async () => 0
|
||||||
|
const r = res()
|
||||||
|
await account.revokeSession({ user: { id: 7 }, params: { id: '999' } }, r)
|
||||||
|
assert.deepEqual(r.body, { revoked: false })
|
||||||
|
assert.equal(logged.length, 0, 'nothing revoked → nothing logged')
|
||||||
|
})
|
||||||
264
server/test/mobileSsoBridge.test.js
Normal file
264
server/test/mobileSsoBridge.test.js
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
// Mobile SSO authorization bridge — controller/integration tests. Models are
|
||||||
|
// stubbed (mutable object exports), so these are DB-free and exercise the bridge
|
||||||
|
// start/exchange handlers plus the mode:'mobile' branches added to sso.controller.
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||||
|
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const mobileSso = require('../src/router/v1/auth/mobileSso.controller')
|
||||||
|
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
|
||||||
|
const ssoState = require('../src/auth/ssoState')
|
||||||
|
const token = require('../src/auth/token')
|
||||||
|
const bridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
|
||||||
|
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
|
||||||
|
const users = require('../src/model/users/users.model')
|
||||||
|
const activity = require('../src/model/activity/activity.model')
|
||||||
|
const authProviders = require('../src/model/authProviders/authProviders.model')
|
||||||
|
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||||
|
const settings = require('../src/model/settings/settings.model')
|
||||||
|
const registry = require('../src/auth/providers/registry')
|
||||||
|
const totp = require('../src/utils/totp')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
const CALLBACK = 'runicgateway://auth/callback'
|
||||||
|
const PROFILE = { subject: 'sub-1', email: 'alice@example.com', name: 'Alice' }
|
||||||
|
const SESSION = {
|
||||||
|
session_id: 'sess-1',
|
||||||
|
status: 'pending',
|
||||||
|
provider: 'google',
|
||||||
|
redirect_uri: CALLBACK,
|
||||||
|
state: 'st-abc',
|
||||||
|
code_challenge: ssoState.codeChallengeFor('verifier-xyz'),
|
||||||
|
expires_at: new Date(Date.now() + 5 * 60 * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
let logged
|
||||||
|
beforeEach(() => {
|
||||||
|
logged = []
|
||||||
|
activity.log = async (e) => { logged.push(e) }
|
||||||
|
authProviders.getWithSecret = async () => ({ id: 'google', kind: 'google', enabled: 1, client_id: 'c', client_secret_enc: 'e' })
|
||||||
|
registry.instantiate = () => ({ handleCallback: async () => ({ ...PROFILE }) })
|
||||||
|
registry.validateConfig = () => ({ valid: true })
|
||||||
|
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
|
||||||
|
settings.getRegistrationMode = async () => 'disabled'
|
||||||
|
users.getById = async (id) => ({ id, username: 'alice', role: 'player' })
|
||||||
|
users.recordLogin = async () => {}
|
||||||
|
mobileSessions.store = async () => 1
|
||||||
|
bridge.getSession = async () => ({ ...SESSION })
|
||||||
|
bridge.issueAuthCode = async () => ({ code: 'RAWCODE' })
|
||||||
|
bridge.startSession = async () => ({ sessionId: 'sess-1' })
|
||||||
|
bridge.findRedeemableCode = async () => ({ user_id: 7, session_id: 'sess-1' })
|
||||||
|
bridge.consumeCode = async () => true
|
||||||
|
bridge.finishSession = async () => 1
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200, redirectedTo: null, body: null, cookies: {}, cleared: [],
|
||||||
|
status(c) { this.statusCode = c; return this },
|
||||||
|
json(b) { this.body = b; return this },
|
||||||
|
redirect(u) { this.redirectedTo = u; return this },
|
||||||
|
cookie(n, v) { this.cookies[n] = v; return this },
|
||||||
|
clearCookie(n) { this.cleared.push(n); return this },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /start ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('start: an un-allowlisted redirect_uri is a hard 400 (no redirect, exact-match only)', async () => {
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.start(
|
||||||
|
{ query: { provider: 'google', code_challenge: 'c', state: 's', redirect_uri: 'evil://auth/callback' }, ip: '1.2.3.4' },
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
assert.equal(res.statusCode, 400)
|
||||||
|
assert.equal(res.redirectedTo, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('start: a prefix of the allowlisted URI is rejected (not a prefix match)', async () => {
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.start(
|
||||||
|
{ query: { provider: 'google', code_challenge: 'c', state: 's', redirect_uri: 'runicgateway://auth/callback.evil.com' }, ip: '1.2.3.4' },
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
assert.equal(res.statusCode, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('start: seeds a bridge session and reuses the SSO redirect tagged mode:mobile', async () => {
|
||||||
|
let startArgs = null
|
||||||
|
let txData = null
|
||||||
|
bridge.startSession = async (a) => { startArgs = a; return { sessionId: 'sess-99' } }
|
||||||
|
const saved = ssoCtrl.redirectToIdp
|
||||||
|
ssoCtrl.redirectToIdp = async (req, res, provider, data) => { txData = { provider, ...data }; res.redirect('https://idp/authorize'); return true }
|
||||||
|
try {
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.start(
|
||||||
|
{ query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: CALLBACK }, ip: '1.2.3.4' },
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
assert.equal(res.redirectedTo, 'https://idp/authorize')
|
||||||
|
assert.equal(startArgs.redirectUri, CALLBACK)
|
||||||
|
assert.equal(startArgs.codeChallenge, 'chal')
|
||||||
|
assert.equal(txData.provider, 'google')
|
||||||
|
assert.equal(txData.mode, 'mobile')
|
||||||
|
assert.equal(txData.mobileSessionId, 'sess-99')
|
||||||
|
} finally {
|
||||||
|
ssoCtrl.redirectToIdp = saved
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('start: a disabled/unavailable provider surfaces the error to the app callback', async () => {
|
||||||
|
const saved = ssoCtrl.redirectToIdp
|
||||||
|
ssoCtrl.redirectToIdp = async () => false
|
||||||
|
try {
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.start(
|
||||||
|
{ query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: CALLBACK }, ip: '1.2.3.4' },
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
assert.equal(res.redirectedTo, `${CALLBACK}?error=provider_unavailable&state=st`)
|
||||||
|
} finally {
|
||||||
|
ssoCtrl.redirectToIdp = saved
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── /exchange ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function exchangeReq(body) {
|
||||||
|
return { body, ip: '127.0.0.1', headers: { 'user-agent': 'Android' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('exchange: valid code + PKCE verifier → mobile bearer tokens (same shape as login)', async () => {
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.ok(res.body.accessToken, 'access token issued')
|
||||||
|
assert.ok(res.body.refreshToken, 'refresh token issued')
|
||||||
|
assert.equal(res.body.user.id, 7)
|
||||||
|
// The issued access token validates as a mobile session.
|
||||||
|
const s = require('../src/auth/session.service').validateBearerToken(res.body.accessToken)
|
||||||
|
assert.equal(s.authMethod, 'mobile')
|
||||||
|
assert.equal(logged.at(-1).action, 'auth.mobile.login')
|
||||||
|
assert.equal(logged.at(-1).detail.sso, 'google')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exchange: unknown/expired/used code → 401', async () => {
|
||||||
|
bridge.findRedeemableCode = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.exchange(exchangeReq({ code: 'nope', code_verifier: 'verifier-xyz' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exchange: wrong PKCE verifier → 401 and the code is NOT consumed', async () => {
|
||||||
|
let consumed = false
|
||||||
|
bridge.consumeCode = async () => { consumed = true; return true }
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'WRONG' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.equal(consumed, false, 'a failed PKCE check must not burn a legitimate code')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exchange: a reused (already-consumed) code → 401 (single use)', async () => {
|
||||||
|
bridge.consumeCode = async () => false // lost the single-use race / replay
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exchange: vanished user → 401', async () => {
|
||||||
|
users.getById = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await mobileSso.exchange(exchangeReq({ code: 'RAWCODE', code_verifier: 'verifier-xyz' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── callback (mode:'mobile') via sso.controller ──────────────────────────────
|
||||||
|
|
||||||
|
function callbackReq(tx) {
|
||||||
|
return {
|
||||||
|
params: { provider: 'google' },
|
||||||
|
cookies: { [ssoState.TX_COOKIE]: tx.txToken },
|
||||||
|
query: { state: tx.nonce, code: 'idp-code' },
|
||||||
|
ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('callback (mobile): linked account → deep link with a one-time code + echoed state, NO cookie', async () => {
|
||||||
|
const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.callback(callbackReq(tx), res)
|
||||||
|
assert.equal(res.redirectedTo, `${CALLBACK}?code=RAWCODE&state=st-abc`)
|
||||||
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no web session cookie on a mobile flow')
|
||||||
|
assert.equal(logged.at(-1).action, 'auth.sso.login')
|
||||||
|
assert.equal(logged.at(-1).detail.mobile, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('callback (mobile): unlinked account with registration closed → error deep link (link-only)', async () => {
|
||||||
|
userIdentities.findByProviderSubject = async () => null
|
||||||
|
const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.callback(callbackReq(tx), res)
|
||||||
|
assert.equal(res.redirectedTo, `${CALLBACK}?error=not_linked&state=st-abc`)
|
||||||
|
assert.equal(logged.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('callback (mobile): a 2FA account is routed through the TOTP form, code NOT yet issued', async () => {
|
||||||
|
users.getById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1 })
|
||||||
|
let issued = false
|
||||||
|
bridge.issueAuthCode = async () => { issued = true; return { code: 'RAWCODE' } }
|
||||||
|
const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'sess-1' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.callback(callbackReq(tx), res)
|
||||||
|
assert.equal(res.redirectedTo, '/account/login?sso_totp=1')
|
||||||
|
assert.ok(res.cookies[ssoState.TOTP_COOKIE], 'pending-TOTP cookie staged')
|
||||||
|
assert.equal(issued, false, 'no auth code before the second factor passes')
|
||||||
|
// The staged challenge carries the bridge session so completion can deep-link back.
|
||||||
|
const pending = ssoState.verifyTotpPending(res.cookies[ssoState.TOTP_COOKIE])
|
||||||
|
assert.equal(pending.mobileSessionId, 'sess-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('callback (mobile): an invalid/expired bridge session fails without leaking a redirect', async () => {
|
||||||
|
bridge.getSession = async () => null
|
||||||
|
const tx = ssoState.createTx({ provider: 'google', mode: 'mobile', mobileSessionId: 'gone' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.callback(callbackReq(tx), res)
|
||||||
|
assert.equal(res.statusCode, 400)
|
||||||
|
assert.equal(res.redirectedTo, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── finishSsoTotp (mode:'mobile') ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function totpReq(pending, code) {
|
||||||
|
return {
|
||||||
|
cookies: pending ? { [ssoState.TOTP_COOKIE]: pending } : {},
|
||||||
|
body: { code }, ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('finishSsoTotp (mobile): correct code → JSON { redirect } deep link, no cookie', async () => {
|
||||||
|
users.getRawById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1, totp_secret: 'S' })
|
||||||
|
totp.verifyCode = () => true
|
||||||
|
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', returnTo: '/account', mobileSessionId: 'sess-1' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.finishSsoTotp(totpReq(pending, '123456'), res)
|
||||||
|
assert.equal(res.body.redirect, `${CALLBACK}?code=RAWCODE&state=st-abc`)
|
||||||
|
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'mobile 2FA completion sets no web session cookie')
|
||||||
|
assert.ok(res.cleared.includes(ssoState.TOTP_COOKIE))
|
||||||
|
assert.equal(logged.at(-1).detail.totp, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finishSsoTotp (mobile): expired bridge session → 401', async () => {
|
||||||
|
users.getRawById = async (id) => ({ id, username: 'alice', role: 'player', totp_enabled: 1, totp_secret: 'S' })
|
||||||
|
totp.verifyCode = () => true
|
||||||
|
bridge.getSession = async () => null
|
||||||
|
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', mobileSessionId: 'gone' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.finishSsoTotp(totpReq(pending, '123456'), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user