my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
at main 649 lines 16 kB view raw
1import { db } from "../db"; 2import { validateProfileURL, verifyDomain } from "./indieauth"; 3 4function getSessionUser( 5 req: Request, 6): 7 | { username: string; userId: number; is_admin: boolean; tier: string } 8 | Response { 9 const authHeader = req.headers.get("Authorization"); 10 11 if (!authHeader || !authHeader.startsWith("Bearer ")) { 12 return Response.json({ error: "Unauthorized" }, { status: 401 }); 13 } 14 15 const token = authHeader.substring(7); 16 17 // Look up session 18 const session = db 19 .query( 20 `SELECT s.expires_at, s.user_id, u.username, u.is_admin, u.tier, u.status 21 FROM sessions s 22 JOIN users u ON s.user_id = u.id 23 WHERE s.token = ?`, 24 ) 25 .get(token) as 26 | { 27 expires_at: number; 28 user_id: number; 29 username: string; 30 is_admin: number; 31 tier: string; 32 status: string; 33 } 34 | undefined; 35 36 if (!session) { 37 return Response.json({ error: "Invalid session" }, { status: 401 }); 38 } 39 40 const now = Math.floor(Date.now() / 1000); 41 if (session.expires_at < now) { 42 return Response.json({ error: "Session expired" }, { status: 401 }); 43 } 44 45 if (session.status !== "active") { 46 return Response.json({ error: "Account is suspended" }, { status: 403 }); 47 } 48 49 return { 50 username: session.username, 51 userId: session.user_id, 52 is_admin: session.is_admin === 1, 53 tier: session.tier, 54 }; 55} 56 57export function hello(req: Request): Response { 58 const user = getSessionUser(req); 59 if (user instanceof Response) { 60 return user; 61 } 62 63 return Response.json({ 64 message: `Hello ${user.username}! You're authenticated with passkeys.`, 65 id: user.userId, 66 username: user.username, 67 isAdmin: user.is_admin, 68 tier: user.tier, 69 }); 70} 71 72export function listUsers(req: Request): Response { 73 const user = getSessionUser(req); 74 if (user instanceof Response) { 75 return user; 76 } 77 78 if (!user.is_admin) { 79 return Response.json({ error: "Admin access required" }, { status: 403 }); 80 } 81 82 const users = db 83 .query( 84 `SELECT u.id, u.username, u.name, u.email, u.photo, u.status, u.role, u.tier, u.is_admin, u.created_at, 85 COUNT(c.id) as credential_count 86 FROM users u 87 LEFT JOIN credentials c ON u.id = c.user_id 88 GROUP BY u.id 89 ORDER BY u.created_at DESC`, 90 ) 91 .all() as Array<{ 92 id: number; 93 username: string; 94 name: string; 95 email: string | null; 96 photo: string | null; 97 status: string; 98 role: string; 99 tier: string; 100 is_admin: number; 101 created_at: number; 102 credential_count: number; 103 }>; 104 105 return Response.json({ 106 users: users.map((u) => ({ 107 id: u.id, 108 username: u.username, 109 name: u.name, 110 email: u.email, 111 photo: u.photo, 112 status: u.status, 113 role: u.role, 114 tier: u.tier, 115 isAdmin: u.is_admin === 1, 116 createdAt: u.created_at, 117 credentialCount: u.credential_count, 118 })), 119 }); 120} 121 122export async function getProfile(req: Request): Promise<Response> { 123 const user = getSessionUser(req); 124 if (user instanceof Response) { 125 return user; 126 } 127 128 const profile = db 129 .query( 130 `SELECT id, username, name, email, photo, url, status, role, tier, is_admin, created_at 131 FROM users 132 WHERE username = ?`, 133 ) 134 .get(user.username) as 135 | { 136 id: number; 137 username: string; 138 name: string; 139 email: string | null; 140 photo: string | null; 141 url: string | null; 142 status: string; 143 role: string; 144 tier: string; 145 is_admin: number; 146 created_at: number; 147 } 148 | undefined; 149 150 if (!profile) { 151 return Response.json({ error: "Profile not found" }, { status: 404 }); 152 } 153 154 return Response.json({ 155 id: profile.id, 156 username: profile.username, 157 name: profile.name, 158 email: profile.email, 159 photo: profile.photo, 160 url: profile.url, 161 status: profile.status, 162 role: profile.role, 163 tier: profile.tier, 164 isAdmin: profile.is_admin === 1, 165 createdAt: profile.created_at, 166 }); 167} 168 169export async function updateProfile(req: Request): Promise<Response> { 170 const user = getSessionUser(req); 171 if (user instanceof Response) { 172 return user; 173 } 174 175 try { 176 const body = await req.json(); 177 const { name, email, photo, url } = body; 178 179 if (!name || typeof name !== "string") { 180 return Response.json({ error: "Name is required" }, { status: 400 }); 181 } 182 183 // If URL is being set, validate format and verify domain ownership 184 if (url && typeof url === "string") { 185 // 1. Validate URL format per IndieAuth spec 186 const validation = validateProfileURL(url); 187 if (!validation.valid) { 188 return Response.json( 189 { error: validation.error || "Invalid URL format" }, 190 { status: 400 }, 191 ); 192 } 193 194 // 2. Verify domain has rel="me" link back to profile 195 const origin = process.env.ORIGIN || "http://localhost:3000"; 196 const indikoProfileUrl = `${origin}/u/${user.username}`; 197 198 const verification = await verifyDomain( 199 validation.canonicalUrl!, 200 indikoProfileUrl, 201 ); 202 if (!verification.success) { 203 return Response.json( 204 { error: verification.error || "Failed to verify domain" }, 205 { status: 400 }, 206 ); 207 } 208 } 209 210 // Update profile 211 db.query( 212 "UPDATE users SET name = ?, email = ?, photo = ?, url = ? WHERE username = ?", 213 ).run(name, email || null, photo || null, url || null, user.username); 214 215 return Response.json({ success: true }); 216 } catch (error) { 217 console.error("Update profile error:", error); 218 return Response.json( 219 { error: "Failed to update profile" }, 220 { status: 500 }, 221 ); 222 } 223} 224 225export function getAuthorizedApps(req: Request): Response { 226 const user = getSessionUser(req); 227 if (user instanceof Response) { 228 return user; 229 } 230 231 const apps = db 232 .query( 233 `SELECT 234 a.client_id, 235 a.name, 236 a.first_seen, 237 a.last_used as app_last_used, 238 p.scopes, 239 p.granted_at, 240 p.last_used 241 FROM permissions p 242 JOIN apps a ON p.client_id = a.client_id 243 WHERE p.user_id = ? 244 ORDER BY p.last_used DESC`, 245 ) 246 .all(user.userId) as Array<{ 247 client_id: string; 248 name: string | null; 249 first_seen: number; 250 app_last_used: number; 251 scopes: string; 252 granted_at: number; 253 last_used: number; 254 }>; 255 256 return Response.json({ 257 apps: apps.map((app) => { 258 let displayName = app.name || app.client_id; 259 // Try to extract hostname if client_id is a URL 260 if (!app.name) { 261 try { 262 displayName = new URL(app.client_id).hostname; 263 } catch { 264 // Not a URL, use client_id as-is 265 displayName = app.client_id; 266 } 267 } 268 return { 269 clientId: app.client_id, 270 name: displayName, 271 scopes: JSON.parse(app.scopes) as string[], 272 grantedAt: app.granted_at, 273 lastUsed: app.last_used, 274 }; 275 }), 276 }); 277} 278 279export function revokeApp(req: Request, clientId: string): Response { 280 const user = getSessionUser(req); 281 if (user instanceof Response) { 282 return user; 283 } 284 285 // Delete permission 286 const result = db 287 .query("DELETE FROM permissions WHERE user_id = ? AND client_id = ?") 288 .run(user.userId, clientId); 289 290 if (result.changes === 0) { 291 return Response.json({ error: "App not found" }, { status: 404 }); 292 } 293 294 // Also delete any unused auth codes for this app 295 db.query( 296 "DELETE FROM authcodes WHERE user_id = ? AND client_id = ? AND used = 0", 297 ).run(user.userId, clientId); 298 299 return Response.json({ success: true }); 300} 301 302export function listAllApps(req: Request): Response { 303 const user = getSessionUser(req); 304 if (user instanceof Response) { 305 return user; 306 } 307 308 if (!user.is_admin) { 309 return Response.json({ error: "Admin access required" }, { status: 403 }); 310 } 311 312 const apps = db 313 .query( 314 `SELECT 315 a.client_id, 316 a.name, 317 a.first_seen, 318 a.last_used, 319 COUNT(DISTINCT p.user_id) as user_count 320 FROM apps a 321 LEFT JOIN permissions p ON a.client_id = p.client_id 322 GROUP BY a.client_id 323 ORDER BY a.last_used DESC`, 324 ) 325 .all() as Array<{ 326 client_id: string; 327 name: string | null; 328 first_seen: number; 329 last_used: number; 330 user_count: number; 331 }>; 332 333 return Response.json({ 334 apps: apps.map((app) => ({ 335 clientId: app.client_id, 336 name: app.name || new URL(app.client_id).hostname, 337 firstSeen: app.first_seen, 338 lastUsed: app.last_used, 339 userCount: app.user_count, 340 })), 341 }); 342} 343 344export function getAppDetails(req: Request, clientId: string): Response { 345 const user = getSessionUser(req); 346 if (user instanceof Response) { 347 return user; 348 } 349 350 if (!user.is_admin) { 351 return Response.json({ error: "Admin access required" }, { status: 403 }); 352 } 353 354 const app = db 355 .query( 356 `SELECT client_id, name, first_seen, last_used 357 FROM apps 358 WHERE client_id = ?`, 359 ) 360 .get(clientId) as 361 | { 362 client_id: string; 363 name: string | null; 364 first_seen: number; 365 last_used: number; 366 } 367 | undefined; 368 369 if (!app) { 370 return Response.json({ error: "App not found" }, { status: 404 }); 371 } 372 373 const permissions = db 374 .query( 375 `SELECT 376 u.username, 377 u.name, 378 p.scopes, 379 p.granted_at, 380 p.last_used 381 FROM permissions p 382 JOIN users u ON p.user_id = u.id 383 WHERE p.client_id = ? 384 ORDER BY p.last_used DESC`, 385 ) 386 .all(clientId) as Array<{ 387 username: string; 388 name: string; 389 scopes: string; 390 granted_at: number; 391 last_used: number; 392 }>; 393 394 return Response.json({ 395 app: { 396 clientId: app.client_id, 397 name: app.name || new URL(app.client_id).hostname, 398 firstSeen: app.first_seen, 399 lastUsed: app.last_used, 400 }, 401 permissions: permissions.map((p) => ({ 402 username: p.username, 403 name: p.name, 404 scopes: JSON.parse(p.scopes) as string[], 405 grantedAt: p.granted_at, 406 lastUsed: p.last_used, 407 })), 408 }); 409} 410 411export function revokeAppForUser( 412 req: Request, 413 clientId: string, 414 username: string, 415): Response { 416 const user = getSessionUser(req); 417 if (user instanceof Response) { 418 return user; 419 } 420 421 if (!user.is_admin) { 422 return Response.json({ error: "Admin access required" }, { status: 403 }); 423 } 424 425 const targetUser = db 426 .query("SELECT id FROM users WHERE username = ?") 427 .get(username) as { id: number } | undefined; 428 429 if (!targetUser) { 430 return Response.json({ error: "User not found" }, { status: 404 }); 431 } 432 433 const result = db 434 .query("DELETE FROM permissions WHERE user_id = ? AND client_id = ?") 435 .run(targetUser.id, clientId); 436 437 if (result.changes === 0) { 438 return Response.json({ error: "Permission not found" }, { status: 404 }); 439 } 440 441 db.query( 442 "DELETE FROM authcodes WHERE user_id = ? AND client_id = ? AND used = 0", 443 ).run(targetUser.id, clientId); 444 445 return Response.json({ success: true }); 446} 447 448export function disableUser(req: Request, userId: string): Response { 449 const user = getSessionUser(req); 450 if (user instanceof Response) { 451 return user; 452 } 453 454 if (!user.is_admin) { 455 return Response.json({ error: "Admin access required" }, { status: 403 }); 456 } 457 458 const targetUserId = Number.parseInt(userId, 10); 459 if (Number.isNaN(targetUserId)) { 460 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 461 } 462 463 // Prevent disabling self 464 if (targetUserId === user.id) { 465 return Response.json( 466 { error: "Cannot disable your own account" }, 467 { status: 400 }, 468 ); 469 } 470 471 const targetUser = db 472 .query("SELECT id, username FROM users WHERE id = ?") 473 .get(targetUserId) as { id: number; username: string } | undefined; 474 475 if (!targetUser) { 476 return Response.json({ error: "User not found" }, { status: 404 }); 477 } 478 479 db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run( 480 targetUserId, 481 ); 482 483 db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId); 484 485 return Response.json({ success: true }); 486} 487 488export function enableUser(req: Request, userId: string): Response { 489 const user = getSessionUser(req); 490 if (user instanceof Response) { 491 return user; 492 } 493 494 if (!user.is_admin) { 495 return Response.json({ error: "Admin access required" }, { status: 403 }); 496 } 497 498 const targetUserId = Number.parseInt(userId, 10); 499 if (Number.isNaN(targetUserId)) { 500 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 501 } 502 503 const targetUser = db 504 .query("SELECT id, username FROM users WHERE id = ?") 505 .get(targetUserId) as { id: number; username: string } | undefined; 506 507 if (!targetUser) { 508 return Response.json({ error: "User not found" }, { status: 404 }); 509 } 510 511 db.query("UPDATE users SET status = 'active' WHERE id = ?").run(targetUserId); 512 513 return Response.json({ success: true }); 514} 515 516export async function updateUserTier( 517 req: Request, 518 userId: string, 519): Promise<Response> { 520 const user = getSessionUser(req); 521 if (user instanceof Response) { 522 return user; 523 } 524 525 if (!user.is_admin) { 526 return Response.json({ error: "Admin access required" }, { status: 403 }); 527 } 528 529 const targetUserId = Number.parseInt(userId, 10); 530 if (Number.isNaN(targetUserId)) { 531 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 532 } 533 534 try { 535 const body = await req.json(); 536 const { tier } = body; 537 538 if (!tier || !["admin", "developer", "user"].includes(tier)) { 539 return Response.json( 540 { error: "Invalid tier. Must be 'admin', 'developer', or 'user'" }, 541 { status: 400 }, 542 ); 543 } 544 545 const targetUser = db 546 .query("SELECT id, username, tier FROM users WHERE id = ?") 547 .get(targetUserId) as 548 | { id: number; username: string; tier: string } 549 | undefined; 550 551 if (!targetUser) { 552 return Response.json({ error: "User not found" }, { status: 404 }); 553 } 554 555 // Prevent changing your own tier 556 if (targetUserId === user.userId) { 557 return Response.json( 558 { error: "Cannot change your own tier" }, 559 { status: 400 }, 560 ); 561 } 562 563 // Update tier and is_admin flag 564 db.query("UPDATE users SET tier = ?, is_admin = ? WHERE id = ?").run( 565 tier, 566 tier === "admin" ? 1 : 0, 567 targetUserId, 568 ); 569 570 return Response.json({ success: true, tier }); 571 } catch (error) { 572 console.error("Update tier error:", error); 573 return Response.json({ error: "Invalid request body" }, { status: 400 }); 574 } 575} 576 577export function deleteUser(req: Request, userId: string): Response { 578 const user = getSessionUser(req); 579 if (user instanceof Response) { 580 return user; 581 } 582 583 if (!user.is_admin) { 584 return Response.json({ error: "Admin access required" }, { status: 403 }); 585 } 586 587 const targetUserId = Number.parseInt(userId, 10); 588 if (Number.isNaN(targetUserId)) { 589 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 590 } 591 592 if (targetUserId === user.userId) { 593 return Response.json( 594 { error: "Cannot delete your own account" }, 595 { status: 400 }, 596 ); 597 } 598 599 const targetUser = db 600 .query("SELECT id, is_admin FROM users WHERE id = ?") 601 .get(targetUserId) as { id: number; is_admin: number } | undefined; 602 603 if (!targetUser) { 604 return Response.json({ error: "User not found" }, { status: 404 }); 605 } 606 607 // Prevent admins from deleting other admin accounts 608 if (targetUser.is_admin === 1) { 609 return Response.json( 610 { error: "Cannot delete admin accounts" }, 611 { status: 403 }, 612 ); 613 } 614 615 db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId); 616 db.query("DELETE FROM credentials WHERE user_id = ?").run(targetUserId); 617 db.query("DELETE FROM permissions WHERE user_id = ?").run(targetUserId); 618 db.query("DELETE FROM authcodes WHERE user_id = ?").run(targetUserId); 619 db.query("DELETE FROM users WHERE id = ?").run(targetUserId); 620 621 return Response.json({ success: true }); 622} 623 624export function deleteSelfAccount(req: Request): Response { 625 const user = getSessionUser(req); 626 if (user instanceof Response) { 627 return user; 628 } 629 630 // Prevent admins from deleting their own accounts 631 if (user.is_admin) { 632 return Response.json( 633 { 634 error: 635 "Admin accounts cannot be self-deleted. Contact another admin for account deletion.", 636 }, 637 { status: 403 }, 638 ); 639 } 640 641 // Delete all user data 642 db.query("DELETE FROM sessions WHERE user_id = ?").run(user.userId); 643 db.query("DELETE FROM credentials WHERE user_id = ?").run(user.userId); 644 db.query("DELETE FROM permissions WHERE user_id = ?").run(user.userId); 645 db.query("DELETE FROM authcodes WHERE user_id = ?").run(user.userId); 646 db.query("DELETE FROM users WHERE id = ?").run(user.userId); 647 648 return Response.json({ success: true }); 649}