feat(auth): implement authentication system

- Username/password login with bcrypt and session cookies
- API key authentication (X-Api-Key header or ?apikey query param)
- AUTH env var modes: on, local, off, oidc
- Generic OIDC support for external providers
- Session metadata tracking (IP, browser, device)
- Security settings page (password, sessions, API key)
- Login analysis with typo and attack detection
- Auth event logging throughout
This commit is contained in:
Sam Chau
2026-01-26 00:22:05 +10:30
parent 71a1c9e969
commit d2133aa457
41 changed files with 3984 additions and 2058 deletions

6
.gitignore vendored
View File

@@ -39,3 +39,9 @@ obj/
# Bruno environments (contain API keys)
bruno/environments/
# OIDC testing (local Keycloak config)
test/oidc/
# Research repos
research/

View File

@@ -62,6 +62,16 @@
- **Testing** - Validate regex patterns, custom format conditions, and quality
profile behavior before syncing
**Authentication**
- `AUTH=on` (default) - Username/password login required
- `AUTH=local` - Skip auth for local network requests
- `AUTH=oidc` - SSO via OpenID Connect provider
- `AUTH=off` - No authentication (use with external auth like Authentik/Authelia)
API access via `X-Api-Key` header or `?apikey=` query param. See
[auth docs](src/lib/server/utils/auth/README.md) for details.
## Discord
We're most active on [Discord](https://discord.gg/2A89tXZMgA), where we post

View File

