Skip to content

Commit b5770fd

Browse files
committed
feat(worker): GitHub OAuth login + API key dashboard
Add a minimal web dashboard served by the same Worker: - GitHub OAuth flow (/auth/github, /auth/github/callback, /auth/logout) - HttpOnly Secure session cookies, 30-day TTL stored in D1 - Dashboard pages (/login, /dashboard) — Tailwind CDN + vanilla JS - /api/me, /api/keys CRUD endpoints, session-gated - D1 schema additions: users, api_keys (sha-256 hashed), sessions - authMiddleware accepts dashboard-issued keys alongside legacy API_KEY - Optional ADMIN_GITHUB_USERS allowlist via wrangler [vars] - New env vars/secrets: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, BASE_URL
1 parent a1fcc98 commit b5770fd

15 files changed

Lines changed: 705 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,23 @@ pnpm deploy # Deploy Worker to Cloudflare
3636

3737
| Method | Path | Auth | Handler |
3838
|--------|------|------|---------|
39-
| POST | /upload | Yes | routes/upload-route.ts |
39+
| POST | /upload | Bearer | routes/upload-route.ts |
4040
| GET | /:id/:filename | No | routes/serve-route.ts |
41-
| GET | /files | Yes | routes/files-route.ts |
42-
| GET | /info/:id | Yes | routes/files-route.ts |
43-
| DELETE | /:id | Yes | index.ts (inline) |
44-
| GET | /usage | Yes | routes/usage-route.ts |
41+
| GET | /files | Bearer | routes/files-route.ts |
42+
| GET | /info/:id | Bearer | routes/files-route.ts |
43+
| DELETE | /:id | Bearer | index.ts (inline) |
44+
| GET | /usage | Bearer | routes/usage-route.ts |
4545
| GET | /health | No | index.ts |
46+
| GET | /login, /dashboard, / | Cookie | routes/web-route.ts |
47+
| GET | /auth/github, /auth/github/callback | No | routes/oauth-route.ts |
48+
| POST | /auth/logout | Cookie | routes/oauth-route.ts |
49+
| GET | /api/me | Cookie | routes/dashboard-api-route.ts |
50+
| GET/POST/DELETE | /api/keys[/:id] | Cookie | routes/dashboard-api-route.ts |
51+
52+
**Auth modes:**
53+
- `Bearer`: API key (legacy `API_KEY` env OR sha-256 hashed key in D1 `api_keys`)
54+
- `Cookie`: HttpOnly `f2u_session` cookie set after GitHub OAuth login
55+
- `No`: Public
4656

4757
## CLI Commands
4858

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,19 +256,42 @@ wrangler d1 create f2u-db
256256
cd packages/worker
257257
wrangler d1 execute f2u-db --file=src/db/schema.sql --remote
258258

259-
# Set API key secret (enter your chosen key when prompted)
259+
# (Optional) Legacy single API key — for CLI access without the dashboard
260260
wrangler secret put API_KEY
261261

262+
# GitHub OAuth — required for the web dashboard
263+
# 1. Create an OAuth App at https://github.com/settings/developers
264+
# Authorization callback URL: https://your-domain.com/auth/github/callback
265+
# 2. Set the credentials as Worker secrets:
266+
wrangler secret put GITHUB_CLIENT_ID
267+
wrangler secret put GITHUB_CLIENT_SECRET
268+
269+
# 3. (Strongly recommended) Restrict who can sign in. Edit wrangler.toml [vars]:
270+
# ADMIN_GITHUB_USERS = "your-github-login,teammate-login"
271+
# Leave empty to allow ANY GitHub user (not recommended for personal deploys).
272+
262273
# Update custom domain in wrangler.toml (optional)
263274
# Edit [[routes]] pattern to your domain
275+
# Also update BASE_URL under [vars] to match.
264276

265277
# Deploy
266278
wrangler deploy
267279

268280
# Verify
269281
curl https://your-domain.com/health
282+
# Then visit https://your-domain.com/login in a browser
270283
```
271284

285+
### Web Dashboard
286+
287+
Once deployed, visit `https://your-domain.com/login` to sign in with GitHub
288+
and manage API keys from the browser. Created keys are shown **once**
289+
copy them immediately. Use them with the CLI via `f2u auth --key <KEY>`
290+
or as the `Authorization: Bearer <KEY>` header.
291+
292+
The legacy `API_KEY` secret (if set) continues to work for backwards
293+
compatibility alongside dashboard-issued keys.
294+
272295
### Custom Domain
273296

274297
1. Your domain must be on Cloudflare DNS (proxied)

