my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add domain verification and use custom domain as identity

- Add verifyDomain function to check for rel="me" links
- Verify custom domains have rel="me" link back to indiko profile
- Update profile endpoint to verify domain ownership before saving
- Return custom domain as "me" in token response when user has verified domain
- Supports both <link rel="me"> and <a rel="me"> tags

This ensures users can only claim domains they control and their
verified custom domain becomes their IndieAuth identity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+93 -3
+15
src/routes/api.ts
··· 1 1 import { db } from "../db"; 2 + import { verifyDomain } from "./indieauth"; 2 3 3 4 function getSessionUser( 4 5 req: Request, ··· 168 169 169 170 if (!name || typeof name !== "string") { 170 171 return Response.json({ error: "Name is required" }, { status: 400 }); 172 + } 173 + 174 + // If URL is being set, verify domain has rel="me" link back to profile 175 + if (url && typeof url === "string") { 176 + const origin = process.env.ORIGIN || "http://localhost:3000"; 177 + const indikoProfileUrl = `${origin}/u/${user.username}`; 178 + 179 + const verification = await verifyDomain(url, indikoProfileUrl); 180 + if (!verification.success) { 181 + return Response.json( 182 + { error: verification.error || "Failed to verify domain" }, 183 + { status: 400 }, 184 + ); 185 + } 171 186 } 172 187 173 188 // Update profile
+78 -3
src/routes/indieauth.ts
··· 328 328 } 329 329 } 330 330 331 + // Verify domain has rel="me" link back to user profile 332 + export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ 333 + success: boolean; 334 + error?: string; 335 + }> { 336 + try { 337 + // Set timeout for fetch 338 + const controller = new AbortController(); 339 + const timeoutId = setTimeout(() => controller.abort(), 5000); 340 + 341 + const response = await fetch(domainUrl, { 342 + method: "GET", 343 + headers: { 344 + Accept: "text/html", 345 + }, 346 + signal: controller.signal, 347 + }); 348 + 349 + clearTimeout(timeoutId); 350 + 351 + if (!response.ok) { 352 + return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` }; 353 + } 354 + 355 + const html = await response.text(); 356 + 357 + // Look for <link rel="me"> or <a rel="me"> pointing to indiko profile 358 + // Match both <link> and <a> tags with rel="me" 359 + const relMeRegex = /<(?:link|a)\s+[^>]*rel=["']me["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 360 + const relMeRegex2 = /<(?:link|a)\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']me["'][^>]*>/gi; 361 + 362 + const relMeLinks: string[] = []; 363 + let match: RegExpExecArray | null; 364 + 365 + while ((match = relMeRegex.exec(html)) !== null) { 366 + relMeLinks.push(match[1]); 367 + } 368 + 369 + while ((match = relMeRegex2.exec(html)) !== null) { 370 + if (!relMeLinks.includes(match[1])) { 371 + relMeLinks.push(match[1]); 372 + } 373 + } 374 + 375 + // Check if any rel="me" link matches the indiko profile URL 376 + const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); 377 + const hasRelMe = relMeLinks.some(link => { 378 + try { 379 + const normalizedLink = canonicalizeURL(link); 380 + return normalizedLink === normalizedIndikoUrl; 381 + } catch { 382 + return false; 383 + } 384 + }); 385 + 386 + if (!hasRelMe) { 387 + return { 388 + success: false, 389 + error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, 390 + }; 391 + } 392 + 393 + return { success: true }; 394 + } catch (error) { 395 + if (error instanceof Error) { 396 + if (error.name === "AbortError") { 397 + return { success: false, error: "Timeout verifying domain" }; 398 + } 399 + return { success: false, error: `Failed to verify domain: ${error.message}` }; 400 + } 401 + return { success: false, error: "Failed to verify domain" }; 402 + } 403 + } 404 + 331 405 // Validate and register app with client information discovery 332 406 async function ensureApp( 333 407 clientId: string, ··· 1525 1599 .query("SELECT role FROM permissions WHERE user_id = ? AND client_id = ?") 1526 1600 .get(authcode.user_id, client_id) as { role: string | null } | undefined; 1527 1601 1528 - // Use the me parameter from authorization if it matches user's website, otherwise use canonical URL 1602 + // Use custom domain as identity if user has verified one, otherwise use indiko profile 1529 1603 let meValue = `${process.env.ORIGIN}/u/${user.username}`; 1530 - if (authcode.me && user.url && authcode.me === user.url) { 1531 - meValue = authcode.me; 1604 + if (user.url) { 1605 + // User has verified custom domain - use it as their identity 1606 + meValue = user.url; 1532 1607 } 1533 1608 1534 1609 const response: Record<string, unknown> = {