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:
+7
-3
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -734,48 +734,6 @@
|
||||
<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>
|
||||
</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 -->
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-13
@@ -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.'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user