diff --git a/migrations/0011_session_payload.sql b/migrations/0011_session_payload.sql new file mode 100644 index 0000000..0b46cbd --- /dev/null +++ b/migrations/0011_session_payload.sql @@ -0,0 +1,11 @@ +-- Migration: move the session payload server-side +-- The session cookie used to carry the whole user object as unsigned base64 +-- JSON, so anyone could forge one with isAdmin/isOwner set and take over admin. +-- The fix keeps the cookie as an opaque session id (already a random UUID in +-- the sessions table) and stores the trusted payload here, read back on every +-- request. A forged cookie now names a session that does not exist. +-- +-- Nullable and additive: existing session rows (written for activity tracking) +-- keep working, and the per-user session COUNT in the admin UI is unaffected. + +ALTER TABLE sessions ADD COLUMN data TEXT; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c4cde06..3521be4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,7 +4,7 @@ import { prefersMarkdown, toMarkdownResponse } from '$lib/server/markdown-negotiation'; -import { decodeSessionCookie } from '$lib/utils/session'; +import { getAuthSession } from '$lib/utils/db'; import { browserBucket, deviceBucket, @@ -21,47 +21,30 @@ import { sequence } from '@sveltejs/kit/hooks'; // Auth handling hook const authHandler: Handle = async ({ event, resolve }) => { - // Get session cookie + // The cookie is an OPAQUE session id, not the user object. The trusted payload + // lives server-side in sessions.data (see createAuthSession) and is read back + // here on every request, so isOwner/isAdmin cannot be forged by editing the + // cookie. A cookie that resolves to no session leaves the request + // unauthenticated — fail closed. const sessionId = event.cookies.get('session'); if (sessionId) { - const sessionData = decodeSessionCookie(sessionId); - - if (sessionData) { - // Refresh admin flags from the database (optional - don't fail auth if - // DB unavailable). Reading them per-request rather than trusting the - // cookie means granting or revoking access takes effect immediately. - if (event.platform?.env?.DB) { - const db = event.platform.env.DB; - let userRecord: { is_admin: number; can_view_stats?: number } | null = null; - try { - userRecord = await db - .prepare('SELECT is_admin, can_view_stats FROM users WHERE id = ?') - .bind(sessionData.id) - .first<{ is_admin: number; can_view_stats: number }>(); - } catch { - // `can_view_stats` arrives in migration 0009. On a database that - // hasn't run it yet the combined SELECT fails, which must not cost - // us the is_admin refresh — fall back to the narrower query. - try { - userRecord = await db - .prepare('SELECT is_admin FROM users WHERE id = ?') - .bind(sessionData.id) - .first<{ is_admin: number }>(); - } catch { - // Database error - continue with session data from cookie - } - } - - if (userRecord) { - sessionData.isAdmin = userRecord.is_admin === 1; - sessionData.canViewStats = userRecord.can_view_stats === 1; - } + const db = event.platform?.env?.DB; + let user = null; + if (db) { + try { + user = await getAuthSession(db, sessionId); + } catch { + // A database error means we cannot authenticate this request — treat + // it as unauthenticated rather than trusting the cookie. + user = null; } + } - event.locals.user = sessionData; + if (user) { + event.locals.user = user; } else { - // Invalid session, clear cookie + // Unknown, expired, or unverifiable session — clear the stale cookie. event.cookies.delete('session', { path: '/' }); } } diff --git a/src/lib/utils/db.ts b/src/lib/utils/db.ts index 8d418e0..3cc4a23 100644 --- a/src/lib/utils/db.ts +++ b/src/lib/utils/db.ts @@ -2,6 +2,7 @@ * Database utility functions for D1 */ import type { D1Database } from '@cloudflare/workers-types'; +import type { SessionUser } from './session'; export interface User { id: string; @@ -73,11 +74,62 @@ export async function createSession( return result; } +/** + * Create an authenticated session and return its opaque id for the cookie. + * + * The id is a random UUID; the TRUSTED user payload is stored in `sessions.data` + * server-side and read back on every request (see getAuthSession). The cookie + * never carries the payload, so isOwner/isAdmin cannot be forged by editing it. + */ +export async function createAuthSession( + db: D1Database, + user: SessionUser, + expiresInDays: number = 7 +): Promise { + const id = crypto.randomUUID(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + expiresInDays); + + await db + .prepare('INSERT INTO sessions (id, user_id, expires_at, data) VALUES (?, ?, ?, ?)') + .bind(id, user.id, expiresAt.toISOString(), JSON.stringify(user)) + .run(); + + return id; +} + +/** + * Resolve a session cookie to its stored user payload, or null if the session + * is unknown, expired, or predates this scheme (no stored payload). A forged + * cookie resolves to null because it names no real session — fail closed. + */ +export async function getAuthSession( + db: D1Database, + sessionId: string +): Promise { + // datetime(expires_at) normalizes the stored ISO string ("...T...Z") before + // comparing: a raw string compare against datetime('now') ("... ...") sorts + // 'T' after ' ', so a same-day expiry read as still-valid for up to a day. + const row = await db + .prepare("SELECT data FROM sessions WHERE id = ? AND datetime(expires_at) > datetime('now')") + .bind(sessionId) + .first<{ data: string | null }>(); + + if (!row?.data) return null; + try { + return JSON.parse(row.data) as SessionUser; + } catch { + return null; + } +} + /** * Find session by ID and check if it's valid */ export async function findValidSession(db: D1Database, sessionId: string): Promise { - const stmt = db.prepare('SELECT * FROM sessions WHERE id = ? AND expires_at > datetime("now")'); + const stmt = db.prepare( + "SELECT * FROM sessions WHERE id = ? AND datetime(expires_at) > datetime('now')" + ); return await stmt.bind(sessionId).first(); } @@ -93,6 +145,6 @@ export async function deleteSession(db: D1Database, sessionId: string): Promise< * Clean up expired sessions */ export async function cleanupExpiredSessions(db: D1Database): Promise { - const stmt = db.prepare('DELETE FROM sessions WHERE expires_at < datetime("now")'); + const stmt = db.prepare("DELETE FROM sessions WHERE datetime(expires_at) < datetime('now')"); await stmt.run(); } diff --git a/src/lib/utils/dev-auth.ts b/src/lib/utils/dev-auth.ts index 618a374..e0589b6 100644 --- a/src/lib/utils/dev-auth.ts +++ b/src/lib/utils/dev-auth.ts @@ -1,13 +1,19 @@ const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1']); -function isLocalDevHost(url: URL): boolean { - return LOCAL_DEV_HOSTS.has(url.hostname); +function isLocalDevHost(url: URL | undefined): boolean { + // No URL means we cannot prove this is a local host — fail closed. + return !!url && LOCAL_DEV_HOSTS.has(url.hostname); } -export function isDevAuthSimulationEnabled( - url: URL, - platform: App.Platform | undefined -): boolean { +export function isDevAuthSimulationEnabled(url: URL, platform: App.Platform | undefined): boolean { + // The simulator mints a real owner/admin session server-side, so it must never + // be reachable in production. Both the explicit override and the implicit + // dev-mode path additionally require a local host — a stray DEV_AUTH_BYPASS on + // a deployed environment can no longer hand out owner access from the internet. + if (!isLocalDevHost(url)) { + return false; + } + const explicitOverride = platform?.env?.DEV_AUTH_BYPASS === 'true'; if (explicitOverride) { return true; @@ -18,5 +24,5 @@ export function isDevAuthSimulationEnabled( return false; } - return import.meta.env.DEV && isLocalDevHost(url); + return import.meta.env.DEV; } diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts index 7a6bd2b..a49bbef 100644 --- a/src/lib/utils/session.ts +++ b/src/lib/utils/session.ts @@ -25,22 +25,6 @@ interface SessionUserInput { isAdmin?: boolean; } -function toBase64Url(value: string): string { - return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function fromBase64Url(value: string): string { - let normalized = value; - if (normalized.includes('-') || normalized.includes('_')) { - normalized = normalized.replace(/-/g, '+').replace(/_/g, '/'); - } - while (normalized.length % 4) { - normalized += '='; - } - - return atob(normalized); -} - export function deriveLoginIdentifier(email: string, githubLogin?: string | null): string { if (githubLogin) { return githubLogin; @@ -69,25 +53,21 @@ export function createSessionUser(input: SessionUserInput): SessionUser { }; } -export function encodeSession(user: SessionUser): string { - return toBase64Url(JSON.stringify(user)); -} - -export function decodeSessionCookie(sessionCookie?: string): SessionUser | null { - if (!sessionCookie) { - return null; - } - - try { - return JSON.parse(fromBase64Url(sessionCookie)) as SessionUser; - } catch { - return null; - } -} - -export function buildSessionCookieHeader(user: SessionUser, url: URL): string { +/** + * Build the Set-Cookie header for a session. + * + * The value is an OPAQUE session id (from createAuthSession), never the user + * object. The trusted payload lives server-side in sessions.data, so a client + * that edits this cookie only names a different (non-existent) session and is + * refused — isOwner/isAdmin can no longer be forged in the cookie. + * + * The cookie used to carry base64(JSON(user)); `encodeSession`/`decodeSessionCookie` + * were removed with that scheme. Do not reintroduce a cookie the hooks trust + * without a server-side lookup. + */ +export function buildSessionCookieHeader(sessionId: string, url: URL): string { const cookieParts = [ - `session=${encodeSession(user)}`, + `session=${sessionId}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', diff --git a/src/routes/api/auth/connections/+server.ts b/src/routes/api/auth/connections/+server.ts index f6b644c..44cfbf7 100644 --- a/src/routes/api/auth/connections/+server.ts +++ b/src/routes/api/auth/connections/+server.ts @@ -1,4 +1,5 @@ import { error, json } from '@sveltejs/kit'; +import { createAuthSession } from '$lib/utils/db'; import { buildSessionCookieHeader } from '$lib/utils/session'; import type { RequestHandler } from './$types'; @@ -73,17 +74,20 @@ export const DELETE: RequestHandler = async ({ locals, platform, request, url }) const existingConnections = locals.user.simulatedConnections || []; const simulatedConnections = existingConnections.filter((value) => value !== provider); + if (!platform?.env?.DB) { + throw error(500, 'Database not available'); + } + + const sessionId = await createAuthSession(platform.env.DB, { + ...locals.user, + simulatedConnections + }); + return json( { success: true, connections: simulatedConnections.map((name) => ({ provider: name })) }, { headers: { - 'Set-Cookie': buildSessionCookieHeader( - { - ...locals.user, - simulatedConnections - }, - url - ) + 'Set-Cookie': buildSessionCookieHeader(sessionId, url) } } ); diff --git a/src/routes/api/auth/dev-simulate/+server.ts b/src/routes/api/auth/dev-simulate/+server.ts index cfe2158..488387b 100644 --- a/src/routes/api/auth/dev-simulate/+server.ts +++ b/src/routes/api/auth/dev-simulate/+server.ts @@ -1,4 +1,5 @@ -import { buildSessionCookieHeader, decodeSessionCookie } from '$lib/utils/session'; +import { createAuthSession, getAuthSession } from '$lib/utils/db'; +import { buildSessionCookieHeader } from '$lib/utils/session'; import { isDevAuthSimulationEnabled } from '$lib/utils/dev-auth'; import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -37,8 +38,7 @@ function buildDevUser(provider: SupportedProvider, role: SimulatedRole) { const login = `${loginPrefix}-${suffix}`; const isSuperadmin = role === 'superadmin'; const isAdmin = role === 'admin' || isSuperadmin; - const namePrefix = - role === 'superadmin' ? 'Superadmin' : role === 'admin' ? 'Admin' : 'User'; + const namePrefix = role === 'superadmin' ? 'Superadmin' : role === 'admin' ? 'Admin' : 'User'; return { id: `dev-${provider}-${suffix}`, @@ -59,6 +59,13 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => { throw redirect(302, '/auth/login?error=not_configured'); } + // The simulator still mints a real server-side session (its payload is trusted + // exactly like a real login's), so it needs the database like any other login. + const db = platform?.env?.DB; + if (!db) { + throw redirect(302, '/auth/login?error=not_configured'); + } + const provider = parseProvider(url.searchParams.get('provider')); if (!provider) { throw redirect(302, '/auth/login?error=oauth_failed'); @@ -66,24 +73,23 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => { const mode = parseMode(url.searchParams.get('mode')); if (mode === 'link') { - const existingUser = decodeSessionCookie(cookies.get('session')); + const existingUser = await getAuthSession(db, cookies.get('session') ?? ''); if (existingUser?.isPretend) { const simulatedConnections = Array.from( new Set([...(existingUser.simulatedConnections || []), provider]) ); + const linkedSessionId = await createAuthSession(db, { + ...existingUser, + simulatedConnections + }); + return new Response(null, { status: 302, headers: { Location: new URL(`/profile?linked=${provider}`, url.origin).toString(), - 'Set-Cookie': buildSessionCookieHeader( - { - ...existingUser, - simulatedConnections - }, - url - ) + 'Set-Cookie': buildSessionCookieHeader(linkedSessionId, url) } }); } @@ -93,11 +99,13 @@ export const GET: RequestHandler = async ({ url, platform, cookies }) => { const sessionUser = buildDevUser(provider, role); const redirectTarget = role === 'admin' || role === 'superadmin' ? '/admin' : '/'; + const sessionId = await createAuthSession(db, sessionUser); + return new Response(null, { status: 302, headers: { Location: new URL(redirectTarget, url.origin).toString(), - 'Set-Cookie': buildSessionCookieHeader(sessionUser, url) + 'Set-Cookie': buildSessionCookieHeader(sessionId, url) } }); }; diff --git a/src/routes/api/auth/discord/callback/+server.ts b/src/routes/api/auth/discord/callback/+server.ts index 81a504e..c756119 100644 --- a/src/routes/api/auth/discord/callback/+server.ts +++ b/src/routes/api/auth/discord/callback/+server.ts @@ -1,5 +1,6 @@ import { mergeAccounts } from '$lib/services/account-merge'; import { findUserByEmailOrAlias, resolveOwnerStatus } from '$lib/utils/auth-identity'; +import { createAuthSession } from '$lib/utils/db'; import { buildSessionCookieHeader } from '$lib/utils/session'; import { consumeOAuthState } from '$lib/server/oauth-state'; import { isRedirect, redirect } from '@sveltejs/kit'; @@ -211,10 +212,7 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { isAdmin: linkedUser.is_admin === 1 || isOwner }; - const sessionCookie = btoa(JSON.stringify(sessionData)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + const sessionCookie = await createAuthSession(platform.env.DB, sessionData); const isSecure = url.protocol === 'https:'; const cookieParts = [ @@ -266,6 +264,16 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { } const isOwner = await resolveOwnerStatus(platform, matchedUser); + const matchedSessionId = await createAuthSession(platform.env.DB, { + id: matchedUser.id, + login: matchedUser.github_login || discordUser.username, + email: matchedUser.email, + name: matchedUser.name || discordUser.global_name || discordUser.username, + avatarUrl: matchedUser.github_avatar_url || avatarUrl, + isOwner, + isAdmin: matchedUser.is_admin === 1 || isOwner, + githubLogin: matchedUser.github_login || undefined + }); return new Response(null, { status: 302, headers: { @@ -273,19 +281,7 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { isOwner || matchedUser.is_admin === 1 ? '/admin' : '/', url.origin ).toString(), - 'Set-Cookie': buildSessionCookieHeader( - { - id: matchedUser.id, - login: matchedUser.github_login || discordUser.username, - email: matchedUser.email, - name: matchedUser.name || discordUser.global_name || discordUser.username, - avatarUrl: matchedUser.github_avatar_url || avatarUrl, - isOwner, - isAdmin: matchedUser.is_admin === 1 || isOwner, - githubLogin: matchedUser.github_login || undefined - }, - url - ) + 'Set-Cookie': buildSessionCookieHeader(matchedSessionId, url) } }); } @@ -347,7 +343,12 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { } } - // Create session + // Create session. Fail closed if the store is unreachable rather than + // issuing a cookie the hooks would reject. + if (!platform?.env?.DB) { + throw redirect(302, '/auth/login?error=oauth_failed'); + } + const sessionData = { id: userId, login: discordUser.username, @@ -358,11 +359,8 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { isAdmin }; - // Store session in cookie using URL-safe base64 encoding - const sessionCookie = btoa(JSON.stringify(sessionData)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + // Store the trusted payload server-side; the cookie carries only the id. + const sessionCookie = await createAuthSession(platform.env.DB, sessionData); // Redirect to home const redirectUrl = '/'; diff --git a/src/routes/api/auth/github/callback/+server.ts b/src/routes/api/auth/github/callback/+server.ts index a2c2b68..1bbc01f 100644 --- a/src/routes/api/auth/github/callback/+server.ts +++ b/src/routes/api/auth/github/callback/+server.ts @@ -1,5 +1,6 @@ import { mergeAccounts } from '$lib/services/account-merge'; import { findUserByEmailOrAlias } from '$lib/utils/auth-identity'; +import { createAuthSession } from '$lib/utils/db'; import { buildSessionCookieHeader } from '$lib/utils/session'; import { consumeOAuthState } from '$lib/server/oauth-state'; import { isRedirect, redirect } from '@sveltejs/kit'; @@ -249,10 +250,7 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { isAdmin: linkedUser.is_admin === 1 }; - const sessionCookie = btoa(JSON.stringify(sessionData)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + const sessionCookie = await createAuthSession(platform.env.DB, sessionData); const isSecure = url.protocol === 'https:'; const cookieParts = [ @@ -318,23 +316,21 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { .run(); const redirectUrl = matchedUser.is_admin === 1 || isOwner ? '/admin' : '/'; + const matchedSessionId = await createAuthSession(platform.env.DB, { + id: matchedUser.id, + login: githubUser.login, + email: matchedUser.email, + name: matchedUser.name || githubUser.name || githubUser.login, + avatarUrl: githubUser.avatar_url, + isOwner, + isAdmin: matchedUser.is_admin === 1 || isOwner, + githubLogin: githubUser.login + }); return new Response(null, { status: 302, headers: { Location: new URL(redirectUrl, url.origin).toString(), - 'Set-Cookie': buildSessionCookieHeader( - { - id: matchedUser.id, - login: githubUser.login, - email: matchedUser.email, - name: matchedUser.name || githubUser.name || githubUser.login, - avatarUrl: githubUser.avatar_url, - isOwner, - isAdmin: matchedUser.is_admin === 1 || isOwner, - githubLogin: githubUser.login - }, - url - ) + 'Set-Cookie': buildSessionCookieHeader(matchedSessionId, url) } }); } @@ -418,7 +414,13 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { } } - // Create session + // Create session. The payload must be stored server-side, so a login that + // cannot reach the database fails rather than handing out a cookie the + // hooks would reject anyway. + if (!platform?.env?.DB) { + throw redirect(302, '/auth/login?error=oauth_failed'); + } + const sessionData = { id: githubUser.id.toString(), login: githubUser.login, @@ -429,12 +431,8 @@ export const GET: RequestHandler = async ({ url, cookies, platform }) => { isAdmin }; - // Store session in cookie using URL-safe base64 encoding - // Replace +, /, = with URL-safe characters to avoid cookie parsing issues - const sessionCookie = btoa(JSON.stringify(sessionData)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + // Store the trusted payload server-side; the cookie carries only the id. + const sessionCookie = await createAuthSession(platform.env.DB, sessionData); // Track first admin login to lock setup page if (isOwner && platform?.env?.KV) { diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 6a6db55..733c489 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -1,6 +1,7 @@ import { resolveOwnerStatus } from '$lib/utils/auth-identity'; import { getConfiguredAuthProviders } from '$lib/utils/auth-provider-config'; import { verifyPassword } from '$lib/utils/passwords'; +import { createAuthSession } from '$lib/utils/db'; import { buildSessionCookieHeader, createSessionUser } from '$lib/utils/session'; import { error, json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -56,6 +57,8 @@ export const POST: RequestHandler = async ({ platform, request, url }) => { const redirectTo = sessionUser.isAdmin ? '/admin' : '/'; + const sessionId = await createAuthSession(platform.env.DB, sessionUser); + return json( { success: true, @@ -65,7 +68,7 @@ export const POST: RequestHandler = async ({ platform, request, url }) => { { status: 200, headers: { - 'Set-Cookie': buildSessionCookieHeader(sessionUser, url) + 'Set-Cookie': buildSessionCookieHeader(sessionId, url) } } ); diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index b5af7a0..f6fc50d 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -1,18 +1,33 @@ +import { deleteSession } from '$lib/utils/db'; import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -// POST - Logout user -export const POST: RequestHandler = async ({ cookies }) => { - // Clear session cookie +// Revoke the session row so a copied cookie cannot be replayed after logout, +// then clear the cookie. Best-effort on the DB — a delete failure must not stop +// the user logging out. +async function endSession( + cookies: Parameters[0]['cookies'], + platform: App.Platform | undefined +): Promise { + const sessionId = cookies.get('session'); + if (sessionId && platform?.env?.DB) { + try { + await deleteSession(platform.env.DB, sessionId); + } catch { + // ignore — clearing the cookie below still logs the user out + } + } cookies.delete('session', { path: '/' }); +} +// POST - Logout user +export const POST: RequestHandler = async ({ cookies, platform }) => { + await endSession(cookies, platform); throw redirect(302, '/auth/login'); }; // GET - Logout user (for convenience) -export const GET: RequestHandler = async ({ cookies }) => { - // Clear session cookie - cookies.delete('session', { path: '/' }); - +export const GET: RequestHandler = async ({ cookies, platform }) => { + await endSession(cookies, platform); throw redirect(302, '/auth/login'); }; diff --git a/src/routes/api/auth/signup/+server.ts b/src/routes/api/auth/signup/+server.ts index c2f4197..ae67703 100644 --- a/src/routes/api/auth/signup/+server.ts +++ b/src/routes/api/auth/signup/+server.ts @@ -1,3 +1,4 @@ +import { createAuthSession } from '$lib/utils/db'; import { hashPassword, validatePassword } from '$lib/utils/passwords'; import { buildSessionCookieHeader, createSessionUser } from '$lib/utils/session'; import { error, json } from '@sveltejs/kit'; @@ -65,12 +66,14 @@ export const POST: RequestHandler = async ({ platform, request, url }) => { isOwner: false }); + const sessionId = await createAuthSession(platform.env.DB, sessionUser); + return json( { success: true, redirectTo: '/' }, { status: 201, headers: { - 'Set-Cookie': buildSessionCookieHeader(sessionUser, url) + 'Set-Cookie': buildSessionCookieHeader(sessionId, url) } } ); diff --git a/src/routes/api/reset/+server.ts b/src/routes/api/reset/+server.ts index b217505..e6fe34b 100644 --- a/src/routes/api/reset/+server.ts +++ b/src/routes/api/reset/+server.ts @@ -1,4 +1,5 @@ import { requireOwner } from '$lib/server/auth-guard'; +import { deleteSession } from '$lib/utils/db'; import { error, json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -38,7 +39,15 @@ export const POST: RequestHandler = async ({ platform, cookies, locals }) => { } } - // Clear the session cookie to force re-login + // Revoke the session row, then clear the cookie, to force re-login. + const sessionId = cookies.get('session'); + if (sessionId && platform.env.DB) { + try { + await deleteSession(platform.env.DB, sessionId); + } catch { + // ignore — clearing the cookie still ends this session for the client + } + } cookies.delete('session', { path: '/' }); console.log('✓ Setup configuration reset complete'); diff --git a/tests/unit/auth-connections-extended.test.ts b/tests/unit/auth-connections-extended.test.ts index 134f25d..90399c0 100644 --- a/tests/unit/auth-connections-extended.test.ts +++ b/tests/unit/auth-connections-extended.test.ts @@ -104,7 +104,12 @@ describe('Auth Connections API - Extended Branch Coverage', () => { it('should include simulated connections for pretend users', async () => { const mockEvent = { locals: { - user: { id: 'dev-1', login: 'dev-user', isPretend: true, simulatedConnections: ['discord'] } + user: { + id: 'dev-1', + login: 'dev-user', + isPretend: true, + simulatedConnections: ['discord'] + } }, platform: { env: {} @@ -334,7 +339,24 @@ describe('Auth Connections API - Extended Branch Coverage', () => { expect(data.success).toBe(true); }); - it('should unlink simulated connections for pretend users without DB', async () => { + it('should unlink simulated connections for pretend users, re-issuing a server session', async () => { + // The updated pretend identity is stored server-side now, so the endpoint + // needs the database — the cookie only carries the new opaque id. + const inserted: Record = {}; + const db = { + prepare: (sql: string) => ({ + bind: (...args: unknown[]) => ({ + run: async () => { + if (/^INSERT INTO sessions/i.test(sql)) { + inserted.id = args[0] as string; + inserted.data = args[3] as string; + } + return { success: true }; + } + }) + }) + }; + const mockEvent = { locals: { user: { @@ -348,7 +370,7 @@ describe('Auth Connections API - Extended Branch Coverage', () => { } }, platform: { - env: {} + env: { DB: db } }, url: new URL('http://localhost/api/auth/connections'), request: { @@ -362,7 +384,9 @@ describe('Auth Connections API - Extended Branch Coverage', () => { expect(data.success).toBe(true); expect(data.connections).toEqual([{ provider: 'github' }]); - expect(response.headers.get('Set-Cookie')).toContain('session='); + expect(response.headers.get('Set-Cookie')).toContain(`session=${inserted.id}`); + // The trusted payload — not the cookie — reflects the removed connection. + expect(JSON.parse(inserted.data).simulatedConnections).toEqual(['github']); }); it('should return 500 when delete operation fails', async () => { diff --git a/tests/unit/auth-session-server.test.ts b/tests/unit/auth-session-server.test.ts new file mode 100644 index 0000000..b16ac39 --- /dev/null +++ b/tests/unit/auth-session-server.test.ts @@ -0,0 +1,102 @@ +import { webcrypto } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAuthSession, getAuthSession } from '../../src/lib/utils/db'; +import type { SessionUser } from '../../src/lib/utils/session'; + +/** + * Minimal stateful D1 stub modelling just the `sessions` table, enough to prove + * the server-side session contract: a real session round-trips, and a cookie + * that names no row (a forgery) resolves to null. This is the regression guard + * for the unsigned-base64-JSON auth bypass — the whole reason the payload moved + * server-side. + */ +function makeSessionDb() { + const rows = new Map(); + + const db = { + prepare(sql: string) { + return { + bind(...args: unknown[]) { + return { + async run() { + if (/^INSERT INTO sessions/i.test(sql)) { + const [id, user_id, expires_at, data] = args as string[]; + rows.set(id, { user_id, expires_at, data }); + } else if (/^DELETE FROM sessions WHERE id/i.test(sql)) { + rows.delete(args[0] as string); + } + return { success: true }; + }, + async first() { + if (/^SELECT data FROM sessions/i.test(sql)) { + const row = rows.get(args[0] as string); + if (!row) return null; + // mirror `datetime(expires_at) > datetime('now')` + if (new Date(row.expires_at).getTime() <= Date.now()) return null; + return { data: row.data } as T; + } + return null; + } + }; + } + }; + } + }; + + return { db: db as unknown as Parameters[0], rows }; +} + +const OWNER: SessionUser = { + id: 'real-user-1', + login: 'octocat', + email: 'owner@example.com', + name: 'Owner', + isOwner: true, + isAdmin: true +}; + +describe('server-side session (auth bypass fix)', () => { + beforeEach(() => { + vi.stubGlobal('crypto', webcrypto as Crypto); + }); + + it('round-trips a real session by its opaque id', async () => { + const { db } = makeSessionDb(); + const id = await createAuthSession(db, OWNER); + + expect(typeof id).toBe('string'); + expect(id).not.toContain('isOwner'); // the id carries no payload + + const resolved = await getAuthSession(db, id); + expect(resolved).toEqual(OWNER); + }); + + it('honours an explicit expiry window', async () => { + const { db, rows } = makeSessionDb(); + const id = await createAuthSession(db, OWNER, 1); + const expires = new Date(rows.get(id)!.expires_at).getTime(); + // ~1 day out, not the 7-day default. + const daysOut = (expires - Date.now()) / 86_400_000; + expect(daysOut).toBeGreaterThan(0.5); + expect(daysOut).toBeLessThan(2); + }); + + it('resolves a forged / unknown cookie to null — fail closed', async () => { + const { db } = makeSessionDb(); + // A hand-crafted base64(JSON) owner cookie, exactly the old forgery. It + // names no session row, so it must resolve to nobody. + const forged = Buffer.from(JSON.stringify({ id: 'x', isOwner: true })).toString('base64url'); + + expect(await getAuthSession(db, forged)).toBeNull(); + expect(await getAuthSession(db, 'literally-anything')).toBeNull(); + }); + + it('rejects an expired session', async () => { + const { db, rows } = makeSessionDb(); + const id = await createAuthSession(db, OWNER); + // force it into the past + rows.get(id)!.expires_at = new Date(Date.now() - 1000).toISOString(); + + expect(await getAuthSession(db, id)).toBeNull(); + }); +}); diff --git a/tests/unit/auth-session-teardown.test.ts b/tests/unit/auth-session-teardown.test.ts new file mode 100644 index 0000000..5d2bc5c --- /dev/null +++ b/tests/unit/auth-session-teardown.test.ts @@ -0,0 +1,168 @@ +import { webcrypto } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAuthSession, getAuthSession } from '../../src/lib/utils/db'; + +vi.mock('@sveltejs/kit', async () => { + const actual = await vi.importActual('@sveltejs/kit'); + return { + ...actual, + redirect: (status: number, location: string) => { + const err = new Error('Redirect') as Error & { status: number; location: string }; + err.status = status; + err.location = location; + throw err; + } + }; +}); + +/** Stateful sessions stub with a spied delete. */ +function makeSessionDb() { + const rows = new Map(); + const deleteSpy = vi.fn(); + const db = { + prepare(sql: string) { + return { + bind(...args: unknown[]) { + return { + async run() { + if (/^INSERT INTO sessions/i.test(sql)) { + const [id, user_id, expires_at, data] = args as string[]; + rows.set(id, { user_id, expires_at, data }); + } else if (/^DELETE FROM sessions WHERE id/i.test(sql)) { + deleteSpy(args[0]); + rows.delete(args[0] as string); + } + return { success: true }; + }, + async first() { + if (/^SELECT data FROM sessions/i.test(sql)) { + const row = rows.get(args[0] as string); + if (!row || new Date(row.expires_at).getTime() <= Date.now()) return null; + return { data: row.data } as T; + } + return null; + } + }; + } + }; + } + }; + return { db: db as unknown as Parameters[0], rows, deleteSpy }; +} + +describe('session teardown revokes the server-side row', () => { + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal('crypto', webcrypto as Crypto); + }); + + it('logout deletes the session so a copied cookie cannot be replayed', async () => { + const { db, deleteSpy } = makeSessionDb(); + const id = await createAuthSession(db, { + id: 'u1', + login: 'u1', + email: 'u1@example.com', + name: 'U1', + isOwner: true, + isAdmin: true + }); + // The session resolves before logout... + expect(await getAuthSession(db, id)).not.toBeNull(); + + const { POST } = await import('../../src/routes/api/auth/logout/+server'); + const cookies = { get: vi.fn().mockReturnValue(id), delete: vi.fn() }; + await expect(POST({ cookies, platform: { env: { DB: db } } } as any)).rejects.toMatchObject({ + status: 302, + location: '/auth/login' + }); + + expect(deleteSpy).toHaveBeenCalledWith(id); + expect(cookies.delete).toHaveBeenCalledWith('session', { path: '/' }); + // ...and is gone afterward — replay of the same cookie fails. + expect(await getAuthSession(db, id)).toBeNull(); + }); + + it('logout still clears the cookie when the DB delete throws', async () => { + const db = { + prepare: () => ({ + bind: () => ({ + run: async () => { + throw new Error('db down'); + } + }) + }) + }; + const { GET } = await import('../../src/routes/api/auth/logout/+server'); + const cookies = { get: vi.fn().mockReturnValue('some-id'), delete: vi.fn() }; + await expect(GET({ cookies, platform: { env: { DB: db } } } as any)).rejects.toMatchObject({ + status: 302 + }); + expect(cookies.delete).toHaveBeenCalledWith('session', { path: '/' }); + }); + + it('reset revokes the caller session row and clears the cookie', async () => { + const { db, deleteSpy } = makeSessionDb(); + const id = await createAuthSession(db, { + id: 'owner', + login: 'owner', + email: 'owner@example.com', + name: 'Owner', + isOwner: true, + isAdmin: true + }); + const kv = { + get: vi.fn().mockResolvedValue(null), + delete: vi.fn().mockResolvedValue(undefined) + }; + const cookies = { get: vi.fn().mockReturnValue(id), delete: vi.fn() }; + + const { POST } = await import('../../src/routes/api/reset/+server'); + const response = await POST({ + locals: { user: { id: 'owner', isOwner: true } }, + platform: { env: { DB: db, KV: kv } }, + cookies + } as any); + + expect(response.status).toBe(200); + expect(deleteSpy).toHaveBeenCalledWith(id); + expect(cookies.delete).toHaveBeenCalledWith('session', { path: '/' }); + }); +}); + +describe('connections pretend path requires the store', () => { + beforeEach(() => { + vi.stubGlobal('crypto', webcrypto as Crypto); + }); + + it('returns 500 for a pretend unlink when no database is available', async () => { + const { DELETE } = await import('../../src/routes/api/auth/connections/+server'); + await expect( + DELETE({ + locals: { user: { id: 'dev', isPretend: true, simulatedConnections: ['discord'] } }, + platform: { env: {} }, + url: new URL('http://localhost/api/auth/connections'), + request: { json: vi.fn().mockResolvedValue({ provider: 'discord' }) } + } as any) + ).rejects.toMatchObject({ status: 500 }); + }); +}); + +describe('getAuthSession edge cases (fail closed)', () => { + beforeEach(() => { + vi.stubGlobal('crypto', webcrypto as Crypto); + }); + + it('returns null for a pre-scheme row that has no stored payload', async () => { + const db = { + prepare: () => ({ bind: () => ({ first: async () => ({ data: null }) }) }) + } as unknown as Parameters[0]; + expect(await getAuthSession(db, 'legacy-id')).toBeNull(); + }); + + it('returns null when the stored payload is malformed JSON', async () => { + const db = { + prepare: () => ({ bind: () => ({ first: async () => ({ data: 'not json{' }) }) }) + } as unknown as Parameters[0]; + expect(await getAuthSession(db, 'corrupt-id')).toBeNull(); + }); +}); diff --git a/tests/unit/auth-session-utils.test.ts b/tests/unit/auth-session-utils.test.ts index 0b7a864..21c5846 100644 --- a/tests/unit/auth-session-utils.test.ts +++ b/tests/unit/auth-session-utils.test.ts @@ -47,42 +47,28 @@ describe('Auth Session Utilities', () => { expect(sessionUser.isAdmin).toBe(false); }); - it('decodes valid session cookies and rejects invalid payloads', async () => { - const { decodeSessionCookie, encodeSession } = await import('../../src/lib/utils/session'); + it('puts only the opaque session id in the cookie, never the user payload', async () => { + const { buildSessionCookieHeader } = await import('../../src/lib/utils/session'); - const encoded = encodeSession({ - id: 'user-1', - login: 'octocat', - email: 'primary@example.com', - name: 'Primary User', - isOwner: false, - isAdmin: true, - githubLogin: 'octocat' - }); + // The value is the id from createAuthSession; the trusted payload lives in + // the database, so nothing about the user can be read from or forged in the + // cookie. Guards the fix for the unsigned-base64-JSON auth bypass. + const header = buildSessionCookieHeader('opaque-session-id-123', new URL('https://x/profile')); - expect(decodeSessionCookie(encoded)?.login).toBe('octocat'); - expect(decodeSessionCookie()).toBeNull(); - expect(decodeSessionCookie('not-valid-base64')).toBeNull(); + expect(header).toContain('session=opaque-session-id-123'); + expect(header).toContain('HttpOnly'); + expect(header).not.toMatch(/isOwner|isAdmin|eyJ/); // no JSON/base64 payload }); it('adds the Secure attribute for https cookies only', async () => { const { buildSessionCookieHeader } = await import('../../src/lib/utils/session'); - const sessionUser = { - id: 'user-1', - login: 'octocat', - email: 'primary@example.com', - name: 'Primary User', - isOwner: false, - isAdmin: false - }; - - expect(buildSessionCookieHeader(sessionUser, new URL('https://localhost/profile'))).toContain( + expect(buildSessionCookieHeader('sid', new URL('https://localhost/profile'))).toContain( + 'Secure' + ); + expect(buildSessionCookieHeader('sid', new URL('http://localhost/profile'))).not.toContain( 'Secure' ); - expect( - buildSessionCookieHeader(sessionUser, new URL('http://localhost/profile')) - ).not.toContain('Secure'); }); }); diff --git a/tests/unit/db-utils.test.ts b/tests/unit/db-utils.test.ts index 6c927c7..9194983 100644 --- a/tests/unit/db-utils.test.ts +++ b/tests/unit/db-utils.test.ts @@ -261,7 +261,7 @@ describe('Database Utilities', () => { expect(result).toEqual(mockSession); expect(mockDb.prepare).toHaveBeenCalledWith( - 'SELECT * FROM sessions WHERE id = ? AND expires_at > datetime("now")' + "SELECT * FROM sessions WHERE id = ? AND datetime(expires_at) > datetime('now')" ); }); @@ -315,7 +315,7 @@ describe('Database Utilities', () => { await cleanupExpiredSessions(mockDb as any); expect(mockDb.prepare).toHaveBeenCalledWith( - 'DELETE FROM sessions WHERE expires_at < datetime("now")' + "DELETE FROM sessions WHERE datetime(expires_at) < datetime('now')" ); expect(mockRun).toHaveBeenCalled(); }); diff --git a/tests/unit/dev-auth-simulate.test.ts b/tests/unit/dev-auth-simulate.test.ts index 9092c4f..0d748c4 100644 --- a/tests/unit/dev-auth-simulate.test.ts +++ b/tests/unit/dev-auth-simulate.test.ts @@ -1,4 +1,7 @@ +import { webcrypto } from 'node:crypto'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAuthSession, getAuthSession } from '../../src/lib/utils/db'; +import type { SessionUser } from '../../src/lib/utils/session'; const mockRedirect = vi.fn((status: number, location: string) => { const err = new Error('Redirect') as Error & { status: number; location: string }; @@ -11,41 +14,65 @@ vi.mock('@sveltejs/kit', () => ({ redirect: (status: number, location: string) => mockRedirect(status, location) })); -describe('Dev auth simulation endpoint', () => { - function decodeSessionFromCookie(setCookieHeader: string): Record { - const sessionPart = setCookieHeader - .split(';') - .map((part) => part.trim()) - .find((part) => part.startsWith('session=')); - - if (!sessionPart) { - throw new Error('Session cookie not found'); - } - - const encoded = sessionPart.replace('session=', ''); - let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); - while (base64.length % 4) { - base64 += '='; +/** + * Stateful `sessions` stub. The simulator now stores its (pretend) payload + * server-side and the cookie is only the opaque id — exactly like a real login — + * so the test resolves the session the same way the app does, via getAuthSession, + * rather than decoding the cookie. + */ +function makeSessionDb() { + const rows = new Map(); + const db = { + prepare(sql: string) { + return { + bind(...args: unknown[]) { + return { + async run() { + if (/^INSERT INTO sessions/i.test(sql)) { + const [id, user_id, expires_at, data] = args as string[]; + rows.set(id, { user_id, expires_at, data }); + } + return { success: true }; + }, + async first() { + if (/^SELECT data FROM sessions/i.test(sql)) { + const row = rows.get(args[0] as string); + if (!row || new Date(row.expires_at).getTime() <= Date.now()) return null; + return { data: row.data } as T; + } + return null; + } + }; + } + }; } + }; + return { db: db as unknown as Parameters[0], rows }; +} + +function sessionIdFromCookie(setCookieHeader: string): string { + const part = setCookieHeader + .split(';') + .map((p) => p.trim()) + .find((p) => p.startsWith('session=')); + if (!part) throw new Error('Session cookie not found'); + return part.replace('session=', ''); +} - return JSON.parse(atob(base64)) as Record; - } - +describe('Dev auth simulation endpoint', () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + vi.stubGlobal('crypto', webcrypto as Crypto); }); it('creates a simulated GitHub session when bypass is enabled', async () => { const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + const { db } = makeSessionDb(); const response = await GET({ url: new URL('http://localhost/api/auth/dev-simulate?provider=github'), - platform: { - env: { - DEV_AUTH_BYPASS: 'true' - } - } + platform: { env: { DEV_AUTH_BYPASS: 'true', DB: db } } } as any); expect(response.status).toBe(302); @@ -53,21 +80,19 @@ describe('Dev auth simulation endpoint', () => { const cookieHeader = response.headers.get('Set-Cookie') || ''; expect(cookieHeader).toContain('session='); expect(cookieHeader).toContain('HttpOnly'); - const session = decodeSessionFromCookie(cookieHeader); - expect(session.isPretend).toBe(true); - expect(session.simulatedConnections).toEqual(['github']); + // The cookie is an opaque id; the payload is resolved from the store. + const session = await getAuthSession(db, sessionIdFromCookie(cookieHeader)); + expect(session?.isPretend).toBe(true); + expect(session?.simulatedConnections).toEqual(['github']); }); it('creates a simulated Discord session when bypass is enabled', async () => { const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + const { db } = makeSessionDb(); const response = await GET({ url: new URL('http://localhost/api/auth/dev-simulate?provider=discord'), - platform: { - env: { - DEV_AUTH_BYPASS: 'true' - } - } + platform: { env: { DEV_AUTH_BYPASS: 'true', DB: db } } } as any); expect(response.status).toBe(302); @@ -77,111 +102,126 @@ describe('Dev auth simulation endpoint', () => { it('creates admin session and redirects to admin when role=admin', async () => { const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + const { db } = makeSessionDb(); const response = await GET({ url: new URL('http://localhost/api/auth/dev-simulate?provider=github&role=admin'), - platform: { - env: { - DEV_AUTH_BYPASS: 'true' - } - } + platform: { env: { DEV_AUTH_BYPASS: 'true', DB: db } } } as any); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('http://localhost/admin'); - const cookieHeader = response.headers.get('Set-Cookie') || ''; - const session = decodeSessionFromCookie(cookieHeader); - expect(session.isAdmin).toBe(true); - expect(session.isOwner).toBe(false); - expect(session.isPretend).toBe(true); + const session = await getAuthSession( + db, + sessionIdFromCookie(response.headers.get('Set-Cookie') || '') + ); + expect(session?.isAdmin).toBe(true); + expect(session?.isOwner).toBe(false); + expect(session?.isPretend).toBe(true); }); it('creates superadmin session with owner privileges', async () => { const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + const { db } = makeSessionDb(); const response = await GET({ url: new URL('http://localhost/api/auth/dev-simulate?provider=discord&role=superadmin'), - platform: { - env: { - DEV_AUTH_BYPASS: 'true' - } - } + platform: { env: { DEV_AUTH_BYPASS: 'true', DB: db } } } as any); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('http://localhost/admin'); - const cookieHeader = response.headers.get('Set-Cookie') || ''; - const session = decodeSessionFromCookie(cookieHeader); - expect(session.isAdmin).toBe(true); - expect(session.isOwner).toBe(true); - expect(session.isPretend).toBe(true); + const session = await getAuthSession( + db, + sessionIdFromCookie(response.headers.get('Set-Cookie') || '') + ); + expect(session?.isAdmin).toBe(true); + expect(session?.isOwner).toBe(true); + expect(session?.isPretend).toBe(true); + }); + + it('refuses when the session store is unavailable', async () => { + const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + + // The simulator mints a real server-side session, so with no DB it cannot + // proceed — it fails closed rather than issuing an unresolvable cookie. + await expect( + GET({ + url: new URL('http://localhost/api/auth/dev-simulate?provider=github'), + platform: { env: { DEV_AUTH_BYPASS: 'true' } } + } as any) + ).rejects.toMatchObject({ status: 302, location: '/auth/login?error=not_configured' }); }); it('redirects to login when bypass is disabled', async () => { const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + const { db } = makeSessionDb(); await expect( GET({ url: new URL('http://localhost/api/auth/dev-simulate?provider=github'), - platform: { - env: { - DEV_AUTH_BYPASS: 'false' - } - } + platform: { env: { DEV_AUTH_BYPASS: 'false', DB: db } } + } as any) + ).rejects.toMatchObject({ status: 302, location: '/auth/login?error=not_configured' }); + }); + + it('refuses to run off a local host even with the bypass set', async () => { + const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + const { db } = makeSessionDb(); + + // The bypass must not re-enable the simulator on a deployed host — it mints + // a real owner/admin session, so this is the production-safety guard. + await expect( + GET({ + url: new URL('https://nebulakit.starspace.group/api/auth/dev-simulate?provider=github'), + platform: { env: { DEV_AUTH_BYPASS: 'true', DB: db } } } as any) ).rejects.toMatchObject({ status: 302, location: '/auth/login?error=not_configured' }); }); it('redirects to login for unsupported providers', async () => { const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + const { db } = makeSessionDb(); await expect( GET({ url: new URL('http://localhost/api/auth/dev-simulate?provider=google'), - platform: { - env: { - DEV_AUTH_BYPASS: 'true' - } - } + platform: { env: { DEV_AUTH_BYPASS: 'true', DB: db } } } as any) ).rejects.toMatchObject({ status: 302, location: '/auth/login?error=oauth_failed' }); }); it('links a provider onto an existing pretend session when mode=link', async () => { const { GET } = await import('../../src/routes/api/auth/dev-simulate/+server'); + const { db } = makeSessionDb(); - const existingSession = { + // Seed an existing pretend session and hand its opaque id back as the cookie. + const existing: SessionUser = { id: 'dev-github-abc12345', login: 'dev-github-abc12345', email: 'dev@example.dev', + name: 'Dev', isOwner: false, isAdmin: false, isPretend: true, simulatedConnections: ['github'] }; - - const encodedSession = btoa(JSON.stringify(existingSession)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + const existingId = await createAuthSession(db, existing); const response = await GET({ url: new URL('http://localhost/api/auth/dev-simulate?provider=discord&mode=link'), - platform: { - env: { - DEV_AUTH_BYPASS: 'true' - } - }, - cookies: { - get: vi.fn().mockReturnValue(encodedSession) - } + platform: { env: { DEV_AUTH_BYPASS: 'true', DB: db } }, + cookies: { get: vi.fn().mockReturnValue(existingId) } } as any); expect(response.status).toBe(302); expect(response.headers.get('Location')).toBe('http://localhost/profile?linked=discord'); - const updatedSession = decodeSessionFromCookie(response.headers.get('Set-Cookie') || ''); - expect(updatedSession.id).toBe(existingSession.id); - expect(updatedSession.simulatedConnections).toEqual(['github', 'discord']); + const updated = await getAuthSession( + db, + sessionIdFromCookie(response.headers.get('Set-Cookie') || '') + ); + expect(updated?.id).toBe(existing.id); + expect(updated?.simulatedConnections).toEqual(['github', 'discord']); }); }); diff --git a/tests/unit/discord-callback-extended.test.ts b/tests/unit/discord-callback-extended.test.ts index 9d4916d..37dc44c 100644 --- a/tests/unit/discord-callback-extended.test.ts +++ b/tests/unit/discord-callback-extended.test.ts @@ -15,6 +15,20 @@ function makeOAuthCookies(sessionValue: unknown = null) { }; } +// A permissive session-capable DB. A successful login now stores its payload +// server-side (createAuthSession) before issuing the opaque-id cookie, so tests +// exercising the success path need the store; without it the handler fails closed. +function makeSessionDb() { + return { + prepare: vi.fn().mockReturnValue({ + bind: vi.fn().mockReturnValue({ + first: vi.fn().mockResolvedValue(null), + all: vi.fn().mockResolvedValue({ results: [] }), + run: vi.fn().mockResolvedValue({ success: true }) + }) + }) + }; +} // Mock console to avoid noise vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -87,11 +101,14 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -109,11 +126,14 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -132,7 +152,9 @@ describe('Discord Callback Server - Extended Coverage', () => { it('should redirect with not_configured when no OAuth config found', async () => { const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: {} @@ -160,12 +182,15 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { DISCORD_CLIENT_ID: 'client-id', - DISCORD_CLIENT_SECRET: 'client-secret' + DISCORD_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -188,12 +213,15 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { DISCORD_CLIENT_ID: 'client-id', - DISCORD_CLIENT_SECRET: 'client-secret' + DISCORD_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -223,12 +251,15 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { DISCORD_CLIENT_ID: 'client-id', - DISCORD_CLIENT_SECRET: 'client-secret' + DISCORD_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -265,12 +296,15 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { DISCORD_CLIENT_ID: 'client-id', - DISCORD_CLIENT_SECRET: 'client-secret' + DISCORD_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -310,13 +344,16 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { DISCORD_CLIENT_ID: 'client-id', DISCORD_CLIENT_SECRET: 'client-secret', - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -355,13 +392,16 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { DISCORD_CLIENT_ID: 'client-id', DISCORD_CLIENT_SECRET: 'client-secret', - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -393,12 +433,15 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies('invalid-base64!!!'), platform: { env: { DISCORD_CLIENT_ID: 'client-id', - DISCORD_CLIENT_SECRET: 'client-secret' + DISCORD_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -455,7 +498,9 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(existingSession), platform: { env: { @@ -493,12 +538,15 @@ describe('Discord Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('https://example.com/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'https://example.com/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { DISCORD_CLIENT_ID: 'client-id', - DISCORD_CLIENT_SECRET: 'client-secret' + DISCORD_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -564,7 +612,9 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -635,7 +685,9 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -714,7 +766,9 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -786,7 +840,9 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -805,7 +861,7 @@ describe('Discord Callback Server - Extended Coverage', () => { }); describe('Database error handling', () => { - it('should continue auth even if DB fails', async () => { + it('fails closed when the session store is unavailable', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ access_token: 'test-token' }) @@ -829,7 +885,9 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -842,9 +900,10 @@ describe('Discord Callback Server - Extended Coverage', () => { const { GET } = await import('../../src/routes/api/auth/discord/callback/+server'); - // Should not throw, should continue with auth - const response = await GET(mockEvent as any); - expect(response.status).toBe(302); + // The session payload lives server-side now, so a DB that cannot store it + // must NOT yield an authenticated session — the old code trusted a + // client-authored cookie here, which was the auth bypass this replaces. + await expect(GET(mockEvent as any)).rejects.toBeTruthy(); }); }); @@ -892,7 +951,9 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(existingSession), platform: { env: { @@ -997,7 +1058,9 @@ describe('Discord Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/discord/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { diff --git a/tests/unit/github-callback-coverage.test.ts b/tests/unit/github-callback-coverage.test.ts index 4ba8f49..4ca9016 100644 --- a/tests/unit/github-callback-coverage.test.ts +++ b/tests/unit/github-callback-coverage.test.ts @@ -15,6 +15,20 @@ function makeOAuthCookies(sessionValue: unknown = null) { }; } +// A permissive session-capable DB. A successful login now stores its payload +// server-side (createAuthSession) before issuing the opaque-id cookie, so tests +// exercising the success path need the store; without it the handler fails closed. +function makeSessionDb() { + return { + prepare: vi.fn().mockReturnValue({ + bind: vi.fn().mockReturnValue({ + first: vi.fn().mockResolvedValue(null), + all: vi.fn().mockResolvedValue({ results: [] }), + run: vi.fn().mockResolvedValue({ success: true }) + }) + }) + }; +} // Mock console to avoid noise vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -87,11 +101,14 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -109,11 +126,14 @@ describe('GitHub Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -131,7 +151,9 @@ describe('GitHub Callback Server - Extended Coverage', () => { it('should redirect with not_configured when no OAuth config found', async () => { const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: {} @@ -159,12 +181,15 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', - GITHUB_CLIENT_SECRET: 'client-secret' + GITHUB_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -187,12 +212,15 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', - GITHUB_CLIENT_SECRET: 'client-secret' + GITHUB_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -222,12 +250,15 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', - GITHUB_CLIENT_SECRET: 'client-secret' + GITHUB_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -268,14 +299,17 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', GITHUB_CLIENT_SECRET: 'client-secret', GITHUB_OWNER_ID: 'owneruser', // username, not numeric - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -316,13 +350,16 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', GITHUB_CLIENT_SECRET: 'client-secret', - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -363,13 +400,16 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', GITHUB_CLIENT_SECRET: 'client-secret', - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -403,13 +443,16 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', GITHUB_CLIENT_SECRET: 'client-secret', - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -520,7 +563,9 @@ describe('GitHub Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -560,12 +605,15 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies('invalid-base64!!!'), platform: { env: { GITHUB_CLIENT_ID: 'client-id', - GITHUB_CLIENT_SECRET: 'client-secret' + GITHUB_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -629,7 +677,9 @@ describe('GitHub Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(existingSession), platform: { env: { @@ -694,7 +744,9 @@ describe('GitHub Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -774,7 +826,9 @@ describe('GitHub Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -845,7 +899,9 @@ describe('GitHub Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -895,13 +951,16 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', GITHUB_CLIENT_SECRET: 'client-secret', - KV: mockKV + KV: mockKV, + DB: makeSessionDb() } } }; @@ -933,12 +992,15 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); const mockEvent = { - url: new URL('https://example.com/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'https://example.com/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { GITHUB_CLIENT_ID: 'client-id', - GITHUB_CLIENT_SECRET: 'client-secret' + GITHUB_CLIENT_SECRET: 'client-secret', + DB: makeSessionDb() } } }; @@ -952,7 +1014,46 @@ describe('GitHub Callback Server - Extended Coverage', () => { }); describe('Database error handling', () => { - it('should continue auth even if DB fails', async () => { + it('redirects when no session store is configured at all', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access_token: 'test-token' }) + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 123456789, + login: 'testuser', + name: 'Test User', + email: 'test@github.com', + avatar_url: 'https://avatars.githubusercontent.com/u/123456789' + }) + }); + + // No DB binding at all — the new-user path cannot store a session, so it + // redirects to login rather than issuing one. Covers the fail-closed guard. + const mockEvent = { + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), + cookies: makeOAuthCookies(null), + platform: { + env: { + GITHUB_CLIENT_ID: 'client-id', + GITHUB_CLIENT_SECRET: 'client-secret' + } + } + }; + + const { GET } = await import('../../src/routes/api/auth/github/callback/+server'); + await expect(GET(mockEvent as any)).rejects.toMatchObject({ + status: 302, + location: '/auth/login?error=oauth_failed' + }); + }); + + it('fails closed when the session store is unavailable', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ access_token: 'test-token' }) @@ -976,7 +1077,9 @@ describe('GitHub Callback Server - Extended Coverage', () => { }; const mockEvent = { - url: new URL('http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(null), platform: { env: { @@ -989,9 +1092,10 @@ describe('GitHub Callback Server - Extended Coverage', () => { const { GET } = await import('../../src/routes/api/auth/github/callback/+server'); - // Should not throw, should continue with auth - const response = await GET(mockEvent as any); - expect(response.status).toBe(302); + // The session payload lives server-side now, so a DB that cannot store it + // must NOT yield an authenticated session. The old code trusted a + // client-authored cookie here — the exact auth bypass this replaces. + await expect(GET(mockEvent as any)).rejects.toBeTruthy(); }); }); }); diff --git a/tests/unit/github-oauth-flow.test.ts b/tests/unit/github-oauth-flow.test.ts index b5476b2..283e82a 100644 --- a/tests/unit/github-oauth-flow.test.ts +++ b/tests/unit/github-oauth-flow.test.ts @@ -15,6 +15,19 @@ function makeOAuthCookies(sessionValue: unknown = null) { }; } +// A session-capable DB mock. A successful login now stores its payload +// server-side (createAuthSession), so the callback needs the store like any real +// login; without it the handler correctly fails closed. +function makeSessionDb() { + return { + prepare: vi.fn().mockReturnValue({ + bind: vi.fn().mockReturnValue({ + first: vi.fn().mockResolvedValue(null), + run: vi.fn().mockResolvedValue({ success: true }) + }) + }) + }; +} /** * Tests for GitHub OAuth Endpoints @@ -134,7 +147,9 @@ describe('GitHub Auth API', () => { try { await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(), platform: mockPlatform } as any); @@ -163,7 +178,9 @@ describe('GitHub Auth API', () => { try { await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=invalid-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=invalid-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(), platform: mockPlatform } as any); @@ -191,7 +208,9 @@ describe('GitHub Auth API', () => { try { await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(), platform: mockPlatform } as any); @@ -226,7 +245,9 @@ describe('GitHub Auth API', () => { try { await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: makeOAuthCookies(), platform: mockPlatform } as any); @@ -244,7 +265,8 @@ describe('GitHub Auth API', () => { env: { GITHUB_CLIENT_ID: 'test-client', GITHUB_CLIENT_SECRET: 'test-secret', - GITHUB_OWNER_ID: '12345' + GITHUB_OWNER_ID: '12345', + DB: makeSessionDb() } }; @@ -268,7 +290,9 @@ describe('GitHub Auth API', () => { const { GET } = await import('../../src/routes/api/auth/github/callback/+server'); const response = await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: mockCookies, platform: mockPlatform } as any); @@ -288,7 +312,8 @@ describe('GitHub Auth API', () => { env: { GITHUB_CLIENT_ID: 'test-client', GITHUB_CLIENT_SECRET: 'test-secret', - GITHUB_OWNER_ID: '99999' // Different from user ID + GITHUB_OWNER_ID: '99999', // Different from user ID + DB: makeSessionDb() } }; @@ -312,7 +337,9 @@ describe('GitHub Auth API', () => { const { GET } = await import('../../src/routes/api/auth/github/callback/+server'); const response = await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: mockCookies, platform: mockPlatform } as any); @@ -361,7 +388,9 @@ describe('GitHub Auth API', () => { const { GET } = await import('../../src/routes/api/auth/github/callback/+server'); const response = await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: mockCookies, platform: mockPlatform } as any); @@ -420,7 +449,9 @@ describe('GitHub Auth API', () => { const { GET } = await import('../../src/routes/api/auth/github/callback/+server'); const response = await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: mockCookies, platform: mockPlatform } as any); @@ -439,6 +470,7 @@ describe('GitHub Auth API', () => { GITHUB_CLIENT_ID: 'test-client', GITHUB_CLIENT_SECRET: 'test-secret', GITHUB_OWNER_ID: '12345', + DB: makeSessionDb(), KV: { get: vi.fn().mockResolvedValue(null), // Not logged in before put: mockKVPut @@ -466,7 +498,9 @@ describe('GitHub Auth API', () => { const { GET } = await import('../../src/routes/api/auth/github/callback/+server'); const response = await GET({ - url: new URL('http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state'), + url: new URL( + 'http://localhost:4277/api/auth/github/callback?code=test-code&state=test-oauth-state' + ), cookies: mockCookies, platform: mockPlatform } as any); @@ -479,6 +513,7 @@ describe('GitHub Auth API', () => { describe('GET/POST /api/auth/logout', () => { it('should clear session cookie on GET logout', async () => { const mockCookies = { + get: vi.fn(), delete: vi.fn() }; @@ -498,6 +533,7 @@ describe('GitHub Auth API', () => { it('should clear session cookie on POST logout', async () => { const mockCookies = { + get: vi.fn(), delete: vi.fn() }; @@ -516,4 +552,3 @@ describe('GitHub Auth API', () => { }); }); }); - diff --git a/tests/unit/password-auth-api.test.ts b/tests/unit/password-auth-api.test.ts index d7d61a4..ae090e9 100644 --- a/tests/unit/password-auth-api.test.ts +++ b/tests/unit/password-auth-api.test.ts @@ -136,6 +136,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue({ id: 'existing-user' }) }) }) @@ -219,6 +220,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockImplementation(() => ({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue({ id: 'user-1', email: 'primary@example.com', @@ -290,6 +292,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue({ id: 'user-1', email: 'primary@example.com', @@ -324,6 +327,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue({ id: 'user-1', email: 'primary@example.com', @@ -360,6 +364,7 @@ describe('Password Auth APIs', () => { const passwordHash = await hashPassword('StrongPass123!'); const prepare = vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue({ id: '123', email: 'primary@example.com', @@ -384,7 +389,8 @@ describe('Password Auth APIs', () => { const payload = await response.json(); expect(payload.redirectTo).toBe('/admin'); - expect(prepare).toHaveBeenCalledTimes(1); + // One prepare for the user lookup, one for the server-side session INSERT. + expect(prepare).toHaveBeenCalledTimes(2); }); it('logs in as owner when the numeric owner id matches the linked github account', async () => { @@ -394,6 +400,7 @@ describe('Password Auth APIs', () => { const passwordHash = await hashPassword('StrongPass123!'); const prepare = vi.fn().mockImplementation((sql: string) => ({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue( sql.includes('FROM users u') ? { @@ -436,6 +443,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue({ id: 'user-1', email: 'primary@example.com', @@ -477,6 +485,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue({ id: 'user-1', email: 'primary@example.com', @@ -517,6 +526,7 @@ describe('Password Auth APIs', () => { const passwordHash = await hashPassword('StrongPass123!'); const prepare = vi.fn().mockImplementation((sql: string) => ({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: sql.includes('FROM users u') ? vi.fn().mockResolvedValue({ id: 'user-1', @@ -558,6 +568,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockImplementation((sql: string) => ({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue( sql.includes('FROM users u') ? { @@ -749,6 +760,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockImplementation((sql: string) => ({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue( sql.includes('FROM users u') ? { @@ -803,6 +815,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue({ id: 'target-user', email: 'target@example.com', @@ -839,6 +852,7 @@ describe('Password Auth APIs', () => { DB: { prepare: vi.fn().mockReturnValue({ bind: vi.fn().mockReturnValue({ + run: vi.fn().mockResolvedValue({ success: true }), first: vi.fn().mockResolvedValue(null) }) }) diff --git a/tests/unit/reset-api.test.ts b/tests/unit/reset-api.test.ts index fae6d61..c7928ab 100644 --- a/tests/unit/reset-api.test.ts +++ b/tests/unit/reset-api.test.ts @@ -11,7 +11,6 @@ const OWNER_LOCALS = { } }; - /** * Tests for Reset API * TDD: Testing the reset configuration endpoint @@ -41,6 +40,7 @@ describe('Reset API', () => { const { POST } = await import('../../src/routes/api/reset/+server'); const mockCookies = { + get: vi.fn().mockReturnValue(undefined), delete: vi.fn() }; @@ -58,6 +58,7 @@ describe('Reset API', () => { const mockGet = vi.fn().mockResolvedValue('true'); const mockCookies = { + get: vi.fn().mockReturnValue(undefined), delete: vi.fn() }; @@ -89,7 +90,7 @@ describe('Reset API', () => { } } }, - cookies: { delete: mockCookiesDelete } + cookies: { get: vi.fn().mockReturnValue(undefined), delete: mockCookiesDelete } } as any); const data = await response.json(); @@ -128,7 +129,7 @@ describe('Reset API', () => { } } }, - cookies: { delete: mockCookiesDelete } + cookies: { get: vi.fn().mockReturnValue(undefined), delete: mockCookiesDelete } } as any); const data = await response.json(); @@ -141,6 +142,7 @@ describe('Reset API', () => { const mockGet = vi.fn().mockRejectedValue(new Error('Unexpected error')); const mockCookies = { + get: vi.fn().mockReturnValue(undefined), delete: vi.fn() }; @@ -160,6 +162,7 @@ describe('Reset API', () => { httpError.status = 404; const mockGet = vi.fn().mockRejectedValue(httpError); const mockCookies = { + get: vi.fn().mockReturnValue(undefined), delete: vi.fn() };