- 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
33 KiB
Auth Implementation Plan
Overview
Two auth methods (implementing both):
- Basic Auth - Username/password, session cookies, API key
- OIDC - Login via external provider (Google, Authentik, Keycloak, etc.)
Which method is used depends on the AUTH environment variable.
Session Configuration
- Duration: 7 days (like Sonarr)
- Sliding expiration: Reset expiry to 7 days from now, but only when past halfway point
- Days 1-3: No DB update (session still has plenty of time)
- Days 4-7: Extend session to 7 days from now
- This avoids a DB write on every request while keeping active users logged in
- Multiple sessions: Users can be logged in from multiple browsers/devices
AUTH Environment Variable
Single env var controls authentication behavior:
AUTH=on # Default - username/password auth required everywhere
AUTH=local # Bypass auth for local IPs only
AUTH=off # Disable auth - trust external proxy (Authelia/Authentik)
AUTH=oidc # Use OIDC provider for login (requires OIDC_* env vars)
| Value | Behavior |
|---|---|
on |
Username/password authentication (default if not set) |
local |
Skip auth for local IPs, require for external |
off |
No auth checks - trust reverse proxy handles it |
oidc |
Login via OIDC provider (Google, Authentik, etc.) |
This is intentionally an env var, not a UI setting (like Sonarr/Radarr's XML config). Users must deliberately configure it.
Add to src/lib/server/utils/config/config.ts:
export type AuthMode = 'on' | 'local' | 'off' | 'oidc';
class Config {
// ... existing fields ...
public readonly authMode: AuthMode;
constructor() {
// ... existing code ...
// 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';
}
}
Then in middleware, use config.authMode instead of reading env var directly.
Local addresses (for AUTH=local):
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)
Phase 1: Basic Auth
Database Migrations
Migration 036: Create auth tables (all tables in one migration)
-- 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, -- UUID
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, -- 7 days
api_key TEXT, -- For programmatic access
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Insert default settings with generated API key
INSERT INTO auth_settings (id, api_key) VALUES (1, lower(hex(randomblob(16))));
Auth Utilities
src/lib/server/utils/auth/password.ts
// Using @felix/bcrypt (Rust FFI)
export function hashPassword(password: string): Promise<string>;
export function verifyPassword(password: string, hash: string): Promise<boolean>;
Session cookies - Use SvelteKit's built-in event.cookies API:
// Set session cookie
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
expires
});
// Read session cookie
const sessionId = cookies.get('session');
// Delete session cookie (logout)
cookies.delete('session', { path: '/' });
No custom session utility needed - browsers handle cookies automatically.
src/lib/server/utils/auth/apiKey.ts
export function generateApiKey(): string; // Random secure token
src/lib/server/utils/auth/network.ts
// isLocalAddress: Based on Sonarr - checks RFC 1918 + link-local + IPv6 local
export function isLocalAddress(ip: string): boolean;
// getClientIp: Based on Overseerr - checks common proxy headers in order:
// x-forwarded-for, x-real-ip, x-client-ip, cf-connecting-ip, etc.
// No configuration needed - works automatically behind most proxies
export function getClientIp(event: RequestEvent): string;
src/lib/server/utils/auth/middleware.ts
Core auth logic - keeps hooks.server.ts thin:
import type { RequestEvent } from '@sveltejs/kit';
import { config } from '$config';
import { authSettingsQueries } from '$db/queries/authSettings.ts';
import { sessionsQueries } from '$db/queries/sessions.ts';
import { usersQueries } from '$db/queries/users.ts';
import { isLocalAddress, getClientIp } from './network.ts';
export interface AuthState {
needsSetup: boolean;
user: User | null;
session: Session | null;
skipAuth: boolean; // true when AUTH=off or AUTH=local+local IP
}
const PUBLIC_PATHS = ['/auth/login', '/auth/setup', '/api/v1/health'];
export function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'));
}
export function getAuthState(event: RequestEvent): AuthState {
const settings = authSettingsQueries.get();
const hasUsers = usersQueries.exists();
// AUTH=off - skip all auth (trust external proxy)
if (config.authMode === 'off') {
return {
needsSetup: false,
user: null,
session: null,
skipAuth: true
};
}
// AUTH=local - skip auth for local IPs
if (config.authMode === 'local' && isLocalAddress(getClientIp(event))) {
return {
needsSetup: !hasUsers,
user: null,
session: null,
skipAuth: true
};
}
// Check API key
const apiKey = request.headers.get('X-Api-Key');
if (apiKey && settings.api_key && apiKey === settings.api_key) {
return {
needsSetup: false,
user: { id: 0, username: 'api' } as User,
session: null,
skipAuth: false
};
}
// Check session cookie (using SvelteKit's cookies API)
const sessionId = event.cookies.get('session');
const session = sessionId ? sessionsQueries.getValidById(sessionId) : null;
const user = session ? usersQueries.getById(session.user_id) : null;
return {
needsSetup: !hasUsers,
user,
session,
skipAuth: false
};
}
// Sliding expiration: extend session if past halfway point
export function maybeExtendSession(session: Session, durationHours: number): void {
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);
}
}
// Clean expired sessions - call on startup
export function cleanupExpiredSessions(): number {
return sessionsQueries.deleteExpired();
}
Auth Middleware (hooks.server.ts)
Thin wrapper that calls auth utilities:
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import {
getAuthState,
isPublicPath,
cleanupExpiredSessions,
maybeExtendSession
} from '$utils/auth/middleware.ts';
import { authSettingsQueries } from '$db/queries/authSettings.ts';
// Clean expired sessions on startup
cleanupExpiredSessions();
export const handle: Handle = async ({ event, resolve }) => {
const auth = getAuthState(event);
// AUTH=off or AUTH=local with local IP - skip all auth
if (auth.skipAuth) {
return resolve(event);
}
// First-run setup flow
if (auth.needsSetup) {
if (event.url.pathname === '/auth/setup') {
return resolve(event);
}
throw redirect(303, '/auth/setup');
}
// Block setup 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
if (!auth.user) {
if (event.url.pathname.startsWith('/api')) {
return new Response('Unauthorized', { status: 401 });
}
throw redirect(303, '/auth/login');
}
// Sliding expiration: extend session if past halfway point
if (auth.session) {
const settings = authSettingsQueries.get();
maybeExtendSession(auth.session, settings.session_duration_hours);
}
// Authenticated - attach to locals
event.locals.user = auth.user;
event.locals.session = auth.session;
return resolve(event);
};
Query Modules
src/lib/server/db/queries/users.ts
export interface User {
id: number;
username: string;
password_hash: string;
created_at: string;
updated_at: string;
}
export const usersQueries = {
exists(): boolean {
const result = db.queryFirst<{ count: number }>(
'SELECT COUNT(*) as count FROM users'
);
return (result?.count ?? 0) > 0;
},
getById(id: number): User | undefined {
return db.queryFirst<User>('SELECT * FROM users WHERE id = ?', id);
},
getByUsername(username: string): User | undefined {
return db.queryFirst<User>('SELECT * FROM users WHERE username = ?', username);
},
getAllUsernames(): string[] {
// For login analysis - typo detection
const results = db.query<{ username: string }>(
"SELECT username FROM users WHERE username NOT LIKE 'oidc:%'"
);
return results.map((r) => r.username);
},
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;
},
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;
}
};
src/lib/server/db/queries/sessions.ts
export interface Session {
id: string;
user_id: number;
expires_at: string;
created_at: string;
}
export const sessionsQueries = {
create(userId: number, durationHours: number): 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) VALUES (?, ?, ?)',
id, userId, expiresAt.toISOString()
);
return id;
},
getById(id: string): Session | undefined {
// For logout logging - get session regardless of expiration
return db.queryFirst<Session>('SELECT * FROM sessions WHERE id = ?', id);
},
getValidById(id: string): Session | undefined {
return db.queryFirst<Session>(
`SELECT * FROM sessions
WHERE id = ? AND datetime(expires_at) > datetime('now')`,
id
);
},
deleteById(id: string): boolean {
const affected = db.execute('DELETE FROM sessions WHERE id = ?', id);
return affected > 0;
},
deleteByUserId(userId: number): number {
return db.execute('DELETE FROM sessions WHERE user_id = ?', userId);
},
deleteExpired(): number {
return db.execute(
`DELETE FROM sessions WHERE datetime(expires_at) <= datetime('now')`
);
},
getByUserId(userId: number): Session[] {
return db.query<Session>(
'SELECT * FROM sessions WHERE user_id = ? ORDER BY created_at DESC',
userId
);
},
// Sliding expiration: extend session by duration from now
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 = ? WHERE id = ?',
expiresAt.toISOString(), id
);
return affected > 0;
}
};
src/lib/server/db/queries/authSettings.ts
export interface AuthSettings {
id: number;
session_duration_hours: number;
api_key: string | null;
created_at: string;
updated_at: string;
}
export const authSettingsQueries = {
get(): AuthSettings {
return db.queryFirst<AuthSettings>(
'SELECT * FROM auth_settings WHERE id = 1'
)!;
},
update(input: {
sessionDurationHours?: number;
apiKey?: string | null;
}): 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);
const affected = db.execute(
`UPDATE auth_settings SET ${updates.join(', ')} WHERE id = ?`,
...params
);
return affected > 0;
},
regenerateApiKey(): string {
const apiKey = crypto.randomUUID();
this.update({ apiKey });
return apiKey;
},
clearApiKey(): void {
this.update({ apiKey: null });
}
};
Routes
/auth/setup - First-run setup (create admin account)
Only accessible when no users exist.
src/routes/auth/setup/
├── +page.svelte -- Form: username, password, confirm password
└── +page.server.ts -- Load: redirect if users exist. Action: create user, login, redirect
/auth/login - Login page
src/routes/auth/login/
├── +page.svelte -- Form: username, password
└── +page.server.ts -- Action: verify password, create session, set cookie, redirect
Example +page.server.ts:
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 { verifyPassword } from '$utils/auth/password.ts';
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username') as string;
const password = data.get('password') as string;
if (!username || !password) {
return fail(400, { error: 'Username and password required' });
}
const user = usersQueries.getByUsername(username);
if (!user) {
return fail(400, { error: 'Invalid credentials' });
}
const valid = await verifyPassword(password, user.password_hash);
if (!valid) {
return fail(400, { error: 'Invalid credentials' });
}
const settings = authSettingsQueries.get();
const sessionId = sessionsQueries.create(user.id, settings.session_duration_hours);
const expires = new Date(Date.now() + settings.session_duration_hours * 60 * 60 * 1000);
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
expires
});
throw redirect(303, '/');
}
};
/settings/security - Auth settings UI
src/routes/settings/security/
├── +page.svelte -- Change password, view sessions, API key management
└── +page.server.ts -- Load settings, actions for password/API key/logout
Features:
- Change password form
- View active sessions (with logout button for each)
- "Logout all other sessions" button
- API Key: show (masked), copy, regenerate, delete
File Structure
src/lib/server/
├── db/
│ ├── migrations/
│ │ ├── 036_create_auth_tables.ts # Users, sessions, auth_settings
│ │ └── 037_add_session_metadata.ts # Session metadata columns
│ └── queries/
│ ├── users.ts
│ ├── sessions.ts
│ └── authSettings.ts
└── utils/
└── auth/
├── password.ts # bcrypt hash/verify
├── apiKey.ts # API key generation
├── network.ts # Local IP detection
├── middleware.ts # Core auth logic
├── userAgent.ts # User agent parsing
├── loginAnalysis.ts # Login failure analysis
└── oidc.ts # OIDC utilities
src/routes/
├── auth/
│ ├── setup/
│ │ ├── +page.svelte
│ │ └── +page.server.ts
│ ├── login/
│ │ ├── +page.svelte
│ │ └── +page.server.ts
│ └── logout/
│ └── +server.ts # Logout endpoint
└── settings/
└── security/
├── +page.svelte
└── +page.server.ts
src/hooks.server.ts -- Auth middleware (thin, calls utils)
Implementation Order
- ✅ Migrations (users, sessions, auth_settings) -
036_create_auth_tables.ts - ✅ Password utility (hash/verify) -
$auth/password.ts - ✅ API key utility (generate) -
$auth/apiKey.ts - ✅ Network utility (isLocalAddress, getClientIp) -
$auth/network.ts - ✅ Query modules (users, sessions, authSettings) -
$db/queries/ - ✅ Auth middleware utility + update hooks.server.ts
- ✅
/auth/setuppage (first-run) - ✅
/auth/loginpage - ✅
/auth/logoutendpoint -src/routes/auth/logout/+server.ts - ✅
/settings/securitypage - ✅ Session metadata migration -
037_add_session_metadata.ts
Phase 1.5: Session Metadata
Add rich session information for better session management UI.
Migration 037: Add session metadata
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 DEFAULT CURRENT_TIMESTAMP;
User Agent Parser
src/lib/server/utils/auth/userAgent.ts
Simple regex-based parser (no heavy libraries):
interface ParsedUserAgent {
browser: string; // "Chrome 120", "Firefox 121", "Safari 17"
os: string; // "Windows 11", "macOS 14", "Ubuntu", "iOS 17"
deviceType: string; // "Desktop", "Mobile", "Tablet"
}
export function parseUserAgent(ua: string): ParsedUserAgent;
Updated Session Creation
When creating sessions (login, setup), capture:
- IP address from request headers
- User agent string
- Parsed browser/OS/device
Updated Session Display
Security page shows:
| Created | Last Active | Browser | OS | Device | IP | Status |
|---|---|---|---|---|---|---|
| Jan 25, 10:30 | 2 min ago | Chrome 120 | Windows 11 | Desktop | 192.168.1.5 | Current |
Update last_active_at
In middleware, when extending session (sliding expiration), also update last_active_at.
Phase 2: OIDC
Overview
OIDC (OpenID Connect) lets users login via external providers (Google, Authentik, Keycloak, etc.) instead of username/password. We implement generic OIDC - works with any compliant provider, no provider-specific code needed.
Environment Variables
AUTH=oidc
OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
OIDC_CLIENT_ID=profilarr
OIDC_CLIENT_SECRET=secret
Env vars are standard for OIDC config because the client secret is sensitive (shouldn't be in database/config files).
Add to src/lib/server/utils/config/config.ts:
class Config {
// ... existing fields ...
public readonly oidc: {
discoveryUrl: string | null;
clientId: string | null;
clientSecret: string | null;
};
constructor() {
// ... existing code ...
// 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
};
}
}
The Flow
1. User clicks "Login with OIDC" on /login
↓
2. GET /auth/oidc/login
- Fetch discovery document (cached)
- Generate state token (CSRF protection)
- Redirect to provider's authorization endpoint
↓
3. User logs in at provider (if not already)
↓
4. Provider redirects to /auth/oidc/callback?code=xxx&state=xxx
↓
5. POST to provider's token endpoint (server-to-server)
- Exchange code for tokens using client secret
- Receive: access_token, id_token (JWT with user info)
↓
6. Decode & verify id_token
- Extract: sub (user ID), email, name
↓
7. Create session, set cookie, redirect to /
Discovery Document
The discovery URL returns JSON telling us everything:
{
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json"
}
This is why it works with any OIDC provider - we read their config dynamically.
Routes
/auth/oidc/login - Initiate OIDC flow
// src/routes/auth/oidc/login/+server.ts
import { redirect } from '@sveltejs/kit';
import { config } from '$config';
import { getDiscoveryDocument, generateState } from '$utils/auth/oidc.ts';
export async function GET({ cookies }) {
const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl!);
const state = generateState();
cookies.set('oidc_state', state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 10 // 10 minutes
});
const params = new URLSearchParams({
client_id: config.oidc.clientId!,
redirect_uri: `${config.serverUrl}/auth/oidc/callback`,
response_type: 'code',
scope: 'openid email profile',
state
});
throw redirect(302, `${discovery.authorization_endpoint}?${params}`);
}
/auth/oidc/callback - Handle provider response
// src/routes/auth/oidc/callback/+server.ts
import { redirect, error } from '@sveltejs/kit';
import { config } from '$config';
import {
getDiscoveryDocument,
exchangeCode,
verifyIdToken
} from '$utils/auth/oidc.ts';
import { sessionsQueries } from '$db/queries/sessions.ts';
import { authSettingsQueries } from '$db/queries/authSettings.ts';
export async function GET({ url, cookies }) {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const savedState = cookies.get('oidc_state');
// Verify state (CSRF protection)
if (!state || state !== savedState) {
throw error(400, 'Invalid state');
}
cookies.delete('oidc_state', { path: '/' });
if (!code) {
throw error(400, 'No code provided');
}
// Exchange code for tokens
const discovery = await getDiscoveryDocument(config.oidc.discoveryUrl!);
const tokens = await exchangeCode(discovery.token_endpoint, code, {
clientId: config.oidc.clientId!,
clientSecret: config.oidc.clientSecret!,
redirectUri: `${config.serverUrl}/auth/oidc/callback`
});
// Verify and decode ID token
const claims = await verifyIdToken(tokens.id_token, discovery.jwks_uri);
// Create session (user ID 0 for OIDC users - single user app)
const settings = authSettingsQueries.get();
const sessionId = sessionsQueries.create(0, settings.session_duration_hours);
const expires = new Date(Date.now() + settings.session_duration_hours * 60 * 60 * 1000);
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
expires
});
throw redirect(303, '/');
}
OIDC Utility
src/lib/server/utils/auth/oidc.ts
interface DiscoveryDocument {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri: string;
}
// Cache discovery document (doesn't change often)
let cachedDiscovery: { url: string; doc: DiscoveryDocument; expires: number } | null = null;
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);
const doc = await response.json() as DiscoveryDocument;
cachedDiscovery = {
url,
doc,
expires: Date.now() + 60 * 60 * 1000 // Cache for 1 hour
};
return doc;
}
export function generateState(): string {
return crypto.randomUUID();
}
export async function exchangeCode(
tokenEndpoint: string,
code: string,
opts: { clientId: string; clientSecret: string; redirectUri: string }
): Promise<{ access_token: string; id_token: string }> {
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) {
throw new Error(`Token exchange failed: ${response.status}`);
}
return response.json();
}
export async function verifyIdToken(
idToken: string,
jwksUri: string
): Promise<{ sub: string; email?: string; name?: string }> {
// Decode JWT (header.payload.signature)
const [, payloadB64] = idToken.split('.');
const payload = JSON.parse(atob(payloadB64));
// TODO: Verify signature using JWKS (for production)
// For now, trust the token since it came from server-to-server exchange
return {
sub: payload.sub,
email: payload.email,
name: payload.name
};
}
Update Middleware
// In getAuthState() - add OIDC handling
if (config.authMode === 'oidc') {
// OIDC still uses sessions - check session cookie
const sessionId = event.cookies.get('session');
const session = sessionId ? sessionsQueries.getValidById(sessionId) : null;
return {
needsSetup: false, // No setup needed for OIDC
user: session ? { id: 0, username: 'oidc' } as User : null,
session,
skipAuth: false
};
}
Update Login Page
When AUTH=oidc, the login page shows "Login with OIDC" button instead of username/password form:
<!-- src/routes/login/+page.svelte -->
<script>
export let data;
</script>
{#if data.authMode === 'oidc'}
<a href="/auth/oidc/login" class="btn btn-primary">
Login with OIDC
</a>
{:else}
<!-- Username/password form -->
{/if}
File Structure (Updated)
src/lib/server/utils/auth/
├── password.ts # bcrypt hash/verify
├── apiKey.ts # API key generation
├── network.ts # Local IP detection
├── middleware.ts # Core auth logic
├── userAgent.ts # User agent parsing
├── loginAnalysis.ts # Login failure analysis (typo/attack detection)
└── oidc.ts # OIDC discovery, token exchange
src/routes/auth/
└── oidc/
├── login/
│ └── +server.ts
└── callback/
└── +server.ts
Implementation Order (Full)
Phase 1: Basic Auth ✅
- ✅ Migrations (users, sessions, auth_settings)
- ✅ Password utility (hash/verify)
- ✅ API key utility (generate)
- ✅ Network utility (isLocalAddress, getClientIp)
- ✅ Query modules (users, sessions, authSettings)
- ✅ Auth middleware utility + update hooks.server.ts
- ✅
/auth/setuppage (first-run) - ✅
/auth/loginpage - ✅
/auth/logoutendpoint - ✅
/settings/securitypage
Phase 1.5: Session Metadata ✅ 11. ✅ Migration 037 (session metadata columns) 12. ✅ User agent parser utility 13. ✅ Update session creation to capture metadata 14. ✅ Update security page to display metadata
Phase 2: OIDC ✅
15. ✅ OIDC utility (discovery, token exchange, verify)
16. ✅ /auth/oidc/login route
17. ✅ /auth/oidc/callback route
18. ✅ Update login page with OIDC button
Phase 3: Auth Logging ✅ 19. ✅ Login analysis utility (typo detection, attack username detection) 20. ✅ Login success/failure logging 21. ✅ Logout logging 22. ✅ Account creation logging 23. ✅ OIDC flow logging 24. ✅ API key and session logging 25. ✅ Unauthorized access and cleanup logging
Phase 3: Auth Logging ✅
Source Tags
Use Auth as the source for all auth-related logging, with sub-categories:
Auth- general auth eventsAuth:Login- login attempts (password-based)Auth:Session- session managementAuth:OIDC- OIDC flow eventsAuth:APIKey- API key usage
Login Analysis
Failed login attempts are analyzed to distinguish between typos and potential attacks:
src/lib/server/utils/auth/loginAnalysis.ts
isCommonAttackUsername(username)- checks against common attack usernames (admin, root, etc.)findSimilarUsername(attempted, existing)- Levenshtein distance for typo detection (≤2 edits)analyzeLoginFailure()/formatLoginFailure()- combines analysis for logging
Example log messages:
WARN Login failed for 'admi': unknown user (similar to 'admin')
WARN Login failed for 'root': unknown user (common attack username)
WARN Login failed for 'admin': invalid password
INFO Level (User-relevant events)
| Event | Source | What to Log | Location |
|---|---|---|---|
| Login success | Auth:Login |
username, IP, browser/device | login/+page.server.ts |
| Logout | Auth:Session |
username, user ID | logout/+server.ts |
| Account created | Auth |
username, IP | setup/+page.server.ts |
| Password changed | Auth |
username, user ID | settings/security/+page.server.ts |
| API key authenticated | Auth:APIKey |
IP, endpoint | middleware.ts |
| API key regenerated | Auth:APIKey |
- | settings/security/+page.server.ts |
| Session revoked | Auth:Session |
session ID (partial) | settings/security/+page.server.ts |
| Other sessions revoked | Auth:Session |
user ID, count | settings/security/+page.server.ts |
| Session cleanup | Auth:Session |
count deleted | hooks.server.ts |
| OIDC login success | Auth:OIDC |
sub, IP, browser/device | oidc/callback/+server.ts |
WARN Level (Security-relevant)
| Event | Source | What to Log | Location |
|---|---|---|---|
| Login failed | Auth:Login |
username, IP, reason (with analysis) | login/+page.server.ts |
| Invalid API key | Auth:APIKey |
IP, endpoint, key (last 4 chars) | middleware.ts |
| OIDC state mismatch | Auth:OIDC |
IP (possible CSRF) | oidc/callback/+server.ts |
| OIDC token exchange failed | Auth:OIDC |
IP, error | oidc/callback/+server.ts |
| Unauthorized API access | Auth |
IP, endpoint, method | hooks.server.ts |
ERROR Level (Failures)
| Event | Source | What to Log | Location |
|---|---|---|---|
| OIDC config missing | Auth:OIDC |
which vars missing | oidc/login/+server.ts |
| ID token verification failed | Auth:OIDC |
IP, error | oidc/callback/+server.ts |
DEBUG Level (Dev only)
| Event | Source | What to Log | Location |
|---|---|---|---|
| Session extended | Auth:Session |
user ID | middleware.ts |
| Local IP bypass | Auth |
IP | middleware.ts |
| OIDC flow started | Auth:OIDC |
IP | oidc/login/+server.ts |
Security: What NOT to Log
- Passwords (obviously)
- Full API keys (mask to last 4 chars:
****abcd) - Full session IDs (use partial:
abc12345...) - Full OIDC tokens
Implementation Files
src/lib/server/utils/auth/loginAnalysis.ts- Login failure analysis (typo/attack detection)src/routes/auth/login/+page.server.ts- Login success/failure with analysissrc/routes/auth/logout/+server.ts- Logoutsrc/routes/auth/setup/+page.server.ts- Account creationsrc/routes/auth/oidc/login/+server.ts- OIDC flow start, config errorssrc/routes/auth/oidc/callback/+server.ts- OIDC success/failuressrc/lib/server/utils/auth/middleware.ts- API key, session validation, local bypasssrc/hooks.server.ts- Unauthorized access, session cleanup countsrc/routes/settings/security/+page.server.ts- Password change, API key regen, session revocation