121 lines · 3.1 KB
| 1 | /** |
| 2 | * Authentication middleware. |
| 3 | * |
| 4 | * Supports two modes: |
| 5 | * - HTTP Basic Auth with PAT (for git operations) |
| 6 | * - Session cookie (for web UI API calls) |
| 7 | */ |
| 8 | |
| 9 | import { sha256 } from '@gitfastr/shared/git/sha1.js'; |
| 10 | import { UnauthorizedError } from '@gitfastr/shared/utils/errors.js'; |
| 11 | import type { Env } from '../index.js'; |
| 12 | import type { DbAccessToken, DbSession, DbUser } from '@gitfastr/shared'; |
| 13 | |
| 14 | const encoder = new TextEncoder(); |
| 15 | |
| 16 | /** |
| 17 | * Authenticate a request. Returns the user ID if authenticated, null otherwise. |
| 18 | * Does not throw — callers decide whether auth is required. |
| 19 | */ |
| 20 | export async function authenticate( |
| 21 | request: Request, |
| 22 | db: D1Database |
| 23 | ): Promise<string | null> { |
| 24 | // Try Basic Auth first (git clients) |
| 25 | const authHeader = request.headers.get('Authorization'); |
| 26 | if (authHeader?.startsWith('Basic ')) { |
| 27 | return authenticateBasic(authHeader, db); |
| 28 | } |
| 29 | |
| 30 | // Try session cookie |
| 31 | const cookie = request.headers.get('Cookie'); |
| 32 | if (cookie) { |
| 33 | return authenticateSession(cookie, db); |
| 34 | } |
| 35 | |
| 36 | return null; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Require authentication — throws UnauthorizedError if not authenticated. |
| 41 | */ |
| 42 | export async function requireAuth( |
| 43 | request: Request, |
| 44 | db: D1Database |
| 45 | ): Promise<string> { |
| 46 | const userId = await authenticate(request, db); |
| 47 | if (!userId) { |
| 48 | throw new UnauthorizedError(); |
| 49 | } |
| 50 | return userId; |
| 51 | } |
| 52 | |
| 53 | /** Authenticate via HTTP Basic Auth with a PAT as the password. */ |
| 54 | async function authenticateBasic( |
| 55 | authHeader: string, |
| 56 | db: D1Database |
| 57 | ): Promise<string | null> { |
| 58 | const encoded = authHeader.slice(6); // Remove "Basic " |
| 59 | const decoded = atob(encoded); |
| 60 | const colonIdx = decoded.indexOf(':'); |
| 61 | if (colonIdx === -1) return null; |
| 62 | |
| 63 | const password = decoded.slice(colonIdx + 1); // The PAT |
| 64 | |
| 65 | // Hash the token to look it up |
| 66 | const tokenHash = await sha256(encoder.encode(password)); |
| 67 | |
| 68 | const result = await db |
| 69 | .prepare( |
| 70 | `SELECT at.user_id, at.expires_at |
| 71 | FROM access_tokens at |
| 72 | WHERE at.token_hash = ?` |
| 73 | ) |
| 74 | .bind(tokenHash) |
| 75 | .first<Pick<DbAccessToken, 'user_id' | 'expires_at'>>(); |
| 76 | |
| 77 | if (!result) return null; |
| 78 | |
| 79 | // Check expiration |
| 80 | if (result.expires_at && new Date(result.expires_at) < new Date()) { |
| 81 | return null; |
| 82 | } |
| 83 | |
| 84 | // Update last_used_at (fire and forget) |
| 85 | db.prepare('UPDATE access_tokens SET last_used_at = datetime(\'now\') WHERE token_hash = ?') |
| 86 | .bind(tokenHash) |
| 87 | .run(); |
| 88 | |
| 89 | return result.user_id; |
| 90 | } |
| 91 | |
| 92 | /** Authenticate via session cookie. */ |
| 93 | async function authenticateSession( |
| 94 | cookie: string, |
| 95 | db: D1Database |
| 96 | ): Promise<string | null> { |
| 97 | // Parse session ID from cookie |
| 98 | const match = cookie.match(/(?:^|;\s*)session=([^;]+)/); |
| 99 | if (!match) return null; |
| 100 | |
| 101 | const sessionId = match[1]; |
| 102 | |
| 103 | const result = await db |
| 104 | .prepare( |
| 105 | `SELECT user_id, expires_at FROM sessions WHERE id = ?` |
| 106 | ) |
| 107 | .bind(sessionId) |
| 108 | .first<Pick<DbSession, 'user_id' | 'expires_at'>>(); |
| 109 | |
| 110 | if (!result) return null; |
| 111 | |
| 112 | // Check expiration |
| 113 | if (new Date(result.expires_at) < new Date()) { |
| 114 | // Clean up expired session |
| 115 | db.prepare('DELETE FROM sessions WHERE id = ?').bind(sessionId).run(); |
| 116 | return null; |
| 117 | } |
| 118 | |
| 119 | return result.user_id; |
| 120 | } |
| 121 |