Add local authentication option (#37)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m20s

Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #37
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
This commit was merged in pull request #37.
This commit is contained in:
2026-02-22 05:17:45 +00:00
committed by shaun
parent 42fe36ca86
commit fc8e80186d
26 changed files with 695 additions and 71 deletions

View File

@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setupTestDb } from '../../../tests/helpers.js';
import type { Database } from '../db/types.js';
import { upsertUserFromAuth } from '../users.js';
import { isAdminUser, requireAdmin } from './auth.js';
let database: Database;
const originalAdminUserIds = process.env.ADMIN_USER_IDS;
beforeEach(() => {
database = setupTestDb();
process.env.ADMIN_USER_IDS = '';
});
afterEach(() => {
process.env.ADMIN_USER_IDS = originalAdminUserIds;
database.close();
});
describe('isAdminUser', () => {
it('allows direct ID matches', () => {
process.env.ADMIN_USER_IDS = 'oidc-sub-123';
expect(isAdminUser('oidc-sub-123')).toBe(true);
});
it('allows username matches for known users', () => {
upsertUserFromAuth({
id: 'oidc-sub-123',
username: 'shaun',
fullName: 'Shaun Campbell',
email: 'shaun@example.com'
});
process.env.ADMIN_USER_IDS = 'shaun';
expect(isAdminUser('oidc-sub-123')).toBe(true);
});
it('allows email matches for known users', () => {
upsertUserFromAuth({
id: 'oidc-sub-123',
username: 'shaun',
fullName: 'Shaun Campbell',
email: 'shaun@example.com'
});
process.env.ADMIN_USER_IDS = 'shaun@example.com';
expect(isAdminUser('oidc-sub-123')).toBe(true);
});
it('allows email local-part matches from session user', () => {
process.env.ADMIN_USER_IDS = 'shaun';
expect(isAdminUser({ id: 'oidc-sub-123', email: 'shaun@example.com' })).toBe(true);
});
});
describe('requireAdmin', () => {
it('throws when not authenticated', () => {
process.env.ADMIN_USER_IDS = 'shaun';
expect(() => requireAdmin(undefined)).toThrow('Not authenticated');
});
it('throws when user is not admin', () => {
process.env.ADMIN_USER_IDS = 'shaun';
expect(() => requireAdmin('someone-else')).toThrow('Admin access required');
});
it('does not throw when username match grants admin access', () => {
upsertUserFromAuth({
id: 'oidc-sub-123',
username: 'shaun',
fullName: 'Shaun Campbell'
});
process.env.ADMIN_USER_IDS = 'shaun';
expect(() => requireAdmin('oidc-sub-123')).not.toThrow();
});
it('does not throw when session user email local-part matches', () => {
process.env.ADMIN_USER_IDS = 'shaun';
expect(() =>
requireAdmin({ id: 'oidc-sub-123', email: 'shaun@example.com', name: 'Shaun Campbell' })
).not.toThrow();
});
});

View File

