Merge pull request 'feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist' (#81) from feat/mobile-app-links into main
Reviewed-on: #81 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -21,6 +21,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
'future news, screenshots, guides, and community notes as the world comes online.',
|
'future news, screenshots, guides, and community notes as the world comes online.',
|
||||||
contact_email: brand.contactEmail,
|
contact_email: brand.contactEmail,
|
||||||
site_title: brand.name,
|
site_title: brand.name,
|
||||||
|
// Android App Links opt-in — off until an admin enables it (docs/android/APP_LINKS.md).
|
||||||
|
mobile_app_links_enabled: 'false',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starter wiki sections (editable later via the admin panel).
|
// Starter wiki sections (editable later via the admin panel).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ require('dotenv').config()
|
|||||||
const swaggerUi = require('swagger-ui-express')
|
const swaggerUi = require('swagger-ui-express')
|
||||||
|
|
||||||
const apiRouter = require('./router/api.router')
|
const apiRouter = require('./router/api.router')
|
||||||
|
const wellKnown = require('./router/wellKnown.controller')
|
||||||
const brand = require('./config/brand')
|
const brand = require('./config/brand')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||||
@@ -143,6 +144,12 @@ app.get(
|
|||||||
app.use('/api', apiRouter)
|
app.use('/api', apiRouter)
|
||||||
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||||
|
|
||||||
|
// ── /.well-known ──────────────────────────────────────────────────────
|
||||||
|
// Android App Links verification file at the web root (M9 follow-up). Mounted
|
||||||
|
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless
|
||||||
|
// the admin has enabled App Links for this shard (docs/android/APP_LINKS.md).
|
||||||
|
app.get('/.well-known/assetlinks.json', wellKnown.assetlinks)
|
||||||
|
|
||||||
// ── Client SPA ────────────────────────────────────────────────────────
|
// ── Client SPA ────────────────────────────────────────────────────────
|
||||||
// Serve the built React app if present; otherwise show a placeholder so the
|
// Serve the built React app if present; otherwise show a placeholder so the
|
||||||
// server is usable API-only before the frontend phase.
|
// server is usable API-only before the frontend phase.
|
||||||
|
|||||||
@@ -54,6 +54,22 @@ async function isGameAccountSignupEnabled() {
|
|||||||
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
|
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android App Links opt-in (M9 follow-up). When on, the shard auto-serves
|
||||||
|
// /.well-known/assetlinks.json and the mobile SSO bridge additionally accepts the
|
||||||
|
// self-origin https://<host>/mobile/callback redirect. Stored as the string
|
||||||
|
// 'true'/'false'; default off. See docs/android/APP_LINKS.md.
|
||||||
|
const MOBILE_APP_LINKS_KEY = 'mobile_app_links_enabled'
|
||||||
|
|
||||||
|
// Fail-closed: any read error (e.g. DB unavailable) reports "disabled" so a
|
||||||
|
// transient fault can never open the https redirect path or serve assetlinks.json.
|
||||||
|
async function isMobileAppLinksEnabled() {
|
||||||
|
try {
|
||||||
|
return String(await settingsDb.get(MOBILE_APP_LINKS_KEY)) === 'true'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function get(key) {
|
async function get(key) {
|
||||||
return settingsDb.get(key)
|
return settingsDb.get(key)
|
||||||
}
|
}
|
||||||
@@ -113,6 +129,11 @@ async function getPublic() {
|
|||||||
// that we use the first NTFY_ALLOWED_ORIGINS entry (a device endpoint must sit
|
// that we use the first NTFY_ALLOWED_ORIGINS entry (a device endpoint must sit
|
||||||
// on an allowed origin anyway). Never NTFY_BASE_URL — it may be internal-only.
|
// on an allowed origin anyway). Never NTFY_BASE_URL — it may be internal-only.
|
||||||
out.push = { ntfyUrl: publicNtfyUrl() }
|
out.push = { ntfyUrl: publicNtfyUrl() }
|
||||||
|
// Whether this shard has opted into Android App Links (M9 follow-up). Lets a
|
||||||
|
// native client tell whether it may request the https App Link redirect_uri
|
||||||
|
// before doing so (the server would otherwise reject an unallowlisted one). The
|
||||||
|
// custom-scheme callback works regardless of this flag.
|
||||||
|
out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true'
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,4 +163,6 @@ module.exports = {
|
|||||||
GAME_SIGNUP_MODES,
|
GAME_SIGNUP_MODES,
|
||||||
getGameSignupMode,
|
getGameSignupMode,
|
||||||
isGameAccountSignupEnabled,
|
isGameAccountSignupEnabled,
|
||||||
|
MOBILE_APP_LINKS_KEY,
|
||||||
|
isMobileAppLinksEnabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,6 +512,15 @@ async function updateSettings(req, res) {
|
|||||||
) {
|
) {
|
||||||
return res.status(400).json({ message: 'Invalid game_account_signup value' })
|
return res.status(400).json({ message: 'Invalid game_account_signup value' })
|
||||||
}
|
}
|
||||||
|
// App Links toggle is a boolean stored as a 'true'/'false' string; accept a real
|
||||||
|
// boolean or those two strings and normalize, reject anything else.
|
||||||
|
if (settings.MOBILE_APP_LINKS_KEY in updates) {
|
||||||
|
const v = updates[settings.MOBILE_APP_LINKS_KEY]
|
||||||
|
if (v !== true && v !== false && v !== 'true' && v !== 'false') {
|
||||||
|
return res.status(400).json({ message: 'Invalid mobile_app_links_enabled value' })
|
||||||
|
}
|
||||||
|
updates[settings.MOBILE_APP_LINKS_KEY] = String(v === true || v === 'true')
|
||||||
|
}
|
||||||
// The homepage teaser is rich text (HTML) from the shared editor — sanitize it
|
// The homepage teaser is rich text (HTML) from the shared editor — sanitize it
|
||||||
// against the same allowlist as post/wiki bodies so a stored value is safe (the
|
// against the same allowlist as post/wiki bodies so a stored value is safe (the
|
||||||
// client re-sanitizes on render as defense in depth).
|
// client re-sanitizes on render as defense in depth).
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const users = require('../../../model/users/users.model')
|
|||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||||
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
|
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
|
||||||
|
const settings = require('../../../model/settings/settings.model')
|
||||||
const sessionService = require('../../../auth/session.service')
|
const sessionService = require('../../../auth/session.service')
|
||||||
const ssoState = require('../../../auth/ssoState')
|
const ssoState = require('../../../auth/ssoState')
|
||||||
const ssoController = require('./sso.controller')
|
const ssoController = require('./sso.controller')
|
||||||
@@ -37,6 +38,32 @@ const REDIRECT_ALLOWLIST = new Set(
|
|||||||
|
|
||||||
const PROVIDER_ID_RE = /^[a-z0-9-]+$/
|
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.
|
// Append query params to an (already-allowlisted) app callback URI.
|
||||||
function appDeepLink(redirectUri, params) {
|
function appDeepLink(redirectUri, params) {
|
||||||
const sep = redirectUri.includes('?') ? '&' : '?'
|
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
|
// 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
|
// 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).
|
// 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 })
|
log.warn('mobile sso start: redirect_uri not in allowlist', { ip: req.ip })
|
||||||
return res.status(400).json({ message: 'Unrecognized redirect URI.' })
|
return res.status(400).json({ message: 'Unrecognized redirect URI.' })
|
||||||
}
|
}
|
||||||
|
|||||||
63
server/src/router/wellKnown.controller.js
Normal file
63
server/src/router/wellKnown.controller.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// ── /.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 }
|
||||||
203
server/test/appLinks.test.js
Normal file
203
server/test/appLinks.test.js
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user