mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
feat: add dev-only login for local admin access without OAuth
- POST /api/auth/dev-login creates/finds user, promotes to admin, sets session cookie - POST /api/auth/dev-logout clears session cookie - Dev login page at #/dev-login in the SPA - Fix: getSessionCookieAttributes omits Secure over HTTP (local dev fix) - Guarded: only works when PUBLIC_HOST=localhost, returns 403 otherwise - Justfile: dev-admin and dev-logout commands - README: local admin login instructions
This commit is contained in:
@@ -19,6 +19,27 @@ just dev # start Worker on http://localhost:8787
|
||||
|
||||
Open `http://localhost:8787` in your browser — the SPA and API are both served by the Worker.
|
||||
|
||||
### Sign in as admin locally
|
||||
|
||||
OAuth isn't available in local dev. Use the dev-only login instead:
|
||||
|
||||
1. Start the worker: `just dev`
|
||||
2. Open `http://localhost:8787/#/dev-login` in your browser
|
||||
3. Enter your email (pre-filled with `wahyd4@gmail.com`) and click "Sign in as admin"
|
||||
|
||||
Or via CLI:
|
||||
|
||||
```bash
|
||||
just dev-admin # creates admin user + session
|
||||
just dev-admin other@example.com # different email
|
||||
just dev-logout # clears the session cookie
|
||||
```
|
||||
|
||||
This endpoint is guarded: it only works when `PUBLIC_HOST=localhost` (or `APP_BASE_URL`
|
||||
contains `localhost`). In dev/prod deployments it returns 403. The dev login bypasses
|
||||
OAuth, finds-or-creates the user, promotes them to admin, and sets a `heygo_session`
|
||||
cookie — exactly what a real OAuth callback would do, minus the provider.
|
||||
|
||||
### Just commands
|
||||
|
||||
Run `just` (no args) to list all recipes. The most common ones:
|
||||
|
||||
@@ -46,6 +46,26 @@ smoke: build migrate-local
|
||||
echo "--- Root (SPA) ---"; curl -sI http://localhost:{{port}}/ | head -1; \
|
||||
kill $$SERVER_PID 2>/dev/null
|
||||
|
||||
# ── Local dev auth ─────────────────────────────────────────
|
||||
|
||||
# Create admin user and sign in locally (prints URL to open in browser)
|
||||
# Requires the worker to be running: `just dev`
|
||||
# Usage: just dev-admin wahyd4@gmail.com
|
||||
dev-admin email="wahyd4@gmail.com":
|
||||
@echo "=== Creating admin user via dev login API ==="
|
||||
@curl -s -X POST http://localhost:{{port}}/api/auth/dev-login \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"email":"{{email}}"}'
|
||||
@echo
|
||||
@echo "Or open http://localhost:{{port}}/#/dev-login in your browser"
|
||||
|
||||
# Sign out locally (clears session cookie)
|
||||
# Requires the worker to be running: `just dev`
|
||||
dev-logout:
|
||||
@curl -s -X POST http://localhost:{{port}}/api/auth/dev-logout
|
||||
@echo
|
||||
@echo "Signed out."
|
||||
|
||||
# ── Local D1 helpers ───────────────────────────────────────
|
||||
|
||||
# Run an arbitrary SQL query against local D1 (quote the SQL)
|
||||
|
||||
+5
-2
@@ -3,18 +3,20 @@ import LoginPage from './routes/LoginPage';
|
||||
import PrivateLinksPage from './routes/PrivateLinksPage';
|
||||
import PublicLinksPage from './routes/PublicLinksPage';
|
||||
import AdminReviewPage from './routes/AdminReviewPage';
|
||||
import DevLoginPage from './routes/DevLoginPage';
|
||||
|
||||
type RouteId = 'private' | 'public' | 'admin' | 'login';
|
||||
type RouteId = 'private' | 'public' | 'admin' | 'login' | 'dev-login';
|
||||
|
||||
const ROUTES: readonly { id: RouteId; label: string }[] = [
|
||||
{ id: 'private', label: 'Private Links' },
|
||||
{ id: 'public', label: 'Public Directory' },
|
||||
{ id: 'admin', label: 'Admin Review' },
|
||||
{ id: 'login', label: 'Login' },
|
||||
{ id: 'dev-login', label: 'Dev Login' },
|
||||
];
|
||||
|
||||
function readRouteFromHash(): RouteId {
|
||||
const match = window.location.hash.match(/^#\/(private|public|admin|login)/);
|
||||
const match = window.location.hash.match(/^#\/(private|public|admin|login|dev-login)/);
|
||||
return (match?.[1] as RouteId) ?? 'private';
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ export default function App() {
|
||||
{route === 'public' ? <PublicLinksPage /> : null}
|
||||
{route === 'admin' ? <AdminReviewPage /> : null}
|
||||
{route === 'login' ? <LoginPage /> : null}
|
||||
{route === 'dev-login' ? <DevLoginPage /> : null}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function DevLoginPage() {
|
||||
const [email, setEmail] = useState('wahyd4@gmail.com');
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setStatus('loading');
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/auth/dev-login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({ error: 'Login failed' }))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(data.error || 'Login failed');
|
||||
}
|
||||
setStatus('success');
|
||||
// Redirect to private links page after short delay
|
||||
setTimeout(() => {
|
||||
window.location.hash = '/private';
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel login-panel">
|
||||
<h1>Dev Login (local only)</h1>
|
||||
<p className="muted">
|
||||
This login bypasses OAuth and is only available when running locally
|
||||
(PUBLIC_HOST=localhost). The user is automatically promoted to admin.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="dev-login-form">
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={status === 'loading'}>
|
||||
{status === 'loading' ? 'Signing in…' : 'Sign in as admin'}
|
||||
</button>
|
||||
{status === 'success' && <p className="success-msg">✅ Signed in! Redirecting…</p>}
|
||||
{status === 'error' && <p className="error-msg">❌ {error}</p>}
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+10
-3
@@ -25,17 +25,24 @@ describe('session cookie policy', () => {
|
||||
it('uses the configured cookie domain when one is supplied', () => {
|
||||
expect(getSessionCookieAttributes('https://my.heygo.cc/app', '.heygo.cc')).toEqual([
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
'Secure',
|
||||
'Domain=.heygo.cc',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits Secure over HTTP (local dev) even when a domain is set', () => {
|
||||
expect(getSessionCookieAttributes('http://localhost:8787/app', '')).toEqual([
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits Domain when the cookie domain env var is empty (localhost development)', () => {
|
||||
expect(getSessionCookieAttributes('http://localhost:5173/app', '')).toEqual([
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
]);
|
||||
@@ -44,9 +51,9 @@ describe('session cookie policy', () => {
|
||||
it('honors a custom cookie domain for non-heygo hosts', () => {
|
||||
expect(getSessionCookieAttributes('https://my.example.com/app', '.example.com')).toEqual([
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
'Secure',
|
||||
'Domain=.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import worker from '../worker/index';
|
||||
import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth';
|
||||
import type { Env } from '../worker/env';
|
||||
|
||||
type UserRole = 'user' | 'admin';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
session_token_hash: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
type RunCall = {
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
};
|
||||
|
||||
type FirstCall = {
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
};
|
||||
|
||||
class FakeDevAuthD1 {
|
||||
readonly users: UserRow[] = [];
|
||||
readonly sessions: SessionRow[] = [];
|
||||
readonly runCalls: RunCall[] = [];
|
||||
readonly firstCalls: FirstCall[] = [];
|
||||
|
||||
prepare(sql: string): FakeDevAuthStatement {
|
||||
return new FakeDevAuthStatement(this, sql);
|
||||
}
|
||||
|
||||
findUserByEmail(email: string): UserRow | null {
|
||||
return this.users.find((u) => u.email === email) ?? null;
|
||||
}
|
||||
|
||||
findSessionByHash(hash: string): SessionRow | null {
|
||||
return this.sessions.find((s) => s.session_token_hash === hash) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDevAuthStatement {
|
||||
private params: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly db: FakeDevAuthD1,
|
||||
private readonly sql: string,
|
||||
) {}
|
||||
|
||||
bind(...params: unknown[]): this {
|
||||
this.params = params;
|
||||
return this;
|
||||
}
|
||||
|
||||
async first<T>(): Promise<T | null> {
|
||||
this.db.firstCalls.push({ sql: this.sql, params: this.params });
|
||||
|
||||
if (this.sql.includes('FROM users') && this.sql.includes('email = ?')) {
|
||||
const row = this.db.findUserByEmail(String(this.params[0]));
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return { id: row.id, email: row.email, name: row.name, role: row.role } as T;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async run(): Promise<D1Result> {
|
||||
this.db.runCalls.push({ sql: this.sql, params: this.params });
|
||||
|
||||
if (this.sql.startsWith('INSERT INTO users')) {
|
||||
const [id, email, name, role] = this.params;
|
||||
this.db.users.push({
|
||||
id: String(id),
|
||||
email: String(email),
|
||||
name: name == null ? null : String(name),
|
||||
role: String(role) as UserRole,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.sql.startsWith('UPDATE users SET role')) {
|
||||
const [role, id] = this.params;
|
||||
const row = this.db.users.find((u) => u.id === String(id));
|
||||
if (row) {
|
||||
row.role = String(role) as UserRole;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.sql.startsWith('INSERT INTO sessions')) {
|
||||
const [id, userId, tokenHash, expiresAt] = this.params;
|
||||
this.db.sessions.push({
|
||||
id: String(id),
|
||||
user_id: String(userId),
|
||||
session_token_hash: String(tokenHash),
|
||||
expires_at: String(expiresAt),
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, meta: { changes: 1 } } as unknown as D1Result;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeExecutionContext {
|
||||
waitUntil(): void {}
|
||||
passThroughOnException(): void {}
|
||||
}
|
||||
|
||||
type DevLoginResponse = {
|
||||
ok: boolean;
|
||||
user: { id: string; email: string; name: string | null; role: string };
|
||||
};
|
||||
|
||||
type DevErrorResponse = { error: string };
|
||||
|
||||
type EnvOverrides = Partial<Env>;
|
||||
|
||||
function makeEnv(db: FakeDevAuthD1, overrides: EnvOverrides = {}): Env {
|
||||
return {
|
||||
DB: db as unknown as D1Database,
|
||||
PUBLIC_HOST: 'localhost',
|
||||
PRIVATE_HOST: 'localhost',
|
||||
APP_BASE_URL: 'http://localhost:8787',
|
||||
COOKIE_DOMAIN: '',
|
||||
...overrides,
|
||||
} as Env;
|
||||
}
|
||||
|
||||
function prodEnv(overrides: EnvOverrides = {}): Env {
|
||||
return {
|
||||
DB: {} as D1Database,
|
||||
PUBLIC_HOST: 'heygo.cc',
|
||||
PRIVATE_HOST: 'my.heygo.cc',
|
||||
APP_BASE_URL: 'https://heygo.cc',
|
||||
COOKIE_DOMAIN: '.heygo.cc',
|
||||
...overrides,
|
||||
} as Env;
|
||||
}
|
||||
|
||||
async function fetchWorker(
|
||||
path: string,
|
||||
env: Env,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const url = `http://localhost:8787${path}`;
|
||||
const request = new Request(url, init);
|
||||
const ctx = new FakeExecutionContext();
|
||||
return worker.fetch(
|
||||
request as unknown as Parameters<typeof worker.fetch>[0],
|
||||
env as unknown as Parameters<typeof worker.fetch>[1],
|
||||
ctx as unknown as Parameters<typeof worker.fetch>[2],
|
||||
);
|
||||
}
|
||||
|
||||
function jsonPost(path: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
describe('dev auth endpoint', () => {
|
||||
it('creates a new admin user and sets a session cookie on POST /api/auth/dev-login', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-login',
|
||||
env,
|
||||
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const setCookie = response.headers.get('set-cookie');
|
||||
expect(setCookie).not.toBeNull();
|
||||
expect(setCookie).toContain(`${AUTH_SESSION_COOKIE_NAME}=`);
|
||||
expect(setCookie).toContain('HttpOnly');
|
||||
expect(setCookie).toContain('Path=/');
|
||||
// Local dev is HTTP, so Secure must NOT be present
|
||||
expect(setCookie).not.toContain('Secure');
|
||||
|
||||
const data = (await response.json()) as DevLoginResponse;
|
||||
expect(data).toMatchObject({
|
||||
ok: true,
|
||||
user: { email: 'wahyd4@gmail.com', role: 'admin' },
|
||||
});
|
||||
|
||||
// A user was created with admin role
|
||||
expect(db.users).toHaveLength(1);
|
||||
expect(db.users[0].email).toBe('wahyd4@gmail.com');
|
||||
expect(db.users[0].role).toBe('admin');
|
||||
|
||||
// A session was created
|
||||
expect(db.sessions).toHaveLength(1);
|
||||
const createdSession = db.sessions[0];
|
||||
expect(createdSession.user_id).toBe(db.users[0].id);
|
||||
expect(createdSession.session_token_hash).not.toBe('');
|
||||
// expires_at is a valid future ISO date
|
||||
const expiresAt = Date.parse(createdSession.expires_at);
|
||||
expect(Number.isNaN(expiresAt)).toBe(false);
|
||||
expect(expiresAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('reuses an existing user and promotes a non-admin to admin', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
db.users.push({
|
||||
id: 'user_existing',
|
||||
email: 'wahyd4@gmail.com',
|
||||
name: 'wahyd4',
|
||||
role: 'user',
|
||||
});
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-login',
|
||||
env,
|
||||
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = (await response.json()) as DevLoginResponse;
|
||||
expect(data.user.role).toBe('admin');
|
||||
expect(data.user.id).toBe('user_existing');
|
||||
|
||||
// No new user inserted
|
||||
expect(db.users).toHaveLength(1);
|
||||
// Role promoted to admin via UPDATE
|
||||
expect(db.users[0].role).toBe('admin');
|
||||
const updateCall = db.runCalls.find((c) => c.sql.startsWith('UPDATE users SET role'));
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.params).toEqual(['admin', 'user_existing']);
|
||||
|
||||
// Session was still created
|
||||
expect(db.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not issue an UPDATE when the existing user is already admin', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
db.users.push({
|
||||
id: 'user_admin',
|
||||
email: 'wahyd4@gmail.com',
|
||||
name: 'wahyd4',
|
||||
role: 'admin',
|
||||
});
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-login',
|
||||
env,
|
||||
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(db.runCalls.find((c) => c.sql.startsWith('UPDATE users SET role'))).toBeUndefined();
|
||||
expect(db.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects GET /api/auth/dev-login with 405', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker('/api/auth/dev-login', env, { method: 'GET' });
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
const data = (await response.json()) as DevErrorResponse;
|
||||
expect(data.error).toMatch(/method/i);
|
||||
// No DB writes happened
|
||||
expect(db.runCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an invalid email with 400', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-login',
|
||||
env,
|
||||
jsonPost('/api/auth/dev-login', { email: 'not-an-email' }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = (await response.json()) as DevErrorResponse;
|
||||
expect(data.error).toMatch(/email/i);
|
||||
expect(db.users).toHaveLength(0);
|
||||
expect(db.sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a missing email with 400', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-login',
|
||||
env,
|
||||
jsonPost('/api/auth/dev-login', {}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(db.users).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an invalid JSON body with 400', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker('/api/auth/dev-login', env, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: 'not-json',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = (await response.json()) as DevErrorResponse;
|
||||
expect(data.error).toMatch(/json/i);
|
||||
});
|
||||
|
||||
it('is guarded: returns 403 when PUBLIC_HOST is not localhost', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = prodEnv({ DB: db as unknown as D1Database });
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-login',
|
||||
env,
|
||||
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const data = (await response.json()) as DevErrorResponse;
|
||||
expect(data.error).toMatch(/local/i);
|
||||
// No user or session created
|
||||
expect(db.users).toHaveLength(0);
|
||||
expect(db.sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is guarded: returns 403 even when APP_BASE_URL has no localhost', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = prodEnv({ DB: db as unknown as D1Database, PUBLIC_HOST: 'dev.heygo.cc' });
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-login',
|
||||
env,
|
||||
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(db.users).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('POST /api/auth/dev-logout clears the session cookie with a past expiry', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-logout',
|
||||
env,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ ok: true });
|
||||
|
||||
const setCookie = response.headers.get('set-cookie');
|
||||
expect(setCookie).not.toBeNull();
|
||||
expect(setCookie).toContain(`${AUTH_SESSION_COOKIE_NAME}=;`);
|
||||
// Must clear the cookie via an expiry in the past
|
||||
expect(setCookie).toContain('Expires=');
|
||||
// Should NOT contain Secure (HTTP local dev)
|
||||
expect(setCookie).not.toContain('Secure');
|
||||
});
|
||||
|
||||
it('dev-logout is guarded: returns 403 when not local dev', async () => {
|
||||
const env = prodEnv();
|
||||
|
||||
const response = await fetchWorker('/api/auth/dev-logout', env, { method: 'POST' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns a valid session that getCurrentUser can later resolve', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker(
|
||||
'/api/auth/dev-login',
|
||||
env,
|
||||
jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const setCookie = response.headers.get('set-cookie');
|
||||
expect(setCookie).not.toBeNull();
|
||||
// Extract the raw token value from the Set-Cookie header
|
||||
const tokenMatch = setCookie!.match(new RegExp(`${AUTH_SESSION_COOKIE_NAME}=([^;]+)`));
|
||||
expect(tokenMatch).not.toBeNull();
|
||||
const token = tokenMatch![1];
|
||||
|
||||
// The stored hash must match a SHA-256 of the token we issued
|
||||
const expectedHash = await hashSessionToken(token);
|
||||
const session = db.findSessionByHash(expectedHash);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.user_id).toBe(db.users[0].id);
|
||||
});
|
||||
|
||||
it('falls through (404) for unrelated /api/ paths', async () => {
|
||||
const db = new FakeDevAuthD1();
|
||||
const env = makeEnv(db);
|
||||
|
||||
const response = await fetchWorker('/api/some-other-path', env, { method: 'GET' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
// No DB writes for dev auth
|
||||
expect(db.runCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+8
-2
@@ -36,8 +36,14 @@ export function getSessionCookieAttributes(
|
||||
// The request URL is retained so callers can derive a host-specific domain in
|
||||
// the future; the shared Domain attribute now comes from the COOKIE_DOMAIN
|
||||
// env var so each deployment (dev/prod/local) controls its own scope.
|
||||
void requestUrl;
|
||||
const attributes = ['HttpOnly', 'Secure', 'SameSite=Lax', 'Path=/'];
|
||||
const url = typeof requestUrl === 'string' ? new URL(requestUrl) : requestUrl;
|
||||
const attributes = ['HttpOnly', 'SameSite=Lax', 'Path=/'];
|
||||
|
||||
// Only set Secure over HTTPS. Local dev (wrangler dev) runs on HTTP;
|
||||
// a Secure cookie would be silently dropped by the browser.
|
||||
if (url.protocol === 'https:') {
|
||||
attributes.push('Secure');
|
||||
}
|
||||
|
||||
if (cookieDomain) {
|
||||
attributes.push(`Domain=${cookieDomain}`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Env } from './env';
|
||||
import { withPrivateNoStoreHeaders } from './lib/responses';
|
||||
import { handleDevAuth } from './routes/api.dev-auth';
|
||||
import { handleLinksApi } from './routes/api.links';
|
||||
import { handlePromotionsApi } from './routes/api.promotions';
|
||||
import { handlePrivateShortlink, isHeygoPrivateHost } from './routes/private-redirect';
|
||||
@@ -34,6 +35,10 @@ export default {
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
const devAuthResponse = await handleDevAuth(request, env);
|
||||
if (devAuthResponse) {
|
||||
return withPrivateHostNoStoreHeaders(url, devAuthResponse, env.PRIVATE_HOST);
|
||||
}
|
||||
const apiResponse = await handleLinksApi(request, env);
|
||||
if (apiResponse) {
|
||||
return withPrivateHostNoStoreHeaders(url, apiResponse, env.PRIVATE_HOST);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
AUTH_SESSION_COOKIE_NAME,
|
||||
getSessionCookieAttributes,
|
||||
hashSessionToken,
|
||||
} from '../auth';
|
||||
import type { Env } from '../env';
|
||||
|
||||
// Guard: only allow when running locally. The dev login bypasses OAuth and
|
||||
// auto-promotes the user to admin, so it must never be reachable from a
|
||||
// deployed environment.
|
||||
function isLocalDev(env: Env): boolean {
|
||||
return env.PUBLIC_HOST === 'localhost' || env.APP_BASE_URL.includes('localhost');
|
||||
}
|
||||
|
||||
const FIND_USER_BY_EMAIL = `SELECT id, email, name, role FROM users WHERE email = ? LIMIT 1`;
|
||||
const CREATE_USER = `INSERT INTO users (id, email, name, image_url, role) VALUES (?, ?, ?, NULL, ?)`;
|
||||
const UPDATE_USER_ROLE = `UPDATE users SET role = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`;
|
||||
const CREATE_SESSION = `INSERT INTO sessions (id, user_id, session_token_hash, expires_at) VALUES (?, ?, ?, ?)`;
|
||||
|
||||
// Session expires in 30 days
|
||||
const SESSION_TTL_DAYS = 30;
|
||||
|
||||
interface DevLoginBody {
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface DevUserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export async function handleDevAuth(request: Request, env: Env): Promise<Response | null> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/api/auth/dev-logout') {
|
||||
// Guard: reject in non-local environments
|
||||
if (!isLocalDev(env)) {
|
||||
return Response.json(
|
||||
{ error: 'Dev logout is only available in local development' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the session cookie by setting it with an expiry in the past.
|
||||
const cookieAttrs = getSessionCookieAttributes(request.url, env.COOKIE_DOMAIN);
|
||||
const expired = new Date(0).toUTCString();
|
||||
const cookieValue = `${AUTH_SESSION_COOKIE_NAME}=; Expires=${expired}; ${cookieAttrs.join('; ')}`;
|
||||
return Response.json({ ok: true }, { headers: { 'Set-Cookie': cookieValue } });
|
||||
}
|
||||
|
||||
// Only handle /api/auth/dev-login
|
||||
if (url.pathname !== '/api/auth/dev-login') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard: reject in non-local environments
|
||||
if (!isLocalDev(env)) {
|
||||
return Response.json(
|
||||
{ error: 'Dev login is only available in local development' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return Response.json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
let body: DevLoginBody;
|
||||
try {
|
||||
body = (await request.json()) as DevLoginBody;
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.email || !body.email.includes('@')) {
|
||||
return Response.json({ error: 'Valid email is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
let user = await env.DB.prepare(FIND_USER_BY_EMAIL)
|
||||
.bind(body.email)
|
||||
.first<DevUserRow>();
|
||||
|
||||
if (!user) {
|
||||
const userId = generateId();
|
||||
const name = body.email.split('@')[0];
|
||||
await env.DB.prepare(CREATE_USER).bind(userId, body.email, name, 'admin').run();
|
||||
user = { id: userId, email: body.email, name, role: 'admin' };
|
||||
} else {
|
||||
// Promote to admin if not already
|
||||
if (user.role !== 'admin') {
|
||||
await env.DB.prepare(UPDATE_USER_ROLE).bind('admin', user.id).run();
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionToken = generateSessionToken();
|
||||
const tokenHash = await hashSessionToken(sessionToken);
|
||||
const sessionId = generateId();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
await env.DB.prepare(CREATE_SESSION).bind(sessionId, user.id, tokenHash, expiresAt).run();
|
||||
|
||||
// Build Set-Cookie header
|
||||
const cookieAttrs = getSessionCookieAttributes(request.url, env.COOKIE_DOMAIN);
|
||||
const cookieValue = `${AUTH_SESSION_COOKIE_NAME}=${sessionToken}; ${cookieAttrs.join('; ')}`;
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
ok: true,
|
||||
user: { id: user.id, email: user.email, name: user.name, role: 'admin' },
|
||||
},
|
||||
{
|
||||
headers: { 'Set-Cookie': cookieValue },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function generateSessionToken(): string {
|
||||
// 32-byte random token as hex
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
Reference in New Issue
Block a user