498 lines
19 KiB
JavaScript
498 lines
19 KiB
JavaScript
|
|
#!/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');
|
||
|
|
})();
|