#!/usr/bin/env node 'use strict'; /** * OIDC / auth-method smoke tests. * * Tests logic that does NOT require a live authentik instance: * - PKCE generation * - State sanitization * - Role/group mapping * - OIDC config validation * - Admin auth-mode lockout logic * * Run: node scripts/test-oidc-smoke.js */ let passed = 0; let failed = 0; const tests = []; function test(name, fn) { tests.push({ name, fn }); } async function runTest({ name, fn }) { try { await fn(); console.log(` ✓ ${name}`); passed++; } catch (err) { console.error(` ✗ ${name}`); console.error(` ${err.message}`); failed++; } } function assert(condition, msg) { if (!condition) throw new Error(msg || 'Assertion failed'); } function assertEqual(a, b, msg) { if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); } const fs = require('fs'); const os = require('os'); const path = require('path'); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bill-tracker-oidc-')); process.env.DB_PATH = path.join(tempDir, 'test.sqlite'); const { getDb, getSetting, setSetting, closeDb } = require('../db/database'); const oidcService = require('../services/oidcService'); function clearOidcEnv() { for (const key of [ 'OIDC_ENABLED', 'OIDC_ISSUER_URL', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI', 'OIDC_SCOPES', 'OIDC_ADMIN_GROUP', 'OIDC_DEFAULT_ROLE', 'OIDC_AUTO_PROVISION', 'OIDC_PROVIDER_NAME', ]) { delete process.env[key]; } } function resetOidcSettings() { setSetting('oidc_login_enabled', 'false'); setSetting('oidc_provider_name', 'authentik'); setSetting('oidc_issuer_url', ''); setSetting('oidc_client_id', ''); setSetting('oidc_client_secret', ''); setSetting('oidc_token_auth_method', 'client_secret_basic'); setSetting('oidc_redirect_uri', ''); setSetting('oidc_scopes', 'openid email profile groups'); setSetting('oidc_admin_group', ''); setSetting('oidc_default_role', 'user'); setSetting('oidc_auto_provision', 'true'); } // ── PKCE helpers ────────────────────────────────────────────────────────────── const crypto = require('crypto'); function generateCodeVerifier() { return crypto.randomBytes(32).toString('base64url'); } function generateCodeChallenge(v) { return crypto.createHash('sha256').update(v).digest('base64url'); } console.log('\nPKCE helpers'); test('code verifier is 43+ base64url chars', () => { const v = generateCodeVerifier(); assert(v.length >= 43, `too short: ${v.length}`); assert(/^[A-Za-z0-9_-]+$/.test(v), 'invalid chars'); }); test('code challenge is SHA-256 of verifier (base64url)', () => { const v = generateCodeVerifier(); const c = generateCodeChallenge(v); assert(c.length > 0, 'empty challenge'); assert(/^[A-Za-z0-9_-]+$/.test(c), 'invalid chars'); }); test('different verifiers produce different challenges', () => { const c1 = generateCodeChallenge(generateCodeVerifier()); const c2 = generateCodeChallenge(generateCodeVerifier()); assert(c1 !== c2, 'challenges should differ'); }); // ── sanitizeRedirectTo ──────────────────────────────────────────────────────── const { sanitizeRedirectTo } = oidcService; console.log('\nsanitizeRedirectTo'); test('allows simple paths', () => assertEqual(sanitizeRedirectTo('/dashboard'), '/dashboard')); test('allows paths with query', () => assert(sanitizeRedirectTo('/a?b=c').startsWith('/a'), 'should allow')); test('rejects protocol-relative //evil.com', () => assertEqual(sanitizeRedirectTo('//evil.com'), null)); test('rejects http:// external URL', () => assertEqual(sanitizeRedirectTo('http://evil.com'), null)); test('rejects javascript: URI', () => assertEqual(sanitizeRedirectTo('javascript:alert(1)'), null)); test('rejects null', () => assertEqual(sanitizeRedirectTo(null), null)); test('rejects empty string', () => assertEqual(sanitizeRedirectTo(''), null)); test('rejects non-string', () => assertEqual(sanitizeRedirectTo(42), null)); test('truncates at 200 chars', () => { const long = '/' + 'a'.repeat(300); const result = sanitizeRedirectTo(long); assert(result !== null && result.length <= 200, `expected ≤200, got ${result?.length}`); }); // ── getOidcConfig ───────────────────────────────────────────────────────────── const { getOidcConfig } = oidcService; console.log('\ngetOidcConfig'); test('returns null when OIDC_ENABLED is not set', () => { clearOidcEnv(); resetOidcSettings(); const cfg = getOidcConfig(); assertEqual(cfg, null); }); test('returns null when required DB settings and env fallback are blank', () => { clearOidcEnv(); resetOidcSettings(); const cfg = getOidcConfig(); assertEqual(cfg, null); }); test('DB settings override env fallback', () => { clearOidcEnv(); resetOidcSettings(); process.env.OIDC_ISSUER_URL = 'https://env.example/application/o/bills/'; process.env.OIDC_CLIENT_ID = 'env-client'; process.env.OIDC_CLIENT_SECRET = 'env-secret'; process.env.OIDC_REDIRECT_URI = 'https://env.example/callback'; setSetting('oidc_issuer_url', 'https://db.example/application/o/bills/'); setSetting('oidc_client_id', 'db-client'); setSetting('oidc_client_secret', 'db-secret'); setSetting('oidc_redirect_uri', 'https://db.example/callback'); const cfg = getOidcConfig(); assertEqual(cfg.issuerUrl, 'https://db.example/application/o/bills/'); assertEqual(cfg.clientId, 'db-client'); assertEqual(cfg.clientSecret, 'db-secret'); assertEqual(cfg.tokenEndpointAuthMethod, 'client_secret_basic'); assertEqual(cfg.redirectUri, 'https://db.example/callback'); }); test('token endpoint auth method can be configured as client_secret_basic', () => { clearOidcEnv(); resetOidcSettings(); setSetting('oidc_issuer_url', 'https://db.example/application/o/bills/'); setSetting('oidc_client_id', 'db-client'); setSetting('oidc_client_secret', 'db-secret'); setSetting('oidc_redirect_uri', 'https://db.example/callback'); setSetting('oidc_token_auth_method', 'client_secret_basic'); const cfg = getOidcConfig(); assertEqual(cfg.tokenEndpointAuthMethod, 'client_secret_basic'); }); test('env fallback works when DB settings are blank', () => { clearOidcEnv(); resetOidcSettings(); process.env.OIDC_ISSUER_URL = 'https://env.example/application/o/bills/'; process.env.OIDC_CLIENT_ID = 'env-client'; process.env.OIDC_CLIENT_SECRET = 'env-secret'; process.env.OIDC_REDIRECT_URI = 'https://env.example/callback'; const cfg = getOidcConfig(); assertEqual(cfg.issuerUrl, 'https://env.example/application/o/bills/'); assertEqual(cfg.clientId, 'env-client'); assertEqual(cfg.clientSecret, 'env-secret'); }); test('does not return client_secret in normal usage check (secret stays server-side)', () => { // Config object has clientSecret but it's for server-side use — getPublicOidcInfo strips it const { getPublicOidcInfo } = oidcService; const info = getPublicOidcInfo(); assert(!('clientSecret' in info), 'clientSecret must not appear in public info'); assert(!('client_secret' in info), 'client_secret must not appear in public info'); }); test('Admin OIDC settings never return the client secret value', () => { clearOidcEnv(); resetOidcSettings(); setSetting('oidc_client_secret', 'stored-secret'); const adminSettings = oidcService.getAdminOidcSettings(); assertEqual(adminSettings.oidc_client_secret_set, true); assert(!('oidc_client_secret' in adminSettings), 'admin settings must not include raw secret'); assert(!Object.values(adminSettings).includes('stored-secret'), 'raw secret leaked in admin settings'); }); test('OIDC configured is true only when required fields exist', () => { clearOidcEnv(); resetOidcSettings(); assertEqual(oidcService.getOidcConfigStatus().oidc_configured, false); setSetting('oidc_issuer_url', 'https://db.example/application/o/bills/'); setSetting('oidc_client_id', 'db-client'); setSetting('oidc_client_secret', 'db-secret'); assertEqual(oidcService.getOidcConfigStatus().oidc_configured, false); setSetting('oidc_redirect_uri', 'https://app.example/api/auth/oidc/callback'); assertEqual(oidcService.getOidcConfigStatus().oidc_configured, true); }); test('OIDC login unavailable when enabled but config incomplete', () => { clearOidcEnv(); resetOidcSettings(); setSetting('oidc_login_enabled', 'true'); setSetting('oidc_issuer_url', 'https://db.example/application/o/bills/'); assertEqual(oidcService.isOidcLoginActive(), false); const publicInfo = oidcService.getPublicOidcInfo(); assertEqual(publicInfo.oidc_enabled, false); assert(!('oidc_login_url' in publicInfo), 'login URL must be hidden while incomplete'); }); // ── Role mapping ────────────────────────────────────────────────────────────── const { mapRoleFromClaims } = oidcService; // Override getSetting to avoid DB dependency in tests // mapRoleFromClaims reads from getSetting internally; we test with no admin group fallback console.log('\nmapRoleFromClaims'); const noGroupConfig = { adminGroup: null, defaultRole: 'user' }; const adminGroupConfig = { adminGroup: 'bt-admins', defaultRole: 'user' }; test('defaults to user when no admin group configured', () => { assertEqual(mapRoleFromClaims({ groups: ['bt-admins', 'everyone'] }, noGroupConfig), 'user'); }); test('defaults to user when groups claim is absent', () => { assertEqual(mapRoleFromClaims({}, adminGroupConfig), 'user'); }); test('defaults to user when groups array is empty', () => { assertEqual(mapRoleFromClaims({ groups: [] }, adminGroupConfig), 'user'); }); test('grants admin when user is in admin group', () => { // Note: mapRoleFromClaims reads DB setting first; in test env getSetting returns null // so it falls back to config.adminGroup. This test validates the config-based path. const role = mapRoleFromClaims({ groups: ['bt-admins', 'users'] }, adminGroupConfig); assertEqual(role, 'admin'); }); test('default role setting cannot grant admin by default', () => { clearOidcEnv(); resetOidcSettings(); setSetting('oidc_default_role', 'admin'); const role = mapRoleFromClaims({ groups: [] }, { adminGroup: null, defaultRole: 'admin' }); assertEqual(role, 'user'); }); test('denies admin when user is NOT in admin group', () => { assertEqual(mapRoleFromClaims({ groups: ['users', 'billing'] }, adminGroupConfig), 'user'); }); test('handles non-array groups gracefully', () => { const role = mapRoleFromClaims({ groups: 'bt-admins' }, adminGroupConfig); // Single-string group — should still match assert(['user', 'admin'].includes(role), 'must return user or admin'); }); // ── getPublicOidcInfo — no secrets ──────────────────────────────────────────── console.log('\ngetPublicOidcInfo'); test('returns oidc_enabled:false when OIDC not configured', () => { const { getPublicOidcInfo } = oidcService; clearOidcEnv(); resetOidcSettings(); const info = getPublicOidcInfo(); // OIDC is not configured in test env so should always be false here assert(!info.clientSecret, 'no clientSecret'); assert(!info.client_secret, 'no client_secret'); assert(!info.OIDC_CLIENT_SECRET, 'no env secret'); }); // ── Admin auth-mode lockout logic ───────────────────────────────────────────── // Test the validation logic directly (without HTTP layer) console.log('\nAdmin auth-mode lockout logic'); function simulateLockoutCheck(nextLocal, nextOidc, oidcConfigured, oidcAdminGroup = 'bt-admins') { if (!nextLocal && !nextOidc) return 'cannot_disable_all'; if (!nextLocal && !oidcConfigured) return 'cannot_disable_local_without_oidc_configured'; if (!nextLocal && !nextOidc) return 'cannot_disable_local_without_oidc_enabled'; if (!nextLocal && !oidcAdminGroup) return 'cannot_disable_local_without_admin_group'; return 'ok'; } test('cannot disable local when OIDC is not configured', () => { assertEqual(simulateLockoutCheck(false, false, false), 'cannot_disable_all'); }); test('cannot disable local when OIDC configured but not enabled', () => { assertEqual(simulateLockoutCheck(false, false, true), 'cannot_disable_all'); }); test('cannot disable local if OIDC configured but would leave no methods', () => { assertEqual(simulateLockoutCheck(false, false, true), 'cannot_disable_all'); }); test('can disable local if OIDC configured AND enabled', () => { assertEqual(simulateLockoutCheck(false, true, true), 'ok'); }); test('cannot disable local without an OIDC admin group', () => { assertEqual(simulateLockoutCheck(false, true, true, ''), 'cannot_disable_local_without_admin_group'); }); test('can disable OIDC if local is still enabled', () => { assertEqual(simulateLockoutCheck(true, false, true), 'ok'); }); test('can disable OIDC if local is still enabled (no oidc config)', () => { assertEqual(simulateLockoutCheck(true, false, false), 'ok'); }); test('both enabled is always ok', () => { assertEqual(simulateLockoutCheck(true, true, true), 'ok'); assertEqual(simulateLockoutCheck(true, true, false), 'ok'); }); // ── User provisioning ──────────────────────────────────────────────────────── console.log('\nfindOrProvisionUser'); function countUsers(username) { return getDb().prepare('SELECT COUNT(*) AS n FROM users WHERE username = ?').get(username).n; } test('auto-provision true creates local OIDC user', async () => { clearOidcEnv(); resetOidcSettings(); setSetting('oidc_admin_group', 'bt-admins'); const user = await oidcService.findOrProvisionUser({ sub: 'sub-create-1', preferred_username: 'sso-user', email: 'sso@example.com', email_verified: true, name: 'SSO User', groups: ['users'], }, getOidcConfig() || { adminGroup: 'bt-admins', defaultRole: 'user', autoProvision: true, }); assertEqual(user.auth_provider, 'oidc'); assertEqual(user.external_subject, 'sub-create-1'); assertEqual(user.username, 'sso-user'); assertEqual(user.role, 'user'); assertEqual(user.first_login, 0); assertEqual(user.must_change_password, 0); assertEqual(user.password_hash, ''); }); test('auto-provision false rejects unknown OIDC user', async () => { clearOidcEnv(); resetOidcSettings(); setSetting('oidc_auto_provision', 'false'); let rejected = false; try { await oidcService.findOrProvisionUser({ sub: 'sub-denied-1', preferred_username: 'denied-user', }, { adminGroup: null, defaultRole: 'user', autoProvision: false }); } catch (err) { rejected = err.status === 403; } assert(rejected, 'unknown OIDC user should be rejected with 403'); }); test('existing OIDC user maps by provider and subject', async () => { clearOidcEnv(); resetOidcSettings(); const first = await oidcService.findOrProvisionUser({ sub: 'sub-existing-1', preferred_username: 'existing-oidc', }, { adminGroup: null, defaultRole: 'user', autoProvision: true }); const second = await oidcService.findOrProvisionUser({ sub: 'sub-existing-1', preferred_username: 'different-name', }, { adminGroup: null, defaultRole: 'user', autoProvision: true }); assertEqual(second.id, first.id); assertEqual(countUsers('different-name'), 0); }); test('verified email links existing local user', async () => { clearOidcEnv(); resetOidcSettings(); getDb().prepare(` INSERT INTO users (username, password_hash, role, auth_provider, email) VALUES ('local-link', 'hash', 'user', 'local', 'link@example.com') `).run(); const linked = await oidcService.findOrProvisionUser({ sub: 'sub-link-1', preferred_username: 'new-link-name', email: 'link@example.com', email_verified: true, }, { adminGroup: null, defaultRole: 'user', autoProvision: true }); assertEqual(linked.username, 'local-link'); assertEqual(linked.auth_provider, 'oidc'); assertEqual(linked.external_subject, 'sub-link-1'); }); test('unverified email does not link existing local user', async () => { clearOidcEnv(); resetOidcSettings(); getDb().prepare(` INSERT INTO users (username, password_hash, role, auth_provider, email) VALUES ('local-unverified', 'hash', 'user', 'local', 'unverified@example.com') `).run(); const created = await oidcService.findOrProvisionUser({ sub: 'sub-unverified-1', preferred_username: 'unverified-created', email: 'unverified@example.com', email_verified: false, }, { adminGroup: null, defaultRole: 'user', autoProvision: true }); assertEqual(created.username, 'unverified-created'); const local = getDb().prepare("SELECT auth_provider FROM users WHERE username = 'local-unverified'").get(); assertEqual(local.auth_provider, 'local'); }); test('admin role only granted by configured group', async () => { clearOidcEnv(); resetOidcSettings(); setSetting('oidc_admin_group', 'bt-admins'); const admin = await oidcService.findOrProvisionUser({ sub: 'sub-admin-1', preferred_username: 'admin-sso', groups: ['bt-admins'], }, { adminGroup: 'bt-admins', defaultRole: 'user', autoProvision: true }); const user = await oidcService.findOrProvisionUser({ sub: 'sub-user-1', preferred_username: 'plain-sso', groups: ['users'], }, { adminGroup: 'bt-admins', defaultRole: 'user', autoProvision: true }); assertEqual(admin.role, 'admin'); assertEqual(user.role, 'user'); }); test('OIDC-created user cannot local-password login', async () => { const { login } = require('../services/authService'); clearOidcEnv(); resetOidcSettings(); await oidcService.findOrProvisionUser({ sub: 'sub-no-local-1', preferred_username: 'oidc-no-local', }, { adminGroup: null, defaultRole: 'user', autoProvision: true }); const result = await login('oidc-no-local', 'anything'); assertEqual(result, null); }); // ── Summary ─────────────────────────────────────────────────────────────────── (async () => { for (const item of tests) { await runTest(item); } console.log(`\n${'─'.repeat(50)}`); console.log(` ${passed} passed, ${failed} failed`); closeDb(); fs.rmSync(tempDir, { recursive: true, force: true }); if (failed > 0) { console.error('\nSome tests failed.'); process.exit(1); } console.log(' All OIDC smoke tests passed.\n'); })();