From 5ffb14560ba33b68f57f67c139922a7a9064624b Mon Sep 17 00:00:00 2001 From: BlackSigKill Date: Mon, 9 Feb 2026 19:44:35 +0100 Subject: [PATCH] add server-side global auth for private selfhosted instance --- .env.example | 11 ++ Dockerfile | 2 +- docker-compose.yml | 9 +- js/accounts/auth.js | 42 +++++- js/accounts/config.js | 3 +- js/settings.js | 31 +++-- package-lock.json | 95 ++++++++++++- package.json | 3 + public/login.html | 290 +++++++++++++++++++++++++++++++++++++++ vite-plugin-auth-gate.js | 203 +++++++++++++++++++++++++++ vite.config.js | 2 + 11 files changed, 674 insertions(+), 17 deletions(-) create mode 100644 public/login.html create mode 100644 vite-plugin-auth-gate.js diff --git a/.env.example b/.env.example index 9dddc97..e3b7c89 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,17 @@ MONOCHROME_PORT=3000 MONOCHROME_DEV_PORT=5173 +# --- Auth Gate (server-side authentication) --- +# Set AUTH_ENABLED=false to disable the auth gate entirely (no login required) +AUTH_ENABLED=true +AUTH_SECRET=change-me-to-a-random-string +FIREBASE_PROJECT_ID=monochrome-database +# Optional: override the Firebase config for the login page (JSON string) +# FIREBASE_CONFIG={"apiKey":"...","authDomain":"...","projectId":"...","storageBucket":"...","messagingSenderId":"...","appId":"..."} +# Optional: set PocketBase URL (hides the field in settings when set) +# POCKETBASE_URL=https://monodb.samidy.com +# SESSION_MAX_AGE=604800000 # 7 days in ms (default) + # --- PocketBase (only used with --profile pocketbase) --- POCKETBASE_PORT=8090 PB_ADMIN_EMAIL=admin@example.com diff --git a/Dockerfile b/Dockerfile index 2f9995d..16b7d76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app RUN apk add --no-cache wget # Copy package files first for caching -COPY package.json ./ +COPY package.json package-lock.json ./ # Install dependencies RUN npm install diff --git a/docker-compose.yml b/docker-compose.yml index 3c39b58..5da3e6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,18 @@ services: container_name: monochrome ports: - '${MONOCHROME_PORT:-3000}:4173' + environment: + AUTH_ENABLED: ${AUTH_ENABLED:-true} + AUTH_SECRET: ${AUTH_SECRET:-} + FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-monochrome-database} + FIREBASE_CONFIG: ${FIREBASE_CONFIG:-} + POCKETBASE_URL: ${POCKETBASE_URL:-} + SESSION_MAX_AGE: ${SESSION_MAX_AGE:-604800000} restart: unless-stopped networks: - monochrome-network healthcheck: - test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/'] + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/health'] interval: 30s timeout: 3s retries: 3 diff --git a/js/accounts/auth.js b/js/accounts/auth.js index 865e31d..fceffde 100644 --- a/js/accounts/auth.js +++ b/js/accounts/auth.js @@ -103,7 +103,14 @@ export class AuthManager { try { await firebaseSignOut(auth); - // The onAuthStateChanged listener will handle the rest + if (window.__AUTH_GATE__) { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } catch (_) { + // Server endpoint may not exist in dev mode + } + window.location.href = '/login'; + } } catch (error) { console.error('Logout failed:', error); throw error; @@ -119,6 +126,39 @@ export class AuthManager { if (!connectBtn) return; // UI might not be rendered yet + // Auth gate active: strip down to status + sign out only + if (window.__AUTH_GATE__) { + connectBtn.textContent = 'Sign Out'; + connectBtn.classList.add('danger'); + connectBtn.onclick = () => this.signOut(); + if (clearDataBtn) clearDataBtn.style.display = 'none'; + if (emailContainer) emailContainer.style.display = 'none'; + if (emailToggleBtn) emailToggleBtn.style.display = 'none'; + if (statusText) statusText.textContent = user ? `Signed in as ${user.email}` : 'Signed in'; + + // Account page: clean up unnecessary text + const accountPage = document.getElementById('page-account'); + if (accountPage) { + const title = accountPage.querySelector('.section-title'); + if (title) title.textContent = 'Account'; + // Hide description + privacy paragraphs, keep only status + accountPage.querySelectorAll('.account-content > p, .account-content > div').forEach((el) => { + if (el.id !== 'firebase-status' && el.id !== 'auth-buttons-container') { + el.style.display = 'none'; + } + }); + } + + // Settings page: hide custom DB/Auth config + const customDbBtn = document.getElementById('custom-db-btn'); + if (customDbBtn) { + const settingItem = customDbBtn.closest('.setting-item'); + if (settingItem) settingItem.style.display = 'none'; + } + + return; + } + if (user) { connectBtn.textContent = 'Sign Out'; connectBtn.classList.add('danger'); diff --git a/js/accounts/config.js b/js/accounts/config.js index a0b6aaa..a7956da 100644 --- a/js/accounts/config.js +++ b/js/accounts/config.js @@ -30,8 +30,9 @@ function getStoredConfig() { } // Attempt to initialize on load +// Priority: server-injected env (auth gate) > localStorage > default const storedConfig = getStoredConfig(); -const config = storedConfig || DEFAULT_CONFIG; +const config = window.__FIREBASE_CONFIG__ || storedConfig || DEFAULT_CONFIG; if (config) { try { diff --git a/js/settings.js b/js/settings.js index 210673f..4507075 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1606,19 +1606,34 @@ export function initializeSettings(scrobbler, player, api, ui) { const customDbCancelBtn = document.getElementById('custom-db-cancel'); if (customDbBtn && customDbModal) { + const fbFromEnv = !!window.__FIREBASE_CONFIG__; + const pbFromEnv = !!window.__POCKETBASE_URL__; + + // Hide entire setting if both are server-configured + if (fbFromEnv && pbFromEnv) { + const settingItem = customDbBtn.closest('.setting-item'); + if (settingItem) settingItem.style.display = 'none'; + } + + // Hide individual fields in the modal + if (pbFromEnv && customPbUrlInput) customPbUrlInput.closest('div[style]').style.display = 'none'; + if (fbFromEnv && customFirebaseConfigInput) customFirebaseConfigInput.closest('div[style]').style.display = 'none'; + customDbBtn.addEventListener('click', () => { const pbUrl = localStorage.getItem('monochrome-pocketbase-url') || ''; const fbConfig = localStorage.getItem('monochrome-firebase-config'); - customPbUrlInput.value = pbUrl; - if (fbConfig) { - try { - customFirebaseConfigInput.value = JSON.stringify(JSON.parse(fbConfig), null, 2); - } catch { - customFirebaseConfigInput.value = fbConfig; + if (!pbFromEnv) customPbUrlInput.value = pbUrl; + if (!fbFromEnv) { + if (fbConfig) { + try { + customFirebaseConfigInput.value = JSON.stringify(JSON.parse(fbConfig), null, 2); + } catch { + customFirebaseConfigInput.value = fbConfig; + } + } else { + customFirebaseConfigInput.value = ''; } - } else { - customFirebaseConfigInput.value = ''; } customDbModal.classList.add('active'); diff --git a/package-lock.json b/package-lock.json index 6e376e8..5534c1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", + "cookie-session": "^2.1.0", "dashjs": "^5.1.1", + "jose": "^6.0.11", "pocketbase": "^0.26.5" }, "devDependencies": { @@ -2467,9 +2469,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3751,6 +3753,43 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-session": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz", + "integrity": "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==", + "license": "MIT", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/core-js": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", @@ -4011,6 +4050,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -5970,6 +6018,15 @@ "node": ">=10" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6074,6 +6131,18 @@ "node": ">=0.10.0" } }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6329,7 +6398,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6448,6 +6516,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7139,7 +7216,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -8176,6 +8252,15 @@ "node": ">=8.0" } }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index d358f9d..705726f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "start": "vite preview", "lint:js": "eslint .", "lint:css": "stylelint \"**/*.css\"", "lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"", @@ -43,6 +44,8 @@ "source-map": "^0.7.4" }, "dependencies": { + "cookie-session": "^2.1.0", + "jose": "^6.0.11", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", "dashjs": "^5.1.1", diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..f318465 --- /dev/null +++ b/public/login.html @@ -0,0 +1,290 @@ + + + + + + Monochrome — Login + + + +
+ + +
+ + + +
or
+ +
+
+ +
+
+ +
+ +
+ +
+ + + + diff --git a/vite-plugin-auth-gate.js b/vite-plugin-auth-gate.js new file mode 100644 index 0000000..b17e2a5 --- /dev/null +++ b/vite-plugin-auth-gate.js @@ -0,0 +1,203 @@ +import { loadEnv } from 'vite'; +import cookieSession from 'cookie-session'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { readFileSync, existsSync } from 'fs'; +import { join, extname } from 'path'; + +function parseBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('Invalid JSON')); + } + }); + req.on('error', reject); + }); +} + +export default function authGatePlugin() { + let env = {}; + + return { + name: 'auth-gate', + + config(_, { mode }) { + env = loadEnv(mode, process.cwd(), ''); + }, + + configurePreviewServer(server) { + const AUTH_ENABLED = (env.AUTH_ENABLED ?? 'true') !== 'false'; + const FIREBASE_CONFIG = env.FIREBASE_CONFIG; + const POCKETBASE_URL = env.POCKETBASE_URL; + + // Parse Firebase config once (used for injection + auth verification) + let parsedFirebaseConfig = null; + let PROJECT_ID = env.FIREBASE_PROJECT_ID || 'monochrome-database'; + if (FIREBASE_CONFIG) { + try { + parsedFirebaseConfig = JSON.parse(FIREBASE_CONFIG); + if (parsedFirebaseConfig.projectId) PROJECT_ID = parsedFirebaseConfig.projectId; + } catch (e) { + console.error('Invalid FIREBASE_CONFIG JSON:', e.message); + process.exit(1); + } + } + + // --- Build injection script (always, for both auth gate and env config) --- + + const flags = []; + if (AUTH_ENABLED) flags.push('window.__AUTH_GATE__=true'); + if (parsedFirebaseConfig) flags.push(`window.__FIREBASE_CONFIG__=${JSON.stringify(parsedFirebaseConfig)}`); + if (POCKETBASE_URL) flags.push(`window.__POCKETBASE_URL__=${JSON.stringify(POCKETBASE_URL)}`); + const configScript = flags.length > 0 ? `` : null; + + // --- Pre-build injected HTML pages --- + + const distDir = join(process.cwd(), 'dist'); + + let indexHtml = null; + const indexPath = join(distDir, 'index.html'); + if (configScript && existsSync(indexPath)) { + indexHtml = readFileSync(indexPath, 'utf-8'); + indexHtml = indexHtml.replace('', `${configScript}\n`); + } + + let loginHtml = null; + if (AUTH_ENABLED) { + const loginPath = join(distDir, 'login.html'); + if (existsSync(loginPath)) { + loginHtml = readFileSync(loginPath, 'utf-8'); + if (configScript) loginHtml = loginHtml.replace('', `${configScript}\n`); + } + } + + // --- /health (always available) --- + + server.middlewares.use((req, res, next) => { + if (req.url.split('?')[0] === '/health') { + res.end('OK'); + return; + } + next(); + }); + + // --- Auth gate (only when enabled) --- + + if (AUTH_ENABLED) { + const AUTH_SECRET = env.AUTH_SECRET; + const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 7 * 24 * 60 * 60 * 1000; + + if (!AUTH_SECRET) { + console.error('AUTH_SECRET is required when AUTH_ENABLED=true'); + process.exit(1); + } + + console.log(`Auth gate enabled (Firebase project: ${PROJECT_ID})`); + + const JWKS = createRemoteJWKSet( + new URL( + 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com', + ), + ); + + server.middlewares.use( + cookieSession({ + name: 'mono_session', + keys: [AUTH_SECRET], + maxAge: SESSION_MAX_AGE, + httpOnly: true, + sameSite: 'lax', + }), + ); + + server.middlewares.use(async (req, res, next) => { + const url = req.url.split('?')[0]; + + if (url === '/login' || url === '/login.html') { + if (loginHtml) { + res.setHeader('Content-Type', 'text/html'); + res.end(loginHtml); + } else { + res.statusCode = 404; + res.end('Login page not found'); + } + return; + } + + if (url === '/api/auth/login' && req.method === 'POST') { + try { + const body = await parseBody(req); + if (!body.token) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Missing token' })); + return; + } + const { payload } = await jwtVerify(body.token, JWKS, { + issuer: `https://securetoken.google.com/${PROJECT_ID}`, + audience: PROJECT_ID, + }); + req.session.uid = payload.sub; + req.session.email = payload.email; + req.session.iat = Date.now(); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + } catch (err) { + console.error('Token verification failed:', err.message); + res.statusCode = 401; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Invalid token' })); + } + return; + } + + if (url === '/api/auth/logout' && req.method === 'POST') { + req.session = null; + res.setHeader('Clear-Site-Data', '"cache", "storage"'); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (!req.session || !req.session.uid) { + const ext = extname(url); + if (ext && ext !== '.html') { + res.statusCode = 401; + res.end('Unauthorized'); + } else { + res.writeHead(302, { Location: '/login' }); + res.end(); + } + return; + } + + // Authenticated: serve injected index.html for HTML routes + const ext = extname(url); + if ((!ext || ext === '.html') && indexHtml) { + res.setHeader('Content-Type', 'text/html'); + res.end(indexHtml); + return; + } + + next(); + }); + } else if (indexHtml) { + // No auth gate, but env config needs injection into HTML + server.middlewares.use((req, res, next) => { + const url = req.url.split('?')[0]; + const ext = extname(url); + if (!ext || ext === '.html') { + res.setHeader('Content-Type', 'text/html'); + res.end(indexHtml); + return; + } + next(); + }); + } + }, + }; +} diff --git a/vite.config.js b/vite.config.js index 08ef0e2..53b6e30 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; +import authGatePlugin from './vite-plugin-auth-gate.js'; export default defineConfig({ base: './', @@ -8,6 +9,7 @@ export default defineConfig({ emptyOutDir: true, }, plugins: [ + authGatePlugin(), VitePWA({ registerType: 'prompt', workbox: {