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:
+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