@skillmark/webapp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/cd45cc5264daa1c125545b5b4c0756df95d8b6ac5900ecf52323d90f61a47f2d.sqlite +0 -0
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite +0 -0
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite-shm +0 -0
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite-wal +0 -0
- package/.wrangler/tmp/bundle-lfa2r7/checked-fetch.js +30 -0
- package/.wrangler/tmp/bundle-lfa2r7/middleware-insertion-facade.js +11 -0
- package/.wrangler/tmp/bundle-lfa2r7/middleware-loader.entry.ts +134 -0
- package/.wrangler/tmp/bundle-lfa2r7/strip-cf-connecting-ip-header.js +13 -0
- package/.wrangler/tmp/dev-IDqSK4/worker-entry-point.js +4918 -0
- package/.wrangler/tmp/dev-IDqSK4/worker-entry-point.js.map +8 -0
- package/package.json +22 -0
- package/src/assets/favicon.png +0 -0
- package/src/assets/skillmark-thumb.png +0 -0
- package/src/db/d1-database-schema.sql +69 -0
- package/src/db/migrations/001-add-github-oauth-and-user-session-tables.sql +40 -0
- package/src/db/migrations/002-add-security-benchmark-columns.sql +30 -0
- package/src/db/migrations/003-add-repo-url-and-update-composite-formula.sql +27 -0
- package/src/routes/api-endpoints-handler.ts +380 -0
- package/src/routes/github-oauth-authentication-handler.ts +427 -0
- package/src/routes/html-pages-renderer.ts +2263 -0
- package/src/routes/static-assets-handler.ts +58 -0
- package/src/worker-entry-point.ts +143 -0
- package/tsconfig.json +19 -0
- package/wrangler.toml +19 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth authentication handler for Skillmark
|
|
3
|
+
* Handles login flow, session management, and API key generation
|
|
4
|
+
*/
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
|
7
|
+
|
|
8
|
+
type Bindings = {
|
|
9
|
+
DB: D1Database;
|
|
10
|
+
GITHUB_CLIENT_ID: string;
|
|
11
|
+
GITHUB_CLIENT_SECRET: string;
|
|
12
|
+
SESSION_SECRET: string;
|
|
13
|
+
ENVIRONMENT: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Variables = {
|
|
17
|
+
user?: UserSession;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface UserSession {
|
|
21
|
+
id: string;
|
|
22
|
+
githubId: number;
|
|
23
|
+
githubUsername: string;
|
|
24
|
+
githubAvatar: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GitHubUserResponse {
|
|
28
|
+
id: number;
|
|
29
|
+
login: string;
|
|
30
|
+
avatar_url: string;
|
|
31
|
+
email: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface GitHubTokenResponse {
|
|
35
|
+
access_token: string;
|
|
36
|
+
token_type: string;
|
|
37
|
+
scope: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const authRouter = new Hono<{ Bindings: Bindings; Variables: Variables }>();
|
|
41
|
+
|
|
42
|
+
const GITHUB_OAUTH_URL = 'https://github.com/login/oauth/authorize';
|
|
43
|
+
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
44
|
+
const GITHUB_USER_URL = 'https://api.github.com/user';
|
|
45
|
+
const SESSION_COOKIE = 'skillmark_session';
|
|
46
|
+
const SESSION_DURATION_DAYS = 30;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* GET /auth/github - Redirect to GitHub OAuth
|
|
50
|
+
*/
|
|
51
|
+
authRouter.get('/github', (c) => {
|
|
52
|
+
const clientId = c.env.GITHUB_CLIENT_ID;
|
|
53
|
+
const baseUrl = getBaseUrl(c.req.url, c.env.ENVIRONMENT);
|
|
54
|
+
const redirectUri = `${baseUrl}/auth/github/callback`;
|
|
55
|
+
|
|
56
|
+
const params = new URLSearchParams({
|
|
57
|
+
client_id: clientId,
|
|
58
|
+
redirect_uri: redirectUri,
|
|
59
|
+
scope: 'read:user user:email',
|
|
60
|
+
state: generateState(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return c.redirect(`${GITHUB_OAUTH_URL}?${params.toString()}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* GET /auth/github/callback - Handle OAuth callback
|
|
68
|
+
*/
|
|
69
|
+
authRouter.get('/github/callback', async (c) => {
|
|
70
|
+
const code = c.req.query('code');
|
|
71
|
+
const error = c.req.query('error');
|
|
72
|
+
|
|
73
|
+
if (error || !code) {
|
|
74
|
+
return c.redirect('/login?error=oauth_failed');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const baseUrl = getBaseUrl(c.req.url, c.env.ENVIRONMENT);
|
|
79
|
+
|
|
80
|
+
// Exchange code for access token
|
|
81
|
+
const tokenResponse = await fetch(GITHUB_TOKEN_URL, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
'Accept': 'application/json',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
client_id: c.env.GITHUB_CLIENT_ID,
|
|
89
|
+
client_secret: c.env.GITHUB_CLIENT_SECRET,
|
|
90
|
+
code,
|
|
91
|
+
redirect_uri: `${baseUrl}/auth/github/callback`,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const tokenData = await tokenResponse.json() as GitHubTokenResponse;
|
|
96
|
+
|
|
97
|
+
if (!tokenData.access_token) {
|
|
98
|
+
console.error('Failed to get access token:', tokenData);
|
|
99
|
+
return c.redirect('/login?error=token_failed');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Fetch user profile
|
|
103
|
+
const userResponse = await fetch(GITHUB_USER_URL, {
|
|
104
|
+
headers: {
|
|
105
|
+
'Authorization': `Bearer ${tokenData.access_token}`,
|
|
106
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
107
|
+
'User-Agent': 'Skillmark-OAuth',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const githubUser = await userResponse.json() as GitHubUserResponse;
|
|
112
|
+
|
|
113
|
+
// Create or update user in database
|
|
114
|
+
const userId = await upsertUser(c.env.DB, githubUser);
|
|
115
|
+
|
|
116
|
+
// Create session
|
|
117
|
+
const sessionId = await createSession(c.env.DB, userId);
|
|
118
|
+
|
|
119
|
+
// Set session cookie
|
|
120
|
+
setCookie(c, SESSION_COOKIE, sessionId, {
|
|
121
|
+
httpOnly: true,
|
|
122
|
+
secure: c.env.ENVIRONMENT === 'production',
|
|
123
|
+
sameSite: 'Lax',
|
|
124
|
+
maxAge: SESSION_DURATION_DAYS * 24 * 60 * 60,
|
|
125
|
+
path: '/',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return c.redirect('/dashboard');
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('OAuth callback error:', err);
|
|
131
|
+
return c.redirect('/login?error=oauth_failed');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* GET /auth/logout - Clear session and redirect
|
|
137
|
+
*/
|
|
138
|
+
authRouter.get('/logout', async (c) => {
|
|
139
|
+
const sessionId = getCookie(c, SESSION_COOKIE);
|
|
140
|
+
|
|
141
|
+
if (sessionId) {
|
|
142
|
+
await c.env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(sessionId).run();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
|
146
|
+
return c.redirect('/');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* GET /api/me - Get current user info
|
|
151
|
+
*/
|
|
152
|
+
authRouter.get('/me', async (c) => {
|
|
153
|
+
const user = await getCurrentUser(c);
|
|
154
|
+
|
|
155
|
+
if (!user) {
|
|
156
|
+
return c.json({ error: 'Not authenticated' }, 401);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return c.json({
|
|
160
|
+
id: user.id,
|
|
161
|
+
githubUsername: user.githubUsername,
|
|
162
|
+
githubAvatar: user.githubAvatar,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* POST /api/keys - Generate new API key
|
|
168
|
+
*/
|
|
169
|
+
authRouter.post('/keys', async (c) => {
|
|
170
|
+
const user = await getCurrentUser(c);
|
|
171
|
+
|
|
172
|
+
if (!user) {
|
|
173
|
+
return c.json({ error: 'Not authenticated' }, 401);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Generate new API key
|
|
177
|
+
const apiKey = generateApiKey();
|
|
178
|
+
const keyHash = await hashApiKey(apiKey);
|
|
179
|
+
const keyId = crypto.randomUUID();
|
|
180
|
+
|
|
181
|
+
// Store key with user info
|
|
182
|
+
await c.env.DB.prepare(`
|
|
183
|
+
INSERT INTO api_keys (id, key_hash, user_name, github_username, github_avatar, github_id)
|
|
184
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
185
|
+
`).bind(
|
|
186
|
+
keyId,
|
|
187
|
+
keyHash,
|
|
188
|
+
user.githubUsername,
|
|
189
|
+
user.githubUsername,
|
|
190
|
+
user.githubAvatar,
|
|
191
|
+
user.githubId
|
|
192
|
+
).run();
|
|
193
|
+
|
|
194
|
+
return c.json({
|
|
195
|
+
apiKey,
|
|
196
|
+
keyId,
|
|
197
|
+
message: 'API key generated. Store it securely - it cannot be retrieved again.',
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* GET /api/keys - List user's API keys (without revealing the key)
|
|
203
|
+
*/
|
|
204
|
+
authRouter.get('/keys', async (c) => {
|
|
205
|
+
const user = await getCurrentUser(c);
|
|
206
|
+
|
|
207
|
+
if (!user) {
|
|
208
|
+
return c.json({ error: 'Not authenticated' }, 401);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const keys = await c.env.DB.prepare(`
|
|
212
|
+
SELECT id, created_at, last_used_at
|
|
213
|
+
FROM api_keys
|
|
214
|
+
WHERE github_id = ?
|
|
215
|
+
ORDER BY created_at DESC
|
|
216
|
+
`).bind(user.githubId).all();
|
|
217
|
+
|
|
218
|
+
const formattedKeys = keys.results?.map((key: Record<string, unknown>) => ({
|
|
219
|
+
id: key.id,
|
|
220
|
+
createdAt: key.created_at ? new Date((key.created_at as number) * 1000).toISOString() : null,
|
|
221
|
+
lastUsedAt: key.last_used_at ? new Date((key.last_used_at as number) * 1000).toISOString() : null,
|
|
222
|
+
})) || [];
|
|
223
|
+
|
|
224
|
+
return c.json({ keys: formattedKeys });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* DELETE /api/keys/:id - Revoke an API key
|
|
229
|
+
*/
|
|
230
|
+
authRouter.delete('/keys/:id', async (c) => {
|
|
231
|
+
const user = await getCurrentUser(c);
|
|
232
|
+
|
|
233
|
+
if (!user) {
|
|
234
|
+
return c.json({ error: 'Not authenticated' }, 401);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const keyId = c.req.param('id');
|
|
238
|
+
|
|
239
|
+
// Verify key belongs to user
|
|
240
|
+
const key = await c.env.DB.prepare(`
|
|
241
|
+
SELECT id FROM api_keys WHERE id = ? AND github_id = ?
|
|
242
|
+
`).bind(keyId, user.githubId).first();
|
|
243
|
+
|
|
244
|
+
if (!key) {
|
|
245
|
+
return c.json({ error: 'Key not found' }, 404);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await c.env.DB.prepare('DELETE FROM api_keys WHERE id = ?').bind(keyId).run();
|
|
249
|
+
|
|
250
|
+
return c.json({ success: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* POST /api/verify - Verify API key and return user info (for CLI)
|
|
255
|
+
*/
|
|
256
|
+
authRouter.post('/verify-key', async (c) => {
|
|
257
|
+
const authHeader = c.req.header('Authorization');
|
|
258
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
259
|
+
return c.json({ valid: false }, 401);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const apiKey = authHeader.slice(7);
|
|
263
|
+
const keyHash = await hashApiKey(apiKey);
|
|
264
|
+
|
|
265
|
+
const keyRecord = await c.env.DB.prepare(`
|
|
266
|
+
SELECT github_username, github_avatar, github_id
|
|
267
|
+
FROM api_keys
|
|
268
|
+
WHERE key_hash = ?
|
|
269
|
+
`).bind(keyHash).first();
|
|
270
|
+
|
|
271
|
+
if (!keyRecord) {
|
|
272
|
+
return c.json({ valid: false }, 401);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Update last used
|
|
276
|
+
await c.env.DB.prepare(`
|
|
277
|
+
UPDATE api_keys SET last_used_at = unixepoch() WHERE key_hash = ?
|
|
278
|
+
`).bind(keyHash).run();
|
|
279
|
+
|
|
280
|
+
return c.json({
|
|
281
|
+
valid: true,
|
|
282
|
+
user: {
|
|
283
|
+
githubUsername: keyRecord.github_username,
|
|
284
|
+
githubAvatar: keyRecord.github_avatar,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Helper: Get current user from session cookie
|
|
291
|
+
*/
|
|
292
|
+
async function getCurrentUser(c: { req: { header: (name: string) => string | undefined }; env: Bindings }): Promise<UserSession | null> {
|
|
293
|
+
// Get session ID from cookie using raw header
|
|
294
|
+
const cookieHeader = c.req.header('Cookie') || '';
|
|
295
|
+
const cookies = parseCookies(cookieHeader);
|
|
296
|
+
const sessionId = cookies[SESSION_COOKIE];
|
|
297
|
+
|
|
298
|
+
if (!sessionId) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const session = await c.env.DB.prepare(`
|
|
303
|
+
SELECT u.id, u.github_id, u.github_username, u.github_avatar
|
|
304
|
+
FROM sessions s
|
|
305
|
+
JOIN users u ON u.id = s.user_id
|
|
306
|
+
WHERE s.id = ? AND s.expires_at > unixepoch()
|
|
307
|
+
`).bind(sessionId).first();
|
|
308
|
+
|
|
309
|
+
if (!session) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
id: session.id as string,
|
|
315
|
+
githubId: session.github_id as number,
|
|
316
|
+
githubUsername: session.github_username as string,
|
|
317
|
+
githubAvatar: session.github_avatar as string | null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Helper: Parse cookies from header
|
|
323
|
+
*/
|
|
324
|
+
function parseCookies(cookieHeader: string): Record<string, string> {
|
|
325
|
+
const cookies: Record<string, string> = {};
|
|
326
|
+
cookieHeader.split(';').forEach(cookie => {
|
|
327
|
+
const [name, ...rest] = cookie.trim().split('=');
|
|
328
|
+
if (name) {
|
|
329
|
+
cookies[name] = rest.join('=');
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
return cookies;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Helper: Create or update user
|
|
337
|
+
*/
|
|
338
|
+
async function upsertUser(db: D1Database, githubUser: GitHubUserResponse): Promise<string> {
|
|
339
|
+
const existingUser = await db.prepare(`
|
|
340
|
+
SELECT id FROM users WHERE github_id = ?
|
|
341
|
+
`).bind(githubUser.id).first();
|
|
342
|
+
|
|
343
|
+
if (existingUser) {
|
|
344
|
+
// Update existing user
|
|
345
|
+
await db.prepare(`
|
|
346
|
+
UPDATE users
|
|
347
|
+
SET github_username = ?, github_avatar = ?, github_email = ?, updated_at = unixepoch()
|
|
348
|
+
WHERE github_id = ?
|
|
349
|
+
`).bind(
|
|
350
|
+
githubUser.login,
|
|
351
|
+
githubUser.avatar_url,
|
|
352
|
+
githubUser.email,
|
|
353
|
+
githubUser.id
|
|
354
|
+
).run();
|
|
355
|
+
return existingUser.id as string;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Create new user
|
|
359
|
+
const userId = crypto.randomUUID();
|
|
360
|
+
await db.prepare(`
|
|
361
|
+
INSERT INTO users (id, github_id, github_username, github_avatar, github_email)
|
|
362
|
+
VALUES (?, ?, ?, ?, ?)
|
|
363
|
+
`).bind(
|
|
364
|
+
userId,
|
|
365
|
+
githubUser.id,
|
|
366
|
+
githubUser.login,
|
|
367
|
+
githubUser.avatar_url,
|
|
368
|
+
githubUser.email
|
|
369
|
+
).run();
|
|
370
|
+
|
|
371
|
+
return userId;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Helper: Create session
|
|
376
|
+
*/
|
|
377
|
+
async function createSession(db: D1Database, userId: string): Promise<string> {
|
|
378
|
+
const sessionId = crypto.randomUUID();
|
|
379
|
+
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION_DAYS * 24 * 60 * 60;
|
|
380
|
+
|
|
381
|
+
await db.prepare(`
|
|
382
|
+
INSERT INTO sessions (id, user_id, expires_at)
|
|
383
|
+
VALUES (?, ?, ?)
|
|
384
|
+
`).bind(sessionId, userId, expiresAt).run();
|
|
385
|
+
|
|
386
|
+
return sessionId;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Helper: Generate random API key
|
|
391
|
+
*/
|
|
392
|
+
function generateApiKey(): string {
|
|
393
|
+
const array = new Uint8Array(32);
|
|
394
|
+
crypto.getRandomValues(array);
|
|
395
|
+
return 'sk_' + Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Helper: Hash API key
|
|
400
|
+
*/
|
|
401
|
+
async function hashApiKey(apiKey: string): Promise<string> {
|
|
402
|
+
const encoder = new TextEncoder();
|
|
403
|
+
const data = encoder.encode(apiKey);
|
|
404
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
405
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
406
|
+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Helper: Generate state for CSRF protection
|
|
411
|
+
*/
|
|
412
|
+
function generateState(): string {
|
|
413
|
+
const array = new Uint8Array(16);
|
|
414
|
+
crypto.getRandomValues(array);
|
|
415
|
+
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Helper: Get base URL based on environment
|
|
420
|
+
*/
|
|
421
|
+
function getBaseUrl(requestUrl: string, environment: string): string {
|
|
422
|
+
const url = new URL(requestUrl);
|
|
423
|
+
if (environment === 'production') {
|
|
424
|
+
return 'https://skillmark.sh';
|
|
425
|
+
}
|
|
426
|
+
return `${url.protocol}//${url.host}`;
|
|
427
|
+
}
|