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