@@ -19,16 +19,20 @@
"$notifications/": "./src/lib/server/notifications/",
"$sync/": "./src/lib/server/sync/",
"$cache/": "./src/lib/server/utils/cache/",
"$auth/": "./src/lib/server/utils/auth/",
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
"@std/assert": "jsr:@std/assert@^1.0.0",
"marked": "npm:marked@^15.0.6",
"simple-icons": "npm:simple-icons@^15.17.0",
"highlight.js": "npm:highlight.js@^11.11.1",
"croner": "npm:croner@^8.1.2",
"@std/yaml": "jsr:@std/yaml@^1.0.10"
"croner": "npm:croner@^9.1.0",
"@std/yaml": "jsr:@std/yaml@^1.0.10",
"@felix/bcrypt": "jsr:@felix/bcrypt@^1.0.8"
},
"tasks": {
"dev": "deno run -A scripts/dev.ts",
"dev:noauth": "AUTH=local deno run -A scripts/dev.ts",
"dev:oidc": "AUTH=oidc OIDC_DISCOVERY_URL=http://localhost:8080/realms/profilarr/.well-known/openid-configuration OIDC_CLIENT_ID=profilarr OIDC_CLIENT_SECRET=secret deno run -A scripts/dev.ts",
"dev:server": "DENO_ENV=development PORT=6969 HOST=0.0.0.0 APP_BASE_PATH=./dist/dev PARSER_HOST=localhost PARSER_PORT=5000 VITE_PLATFORM=linux-amd64 VITE_CHANNEL=dev deno run -A npm:vite dev",
"dev:parser": "cd src/services/parser && dotnet watch run --urls http://localhost:5000",
"build": "APP_BASE_PATH=./dist/build deno run -A npm:vite build && deno compile --no-check --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --allow-sys --target x86_64-unknown-linux-gnu --output dist/build/profilarr dist/build/mod.ts",
@@ -37,7 +41,7 @@
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"check": "deno task check:server && deno task check:client",
"check:server": "deno check src/lib/server/**/*.ts",
"check:server": "deno check --quiet src/lib/server/**/*.ts",
"check:client": "npx svelte-check --tsconfig ./tsconfig.json",
"test": "deno run -A scripts/test.ts",
"test:watch": "APP_BASE_PATH=./dist/test deno test src/tests --allow-read --allow-write --allow-env --watch",

2039
deno.lock generated

File diff suppressed because it is too large Load Diff

1130
docs/todo/auth.md Normal file

File diff suppressed because it is too large Load Diff

1
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"": {
"name": "profilarr",
"version": "2.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@deno/vite-plugin": "^1.0.5",
"@jsr/db__sqlite": "^0.12.0",

8
src/app.d.ts vendored
View File

@@ -1,9 +1,15 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { User } from '$db/queries/users.ts';
import type { Session } from '$db/queries/sessions.ts';
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
user: User | null;
session: Session | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}

6
src/deno.d.ts vendored
View File

@@ -7,3 +7,9 @@ declare const __APP_VERSION__: string;
// Note: Deno namespace types are provided by Deno's built-in lib.deno.ns.d.ts
// Do not redeclare them here to avoid conflicts
// JSR package declarations for svelte-check compatibility
declare module '@felix/bcrypt' {
export function hash(password: string, rounds?: number): Promise<string>;
export function verify(password: string, hash: string): Promise<boolean>;
}

View File

@@ -1,3 +1,5 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { config } from '$config';
import { printBanner, getServerInfo, logContainerConfig } from '$logger/startup.ts';
import { logSettings } from '$logger/settings.ts';
@@ -7,6 +9,13 @@ import { runMigrations } from '$db/migrations.ts';
import { initializeJobs } from '$jobs/init.ts';
import { jobScheduler } from '$jobs/scheduler.ts';
import { pcdManager } from '$pcd/pcd.ts';
import {
getAuthState,
isPublicPath,
maybeExtendSession,
cleanupExpiredSessions
} from '$auth/middleware.ts';
import { getClientIp } from '$auth/network.ts';
// Initialize configuration on server startup
await config.init();
@@ -30,6 +39,15 @@ await pcdManager.initialize();
await initializeJobs();
await jobScheduler.start();
// Clean expired sessions on startup
const expiredCount = cleanupExpiredSessions();
if (expiredCount > 0) {
await logger.info(`Cleaned up ${expiredCount} expired session${expiredCount === 1 ? '' : 's'}`, {
source: 'Auth:Session',
meta: { count: expiredCount }
});
}
// Log server ready
await logger.info('Server ready', {
source: 'Startup',
@@ -38,3 +56,61 @@ await logger.info('Server ready', {
// Print startup banner with URL
printBanner();
/**
* Auth middleware
* Handles authentication, authorization, and session management
*/
export const handle: Handle = async ({ event, resolve }) => {
const auth = getAuthState(event);
// First-run setup flow (applies to all auth modes except AUTH=off)
if (auth.needsSetup) {
if (event.url.pathname === '/auth/setup') {
return resolve(event);
}
throw redirect(303, '/auth/setup');
}
// AUTH=off or AUTH=local with local IP - skip auth after setup
if (auth.skipAuth) {
return resolve(event);
}
// Block setup page after user exists
if (event.url.pathname === '/auth/setup') {
throw redirect(303, '/');
}
// Public paths don't need auth
if (isPublicPath(event.url.pathname)) {
return resolve(event);
}
// Not authenticated - redirect or return 401
if (!auth.user) {
if (event.url.pathname.startsWith('/api')) {
const ip = getClientIp(event);
void logger.warn('Unauthorized API access', {
source: 'Auth',
meta: { ip, endpoint: event.url.pathname, method: event.request.method }
});
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
throw redirect(303, '/auth/login');
}
// Sliding expiration: extend session if past halfway point
if (auth.session) {
maybeExtendSession(auth.session);
}
// Authenticated - attach user to locals for use in routes
event.locals.user = auth.user;
event.locals.session = auth.session;
return resolve(event);
};

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { Eye, EyeOff } from 'lucide-svelte';
export let label: string;
export let description: string = '';
export let placeholder: string = '';
@@ -6,12 +8,20 @@
export let textarea: boolean = false;
export let type: 'text' | 'number' | 'email' | 'password' | 'url' = 'text';
export let required: boolean = false;
export let name: string = '';
export let autocomplete: string = '';
export let private_: boolean = false;
export let readonly: boolean = false;
let showPassword = false;
$: inputType = private_ && showPassword ? 'text' : type;
</script>
<div class="space-y-2">
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
<label for={name} class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{label}
</div>
</label>
{#if description}
<p class="text-xs text-neutral-600 dark:text-neutral-400">
@@ -21,18 +31,49 @@
{#if textarea}
<textarea
id={name}
{name}
bind:value
{placeholder}
rows="6"
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
class="block w-full rounded-xl border border-neutral-300 bg-white px-3 py-2 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-300 focus:outline-none dark:border-neutral-700/60 dark:bg-neutral-800/50 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-600"
></textarea>
{:else if private_}
<div class="relative">
<input
id={name}
{name}
type={inputType}
bind:value
{placeholder}
{required}
readonly={readonly}
autocomplete={autocomplete ? (autocomplete as typeof HTMLInputElement.prototype.autocomplete) : undefined}
class="block w-full rounded-xl border border-neutral-300 px-3 py-2 pr-10 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none dark:border-neutral-700/60 dark:text-neutral-50 dark:placeholder-neutral-500 {readonly ? 'bg-white cursor-default dark:bg-neutral-800/50' : 'bg-white focus:border-neutral-400 dark:bg-neutral-800/50 dark:focus:border-neutral-600'}"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300"
onclick={() => (showPassword = !showPassword)}
>
{#if showPassword}
<EyeOff size={18} />
{:else}
<Eye size={18} />
{/if}
</button>
</div>
{:else}
<input
id={name}
{name}
{type}
bind:value
{placeholder}
{required}
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
readonly={readonly}
autocomplete={autocomplete ? (autocomplete as typeof HTMLInputElement.prototype.autocomplete) : undefined}
class="block w-full rounded-xl border border-neutral-300 px-3 py-2 shadow text-neutral-900 placeholder-neutral-400 transition-colors focus:outline-none dark:border-neutral-700/60 dark:text-neutral-50 dark:placeholder-neutral-500 {readonly ? 'bg-white cursor-default dark:bg-neutral-800/50' : 'bg-white focus:border-neutral-400 dark:bg-neutral-800/50 dark:focus:border-neutral-600'}"
/>
{/if}
</div>

View File

@@ -76,7 +76,9 @@
<GroupItem label="Logs" href="/settings/logs" />
<GroupItem label="Backups" href="/settings/backups" />
<GroupItem label="Notifications" href="/settings/notifications" />
<GroupItem label="Security" href="/settings/security" />
<GroupItem label="About" href="/settings/about" />
<GroupItem label="Log Out" href="/auth/logout" />
</Group>
</div>

View File

@@ -12,6 +12,7 @@
export let onRowClick: ((row: T) => void) | undefined = undefined;
export let initialSort: SortState | null = null;
export let onSortChange: ((sort: SortState | null) => void) | undefined = undefined;
export let actionsHeader: string = 'Actions';
let sortKey: string | null = initialSort?.key ?? null;
let sortDirection: SortDirection = initialSort?.direction ?? 'asc';
@@ -169,7 +170,7 @@
<th
class={`${compact ? 'px-4 py-2' : 'px-6 py-3'} text-right text-xs font-medium tracking-wider text-neutral-700 uppercase dark:text-neutral-300`}
>
Actions
{actionsHeader}
</th>
{/if}
</tr>

View File

@@ -37,6 +37,8 @@ import { migration as migration032 } from './migrations/032_add_filter_id_to_upg
import { migration as migration033 } from './migrations/033_create_github_cache.ts';
import { migration as migration034 } from './migrations/034_add_sync_status.ts';
import { migration as migration035 } from './migrations/035_add_job_skipped_status.ts';
import { migration as migration036 } from './migrations/036_create_auth_tables.ts';
import { migration as migration037 } from './migrations/037_add_session_metadata.ts';
export interface Migration {
version: number;
@@ -292,7 +294,9 @@ export function loadMigrations(): Migration[] {
migration032,
migration033,
migration034,
migration035
migration035,
migration036,
migration037
];
// Sort by version number

View File

@@ -0,0 +1,58 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 036: Create authentication tables
*
* Creates all auth-related tables:
* - users: Single admin user (this is a single-user app)
* - sessions: Multiple sessions per user (allows login from multiple devices)
* - auth_settings: Singleton for session duration and API key
*/
export const migration: Migration = {
version: 36,
name: 'Create auth tables',
up: `
-- Users table (single admin user)
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Sessions table (multiple sessions per user)
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
-- Auth settings table (singleton)
CREATE TABLE auth_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
session_duration_hours INTEGER NOT NULL DEFAULT 168,
api_key TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Insert default auth settings with generated API key
INSERT INTO auth_settings (id, api_key) VALUES (1, lower(hex(randomblob(16))));
`,
down: `
DROP TABLE IF EXISTS auth_settings;
DROP INDEX IF EXISTS idx_sessions_expires_at;
DROP INDEX IF EXISTS idx_sessions_user_id;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
`
};

View File

@@ -0,0 +1,47 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 037: Add session metadata columns
*
* Adds rich session information for better session management UI:
* - ip_address: Client IP address when session was created
* - user_agent: Full user agent string
* - browser: Parsed browser name and version (e.g., "Chrome 120")
* - os: Parsed operating system (e.g., "Windows 11")
* - device_type: Device category (Desktop, Mobile, Tablet)
* - last_active_at: Updated on sliding expiration for activity tracking
*/
export const migration: Migration = {
version: 37,
name: 'Add session metadata',
up: `
ALTER TABLE sessions ADD COLUMN ip_address TEXT;
ALTER TABLE sessions ADD COLUMN user_agent TEXT;
ALTER TABLE sessions ADD COLUMN browser TEXT;
ALTER TABLE sessions ADD COLUMN os TEXT;
ALTER TABLE sessions ADD COLUMN device_type TEXT;
ALTER TABLE sessions ADD COLUMN last_active_at DATETIME;
`,
down: `
-- SQLite doesn't support DROP COLUMN directly, so we recreate the table
CREATE TABLE sessions_new (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO sessions_new (id, user_id, expires_at, created_at)
SELECT id, user_id, expires_at, created_at FROM sessions;
DROP TABLE sessions;
ALTER TABLE sessions_new RENAME TO sessions;
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
`
};

View File

@@ -0,0 +1,104 @@
import { db } from '../db.ts';
import { generateApiKey } from '$auth/apiKey.ts';
/**
* Types for auth_settings table
*/
export interface AuthSettings {
id: number;
session_duration_hours: number;
api_key: string | null;
created_at: string;
updated_at: string;
}
export interface UpdateAuthSettingsInput {
sessionDurationHours?: number;
apiKey?: string | null;
}
/**
* All queries for auth_settings table
* Singleton pattern - only one settings record exists
*/
export const authSettingsQueries = {
/**
* Get auth settings (singleton)
*/
get(): AuthSettings {
const settings = db.queryFirst<AuthSettings>('SELECT * FROM auth_settings WHERE id = 1');
if (!settings) {
throw new Error('Auth settings not found - database may not be initialized');
}
return settings;
},
/**
* Get session duration in hours
*/
getSessionDurationHours(): number {
return this.get().session_duration_hours;
},
/**
* Get API key (may be null)
*/
getApiKey(): string | null {
return this.get().api_key;
},
/**
* Update auth settings
*/
update(input: UpdateAuthSettingsInput): boolean {
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (input.sessionDurationHours !== undefined) {
updates.push('session_duration_hours = ?');
params.push(input.sessionDurationHours);
}
if (input.apiKey !== undefined) {
updates.push('api_key = ?');
params.push(input.apiKey);
}
if (updates.length === 0) {
return false;
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(1); // id is always 1
const affected = db.execute(
`UPDATE auth_settings SET ${updates.join(', ')} WHERE id = ?`,
...params
);
return affected > 0;
},
/**
* Regenerate API key and return the new key
*/
regenerateApiKey(): string {
const newKey = generateApiKey();
this.update({ apiKey: newKey });
return newKey;
},
/**
* Clear API key (disable API access)
*/
clearApiKey(): boolean {
return this.update({ apiKey: null });
},
/**
* Validate an API key
*/
validateApiKey(key: string): boolean {
const settings = this.get();
return settings.api_key !== null && settings.api_key === key;
}
};

View File

@@ -0,0 +1,145 @@
import { db } from '../db.ts';
/**
* Types for sessions table
*/
export interface Session {
id: string;
user_id: number;
expires_at: string;
created_at: string;
// Metadata fields (Migration 037)
ip_address: string | null;
user_agent: string | null;
browser: string | null;
os: string | null;
device_type: string | null;
last_active_at: string | null;
}
/**
* Metadata to capture when creating a session
*/
export interface SessionMetadata {
ipAddress?: string;
userAgent?: string;
browser?: string;
os?: string;
deviceType?: string;
}
/**
* All queries for sessions table
* Multiple sessions per user (different browsers/devices)
*/
export const sessionsQueries = {
/**
* Create a new session with optional metadata
*/
create(userId: number, durationHours: number, metadata?: SessionMetadata): string {
const id = crypto.randomUUID();
const expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000);
db.execute(
`INSERT INTO sessions (id, user_id, expires_at, ip_address, user_agent, browser, os, device_type, last_active_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
id,
userId,
expiresAt.toISOString(),
metadata?.ipAddress ?? null,
metadata?.userAgent ?? null,
metadata?.browser ?? null,
metadata?.os ?? null,
metadata?.deviceType ?? null
);
return id;
},
/**
* Get a session by ID (regardless of expiration)
*/
getById(id: string): Session | undefined {
return db.queryFirst<Session>('SELECT * FROM sessions WHERE id = ?', id);
},
/**
* Get a valid (non-expired) session by ID
*/
getValidById(id: string): Session | undefined {
return db.queryFirst<Session>(
`SELECT * FROM sessions
WHERE id = ? AND datetime(expires_at) > datetime('now')`,
id
);
},
/**
* Get all sessions for a user
*/
getByUserId(userId: number): Session[] {
return db.query<Session>(
'SELECT * FROM sessions WHERE user_id = ? ORDER BY created_at DESC',
userId
);
},
/**
* Delete a specific session (logout)
*/
deleteById(id: string): boolean {
const affected = db.execute('DELETE FROM sessions WHERE id = ?', id);
return affected > 0;
},
/**
* Delete all sessions for a user (logout everywhere)
*/
deleteByUserId(userId: number): number {
return db.execute('DELETE FROM sessions WHERE user_id = ?', userId);
},
/**
* Delete all sessions except one (logout other devices)
*/
deleteOthersByUserId(userId: number, keepSessionId: string): number {
return db.execute(
'DELETE FROM sessions WHERE user_id = ? AND id != ?',
userId,
keepSessionId
);
},
/**
* Delete all expired sessions (cleanup)
*/
deleteExpired(): number {
return db.execute(`DELETE FROM sessions WHERE datetime(expires_at) <= datetime('now')`);
},
/**
* Extend session expiration (sliding expiration)
* Also updates last_active_at for activity tracking
*/
extendExpiration(id: string, durationHours: number): boolean {
const expiresAt = new Date(Date.now() + durationHours * 60 * 60 * 1000);
const affected = db.execute(
'UPDATE sessions SET expires_at = ?, last_active_at = CURRENT_TIMESTAMP WHERE id = ?',
expiresAt.toISOString(),
id
);
return affected > 0;
},
/**
* Count active sessions for a user
*/
countByUserId(userId: number): number {
const result = db.queryFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM sessions
WHERE user_id = ? AND datetime(expires_at) > datetime('now')`,
userId
);
return result?.count ?? 0;
}
};

View File

@@ -0,0 +1,122 @@
import { db } from '../db.ts';
/**
* Types for users table
*/
export interface User {
id: number;
username: string;
password_hash: string;
created_at: string;
updated_at: string;
}
/**
* All queries for users table
* Single admin user - no multi-user support
*/
export const usersQueries = {
/**
* Check if any users exist (for first-run setup detection)
*/
exists(): boolean {
const result = db.queryFirst<{ count: number }>('SELECT COUNT(*) as count FROM users');
return (result?.count ?? 0) > 0;
},
/**
* Check if any local (non-OIDC) users exist
* OIDC users have username starting with 'oidc:'
*/
existsLocal(): boolean {
const result = db.queryFirst<{ count: number }>(
"SELECT COUNT(*) as count FROM users WHERE username NOT LIKE 'oidc:%'"
);
return (result?.count ?? 0) > 0;
},
/**
* Get user by ID
*/
getById(id: number): User | undefined {
return db.queryFirst<User>('SELECT * FROM users WHERE id = ?', id);
},
/**
* Get user by username
*/
getByUsername(username: string): User | undefined {
return db.queryFirst<User>('SELECT * FROM users WHERE username = ?', username);
},
/**
* Get all usernames (for login analysis - typo detection)
* Excludes OIDC users since they can't login with password
*/
getAllUsernames(): string[] {
const results = db.query<{ username: string }>(
"SELECT username FROM users WHERE username NOT LIKE 'oidc:%'"
);
return results.map((r) => r.username);
},
/**
* Create a new user (should only be called once during setup)
*/
create(username: string, passwordHash: string): number {
db.execute(
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
username,
passwordHash
);
const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id');
return result?.id ?? 0;
},
/**
* Update user's password
*/
updatePassword(id: number, passwordHash: string): boolean {
const affected = db.execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
passwordHash,
id
);
return affected > 0;
},
/**
* Update username
*/
updateUsername(id: number, username: string): boolean {
const affected = db.execute(
'UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
username,
id
);
return affected > 0;
},
/**
* Get or create OIDC user
* OIDC users have no password (placeholder hash)
*/
getOrCreateOidcUser(identifier: string): number {
const username = `oidc:${identifier}`;
const existing = this.getByUsername(username);
if (existing) {
return existing.id;
}
// Create with placeholder - OIDC users can't login with password
db.execute(
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
username,
'OIDC_NO_PASSWORD'
);
const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id');
return result?.id ?? 0;
}
};

View File

@@ -1,7 +1,7 @@
-- Profilarr Database Schema
-- This file documents the current database schema after all migrations
-- DO NOT execute this file directly - use migrations instead
-- Last updated: 2026-01-22
-- Last updated: 2026-01-24
-- ==============================================================================
-- TABLE: migrations
@@ -648,3 +648,57 @@ CREATE TABLE github_cache (
-- GitHub cache indexes (Migration: 033_create_github_cache.ts)
CREATE INDEX idx_github_cache_type ON github_cache(cache_type);
CREATE INDEX idx_github_cache_expires ON github_cache(expires_at);
-- ==============================================================================
-- TABLE: users
-- Purpose: Store admin user credentials (single-user app)
-- Migration: 036_create_auth_tables.ts
-- ==============================================================================
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- ==============================================================================
-- TABLE: sessions
-- Purpose: Store user sessions (allows login from multiple devices)
-- Migration: 036_create_auth_tables.ts, 037_add_session_metadata.ts
-- ==============================================================================
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- UUID
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- Session metadata (Migration 037)
ip_address TEXT, -- Client IP when session created
user_agent TEXT, -- Full user agent string
browser TEXT, -- Parsed browser name/version (e.g., "Chrome 120")
os TEXT, -- Parsed OS (e.g., "Windows 11")
device_type TEXT, -- Device category (Desktop, Mobile, Tablet)
last_active_at DATETIME, -- Updated on sliding expiration
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
-- ==============================================================================
-- TABLE: auth_settings
-- Purpose: Store auth configuration (singleton pattern with id=1)
-- Migration: 036_create_auth_tables.ts
-- ==============================================================================
CREATE TABLE auth_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
session_duration_hours INTEGER NOT NULL DEFAULT 168, -- 7 days
api_key TEXT, -- For programmatic access
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,207 @@
# Auth Module
Authentication and session management for Profilarr.
## Auth Modes
Controlled by the `AUTH` environment variable:
| Mode | Description |
|------|-------------|
| `on` | Default. Username/password required for all requests |
| `local` | Skip auth for local IPs (192.168.x.x, 10.x.x.x, etc.) |
| `off` | Disable auth entirely (trust reverse proxy like Authelia) |
| `oidc` | Login via external OIDC provider (Google, Authentik, Keycloak) |
## Sequence Diagrams
### AUTH=on (Default)
#### First Run (No User)
```mermaid
sequenceDiagram
participant U as User
participant P as Profilarr
U->>P: GET /
P->>P: Check: user exists? No
P->>U: Redirect /auth/setup
U->>P: GET /auth/setup
P->>U: Show setup form
U->>P: POST /auth/setup (username, password)
P->>P: Create user, create session
P->>U: Set cookie, redirect /
```
#### Login Flow
```mermaid
sequenceDiagram
participant U as User
participant P as Profilarr
U->>P: GET /
P->>P: Check: has session? No
P->>U: Redirect /auth/login
U->>P: GET /auth/login
P->>U: Show login form
U->>P: POST /auth/login (username, password)
P->>P: Verify password, create session
P->>U: Set cookie, redirect /
```
#### Authenticated Request
```mermaid
sequenceDiagram
participant U as User
participant P as Profilarr
U->>P: GET / (with session cookie)
P->>P: Validate session
P->>P: Past halfway? Extend session
P->>U: 200 OK (page content)
```
### AUTH=local
#### Local IP
```mermaid
sequenceDiagram
participant U as User (192.168.x.x)
participant P as Profilarr
U->>P: GET /
P->>P: Check: AUTH=local + local IP? Yes
P->>U: 200 OK (skip auth)
```
#### External IP
```mermaid
sequenceDiagram
participant U as User (external)
participant P as Profilarr
U->>P: GET /
P->>P: Check: AUTH=local + local IP? No
P->>P: Check: has session? No
P->>U: Redirect /auth/login
Note over U,P: Same as AUTH=on login flow
```
### AUTH=oidc
```mermaid
sequenceDiagram
participant U as User
participant P as Profilarr
participant IDP as OIDC Provider
U->>P: GET /
P->>P: Check: has session? No
P->>U: Redirect /auth/login
U->>P: GET /auth/login
P->>U: Show "Sign in with SSO" button
U->>P: GET /auth/oidc/login
P->>P: Generate state, store in cookie
P->>U: Redirect to IDP
U->>IDP: Login at provider
IDP->>U: Redirect /auth/oidc/callback?code=xxx
U->>P: GET /auth/oidc/callback
P->>P: Verify state
P->>IDP: Exchange code for tokens
IDP->>P: access_token + id_token
P->>P: Verify id_token, create session
P->>U: Set cookie, redirect /
```
### AUTH=off
```mermaid
sequenceDiagram
participant U as User
participant P as Profilarr
U->>P: GET /
P->>P: Check: AUTH=off? Yes
P->>U: 200 OK (no auth checks)
Note over U,P: Trust reverse proxy (Authelia, etc.)
```
## Scenarios
### AUTH=on (Default)
| Scenario | What Happens |
|----------|--------------|
| First run, no users | Redirect to `/auth/setup` |
| User exists, not logged in | Redirect to `/auth/login` |
| User exists, logged in | Allow access |
| Session expired | Redirect to `/auth/login` |
| API request, no auth | 401 JSON response |
| API request, valid X-Api-Key | Allow access |
| Visit `/auth/setup` after user exists | Redirect to `/` |
### AUTH=local
| Scenario | What Happens |
|----------|--------------|
| Local IP (192.168.x.x) | Allow access (skip auth) |
| Local IP, first run | Redirect to `/auth/setup` (still need to create user) |
| External IP, not logged in | Redirect to `/auth/login` |
| External IP, logged in | Allow access |
### AUTH=oidc
| Scenario | What Happens |
|----------|--------------|
| Not logged in | Redirect to `/auth/login` (shows SSO button) |
| Click "Sign in with SSO" | Redirect to OIDC provider |
| Return from provider | Create session, redirect to `/` |
| Session expired | Redirect to `/auth/login` |
| Visit `/auth/setup` | Redirect to `/` (no setup needed) |
### AUTH=off
| Scenario | What Happens |
|----------|--------------|
| Any request | Allow access (no auth checks) |
## Session Management
- **Duration**: 7 days (configurable in Settings > Security)
- **Sliding expiration**: Session extended when past halfway point
- **Multiple sessions**: Users can be logged in from multiple devices
- **Metadata tracked**: IP, user agent, browser, OS, device type, last active
## Files
| File | Purpose |
|------|---------|
| `middleware.ts` | Core auth logic (getAuthState, isPublicPath) |
| `password.ts` | Bcrypt hash/verify |
| `network.ts` | IP detection (getClientIp, isLocalAddress) |
| `userAgent.ts` | Parse browser/OS/device from user agent |
| `apiKey.ts` | API key generation |
| `oidc.ts` | OIDC discovery, token exchange, JWT parsing |
## Environment Variables
```bash
# Auth mode
AUTH=on # on, local, off, oidc
# OIDC (only when AUTH=oidc)
OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
OIDC_CLIENT_ID=profilarr
OIDC_CLIENT_SECRET=your-secret
```
## Routes
| Route | Purpose |
|-------|---------|
| `/auth/setup` | First-run setup (create admin account) |
| `/auth/login` | Login page (form or OIDC button) |
| `/auth/logout` | Logout (clear session) |
| `/auth/oidc/login` | Initiate OIDC flow |
| `/auth/oidc/callback` | Handle OIDC provider response |
| `/settings/security` | Manage sessions, API key, password |

View File

@@ -0,0 +1,12 @@
/**
* API key generation utility
* Generates 32 hex character keys (UUID without hyphens, like Sonarr)
*/
/**
* Generate a new API key
* Returns 32 lowercase hex characters (128 bits)
*/
export function generateApiKey(): string {
return crypto.randomUUID().replace(/-/g, '');
}

View File

@@ -0,0 +1,126 @@
/**
* Login attempt analysis utilities
* Helps distinguish between typos and potential attack attempts
*/
// Common usernames attackers try
const COMMON_ATTACK_USERNAMES = [
'admin',
'administrator',
'root',
'user',
'test',
'guest',
'demo',
'system',
'operator',
'superuser',
'master',
'default'
];
/**
* Check if a username is commonly used in brute force attacks
*/
export function isCommonAttackUsername(username: string): boolean {
return COMMON_ATTACK_USERNAMES.includes(username.toLowerCase());
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
/**
* Find a similar username from the list of existing usernames
* Returns the similar username if found (within 2 edits), null otherwise
*/
export function findSimilarUsername(
attempted: string,
existingUsernames: string[]
): string | null {
const attemptedLower = attempted.toLowerCase();
for (const existing of existingUsernames) {
const distance = levenshteinDistance(attemptedLower, existing.toLowerCase());
// Allow up to 2 character differences for typo detection
if (distance > 0 && distance <= 2) {
return existing;
}
}
return null;
}
export interface LoginFailureAnalysis {
reason: 'user_not_found' | 'invalid_password';
similarUser: string | null;
isCommonAttack: boolean;
}
/**
* Analyze a failed login attempt for logging purposes
*/
export function analyzeLoginFailure(
username: string,
existingUsernames: string[],
userExists: boolean
): LoginFailureAnalysis {
if (userExists) {
return {
reason: 'invalid_password',
similarUser: null,
isCommonAttack: false
};
}
return {
reason: 'user_not_found',
similarUser: findSimilarUsername(username, existingUsernames),
isCommonAttack: isCommonAttackUsername(username)
};
}
/**
* Format a login failure for logging
*/
export function formatLoginFailure(analysis: LoginFailureAnalysis): string {
if (analysis.reason === 'invalid_password') {
return 'invalid password';
}
if (analysis.similarUser) {
return `unknown user (similar to '${analysis.similarUser}')`;
}
if (analysis.isCommonAttack) {
return 'unknown user (common attack username)';
}
return 'unknown user';
}

View File

@@ -0,0 +1,152 @@
/**
* Auth middleware utilities
* Core auth logic - keeps hooks.server.ts thin
*/
import type { RequestEvent } from '@sveltejs/kit';
import { config } from '$config';
import { usersQueries, type User } from '$db/queries/users.ts';
import { sessionsQueries, type Session } from '$db/queries/sessions.ts';
import { authSettingsQueries } from '$db/queries/authSettings.ts';
import { isLocalAddress, getClientIp } from './network.ts';
import { logger } from '$logger/logger.ts';
/**
* Auth state returned by getAuthState
*/
export interface AuthState {
needsSetup: boolean;
user: User | null;
session: Session | null;
skipAuth: boolean; // true when AUTH=off or AUTH=local+local IP
}
/**
* Paths that don't require authentication
*/
const PUBLIC_PATHS = ['/auth/login', '/auth/setup', '/auth/oidc', '/api/v1/health'];
/**
* Check if a path is public (doesn't require auth)
*/
export function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/'));
}
/**
* Get auth state from request
* Checks auth mode, API key, and session cookie
*/
export function getAuthState(event: RequestEvent): AuthState {
const hasLocalUsers = usersQueries.existsLocal();
// AUTH=off - skip all auth (trust external proxy like Authelia/Authentik)
if (config.authMode === 'off') {
return {
needsSetup: false,
user: null,
session: null,
skipAuth: true
};
}
// AUTH=local - skip auth for local IPs
if (config.authMode === 'local') {
const clientIp = getClientIp(event);
if (isLocalAddress(clientIp)) {
void logger.debug('Local IP bypass', {
source: 'Auth',
meta: { ip: clientIp }
});
return {
needsSetup: !hasLocalUsers,
user: null,
session: null,
skipAuth: true
};
}
}
// AUTH=oidc - uses sessions but no local user/password
if (config.authMode === 'oidc') {
const sessionId = event.cookies.get('session');
const session = sessionId ? sessionsQueries.getValidById(sessionId) ?? null : null;
const user = session ? usersQueries.getById(session.user_id) ?? null : null;
return {
needsSetup: false, // No setup needed for OIDC
user,
session,
skipAuth: false
};
}
// AUTH=on (default) - full username/password auth
// Check API key (header or query param)
const apiKey =
event.request.headers.get('X-Api-Key') || event.url.searchParams.get('apikey');
if (apiKey) {
const ip = getClientIp(event);
const endpoint = event.url.pathname;
if (authSettingsQueries.validateApiKey(apiKey)) {
void logger.info('API key authenticated', {
source: 'Auth:APIKey',
meta: { ip, endpoint }
});
return {
needsSetup: false,
user: { id: 0, username: 'api' } as User,
session: null,
skipAuth: false
};
} else {
// Mask API key - only show last 4 chars
const maskedKey = apiKey.length > 4 ? `****${apiKey.slice(-4)}` : '****';
void logger.warn('Invalid API key', {
source: 'Auth:APIKey',
meta: { ip, endpoint, key: maskedKey }
});
}
}
// Check session cookie
const sessionId = event.cookies.get('session');
const session = sessionId ? sessionsQueries.getValidById(sessionId) ?? null : null;
const user = session ? usersQueries.getById(session.user_id) ?? null : null;
return {
needsSetup: !hasLocalUsers,
user,
session,
skipAuth: false
};
}
/**
* Sliding expiration: extend session if past halfway point
* Avoids DB write on every request while keeping active users logged in
*/
export function maybeExtendSession(session: Session): void {
const durationHours = authSettingsQueries.getSessionDurationHours();
const expiresAt = new Date(session.expires_at).getTime();
const now = Date.now();
const halfDuration = (durationHours * 60 * 60 * 1000) / 2;
// Only extend if less than half the duration remains
if (expiresAt - now < halfDuration) {
sessionsQueries.extendExpiration(session.id, durationHours);
void logger.debug('Session extended', {
source: 'Auth:Session',
meta: { userId: session.user_id }
});
}
}
/**
* Clean expired sessions - call on startup
*/
export function cleanupExpiredSessions(): number {
return sessionsQueries.deleteExpired();
}

View File

@@ -0,0 +1,136 @@
/**
* Network utilities for detecting local/private IP addresses
* Used for AUTH=local mode to bypass auth for local network requests
*
* Based on Sonarr's implementation:
* https://github.com/Sonarr/Sonarr/blob/develop/src/NzbDrone.Common/Extensions/IpAddressExtensions.cs
*/
/**
* Check if an IP address is a local/private network address
*
* IPv4 ranges:
* - 127.0.0.0/8 (loopback)
* - 10.0.0.0/8 (Class A private)
* - 172.16.0.0/12 (Class B private)
* - 192.168.0.0/16 (Class C private)
* - 169.254.0.0/16 (link-local, no DHCP)
*
* IPv6 ranges:
* - ::1 (loopback)
* - fe80::/10 (link-local)
* - fc00::/7 (unique local)
* - fec0::/10 (site-local, deprecated but still checked)
*/
export function isLocalAddress(ip: string): boolean {
// Handle IPv6-mapped IPv4 (::ffff:192.168.1.1)
if (ip.startsWith('::ffff:')) {
ip = ip.slice(7);
}
// Check if it's an IPv4 address
if (ip.includes('.')) {
return isLocalIPv4(ip);
}
// IPv6 checks
return isLocalIPv6(ip);
}
/**
* Check if an IPv4 address is local/private
*/
function isLocalIPv4(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
const bytes = parts.map((p) => parseInt(p, 10));
if (bytes.some((b) => isNaN(b) || b < 0 || b > 255)) return false;
const [a, b] = bytes;
// Loopback: 127.0.0.0/8
if (a === 127) return true;
// Class A private: 10.0.0.0/8
if (a === 10) return true;
// Class B private: 172.16.0.0/12 (172.16.x.x - 172.31.x.x)
if (a === 172 && b >= 16 && b <= 31) return true;
// Class C private: 192.168.0.0/16
if (a === 192 && b === 168) return true;
// Link-local: 169.254.0.0/16 (no DHCP assigned)
if (a === 169 && b === 254) return true;
return false;
}
/**
* Check if an IPv6 address is local
*/
function isLocalIPv6(ip: string): boolean {
const lower = ip.toLowerCase();
// Loopback
if (lower === '::1') return true;
// Link-local: fe80::/10
if (lower.startsWith('fe80:')) return true;
// Unique local: fc00::/7 (fc00:: or fd00::)
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
// Site-local (deprecated): fec0::/10
if (lower.startsWith('fec')) return true;
return false;
}
/**
* Headers to check for client IP, in order of precedence
* Based on @supercharge/request-ip (used by Overseerr)
*/
const IP_HEADERS = [
'x-forwarded-for', // Standard proxy header (may contain multiple IPs)
'x-real-ip', // Nginx
'x-client-ip', // Apache
'cf-connecting-ip', // Cloudflare
'fastly-client-ip', // Fastly
'true-client-ip', // Akamai/Cloudflare
'x-cluster-client-ip' // Rackspace
];
/**
* Extract client IP from request
*
* Checks common proxy headers in order (like Overseerr's approach),
* then falls back to SvelteKit's getClientAddress()
*/
export function getClientIp(event: { getClientAddress: () => string; request: Request }): string {
const headers = event.request.headers;
// Check proxy headers in order
for (const header of IP_HEADERS) {
const value = headers.get(header);
if (value) {
// x-forwarded-for may contain multiple IPs: "client, proxy1, proxy2"
const ip = value.split(',')[0].trim();
if (ip) return ip;
}
}
// Fall back to SvelteKit's built-in
try {
const address = event.getClientAddress();
if (address && address !== 'unknown') {
return address;
}
} catch {
// Can throw during prerendering
}
// Default to loopback
return '127.0.0.1';
}

View File

@@ -0,0 +1,194 @@
/**
* OIDC (OpenID Connect) utilities
* Handles discovery, token exchange, and ID token parsing
*
* No external dependencies - just native fetch and crypto
*/
export interface DiscoveryDocument {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
jwks_uri: string;
}
export interface TokenResponse {
access_token: string;
id_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
}
export interface IdTokenClaims {
sub: string;
email?: string;
name?: string;
preferred_username?: string;
iss: string;
aud: string | string[];
exp: number;
iat: number;
}
// Cache discovery document (doesn't change often)
let cachedDiscovery: {
url: string;
doc: DiscoveryDocument;
expires: number;
} | null = null;
/**
* Fetch and cache OIDC discovery document
*/
export async function getDiscoveryDocument(url: string): Promise<DiscoveryDocument> {
if (cachedDiscovery && cachedDiscovery.url === url && Date.now() < cachedDiscovery.expires) {
return cachedDiscovery.doc;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch OIDC discovery: ${response.status}`);
}
const doc = (await response.json()) as DiscoveryDocument;
if (!doc.authorization_endpoint || !doc.token_endpoint) {
throw new Error('Invalid OIDC discovery document');
}
// Cache for 1 hour
cachedDiscovery = {
url,
doc,
expires: Date.now() + 60 * 60 * 1000
};
return doc;
}
/**
* Generate a random state token for CSRF protection
*/
export function generateState(): string {
return crypto.randomUUID();
}
/**
* Build the authorization URL
*/
export function buildAuthorizationUrl(
authorizationEndpoint: string,
opts: {
clientId: string;
redirectUri: string;
state: string;
scope?: string;
}
): string {
const params = new URLSearchParams({
client_id: opts.clientId,
redirect_uri: opts.redirectUri,
response_type: 'code',
scope: opts.scope || 'openid email profile',
state: opts.state
});
return `${authorizationEndpoint}?${params.toString()}`;
}
/**
* Exchange authorization code for tokens
*/
export async function exchangeCode(
tokenEndpoint: string,
code: string,
opts: {
clientId: string;
clientSecret: string;
redirectUri: string;
}
): Promise<TokenResponse> {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: opts.clientId,
client_secret: opts.clientSecret,
redirect_uri: opts.redirectUri
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${response.status} - ${error}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`Token exchange error: ${data.error}`);
}
return data as TokenResponse;
}
/**
* Decode a JWT and extract claims (no signature verification)
*
* Note: We trust the token because it came from a server-to-server
* exchange using our client secret. The provider validated everything.
*/
export function decodeIdToken(idToken: string): IdTokenClaims {
const parts = idToken.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}
// Base64URL decode the payload
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const decoded = atob(payload);
const claims = JSON.parse(decoded) as IdTokenClaims;
return claims;
}
/**
* Verify basic claims on the ID token
*/
export function verifyIdToken(
claims: IdTokenClaims,
opts: {
clientId: string;
issuer: string;
}
): void {
// Verify issuer
if (claims.iss !== opts.issuer) {
throw new Error(`Invalid issuer: expected ${opts.issuer}, got ${claims.iss}`);
}
// Verify audience
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (!audiences.includes(opts.clientId)) {
throw new Error(`Invalid audience: token not issued for ${opts.clientId}`);
}
// Verify expiration
const now = Math.floor(Date.now() / 1000);
if (claims.exp && claims.exp < now) {
throw new Error('ID token has expired');
}
}
/**
* Clear the cached discovery document
*/
export function clearDiscoveryCache(): void {
cachedDiscovery = null;
}

View File

@@ -0,0 +1,20 @@
/**
* Password hashing utilities using bcrypt via @felix/bcrypt
* Uses Rust bcrypt via Deno FFI
*/
import { hash, verify } from '@felix/bcrypt';
/**
* Hash a password using bcrypt
*/
export function hashPassword(password: string): Promise<string> {
return hash(password);
}
/**
* Verify a password against a stored hash
*/
export function verifyPassword(password: string, storedHash: string): Promise<boolean> {
return verify(password, storedHash);
}

View File

@@ -0,0 +1,151 @@
/**
* User Agent Parser
*
* Simple regex-based parser to extract browser, OS, and device type
* from user agent strings. No heavy libraries - just pattern matching.
*/
export interface ParsedUserAgent {
browser: string; // "Chrome 120", "Firefox 121", "Safari 17"
os: string; // "Windows 11", "macOS 14", "Ubuntu", "iOS 17"
deviceType: string; // "Desktop", "Mobile", "Tablet"
}
/**
* Parse a user agent string into structured data
*/
export function parseUserAgent(ua: string): ParsedUserAgent {
if (!ua) {
return { browser: 'Unknown', os: 'Unknown', deviceType: 'Unknown' };
}
return {
browser: parseBrowser(ua),
os: parseOS(ua),
deviceType: parseDeviceType(ua)
};
}
/**
* Extract browser name and version
*/
function parseBrowser(ua: string): string {
// Order matters - check more specific patterns first
// Edge (Chromium-based)
const edge = ua.match(/Edg(?:e|A|iOS)?\/(\d+)/);
if (edge) return `Edge ${edge[1]}`;
// Opera (also Chromium-based, check before Chrome)
const opera = ua.match(/(?:OPR|Opera)\/(\d+)/);
if (opera) return `Opera ${opera[1]}`;
// Firefox
const firefox = ua.match(/Firefox\/(\d+)/);
if (firefox) return `Firefox ${firefox[1]}`;
// Safari (check before Chrome since Chrome includes Safari in UA)
// Safari doesn't include "Chrome" in its UA
if (ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium')) {
const safari = ua.match(/Version\/(\d+)/);
if (safari) return `Safari ${safari[1]}`;
return 'Safari';
}
// Chrome (and Chromium-based browsers not caught above)
const chrome = ua.match(/(?:Chrome|Chromium)\/(\d+)/);
if (chrome) return `Chrome ${chrome[1]}`;
// Internet Explorer
const ie = ua.match(/(?:MSIE |rv:)(\d+)/);
if (ie) return `IE ${ie[1]}`;
// Fallback: try to find any browser-like pattern
const generic = ua.match(/(\w+)\/(\d+)/);
if (generic) return `${generic[1]} ${generic[2]}`;
return 'Unknown';
}
/**
* Extract operating system name and version
*/
function parseOS(ua: string): string {
// iOS (check before Mac since iOS includes "like Mac OS X")
const ios = ua.match(/(?:iPhone|iPad|iPod).*?OS (\d+)/);
if (ios) return `iOS ${ios[1]}`;
// Android
const android = ua.match(/Android (\d+(?:\.\d+)?)/);
if (android) return `Android ${android[1]}`;
// Windows
// Note: Windows 11 still reports "Windows NT 10.0" for backwards compatibility
// There's no reliable way to distinguish Win10 from Win11 via user agent alone
if (ua.includes('Windows')) {
if (ua.includes('Windows NT 10.0')) return 'Windows';
if (ua.includes('Windows NT 6.3')) return 'Windows 8.1';
if (ua.includes('Windows NT 6.2')) return 'Windows 8';
if (ua.includes('Windows NT 6.1')) return 'Windows 7';
if (ua.includes('Windows NT 6.0')) return 'Windows Vista';
if (ua.includes('Windows NT 5.1')) return 'Windows XP';
return 'Windows';
}
// macOS (after iOS check)
const mac = ua.match(/Mac OS X (\d+)[_.](\d+)/);
if (mac) {
const major = parseInt(mac[1]);
const minor = parseInt(mac[2]);
// macOS 11+ uses major version only in marketing
if (major >= 11) return `macOS ${major}`;
// macOS 10.x uses 10.minor naming
return `macOS ${major}.${minor}`;
}
if (ua.includes('Macintosh')) return 'macOS';
// Linux distributions
if (ua.includes('Ubuntu')) return 'Ubuntu';
if (ua.includes('Fedora')) return 'Fedora';
if (ua.includes('Debian')) return 'Debian';
if (ua.includes('Arch')) return 'Arch Linux';
if (ua.includes('CrOS')) return 'Chrome OS';
if (ua.includes('Linux')) return 'Linux';
// BSD variants
if (ua.includes('FreeBSD')) return 'FreeBSD';
if (ua.includes('OpenBSD')) return 'OpenBSD';
return 'Unknown';
}
/**
* Determine device type from user agent
*/
function parseDeviceType(ua: string): string {
// Tablets (check before mobile since some tablets include "Mobile")
if (
ua.includes('iPad') ||
ua.includes('Tablet') ||
(ua.includes('Android') && !ua.includes('Mobile'))
) {
return 'Tablet';
}
// Mobile devices
if (
ua.includes('Mobile') ||
ua.includes('iPhone') ||
ua.includes('iPod') ||
ua.includes('Android') ||
ua.includes('webOS') ||
ua.includes('BlackBerry') ||
ua.includes('Opera Mini') ||
ua.includes('IEMobile')
) {
return 'Mobile';
}
// Default to Desktop
return 'Desktop';
}

View File

@@ -2,12 +2,20 @@
* Application configuration singleton
*/
export type AuthMode = 'on' | 'local' | 'off' | 'oidc';
class Config {
private basePath: string;
public readonly timezone: string;
public readonly parserUrl: string;
public readonly port: number;
public readonly host: string;
public readonly authMode: AuthMode;
public readonly oidc: {
discoveryUrl: string | null;
clientId: string | null;
clientSecret: string | null;
};
constructor() {
// Default base path logic:
@@ -36,6 +44,19 @@ class Config {
// Server bind configuration
this.port = parseInt(Deno.env.get('PORT') || '6868', 10);
this.host = Deno.env.get('HOST') || '0.0.0.0';
// Auth mode: 'on' (default), 'local', 'off', 'oidc'
const auth = (Deno.env.get('AUTH') || 'on').toLowerCase();
this.authMode = ['on', 'local', 'off', 'oidc'].includes(auth)
? (auth as AuthMode)
: 'on';
// OIDC configuration (only used when AUTH=oidc)
this.oidc = {
discoveryUrl: Deno.env.get('OIDC_DISCOVERY_URL') || null,
clientId: Deno.env.get('OIDC_CLIENT_ID') || null,
clientSecret: Deno.env.get('OIDC_CLIENT_SECRET') || null
};
}
/**

View File

@@ -5,8 +5,12 @@
import PageNav from '$ui/navigation/pageNav/pageNav.svelte';
import AlertContainer from '$alerts/AlertContainer.svelte';
import { sidebarCollapsed } from '$lib/client/stores/sidebar';
import { page } from '$app/stores';
export let data;
// Hide navigation on auth pages (login, setup, etc.)
$: isAuthPage = $page.url.pathname.startsWith('/auth/');
</script>
<svelte:head>
@@ -14,25 +18,29 @@
<title>Profilarr</title>
</svelte:head>
<Navbar collapsed={$sidebarCollapsed} />
<PageNav collapsed={$sidebarCollapsed} version={data.version} />
{#if !isAuthPage}
<Navbar collapsed={$sidebarCollapsed} />
<PageNav collapsed={$sidebarCollapsed} version={data.version} />
{/if}
<AlertContainer />
<!-- Sidebar collapse toggle button -->
<button
type="button"
on:click={() => sidebarCollapsed.toggle()}
class="fixed top-16 z-50 flex h-6 w-6 -translate-x-1/2 -translate-y-1/3 items-center justify-center rounded-md border border-neutral-300 bg-white shadow-sm transition-all hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
style="left: {$sidebarCollapsed ? '24px' : '288px'}"
aria-label={$sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<div class="flex flex-col gap-[3px]">
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
</div>
</button>
{#if !isAuthPage}
<!-- Sidebar collapse toggle button -->
<button
type="button"
on:click={() => sidebarCollapsed.toggle()}
class="fixed top-16 z-50 flex h-6 w-6 -translate-x-1/2 -translate-y-1/3 items-center justify-center rounded-md border border-neutral-300 bg-white shadow-sm transition-all hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
style="left: {$sidebarCollapsed ? '24px' : '288px'}"
aria-label={$sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<div class="flex flex-col gap-[3px]">
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
<div class="h-[2px] w-3 rounded-full bg-neutral-400 dark:bg-neutral-500"></div>
</div>
</button>
{/if}
<main class="transition-all duration-200 {$sidebarCollapsed ? 'pl-[24px]' : 'pl-72'}">
<main class={isAuthPage ? '' : `transition-all duration-200 ${$sidebarCollapsed ? 'pl-[24px]' : 'pl-72'}`}>
<slot />
</main>

View File

@@ -166,7 +166,7 @@
name="value-{childIndex}"
value={child.value as number}
on:change={(e) => {
child.value = e.detail;
if (e.detail !== undefined) child.value = e.detail;
notifyChange();
}}
font="mono"
@@ -181,7 +181,7 @@
name="value-{childIndex}"
value={child.value as number}
on:change={(e) => {
child.value = e.detail;
if (e.detail !== undefined) child.value = e.detail;
notifyChange();
}}
min={1}

View File

@@ -0,0 +1,99 @@
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { fail, redirect } from '@sveltejs/kit';
import { config } from '$config';
import { usersQueries } from '$db/queries/users.ts';
import { sessionsQueries } from '$db/queries/sessions.ts';
import { authSettingsQueries } from '$db/queries/authSettings.ts';
import { verifyPassword } from '$auth/password.ts';
import { getClientIp } from '$auth/network.ts';
import { parseUserAgent } from '$auth/userAgent.ts';
import { analyzeLoginFailure, formatLoginFailure } from '$auth/loginAnalysis.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = () => {
// OIDC mode - just show the OIDC button, no setup needed
if (config.authMode === 'oidc') {
return { authMode: 'oidc' };
}
// If no local users exist, redirect to setup
// (OIDC users don't count - they can't login with password)
if (!usersQueries.existsLocal()) {
throw redirect(303, '/auth/setup');
}
return { authMode: config.authMode };
};
export const actions: Actions = {
default: async (event) => {
const { request, cookies } = event;
const formData = await request.formData();
const username = (formData.get('username') as string)?.trim();
const password = formData.get('password') as string;
// Validation
if (!username || !password) {
return fail(400, { error: 'Username and password are required', username });
}
// Find user
const user = usersQueries.getByUsername(username);
if (!user) {
const ip = getClientIp(event);
const allUsernames = usersQueries.getAllUsernames();
const analysis = analyzeLoginFailure(username, allUsernames, false);
await logger.warn(`Login failed for '${username}': ${formatLoginFailure(analysis)}`, {
source: 'Auth:Login',
meta: { username, ip, ...analysis }
});
return fail(400, { error: 'Invalid username or password', username });
}
// Verify password
const valid = await verifyPassword(password, user.password_hash);
if (!valid) {
const ip = getClientIp(event);
const analysis = analyzeLoginFailure(username, [], true);
await logger.warn(`Login failed for '${username}': ${formatLoginFailure(analysis)}`, {
source: 'Auth:Login',
meta: { username, ip, ...analysis }
});
return fail(400, { error: 'Invalid username or password', username });
}
// Capture session metadata
const ipAddress = getClientIp(event);
const userAgent = request.headers.get('user-agent') ?? '';
const parsed = parseUserAgent(userAgent);
// Create session with metadata
const durationHours = authSettingsQueries.getSessionDurationHours();
const sessionId = sessionsQueries.create(user.id, durationHours, {
ipAddress,
userAgent,
browser: parsed.browser,
os: parsed.os,
deviceType: parsed.deviceType
});
await logger.info(`Login successful for '${username}'`, {
source: 'Auth:Login',
meta: { username, ip: ipAddress, browser: parsed.browser, device: parsed.deviceType }
});
// Set session cookie
const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000);
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
expires
});
// Redirect to home
throw redirect(303, '/');
}
};

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
import { enhance } from '$app/forms';
import { LogIn, KeyRound } from 'lucide-svelte';
import Button from '$ui/button/Button.svelte';
import FormInput from '$ui/form/FormInput.svelte';
import { alertStore } from '$alerts/store';
import logo from '$assets/logo-512.png';
export let data: PageData;
export let form: ActionData;
let submitting = false;
let username = form?.username ?? '';
let password = '';
// Show errors via alert system
$: if (form?.error) {
alertStore.add('error', form.error);
}
</script>
<svelte:head>
<title>Login - Profilarr</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-neutral-100 dark:bg-neutral-900">
<div class="w-full max-w-sm p-8">
<div class="mb-8 flex items-center gap-4">
<img src={logo} alt="Profilarr logo" class="h-12 w-12" />
<div>
<h1 class="text-xl font-bold text-neutral-900 dark:text-neutral-50">Welcome back</h1>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Sign in to continue.
</p>
</div>
</div>
{#if data.authMode === 'oidc'}
<!-- OIDC login button -->
<Button
href="/auth/oidc/login"
variant="primary"
size="md"
fullWidth
icon={KeyRound}
text="Sign in with SSO"
/>
{:else}
<!-- Username/password form -->
<form
method="POST"
class="space-y-6"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update({ reset: false });
submitting = false;
};
}}
>
<FormInput
name="username"
label="Username"
type="text"
placeholder="Username"
autocomplete="username"
bind:value={username}
/>
<FormInput
name="password"
label="Password"
type="password"
placeholder="Password"
autocomplete="current-password"
private_
bind:value={password}
/>
<Button
type="submit"
variant="primary"
size="md"
fullWidth
icon={LogIn}
text={submitting ? 'Signing In...' : 'Sign In'}
disabled={submitting}
/>
</form>
{/if}
</div>
</div>

View File

@@ -0,0 +1,26 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sessionsQueries } from '$db/queries/sessions.ts';
import { usersQueries } from '$db/queries/users.ts';
import { logger } from '$logger/logger.ts';
export const GET: RequestHandler = async ({ cookies }) => {
const sessionId = cookies.get('session');
if (sessionId) {
// Get session info before deleting for logging
const session = sessionsQueries.getById(sessionId);
if (session) {
const user = usersQueries.getById(session.user_id);
await logger.info(`User '${user?.username ?? 'unknown'}' logged out`, {
source: 'Auth:Session',
meta: { userId: session.user_id, username: user?.username }
});
}
sessionsQueries.deleteById(sessionId);
}
cookies.delete('session', { path: '/' });
throw redirect(303, '/auth/login');
};

View File

@@ -0,0 +1,118 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { config } from '$config';
import { getDiscoveryDocument, exchangeCode, decodeIdToken, verifyIdToken } from '$auth/oidc.ts';
import { usersQueries } from '$db/queries/users.ts';
import { sessionsQueries } from '$db/queries/sessions.ts';
import { authSettingsQueries } from '$db/queries/authSettings.ts';
import { getClientIp } from '$auth/network.ts';
import { parseUserAgent } from '$auth/userAgent.ts';
import { logger } from '$logger/logger.ts';
export const GET: RequestHandler = async (event) => {
const { url, cookies, request } = event;
// Get code and state from query params
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Handle provider errors
if (errorParam) {
throw error(400, `OIDC error: ${errorParam} - ${errorDescription || 'Unknown error'}`);
}
// Verify state (CSRF protection)
const savedState = cookies.get('oidc_state');
if (!state || state !== savedState) {
const ip = getClientIp(event);
await logger.warn('OIDC state mismatch (possible CSRF attempt)', {
source: 'Auth:OIDC',
meta: { ip }
});
throw error(400, 'Invalid state parameter');
}
cookies.delete('oidc_state', { path: '/' });
// Verify we have a code
if (!code) {
throw error(400, 'No authorization code provided');
}
// Validate OIDC configuration
if (!config.oidc.discoveryUrl || !config.oidc.clientId || !config.oidc.clientSecret) {
throw error(500, 'OIDC is not configured');
}
// Fetch discovery document
const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl);
// Exchange code for tokens
const ip = getClientIp(event);
let tokens;
try {
tokens = await exchangeCode(discovery.token_endpoint, code, {
clientId: config.oidc.clientId,
clientSecret: config.oidc.clientSecret,
redirectUri: `${config.serverUrl}/auth/oidc/callback`
});
} catch (err) {
await logger.warn('OIDC token exchange failed', {
source: 'Auth:OIDC',
meta: { ip, error: err instanceof Error ? err.message : String(err) }
});
throw error(500, 'Failed to exchange authorization code');
}
// Decode and verify ID token
let claims;
try {
claims = decodeIdToken(tokens.id_token);
verifyIdToken(claims, {
clientId: config.oidc.clientId,
issuer: discovery.issuer
});
} catch (err) {
await logger.error('OIDC ID token verification failed', {
source: 'Auth:OIDC',
meta: { ip, error: err instanceof Error ? err.message : String(err) }
});
throw error(500, 'Failed to verify ID token');
}
// Get or create OIDC user (using 'sub' as unique identifier)
const userId = usersQueries.getOrCreateOidcUser(claims.sub);
// Capture session metadata
const ipAddress = getClientIp(event);
const userAgent = request.headers.get('user-agent') ?? '';
const parsed = parseUserAgent(userAgent);
// Create session
const durationHours = authSettingsQueries.getSessionDurationHours();
const sessionId = sessionsQueries.create(userId, durationHours, {
ipAddress,
userAgent,
browser: parsed.browser,
os: parsed.os,
deviceType: parsed.deviceType
});
await logger.info(`OIDC login successful for '${claims.sub}'`, {
source: 'Auth:OIDC',
meta: { sub: claims.sub, ip: ipAddress, browser: parsed.browser, device: parsed.deviceType }
});
// Set session cookie
const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000);
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
expires
});
// Redirect to home
throw redirect(303, '/');
};

View File

@@ -0,0 +1,58 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { config } from '$config';
import { getDiscoveryDocument, generateState, buildAuthorizationUrl } from '$auth/oidc.ts';
import { getClientIp } from '$auth/network.ts';
import { logger } from '$logger/logger.ts';
export const GET: RequestHandler = async (event) => {
const { cookies } = event;
const ip = getClientIp(event);
// Validate OIDC configuration
if (config.authMode !== 'oidc') {
throw error(400, 'OIDC authentication is not enabled');
}
if (!config.oidc.discoveryUrl || !config.oidc.clientId || !config.oidc.clientSecret) {
const missing = [
!config.oidc.discoveryUrl && 'OIDC_DISCOVERY_URL',
!config.oidc.clientId && 'OIDC_CLIENT_ID',
!config.oidc.clientSecret && 'OIDC_CLIENT_SECRET'
].filter(Boolean);
await logger.error(`OIDC config missing: ${missing.join(', ')}`, {
source: 'Auth:OIDC',
meta: { missing }
});
throw error(500, 'OIDC is not configured. Set OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET');
}
await logger.debug('OIDC flow started', {
source: 'Auth:OIDC',
meta: { ip }
});
// Fetch discovery document
const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl);
// Generate state token for CSRF protection
const state = generateState();
// Store state in cookie (10 minute expiry)
cookies.set('oidc_state', state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 10
});
// Build authorization URL and redirect
const authUrl = buildAuthorizationUrl(discovery.authorization_endpoint, {
clientId: config.oidc.clientId,
redirectUri: `${config.serverUrl}/auth/oidc/callback`,
state
});
throw redirect(302, authUrl);
};

View File

@@ -0,0 +1,105 @@
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { fail, redirect } from '@sveltejs/kit';
import { usersQueries } from '$db/queries/users.ts';
import { sessionsQueries } from '$db/queries/sessions.ts';
import { authSettingsQueries } from '$db/queries/authSettings.ts';
import { hashPassword } from '$auth/password.ts';
import { getClientIp } from '$auth/network.ts';
import { parseUserAgent } from '$auth/userAgent.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = () => {
// If local users already exist, redirect to home
// (OIDC users don't count - they need to create a local account to use password auth)
if (usersQueries.existsLocal()) {
throw redirect(303, '/');
}
return {};
};
export const actions: Actions = {
default: async (event) => {
const { request, cookies } = event;
// Double-check no local users exist (race condition protection)
if (usersQueries.existsLocal()) {
throw redirect(303, '/');
}
const formData = await request.formData();
const username = (formData.get('username') as string)?.trim();
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
// Validation
if (!username) {
return fail(400, { error: 'Username is required', username });
}
if (username.length < 3) {
return fail(400, { error: 'Username must be at least 3 characters', username });
}
if (!password) {
return fail(400, { error: 'Password is required', username });
}
if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters', username });
}
if (password !== confirmPassword) {
return fail(400, { error: 'Passwords do not match', username });
}
try {
// Hash password and create user
const passwordHash = await hashPassword(password);
const userId = usersQueries.create(username, passwordHash);
if (!userId) {
return fail(500, { error: 'Failed to create account', username });
}
// Capture session metadata
const ipAddress = getClientIp(event);
const userAgent = request.headers.get('user-agent') ?? '';
const parsed = parseUserAgent(userAgent);
// Create session with metadata
const durationHours = authSettingsQueries.getSessionDurationHours();
const sessionId = sessionsQueries.create(userId, durationHours, {
ipAddress,
userAgent,
browser: parsed.browser,
os: parsed.os,
deviceType: parsed.deviceType
});
await logger.info(`Account created: '${username}'`, {
source: 'Auth',
meta: { username, ip: ipAddress }
});
// Set session cookie
const expires = new Date(Date.now() + durationHours * 60 * 60 * 1000);
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
expires
});
// Redirect to home
throw redirect(303, '/');
} catch (err) {
// Re-throw redirects
if (err instanceof Response || (err && typeof err === 'object' && 'status' in err)) {
throw err;
}
return fail(500, { error: 'Failed to create account', username });
}
}
};

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import type { ActionData } from './$types';
import { enhance } from '$app/forms';
import { UserPlus, Shield, Wifi, KeyRound, ShieldOff } from 'lucide-svelte';
import Button from '$ui/button/Button.svelte';
import FormInput from '$ui/form/FormInput.svelte';
import { alertStore } from '$alerts/store';
import logo from '$assets/logo-512.png';
export let form: ActionData;
let submitting = false;
let username = form?.username ?? '';
let password = '';
let confirmPassword = '';
// Show errors via alert system
$: if (form?.error) {
alertStore.add('error', form.error);
}
</script>
<svelte:head>
<title>Setup - Profilarr</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-neutral-100 dark:bg-neutral-900">
<div class="flex w-full max-w-3xl gap-12 p-8">
<!-- Left column: Branding and info -->
<div class="flex flex-1 flex-col space-y-6">
<div class="flex items-center gap-4">
<img src={logo} alt="Profilarr logo" class="h-12 w-12" />
<div>
<h1 class="text-xl font-bold text-neutral-900 dark:text-neutral-50">Welcome to Profilarr</h1>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Create your admin account to get started.
</p>
</div>
</div>
<div class="rounded-xl border border-neutral-200/60 bg-white/50 p-5 shadow-sm backdrop-blur-sm dark:border-neutral-700/60 dark:bg-neutral-800/50">
<p class="text-xs font-medium text-neutral-700 dark:text-neutral-300">
Configure authentication via the <code class="rounded bg-neutral-200 px-1 py-0.5 text-[11px] dark:bg-neutral-700">AUTH</code> environment variable:
</p>
<ul class="mt-3 space-y-2 text-xs text-neutral-600 dark:text-neutral-400">
<li class="flex items-center gap-2">
<Shield size={12} class="text-neutral-400" />
<code class="font-mono text-neutral-800 dark:text-neutral-200">on</code>
<span>— Full authentication</span>
<span class="text-neutral-400 dark:text-neutral-500">(default)</span>
</li>
<li class="flex items-center gap-2">
<Wifi size={12} class="text-neutral-400" />
<code class="font-mono text-neutral-800 dark:text-neutral-200">local</code>
<span>— Skip auth for local network</span>
</li>
<li class="flex items-center gap-2">
<KeyRound size={12} class="text-neutral-400" />
<code class="font-mono text-neutral-800 dark:text-neutral-200">oidc</code>
<span>— Use external provider</span>
</li>
<li class="flex items-center gap-2">
<ShieldOff size={12} class="text-neutral-400" />
<code class="font-mono text-neutral-800 dark:text-neutral-200">off</code>
<span>— For reverse proxy setups</span>
</li>
</ul>
</div>
</div>
<!-- Right column: Form -->
<div class="flex-1">
<form
method="POST"
class="space-y-6"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update({ reset: false });
submitting = false;
};
}}
>
<FormInput
name="username"
label="Username"
type="text"
placeholder="admin"
autocomplete="username"
bind:value={username}
/>
<FormInput
name="password"
label="Password"
type="password"
placeholder="Minimum 8 characters"
autocomplete="new-password"
private_
bind:value={password}
/>
<FormInput
name="confirmPassword"
label="Confirm Password"
type="password"
placeholder="Re-enter your password"
autocomplete="new-password"
private_
bind:value={confirmPassword}
/>
<Button
type="submit"
variant="primary"
size="md"
fullWidth
icon={UserPlus}
text={submitting ? 'Creating Account...' : 'Create Account'}
disabled={submitting}
/>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,145 @@
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import { usersQueries } from '$db/queries/users.ts';
import { sessionsQueries } from '$db/queries/sessions.ts';
import { authSettingsQueries } from '$db/queries/authSettings.ts';
import { hashPassword, verifyPassword } from '$auth/password.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = async ({ cookies }) => {
const currentSessionId = cookies.get('session');
const user = usersQueries.getByUsername('admin') ?? usersQueries.getById(1);
if (!user) {
return { sessions: [], apiKey: null, currentSessionId: null };
}
const sessions = sessionsQueries.getByUserId(user.id);
const apiKey = authSettingsQueries.getApiKey();
return {
sessions: sessions.map(s => ({
id: s.id,
created_at: s.created_at,
expires_at: s.expires_at,
last_active_at: s.last_active_at,
ip_address: s.ip_address,
browser: s.browser,
os: s.os,
device_type: s.device_type,
isCurrent: s.id === currentSessionId
})),
apiKey,
currentSessionId
};
};
export const actions: Actions = {
changePassword: async ({ request, cookies }) => {
const formData = await request.formData();
const currentPassword = formData.get('currentPassword') as string;
const newPassword = formData.get('newPassword') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (!currentPassword || !newPassword || !confirmPassword) {
return fail(400, { passwordError: 'All fields are required' });
}
if (newPassword.length < 8) {
return fail(400, { passwordError: 'New password must be at least 8 characters' });
}
if (newPassword !== confirmPassword) {
return fail(400, { passwordError: 'Passwords do not match' });
}
// Get current user from session
const sessionId = cookies.get('session');
if (!sessionId) {
return fail(401, { passwordError: 'Not authenticated' });
}
const session = sessionsQueries.getValidById(sessionId);
if (!session) {
return fail(401, { passwordError: 'Invalid session' });
}
const user = usersQueries.getById(session.user_id);
if (!user) {
return fail(401, { passwordError: 'User not found' });
}
// Verify current password
const valid = await verifyPassword(currentPassword, user.password_hash);
if (!valid) {
return fail(400, { passwordError: 'Current password is incorrect' });
}
// Update password
const newHash = await hashPassword(newPassword);
usersQueries.updatePassword(user.id, newHash);
await logger.info(`Password changed for '${user.username}'`, {
source: 'Auth',
meta: { userId: user.id, username: user.username }
});
return { passwordSuccess: true };
},
regenerateApiKey: async () => {
const newKey = authSettingsQueries.regenerateApiKey();
await logger.info('API key regenerated', {
source: 'Auth:APIKey'
});
return { apiKey: newKey, apiKeyRegenerated: true };
},
revokeSession: async ({ request, cookies }) => {
const formData = await request.formData();
const sessionId = formData.get('sessionId') as string;
const currentSessionId = cookies.get('session');
if (!sessionId) {
return fail(400, { sessionError: 'Session ID required' });
}
if (sessionId === currentSessionId) {
return fail(400, { sessionError: 'Cannot revoke current session' });
}
sessionsQueries.deleteById(sessionId);
await logger.info('Session revoked', {
source: 'Auth:Session',
meta: { revokedSessionId: sessionId.slice(0, 8) + '...' }
});
return { sessionRevoked: true };
},
revokeOtherSessions: async ({ cookies }) => {
const currentSessionId = cookies.get('session');
if (!currentSessionId) {
return fail(401, { sessionError: 'Not authenticated' });
}
const session = sessionsQueries.getValidById(currentSessionId);
if (!session) {
return fail(401, { sessionError: 'Invalid session' });
}
const count = sessionsQueries.deleteOthersByUserId(session.user_id, currentSessionId);
if (count > 0) {
await logger.info(`Revoked ${count} other session${count === 1 ? '' : 's'}`, {
source: 'Auth:Session',
meta: { userId: session.user_id, count }
});
}
return { sessionsRevoked: count };
}
};

View File

@@ -0,0 +1,326 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { Copy, RefreshCw, LogOut, Check, Globe, Monitor, Smartphone, Network, Clock } from 'lucide-svelte';
import { parseUTC } from '$shared/dates';
import Button from '$ui/button/Button.svelte';
import FormInput from '$ui/form/FormInput.svelte';
import Table from '$ui/table/Table.svelte';
import TableActionButton from '$ui/table/TableActionButton.svelte';
import { alertStore } from '$alerts/store';
import type { Column } from '$ui/table/types';
export let data: PageData;
export let form: ActionData;
let changingPassword = false;
let currentPassword = '';
let newPassword = '';
let confirmPassword = '';
let showApiKey = false;
let regeneratingKey = false;
// Handle form responses
$: if (form?.passwordSuccess) {
alertStore.add('success', 'Password changed successfully');
currentPassword = '';
newPassword = '';
confirmPassword = '';
}
$: if (form?.passwordError) {
alertStore.add('error', form.passwordError);
}
$: if (form?.apiKeyRegenerated) {
alertStore.add('success', 'API key regenerated');
}
$: if (form?.sessionRevoked) {
alertStore.add('success', 'Session revoked');
}
$: if (form?.sessionsRevoked !== undefined) {
alertStore.add('success', `Revoked ${form.sessionsRevoked} session(s)`);
}
$: if (form?.sessionError) {
alertStore.add('error', form.sessionError);
}
// Get API key from form response or data
$: apiKey = form?.apiKey ?? data.apiKey;
function copyApiKey() {
if (apiKey) {
navigator.clipboard.writeText(apiKey);
alertStore.add('success', 'API key copied to clipboard');
}
}
function formatDate(dateStr: string): string {
const date = parseUTC(dateStr);
return date ? date.toLocaleString() : '';
}
interface SessionRow {
id: string;
created_at: string;
expires_at: string;
last_active_at: string | null;
ip_address: string | null;
browser: string | null;
os: string | null;
device_type: string | null;
isCurrent: boolean;
}
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = parseUTC(dateStr);
if (!date) return 'Unknown';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
const sessionColumns: Column<SessionRow>[] = [
{
key: 'browser',
header: 'Browser',
headerIcon: Globe,
cell: (row) => row.browser ?? 'Unknown'
},
{
key: 'os',
header: 'OS',
headerIcon: Monitor,
cell: (row) => row.os ?? 'Unknown'
},
{
key: 'device_type',
header: 'Device',
headerIcon: Smartphone,
cell: (row) => row.device_type ?? 'Unknown'
},
{
key: 'ip_address',
header: 'IP',
headerIcon: Network,
cell: (row) => ({
html: `<span class="font-mono text-xs text-neutral-500 dark:text-neutral-400">${row.ip_address ?? 'Unknown'}</span>`
})
},
{
key: 'last_active_at',
header: 'Last Active',
headerIcon: Clock,
cell: (row) => ({
html: `<span class="text-xs text-neutral-500 dark:text-neutral-400">${formatRelativeTime(row.last_active_at)}</span>`
})
}
];
</script>
<div class="p-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Security</h1>
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
Manage your password, API key, and active sessions
</p>
</div>
<div class="space-y-8">
<!-- Change Password -->
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Change Password</h2>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Update your account password
</p>
</div>
<div class="p-6">
<form
method="POST"
action="?/changePassword"
class="space-y-4"
use:enhance={() => {
changingPassword = true;
return async ({ update }) => {
await update({ reset: false });
changingPassword = false;
};
}}
>
<FormInput
name="currentPassword"
label="Current Password"
type="password"
placeholder="Enter current password"
autocomplete="current-password"
private_
bind:value={currentPassword}
/>
<FormInput
name="newPassword"
label="New Password"
type="password"
placeholder="Minimum 8 characters"
autocomplete="new-password"
private_
bind:value={newPassword}
/>
<div class="flex items-end gap-2">
<div class="flex-1">
<FormInput
name="confirmPassword"
label="Confirm New Password"
type="password"
placeholder="Re-enter new password"
autocomplete="new-password"
private_
bind:value={confirmPassword}
/>
</div>
<Button
type="submit"
variant="secondary"
size="sm"
icon={Check}
iconColor="text-accent-500"
text={changingPassword ? 'Saving...' : 'Change Password'}
disabled={changingPassword}
/>
</div>
</form>
</div>
</div>
<!-- API Key -->
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">API Key</h2>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Authenticate API requests via <code class="rounded bg-neutral-100 px-1 py-0.5 text-xs dark:bg-neutral-800">X-Api-Key</code> header
</p>
</div>
<div class="p-6">
{#if apiKey}
<div class="flex items-center gap-2">
<div class="flex-1">
<FormInput
name="apiKey"
label=""
type="password"
value={apiKey}
readonly
private_
/>
</div>
<button
type="button"
class="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
title="Copy"
onclick={copyApiKey}
>
<Copy size={18} />
</button>
<form method="POST" action="?/regenerateApiKey" use:enhance={() => {
regeneratingKey = true;
return async ({ update }) => {
await update();
regeneratingKey = false;
};
}}>
<button
type="submit"
class="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
title="Regenerate"
disabled={regeneratingKey}
>
<RefreshCw size={18} class={regeneratingKey ? 'animate-spin' : ''} />
</button>
</form>
</div>
{:else}
<div class="flex items-center gap-4">
<p class="text-sm text-neutral-500 dark:text-neutral-400">No API key configured</p>
<form method="POST" action="?/regenerateApiKey" use:enhance>
<Button type="submit" variant="secondary" size="sm" text="Generate Key" />
</form>
</div>
{/if}
</div>
</div>
<!-- Active Sessions -->
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div class="flex items-start justify-between border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div>
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Active Sessions</h2>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Manage your logged-in sessions across devices
</p>
</div>
{#if data.sessions.length > 1}
<form method="POST" action="?/revokeOtherSessions" use:enhance={() => {
return async ({ update }) => {
await update();
await invalidateAll();
};
}}>
<Button
type="submit"
variant="secondary"
size="xs"
icon={LogOut}
iconColor="text-red-500"
text="Revoke Others"
/>
</form>
{/if}
</div>
<div class="p-6">
{#if data.sessions.length > 0}
<Table
columns={sessionColumns}
data={data.sessions}
compact
actionsHeader="Status"
>
<svelte:fragment slot="actions" let:row>
{#if row.isCurrent}
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200">Current</span>
{:else}
<form method="POST" action="?/revokeSession" use:enhance={() => {
return async ({ update }) => {
await update();
await invalidateAll();
};
}}>
<input type="hidden" name="sessionId" value={row.id} />
<TableActionButton
icon={LogOut}
title="Revoke session"
variant="danger"
size="sm"
type="submit"
/>
</form>
{/if}
</svelte:fragment>
</Table>
{:else}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No active sessions</p>
{/if}
</div>
</div>
</div>
</div>

View File

@@ -29,7 +29,8 @@ const config = {
$utils: './src/lib/server/utils',
$notifications: './src/lib/server/notifications',
$cache: './src/lib/server/utils/cache',
$sync: './src/lib/server/sync'
$sync: './src/lib/server/sync',
$auth: './src/lib/server/utils/auth'
}
}
};

View File

@@ -10,6 +10,7 @@ export default defineConfig({
plugins: [deno(), tailwindcss(), sveltekit()],
server: {
port: 6969,
host: true,
watch: {
// Ignore temporary files created by editors
ignored: ['**/*.tmp.*', '**/*~', '**/.#*']