From da5d4364327833a584de532db71b907b024facbb Mon Sep 17 00:00:00 2001 From: Steve Dogiakos Date: Thu, 9 Apr 2026 16:34:14 -0600 Subject: [PATCH] feat: move OIDC settings to env vars and add debug logging OIDC configuration now comes from environment variables instead of the database settings table. This is more natural for Docker/compose deployments where secrets live in .env files. Env vars: OIDC_ENABLED, OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_BUTTON_LABEL. Also adds detailed [oidc] console logging throughout the authorize, callback, and link flows to aid debugging connection issues. Removes the OIDC settings UI section from the admin modal and the GET/PUT /api/settings/oidc endpoints. --- .env.example | 10 ++++++--- docker-compose.yml | 7 +++++++ public/index.html | 42 ------------------------------------- public/js/app.js | 44 --------------------------------------- src/routes/auth.js | 47 ++++++++++++++++++++++++++++++------------ src/routes/settings.js | 31 ---------------------------- 6 files changed, 48 insertions(+), 133 deletions(-) diff --git a/.env.example b/.env.example index 4c2a071..4afb6f3 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days) PORT=3000 DB_PATH=/app/data/check-printing.db -# OIDC settings are configured in the admin UI (Manage Users > Single Sign-On). -# No environment variables needed — discovery URL, client ID/secret, and -# redirect URI are stored in the database settings table. +# OIDC / SSO (optional — omit or leave blank to disable) +OIDC_ENABLED=false +OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=https://checks.example.com/api/auth/oidc/callback +OIDC_BUTTON_LABEL=Sign in with SSO diff --git a/docker-compose.yml b/docker-compose.yml index 40cb776..9e4ad2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,13 @@ services: - DB_PATH=/app/data/check-printing.db # Required in production — generate with: openssl rand -hex 32 - SESSION_SECRET=${SESSION_SECRET} + # OIDC / SSO (optional — omit or leave blank to disable) + - OIDC_ENABLED=${OIDC_ENABLED:-} + - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-} + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} + - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-} + - OIDC_BUTTON_LABEL=${OIDC_BUTTON_LABEL:-Sign in with SSO} volumes: check-printing-data: diff --git a/public/index.html b/public/index.html index 91fbd16..e2600c8 100644 --- a/public/index.html +++ b/public/index.html @@ -734,48 +734,6 @@ - -
-

Single Sign-On (OIDC)

-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - - -

Change My Password

