Files
website/server/src/router/wellKnown.controller.js
wtclaude bcc96e7cfb
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
feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist
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
2026-07-20 18:37:17 -05:00

64 lines
2.5 KiB
JavaScript

// ── /.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 }