Add a Mobile SSO Authorization Bridge so the native app can "Sign in with Google/Discord" without shipping any OAuth secret. It EXTENDS the existing /auth/sso/* redirect flow (same PKCE-vs-IdP, link-only + opt-in provisioning, TOTP gate) and terminates in the existing mobile bearer tokens — not a parallel auth path. - Schema: mobile_auth_sessions + mobile_auth_codes (short-lived, self-pruning; authorization code stored hash-only, PKCE challenge is a hash by construction). - GET /auth/mobile/sso/start: validate provider enabled + redirect_uri by EXACT allowlist match (never prefix), seed a bridge session, reuse the SSO redirect tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow). - SSO callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use, hashed, PKCE-bound code and redirect to the fixed app callback (code + echoed state, never a token) instead of setting a cookie. 2FA keeps full parity via the existing web TOTP form (now carrying the bridge session). - POST /auth/mobile/sso/exchange: verify Layer-B PKCE (before burning the code), single-use consume, then issue the SAME pair as /auth/mobile/login. - Discovery reuses GET /auth/providers; refresh/logout reuse /auth/mobile/*. - Rate limits: /start per-IP+provider, /exchange per-IP. Boot-time + opportunistic prune of both tables (no cron, mirrors revoked_sessions). - Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed runicgateway://auth/callback); App Link URIs can be appended per shard later. - Swagger regenerated; 39 tests (model single-use/gating + full controller matrix: bad/expired/reused code, PKCE mismatch, disabled provider, redirect allowlist, TOTP-through-bridge). Full suite green (271). Refs docs/website/BACKEND_DESIGN.md, docs/android/PLAN.md §9 (M9). Co-Authored-By: Claude <noreply@anthropic.com>
177 lines
7.1 KiB
JavaScript
177 lines
7.1 KiB
JavaScript
require('dotenv').config()
|
|
const http = require('http')
|
|
|
|
const app = require('./app')
|
|
const internalApp = require('./internalApp')
|
|
const botScore = require('./middleware/botScore')
|
|
const uoLinkSocket = require('./utils/uoLinkSocket')
|
|
const uoLinkClient = require('./utils/uoLinkClient')
|
|
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
|
const shardBroadcast = require('./utils/shardBroadcast')
|
|
const announceWorker = require('./utils/announceWorker')
|
|
const { ensureSchema, close } = require('./utils/db')
|
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
|
const settings = require('./model/settings/settings.model')
|
|
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
|
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
|
const createLogger = require('./utils/logger')
|
|
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
|
const brand = require('./config/brand')
|
|
const pkg = require('../package.json')
|
|
|
|
const log = createLogger('server')
|
|
const PORT = Number(process.env.PORT) || 3000
|
|
// Separate, UNPUBLISHED listener for server<->bot /internal/* traffic. Kept off
|
|
// the public PORT so the decrypted-token route can't ride the listener Pangolin
|
|
// proxies to the world (issue #33). Must match the port in the bot's
|
|
// SITE_INTERNAL_URL (docker-compose.yml).
|
|
const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
|
|
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
|
|
|
|
async function start() {
|
|
log.info(`starting ${brand.name} server v${pkg.version}`, {
|
|
node: process.version,
|
|
env: process.env.NODE_ENV || 'development',
|
|
logLevel: process.env.LOG_LEVEL || 'info',
|
|
logFile: createLogger.logFilePath || 'disabled (console only)',
|
|
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'runic_gateway'}`,
|
|
cookieSecure: process.env.COOKIE_SECURE || 'auto',
|
|
email: 'gmail-oauth2 (configured in admin → settings)',
|
|
})
|
|
|
|
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in
|
|
// production (the /internal/bot-config route hands back the decrypted Discord
|
|
// token and this key is its only guard); a warning otherwise.
|
|
const keyCheck = evaluateBotInternalKey({
|
|
key: process.env.BOT_INTERNAL_KEY,
|
|
nodeEnv: process.env.NODE_ENV,
|
|
})
|
|
if (keyCheck.fatal) {
|
|
log.error(keyCheck.message)
|
|
process.exit(1)
|
|
} else if (!keyCheck.ok) {
|
|
log.warn(keyCheck.message)
|
|
}
|
|
|
|
log.info('ensuring database schema...')
|
|
await ensureSchema()
|
|
log.info('seeding defaults...')
|
|
await seedDefaults()
|
|
await createInitialAdminFromEnv()
|
|
|
|
// Clear out session-denylist rows whose token has already expired (dead weight).
|
|
// Best-effort — a prune failure must never block startup.
|
|
try {
|
|
const pruned = await revokedSessions.pruneExpired()
|
|
if (pruned) log.info(`pruned ${pruned} expired revoked-session row(s)`)
|
|
} catch (err) {
|
|
log.warn('revoked-session prune failed', { error: err.message })
|
|
}
|
|
|
|
// Same treatment for the mobile SSO bridge tables (also pruned opportunistically
|
|
// on each bridge write). Boot-time sweep catches rows orphaned by a crash.
|
|
try {
|
|
const pruned = await mobileAuthBridge.pruneExpired()
|
|
if (pruned) log.info(`pruned ${pruned} expired mobile-auth-bridge row(s)`)
|
|
} catch (err) {
|
|
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
|
}
|
|
|
|
const mode = await settings.get('site_mode')
|
|
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
|
|
|
const server = http.createServer(app)
|
|
server.listen(PORT, HOST, () => {
|
|
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
|
|
})
|
|
|
|
// Internal server<->bot API on a separate, unpublished port. NEVER expose this
|
|
// through Pangolin/the public reverse proxy — it serves the decrypted Discord
|
|
// bot token to the bot process over the private compose network only (#33).
|
|
const internalServer = http.createServer(internalApp)
|
|
internalServer.listen(INTERNAL_PORT, HOST, () => {
|
|
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
|
|
})
|
|
|
|
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
|
|
// connects when the admin has enabled the integration and saved a token, so
|
|
// this is a no-op on shards that haven't configured the sidecar. Never let a
|
|
// sidecar problem block server startup.
|
|
try {
|
|
await uoLinkSocket.start()
|
|
await checkUoLink()
|
|
} catch (err) {
|
|
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
|
}
|
|
|
|
// Start the news-announcement dispatcher: a light in-process poller that pushes
|
|
// published news posts to the in-game town crier + Discord with independent
|
|
// retry per leg. No-op until a news post is actually published.
|
|
announceWorker.start()
|
|
|
|
setupShutdown(server, internalServer)
|
|
}
|
|
|
|
// Best-effort startup probe of the uo-link sidecar: if the integration is
|
|
// enabled, log whether it is reachable and warn loudly on a protocol mismatch
|
|
// (fail-fast visibility rather than silently mis-parsing a newer wire format).
|
|
async function checkUoLink() {
|
|
const config = await uoLinkConfig.getSafe()
|
|
if (!config.enabled) return
|
|
const health = await uoLinkClient.health()
|
|
if (!health.ok) {
|
|
log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
|
|
baseUrl: config.baseUrl,
|
|
error: health.error || `status ${health.status}`,
|
|
})
|
|
return
|
|
}
|
|
if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
|
|
log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
|
|
pinned: config.protocol,
|
|
sidecar: health.data.protocol,
|
|
})
|
|
} else {
|
|
log.info('uo-link sidecar reachable', {
|
|
pluginConnected: health.data && health.data.plugin_connected,
|
|
protocol: health.data && health.data.protocol,
|
|
})
|
|
}
|
|
}
|
|
|
|
function setupShutdown(server, internalServer) {
|
|
let closing = false
|
|
const shutdown = async (signal) => {
|
|
if (closing) return
|
|
closing = true
|
|
log.warn(`${signal} received — shutting down gracefully`)
|
|
botScore.stopSweeper() // stop the bot-store cleanup interval
|
|
announceWorker.stop() // stop the news-announcement dispatcher poller
|
|
uoLinkSocket.stop() // close the uo-link WS ingest client
|
|
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
|
server.close(() => log.info('http server closed'))
|
|
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
|
try {
|
|
await close()
|
|
log.info('database pool closed')
|
|
} catch (err) {
|
|
log.error('error closing database pool', err)
|
|
}
|
|
await createLogger.close() // flush the log file
|
|
process.exit(0)
|
|
}
|
|
|
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
|
process.on('uncaughtException', (err) => {
|
|
log.error('uncaughtException', err)
|
|
process.exit(1)
|
|
})
|
|
}
|
|
|
|
start().catch((err) => {
|
|
log.error('failed to start server', err)
|
|
process.exit(1)
|
|
})
|