From fc8e80186d93b8bac856f01a50e48b2ecb44f6b8 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Sun, 22 Feb 2026 05:17:45 +0000 Subject: [PATCH] Add local authentication option (#37) Co-authored-by: Shaun Campbell Reviewed-on: https://cloud.campbellwireless.net/git/campbellwireless/trips/pulls/37 Co-authored-by: AI Agent Co-committed-by: AI Agent --- .env.example | 9 + README.md | 13 ++ bun.lock | 9 + package.json | 3 +- src/auth.ts | 32 +++- src/lib/server/admin/auth.test.ts | 81 +++++++++ src/lib/server/admin/auth.ts | 69 +++++++- src/lib/server/db/migrations.ts | 9 + src/lib/server/local-auth.test.ts | 84 ++++++++++ src/lib/server/local-auth.ts | 154 ++++++++++++++++++ src/lib/server/local-credentials.ts | 67 ++++++++ src/routes/(protected)/+layout.server.ts | 8 +- .../(protected)/admin/api/airlines/+server.ts | 8 +- .../(protected)/admin/api/airports/+server.ts | 8 +- .../(protected)/admin/api/cities/+server.ts | 8 +- .../admin/api/countries/+server.ts | 8 +- .../admin/api/operator-tour-days/+server.ts | 8 +- .../admin/api/operator-tours/+server.ts | 8 +- .../admin/api/provider-search/+server.ts | 2 +- .../admin/api/tour-operators/+server.ts | 8 +- .../(protected)/admin/api/users/+server.ts | 6 +- .../[operatorId]/+page.server.ts | 10 +- .../tours/[tourId]/+page.server.ts | 14 +- src/routes/login/+page.server.ts | 16 +- src/routes/login/+page.svelte | 87 ++++++++-- src/routes/login/page.server.test.ts | 37 +++++ 26 files changed, 695 insertions(+), 71 deletions(-) create mode 100644 src/lib/server/admin/auth.test.ts create mode 100644 src/lib/server/local-auth.test.ts create mode 100644 src/lib/server/local-auth.ts create mode 100644 src/lib/server/local-credentials.ts create mode 100644 src/routes/login/page.server.test.ts diff --git a/.env.example b/.env.example index ebcc086..59361a3 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,15 @@ AUTH_URL=https://cloud.campbellwireless.net/trips/auth # Auth.js secret β€” generate with: openssl rand -base64 32 AUTH_SECRET= +# Local auth +LOCAL_AUTH_ENABLED=false +LOCAL_AUTH_ARGON2_MEMORY_KB=65536 +LOCAL_AUTH_ARGON2_TIME_COST=3 +LOCAL_AUTH_ARGON2_PARALLELISM=1 +LOCAL_AUTH_MAX_ATTEMPTS=5 +LOCAL_AUTH_WINDOW_SECONDS=900 +LOCAL_AUTH_LOCKOUT_SECONDS=900 + # Admin β€” comma-separated user IDs (from auth provider) that can access /admin ADMIN_USER_IDS= diff --git a/README.md b/README.md index e2ae38f..3a3818b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,19 @@ bun install bun run dev ``` +## Local authentication + +Local auth is optional and off by default. Enable it with the env vars in `.env.example` and ensure users have a matching row in both `users` and `local_credentials`. + +To seed a local password hash, use Argon2id with the configured parameters and insert it into `local_credentials`: + +```sql +INSERT INTO local_credentials (user_id, password_hash) +VALUES ('', ''); +``` + +Passwords must be at least 12 characters. Avoid storing plaintext passwords anywhere. + ## Quality and Tests ```sh diff --git a/bun.lock b/bun.lock index d6767cd..65e4524 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "trips", "dependencies": { "@auth/sveltekit": "^1.11.1", + "argon2": "^0.41.1", }, "devDependencies": { "@biomejs/biome": "^2.4.4", @@ -153,6 +154,8 @@ "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="], @@ -315,6 +318,8 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "argon2": ["argon2@0.41.1", "", { "dependencies": { "@phc/format": "^1.0.0", "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" } }, "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -509,6 +514,10 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], diff --git a/package.json b/package.json index 3539a01..eb7a9e2 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "vitest": "^4.0.18" }, "dependencies": { - "@auth/sveltekit": "^1.11.1" + "@auth/sveltekit": "^1.11.1", + "argon2": "^0.41.1" } } diff --git a/src/auth.ts b/src/auth.ts index a73dbf9..4fb449b 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,6 +1,8 @@ import { SvelteKitAuth } from '@auth/sveltekit'; +import Credentials from '@auth/core/providers/credentials'; import { env } from '$env/dynamic/private'; import { upsertUserFromAuth } from '$lib/server/users.js'; +import { verifyLocalCredentials } from '$lib/server/local-auth.js'; export const { handle, signIn, signOut } = SvelteKitAuth({ providers: [ @@ -19,11 +21,34 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ email: profile.email as string | undefined }; } - } + }, + Credentials({ + id: 'local', + name: 'Local', + credentials: { + identifier: { label: 'Username or email', type: 'text' }, + password: { label: 'Password', type: 'password' } + }, + async authorize(credentials, request) { + const identifier = String(credentials?.identifier ?? '').trim(); + const password = String(credentials?.password ?? '').trim(); + const ip = + request?.headers?.get?.('x-forwarded-for') ?? + request?.headers?.get?.('x-real-ip') ?? + undefined; + const result = await verifyLocalCredentials(identifier, password, ip); + if (result.status !== 'success') return null; + return { + id: result.user.id, + name: result.user.name, + email: result.user.email ?? undefined + }; + } + }) ], trustHost: true, callbacks: { - jwt({ token, profile }) { + jwt({ token, profile, user }) { const details = profile as | { sub?: string; @@ -43,6 +68,9 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ authSource: 'OIDC - Synology' }); } + if (user?.id) { + token.sub = user.id as string; + } return token; }, session({ session, token }) { diff --git a/src/lib/server/admin/auth.test.ts b/src/lib/server/admin/auth.test.ts new file mode 100644 index 0000000..b80dc55 --- /dev/null +++ b/src/lib/server/admin/auth.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setupTestDb } from '../../../tests/helpers.js'; +import type { Database } from '../db/types.js'; +import { upsertUserFromAuth } from '../users.js'; +import { isAdminUser, requireAdmin } from './auth.js'; + +let database: Database; +const originalAdminUserIds = process.env.ADMIN_USER_IDS; + +beforeEach(() => { + database = setupTestDb(); + process.env.ADMIN_USER_IDS = ''; +}); + +afterEach(() => { + process.env.ADMIN_USER_IDS = originalAdminUserIds; + database.close(); +}); + +describe('isAdminUser', () => { + it('allows direct ID matches', () => { + process.env.ADMIN_USER_IDS = 'oidc-sub-123'; + expect(isAdminUser('oidc-sub-123')).toBe(true); + }); + + it('allows username matches for known users', () => { + upsertUserFromAuth({ + id: 'oidc-sub-123', + username: 'shaun', + fullName: 'Shaun Campbell', + email: 'shaun@example.com' + }); + process.env.ADMIN_USER_IDS = 'shaun'; + expect(isAdminUser('oidc-sub-123')).toBe(true); + }); + + it('allows email matches for known users', () => { + upsertUserFromAuth({ + id: 'oidc-sub-123', + username: 'shaun', + fullName: 'Shaun Campbell', + email: 'shaun@example.com' + }); + process.env.ADMIN_USER_IDS = 'shaun@example.com'; + expect(isAdminUser('oidc-sub-123')).toBe(true); + }); + + it('allows email local-part matches from session user', () => { + process.env.ADMIN_USER_IDS = 'shaun'; + expect(isAdminUser({ id: 'oidc-sub-123', email: 'shaun@example.com' })).toBe(true); + }); +}); + +describe('requireAdmin', () => { + it('throws when not authenticated', () => { + process.env.ADMIN_USER_IDS = 'shaun'; + expect(() => requireAdmin(undefined)).toThrow('Not authenticated'); + }); + + it('throws when user is not admin', () => { + process.env.ADMIN_USER_IDS = 'shaun'; + expect(() => requireAdmin('someone-else')).toThrow('Admin access required'); + }); + + it('does not throw when username match grants admin access', () => { + upsertUserFromAuth({ + id: 'oidc-sub-123', + username: 'shaun', + fullName: 'Shaun Campbell' + }); + process.env.ADMIN_USER_IDS = 'shaun'; + expect(() => requireAdmin('oidc-sub-123')).not.toThrow(); + }); + + it('does not throw when session user email local-part matches', () => { + process.env.ADMIN_USER_IDS = 'shaun'; + expect(() => + requireAdmin({ id: 'oidc-sub-123', email: 'shaun@example.com', name: 'Shaun Campbell' }) + ).not.toThrow(); + }); +}); diff --git a/src/lib/server/admin/auth.ts b/src/lib/server/admin/auth.ts index ec6df08..95c31a7 100644 --- a/src/lib/server/admin/auth.ts +++ b/src/lib/server/admin/auth.ts @@ -1,12 +1,73 @@ import { env } from '$env/dynamic/private'; +import { db } from '$lib/server/db/index.js'; -export function requireAdmin(userId: string | undefined): void { - if (!userId) throw new Error('Not authenticated'); - const adminIds = (env.ADMIN_USER_IDS ?? '') +const getAdminIdentifiers = (): string[] => + (process.env.ADMIN_USER_IDS ?? env.ADMIN_USER_IDS ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean); - if (adminIds.length === 0 || !adminIds.includes(userId)) { + +type AdminPrincipal = + | string + | { + id?: string | null; + name?: string | null; + email?: string | null; + } + | undefined; + +const normalize = (value: string): string => value.trim().toLowerCase(); + +const getPrincipalValues = (principal: AdminPrincipal): { userId?: string; values: string[] } => { + if (!principal) return { values: [] }; + if (typeof principal === 'string') { + const value = principal.trim(); + return value ? { userId: value, values: [value] } : { values: [] }; + } + + const values = [principal.id, principal.name, principal.email] + .filter((value): value is string => Boolean(value?.trim())) + .map((value) => value.trim()); + const localPart = principal.email?.split('@')[0]?.trim(); + if (localPart) values.push(localPart); + + const userId = principal.id?.trim(); + return { userId, values }; +}; + +export function isAdminUser(principal: AdminPrincipal): boolean { + const { userId, values } = getPrincipalValues(principal); + if (values.length === 0) return false; + + const adminIds = getAdminIdentifiers(); + if (adminIds.length === 0) return false; + + const normalizedAdminIds = new Set(adminIds.map(normalize)); + for (const value of values) { + if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true; + } + + if (!userId) return false; + + const user = db.get<{ username: string; email: string | null }>( + 'SELECT username, email FROM users WHERE id = ?', + [userId] + ); + if (!user) return false; + + const dbValues = [user.username, user.email, user.email?.split('@')[0]] + .filter((value): value is string => Boolean(value?.trim())) + .map((value) => value.trim()); + for (const value of dbValues) { + if (adminIds.includes(value) || normalizedAdminIds.has(normalize(value))) return true; + } + + return false; +} + +export function requireAdmin(principal: AdminPrincipal): void { + if (!principal) throw new Error('Not authenticated'); + if (!isAdminUser(principal)) { throw new Error('Admin access required'); } } diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 3e65b0f..cf99467 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -65,6 +65,15 @@ export function runMigrations(db: Database): void { ) `); + db.run(` + CREATE TABLE IF NOT EXISTS local_credentials ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.run(` CREATE TABLE IF NOT EXISTS cities ( id INTEGER PRIMARY KEY, diff --git a/src/lib/server/local-auth.test.ts b/src/lib/server/local-auth.test.ts new file mode 100644 index 0000000..16a83b7 --- /dev/null +++ b/src/lib/server/local-auth.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupTestDb } from '../../tests/helpers.js'; +import type { Database } from './db/types.js'; +import { setLocalCredentialPassword } from './local-credentials.js'; +import { resetLocalAuthRateLimits, verifyLocalCredentials } from './local-auth.js'; +import { upsertUserFromAuth } from './users.js'; + +let database: Database; + +beforeEach(() => { + process.env.LOCAL_AUTH_ENABLED = 'true'; + process.env.LOCAL_AUTH_ARGON2_MEMORY_KB = '8192'; + process.env.LOCAL_AUTH_ARGON2_TIME_COST = '2'; + process.env.LOCAL_AUTH_ARGON2_PARALLELISM = '1'; + process.env.LOCAL_AUTH_MAX_ATTEMPTS = '2'; + process.env.LOCAL_AUTH_WINDOW_SECONDS = '60'; + process.env.LOCAL_AUTH_LOCKOUT_SECONDS = '10'; + database = setupTestDb(); + resetLocalAuthRateLimits(); +}); + +afterEach(() => { + vi.useRealTimers(); + resetLocalAuthRateLimits(); + database.close(); +}); + +describe('verifyLocalCredentials', () => { + it('authenticates valid local credentials', async () => { + upsertUserFromAuth({ + id: 'user-1', + username: 'jdoe', + fullName: 'Jane Doe', + email: 'jane@example.com', + authSource: 'Local' + }); + await setLocalCredentialPassword('user-1', 'averysecurepassword'); + + const result = await verifyLocalCredentials('jdoe', 'averysecurepassword', '127.0.0.1'); + + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.user.id).toBe('user-1'); + expect(result.user.email).toBe('jane@example.com'); + } + }); + + it('returns invalid for bad credentials and locks after max attempts', async () => { + upsertUserFromAuth({ id: 'user-2', username: 'sally', fullName: 'Sally Sample' }); + await setLocalCredentialPassword('user-2', 'averysecurepassword'); + + const first = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1'); + const second = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1'); + const third = await verifyLocalCredentials('sally', 'wrong-password', '10.0.0.1'); + + expect(first.status).toBe('invalid'); + expect(second.status).toBe('invalid'); + expect(third.status).toBe('locked'); + }); + + it('clears lockout after window expires', async () => { + upsertUserFromAuth({ id: 'user-3', username: 'morgan', fullName: 'Morgan West' }); + await setLocalCredentialPassword('user-3', 'averysecurepassword'); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + + await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2'); + await verifyLocalCredentials('morgan', 'wrong-password', '10.0.0.2'); + const locked = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2'); + + expect(locked.status).toBe('locked'); + + vi.advanceTimersByTime(11_000); + const after = await verifyLocalCredentials('morgan', 'averysecurepassword', '10.0.0.2'); + + expect(after.status).toBe('success'); + }); + + it('rejects when local auth is disabled', async () => { + process.env.LOCAL_AUTH_ENABLED = 'false'; + const result = await verifyLocalCredentials('anyone', 'password', '10.0.0.3'); + expect(result.status).toBe('disabled'); + }); +}); diff --git a/src/lib/server/local-auth.ts b/src/lib/server/local-auth.ts new file mode 100644 index 0000000..4cd47f8 --- /dev/null +++ b/src/lib/server/local-auth.ts @@ -0,0 +1,154 @@ +import { env } from '$env/dynamic/private'; +import argon2 from 'argon2'; +import { getLocalCredentialByIdentifier, hashLocalPassword } from './local-credentials.js'; + +export interface LocalAuthUser { + id: string; + name: string; + email?: string | null; +} + +export type LocalAuthResult = + | { status: 'success'; user: LocalAuthUser } + | { status: 'invalid' | 'locked' | 'disabled' }; + +interface AttemptState { + count: number; + firstAttemptAt: number; + lockedUntil?: number; +} + +const attemptsByIdentifier = new Map(); +const attemptsByIp = new Map(); +let dummyHashPromise: Promise | null = null; + +const getEnvValue = (key: keyof typeof env): string | undefined => { + return process.env[key] ?? env[key]; +}; + +const getNumberEnv = (value: string | undefined, fallback: number): number => { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const getLocalAuthConfig = () => { + return { + enabled: getEnvValue('LOCAL_AUTH_ENABLED') === 'true', + maxAttempts: getNumberEnv(getEnvValue('LOCAL_AUTH_MAX_ATTEMPTS'), 5), + windowMs: getNumberEnv(getEnvValue('LOCAL_AUTH_WINDOW_SECONDS'), 900) * 1000, + lockoutMs: getNumberEnv(getEnvValue('LOCAL_AUTH_LOCKOUT_SECONDS'), 900) * 1000 + }; +}; + +const normalizeIdentifier = (value: string): string => value.trim().toLowerCase(); +const normalizePassword = (value: string): string => value.trim(); + +const normalizeIp = (value?: string | null): string => { + if (!value) return 'unknown'; + const trimmed = value.trim(); + if (!trimmed) return 'unknown'; + return trimmed.split(',')[0]?.trim() || 'unknown'; +}; + +const getDummyHash = async (): Promise => { + if (!dummyHashPromise) { + dummyHashPromise = hashLocalPassword('invalid-password-placeholder'); + } + return dummyHashPromise; +}; + +const getEffectiveState = (state: AttemptState | undefined, now: number, windowMs: number) => { + if (!state) return { count: 0, firstAttemptAt: now }; + if (state.lockedUntil && state.lockedUntil <= now) { + return { count: 0, firstAttemptAt: now }; + } + if (now - state.firstAttemptAt > windowMs) { + return { count: 0, firstAttemptAt: now }; + } + return { ...state }; +}; + +const isLocked = (state: AttemptState, now: number): boolean => { + return Boolean(state.lockedUntil && state.lockedUntil > now); +}; + +const recordFailure = ( + map: Map, + key: string, + now: number, + windowMs: number, + lockoutMs: number, + maxAttempts: number +): void => { + const state = getEffectiveState(map.get(key), now, windowMs); + const nextCount = state.count + 1; + const nextState: AttemptState = { + count: nextCount, + firstAttemptAt: state.firstAttemptAt + }; + if (nextCount >= maxAttempts) { + nextState.lockedUntil = now + lockoutMs; + } + map.set(key, nextState); +}; + +const clearAttemptState = (map: Map, key: string): void => { + map.delete(key); +}; + +export function resetLocalAuthRateLimits(): void { + attemptsByIdentifier.clear(); + attemptsByIp.clear(); +} + +export async function verifyLocalCredentials( + identifierInput: string, + passwordInput: string, + ipAddress?: string | null +): Promise { + const config = getLocalAuthConfig(); + if (!config.enabled) return { status: 'disabled' }; + + const identifier = normalizeIdentifier(identifierInput ?? ''); + const password = normalizePassword(passwordInput ?? ''); + const identifierKey = identifier || 'unknown'; + const ipKey = normalizeIp(ipAddress); + const now = Date.now(); + + const identifierState = getEffectiveState( + attemptsByIdentifier.get(identifierKey), + now, + config.windowMs + ); + const ipState = getEffectiveState(attemptsByIp.get(ipKey), now, config.windowMs); + const locked = isLocked(identifierState, now) || isLocked(ipState, now); + + const record = identifier ? getLocalCredentialByIdentifier(identifier) : null; + const passwordHash = record?.passwordHash ?? (await getDummyHash()); + const passwordMatches = await argon2.verify(passwordHash, password); + + if (!locked && record && passwordMatches) { + clearAttemptState(attemptsByIdentifier, identifierKey); + clearAttemptState(attemptsByIp, ipKey); + return { + status: 'success', + user: { + id: record.userId, + name: record.fullName || record.username, + email: record.email ?? undefined + } + }; + } + + recordFailure( + attemptsByIdentifier, + identifierKey, + now, + config.windowMs, + config.lockoutMs, + config.maxAttempts + ); + recordFailure(attemptsByIp, ipKey, now, config.windowMs, config.lockoutMs, config.maxAttempts); + + return { status: locked ? 'locked' : 'invalid' }; +} diff --git a/src/lib/server/local-credentials.ts b/src/lib/server/local-credentials.ts new file mode 100644 index 0000000..0e3dd13 --- /dev/null +++ b/src/lib/server/local-credentials.ts @@ -0,0 +1,67 @@ +import { env } from '$env/dynamic/private'; +import argon2 from 'argon2'; +import { db } from './db/index.js'; + +export interface LocalCredentialRecord { + userId: string; + username: string; + fullName: string; + email: string | null; + passwordHash: string; +} + +export const LOCAL_AUTH_MIN_PASSWORD_LENGTH = 12; + +const getNumberEnv = (value: string | undefined, fallback: number): number => { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +export function getLocalCredentialByIdentifier(identifier: string): LocalCredentialRecord | null { + const normalized = identifier.trim(); + if (!normalized) return null; + return ( + db.get( + `SELECT + users.id as userId, + users.username as username, + users.full_name as fullName, + users.email as email, + local_credentials.password_hash as passwordHash + FROM users + INNER JOIN local_credentials ON local_credentials.user_id = users.id + WHERE lower(users.username) = lower(?) + OR (users.email IS NOT NULL AND lower(users.email) = lower(?)) + LIMIT 1`, + [normalized, normalized] + ) ?? null + ); +} + +export async function hashLocalPassword(password: string): Promise { + const trimmed = password.trim(); + if (trimmed.length < LOCAL_AUTH_MIN_PASSWORD_LENGTH) { + throw new Error(`Password must be at least ${LOCAL_AUTH_MIN_PASSWORD_LENGTH} characters`); + } + const memoryCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_MEMORY_KB, 65536); + const timeCost = getNumberEnv(env.LOCAL_AUTH_ARGON2_TIME_COST, 3); + const parallelism = getNumberEnv(env.LOCAL_AUTH_ARGON2_PARALLELISM, 1); + return argon2.hash(trimmed, { + type: argon2.argon2id, + memoryCost, + timeCost, + parallelism + }); +} + +export async function setLocalCredentialPassword(userId: string, password: string): Promise { + const passwordHash = await hashLocalPassword(password); + db.run( + `INSERT INTO local_credentials (user_id, password_hash) + VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET + password_hash = excluded.password_hash, + updated_at = datetime('now')`, + [userId, passwordHash] + ); +} diff --git a/src/routes/(protected)/+layout.server.ts b/src/routes/(protected)/+layout.server.ts index f4335d1..fa16498 100644 --- a/src/routes/(protected)/+layout.server.ts +++ b/src/routes/(protected)/+layout.server.ts @@ -1,6 +1,6 @@ import { redirect } from '@sveltejs/kit'; import { base } from '$app/paths'; -import { env } from '$env/dynamic/private'; +import { isAdminUser } from '$lib/server/admin/auth.js'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async (event) => { @@ -9,11 +9,7 @@ export const load: LayoutServerLoad = async (event) => { redirect(303, `${base}/login`); } - const adminIds = (env.ADMIN_USER_IDS ?? '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const isAdmin = adminIds.length > 0 && session.user.id && adminIds.includes(session.user.id); + const isAdmin = isAdminUser(session.user); return { session, isAdmin }; }; diff --git a/src/routes/(protected)/admin/api/airlines/+server.ts b/src/routes/(protected)/admin/api/airlines/+server.ts index dfaef7a..8818491 100644 --- a/src/routes/(protected)/admin/api/airlines/+server.ts +++ b/src/routes/(protected)/admin/api/airlines/+server.ts @@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js'; import * as data from '$lib/server/admin/data.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const q = event.url.searchParams.get('q') ?? undefined; return json(data.listAirlines(q)); }; export const POST: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { name, country, country_code, iata_code, icao_code } = body as { name?: string; @@ -32,7 +32,7 @@ export const POST: RequestHandler = async (event) => { }; export const PATCH: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { id, name, country, country_code, iata_code, icao_code } = body as { id?: number; @@ -55,7 +55,7 @@ export const PATCH: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const id = parseInt(event.url.searchParams.get('id') ?? ''); if (isNaN(id)) return json({ error: 'id required' }, { status: 400 }); data.deleteAirline(id); diff --git a/src/routes/(protected)/admin/api/airports/+server.ts b/src/routes/(protected)/admin/api/airports/+server.ts index a98347e..10a7fb2 100644 --- a/src/routes/(protected)/admin/api/airports/+server.ts +++ b/src/routes/(protected)/admin/api/airports/+server.ts @@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js'; import * as data from '$lib/server/admin/data.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const q = event.url.searchParams.get('q') ?? undefined; return json(data.listAirports(q)); }; export const POST: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { name, country, country_code, iata_code, icao_code, city, latitude, longitude, timezone } = body as { @@ -43,7 +43,7 @@ export const POST: RequestHandler = async (event) => { }; export const PATCH: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { id, @@ -87,7 +87,7 @@ export const PATCH: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const id = parseInt(event.url.searchParams.get('id') ?? ''); if (isNaN(id)) return json({ error: 'id required' }, { status: 400 }); data.deleteAirport(id); diff --git a/src/routes/(protected)/admin/api/cities/+server.ts b/src/routes/(protected)/admin/api/cities/+server.ts index ccf656c..8aa8a6c 100644 --- a/src/routes/(protected)/admin/api/cities/+server.ts +++ b/src/routes/(protected)/admin/api/cities/+server.ts @@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js'; import * as data from '$lib/server/admin/data.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const q = event.url.searchParams.get('q') ?? undefined; const countryCode = event.url.searchParams.get('country_code') ?? undefined; return json(data.listCities(q, countryCode)); }; export const POST: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { name, country, country_code, population } = body as { name?: string; @@ -26,7 +26,7 @@ export const POST: RequestHandler = async (event) => { }; export const PATCH: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { id, name, country, country_code, population } = body as { id?: number; @@ -43,7 +43,7 @@ export const PATCH: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const id = parseInt(event.url.searchParams.get('id') ?? ''); if (isNaN(id)) return json({ error: 'id required' }, { status: 400 }); data.deleteCity(id); diff --git a/src/routes/(protected)/admin/api/countries/+server.ts b/src/routes/(protected)/admin/api/countries/+server.ts index 80f25ba..52d7fb6 100644 --- a/src/routes/(protected)/admin/api/countries/+server.ts +++ b/src/routes/(protected)/admin/api/countries/+server.ts @@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js'; import * as data from '$lib/server/admin/data.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const q = event.url.searchParams.get('q') ?? undefined; return json(data.listCountries(q)); }; export const POST: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { name, country_code } = body as { name?: string; country_code?: string }; if (!name?.trim() || !country_code?.trim()) { @@ -20,7 +20,7 @@ export const POST: RequestHandler = async (event) => { }; export const PATCH: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { id, name, country_code } = body as { id?: number; name?: string; country_code?: string }; if (id == null || !name?.trim() || !country_code?.trim()) { @@ -31,7 +31,7 @@ export const PATCH: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const id = parseInt(event.url.searchParams.get('id') ?? ''); if (isNaN(id)) return json({ error: 'id required' }, { status: 400 }); data.deleteCountry(id); diff --git a/src/routes/(protected)/admin/api/operator-tour-days/+server.ts b/src/routes/(protected)/admin/api/operator-tour-days/+server.ts index 4d44a50..0512d07 100644 --- a/src/routes/(protected)/admin/api/operator-tour-days/+server.ts +++ b/src/routes/(protected)/admin/api/operator-tour-days/+server.ts @@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js'; import * as data from '$lib/server/admin/data.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const tourId = parseInt(event.url.searchParams.get('operator_tour_id') ?? ''); if (isNaN(tourId)) return json({ error: 'operator_tour_id required' }, { status: 400 }); return json(data.listDaysForOperatorTour(tourId)); }; export const POST: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { operator_tour_id, title, notes } = body as { operator_tour_id?: number; @@ -25,7 +25,7 @@ export const POST: RequestHandler = async (event) => { }; export const PATCH: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { id, title, notes } = body as { id?: number; title?: string; notes?: string }; if (id == null) return json({ error: 'id is required' }, { status: 400 }); @@ -34,7 +34,7 @@ export const PATCH: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const id = parseInt(event.url.searchParams.get('id') ?? ''); if (isNaN(id)) return json({ error: 'id required' }, { status: 400 }); data.deleteOperatorTourDay(id); diff --git a/src/routes/(protected)/admin/api/operator-tours/+server.ts b/src/routes/(protected)/admin/api/operator-tours/+server.ts index 4c13a65..b0511a8 100644 --- a/src/routes/(protected)/admin/api/operator-tours/+server.ts +++ b/src/routes/(protected)/admin/api/operator-tours/+server.ts @@ -4,14 +4,14 @@ import { requireAdmin } from '$lib/server/admin/auth.js'; import * as data from '$lib/server/admin/data.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? ''); if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 }); return json(data.listToursForOperator(operatorId)); }; export const POST: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { operator_id, name } = body as { operator_id?: number; name?: string }; if (operator_id == null || !name?.trim()) { @@ -21,7 +21,7 @@ export const POST: RequestHandler = async (event) => { }; export const PATCH: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { id, name } = body as { id?: number; name?: string }; if (id == null || !name?.trim()) { @@ -32,7 +32,7 @@ export const PATCH: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const id = parseInt(event.url.searchParams.get('id') ?? ''); if (isNaN(id)) return json({ error: 'id required' }, { status: 400 }); data.deleteOperatorTour(id); diff --git a/src/routes/(protected)/admin/api/provider-search/+server.ts b/src/routes/(protected)/admin/api/provider-search/+server.ts index 9dabca9..7edf9af 100644 --- a/src/routes/(protected)/admin/api/provider-search/+server.ts +++ b/src/routes/(protected)/admin/api/provider-search/+server.ts @@ -5,7 +5,7 @@ import { listTourOperators } from '$lib/server/admin/data.js'; import { getProviderForOperator } from '$lib/server/admin/providers/index.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const operatorId = parseInt(event.url.searchParams.get('operator_id') ?? ''); if (isNaN(operatorId)) return json({ error: 'operator_id required' }, { status: 400 }); diff --git a/src/routes/(protected)/admin/api/tour-operators/+server.ts b/src/routes/(protected)/admin/api/tour-operators/+server.ts index 96f1f89..21c056e 100644 --- a/src/routes/(protected)/admin/api/tour-operators/+server.ts +++ b/src/routes/(protected)/admin/api/tour-operators/+server.ts @@ -4,13 +4,13 @@ import { requireAdmin } from '$lib/server/admin/auth.js'; import * as data from '$lib/server/admin/data.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const q = event.url.searchParams.get('q') ?? undefined; return json(data.listTourOperators(q)); }; export const POST: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { name, website, highlight_color } = body as { name?: string; @@ -24,7 +24,7 @@ export const POST: RequestHandler = async (event) => { }; export const PATCH: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { id, name, website, highlight_color } = body as { id?: number; @@ -40,7 +40,7 @@ export const PATCH: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const id = parseInt(event.url.searchParams.get('id') ?? ''); if (isNaN(id)) return json({ error: 'id required' }, { status: 400 }); data.deleteTourOperator(id); diff --git a/src/routes/(protected)/admin/api/users/+server.ts b/src/routes/(protected)/admin/api/users/+server.ts index f8257f1..378fb6b 100644 --- a/src/routes/(protected)/admin/api/users/+server.ts +++ b/src/routes/(protected)/admin/api/users/+server.ts @@ -4,12 +4,12 @@ import { requireAdmin } from '$lib/server/admin/auth.js'; import { deleteUser, listUsers, updateUser } from '$lib/server/users.js'; export const GET: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); return json(listUsers()); }; export const PATCH: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const body = await event.request.json(); const { id, username, full_name, email } = body as { id?: string; @@ -25,7 +25,7 @@ export const PATCH: RequestHandler = async (event) => { }; export const DELETE: RequestHandler = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const id = event.url.searchParams.get('id')?.trim(); if (!id) return json({ error: 'id required' }, { status: 400 }); deleteUser(id); diff --git a/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.server.ts b/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.server.ts index 4c844b1..8871e8c 100644 --- a/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.server.ts +++ b/src/routes/(protected)/admin/tour-operators/[operatorId]/+page.server.ts @@ -5,7 +5,7 @@ import { getProviderForOperator } from '$lib/server/admin/providers/index.js'; import type { PageServerLoad, Actions } from './$types'; export const load: PageServerLoad = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const operatorId = parseInt(event.params.operatorId); if (isNaN(operatorId)) error(404, 'Not found'); @@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => { export const actions: Actions = { addTour: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const operatorId = parseInt(event.params.operatorId); if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' }); @@ -45,7 +45,7 @@ export const actions: Actions = { }, editTour: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const formData = await event.request.formData(); const id = parseInt(formData.get('id') as string); @@ -63,7 +63,7 @@ export const actions: Actions = { }, deleteTour: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const formData = await event.request.formData(); const id = parseInt(formData.get('id') as string); @@ -78,7 +78,7 @@ export const actions: Actions = { }, importTour: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const operatorId = parseInt(event.params.operatorId); if (isNaN(operatorId)) return fail(400, { error: 'Invalid operator ID' }); diff --git a/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts index 82df5b9..3c63b8f 100644 --- a/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts +++ b/src/routes/(protected)/admin/tour-operators/[operatorId]/tours/[tourId]/+page.server.ts @@ -4,7 +4,7 @@ import * as data from '$lib/server/admin/data.js'; import type { PageServerLoad, Actions } from './$types'; export const load: PageServerLoad = async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const operatorId = parseInt(event.params.operatorId); const tourId = parseInt(event.params.tourId); @@ -27,7 +27,7 @@ export const load: PageServerLoad = async (event) => { export const actions: Actions = { addDay: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const tourId = parseInt(event.params.tourId); if (isNaN(tourId)) return fail(400, { error: 'Invalid tour ID' }); @@ -45,7 +45,7 @@ export const actions: Actions = { }, editDay: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const formData = await event.request.formData(); const id = parseInt(formData.get('id') as string); @@ -63,7 +63,7 @@ export const actions: Actions = { }, deleteDay: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const formData = await event.request.formData(); const id = parseInt(formData.get('id') as string); @@ -78,7 +78,7 @@ export const actions: Actions = { }, addDayPlan: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const formData = await event.request.formData(); const dayId = parseInt(formData.get('dayId') as string); @@ -247,7 +247,7 @@ export const actions: Actions = { }, editDayPlan: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const formData = await event.request.formData(); const id = parseInt(formData.get('id') as string); @@ -310,7 +310,7 @@ export const actions: Actions = { }, deleteDayPlan: async (event) => { - requireAdmin((await event.locals.auth())?.user?.id); + requireAdmin((await event.locals.auth())?.user); const formData = await event.request.formData(); const id = parseInt(formData.get('id') as string); diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index c9a1406..f846970 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -1,8 +1,20 @@ import { env } from '$env/dynamic/private'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { +const resolveErrorMessage = (value: string | null): string | null => { + if (!value) return null; + if (value === 'CredentialsSignin') return 'Invalid credentials'; + return null; +}; + +export const load: PageServerLoad = async ({ url }) => { + const authUrl = process.env.AUTH_URL ?? env.AUTH_URL ?? ''; + const localAuthEnabled = (process.env.LOCAL_AUTH_ENABLED ?? env.LOCAL_AUTH_ENABLED) === 'true'; + return { - signinUrl: `${env.AUTH_URL}/signin/synology` + signinUrl: `${authUrl}/signin/synology`, + localSigninUrl: `${authUrl}/signin/local`, + localAuthEnabled, + error: resolveErrorMessage(url.searchParams.get('error')) }; }; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 0b3b47e..e89c471 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,22 +1,85 @@ Sign in β€” Trips - +
+
+

Sign in

+

+ Choose a sign-in method to access Trips. +

+
-
-

Redirecting to sign in…

+ {#if data.error} +
+ {data.error} +
+ {/if} + +
+
+

Synology account

+

+ Use your Synology SSO account. +

+
+ + +
+
+ +
+

Local account

+

+ Sign in with your local username or email. +

+ {#if data.localAuthEnabled} +
+ +
+ + +
+
+ + +
+ +
+ {:else} +
+ Local sign-in is disabled. +
+ {/if} +
+
diff --git a/src/routes/login/page.server.test.ts b/src/routes/login/page.server.test.ts new file mode 100644 index 0000000..4c4d6ff --- /dev/null +++ b/src/routes/login/page.server.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { load } from './+page.server'; + +beforeEach(() => { + process.env.AUTH_URL = 'https://example.com/auth'; +}); + +describe('login page load', () => { + it('returns auth options when local auth is enabled', async () => { + process.env.LOCAL_AUTH_ENABLED = 'true'; + const result = await load({ + url: new URL('https://example.com/login') + } as Parameters[0]); + + expect(result.signinUrl).toBe('https://example.com/auth/signin/synology'); + expect(result.localSigninUrl).toBe('https://example.com/auth/signin/local'); + expect(result.localAuthEnabled).toBe(true); + }); + + it('maps credentials errors to a generic message', async () => { + process.env.LOCAL_AUTH_ENABLED = 'true'; + const result = await load({ + url: new URL('https://example.com/login?error=CredentialsSignin') + } as Parameters[0]); + + expect(result.error).toBe('Invalid credentials'); + }); + + it('disables local auth in the response when disabled', async () => { + process.env.LOCAL_AUTH_ENABLED = 'false'; + const result = await load({ + url: new URL('https://example.com/login') + } as Parameters[0]); + + expect(result.localAuthEnabled).toBe(false); + }); +});