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:
Hermes Agent
2026-06-20 14:55:28 +10:00
parent a5b0953cc0
commit fd647bcc2e
9 changed files with 687 additions and 7 deletions
+8 -2
View File
@@ -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}`);
+5
View File
@@ -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);
+132
View File
@@ -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('');
}