my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
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}