packages/worker/src/db/schema.sql

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,41 @@ CREATE TABLE IF NOT EXISTS files (
1313

1414
CREATE INDEX IF NOT EXISTS idx_expires_at ON files(expires_at);
1515
CREATE INDEX IF NOT EXISTS idx_deleted ON files(deleted);
16+
17+
CREATE TABLE IF NOT EXISTS users (
18+
id INTEGER PRIMARY KEY AUTOINCREMENT,
19+
github_id INTEGER NOT NULL UNIQUE,
20+
github_login TEXT NOT NULL,
21+
name TEXT,
22+
email TEXT,
23+
avatar_url TEXT,
24+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
25+
last_login_at TEXT
26+
);
27+
28+
CREATE INDEX IF NOT EXISTS idx_users_github_id ON users(github_id);
29+
30+
CREATE TABLE IF NOT EXISTS api_keys (
31+
id TEXT PRIMARY KEY,
32+
user_id INTEGER NOT NULL,
33+
name TEXT NOT NULL,
34+
key_hash TEXT NOT NULL UNIQUE,
35+
prefix TEXT NOT NULL,
36+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
37+
last_used_at TEXT,
38+
revoked INTEGER DEFAULT 0,
39+
FOREIGN KEY (user_id) REFERENCES users(id)
40+
);
41+
42+
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
43+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
44+
45+
CREATE TABLE IF NOT EXISTS sessions (
46+
id TEXT PRIMARY KEY,
47+
user_id INTEGER NOT NULL,
48+
expires_at TEXT NOT NULL,
49+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
50+
FOREIGN KEY (user_id) REFERENCES users(id)
51+
);
52+
53+
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);

packages/worker/src/index.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import uploadRoute from './routes/upload-route';
66
import serveRoute from './routes/serve-route';
77
import filesRoute from './routes/files-route';
88
import usageRoute from './routes/usage-route';
9+
import oauthRoute from './routes/oauth-route';
10+
import dashboardApiRoute from './routes/dashboard-api-route';
11+
import webRoute from './routes/web-route';
912
import { cleanupExpiredFiles } from './cron/cleanup-expired-files';
1013

1114
const app = new Hono<{ Bindings: Env }>();
@@ -17,7 +20,16 @@ app.use('*', cors());
1720

1821
app.get('/health', (c) => c.json({ status: 'ok', ts: new Date().toISOString() }));
1922

20-
// --- Protected routes (auth required) — registered BEFORE serve wildcard ---
23+
// Web pages (login, dashboard, root) — single-segment paths, no conflict with serve wildcard
24+
app.route('/', webRoute);
25+
26+
// GitHub OAuth flow (public)
27+
app.route('/', oauthRoute);
28+
29+
// Dashboard API (session-gated inside the route handlers)
30+
app.route('/', dashboardApiRoute);
31+
32+
// --- Protected file API (Bearer token) — registered BEFORE serve wildcard ---
2133

2234
app.use('/upload', authMiddleware);
2335
app.route('/', uploadRoute);
@@ -33,9 +45,6 @@ app.route('/', serveRoute);
3345

