diff --git a/server/db/seed.js b/server/db/seed.js index 1269d84..78ed7e8 100644 --- a/server/db/seed.js +++ b/server/db/seed.js @@ -21,6 +21,8 @@ const DEFAULT_SETTINGS = { 'future news, screenshots, guides, and community notes as the world comes online.', contact_email: brand.contactEmail, site_title: brand.name, + // Android App Links opt-in — off until an admin enables it (docs/android/APP_LINKS.md). + mobile_app_links_enabled: 'false', } // Starter wiki sections (editable later via the admin panel). diff --git a/server/src/app.js b/server/src/app.js index 79c464b..69020b1 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -10,6 +10,7 @@ require('dotenv').config() const swaggerUi = require('swagger-ui-express') const apiRouter = require('./router/api.router') +const wellKnown = require('./router/wellKnown.controller') const brand = require('./config/brand') const createLogger = require('./utils/logger') const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy') @@ -143,6 +144,12 @@ app.get( app.use('/api', apiRouter) app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' })) +// ── /.well-known ────────────────────────────────────────────────────── +// Android App Links verification file at the web root (M9 follow-up). Mounted +// before the SPA catch-all so it returns JSON, not the index shell. 404s unless +// the admin has enabled App Links for this shard (docs/android/APP_LINKS.md). +app.get('/.well-known/assetlinks.json', wellKnown.assetlinks) + // ── Client SPA ──────────────────────────────────────────────────────── // Serve the built React app if present; otherwise show a placeholder so the // server is usable API-only before the frontend phase. diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 1b9a605..979d8b2 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -54,6 +54,22 @@ async function isGameAccountSignupEnabled() { return GAME_SIGNUP_OFFER.includes(await getGameSignupMode()) } +// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves +// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the +// self-origin https:///mobile/callback redirect. Stored as the string +// 'true'/'false'; default off. See docs/android/APP_LINKS.md. +const MOBILE_APP_LINKS_KEY = 'mobile_app_links_enabled' + +// Fail-closed: any read error (e.g. DB unavailable) reports "disabled" so a +// transient fault can never open the https redirect path or serve assetlinks.json. +async function isMobileAppLinksEnabled() { + try { + return String(await settingsDb.get(MOBILE_APP_LINKS_KEY)) === 'true' + } catch { + return false + } +} + async function get(key) { return settingsDb.get(key) } @@ -113,6 +129,11 @@ async function getPublic() { // that we use the first NTFY_ALLOWED_ORIGINS entry (a device endpoint must sit // on an allowed origin anyway). Never NTFY_BASE_URL — it may be internal-only. out.push = { ntfyUrl: publicNtfyUrl() } + // Whether this shard has opted into Android App Links (M9 follow-up). Lets a + // native client tell whether it may request the https App Link redirect_uri + // before doing so (the server would otherwise reject an unallowlisted one). The + // custom-scheme callback works regardless of this flag. + out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true' return out } @@ -142,4 +163,6 @@ module.exports = { GAME_SIGNUP_MODES, getGameSignupMode, isGameAccountSignupEnabled, + MOBILE_APP_LINKS_KEY, + isMobileAppLinksEnabled, } diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index b6d4bff..5edaeff 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -512,6 +512,15 @@ async function updateSettings(req, res) { ) { return res.status(400).json({ message: 'Invalid game_account_signup value' }) } + // App Links toggle is a boolean stored as a 'true'/'false' string; accept a real + // boolean or those two strings and normalize, reject anything else. + if (settings.MOBILE_APP_LINKS_KEY in updates) { + const v = updates[settings.MOBILE_APP_LINKS_KEY] + if (v !== true && v !== false && v !== 'true' && v !== 'false') { + return res.status(400).json({ message: 'Invalid mobile_app_links_enabled value' }) + } + updates[settings.MOBILE_APP_LINKS_KEY] = String(v === true || v === 'true') + } // The homepage teaser is rich text (HTML) from the shared editor — sanitize it // against the same allowlist as post/wiki bodies so a stored value is safe (the // client re-sanitizes on render as defense in depth). diff --git a/server/src/router/v1/auth/mobileSso.controller.js b/server/src/router/v1/auth/mobileSso.controller.js index fdb1299..e8dbe5c 100644 --- a/server/src/router/v1/auth/mobileSso.controller.js +++ b/server/src/router/v1/auth/mobileSso.controller.js @@ -18,6 +18,7 @@ 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 settings = require('../../../model/settings/settings.model') const sessionService = require('../../../auth/session.service') const ssoState = require('../../../auth/ssoState') const ssoController = require('./sso.controller') @@ -37,6 +38,32 @@ const REDIRECT_ALLOWLIST = new Set( const PROVIDER_ID_RE = /^[a-z0-9-]+$/ +// This shard's own https App Link callback. Built from APP_BASE_URL (preferred, so +// it is never derived from an attacker-set Host header) or, failing that, the +// request's own origin. Path is the fixed /mobile/callback the app's autoVerify +// intent-filter is registered for. +function selfOriginCallback(req) { + const base = (process.env.APP_BASE_URL || '').trim().replace(/\/+$/, '') + const origin = base || `${req.protocol}://${req.get('host')}` + return `${origin}/mobile/callback` +} + +// redirect_uri is valid if it is one of the statically-allowlisted app callbacks +// (default the custom scheme), OR — only when the admin has enabled App Links — +// this shard's own https:///mobile/callback. EXACT match in both cases; the +// App Links entry is additive and never narrows the custom-scheme allowlist. +async function isAllowedRedirect(redirectUri, req) { + if (REDIRECT_ALLOWLIST.has(redirectUri)) return true + // App Links only ever add an https callback, so skip the settings lookup for + // anything that can't be one — custom-scheme rejections stay fast and DB-free. + if (typeof redirectUri === 'string' && redirectUri.startsWith('https://')) { + if (await settings.isMobileAppLinksEnabled()) { + return redirectUri === selfOriginCallback(req) + } + } + return false +} + // Append query params to an (already-allowlisted) app callback URI. function appDeepLink(redirectUri, params) { const sep = redirectUri.includes('?') ? '&' : '?' @@ -63,7 +90,7 @@ async function start(req, res) { // 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)) { + if (!(await isAllowedRedirect(redirectUri, req))) { log.warn('mobile sso start: redirect_uri not in allowlist', { ip: req.ip }) return res.status(400).json({ message: 'Unrecognized redirect URI.' }) } diff --git a/server/src/router/wellKnown.controller.js b/server/src/router/wellKnown.controller.js new file mode 100644 index 0000000..795fd69 --- /dev/null +++ b/server/src/router/wellKnown.controller.js @@ -0,0 +1,63 @@ +// ── /.well-known/* — web-root, non-API endpoints ────────────────────────── +// +// Android App Links verification file (M9 follow-up, docs/android/APP_LINKS.md). +// Served at the web root (outside /api/v1) because Android's Play/verifier fetch +// it from a fixed path. It is gated by the `mobile_app_links_enabled` admin +// setting: off ⇒ 404 (the app stays on the custom-scheme callback for this shard). +// +// The asserted package + fingerprint are constants of the ONE published app, not +// per-shard: the same binary is verifiable against every shard that opts in. + +const settings = require('../model/settings/settings.model') +const log = require('../utils/logger')('well-known') + +// Fixed identity of the published app. Overridable via env for a white-label build +// that ships under a different package / release cert. +const PACKAGE = (process.env.MOBILE_APP_PACKAGE || 'com.runicgateway.app').trim() + +// Release signing-cert SHA-256 fingerprint(s), comma-separated. Multiple entries +// support cert rotation (old + new) and a debug + release cert during testing. +// Colons and case are normalized to the upper-cased, colon-separated form the +// Digital Asset Links spec expects. +function fingerprints() { + return (process.env.MOBILE_APP_CERT_SHA256 || '') + .split(',') + .map((s) => s.trim().toUpperCase()) + .filter(Boolean) +} + +let warnedNoFingerprint = false + +// GET /.well-known/assetlinks.json +async function assetlinks(req, res) { + // #swagger.ignore = true (web-root verification file, not part of the API surface) + const enabled = await settings.isMobileAppLinksEnabled() // fail-closed on any error + const fps = fingerprints() + + // Off, or on-but-unconfigured, both 404 — serving a statement with no fingerprint + // asserts nothing and would only mislead the verifier. + if (!enabled || fps.length === 0) { + if (enabled && fps.length === 0 && !warnedNoFingerprint) { + warnedNoFingerprint = true + log.warn( + 'mobile_app_links_enabled is ON but MOBILE_APP_CERT_SHA256 is unset — assetlinks.json 404s', + ) + } + return res.status(404).json({ message: 'Not found' }) + } + + // The OS/verifier re-fetch this; it changes only on a cert rotation. + res.set('Cache-Control', 'public, max-age=3600') + return res.json([ + { + relation: ['delegate_permission/common.handle_all_urls'], + target: { + namespace: 'android_app', + package_name: PACKAGE, + sha256_cert_fingerprints: fps, + }, + }, + ]) +} + +module.exports = { assetlinks } diff --git a/server/test/appLinks.test.js b/server/test/appLinks.test.js new file mode 100644 index 0000000..c7d5bb0 --- /dev/null +++ b/server/test/appLinks.test.js @@ -0,0 +1,203 @@ +// Android App Links (M9 follow-up, docs/android/APP_LINKS.md): the web-root +// assetlinks.json verification file and the mobile-SSO redirect allowlist's +// additive https App Link entry. 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 wellKnown = require('../src/router/wellKnown.controller') +const mobileSso = require('../src/router/v1/auth/mobileSso.controller') +const ssoCtrl = require('../src/router/v1/auth/sso.controller') +const settings = require('../src/model/settings/settings.model') +const bridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +function mockRes() { + return { + statusCode: 200, + body: null, + headers: {}, + redirectedTo: null, + status(c) { this.statusCode = c; return this }, + json(b) { this.body = b; return this }, + set(k, v) { this.headers[String(k).toLowerCase()] = v; return this }, + redirect(u) { this.redirectedTo = u; return this }, + } +} + +let savedEnabled +beforeEach(() => { + savedEnabled = settings.isMobileAppLinksEnabled + delete process.env.APP_BASE_URL + delete process.env.MOBILE_APP_CERT_SHA256 + delete process.env.MOBILE_APP_PACKAGE +}) + +function restore() { + settings.isMobileAppLinksEnabled = savedEnabled +} + +// ── /.well-known/assetlinks.json ───────────────────────────────────────────── + +test('assetlinks: 404 when App Links are disabled (even if a fingerprint is set)', async () => { + settings.isMobileAppLinksEnabled = async () => false + process.env.MOBILE_APP_CERT_SHA256 = 'AB:CD:EF' + try { + const res = mockRes() + await wellKnown.assetlinks({}, res) + assert.equal(res.statusCode, 404) + } finally { + restore() + } +}) + +test('assetlinks: 404 when enabled but no fingerprint configured', async () => { + settings.isMobileAppLinksEnabled = async () => true + try { + const res = mockRes() + await wellKnown.assetlinks({}, res) + assert.equal(res.statusCode, 404) + } finally { + restore() + } +}) + +test('assetlinks: enabled + fingerprint → Digital Asset Links statement', async () => { + settings.isMobileAppLinksEnabled = async () => true + process.env.MOBILE_APP_CERT_SHA256 = 'ab:cd:ef, 11:22:33' + try { + const res = mockRes() + await wellKnown.assetlinks({}, res) + assert.equal(res.statusCode, 200) + assert.ok(Array.isArray(res.body)) + const stmt = res.body[0] + assert.deepEqual(stmt.relation, ['delegate_permission/common.handle_all_urls']) + assert.equal(stmt.target.namespace, 'android_app') + assert.equal(stmt.target.package_name, 'com.runicgateway.app') + // normalized to upper-case, comma-split into multiple fingerprints + assert.deepEqual(stmt.target.sha256_cert_fingerprints, ['AB:CD:EF', '11:22:33']) + assert.match(res.headers['cache-control'], /max-age=3600/) + } finally { + restore() + } +}) + +test('assetlinks: asserts the fixed published package name', async () => { + // PACKAGE is resolved at module load from MOBILE_APP_PACKAGE (default below); a + // white-label build sets that env at boot. Here we assert the shipped default. + settings.isMobileAppLinksEnabled = async () => true + process.env.MOBILE_APP_CERT_SHA256 = 'AB:CD' + try { + const res = mockRes() + await wellKnown.assetlinks({}, res) + assert.equal(res.statusCode, 200) + assert.equal(res.body[0].target.package_name, 'com.runicgateway.app') + } finally { + restore() + } +}) + +// ── mobile SSO redirect allowlist ──────────────────────────────────────────── + +const CUSTOM = 'runicgateway://auth/callback' + +function startReq(redirectUri) { + return { + query: { provider: 'google', code_challenge: 'chal', state: 'st', redirect_uri: redirectUri }, + ip: '1.2.3.4', + protocol: 'https', + get: () => 'play.shard.com', + } +} + +test('allowlist: https self-origin callback is accepted when App Links are enabled', async () => { + settings.isMobileAppLinksEnabled = async () => true + bridge.startSession = async () => ({ sessionId: 'sess-1' }) + const savedRedirect = ssoCtrl.redirectToIdp + ssoCtrl.redirectToIdp = async (req, res) => { res.redirect('https://idp/authorize'); return true } + try { + const res = mockRes() + await mobileSso.start(startReq('https://play.shard.com/mobile/callback'), res) + assert.equal(res.redirectedTo, 'https://idp/authorize') + assert.notEqual(res.statusCode, 400) + } finally { + ssoCtrl.redirectToIdp = savedRedirect + restore() + } +}) + +test('allowlist: the same https callback is REJECTED when App Links are disabled', async () => { + settings.isMobileAppLinksEnabled = async () => false + try { + const res = mockRes() + await mobileSso.start(startReq('https://play.shard.com/mobile/callback'), res) + assert.equal(res.statusCode, 400) + assert.equal(res.redirectedTo, null) + } finally { + restore() + } +}) + +test('allowlist: a foreign https host is rejected even when App Links are enabled', async () => { + settings.isMobileAppLinksEnabled = async () => true + try { + const res = mockRes() + await mobileSso.start(startReq('https://evil.example.com/mobile/callback'), res) + assert.equal(res.statusCode, 400) + } finally { + restore() + } +}) + +test('allowlist: wrong path on the self-origin is rejected (exact match, not prefix)', async () => { + settings.isMobileAppLinksEnabled = async () => true + try { + const res = mockRes() + await mobileSso.start(startReq('https://play.shard.com/mobile/callback/../evil'), res) + assert.equal(res.statusCode, 400) + } finally { + restore() + } +}) + +test('allowlist: the custom scheme is always accepted, App Links off', async () => { + settings.isMobileAppLinksEnabled = async () => false + bridge.startSession = async () => ({ sessionId: 'sess-1' }) + const savedRedirect = ssoCtrl.redirectToIdp + ssoCtrl.redirectToIdp = async (req, res) => { res.redirect('https://idp/authorize'); return true } + try { + const res = mockRes() + await mobileSso.start(startReq(CUSTOM), res) + assert.equal(res.redirectedTo, 'https://idp/authorize') + } finally { + ssoCtrl.redirectToIdp = savedRedirect + restore() + } +}) + +test('allowlist: APP_BASE_URL is preferred over the request Host header', async () => { + settings.isMobileAppLinksEnabled = async () => true + process.env.APP_BASE_URL = 'https://canonical.shard.com/' + bridge.startSession = async () => ({ sessionId: 'sess-1' }) + const savedRedirect = ssoCtrl.redirectToIdp + ssoCtrl.redirectToIdp = async (req, res) => { res.redirect('https://idp/authorize'); return true } + try { + // Host header says play.shard.com, but APP_BASE_URL pins the origin. + const res = mockRes() + await mobileSso.start(startReq('https://canonical.shard.com/mobile/callback'), res) + assert.equal(res.redirectedTo, 'https://idp/authorize') + // A callback matching the (spoofable) Host header but NOT APP_BASE_URL is rejected. + const res2 = mockRes() + await mobileSso.start(startReq('https://play.shard.com/mobile/callback'), res2) + assert.equal(res2.statusCode, 400) + } finally { + ssoCtrl.redirectToIdp = savedRedirect + restore() + } +})