Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ declare global {
GOOGLE_CLIENT_SECRET: string;
JWT_SECRET: string;
APP_URL: string;
DEV_AUTH?: string;
};
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);

Expand Down
97 changes: 97 additions & 0 deletions src/lib/server/dev-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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<typeof makeCookies> }) {
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);
});
});
55 changes: 55 additions & 0 deletions src/lib/server/dev-auth.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
});
}
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions vitest.workers.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading