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.

at go-port 228 lines 7.1 kB view raw
1// PKCE helper functions 2function generateRandomString(length: number): string { 3 const array = new Uint8Array(length); 4 crypto.getRandomValues(array); 5 return btoa(String.fromCharCode(...array)) 6 .replace(/\+/g, "-") 7 .replace(/\//g, "_") 8 .replace(/=/g, ""); 9} 10 11async function sha256(plain: string): Promise<string> { 12 const encoder = new TextEncoder(); 13 const data = encoder.encode(plain); 14 const hash = await crypto.subtle.digest("SHA-256", data); 15 const hashArray = Array.from(new Uint8Array(hash)); 16 return btoa(String.fromCharCode(...hashArray)) 17 .replace(/\+/g, "-") 18 .replace(/\//g, "_") 19 .replace(/=/g, ""); 20} 21 22// Elements 23const clientIdInput = document.getElementById("clientId") as HTMLInputElement; 24const redirectUriInput = document.getElementById( 25 "redirectUri", 26) as HTMLInputElement; 27const startBtn = document.getElementById("startBtn") as HTMLButtonElement; 28const callbackSection = document.getElementById( 29 "callbackSection", 30) as HTMLElement; 31const callbackInfo = document.getElementById("callbackInfo") as HTMLElement; 32const exchangeBtn = document.getElementById("exchangeBtn") as HTMLButtonElement; 33const resultSection = document.getElementById("resultSection") as HTMLElement; 34const resultDiv = document.getElementById("result") as HTMLElement; 35 36// Auto-fill redirect URI with current page URL 37const currentUrl = window.location.origin + window.location.pathname; 38redirectUriInput.value = currentUrl; 39 40// Auto-fill client ID with a test URL 41clientIdInput.value = window.location.origin; 42 43// Check if we're handling a callback 44const urlParams = new URLSearchParams(window.location.search); 45const code = urlParams.get("code"); 46const state = urlParams.get("state"); 47const error = urlParams.get("error"); 48 49if (error) { 50 // OAuth error response 51 showResult( 52 `Error: ${error}\n${urlParams.get("error_description") || ""}`, 53 "error", 54 ); 55 resultSection.style.display = "block"; 56} else if (code && state) { 57 // We have a callback with authorization code 58 handleCallback(code, state); 59} 60 61// Start OAuth flow 62startBtn.addEventListener("click", async () => { 63 const clientId = clientIdInput.value.trim(); 64 const redirectUri = redirectUriInput.value.trim(); 65 66 if (!clientId || !redirectUri) { 67 alert("Please fill in client ID and redirect URI"); 68 return; 69 } 70 71 // Get selected scopes 72 const scopeCheckboxes = document.querySelectorAll( 73 'input[name="scope"]:checked', 74 ); 75 const scopes = Array.from(scopeCheckboxes).map( 76 (cb) => (cb as HTMLInputElement).value, 77 ); 78 79 if (scopes.length === 0) { 80 alert("Please select at least one scope"); 81 return; 82 } 83 84 // Generate PKCE parameters 85 const codeVerifier = generateRandomString(64); 86 const codeChallenge = await sha256(codeVerifier); 87 const state = generateRandomString(32); 88 89 // Store PKCE values in localStorage for callback 90 localStorage.setItem("oauth_code_verifier", codeVerifier); 91 localStorage.setItem("oauth_state", state); 92 localStorage.setItem("oauth_client_id", clientId); 93 localStorage.setItem("oauth_redirect_uri", redirectUri); 94 95 // Build authorization URL 96 const authUrl = new URL("/auth/authorize", window.location.origin); 97 authUrl.searchParams.set("response_type", "code"); 98 authUrl.searchParams.set("client_id", clientId); 99 authUrl.searchParams.set("redirect_uri", redirectUri); 100 authUrl.searchParams.set("state", state); 101 authUrl.searchParams.set("code_challenge", codeChallenge); 102 authUrl.searchParams.set("code_challenge_method", "S256"); 103 authUrl.searchParams.set("scope", scopes.join(" ")); 104 105 // Redirect to authorization endpoint 106 window.location.href = authUrl.toString(); 107}); 108 109// Handle OAuth callback 110function handleCallback(code: string, state: string) { 111 const storedState = localStorage.getItem("oauth_state"); 112 113 if (state !== storedState) { 114 showResult("Error: State mismatch (CSRF attack?)", "error"); 115 resultSection.style.display = "block"; 116 return; 117 } 118 119 callbackSection.style.display = "block"; 120 callbackInfo.innerHTML = ` 121 <p style="margin-bottom: 1rem;"><strong>Authorization Code:</strong><br><code style="word-break: break-all;">${code}</code></p> 122 <p><strong>State:</strong> <code>${state}</code> ✓ (verified)</p> 123 `; 124 125 // Scroll to callback section 126 callbackSection.scrollIntoView({ behavior: "smooth" }); 127} 128 129// Exchange authorization code for user profile 130exchangeBtn.addEventListener("click", async () => { 131 const code = urlParams.get("code"); 132 const codeVerifier = localStorage.getItem("oauth_code_verifier"); 133 const clientId = localStorage.getItem("oauth_client_id"); 134 const redirectUri = localStorage.getItem("oauth_redirect_uri"); 135 136 if (!code || !codeVerifier || !clientId || !redirectUri) { 137 showResult("Error: Missing OAuth parameters", "error"); 138 resultSection.style.display = "block"; 139 return; 140 } 141 142 exchangeBtn.disabled = true; 143 exchangeBtn.textContent = "exchanging..."; 144 145 try { 146 const response = await fetch("/auth/token", { 147 method: "POST", 148 headers: { 149 "Content-Type": "application/json", 150 }, 151 body: JSON.stringify({ 152 grant_type: "authorization_code", 153 code, 154 client_id: clientId, 155 redirect_uri: redirectUri, 156 code_verifier: codeVerifier, 157 }), 158 }); 159 160 const data = await response.json(); 161 162 if (!response.ok) { 163 showResult( 164 `Error: ${data.error}\n${data.error_description || ""}`, 165 "error", 166 ); 167 } else { 168 showResult( 169 `Success! User authenticated:\n\n${JSON.stringify(data, null, 2)}`, 170 "success", 171 ); 172 173 // Clean up localStorage 174 localStorage.removeItem("oauth_code_verifier"); 175 localStorage.removeItem("oauth_state"); 176 localStorage.removeItem("oauth_client_id"); 177 localStorage.removeItem("oauth_redirect_uri"); 178 } 179 } catch (error) { 180 showResult(`Error: ${(error as Error).message}`, "error"); 181 } finally { 182 exchangeBtn.disabled = false; 183 exchangeBtn.textContent = "exchange code for profile"; 184 resultSection.style.display = "block"; 185 resultSection.scrollIntoView({ behavior: "smooth" }); 186 } 187}); 188 189function showResult(text: string, type: "success" | "error") { 190 if (type === "success" && text.includes("{")) { 191 // Extract and parse JSON from success message 192 const jsonStart = text.indexOf("{"); 193 const jsonStr = text.substring(jsonStart); 194 const prefix = text.substring(0, jsonStart); 195 196 try { 197 const data = JSON.parse(jsonStr); 198 resultDiv.innerHTML = `${prefix}<pre style="margin: 0; font-family: 'Space Grotesk', monospace;">${syntaxHighlightJSON(data)}</pre>`; 199 } catch { 200 resultDiv.textContent = text; 201 } 202 } else { 203 resultDiv.textContent = text; 204 } 205 resultDiv.className = `result show ${type}`; 206} 207 208function syntaxHighlightJSON(obj: any): string { 209 const json = JSON.stringify(obj, null, 2); 210 return json.replace( 211 /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, 212 (match) => { 213 let cls = "json-number"; 214 if (/^"/.test(match)) { 215 if (/:$/.test(match)) { 216 cls = "json-key"; 217 } else { 218 cls = "json-string"; 219 } 220 } else if (/true|false/.test(match)) { 221 cls = "json-boolean"; 222 } else if (/null/.test(match)) { 223 cls = "json-null"; 224 } 225 return `<span class="${cls}">${match}</span>`; 226 }, 227 ); 228}