@@ -1,12 +1,73 @@
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db/index.js';
export function requireAdmin(userId: string | undefined): void {
if (!userId) throw new Error('Not authenticated');
const adminIds = (env.ADMIN_USER_IDS ?? '')
const getAdminIdentifiers = (): string[] =>
(process.env.ADMIN_USER_IDS ?? env.ADMIN_USER_IDS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (adminIds.length === 0 || !adminIds.includes(userId)) {
type AdminPrincipal =
| string
| {
id?: string | null;
name?: string | null;
email?: string | null;
}
| undefined;
const normalize = (value: string): string => value.trim().toLowerCase();
const getPrincipalValues = (principal: AdminPrincipal): { userId?: string; values: string[] } => {
if (!principal) return { values: [] };
if (typeof principal === 'string') {
const value = principal.trim();
return value ? { userId: value, values: [value] } : { values: [] };
}
const values = [principal.id, principal.name, principal.email]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.trim());
const localPart = principal.email?.split('@')[0]?.trim();
if (localPart) values.push(localPart);
const userId = principal.id?.trim();
return { userId, values };
};
export function isAdminUser(principal: AdminPrincipal): boolean {
const { userId, values } = getPrincipalValues(principal);
if (values.length === 0) return false;
const adminIds = getAdminIdentifiers();
if (adminIds.length === 0) return false;
const normalizedAdminIds = new Set(adminIds.map(normalize));
for (const value of values) {
if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true;
}
if (!userId) return false;
const user = db.get<{ username: string; email: string | null }>(
'SELECT username, email FROM users WHERE id = ?',
[userId]
);
if (!user) return false;
const dbValues = [user.username, user.email, user.email?.split('@')[0]]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.trim());
for (const value of dbValues) {
if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true;
}
return false;
}
export function requireAdmin(principal: AdminPrincipal): void {
if (!principal) throw new Error('Not authenticated');
if (!isAdminUser(principal)) {
throw new Error('Admin access required');
}
}

View File

@@ -65,6 +65,15 @@ export function runMigrations(db: Database): void {
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS local_credentials (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS cities (
id INTEGER PRIMARY KEY,

View File

@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupTestDb } from '../../tests/helpers.js';
import type { Database } from './db/types.js';
import { setLocalCredentialPassword } from './local-credentials.js';
import { resetLocalAuthRateLimits, verifyLocalCredentials } from './local-auth.js';
import { upsertUserFromAuth } from './users.js';
let database: Database;
beforeEach(() => {
process.env.LOCAL_AUTH_ENABLED = 'true';
process.env.LOCAL_AUTH_ARGON2_MEMORY_KB = '8192';
process.env.LOCAL_AUTH_ARGON2_TIME_COST = '2';
process.env.LOCAL_AUTH_ARGON2_PARALLELISM = '1';
process.env.LOCAL_AUTH_MAX_ATTEMPTS = '2';
process.env.LOCAL_AUTH_WINDOW_SECONDS = '60';
process.env.LOCAL_AUTH_LOCKOUT_SECONDS = '10';
database = setupTestDb();
resetLocalAuthRateLimits();
});
afterEach(() => {
vi.useRealTimers();
resetLocalAuthRateLimits();
database.close();
});
describe('verifyLocalCredentials', () => {
it('authenticates valid local credentials', async () => {
upsertUserFromAuth({
id: 'user-1',
username: 'jdoe',
fullName: 'Jane Doe',
email: 'jane@example.com',
authSource: 'Local'
});
await setLocalCredentialPassword('user-1', 'averysecurepassword');
const result = await verifyLocalCredentials('jdoe', 'averysecurepassword', '127.0.0.1');
expect(result.status).toBe('success');
if (result.status === 'success') {
expect(result.user.id).toBe('user-1');
expect(result.user.email).toBe('jane@example.com');
}
});
it('returns invalid for bad credentials and locks after max attempts', async () => {
upsertUserFromAuth({ id: 'user-2', username: 'sally', fullName: 'Sally Sample' });
await setLocalCredentialPassword('user-2', 'averysecurepassword');
const first = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
const second = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
const third = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1');
expect(first.status).toBe('invalid');
expect(second.status).toBe('invalid');
expect(third.status).toBe('locked');
});
it('clears lockout after window expires', async () => {
upsertUserFromAuth({ id: 'user-3', username: 'morgan', fullName: 'Morgan West' });
await setLocalCredentialPassword('user-3', 'averysecurepassword');
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2');
await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2');
const locked = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2');
expect(locked.status).toBe('locked');
vi.advanceTimersByTime(11_000);
const after = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2');
expect(after.status).toBe('success');
});
it('rejects when local auth is disabled', async () => {
process.env.LOCAL_AUTH_ENABLED = 'false';
const result = await verifyLocalCredentials('anyone', 'password', '10.0.0.3');
expect(result.status).toBe('disabled');
});
});

View File

@@ -0,0 +1,154 @@
import { env } from '$env/dynamic/private';
import argon2 from 'argon2';
import { getLocalCredentialByIdentifier, hashLocalPassword } from './local-credentials.js';
export interface LocalAuthUser {
id: string;
name: string;
email?: string | null;
}
export type LocalAuthResult =
| { status: 'success'; user: LocalAuthUser }
| { status: 'invalid' | 'locked' | 'disabled' };
interface AttemptState {
count: number;
firstAttemptAt: number;
lockedUntil?: number;
}
const attemptsByIdentifier = new Map<string, AttemptState>();
const attemptsByIp = new Map<string, AttemptState>();
let dummyHashPromise: Promise<string> | null = null;
const getEnvValue = (key: keyof typeof env): string | undefined => {
return process.env[key] ?? env[key];
};
const getNumberEnv = (value: string | undefined, fallback: number): number => {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
};
const getLocalAuthConfig = () => {
return {
enabled: getEnvValue('LOCAL_AUTH_ENABLED') === 'true',
maxAttempts: getNumberEnv(getEnvValue('LOCAL_AUTH_MAX_ATTEMPTS'), 5),
windowMs: getNumberEnv(getEnvValue('LOCAL_AUTH_WINDOW_SECONDS'), 900) * 1000,
lockoutMs: getNumberEnv(getEnvValue('LOCAL_AUTH_LOCKOUT_SECONDS'), 900) * 1000
};
};
const normalizeIdentifier = (value: string): string => value.trim().toLowerCase();
const normalizePassword = (value: string): string => value.trim();
const normalizeIp = (value?: string | null): string => {
if (!value) return 'unknown';
const trimmed = value.trim();
if (!trimmed) return 'unknown';
return trimmed.split(',')[0]?.trim() || 'unknown';
};
const getDummyHash = async (): Promise<string> => {
if (!dummyHashPromise) {
dummyHashPromise = hashLocalPassword('invalid-password-placeholder');
}
return dummyHashPromise;
};
const getEffectiveState = (state: AttemptState | undefined, now: number, windowMs: number) => {
if (!state) return { count: 0, firstAttemptAt: now };
if (state.lockedUntil && state.lockedUntil <= now) {
return { count: 0, firstAttemptAt: now };
}
if (now - state.firstAttemptAt > windowMs) {
return { count: 0, firstAttemptAt: now };
}
return { ...state };
};
const isLocked = (state: AttemptState, now: number): boolean => {
return Boolean(state.lockedUntil && state.lockedUntil > now);
};
const recordFailure = (
map: Map<string, AttemptState>,
key: string,
now: number,
windowMs: number,
lockoutMs: number,
maxAttempts: number
): void => {
const state = getEffectiveState(map.get(key), now, windowMs);
const nextCount = state.count + 1;
const nextState: AttemptState = {
count: nextCount,
firstAttemptAt: state.firstAttemptAt
};
if (nextCount >= maxAttempts) {
nextState.lockedUntil = now + lockoutMs;
}
map.set(key, nextState);
};
const clearAttemptState = (map: Map<string, AttemptState>, key: string): void => {
map.delete(key);
};
export function resetLocalAuthRateLimits(): void {
attemptsByIdentifier.clear();
attemptsByIp.clear();
}
export async function verifyLocalCredentials(
identifierInput: string,
passwordInput: string,
ipAddress?: string | null
): Promise<LocalAuthResult> {
const config = getLocalAuthConfig();
if (!config.enabled) return { status: 'disabled' };
const identifier = normalizeIdentifier(identifierInput ?? '');
const password = normalizePassword(passwordInput ?? '');
const identifierKey = identifier || 'unknown';
const ipKey = normalizeIp(ipAddress);
const now = Date.now();
const identifierState = getEffectiveState(
attemptsByIdentifier.get(identifierKey),
now,
config.windowMs
);
const ipState = getEffectiveState(attemptsByIp.get(ipKey), now, config.windowMs);
const locked = isLocked(identifierState, now) || isLocked(ipState, now);
const record = identifier ? getLocalCredentialByIdentifier(identifier) : null;
const passwordHash = record?.passwordHash ?? (await getDummyHash());
const passwordMatches = await argon2.verify(passwordHash, password);
if (!locked && record && passwordMatches) {
clearAttemptState(attemptsByIdentifier, identifierKey);
clearAttemptState(attemptsByIp, ipKey);
return {
status: 'success',
user: {
id: record.userId,
name: record.fullName || record.username,
email: record.email ?? undefined
}
};
}
recordFailure(
attemptsByIdentifier,
identifierKey,
now,
config.windowMs,
config.lockoutMs,
config.maxAttempts
);
recordFailure(attemptsByIp, ipKey, now, config.windowMs, config.lockoutMs, config.maxAttempts);
return { status: locked ? 'locked' : 'invalid' };
}

View File

@@ -0,0 +1,67 @@
import { env } from '$env/dynamic/private';
import argon2 from 'argon2';
import { db } from './db/index.js';
export interface LocalCredentialRecord {
userId: string;
username: string;
fullName: string;
email: string | null;
passwordHash: string;
}
export const LOCAL_AUTH_MIN_PASSWORD_LENGTH = 12;
const getNumberEnv = (value: string | undefined, fallback: number): number => {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
};
export function getLocalCredentialByIdentifier(identifier: string): LocalCredentialRecord | null {
const normalized = identifier.trim();
if (!normalized) return null;
return (
db.get<LocalCredentialRecord>(
`SELECT
users.id as userId,
users.username as username,
users.full_name as fullName,
users.email as email,
local_credentials.password_hash as passwordHash
FROM users
INNER JOIN local_credentials ON local_credentials.user_id = users.id
WHERE lower(users.username) = lower(?)
OR (users.email IS NOT NULL AND lower(users.email) = lower(?))
LIMIT 1`,
[normalized, normalized]
) ?? null
);
}
export async function hashLocalPassword(password: string): Promise<string> {
const trimmed = password.trim();
if (trimmed.length < LOCAL_AUTH_MIN_PASSWORD_LENGTH) {
throw new Error(`Password must be at least ${LOCAL_AUTH_MIN_PASSWORD_LENGTH} characters`);
}
const memoryCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_MEMORY_KB, 65536);
const timeCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_TIME_COST, 3);
const parallelism = getNumberEnv(env.LOCAL_AUTH_ARGON2_PARALLELISM, 1);
return argon2.hash(trimmed, {
type: argon2.argon2id,
memoryCost,
timeCost,
parallelism
});
}
export async function setLocalCredentialPassword(userId: string, password: string): Promise<void> {
const passwordHash = await hashLocalPassword(password);
db.run(
`INSERT INTO local_credentials (user_id, password_hash)
VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET
password_hash = excluded.password_hash,
updated_at = datetime('now')`,
[userId, passwordHash]
);
}