diff --git a/public/js/app.js b/public/js/app.js index 3e92e15..9135bd1 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -201,12 +201,10 @@ function openUsersModal() { document.getElementById('users-list').hidden = !isAdmin; document.getElementById('user-form-section').hidden = !isAdmin; document.getElementById('smtp-settings-section').hidden = !isAdmin; - document.getElementById('oidc-settings-section').hidden = !isAdmin; if (isAdmin) { loadUsers(); renderUfAccountCheckboxes(); loadSmtpSettings(); - loadOidcSettings(); } loadOidcLinkStatus(); } @@ -1659,47 +1657,6 @@ async function saveSmtpSettings() { } } -// ── OIDC settings ──────────────────────────────────────────────────────────── - -async function loadOidcSettings() { - try { - const s = await apiFetch('GET', '/api/settings/oidc'); - if (!s) return; - document.getElementById('oidc-enabled').value = s.enabled ? '1' : '0'; - document.getElementById('oidc-discovery-url').value = s.discovery_url; - document.getElementById('oidc-client-id').value = s.client_id; - document.getElementById('oidc-redirect-uri').value = s.redirect_uri; - document.getElementById('oidc-button-label').value = s.button_label; - document.getElementById('oidc-secret-hint').textContent = s.has_secret ? '(leave blank to keep)' : ''; - } catch (_) {} -} - -async function saveOidcSettings() { - const errEl = document.getElementById('oidc-error'); - const successEl = document.getElementById('oidc-success'); - const btn = document.getElementById('btn-save-oidc'); - errEl.hidden = true; successEl.hidden = true; - btn.disabled = true; - try { - await apiFetch('PUT', '/api/settings/oidc', { - enabled: document.getElementById('oidc-enabled').value === '1', - discovery_url: document.getElementById('oidc-discovery-url').value.trim(), - client_id: document.getElementById('oidc-client-id').value.trim(), - client_secret: document.getElementById('oidc-client-secret').value, - redirect_uri: document.getElementById('oidc-redirect-uri').value.trim(), - button_label: document.getElementById('oidc-button-label').value.trim(), - }); - successEl.textContent = 'Saved.'; successEl.hidden = false; - document.getElementById('oidc-client-secret').value = ''; - await loadOidcSettings(); - setTimeout(() => { successEl.hidden = true; }, 3000); - } catch (err) { - errEl.textContent = err.message; errEl.hidden = false; - } finally { - btn.disabled = false; - } -} - // ── OIDC self-service linking ──────────────────────────────────────────────── async function loadOidcLinkStatus() { @@ -1943,7 +1900,6 @@ async function init() { document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes); document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword); document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings); - document.getElementById('btn-save-oidc').addEventListener('click', saveOidcSettings); document.getElementById('btn-oidc-unlink').addEventListener('click', unlinkOidc); // Add checking account diff --git a/src/routes/auth.js b/src/routes/auth.js index 9f5f83d..4211abf 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -209,27 +209,29 @@ router.post('/reset-password', async (req, res) => { // ── OIDC helpers ───────────────────────────────────────────────────────────── function getOidcSettings() { - const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'oidc_%'").all(); - const s = Object.fromEntries(rows.map(r => [r.key.replace('oidc_', ''), r.value || ''])); return { - enabled: s.enabled === '1', - discovery_url: s.discovery_url || '', - client_id: s.client_id || '', - client_secret: s.client_secret || '', - redirect_uri: s.redirect_uri || '', - button_label: s.button_label || 'Sign in with SSO', + enabled: process.env.OIDC_ENABLED === '1' || process.env.OIDC_ENABLED === 'true', + discovery_url: process.env.OIDC_DISCOVERY_URL || '', + client_id: process.env.OIDC_CLIENT_ID || '', + client_secret: process.env.OIDC_CLIENT_SECRET || '', + redirect_uri: process.env.OIDC_REDIRECT_URI || '', + button_label: process.env.OIDC_BUTTON_LABEL || 'Sign in with SSO', }; } async function getOidcClient(settings) { const { Issuer } = require('openid-client'); + console.log('[oidc] discovering issuer from:', settings.discovery_url); const issuer = await Issuer.discover(settings.discovery_url); - return new issuer.Client({ + console.log('[oidc] discovered issuer:', issuer.issuer); + const client = new issuer.Client({ client_id: settings.client_id, client_secret: settings.client_secret, redirect_uris: [settings.redirect_uri], response_types: ['code'], }); + console.log('[oidc] client created, redirect_uri:', settings.redirect_uri); + return client; } // GET /api/auth/oidc/config — public, returns whether OIDC is enabled + button label @@ -242,6 +244,8 @@ router.get('/oidc/config', (req, res) => { router.get('/oidc/authorize', async (req, res) => { try { const settings = getOidcSettings(); + console.log('[oidc] authorize: enabled=%s, discovery_url=%s, client_id=%s, redirect_uri=%s', + settings.enabled, settings.discovery_url, settings.client_id, settings.redirect_uri); if (!settings.enabled) return res.status(400).json({ error: 'OIDC is not enabled.' }); const { generators } = require('openid-client'); @@ -262,10 +266,11 @@ router.get('/oidc/authorize', async (req, res) => { code_challenge_method: 'S256', }); + console.log('[oidc] authorize: redirecting to:', authUrl.substring(0, 200) + '...'); // Ensure session is persisted before redirecting (saveUninitialized is false) req.session.save(() => res.redirect(authUrl)); } catch (err) { - console.error('[oidc] authorize error:', err.message); + console.error('[oidc] authorize error:', err.message, err.stack); res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO login.')); } }); @@ -273,14 +278,21 @@ router.get('/oidc/authorize', async (req, res) => { // GET /api/auth/oidc/callback — handles the provider redirect router.get('/oidc/callback', async (req, res) => { try { + console.log('[oidc] callback: query params:', JSON.stringify(req.query)); const settings = getOidcSettings(); if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.')); const oidcSession = req.session.oidc; - if (!oidcSession) return res.redirect('/#oidc-error=' + encodeURIComponent('Session expired. Please try again.')); + if (!oidcSession) { + console.error('[oidc] callback: no oidc session data found — session may have expired or cookie lost'); + return res.redirect('/#oidc-error=' + encodeURIComponent('Session expired. Please try again.')); + } + console.log('[oidc] callback: session has oidc data, linking=%s, linkUserId=%s', + !!oidcSession.linking, oidcSession.linkUserId || 'n/a'); const client = await getOidcClient(settings); const params = client.callbackParams(req); + console.log('[oidc] callback: exchanging code for tokens...'); const tokenSet = await client.callback(settings.redirect_uri, params, { code_verifier: oidcSession.code_verifier, @@ -291,20 +303,25 @@ router.get('/oidc/callback', async (req, res) => { const claims = tokenSet.claims(); const sub = claims.sub; const issuer = claims.iss; + console.log('[oidc] callback: token exchange OK, sub=%s, iss=%s, email=%s, name=%s', + sub, issuer, claims.email || 'n/a', claims.name || 'n/a'); delete req.session.oidc; // Self-service linking flow if (oidcSession.linking && oidcSession.linkUserId) { + console.log('[oidc] callback: linking flow for userId=%s', oidcSession.linkUserId); const existing = db.prepare( 'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?' ).get(issuer, sub, oidcSession.linkUserId); if (existing) { + console.warn('[oidc] callback: identity already linked to userId=%s', existing.id); return res.redirect('/#oidc-error=' + encodeURIComponent('This identity is already linked to another account.')); } db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?") .run(sub, issuer, oidcSession.linkUserId); + console.log('[oidc] callback: linked sub=%s to userId=%s', sub, oidcSession.linkUserId); return res.redirect('/#oidc-linked'); } @@ -314,11 +331,13 @@ router.get('/oidc/callback', async (req, res) => { ).get(issuer, sub); if (!user) { + console.warn('[oidc] callback: no user found for iss=%s sub=%s — not linked', issuer, sub); return res.redirect('/#oidc-error=' + encodeURIComponent( 'No account is linked to this identity. Ask an admin to link your account, or sign in with your password and link it yourself.' )); } + console.log('[oidc] callback: login success, userId=%s, username=%s, role=%s', user.id, user.username, user.role); req.session.userId = user.id; req.session.username = user.username; req.session.role = user.role; @@ -331,7 +350,7 @@ router.get('/oidc/callback', async (req, res) => { res.redirect('/'); } catch (err) { - console.error('[oidc] callback error:', err.message); + console.error('[oidc] callback error:', err.message, err.stack); res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.')); } }); @@ -343,6 +362,7 @@ router.get('/oidc/link', async (req, res) => { } try { + console.log('[oidc] link: userId=%s initiating linking flow', req.session.userId); const settings = getOidcSettings(); if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.')); @@ -364,9 +384,10 @@ router.get('/oidc/link', async (req, res) => { code_challenge_method: 'S256', }); + console.log('[oidc] link: redirecting to provider'); req.session.save(() => res.redirect(authUrl)); } catch (err) { - console.error('[oidc] link error:', err.message); + console.error('[oidc] link error:', err.message, err.stack); res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.')); } }); diff --git a/src/routes/settings.js b/src/routes/settings.js index a244035..d220c62 100644 --- a/src/routes/settings.js +++ b/src/routes/settings.js @@ -36,35 +36,4 @@ router.put('/smtp', (req, res) => { res.json({ ok: true }); }); -// GET /api/settings/oidc -router.get('/oidc', (req, res) => { - const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'oidc_%'").all(); - const s = Object.fromEntries(rows.map(r => [r.key.replace('oidc_', ''), r.value || ''])); - res.json({ - enabled: s.enabled === '1', - discovery_url: s.discovery_url || '', - client_id: s.client_id || '', - redirect_uri: s.redirect_uri || '', - button_label: s.button_label || 'Sign in with SSO', - has_secret: !!(rows.find(r => r.key === 'oidc_client_secret') || {}).value, - }); -}); - -// PUT /api/settings/oidc -router.put('/oidc', (req, res) => { - const { enabled, discovery_url, client_id, client_secret, redirect_uri, button_label } = req.body; - const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); - db.transaction(() => { - upsert.run('oidc_enabled', enabled ? '1' : '0'); - upsert.run('oidc_discovery_url', discovery_url || ''); - upsert.run('oidc_client_id', client_id || ''); - upsert.run('oidc_redirect_uri', redirect_uri || ''); - upsert.run('oidc_button_label', button_label || 'Sign in with SSO'); - if (client_secret !== undefined && client_secret !== '') { - upsert.run('oidc_client_secret', client_secret); - } - })(); - res.json({ ok: true }); -}); - module.exports = router;