import { db } from "../../db"; import { safeFetch, validateExternalURL } from "../ssrf-safe-fetch"; import { canonicalizeURL, isLoopbackURL, validateClientURL } from "./urls"; export interface ClientMetadata { client_id: string; client_name?: string; client_uri?: string; logo_uri?: string; redirect_uris?: string[]; token_endpoint_auth_method?: string; [key: string]: unknown; } // Per the Client ID Metadata Document draft, a public client publishing // metadata at its URL must not claim a shared-secret auth method. const SECRET_AUTH_METHODS = new Set([ "client_secret_post", "client_secret_basic", "client_secret_jwt", "private_key_jwt", ]); // Draft recommends bounding the metadata document size. const MAX_METADATA_BYTES = 5 * 1024; // Validate a fetched metadata document against the client_id URL it came // from. Pure so it's directly testable without a live fetch. export function validateMetadataDocument( text: string, clientId: string, ): { success: boolean; metadata?: ClientMetadata; error?: string } { if (text.length > MAX_METADATA_BYTES) { return { success: false, error: "Client metadata document too large" }; } let metadata: ClientMetadata; try { metadata = JSON.parse(text) as ClientMetadata; } catch { return { success: false, error: "Invalid JSON in client metadata" }; } if (metadata.client_id && metadata.client_id !== clientId) { return { success: false, error: "client_id in metadata does not match URL", }; } // Public clients publishing metadata must not request a shared-secret // auth method; there's no registration step to establish one. if ( metadata.token_endpoint_auth_method && SECRET_AUTH_METHODS.has(metadata.token_endpoint_auth_method) ) { return { success: false, error: `token_endpoint_auth_method "${metadata.token_endpoint_auth_method}" is not allowed for URL-based clients`, }; } // Validate metadata URL fields to prevent SSRF via later fetches if (metadata.logo_uri) { const logoValidation = validateExternalURL(metadata.logo_uri); if (!logoValidation.safe) { delete metadata.logo_uri; } } if (metadata.client_uri) { const clientUriValidation = validateExternalURL(metadata.client_uri); if (!clientUriValidation.safe) { delete metadata.client_uri; } } return { success: true, metadata }; } export async function fetchClientMetadata(clientId: string): Promise<{ success: boolean; metadata?: ClientMetadata; error?: string; }> { const urlValidation = validateExternalURL(clientId); if (!urlValidation.safe) { return { success: false, error: urlValidation.error || "Invalid client_id URL", }; } // IndieAuth spec: MUST NOT fetch loopback addresses if (isLoopbackURL(clientId)) { return { success: false, error: "Cannot fetch metadata from loopback addresses", }; } const fetchResult = await safeFetch(clientId, { timeout: 5000 }); if (!fetchResult.success) { return { success: false, error: `Failed to fetch client metadata: ${fetchResult.error}`, }; } const response = fetchResult.data; if (!response.ok) { return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}`, }; } const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const text = await response.text(); return validateMetadataDocument(text, clientId); } // HTML: look for tags if (contentType.includes("text/html")) { const html = await response.text(); const redirectUris: string[] = []; const linkTagRegex = /]*>/gi; for (const tagMatch of html.matchAll(linkTagRegex)) { const tag = tagMatch[0]; const rel = tag.match(/rel=["']?([^"'\s>]+)["']?/i)?.[1]; if (!rel) continue; if (!rel.split(/\s+/).includes("redirect_uri")) continue; const href = tag.match(/href=["']?([^"'\s>]+)["']?/i)?.[1]; if (href && !redirectUris.includes(href)) { redirectUris.push(href); } } if (redirectUris.length > 0) { return { success: true, metadata: { client_id: clientId, redirect_uris: redirectUris, }, }; } return { success: false, error: "No client metadata or redirect_uri links found in HTML", }; } return { success: false, error: "Unsupported content type" }; } // Verify domain has rel="me" link back to user profile (with SSRF protection) export async function verifyDomain( domainUrl: string, indikoProfileUrl: string, ): Promise<{ success: boolean; error?: string; }> { const urlValidation = validateExternalURL(domainUrl); if (!urlValidation.safe) { return { success: false, error: urlValidation.error || "Invalid domain URL", }; } const fetchResult = await safeFetch(domainUrl, { timeout: 5000, headers: { Accept: "text/html", "User-Agent": "indiko/1.0 (+https://indiko.dunkirk.sh/)", }, }); if (!fetchResult.success) { console.error( `[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`, ); return { success: false, error: `Failed to fetch domain: ${fetchResult.error}`, }; } const response = fetchResult.data; if (!response.ok) { const errorBody = await response.text(); console.error( `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, { status: response.status, contentType: response.headers.get("content-type"), bodyPreview: errorBody.substring(0, 200), }, ); return { success: false, error: `Failed to fetch domain: HTTP ${response.status}`, }; } const html = await response.text(); // Find all and tags with rel containing "me", collect their hrefs const relMeLinks: string[] = []; const tagRegex = /<(?:link|a)\s+[^>]*>/gi; for (const tagMatch of html.matchAll(tagRegex)) { const tag = tagMatch[0]; const rel = tag.match(/rel=["']?([^"'\s>]+)["']?/i)?.[1]; if (!rel) continue; if (!rel.split(/\s+/).includes("me")) continue; const href = tag.match(/href=["']?([^"'\s>]+)["']?/i)?.[1]; if (href && !relMeLinks.includes(href)) { relMeLinks.push(href); } } const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); const hasRelMe = relMeLinks.some((link) => { try { return canonicalizeURL(link) === normalizedIndikoUrl; } catch { return false; } }); if (!hasRelMe) { console.error( `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { foundLinks: relMeLinks, normalizedTarget: normalizedIndikoUrl, }, ); return { success: false, error: `Your site must link back to ${indikoProfileUrl} with rel="me" to verify you own it. Add a link tag or anchor with rel="me" pointing to that URL, then try again.`, }; } return { success: true }; } export interface AppRecord { name: string | null; redirect_uris: string; logo_url?: string | null; } // Validate and register app with client information discovery export async function ensureApp( clientId: string, redirectUri: string, ): Promise<{ error?: string; app?: AppRecord; }> { const existing = db .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") .get(clientId) as | { name: string | null; redirect_uris: string; logo_url?: string | null } | undefined; if (existing) { db.query("UPDATE apps SET last_used = ? WHERE client_id = ?").run( Math.floor(Date.now() / 1000), clientId, ); return { app: existing }; } // Validate client URL per IndieAuth spec const validation = validateClientURL(clientId); if (!validation.valid) { return { error: validation.error || "Invalid client URL" }; } const canonicalClientId = validation.canonicalUrl; if (!canonicalClientId) { return { error: "Invalid client URL" }; } // Fetch client metadata per IndieAuth spec const metadataResult = await fetchClientMetadata(canonicalClientId); let clientName: string | null = null; let logoUrl: string | null = null; let allowedRedirectUris: string[] = []; const hostsDiffer = (a: string, b: string): boolean => { const urlA = new URL(a); const urlB = new URL(b); return ( urlA.protocol !== urlB.protocol || urlA.hostname !== urlB.hostname || urlA.port !== urlB.port ); }; if (metadataResult.success && metadataResult.metadata) { clientName = metadataResult.metadata.client_name || null; logoUrl = metadataResult.metadata.logo_uri || null; allowedRedirectUris = metadataResult.metadata.redirect_uris || []; if (allowedRedirectUris.length > 0) { if (hostsDiffer(canonicalClientId, redirectUri)) { // MUST verify redirect_uri is in published list if (!allowedRedirectUris.includes(redirectUri)) { return { error: `redirect_uri not registered in client metadata. The client published a list of allowed redirect URIs, but ${redirectUri} is not in that list.`, }; } } else if (!allowedRedirectUris.includes(redirectUri)) { allowedRedirectUris.push(redirectUri); } } else { allowedRedirectUris = [redirectUri]; } } else { // Could not fetch metadata - allow only same-host redirects if (hostsDiffer(canonicalClientId, redirectUri)) { return { error: `Could not fetch client metadata to verify redirect_uri. For security, redirect_uri must have same host as client_id, or client must publish redirect_uris. Error: ${metadataResult.error}`, }; } allowedRedirectUris = [redirectUri]; } db.query( "INSERT INTO apps (client_id, redirect_uris, name, logo_url, is_preregistered, first_seen, last_used) VALUES (?, ?, ?, ?, 0, ?, ?)", ).run( canonicalClientId, JSON.stringify(allowedRedirectUris), clientName, logoUrl, Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000), ); const newApp = db .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") .get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null; }; return { app: newApp }; }