BillTracker/scripts/test-oidc-smoke.js

498 lines
19 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
#!/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');
})();