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.
This commit is contained in:
2026-04-09 16:34:14 -06:00
parent dff5fd4156
commit da5d436432
6 changed files with 48 additions and 133 deletions
+7 -3
View File
@@ -6,6 +6,10 @@ SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days)
PORT=3000 PORT=3000
DB_PATH=/app/data/check-printing.db DB_PATH=/app/data/check-printing.db
# OIDC settings are configured in the admin UI (Manage Users > Single Sign-On). # OIDC / SSO (optional — omit or leave blank to disable)
# No environment variables needed — discovery URL, client ID/secret, and OIDC_ENABLED=false
# redirect URI are stored in the database settings table. 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
+7
View File
@@ -14,6 +14,13 @@ services:
- DB_PATH=/app/data/check-printing.db - DB_PATH=/app/data/check-printing.db
# Required in production — generate with: openssl rand -hex 32 # Required in production — generate with: openssl rand -hex 32
- SESSION_SECRET=${SESSION_SECRET} - 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: volumes:
check-printing-data: check-printing-data:
-42
View File
@@ -734,48 +734,6 @@
<div id="smtp-success" class="import-result" hidden></div> <div id="smtp-success" class="import-result" hidden></div>
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:8px">Save Email Settings</button> <button id="btn-save-smtp" class="btn-secondary" style="margin-top:8px">Save Email Settings</button>
</div> </div>
<!-- OIDC settings (admin only) -->
<div id="oidc-settings-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Single Sign-On (OIDC)</h3>
<div class="form-row">
<div class="form-group" style="max-width:100px">
<label for="oidc-enabled">Enabled</label>
<select id="oidc-enabled">
<option value="0">No</option>
<option value="1">Yes</option>
</select>
</div>
<div class="form-group">
<label for="oidc-button-label">Button Label</label>
<input type="text" id="oidc-button-label" placeholder="Sign in with SSO">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="oidc-discovery-url">Discovery URL</label>
<input type="url" id="oidc-discovery-url" placeholder="https://auth.example.com/.well-known/openid-configuration">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="oidc-client-id">Client ID</label>
<input type="text" id="oidc-client-id" autocomplete="off">
</div>
<div class="form-group">
<label for="oidc-client-secret">Client Secret <span class="field-hint" id="oidc-secret-hint"></span></label>
<input type="password" id="oidc-client-secret" autocomplete="new-password">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="oidc-redirect-uri">Redirect URI <span class="field-hint">(full external callback URL)</span></label>
<input type="url" id="oidc-redirect-uri" placeholder="https://checks.example.com/api/auth/oidc/callback">
</div>
</div>
<div id="oidc-error" class="wizard-error" hidden></div>
<div id="oidc-success" class="import-result" hidden></div>
<button id="btn-save-oidc" class="btn-secondary" style="margin-top:8px">Save OIDC Settings</button>
</div>
<!-- Change own password --> <!-- Change own password -->
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px"> <div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Change My Password</h3> <h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Change My Password</h3>
-44
View File
@@ -201,12 +201,10 @@ function openUsersModal() {
document.getElementById('users-list').hidden = !isAdmin; document.getElementById('users-list').hidden = !isAdmin;
document.getElementById('user-form-section').hidden = !isAdmin; document.getElementById('user-form-section').hidden = !isAdmin;
document.getElementById('smtp-settings-section').hidden = !isAdmin; document.getElementById('smtp-settings-section').hidden = !isAdmin;
document.getElementById('oidc-settings-section').hidden = !isAdmin;
if (isAdmin) { if (isAdmin) {
loadUsers(); loadUsers();
renderUfAccountCheckboxes(); renderUfAccountCheckboxes();
loadSmtpSettings(); loadSmtpSettings();
loadOidcSettings();
} }
loadOidcLinkStatus(); 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 ──────────────────────────────────────────────── // ── OIDC self-service linking ────────────────────────────────────────────────
async function loadOidcLinkStatus() { async function loadOidcLinkStatus() {
@@ -1943,7 +1900,6 @@ async function init() {
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes); document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword); document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings); document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings);
document.getElementById('btn-save-oidc').addEventListener('click', saveOidcSettings);
document.getElementById('btn-oidc-unlink').addEventListener('click', unlinkOidc); document.getElementById('btn-oidc-unlink').addEventListener('click', unlinkOidc);
// Add checking account // Add checking account
+34 -13
View File
@@ -209,27 +209,29 @@ router.post('/reset-password', async (req, res) => {
// ── OIDC helpers ───────────────────────────────────────────────────────────── // ── OIDC helpers ─────────────────────────────────────────────────────────────
function getOidcSettings() { 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 { return {
enabled: s.enabled === '1', enabled: process.env.OIDC_ENABLED === '1' || process.env.OIDC_ENABLED === 'true',
discovery_url: s.discovery_url || '', discovery_url: process.env.OIDC_DISCOVERY_URL || '',
client_id: s.client_id || '', client_id: process.env.OIDC_CLIENT_ID || '',
client_secret: s.client_secret || '', client_secret: process.env.OIDC_CLIENT_SECRET || '',
redirect_uri: s.redirect_uri || '', redirect_uri: process.env.OIDC_REDIRECT_URI || '',
button_label: s.button_label || 'Sign in with SSO', button_label: process.env.OIDC_BUTTON_LABEL || 'Sign in with SSO',
}; };
} }
async function getOidcClient(settings) { async function getOidcClient(settings) {
const { Issuer } = require('openid-client'); const { Issuer } = require('openid-client');
console.log('[oidc] discovering issuer from:', settings.discovery_url);
const issuer = await Issuer.discover(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_id: settings.client_id,
client_secret: settings.client_secret, client_secret: settings.client_secret,
redirect_uris: [settings.redirect_uri], redirect_uris: [settings.redirect_uri],
response_types: ['code'], 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 // 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) => { router.get('/oidc/authorize', async (req, res) => {
try { try {
const settings = getOidcSettings(); 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.' }); if (!settings.enabled) return res.status(400).json({ error: 'OIDC is not enabled.' });
const { generators } = require('openid-client'); const { generators } = require('openid-client');
@@ -262,10 +266,11 @@ router.get('/oidc/authorize', async (req, res) => {
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
console.log('[oidc] authorize: redirecting to:', authUrl.substring(0, 200) + '...');
// Ensure session is persisted before redirecting (saveUninitialized is false) // Ensure session is persisted before redirecting (saveUninitialized is false)
req.session.save(() => res.redirect(authUrl)); req.session.save(() => res.redirect(authUrl));
} catch (err) { } 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.')); 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 // GET /api/auth/oidc/callback — handles the provider redirect
router.get('/oidc/callback', async (req, res) => { router.get('/oidc/callback', async (req, res) => {
try { try {
console.log('[oidc] callback: query params:', JSON.stringify(req.query));
const settings = getOidcSettings(); const settings = getOidcSettings();
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.')); if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
const oidcSession = req.session.oidc; 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 client = await getOidcClient(settings);
const params = client.callbackParams(req); const params = client.callbackParams(req);
console.log('[oidc] callback: exchanging code for tokens...');
const tokenSet = await client.callback(settings.redirect_uri, params, { const tokenSet = await client.callback(settings.redirect_uri, params, {
code_verifier: oidcSession.code_verifier, code_verifier: oidcSession.code_verifier,
@@ -291,20 +303,25 @@ router.get('/oidc/callback', async (req, res) => {
const claims = tokenSet.claims(); const claims = tokenSet.claims();
const sub = claims.sub; const sub = claims.sub;
const issuer = claims.iss; 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; delete req.session.oidc;
// Self-service linking flow // Self-service linking flow
if (oidcSession.linking && oidcSession.linkUserId) { if (oidcSession.linking && oidcSession.linkUserId) {
console.log('[oidc] callback: linking flow for userId=%s', oidcSession.linkUserId);
const existing = db.prepare( const existing = db.prepare(
'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?' 'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
).get(issuer, sub, oidcSession.linkUserId); ).get(issuer, sub, oidcSession.linkUserId);
if (existing) { 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.')); 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 = ?") db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
.run(sub, issuer, oidcSession.linkUserId); .run(sub, issuer, oidcSession.linkUserId);
console.log('[oidc] callback: linked sub=%s to userId=%s', sub, oidcSession.linkUserId);
return res.redirect('/#oidc-linked'); return res.redirect('/#oidc-linked');
} }
@@ -314,11 +331,13 @@ router.get('/oidc/callback', async (req, res) => {
).get(issuer, sub); ).get(issuer, sub);
if (!user) { if (!user) {
console.warn('[oidc] callback: no user found for iss=%s sub=%s — not linked', issuer, sub);
return res.redirect('/#oidc-error=' + encodeURIComponent( 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.' '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.userId = user.id;
req.session.username = user.username; req.session.username = user.username;
req.session.role = user.role; req.session.role = user.role;
@@ -331,7 +350,7 @@ router.get('/oidc/callback', async (req, res) => {
res.redirect('/'); res.redirect('/');
} catch (err) { } 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.')); res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.'));
} }
}); });
@@ -343,6 +362,7 @@ router.get('/oidc/link', async (req, res) => {
} }
try { try {
console.log('[oidc] link: userId=%s initiating linking flow', req.session.userId);
const settings = getOidcSettings(); const settings = getOidcSettings();
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.')); 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', code_challenge_method: 'S256',
}); });
console.log('[oidc] link: redirecting to provider');
req.session.save(() => res.redirect(authUrl)); req.session.save(() => res.redirect(authUrl));
} catch (err) { } 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.')); res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.'));
} }
}); });
-31
View File
@@ -36,35 +36,4 @@ router.put('/smtp', (req, res) => {
res.json({ ok: true }); 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; module.exports = router;