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:
+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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user