diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..9d71609 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,24 @@ +# Local development config for `npm run dev`. +# Copy this file to `.dev.vars` (gitignored, never deployed) and adjust. +# Values here override the production [vars] in wrangler.toml when running locally. + +# Auto-login as a fixed fake test user (devuser) — no OAuth needed for local dev. +# Gated to localhost, and the flag only lives in .dev.vars, so it can never be +# active in production. Set to "false" (or remove) to test the real OAuth flow +# or the logged-out UI, then restart `npm run dev`. +DEV_AUTH=true + +# Secret used to sign session JWTs. Any long random string works locally. +JWT_SECRET=any-long-random-string-for-local-dev + +# Base URL for OAuth redirect_uri and generated install scripts. Overrides the +# production APP_URL in wrangler.toml. Match the port `npm run dev` prints. +APP_URL=http://localhost:5173 + +# --- Optional: only needed if you set DEV_AUTH=false to test real OAuth --- +# GitHub OAuth app — callback URL: http://localhost:5173/api/auth/callback/github +# GITHUB_CLIENT_ID=your_github_oauth_client_id +# GITHUB_CLIENT_SECRET=your_github_oauth_client_secret +# Google OAuth — redirect URI: http://localhost:5173/api/auth/callback/google +# GOOGLE_CLIENT_ID=your_google_oauth_client_id +# GOOGLE_CLIENT_SECRET=your_google_oauth_client_secret diff --git a/src/app.d.ts b/src/app.d.ts index 61d8b43..d8e5f1d 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -15,6 +15,7 @@ declare global { GOOGLE_CLIENT_SECRET: string; JWT_SECRET: string; APP_URL: string; + DEV_AUTH?: string; }; } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 1d1ec75..dd3725d 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,6 +2,7 @@ import type { Handle } from '@sveltejs/kit'; import { RESERVED_ALIASES } from '$lib/server/validation'; import { getConfigForHookAlias, getConfigForInstall, getConfigForHookSlug } from '$lib/server/db'; import { serveInstallByAlias, serveInstallBySlug } from '$lib/server/alias'; +import { ensureDevSession } from '$lib/server/dev-auth'; const INSTALL_SCRIPT_URL = 'https://raw.githubusercontent.com/openbootdotdev/openboot/main/scripts/install.sh'; @@ -104,6 +105,10 @@ export const handle: Handle = async ({ event, resolve }) => { } } + // Local-dev only: auto-authenticate as a fixed test user (gated on DEV_AUTH + + // localhost). No-op in production. + await ensureDevSession(event); + const response = await resolve(event); const securedResponse = withSecurityHeaders(response); diff --git a/src/lib/server/dev-auth.test.ts b/src/lib/server/dev-auth.test.ts new file mode 100644 index 0000000..76dcab4 --- /dev/null +++ b/src/lib/server/dev-auth.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for dev-auth.ts — the local-development auto-login and its safety gate. + * Runs inside the Workers runtime with real D1 (via vitest-pool-workers). + * + * The security-critical property: ensureDevSession must be a no-op unless BOTH + * DEV_AUTH === 'true' AND the request is served from localhost. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import { ensureDevSession } from './dev-auth'; +import { verifyToken } from './auth'; +import { getUserById, countUserConfigs } from './db'; +import { resetDb } from '$lib/test/seed'; + +const db = env.DB; +const JWT_SECRET = 'test-jwt-secret-key-32-chars-long'; + +function makeCookies(values: Record = {}) { + const store = { ...values }; + return { + get: (n: string) => store[n], + set: (n: string, v: string) => { + store[n] = v; + }, + delete: (n: string) => { + delete store[n]; + }, + getAll: () => Object.entries(store).map(([name, value]) => ({ name, value })), + serialize: () => '' + } as any; +} + +function makeEvent(opts: { devAuth?: string; host?: string; cookies?: ReturnType }) { + const host = opts.host ?? 'localhost'; + return { + platform: { env: { DB: db, JWT_SECRET, DEV_AUTH: opts.devAuth } }, + url: new URL(`http://${host}:5173/dashboard`), + request: new Request(`http://${host}:5173/dashboard`), + cookies: opts.cookies ?? makeCookies() + } as any; +} + +beforeEach(async () => { + await resetDb(db); +}); + +describe('ensureDevSession', () => { + it('auto-logs in the dev user on localhost when DEV_AUTH=true', async () => { + const cookies = makeCookies(); + await ensureDevSession(makeEvent({ devAuth: 'true', cookies })); + + const token = cookies.get('session'); + expect(token).toBeTruthy(); + const payload = await verifyToken(token!, JWT_SECRET); + expect(payload?.userId).toBe('dev-user'); + + const user = await getUserById(db, 'dev-user'); + expect(user?.username).toBe('devuser'); + expect(await countUserConfigs(db, 'dev-user')).toBe(1); + }); + + it('is a no-op when DEV_AUTH is not "true"', async () => { + const cookies = makeCookies(); + await ensureDevSession(makeEvent({ devAuth: 'false', cookies })); + + expect(cookies.get('session')).toBeUndefined(); + expect(await getUserById(db, 'dev-user')).toBeNull(); + }); + + it('is a no-op when DEV_AUTH is unset', async () => { + const cookies = makeCookies(); + await ensureDevSession(makeEvent({ devAuth: undefined, cookies })); + + expect(cookies.get('session')).toBeUndefined(); + expect(await getUserById(db, 'dev-user')).toBeNull(); + }); + + it('is a no-op when the host is not localhost (gate holds even if the flag leaks)', async () => { + const cookies = makeCookies(); + await ensureDevSession(makeEvent({ devAuth: 'true', host: 'openboot.dev', cookies })); + + expect(cookies.get('session')).toBeUndefined(); + expect(await getUserById(db, 'dev-user')).toBeNull(); + }); + + it('is idempotent — does not reseed when already authenticated', async () => { + const cookies = makeCookies(); + await ensureDevSession(makeEvent({ devAuth: 'true', cookies })); + const firstToken = cookies.get('session'); + + await ensureDevSession(makeEvent({ devAuth: 'true', cookies })); + + expect(cookies.get('session')).toBe(firstToken); + expect(await countUserConfigs(db, 'dev-user')).toBe(1); + }); +}); diff --git a/src/lib/server/dev-auth.ts b/src/lib/server/dev-auth.ts new file mode 100644 index 0000000..2856e24 --- /dev/null +++ b/src/lib/server/dev-auth.ts @@ -0,0 +1,55 @@ +import type { RequestEvent } from '@sveltejs/kit'; +import { signToken, getCurrentUser, generateId } from './auth'; +import { upsertUser, countUserConfigs, createDefaultConfig } from './db'; + +// Fixed fake account used for local-development auto-login. +const DEV_USER = { + id: 'dev-user', + username: 'devuser', + email: 'dev@localhost' +}; + +const THIRTY_DAYS = 30 * 24 * 60 * 60; + +/** + * Dev auto-login is gated twice so it can never be active in production: + * 1. `DEV_AUTH === 'true'` — and that flag only ever lives in `.dev.vars`, + * which is gitignored and never deployed, so production env won't have it. + * 2. The request must be served from localhost — production runs on + * openboot.dev, so even a leaked flag can't open the gate there. + */ +function isDevAuthEnabled(event: RequestEvent): boolean { + if (event.platform?.env?.DEV_AUTH !== 'true') return false; + const host = event.url.hostname; + return host === 'localhost' || host === '127.0.0.1'; +} + +/** + * On a gated local dev server, make every request authenticated as the fixed + * dev user: seed it (and a default config) into the local D1 on first use and + * issue a session cookie. No-op everywhere else, so production is untouched. + */ +export async function ensureDevSession(event: RequestEvent): Promise { + if (!isDevAuthEnabled(event)) return; + const env = event.platform!.env; + + const existing = await getCurrentUser(event.request, event.cookies, env.DB, env.JWT_SECRET); + if (existing) return; + + await upsertUser(env.DB, DEV_USER.id, DEV_USER.username, DEV_USER.email, ''); + if ((await countUserConfigs(env.DB, DEV_USER.id)) === 0) { + await createDefaultConfig(env.DB, generateId(), DEV_USER.id); + } + + const token = await signToken( + { userId: DEV_USER.id, username: DEV_USER.username, exp: Date.now() + THIRTY_DAYS * 1000 }, + env.JWT_SECRET + ); + event.cookies.set('session', token, { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: THIRTY_DAYS + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 6287143..df634e1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ exclude: [ 'src/routes/api/health/**/*.test.ts', 'src/lib/server/auth.test.ts', + 'src/lib/server/dev-auth.test.ts', 'src/lib/server/db/configs.test.ts', 'src/routes/api/configs/server.test.ts', 'src/routes/api/configs/[slug]/server.test.ts', diff --git a/vitest.workers.config.ts b/vitest.workers.config.ts index d8b3470..e434172 100644 --- a/vitest.workers.config.ts +++ b/vitest.workers.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ include: [ 'src/routes/api/health/**/*.test.ts', 'src/lib/server/auth.test.ts', + 'src/lib/server/dev-auth.test.ts', 'src/lib/server/db/configs.test.ts', 'src/routes/api/configs/server.test.ts', 'src/routes/api/configs/[slug]/server.test.ts',