3446
// DELETE /:id — auth applied inline to avoid conflicting with serve route
3547
app.delete('/:id', authMiddleware, async (c) => {
36-
// Delegate to filesRoute handler by re-using its logic inline
37-
// (Hono sub-app DELETE handler is already defined in filesRoute but registering
38-
// it here ensures auth middleware fires before the wildcard serve GET)
3948
const { id } = c.req.param();
4049
const db = c.env.D1_DATABASE;
4150

packages/worker/src/lib/cookies.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Minimal cookie helpers — no external deps
2+
3+
export function parseCookies(header: string | undefined | null): Record<string, string> {
4+
const out: Record<string, string> = {};
5+
if (!header) return out;
6+
for (const part of header.split(';')) {
7+
const idx = part.indexOf('=');
8+
if (idx < 0) continue;
9+
const k = part.slice(0, idx).trim();
10+
const v = part.slice(idx + 1).trim();
11+
if (k) out[k] = decodeURIComponent(v);
12+
}
13+
return out;
14+
}
15+
16+
export interface CookieOptions {
17+
maxAge?: number;
18+
path?: string;
19+
httpOnly?: boolean;
20+
secure?: boolean;
21+
sameSite?: 'Lax' | 'Strict' | 'None';
22+
}
23+
24+
export function serializeCookie(name: string, value: string, opts: CookieOptions = {}): string {
25+
const parts = [`${name}=${encodeURIComponent(value)}`];
26+
if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`);
27+
parts.push(`Path=${opts.path ?? '/'}`);
28+
if (opts.httpOnly !== false) parts.push('HttpOnly');
29+
if (opts.secure !== false) parts.push('Secure');
30+
parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`);
31+
return parts.join('; ');
32+
}

packages/worker/src/lib/crypto.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Web Crypto helpers — Workers runtime provides crypto.subtle and crypto.getRandomValues
2+
3+
const HEX_TABLE = '0123456789abcdef';
4+
5+
function bytesToHex(bytes: Uint8Array): string {
6+
let out = '';
7+
for (let i = 0; i < bytes.length; i++) {
8+
const b = bytes[i]!;
9+
out += HEX_TABLE[b >>> 4]! + HEX_TABLE[b & 0x0f]!;
10+
}
11+
return out;
12+
}
13+
14+
export async function sha256Hex(input: string): Promise<string> {
15+
const data = new TextEncoder().encode(input);
16+
const digest = await crypto.subtle.digest('SHA-256', data);
17+
return bytesToHex(new Uint8Array(digest));
18+
}
19+
20+
export function randomToken(byteLength = 32): string {
21+
const bytes = new Uint8Array(byteLength);
22+
crypto.getRandomValues(bytes);
23+
return bytesToHex(bytes);
24+
}
25+
26+
// Constant-time string comparison to avoid timing attacks
27+
export function safeEqual(a: string, b: string): boolean {
28+
if (a.length !== b.length) return false;
29+
let diff = 0;
30+
for (let i = 0; i < a.length; i++) {
31+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
32+
}
33+
return diff === 0;
34+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Env } from '../types';
2+
import { randomToken } from './crypto';
3+
4+
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days
5+
export const SESSION_COOKIE = 'f2u_session';
6+
7+
export interface SessionRow {
8+
id: string;
9+
user_id: number;
10+
expires_at: string;
11+
created_at: string;
12+
}
13+
14+
export async function createSession(env: Env, userId: number): Promise<{ id: string; expiresAt: Date }> {
15+
const id = randomToken(32);
16+
const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000);
17+
await env.D1_DATABASE.prepare(
18+
'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)',
19+
)
20+
.bind(id, userId, expiresAt.toISOString())
21+
.run();
22+
return { id, expiresAt };
23+
}
24+
25+
export async function getSession(env: Env, sessionId: string | undefined): Promise<SessionRow | null> {
26+
if (!sessionId) return null;
27+
const row = await env.D1_DATABASE.prepare(
28+
'SELECT * FROM sessions WHERE id = ? LIMIT 1',
29+
)
30+
.bind(sessionId)
31+
.first<SessionRow>();
32+
if (!row) return null;
33+
if (new Date(row.expires_at) < new Date()) {
34+
await deleteSession(env, sessionId).catch(() => undefined);
35+
return null;
36+
}
37+
return row;
38+
}
39+
40+
export async function deleteSession(env: Env, sessionId: string): Promise<void> {
41+
await env.D1_DATABASE.prepare('DELETE FROM sessions WHERE id = ?').bind(sessionId).run();
42+
}
43+
44+
export const SESSION_MAX_AGE = SESSION_TTL_SECONDS;
Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { createMiddleware } from 'hono/factory';
22
import type { Env } from '../types';
3+
import { sha256Hex, safeEqual } from '../lib/crypto';
34

4-
// Validates Bearer token against API_KEY env var
5+
/**
6+
* Validates Bearer token. Accepts either:
7+
* 1. Legacy single API_KEY env (constant-time compare)
8+
* 2. User-issued key from D1 api_keys table (sha-256 hash lookup)
9+
*
10+
* Updates last_used_at on successful D1 key match (best-effort, non-blocking).
11+
*/
512
export const authMiddleware = createMiddleware<{ Bindings: Env }>(async (c, next) => {
613
const authHeader = c.req.header('Authorization');
714

@@ -11,9 +18,35 @@ export const authMiddleware = createMiddleware<{ Bindings: Env }>(async (c, next
1118

1219
const token = authHeader.slice(7);
1320

14-
if (token !== c.env.API_KEY) {
15-
return c.json({ error: 'Unauthorized: invalid API key' }, 401);
21+
// Legacy single-key fallback
22+
if (c.env.API_KEY && safeEqual(token, c.env.API_KEY)) {
23+
await next();
24+
return;
1625
}
1726

18-
await next();
27+
// D1-backed key lookup
28+
try {
29+
const hash = await sha256Hex(token);
30+
const row = await c.env.D1_DATABASE
31+
.prepare('SELECT id FROM api_keys WHERE key_hash = ? AND revoked = 0 LIMIT 1')
32+
.bind(hash)
33+
.first<{ id: string }>();
34+
if (row) {
35+
// Best-effort last_used_at update — do not block request
36+
c.executionCtx.waitUntil(
37+
c.env.D1_DATABASE
38+
.prepare('UPDATE api_keys SET last_used_at = ? WHERE id = ?')
39+
.bind(new Date().toISOString(), row.id)
40+
.run()
41+
.catch(() => undefined),
42+
);
43+
await next();
44+
return;
45+
}
46+
} catch (err) {
47+
console.error('auth lookup error:', err);
48+
return c.json({ error: 'Auth lookup failed' }, 500);
49+
}
50+
51+
return c.json({ error: 'Unauthorized: invalid API key' }, 401);
1952
});

0 commit comments

Comments
 (0)