Add the server side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md): - GET /.well-known/assetlinks.json at the web root, gated by the new admin setting `mobile_app_links_enabled` (default off -> 404; on-but-no-fingerprint -> 404). Emits the Digital Asset Links statement for the fixed published package (MOBILE_APP_PACKAGE) + MOBILE_APP_CERT_SHA256 fingerprint(s). - mobileSso `/start` additionally accepts this shard's own self-origin https://<host>/mobile/callback when App Links are enabled — one additive exact-match entry, derived from APP_BASE_URL/request origin, never client input; the custom-scheme allowlist is never narrowed. The settings lookup is short-circuited for non-https redirects so custom-scheme rejections stay fast. - settings.isMobileAppLinksEnabled() (fail-closed) + getPublic().mobileAppLinks; admin updateSettings validates the boolean; seed default off. Tests: test/appLinks.test.js (route gating + allowlist). Full suite 284 pass. Swagger unchanged (web-root verification file is #swagger.ignore'd). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
204 lines
7.4 KiB
JavaScript
204 lines
7.4 KiB
JavaScript
// 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()
|
|
}
|
|
})
|