feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m23s
PR Checks / client-build (pull_request) Successful in 10m6s
PR Checks / bot-install (pull_request) Successful in 9m16s

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
This commit is contained in:
2026-07-20 18:37:17 -05:00
parent d37c3a46a9
commit bcc96e7cfb
7 changed files with 335 additions and 1 deletions

View File

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