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
This commit is contained in:
@@ -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://<host>/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.' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user