139 lines · 4.0 KB
1 /**
2 * REST API: Authentication endpoints.
3 * Register, login, and create personal access tokens.
4 */
5
6 import { hashPassword, verifyPassword, generateToken, tokenPrefix } from '@gitfastr/shared/utils/auth.js';
7 import { sha256 } from '@gitfastr/shared/git/sha1.js';
8 import { ValidationError, UnauthorizedError } from '@gitfastr/shared/utils/errors.js';
9 import { requireAuth } from '../../middleware/auth.js';
10 import type { HandlerContext } from '../../router.js';
11 import type { DbUser } from '@gitfastr/shared';
12
13 const encoder = new TextEncoder();
14
15 export async function handleApiRegister(
16 request: Request,
17 hctx: HandlerContext
18 ): Promise<Response> {
19 const body = await request.json<{
20 username: string;
21 email: string;
22 password: string;
23 display_name?: string;
24 }>();
25
26 if (!body.username || !body.email || !body.password) {
27 throw new ValidationError('username, email, and password are required');
28 }
29
30 if (!/^[a-zA-Z0-9_-]{2,32}$/.test(body.username)) {
31 throw new ValidationError(
32 'Username must be 2-32 characters, alphanumeric, hyphens, or underscores'
33 );
34 }
35
36 if (body.password.length < 8) {
37 throw new ValidationError('Password must be at least 8 characters');
38 }
39
40 const passwordHash = await hashPassword(body.password);
41
42 try {
43 const result = await hctx.env.DB.prepare(
44 `INSERT INTO users (username, email, password_hash, display_name)
45 VALUES (?, ?, ?, ?)
46 RETURNING id, username, email, display_name, created_at`
47 )
48 .bind(body.username, body.email, passwordHash, body.display_name ?? null)
49 .first<Pick<DbUser, 'id' | 'username' | 'email' | 'display_name' | 'created_at'>>();
50
51 return Response.json(result, { status: 201 });
52 } catch (error: any) {
53 if (error.message?.includes('UNIQUE constraint')) {
54 throw new ValidationError('Username or email already taken');
55 }
56 throw error;
57 }
58 }
59
60 export async function handleApiLogin(
61 request: Request,
62 hctx: HandlerContext
63 ): Promise<Response> {
64 const body = await request.json<{
65 username: string;
66 password: string;
67 }>();
68
69 if (!body.username || !body.password) {
70 throw new ValidationError('username and password are required');
71 }
72
73 const user = await hctx.env.DB.prepare(
74 'SELECT id, password_hash FROM users WHERE username = ?'
75 )
76 .bind(body.username)
77 .first<Pick<DbUser, 'id' | 'password_hash'>>();
78
79 if (!user) {
80 throw new UnauthorizedError('Invalid username or password');
81 }
82
83 const valid = await verifyPassword(body.password, user.password_hash);
84 if (!valid) {
85 throw new UnauthorizedError('Invalid username or password');
86 }
87
88 // Create session
89 const sessionId = crypto.randomUUID();
90 const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
91
92 await hctx.env.DB.prepare(
93 'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)'
94 )
95 .bind(sessionId, user.id, expiresAt.toISOString())
96 .run();
97
98 const response = Response.json({ user_id: user.id });
99
100 // Set session cookie
101 const headers = new Headers(response.headers);
102 headers.set(
103 'Set-Cookie',
104 `session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Strict; Expires=${expiresAt.toUTCString()}`
105 );
106
107 return new Response(response.body, { status: 200, headers });
108 }
109
110 export async function handleApiCreateToken(
111 request: Request,
112 hctx: HandlerContext
113 ): Promise<Response> {
114 const userId = await requireAuth(request, hctx.env.DB);
115
116 const body = await request.json<{
117 name: string;
118 scopes?: string;
119 }>();
120
121 if (!body.name) {
122 throw new ValidationError('Token name is required');
123 }
124
125 const token = generateToken();
126 const prefix = tokenPrefix(token);
127 const hash = await sha256(encoder.encode(token));
128
129 await hctx.env.DB.prepare(
130 `INSERT INTO access_tokens (user_id, name, token_hash, token_prefix, scopes)
131 VALUES (?, ?, ?, ?, ?)`
132 )
133 .bind(userId, body.name, hash, prefix, body.scopes ?? 'repo')
134 .run();
135
136 // Return the plaintext token — this is the only time it's shown
137 return Response.json({ token, prefix, name: body.name }, { status: 201 });
138 }
139