// 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() } })