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 user profiles and indieauth endpoints

+1557 -21
+42 -2
CRUSH.md
··· 14 14 - Import handlers in `src/index.ts` and wire them in the `routes` object 15 15 - Use Bun's built-in routing: `routes: { "/path": handler }` 16 16 - Example: `src/routes/auth.ts` contains authentication-related routes 17 + - IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts` 17 18 18 19 ### Project Structure 19 20 ``` ··· 21 22 ├── db.ts # Database setup and exports 22 23 ├── index.ts # Main server entry point 23 24 ├── routes/ # Route handlers (server-side) 24 - │ └── auth.ts # Authentication routes 25 + │ ├── auth.ts # Passkey authentication routes 26 + │ ├── api.ts # API endpoints (hello, users, profile) 27 + │ └── indieauth.ts # IndieAuth/OAuth 2.0 server endpoints 25 28 ├── client/ # Client-side TypeScript modules 26 - │ └── login.ts # Login page logic 29 + │ ├── login.ts # Login page logic 30 + │ ├── index.ts # Dashboard logic 31 + │ └── profile.ts # Profile editing logic 27 32 ├── html/ # HTML templates (Bun bundles them with script imports) 33 + │ ├── login.html 34 + │ ├── index.html 35 + │ └── profile.html 28 36 └── migrations/ # SQL migrations 37 + ├── 001_init.sql 38 + ├── 002_add_user_status_role.sql 39 + └── 003_add_indieauth_tables.sql 29 40 ``` 30 41 31 42 ### Client-Side Code ··· 35 46 - Static assets (images, favicons) in `public/` are served at root path 36 47 - In HTML files: use paths relative to server root (e.g., `/logo.svg`, `/favicon.svg`) since Bun bundles HTML and resolves paths from server context 37 48 49 + ### IndieAuth/OAuth 2.0 Implementation 50 + - Full IndieAuth server supporting OAuth 2.0 with PKCE 51 + - Authorization code flow with single-use, short-lived codes (60 seconds) 52 + - Auto-registration of client apps on first authorization 53 + - Consent screen with scope selection 54 + - Auto-approval for previously approved apps 55 + - Session-based SSO (users only authenticate once with passkey) 56 + - User profile endpoints with h-card microformats 57 + - Token exchange endpoint for client apps 58 + - Invite-based registration for new users (admin only) 59 + 60 + ### Database Schema 61 + - **users**: username, name, email, photo, url, status, role, is_admin 62 + - **credentials**: passkey credentials (credential_id, public_key, counter) 63 + - **sessions**: user sessions with 24-hour expiry 64 + - **challenges**: WebAuthn challenges (5-minute expiry) 65 + - **apps**: auto-registered OAuth clients 66 + - **permissions**: per-user, per-app granted scopes 67 + - **authcodes**: short-lived authorization codes (60-second expiry, single-use) 68 + - **invites**: admin-created invite codes 69 + 70 + ### WebAuthn/Passkey Settings 71 + - **Registration**: residentKey="required", userVerification="required" 72 + - **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials) 73 + - **Credential lookup**: credential_id stored as Buffer, compare using base64url string 74 + - Passkeys are discoverable so password managers can show them without hints 75 + 38 76 ## Commands 39 77 40 78 (Add test/lint/build commands here as discovered) ··· 45 83 - TypeScript with Bun runtime 46 84 - Use SQLite with WAL mode 47 85 - Route handlers: `(req: Request) => Response` 86 + - Session cookies named `indiko_session` 87 + - Authorization header: `Bearer {token}`
+1 -1
src/client/index.ts
··· 24 24 25 25 const data = await response.json(); 26 26 27 - footer.innerHTML = `signed in as <strong>${data.username}</strong> • <a href="/profile">edit profile</a> • <a href="/login" id="logoutLink">sign out</a>`; 27 + footer.innerHTML = `signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/profile">edit profile</a> • <a href="/oauth-test">test oauth</a> • <a href="/login" id="logoutLink">sign out</a>`; 28 28 29 29 // Handle logout 30 30 document.getElementById('logoutLink')?.addEventListener('click', async (e) => {
+12 -2
src/client/login.ts
··· 82 82 localStorage.setItem('indiko_session', token); 83 83 84 84 showMessage('Login successful!', 'success'); 85 + 86 + // Check for return URL parameter 87 + const urlParams = new URLSearchParams(window.location.search); 88 + const returnUrl = urlParams.get('return') || '/'; 89 + 85 90 const redirectTimer = setTimeout(() => { 86 - window.location.href = '/'; 91 + window.location.href = returnUrl; 87 92 }, 1000); 88 93 (redirectTimer as unknown as number); 89 94 ··· 141 146 localStorage.setItem('indiko_session', token); 142 147 143 148 showMessage('Registration successful!', 'success'); 149 + 150 + // Check for return URL parameter 151 + const urlParams = new URLSearchParams(window.location.search); 152 + const returnUrl = urlParams.get('return') || '/'; 153 + 144 154 const redirectTimer = setTimeout(() => { 145 - window.location.href = '/'; 155 + window.location.href = returnUrl; 146 156 }, 1000); 147 157 (redirectTimer as unknown as number); 148 158
+214
src/client/oauth-test.ts
··· 1 + // PKCE helper functions 2 + function 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 + 11 + async 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 23 + const clientIdInput = document.getElementById('clientId') as HTMLInputElement; 24 + const redirectUriInput = document.getElementById('redirectUri') as HTMLInputElement; 25 + const startBtn = document.getElementById('startBtn') as HTMLButtonElement; 26 + const callbackSection = document.getElementById('callbackSection') as HTMLElement; 27 + const callbackInfo = document.getElementById('callbackInfo') as HTMLElement; 28 + const exchangeBtn = document.getElementById('exchangeBtn') as HTMLButtonElement; 29 + const resultSection = document.getElementById('resultSection') as HTMLElement; 30 + const resultDiv = document.getElementById('result') as HTMLElement; 31 + 32 + // Auto-fill redirect URI with current page URL 33 + const currentUrl = window.location.origin + window.location.pathname; 34 + redirectUriInput.value = currentUrl; 35 + 36 + // Auto-fill client ID with a test URL 37 + clientIdInput.value = window.location.origin; 38 + 39 + // Check if we're handling a callback 40 + const urlParams = new URLSearchParams(window.location.search); 41 + const code = urlParams.get('code'); 42 + const state = urlParams.get('state'); 43 + const error = urlParams.get('error'); 44 + 45 + if (error) { 46 + // OAuth error response 47 + showResult(`Error: ${error}\n${urlParams.get('error_description') || ''}`, 'error'); 48 + resultSection.style.display = 'block'; 49 + } else if (code && state) { 50 + // We have a callback with authorization code 51 + handleCallback(code, state); 52 + } 53 + 54 + // Start OAuth flow 55 + startBtn.addEventListener('click', async () => { 56 + const clientId = clientIdInput.value.trim(); 57 + const redirectUri = redirectUriInput.value.trim(); 58 + 59 + if (!clientId || !redirectUri) { 60 + alert('Please fill in client ID and redirect URI'); 61 + return; 62 + } 63 + 64 + // Get selected scopes 65 + const scopeCheckboxes = document.querySelectorAll('input[name="scope"]:checked'); 66 + const scopes = Array.from(scopeCheckboxes).map((cb) => (cb as HTMLInputElement).value); 67 + 68 + if (scopes.length === 0) { 69 + alert('Please select at least one scope'); 70 + return; 71 + } 72 + 73 + // Generate PKCE parameters 74 + const codeVerifier = generateRandomString(64); 75 + const codeChallenge = await sha256(codeVerifier); 76 + const state = generateRandomString(32); 77 + 78 + // Store PKCE values in localStorage for callback 79 + localStorage.setItem('oauth_code_verifier', codeVerifier); 80 + localStorage.setItem('oauth_state', state); 81 + localStorage.setItem('oauth_client_id', clientId); 82 + localStorage.setItem('oauth_redirect_uri', redirectUri); 83 + 84 + // Build authorization URL 85 + const authUrl = new URL('/auth/authorize', window.location.origin); 86 + authUrl.searchParams.set('response_type', 'code'); 87 + authUrl.searchParams.set('client_id', clientId); 88 + authUrl.searchParams.set('redirect_uri', redirectUri); 89 + authUrl.searchParams.set('state', state); 90 + authUrl.searchParams.set('code_challenge', codeChallenge); 91 + authUrl.searchParams.set('code_challenge_method', 'S256'); 92 + authUrl.searchParams.set('scope', scopes.join(' ')); 93 + 94 + // Redirect to authorization endpoint 95 + window.location.href = authUrl.toString(); 96 + }); 97 + 98 + // Handle OAuth callback 99 + function handleCallback(code: string, state: string) { 100 + const storedState = localStorage.getItem('oauth_state'); 101 + 102 + if (state !== storedState) { 103 + showResult('Error: State mismatch (CSRF attack?)', 'error'); 104 + resultSection.style.display = 'block'; 105 + return; 106 + } 107 + 108 + callbackSection.style.display = 'block'; 109 + callbackInfo.innerHTML = ` 110 + <p style="margin-bottom: 1rem;"><strong>Authorization Code:</strong><br><code style="word-break: break-all;">${code}</code></p> 111 + <p><strong>State:</strong> <code>${state}</code> ✓ (verified)</p> 112 + `; 113 + 114 + // Scroll to callback section 115 + callbackSection.scrollIntoView({ behavior: 'smooth' }); 116 + } 117 + 118 + // Exchange authorization code for user profile 119 + exchangeBtn.addEventListener('click', async () => { 120 + const code = urlParams.get('code'); 121 + const codeVerifier = localStorage.getItem('oauth_code_verifier'); 122 + const clientId = localStorage.getItem('oauth_client_id'); 123 + const redirectUri = localStorage.getItem('oauth_redirect_uri'); 124 + 125 + if (!code || !codeVerifier || !clientId || !redirectUri) { 126 + showResult('Error: Missing OAuth parameters', 'error'); 127 + resultSection.style.display = 'block'; 128 + return; 129 + } 130 + 131 + exchangeBtn.disabled = true; 132 + exchangeBtn.textContent = 'exchanging...'; 133 + 134 + try { 135 + const response = await fetch('/auth/token', { 136 + method: 'POST', 137 + headers: { 138 + 'Content-Type': 'application/json', 139 + }, 140 + body: JSON.stringify({ 141 + grant_type: 'authorization_code', 142 + code, 143 + client_id: clientId, 144 + redirect_uri: redirectUri, 145 + code_verifier: codeVerifier, 146 + }), 147 + }); 148 + 149 + const data = await response.json(); 150 + 151 + if (!response.ok) { 152 + showResult( 153 + `Error: ${data.error}\n${data.error_description || ''}`, 154 + 'error' 155 + ); 156 + } else { 157 + showResult( 158 + `Success! User authenticated:\n\n${JSON.stringify(data, null, 2)}`, 159 + 'success' 160 + ); 161 + 162 + // Clean up localStorage 163 + localStorage.removeItem('oauth_code_verifier'); 164 + localStorage.removeItem('oauth_state'); 165 + localStorage.removeItem('oauth_client_id'); 166 + localStorage.removeItem('oauth_redirect_uri'); 167 + } 168 + } catch (error) { 169 + showResult(`Error: ${(error as Error).message}`, 'error'); 170 + } finally { 171 + exchangeBtn.disabled = false; 172 + exchangeBtn.textContent = 'exchange code for profile'; 173 + resultSection.style.display = 'block'; 174 + resultSection.scrollIntoView({ behavior: 'smooth' }); 175 + } 176 + }); 177 + 178 + function showResult(text: string, type: 'success' | 'error') { 179 + if (type === 'success' && text.includes('{')) { 180 + // Extract and parse JSON from success message 181 + const jsonStart = text.indexOf('{'); 182 + const jsonStr = text.substring(jsonStart); 183 + const prefix = text.substring(0, jsonStart); 184 + 185 + try { 186 + const data = JSON.parse(jsonStr); 187 + resultDiv.innerHTML = `${prefix}<pre style="margin: 0; font-family: 'Space Grotesk', monospace;">${syntaxHighlightJSON(data)}</pre>`; 188 + } catch { 189 + resultDiv.textContent = text; 190 + } 191 + } else { 192 + resultDiv.textContent = text; 193 + } 194 + resultDiv.className = `result show ${type}`; 195 + } 196 + 197 + function syntaxHighlightJSON(obj: any): string { 198 + const json = JSON.stringify(obj, null, 2); 199 + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => { 200 + let cls = 'json-number'; 201 + if (/^"/.test(match)) { 202 + if (/:$/.test(match)) { 203 + cls = 'json-key'; 204 + } else { 205 + cls = 'json-string'; 206 + } 207 + } else if (/true|false/.test(match)) { 208 + cls = 'json-boolean'; 209 + } else if (/null/.test(match)) { 210 + cls = 'json-null'; 211 + } 212 + return `<span class="${cls}">${match}</span>`; 213 + }); 214 + }
+1 -6
src/html/index.html
··· 72 72 letter-spacing: 0.05rem; 73 73 } 74 74 75 - footer strong { 76 - color: var(--lavender); 77 - font-weight: 500; 78 - } 79 - 80 75 footer a { 81 76 color: var(--berry-crush); 82 77 text-decoration: none; ··· 249 244 </main> 250 245 251 246 <footer id="footer"> 252 - loading... 247 + loading... • <a href="/oauth-test">test oauth</a> 253 248 </footer> 254 249 255 250 <script type="module" src="../client/index.ts"></script>
+325
src/html/oauth-test.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>oauth test client • indiko</title> 8 + <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 9 + <link rel="preconnect" href="https://fonts.googleapis.com"> 10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 12 + <style> 13 + :root { 14 + --mahogany: #26242b; 15 + --lavender: #d9d0de; 16 + --old-rose: #bc8da0; 17 + --rosewood: #a04668; 18 + --berry-crush: #ab4967; 19 + } 20 + 21 + * { 22 + margin: 0; 23 + padding: 0; 24 + box-sizing: border-box; 25 + } 26 + 27 + body { 28 + font-family: "Space Grotesk", sans-serif; 29 + background: var(--mahogany); 30 + color: var(--lavender); 31 + min-height: 100vh; 32 + padding: 2.5rem 1.25rem; 33 + } 34 + 35 + .container { 36 + max-width: 56.25rem; 37 + margin: 0 auto; 38 + } 39 + 40 + h1 { 41 + font-size: 2rem; 42 + font-weight: 700; 43 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 44 + -webkit-background-clip: text; 45 + -webkit-text-fill-color: transparent; 46 + background-clip: text; 47 + letter-spacing: -0.125rem; 48 + margin-bottom: 0.5rem; 49 + } 50 + 51 + .subtitle { 52 + color: var(--old-rose); 53 + margin-bottom: 2rem; 54 + font-size: 1rem; 55 + font-weight: 300; 56 + } 57 + 58 + .section { 59 + background: rgba(188, 141, 160, 0.05); 60 + border: 1px solid var(--old-rose); 61 + padding: 2rem; 62 + margin-bottom: 1.5rem; 63 + } 64 + 65 + .section h2 { 66 + font-size: 1.25rem; 67 + font-weight: 600; 68 + color: var(--lavender); 69 + margin-bottom: 1.5rem; 70 + } 71 + 72 + label { 73 + display: block; 74 + color: var(--old-rose); 75 + font-size: 0.875rem; 76 + font-weight: 500; 77 + margin-bottom: 0.5rem; 78 + text-transform: uppercase; 79 + letter-spacing: 0.05rem; 80 + } 81 + 82 + input[type="text"], 83 + input[type="url"] { 84 + width: 100%; 85 + padding: 0.875rem 1rem; 86 + background: rgba(12, 23, 19, 0.6); 87 + border: 2px solid var(--rosewood); 88 + border-radius: 0; 89 + color: var(--lavender); 90 + font-size: 1rem; 91 + font-family: "Space Grotesk", sans-serif; 92 + margin-bottom: 1.5rem; 93 + transition: border-color 0.2s; 94 + } 95 + 96 + input:focus { 97 + outline: none; 98 + border-color: var(--berry-crush); 99 + background: rgba(12, 23, 19, 0.8); 100 + } 101 + 102 + .checkbox-group { 103 + margin-bottom: 1.5rem; 104 + } 105 + 106 + .checkbox-group label { 107 + display: flex; 108 + align-items: center; 109 + gap: 0.5rem; 110 + text-transform: none; 111 + font-weight: 400; 112 + margin-bottom: 0.75rem; 113 + cursor: pointer; 114 + } 115 + 116 + input[type="checkbox"] { 117 + width: 1.25rem; 118 + height: 1.25rem; 119 + cursor: pointer; 120 + } 121 + 122 + button { 123 + position: relative; 124 + padding: 1rem 2rem; 125 + background: var(--berry-crush); 126 + color: var(--lavender); 127 + border: 4px solid var(--mahogany); 128 + border-radius: 0; 129 + font-size: 1rem; 130 + font-weight: 700; 131 + cursor: pointer; 132 + font-family: "Space Grotesk", sans-serif; 133 + transition: all 0.15s ease; 134 + text-transform: uppercase; 135 + letter-spacing: 0.1rem; 136 + box-shadow: 6px 6px 0 var(--mahogany); 137 + width: 100%; 138 + } 139 + 140 + button::before { 141 + content: ''; 142 + position: absolute; 143 + top: -4px; 144 + left: -4px; 145 + right: -4px; 146 + bottom: -4px; 147 + background: transparent; 148 + border: 4px solid var(--rosewood); 149 + pointer-events: none; 150 + transition: all 0.15s ease; 151 + } 152 + 153 + button:hover:not(:disabled) { 154 + transform: translate(3px, 3px); 155 + box-shadow: 3px 3px 0 var(--mahogany); 156 + } 157 + 158 + button:hover:not(:disabled)::before { 159 + top: -7px; 160 + left: -7px; 161 + right: -7px; 162 + bottom: -7px; 163 + } 164 + 165 + button:active:not(:disabled) { 166 + transform: translate(6px, 6px); 167 + box-shadow: 0 0 0 var(--mahogany); 168 + } 169 + 170 + button:disabled { 171 + opacity: 0.5; 172 + cursor: not-allowed; 173 + } 174 + 175 + .result { 176 + background: rgba(12, 23, 19, 0.6); 177 + border: 2px solid var(--rosewood); 178 + padding: 1.5rem; 179 + margin-top: 1.5rem; 180 + font-family: monospace; 181 + font-size: 0.875rem; 182 + white-space: pre-wrap; 183 + word-break: break-all; 184 + display: none; 185 + } 186 + 187 + .result.show { 188 + display: block; 189 + } 190 + 191 + .result.success { 192 + border-color: #81c784; 193 + background: rgba(139, 195, 74, 0.1); 194 + } 195 + 196 + .result.error { 197 + border-color: var(--rosewood); 198 + background: rgba(160, 70, 104, 0.1); 199 + } 200 + 201 + .info-box { 202 + background: rgba(188, 141, 160, 0.1); 203 + border-left: 3px solid var(--berry-crush); 204 + padding: 1rem; 205 + margin-bottom: 1.5rem; 206 + font-size: 0.875rem; 207 + color: var(--old-rose); 208 + } 209 + 210 + .info-box strong { 211 + color: var(--lavender); 212 + } 213 + 214 + code { 215 + background: rgba(12, 23, 19, 0.8); 216 + padding: 0.125rem 0.375rem; 217 + font-family: monospace; 218 + color: var(--berry-crush); 219 + } 220 + 221 + a { 222 + color: var(--berry-crush); 223 + text-decoration: none; 224 + } 225 + 226 + a:hover { 227 + text-decoration: underline; 228 + } 229 + 230 + /* JSON syntax highlighting */ 231 + .json-key { 232 + color: var(--berry-crush); 233 + } 234 + 235 + .json-string { 236 + color: #a5d6a7; 237 + } 238 + 239 + .json-number { 240 + color: #81c784; 241 + } 242 + 243 + .json-boolean { 244 + color: var(--old-rose); 245 + } 246 + 247 + .json-null { 248 + color: #9e9e9e; 249 + } 250 + </style> 251 + </head> 252 + 253 + <body> 254 + <div class="container"> 255 + <h1>oauth test client</h1> 256 + <p class="subtitle">test your indiko indieauth/oauth 2.0 server</p> 257 + 258 + <div class="section"> 259 + <h2>step 1: configure</h2> 260 + 261 + <div class="info-box"> 262 + <strong>How this works:</strong><br> 263 + This page simulates an OAuth client (like your blog or app). It will redirect you to indiko for authentication, 264 + show you a consent screen, then exchange the authorization code for your user profile. 265 + </div> 266 + 267 + <label for="clientId">client id (your app's URL)</label> 268 + <input type="url" id="clientId" value="" placeholder="https://example.com" /> 269 + 270 + <label for="redirectUri">redirect uri (callback URL)</label> 271 + <input type="url" id="redirectUri" value="" placeholder="https://example.com/callback" /> 272 + 273 + <div class="checkbox-group"> 274 + <label>scopes to request:</label> 275 + <label> 276 + <input type="checkbox" name="scope" value="profile" checked /> 277 + <span>profile (name, photo, URL)</span> 278 + </label> 279 + <label> 280 + <input type="checkbox" name="scope" value="email" /> 281 + <span>email</span> 282 + </label> 283 + </div> 284 + 285 + <button id="startBtn">start oauth flow</button> 286 + </div> 287 + 288 + <div class="section" id="callbackSection" style="display: none;"> 289 + <h2>step 2: callback handler</h2> 290 + 291 + <div class="info-box"> 292 + You've been redirected back with an authorization code. Click below to exchange it for user data. 293 + </div> 294 + 295 + <div id="callbackInfo"></div> 296 + 297 + <button id="exchangeBtn">exchange code for profile</button> 298 + </div> 299 + 300 + <div class="section" id="resultSection" style="display: none;"> 301 + <h2>step 3: result</h2> 302 + <div id="result" class="result"></div> 303 + </div> 304 + 305 + <div class="section"> 306 + <h2>development notes</h2> 307 + <ul style="list-style: none; line-height: 1.8;"> 308 + <li>• This page handles the OAuth callback at the current URL</li> 309 + <li>• Set <code>redirect_uri</code> to the current page URL (it will be auto-filled)</li> 310 + <li>• <code>client_id</code> should be a valid URL (can be any URL, it auto-registers)</li> 311 + <li>• Authorization codes expire in 60 seconds</li> 312 + <li>• Codes are single-use only</li> 313 + <li>• PKCE (S256) is required and handled automatically</li> 314 + </ul> 315 + </div> 316 + 317 + <div style="text-align: center; margin-top: 2rem;"> 318 + <a href="/">← back to dashboard</a> 319 + </div> 320 + </div> 321 + 322 + <script type="module" src="../client/oauth-test.ts"></script> 323 + </body> 324 + 325 + </html>
+34
src/index.ts
··· 3 3 import indexHTML from "./html/index.html"; 4 4 import loginHTML from "./html/login.html"; 5 5 import profileHTML from "./html/profile.html"; 6 + import oauthTestHTML from "./html/oauth-test.html"; 6 7 import { canRegister, registerOptions, registerVerify, loginOptions, loginVerify } from "./routes/auth"; 7 8 import { hello, listUsers, getProfile, updateProfile } from "./routes/api"; 9 + import { authorizeGet, authorizePost, token, logout, userProfile, createInvite } from "./routes/indieauth"; 8 10 9 11 (() => { 10 12 const required = ["ORIGIN", "RP_ID"]; ··· 25 27 "/": indexHTML, 26 28 "/login": loginHTML, 27 29 "/profile": profileHTML, 30 + "/oauth-test": oauthTestHTML, 28 31 // API endpoints 29 32 "/api/hello": hello, 30 33 "/api/users": listUsers, ··· 33 36 if (req.method === "PUT") return updateProfile(req); 34 37 return new Response("Method not allowed", { status: 405 }); 35 38 }, 39 + "/api/invites/create": (req: Request) => { 40 + if (req.method === "POST") return createInvite(req); 41 + return new Response("Method not allowed", { status: 405 }); 42 + }, 43 + // IndieAuth/OAuth 2.0 endpoints 44 + "/auth/authorize": (req: Request) => { 45 + if (req.method === "GET") return authorizeGet(req); 46 + if (req.method === "POST") return authorizePost(req); 47 + return new Response("Method not allowed", { status: 405 }); 48 + }, 49 + "/auth/token": (req: Request) => { 50 + if (req.method === "POST") return token(req); 51 + return new Response("Method not allowed", { status: 405 }); 52 + }, 53 + "/auth/logout": (req: Request) => { 54 + if (req.method === "POST") return logout(req); 55 + return new Response("Method not allowed", { status: 405 }); 56 + }, 57 + // Passkey auth endpoints 36 58 "/auth/can-register": canRegister, 37 59 "/auth/register/options": registerOptions, 38 60 "/auth/register/verify": registerVerify, ··· 40 62 "/auth/login/verify": loginVerify, 41 63 }, 42 64 development: process.env.NODE_ENV === "dev", 65 + fetch(req) { 66 + // Handle dynamic routes like /u/:username 67 + const url = new URL(req.url); 68 + const match = url.pathname.match(/^\/u\/([^\/]+)$/); 69 + if (match) { 70 + const username = match[1]; 71 + return userProfile(req, username); 72 + } 73 + 74 + // Let Bun handle static routes 75 + return undefined as never; 76 + }, 43 77 }); 44 78 45 79 console.log("[Indiko] running on", env.ORIGIN);
-1
src/migrations/002_add_user_status_role.sql
··· 1 1 -- Add status and role columns to users table 2 2 ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'suspended', 'inactive')); 3 3 ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; 4 - 5 4 -- Update existing admin users to have 'admin' role 6 5 UPDATE users SET role = 'admin' WHERE is_admin = 1;
+43
src/migrations/003_add_indieauth_tables.sql
··· 1 + -- Add tables for IndieAuth/OAuth 2.0 support 2 + -- Apps (auto-registered on first authorization request) 3 + CREATE TABLE IF NOT EXISTS apps ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + client_id TEXT NOT NULL UNIQUE, 6 + redirect_uris TEXT NOT NULL, -- JSON array 7 + name TEXT, 8 + first_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')), 9 + last_used INTEGER NOT NULL DEFAULT (strftime('%s','now')) 10 + ); 11 + -- User permissions per app 12 + CREATE TABLE IF NOT EXISTS permissions ( 13 + id INTEGER PRIMARY KEY AUTOINCREMENT, 14 + user_id INTEGER NOT NULL, 15 + client_id TEXT NOT NULL, 16 + scopes TEXT NOT NULL, -- JSON array 17 + granted_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), 18 + last_used INTEGER NOT NULL DEFAULT (strftime('%s','now')), 19 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 20 + FOREIGN KEY (client_id) REFERENCES apps(client_id) ON DELETE CASCADE, 21 + UNIQUE(user_id, client_id) 22 + ); 23 + -- Authorization codes (short-lived, single-use) 24 + CREATE TABLE IF NOT EXISTS authcodes ( 25 + id INTEGER PRIMARY KEY AUTOINCREMENT, 26 + code TEXT NOT NULL UNIQUE, 27 + user_id INTEGER NOT NULL, 28 + client_id TEXT NOT NULL, 29 + redirect_uri TEXT NOT NULL, 30 + scopes TEXT NOT NULL, -- JSON array 31 + code_challenge TEXT NOT NULL, 32 + code_challenge_method TEXT NOT NULL DEFAULT 'S256', 33 + expires_at INTEGER NOT NULL, 34 + used INTEGER NOT NULL DEFAULT 0, 35 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), 36 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 37 + ); 38 + -- Indexes 39 + CREATE INDEX IF NOT EXISTS idx_apps_client_id ON apps(client_id); 40 + CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id); 41 + CREATE INDEX IF NOT EXISTS idx_permissions_client_id ON permissions(client_id); 42 + CREATE INDEX IF NOT EXISTS idx_authcodes_code ON authcodes(code); 43 + CREATE INDEX IF NOT EXISTS idx_authcodes_expires_at ON authcodes(expires_at);
+23 -9
src/routes/auth.ts
··· 199 199 "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", 200 200 ).run(token, user.id, expiresAt); 201 201 202 - return Response.json({ 203 - token, 204 - username, 205 - isAdmin: true, 206 - }); 202 + return Response.json( 203 + { 204 + token, 205 + username, 206 + isAdmin: true, 207 + }, 208 + { 209 + headers: { 210 + "Set-Cookie": `indiko_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`, 211 + }, 212 + }, 213 + ); 207 214 } catch (error) { 208 215 console.error("Registration verify error:", error); 209 216 return Response.json({ error: "Internal server error" }, { status: 500 }); ··· 379 386 "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", 380 387 ).run(token, user.id, expiresAt); 381 388 382 - return Response.json({ 383 - token, 384 - username, 385 - }); 389 + return Response.json( 390 + { 391 + token, 392 + username, 393 + }, 394 + { 395 + headers: { 396 + "Set-Cookie": `indiko_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`, 397 + }, 398 + }, 399 + ); 386 400 } catch (error) { 387 401 console.error("Login verify error:", error); 388 402 return Response.json({ error: "Internal server error" }, { status: 500 });
+862
src/routes/indieauth.ts
··· 1 + import { db } from "../db"; 2 + import crypto from "crypto"; 3 + 4 + interface SessionUser { 5 + username: string; 6 + userId: number; 7 + isAdmin: boolean; 8 + } 9 + 10 + // Helper to get authenticated user from session token 11 + function getSessionUser(req: Request): SessionUser | Response { 12 + const authHeader = req.headers.get("Authorization"); 13 + 14 + if (!authHeader || !authHeader.startsWith("Bearer ")) { 15 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 16 + } 17 + 18 + const token = authHeader.substring(7); 19 + 20 + const session = db 21 + .query( 22 + `SELECT s.expires_at, u.id, u.username, u.is_admin 23 + FROM sessions s 24 + JOIN users u ON s.user_id = u.id 25 + WHERE s.token = ?`, 26 + ) 27 + .get(token) as 28 + | { expires_at: number; id: number; username: string; is_admin: number } 29 + | undefined; 30 + 31 + if (!session) { 32 + return Response.json({ error: "Invalid session" }, { status: 401 }); 33 + } 34 + 35 + const now = Math.floor(Date.now() / 1000); 36 + if (session.expires_at < now) { 37 + return Response.json({ error: "Session expired" }, { status: 401 }); 38 + } 39 + 40 + return { 41 + username: session.username, 42 + userId: session.id, 43 + isAdmin: session.is_admin === 1, 44 + }; 45 + } 46 + 47 + // Helper to get user from session cookie 48 + function getUserFromCookie(req: Request): SessionUser | null { 49 + const cookieHeader = req.headers.get("Cookie"); 50 + if (!cookieHeader) return null; 51 + 52 + const cookies = Object.fromEntries( 53 + cookieHeader.split("; ").map((c) => { 54 + const [key, ...v] = c.split("="); 55 + return [key, v.join("=")]; 56 + }), 57 + ); 58 + 59 + const sessionToken = cookies["indiko_session"]; 60 + if (!sessionToken) return null; 61 + 62 + const session = db 63 + .query( 64 + `SELECT s.expires_at, u.id, u.username, u.is_admin 65 + FROM sessions s 66 + JOIN users u ON s.user_id = u.id 67 + WHERE s.token = ?`, 68 + ) 69 + .get(sessionToken) as 70 + | { expires_at: number; id: number; username: string; is_admin: number } 71 + | undefined; 72 + 73 + if (!session) return null; 74 + 75 + const now = Math.floor(Date.now() / 1000); 76 + if (session.expires_at < now) return null; 77 + 78 + return { 79 + username: session.username, 80 + userId: session.id, 81 + isAdmin: session.is_admin === 1, 82 + }; 83 + } 84 + 85 + // Verify PKCE code challenge 86 + function verifyPKCE(verifier: string, challenge: string): boolean { 87 + const hash = crypto.createHash("sha256").update(verifier).digest("base64url"); 88 + return hash === challenge; 89 + } 90 + 91 + // Auto-register app if it doesn't exist 92 + function ensureApp(clientId: string, redirectUri: string): void { 93 + const existing = db 94 + .query("SELECT id FROM apps WHERE client_id = ?") 95 + .get(clientId); 96 + 97 + if (!existing) { 98 + // New app - auto-register 99 + db.query( 100 + "INSERT INTO apps (client_id, redirect_uris, last_used) VALUES (?, ?, ?)", 101 + ).run(clientId, JSON.stringify([redirectUri]), Math.floor(Date.now() / 1000)); 102 + } else { 103 + // Update last_used 104 + db.query("UPDATE apps SET last_used = ? WHERE client_id = ?").run( 105 + Math.floor(Date.now() / 1000), 106 + clientId, 107 + ); 108 + } 109 + } 110 + 111 + // GET /auth/authorize - Authorization request 112 + export function authorizeGet(req: Request): Response { 113 + const url = new URL(req.url); 114 + const params = url.searchParams; 115 + 116 + // Validate required OAuth 2.0 parameters 117 + const responseType = params.get("response_type"); 118 + const clientId = params.get("client_id"); 119 + const redirectUri = params.get("redirect_uri"); 120 + const state = params.get("state"); 121 + const codeChallenge = params.get("code_challenge"); 122 + const codeChallengeMethod = params.get("code_challenge_method"); 123 + const scope = params.get("scope") || "profile"; 124 + 125 + if (responseType !== "code") { 126 + return new Response("Unsupported response_type", { status: 400 }); 127 + } 128 + 129 + if (!clientId || !redirectUri || !state || !codeChallenge) { 130 + return new Response("Missing required parameters", { status: 400 }); 131 + } 132 + 133 + if (codeChallengeMethod && codeChallengeMethod !== "S256") { 134 + return new Response("Only S256 code_challenge_method supported", { 135 + status: 400, 136 + }); 137 + } 138 + 139 + // Check if user is logged in 140 + const user = getUserFromCookie(req); 141 + 142 + if (!user) { 143 + // Not logged in - redirect to login with return URL 144 + const returnUrl = `/auth/authorize${url.search}`; 145 + return Response.redirect(`/login?return=${encodeURIComponent(returnUrl)}`); 146 + } 147 + 148 + // Auto-register app 149 + ensureApp(clientId, redirectUri); 150 + 151 + // Check if user has previously granted permission to this app 152 + const permission = db 153 + .query( 154 + "SELECT scopes FROM permissions WHERE user_id = ? AND client_id = ?", 155 + ) 156 + .get(user.userId, clientId) as { scopes: string } | undefined; 157 + 158 + const requestedScopes = scope.split(" ").filter(Boolean); 159 + 160 + // If permission exists and covers all requested scopes, auto-approve 161 + if (permission) { 162 + const grantedScopes = JSON.parse(permission.scopes) as string[]; 163 + const hasAllScopes = requestedScopes.every((s) => grantedScopes.includes(s)); 164 + 165 + if (hasAllScopes) { 166 + // Auto-approve - create auth code and redirect 167 + const code = crypto.randomBytes(32).toString("base64url"); 168 + const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 169 + 170 + db.query( 171 + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 172 + ).run( 173 + code, 174 + user.userId, 175 + clientId, 176 + redirectUri, 177 + JSON.stringify(requestedScopes), 178 + codeChallenge, 179 + expiresAt, 180 + ); 181 + 182 + // Update permission last_used 183 + db.query( 184 + "UPDATE permissions SET last_used = ? WHERE user_id = ? AND client_id = ?", 185 + ).run(Math.floor(Date.now() / 1000), user.userId, clientId); 186 + 187 + return Response.redirect(`${redirectUri}?code=${code}&state=${state}`); 188 + } 189 + } 190 + 191 + // Show consent screen 192 + return showConsentScreen( 193 + user, 194 + clientId, 195 + redirectUri, 196 + state, 197 + codeChallenge, 198 + requestedScopes, 199 + ); 200 + } 201 + 202 + function showConsentScreen( 203 + user: SessionUser, 204 + clientId: string, 205 + redirectUri: string, 206 + state: string, 207 + codeChallenge: string, 208 + scopes: string[], 209 + ): Response { 210 + const appName = new URL(clientId).hostname; 211 + 212 + const html = `<!doctype html> 213 + <html lang="en"> 214 + <head> 215 + <meta charset="UTF-8" /> 216 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 217 + <title>authorize app • indiko</title> 218 + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 219 + <link rel="preconnect" href="https://fonts.googleapis.com"> 220 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 221 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 222 + <style> 223 + :root { 224 + --mahogany: #26242b; 225 + --lavender: #d9d0de; 226 + --old-rose: #bc8da0; 227 + --rosewood: #a04668; 228 + --berry-crush: #ab4967; 229 + } 230 + * { margin: 0; padding: 0; box-sizing: border-box; } 231 + body { 232 + font-family: "Space Grotesk", sans-serif; 233 + background: var(--mahogany); 234 + color: var(--lavender); 235 + min-height: 100vh; 236 + display: flex; 237 + align-items: center; 238 + justify-content: center; 239 + padding: 2rem 1rem; 240 + } 241 + .consent-box { 242 + max-width: 28rem; 243 + width: 100%; 244 + background: rgba(188, 141, 160, 0.05); 245 + border: 1px solid var(--old-rose); 246 + padding: 2rem; 247 + } 248 + h1 { 249 + font-size: 1.5rem; 250 + font-weight: 700; 251 + color: var(--lavender); 252 + margin-bottom: 1rem; 253 + } 254 + .app-name { 255 + color: var(--berry-crush); 256 + font-weight: 700; 257 + } 258 + .scopes { 259 + margin: 1.5rem 0; 260 + padding: 1rem; 261 + background: rgba(12, 23, 19, 0.6); 262 + border: 1px solid var(--rosewood); 263 + } 264 + .scope-title { 265 + font-size: 0.875rem; 266 + color: var(--old-rose); 267 + text-transform: uppercase; 268 + letter-spacing: 0.05rem; 269 + margin-bottom: 0.75rem; 270 + } 271 + .scope-list { 272 + list-style: none; 273 + display: flex; 274 + flex-direction: column; 275 + gap: 0.5rem; 276 + } 277 + .scope-list li { 278 + color: var(--lavender); 279 + padding-left: 1.5rem; 280 + position: relative; 281 + } 282 + .scope-list li::before { 283 + content: "✓"; 284 + position: absolute; 285 + left: 0; 286 + color: var(--berry-crush); 287 + } 288 + .buttons { 289 + display: flex; 290 + gap: 1rem; 291 + margin-top: 1.5rem; 292 + } 293 + button { 294 + flex: 1; 295 + padding: 1rem; 296 + border: 4px solid var(--mahogany); 297 + font-family: "Space Grotesk", sans-serif; 298 + font-size: 1rem; 299 + font-weight: 700; 300 + text-transform: uppercase; 301 + letter-spacing: 0.1rem; 302 + cursor: pointer; 303 + transition: all 0.15s ease; 304 + box-shadow: 6px 6px 0 var(--mahogany); 305 + position: relative; 306 + } 307 + button::before { 308 + content: ''; 309 + position: absolute; 310 + top: -4px; left: -4px; right: -4px; bottom: -4px; 311 + background: transparent; 312 + pointer-events: none; 313 + transition: all 0.15s ease; 314 + } 315 + button:hover { 316 + transform: translate(3px, 3px); 317 + box-shadow: 3px 3px 0 var(--mahogany); 318 + } 319 + .allow { 320 + background: var(--berry-crush); 321 + color: var(--lavender); 322 + } 323 + .allow::before { 324 + border: 4px solid var(--rosewood); 325 + } 326 + .deny { 327 + background: transparent; 328 + color: var(--old-rose); 329 + box-shadow: 4px 4px 0 var(--mahogany); 330 + } 331 + .deny::before { 332 + border: 4px solid var(--old-rose); 333 + } 334 + .user-info { 335 + margin-bottom: 1.5rem; 336 + padding: 1rem; 337 + background: rgba(188, 141, 160, 0.1); 338 + border-left: 3px solid var(--berry-crush); 339 + font-size: 0.875rem; 340 + color: var(--old-rose); 341 + } 342 + </style> 343 + </head> 344 + <body> 345 + <div class="consent-box"> 346 + <h1>authorize app</h1> 347 + 348 + <div class="user-info"> 349 + Signing in as <strong>${user.username}</strong> 350 + </div> 351 + 352 + <p style="margin-bottom: 1rem;"> 353 + <span class="app-name">${appName}</span> is requesting access to: 354 + </p> 355 + 356 + <div class="scopes"> 357 + <div class="scope-title">permissions</div> 358 + <ul class="scope-list"> 359 + ${scopes 360 + .map( 361 + (scope) => ` 362 + <li>${scope === "profile" ? "Your profile (name, photo, URL)" : scope === "email" ? "Your email address" : scope}</li> 363 + `, 364 + ) 365 + .join("")} 366 + </ul> 367 + </div> 368 + 369 + <form method="POST" action="/auth/authorize"> 370 + <input type="hidden" name="client_id" value="${clientId}" /> 371 + <input type="hidden" name="redirect_uri" value="${redirectUri}" /> 372 + <input type="hidden" name="state" value="${state}" /> 373 + <input type="hidden" name="code_challenge" value="${codeChallenge}" /> 374 + <input type="hidden" name="scopes" value="${scopes.join(" ")}" /> 375 + 376 + <div class="buttons"> 377 + <button type="submit" name="action" value="deny" class="deny">deny</button> 378 + <button type="submit" name="action" value="allow" class="allow">allow</button> 379 + </div> 380 + </form> 381 + </div> 382 + </body> 383 + </html>`; 384 + 385 + return new Response(html, { 386 + headers: { "Content-Type": "text/html" }, 387 + }); 388 + } 389 + 390 + // POST /auth/authorize - Consent form submission 391 + export async function authorizePost(req: Request): Promise<Response> { 392 + const user = getUserFromCookie(req); 393 + 394 + if (!user) { 395 + return new Response("Unauthorized", { status: 401 }); 396 + } 397 + 398 + const formData = await req.formData(); 399 + const action = formData.get("action") as string; 400 + const clientId = formData.get("client_id") as string; 401 + const redirectUri = formData.get("redirect_uri") as string; 402 + const state = formData.get("state") as string; 403 + const codeChallenge = formData.get("code_challenge") as string; 404 + const scopesStr = formData.get("scopes") as string; 405 + 406 + if (!clientId || !redirectUri || !state || !codeChallenge || !scopesStr) { 407 + return new Response("Missing required parameters", { status: 400 }); 408 + } 409 + 410 + if (action === "deny") { 411 + return Response.redirect( 412 + `${redirectUri}?error=access_denied&state=${state}`, 413 + ); 414 + } 415 + 416 + const scopes = scopesStr.split(" ").filter(Boolean); 417 + 418 + // Create authorization code 419 + const code = crypto.randomBytes(32).toString("base64url"); 420 + const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds 421 + 422 + db.query( 423 + "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 424 + ).run( 425 + code, 426 + user.userId, 427 + clientId, 428 + redirectUri, 429 + JSON.stringify(scopes), 430 + codeChallenge, 431 + expiresAt, 432 + ); 433 + 434 + // Store or update permission grant 435 + const existing = db 436 + .query("SELECT id FROM permissions WHERE user_id = ? AND client_id = ?") 437 + .get(user.userId, clientId); 438 + 439 + if (existing) { 440 + db.query( 441 + "UPDATE permissions SET scopes = ?, last_used = ? WHERE user_id = ? AND client_id = ?", 442 + ).run(JSON.stringify(scopes), Math.floor(Date.now() / 1000), user.userId, clientId); 443 + } else { 444 + db.query( 445 + "INSERT INTO permissions (user_id, client_id, scopes) VALUES (?, ?, ?)", 446 + ).run(user.userId, clientId, JSON.stringify(scopes)); 447 + } 448 + 449 + // Update app last_used 450 + db.query("UPDATE apps SET last_used = ? WHERE client_id = ?").run( 451 + Math.floor(Date.now() / 1000), 452 + clientId, 453 + ); 454 + 455 + return Response.redirect(`${redirectUri}?code=${code}&state=${state}`); 456 + } 457 + 458 + // POST /auth/token - Exchange authorization code for user identity 459 + export async function token(req: Request): Promise<Response> { 460 + try { 461 + const body = await req.json(); 462 + const { 463 + grant_type, 464 + code, 465 + client_id, 466 + redirect_uri, 467 + code_verifier, 468 + } = body; 469 + 470 + if (grant_type !== "authorization_code") { 471 + return Response.json( 472 + { 473 + error: "unsupported_grant_type", 474 + error_description: "Only authorization_code grant type is supported", 475 + }, 476 + { status: 400 }, 477 + ); 478 + } 479 + 480 + if (!code || !client_id || !redirect_uri || !code_verifier) { 481 + return Response.json( 482 + { 483 + error: "invalid_request", 484 + error_description: "Missing required parameters", 485 + }, 486 + { status: 400 }, 487 + ); 488 + } 489 + 490 + // Look up authorization code 491 + const authcode = db 492 + .query( 493 + "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used FROM authcodes WHERE code = ?", 494 + ) 495 + .get(code) as 496 + | { 497 + user_id: number; 498 + client_id: string; 499 + redirect_uri: string; 500 + scopes: string; 501 + code_challenge: string; 502 + expires_at: number; 503 + used: number; 504 + } 505 + | undefined; 506 + 507 + if (!authcode) { 508 + return Response.json( 509 + { 510 + error: "invalid_grant", 511 + error_description: "Authorization code not found", 512 + }, 513 + { status: 400 }, 514 + ); 515 + } 516 + 517 + // Check if already used 518 + if (authcode.used) { 519 + return Response.json( 520 + { 521 + error: "invalid_grant", 522 + error_description: "Authorization code already used", 523 + }, 524 + { status: 400 }, 525 + ); 526 + } 527 + 528 + // Check if expired 529 + const now = Math.floor(Date.now() / 1000); 530 + if (authcode.expires_at < now) { 531 + return Response.json( 532 + { 533 + error: "invalid_grant", 534 + error_description: "Authorization code expired", 535 + }, 536 + { status: 400 }, 537 + ); 538 + } 539 + 540 + // Verify client_id matches 541 + if (authcode.client_id !== client_id) { 542 + return Response.json( 543 + { 544 + error: "invalid_grant", 545 + error_description: "client_id mismatch", 546 + }, 547 + { status: 400 }, 548 + ); 549 + } 550 + 551 + // Verify redirect_uri matches 552 + if (authcode.redirect_uri !== redirect_uri) { 553 + return Response.json( 554 + { 555 + error: "invalid_grant", 556 + error_description: "redirect_uri mismatch", 557 + }, 558 + { status: 400 }, 559 + ); 560 + } 561 + 562 + // Verify PKCE code_verifier 563 + if (!verifyPKCE(code_verifier, authcode.code_challenge)) { 564 + return Response.json( 565 + { 566 + error: "invalid_grant", 567 + error_description: "Invalid code_verifier", 568 + }, 569 + { status: 400 }, 570 + ); 571 + } 572 + 573 + // Mark code as used 574 + db.query("UPDATE authcodes SET used = 1 WHERE code = ?").run(code); 575 + 576 + // Get user profile 577 + const user = db 578 + .query( 579 + "SELECT username, name, email, photo, url FROM users WHERE id = ?", 580 + ) 581 + .get(authcode.user_id) as 582 + | { 583 + username: string; 584 + name: string; 585 + email: string | null; 586 + photo: string | null; 587 + url: string | null; 588 + } 589 + | undefined; 590 + 591 + if (!user) { 592 + return Response.json( 593 + { 594 + error: "server_error", 595 + error_description: "User not found", 596 + }, 597 + { status: 500 }, 598 + ); 599 + } 600 + 601 + const scopes = JSON.parse(authcode.scopes) as string[]; 602 + const profile: Record<string, string> = {}; 603 + 604 + if (scopes.includes("profile")) { 605 + profile.name = user.name; 606 + if (user.photo) profile.photo = user.photo; 607 + if (user.url) profile.url = user.url; 608 + } 609 + 610 + if (scopes.includes("email") && user.email) { 611 + profile.email = user.email; 612 + } 613 + 614 + return Response.json({ 615 + me: `${process.env.ORIGIN}/u/${user.username}`, 616 + profile, 617 + }); 618 + } catch (error) { 619 + console.error("Token exchange error:", error); 620 + return Response.json( 621 + { 622 + error: "server_error", 623 + error_description: "Internal server error", 624 + }, 625 + { status: 500 }, 626 + ); 627 + } 628 + } 629 + 630 + // POST /auth/logout - Clear session 631 + export function logout(req: Request): Response { 632 + const user = getSessionUser(req); 633 + if (user instanceof Response) { 634 + return user; 635 + } 636 + 637 + const authHeader = req.headers.get("Authorization"); 638 + const token = authHeader?.substring(7); 639 + 640 + if (token) { 641 + db.query("DELETE FROM sessions WHERE token = ?").run(token); 642 + } 643 + 644 + return Response.json({ success: true }); 645 + } 646 + 647 + // GET /u/:username - Public user profile (h-card) 648 + export function userProfile(req: Request, username: string): Response { 649 + const user = db 650 + .query("SELECT username, name, email, photo, url FROM users WHERE username = ?") 651 + .get(username) as 652 + | { 653 + username: string; 654 + name: string; 655 + email: string | null; 656 + photo: string | null; 657 + url: string | null; 658 + } 659 + | undefined; 660 + 661 + if (!user) { 662 + return new Response("User not found", { status: 404 }); 663 + } 664 + 665 + const html = `<!doctype html> 666 + <html lang="en"> 667 + <head> 668 + <meta charset="UTF-8" /> 669 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 670 + <title>${user.name} • indiko</title> 671 + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 672 + <link rel="authorization_endpoint" href="${process.env.ORIGIN}/auth/authorize" /> 673 + <link rel="token_endpoint" href="${process.env.ORIGIN}/auth/token" /> 674 + ${user.url ? `<link rel="me" href="${user.url}" />` : ""} 675 + <link rel="preconnect" href="https://fonts.googleapis.com"> 676 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 677 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 678 + <style> 679 + :root { 680 + --mahogany: #26242b; 681 + --lavender: #d9d0de; 682 + --old-rose: #bc8da0; 683 + --rosewood: #a04668; 684 + --berry-crush: #ab4967; 685 + } 686 + * { 687 + margin: 0; 688 + padding: 0; 689 + box-sizing: border-box; 690 + } 691 + body { 692 + font-family: "Space Grotesk", sans-serif; 693 + background: var(--mahogany); 694 + color: var(--lavender); 695 + min-height: 100vh; 696 + padding: 2.5rem 1.25rem; 697 + } 698 + .container { 699 + max-width: 600px; 700 + margin: 0 auto; 701 + } 702 + .h-card { 703 + background: rgba(188, 141, 160, 0.05); 704 + border: 1px solid var(--old-rose); 705 + padding: 2rem; 706 + margin-bottom: 2rem; 707 + display: flex; 708 + flex-direction: column; 709 + align-items: center; 710 + text-align: center; 711 + } 712 + .u-photo { 713 + width: 128px; 714 + height: 128px; 715 + border-radius: 50%; 716 + object-fit: cover; 717 + margin-bottom: 1rem; 718 + border: 3px solid var(--berry-crush); 719 + } 720 + .p-name { 721 + font-size: 1.5rem; 722 + font-weight: 700; 723 + text-decoration: none; 724 + color: var(--lavender); 725 + margin-bottom: 0.5rem; 726 + } 727 + .p-name:hover { 728 + color: var(--berry-crush); 729 + } 730 + .u-email { 731 + color: var(--old-rose); 732 + text-decoration: none; 733 + margin-top: 0.5rem; 734 + font-size: 0.875rem; 735 + } 736 + .u-email:hover { 737 + color: var(--berry-crush); 738 + } 739 + .identity-info { 740 + margin-top: 1rem; 741 + padding: 1rem; 742 + background: rgba(12, 23, 19, 0.6); 743 + border: 1px solid var(--rosewood); 744 + font-size: 0.875rem; 745 + color: var(--old-rose); 746 + } 747 + .identity-info code { 748 + color: var(--berry-crush); 749 + font-family: "Space Grotesk", monospace; 750 + } 751 + .indieauth-info { 752 + background: rgba(188, 141, 160, 0.05); 753 + border: 1px solid var(--old-rose); 754 + padding: 2rem; 755 + } 756 + .indieauth-info h2 { 757 + font-size: 1.25rem; 758 + font-weight: 600; 759 + margin-bottom: 1rem; 760 + color: var(--lavender); 761 + } 762 + .indieauth-info p { 763 + margin-bottom: 1rem; 764 + color: var(--old-rose); 765 + line-height: 1.6; 766 + } 767 + .indieauth-info code { 768 + color: var(--berry-crush); 769 + font-family: "Space Grotesk", monospace; 770 + } 771 + .code-box { 772 + background: rgba(12, 23, 19, 0.6); 773 + border: 2px solid var(--rosewood); 774 + padding: 1rem; 775 + margin: 1rem 0; 776 + font-family: "Space Grotesk", monospace; 777 + font-size: 0.875rem; 778 + overflow-x: auto; 779 + white-space: pre-wrap; 780 + word-break: break-all; 781 + } 782 + .html-tag { 783 + color: var(--berry-crush); 784 + } 785 + .html-attr { 786 + color: #81c784; 787 + } 788 + .html-value { 789 + color: #a5d6a7; 790 + } 791 + .back-link { 792 + text-align: center; 793 + margin-top: 2rem; 794 + font-size: 0.875rem; 795 + } 796 + .back-link a { 797 + color: var(--berry-crush); 798 + text-decoration: none; 799 + } 800 + .back-link a:hover { 801 + text-decoration: underline; 802 + } 803 + </style> 804 + </head> 805 + <body> 806 + <div class="container"> 807 + <div class="h-card"> 808 + ${user.photo ? `<img class="u-photo" src="${user.photo}" alt="${user.name}" />` : ""} 809 + <a class="p-name u-url" href="${user.url || `${process.env.ORIGIN}/u/${user.username}`}">${user.name}</a> 810 + ${user.email ? `<a class="u-email" href="mailto:${user.email}">email</a>` : ""} 811 + <div class="identity-info"> 812 + IndieAuth identity: <code>${process.env.ORIGIN}/u/${user.username}</code> 813 + </div> 814 + </div> 815 + 816 + <div class="indieauth-info"> 817 + <h2>Use This Identity on Your Website</h2> 818 + <p> 819 + You can delegate IndieAuth to this server from your own website. Add these tags to your site's <code>&lt;head&gt;</code>: 820 + </p> 821 + <div class="code-box"><span class="html-tag">&lt;link</span> <span class="html-attr">rel</span>=<span class="html-value">"authorization_endpoint"</span> <span class="html-attr">href</span>=<span class="html-value">"${process.env.ORIGIN}/auth/authorize"</span> <span class="html-tag">/&gt;</span> 822 + <span class="html-tag">&lt;link</span> <span class="html-attr">rel</span>=<span class="html-value">"token_endpoint"</span> <span class="html-attr">href</span>=<span class="html-value">"${process.env.ORIGIN}/auth/token"</span> <span class="html-tag">/&gt;</span> 823 + <span class="html-tag">&lt;link</span> <span class="html-attr">rel</span>=<span class="html-value">"me"</span> <span class="html-attr">href</span>=<span class="html-value">"${process.env.ORIGIN}/u/${user.username}"</span> <span class="html-tag">/&gt;</span></div> 824 + <p> 825 + This lets you sign in to IndieAuth-compatible sites using your own domain while this server handles the authentication. 826 + </p> 827 + </div> 828 + 829 + <div class="back-link"> 830 + <a href="/">← back to dashboard</a> 831 + </div> 832 + </div> 833 + </body> 834 + </html>`; 835 + 836 + return new Response(html, { 837 + headers: { "Content-Type": "text/html" }, 838 + }); 839 + } 840 + 841 + // POST /api/invites/create - Create invite link (admin only) 842 + export function createInvite(req: Request): Response { 843 + const user = getSessionUser(req); 844 + if (user instanceof Response) { 845 + return user; 846 + } 847 + 848 + if (!user.isAdmin) { 849 + return Response.json({ error: "Admin access required" }, { status: 403 }); 850 + } 851 + 852 + const inviteCode = crypto.randomBytes(16).toString("base64url"); 853 + 854 + db.query( 855 + "INSERT INTO invites (code, created_by) VALUES (?, ?)", 856 + ).run(inviteCode, user.userId); 857 + 858 + return Response.json({ 859 + inviteCode, 860 + inviteUrl: `${process.env.ORIGIN}/login?invite=${inviteCode}`, 861 + }); 862 + }