An browser-side ATProtocol OAuth application with no dependencies that verifies supporters
index.html edited
584 lines 22 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"> 7 <title>ATProtoFans Supporter Check</title> 8 <style> 9 * { margin: 0; padding: 0; box-sizing: border-box; } 10 body { 11 font-family: system-ui, -apple-system, sans-serif; 12 background: #1a1a2e; 13 color: #eee; 14 min-height: 100vh; 15 display: flex; 16 align-items: center; 17 justify-content: center; 18 padding: 20px; 19 } 20 .container { 21 background: #16213e; 22 border-radius: 12px; 23 padding: 32px; 24 max-width: 480px; 25 width: 100%; 26 box-shadow: 0 8px 32px rgba(0,0,0,0.3); 27 } 28 h1 { font-size: 24px; margin-bottom: 8px; color: #fff; } 29 .subtitle { color: #888; margin-bottom: 24px; font-size: 14px; } 30 .form-group { margin-bottom: 16px; } 31 label { display: block; margin-bottom: 6px; font-size: 14px; color: #aaa; } 32 input { 33 width: 100%; 34 padding: 12px; 35 border: 1px solid #333; 36 border-radius: 6px; 37 background: #0f0f23; 38 color: #fff; 39 font-size: 16px; 40 } 41 input:focus { outline: none; border-color: #0066ff; } 42 button { 43 width: 100%; 44 padding: 14px; 45 background: #0066ff; 46 color: white; 47 border: none; 48 border-radius: 6px; 49 font-size: 16px; 50 font-weight: 600; 51 cursor: pointer; 52 transition: background 0.2s; 53 } 54 button:hover { background: #0055dd; } 55 button:disabled { opacity: 0.5; cursor: not-allowed; } 56 .secondary { background: #333; margin-top: 8px; } 57 .secondary:hover { background: #444; } 58 .status { 59 padding: 12px; 60 border-radius: 6px; 61 margin-top: 16px; 62 font-size: 14px; 63 display: none; 64 } 65 .status.show { display: block; } 66 .status.info { background: #1e3a5f; border-left: 3px solid #0066ff; } 67 .status.error { background: #3d1f1f; border-left: 3px solid #ff4444; } 68 .status.success { background: #1f3d1f; border-left: 3px solid #44ff44; } 69 .user-section { display: none; } 70 .user-section.show { display: block; } 71 .user-card { 72 background: #0f0f23; 73 border-radius: 8px; 74 padding: 16px; 75 margin-bottom: 16px; 76 } 77 .user-did { font-family: monospace; font-size: 12px; color: #888; word-break: break-all; } 78 .user-handle { font-size: 18px; font-weight: 600; margin-bottom: 4px; } 79 .result-card { 80 background: #0f0f23; 81 border-radius: 8px; 82 padding: 20px; 83 text-align: center; 84 margin-bottom: 16px; 85 } 86 .result-card.supporter { border: 2px solid #44ff44; } 87 .result-card.not-supporter { border: 2px solid #ff4444; } 88 .result-icon { font-size: 48px; margin-bottom: 12px; } 89 .result-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; } 90 .result-detail { font-size: 14px; color: #888; } 91 .hidden { display: none !important; } 92 a { color: #0088ff; } 93 </style> 94</head> 95<body> 96 <div class="container"> 97 <h1>ATProtoFans Supporter Check</h1> 98 <p class="subtitle">Sign in with ATProtocol to check if you support <a href="https://bsky.app/profile/ngerakines.me" target="_blank" rel="noopener noreferrer">ngerakines.me</a></p> 99 100 <!-- Login Section --> 101 <div id="loginSection"> 102 <div class="form-group"> 103 <label for="handle">Your ATProtocol Handle</label> 104 <input type="text" id="handle" placeholder="yourname.bsky.social" autocomplete="off"> 105 </div> 106 <button id="loginBtn" onclick="login()">Sign in with ATProtocol</button> 107 </div> 108 109 <!-- User Section (shown after login) --> 110 <div id="userSection" class="user-section"> 111 <div class="user-card"> 112 <div class="user-handle" id="userHandle">Loading...</div> 113 <div class="user-did" id="userDid"></div> 114 </div> 115 116 <div id="resultCard" class="result-card hidden"> 117 <div class="result-icon" id="resultIcon"></div> 118 <div class="result-title" id="resultTitle"></div> 119 <div class="result-detail" id="resultDetail"></div> 120 </div> 121 122 <button onclick="checkSupporter()">Check Supporter Status</button> 123 <button class="secondary" onclick="logout()">Sign Out</button> 124 </div> 125 126 <div id="status" class="status"></div> 127 </div> 128 129 <script> 130// Configuration 131const SUBJECT_DID = 'did:plc:cbkjy5n7bk3ax2wplmtjofq2'; // ngerakines.me 132const SIGNER_DID = 'did:plc:zwimedywn2ktwss7m5z37qsk'; // broker signer 133const ATPROTOFANS_XRPC = 'https://atprotofans.com/xrpc/com.atprotofans.validateSupporter'; 134 135 136const CLIENT_METADATA_PATH = '/support/oauth-client-metadata.json'; 137 138/* 139Populate '/support/oauth-client-metadata.json' with: 140{ 141 "client_id": "https://ngerakines.me/support/oauth-client-metadata.json", 142 "client_name": "ATProtoFans Supporter Check", 143 "client_uri": "https://ngerakines.me/support/", 144 "redirect_uris": [ 145 "https://ngerakines.me/support/", 146 "https://ngerakines.me/support/index.html" 147 ], 148 "scope": "atproto", 149 "grant_types": ["authorization_code", "refresh_token"], 150 "response_types": ["code"], 151 "token_endpoint_auth_method": "none", 152 "application_type": "web", 153 "dpop_bound_access_tokens": true 154} 155 156*/ 157 158const STORAGE_PREFIX = 'atproto_'; 159 160// Utility functions 161function base64UrlEncode(buffer) { 162 return btoa(String.fromCharCode(...new Uint8Array(buffer))) 163 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); 164} 165 166function randomString(len = 43) { 167 const arr = new Uint8Array(len); 168 crypto.getRandomValues(arr); 169 return base64UrlEncode(arr); 170} 171 172async function sha256(str) { 173 return await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); 174} 175 176// Handle validation - ensures handle is a valid ATProtocol handle format 177function isValidHandle(handle) { 178 // ATProtocol handles are domain-like: segments separated by dots 179 // Each segment: alphanumeric and hyphens, can't start/end with hyphen 180 // Minimum 2 segments, max 253 chars total 181 if (!handle || handle.length > 253) return false; 182 const segments = handle.split('.'); 183 if (segments.length < 2) return false; 184 const segmentRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; 185 return segments.every(seg => seg.length > 0 && seg.length <= 63 && segmentRegex.test(seg)); 186} 187 188// Safe JSON parsing with error handling 189function safeJsonParse(str, fallback = null) { 190 if (!str) return fallback; 191 try { 192 return JSON.parse(str); 193 } catch (e) { 194 console.error('JSON parse error:', e); 195 return fallback; 196 } 197} 198 199// DPoP key management 200async function generateDPoPKey() { 201 const keyPair = await crypto.subtle.generateKey( 202 { name: 'ECDSA', namedCurve: 'P-256' }, 203 true, 204 ['sign', 'verify'] 205 ); 206 return { 207 publicKey: await crypto.subtle.exportKey('jwk', keyPair.publicKey), 208 privateKey: await crypto.subtle.exportKey('jwk', keyPair.privateKey) 209 }; 210} 211 212async function createDPoPProof(privateKeyJwk, publicKeyJwk, method, url, ath = null, nonce = null) { 213 const header = { 214 typ: 'dpop+jwt', 215 alg: 'ES256', 216 jwk: { kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y } 217 }; 218 const payload = { 219 jti: randomString(32), 220 htm: method, 221 htu: url, 222 iat: Math.floor(Date.now() / 1000) 223 }; 224 if (ath) payload.ath = ath; 225 if (nonce) payload.nonce = nonce; 226 227 const privateKey = await crypto.subtle.importKey( 228 'jwk', privateKeyJwk, 229 { name: 'ECDSA', namedCurve: 'P-256' }, 230 false, ['sign'] 231 ); 232 233 const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))); 234 const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))); 235 const message = `${headerB64}.${payloadB64}`; 236 237 const signature = await crypto.subtle.sign( 238 { name: 'ECDSA', hash: 'SHA-256' }, 239 privateKey, 240 new TextEncoder().encode(message) 241 ); 242 243 return `${message}.${base64UrlEncode(signature)}`; 244} 245 246// Handle resolution 247async function resolveHandle(handle) { 248 handle = handle.replace(/^@/, '').trim(); 249 250 // Try DNS resolution via Google DNS 251 try { 252 const res = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 253 const data = await res.json(); 254 if (data.Answer) { 255 for (const ans of data.Answer) { 256 const txt = ans.data.replace(/"/g, ''); 257 if (txt.startsWith('did=')) return txt.substring(4); 258 } 259 } 260 } catch (e) { /* fallback to HTTPS */ } 261 262 // Fallback to HTTPS well-known 263 const res = await fetch(`https://${handle}/.well-known/atproto-did`); 264 return (await res.text()).trim(); 265} 266 267// DID resolution 268async function resolveDID(did) { 269 if (did.startsWith('did:plc:')) { 270 const res = await fetch(`https://plc.directory/${did}`); 271 return await res.json(); 272 } 273 if (did.startsWith('did:web:')) { 274 const domain = did.replace('did:web:', '').replace(/:/g, '/'); 275 const res = await fetch(`https://${domain}/.well-known/did.json`); 276 return await res.json(); 277 } 278 throw new Error('Unsupported DID method'); 279} 280 281// Discover authorization server via protected resource metadata (RFC 9728) 282async function discoverAuthServer(didDoc) { 283 const pds = didDoc.service?.find(s => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'); 284 if (!pds) throw new Error('No PDS found'); 285 286 const pdsUrl = pds.serviceEndpoint; 287 288 // Step 1: Fetch protected resource metadata from PDS 289 const resourceMetaRes = await fetch(`${pdsUrl}/.well-known/oauth-protected-resource`); 290 if (!resourceMetaRes.ok) { 291 throw new Error(`Failed to fetch protected resource metadata: ${resourceMetaRes.status}`); 292 } 293 const resourceMetadata = await resourceMetaRes.json(); 294 295 // Step 2: Extract authorization server URL 296 const authServers = resourceMetadata.authorization_servers; 297 if (!authServers || !Array.isArray(authServers) || authServers.length === 0) { 298 throw new Error('No authorization servers found in protected resource metadata'); 299 } 300 const authServerUrl = authServers[0]; 301 302 // Step 3: Fetch authorization server metadata 303 const authMetaRes = await fetch(`${authServerUrl}/.well-known/oauth-authorization-server`); 304 if (!authMetaRes.ok) { 305 throw new Error(`Failed to fetch authorization server metadata: ${authMetaRes.status}`); 306 } 307 const authMetadata = await authMetaRes.json(); 308 309 return { pdsUrl, metadata: authMetadata }; 310} 311 312// OAuth flow 313async function login() { 314 const handle = document.getElementById('handle').value.replace(/^@/, '').trim(); 315 if (!handle) return showStatus('Please enter your handle', 'error'); 316 if (!isValidHandle(handle)) return showStatus('Invalid handle format. Use format: name.domain.tld', 'error'); 317 318 showStatus('Starting authentication...', 'info'); 319 setLoading(true); 320 321 try { 322 const did = await resolveHandle(handle); 323 const didDoc = await resolveDID(did); 324 const authServer = await discoverAuthServer(didDoc); 325 326 // Generate PKCE (verifier must be 43-128 chars, so use 96 bytes -> ~128 chars) 327 const verifier = randomString(96); 328 const challenge = base64UrlEncode(await sha256(verifier)); 329 const state = randomString(32); 330 const dpopKey = await generateDPoPKey(); 331 332 // Store OAuth state 333 sessionStorage.setItem(STORAGE_PREFIX + 'state', state); 334 sessionStorage.setItem(STORAGE_PREFIX + 'verifier', verifier); 335 sessionStorage.setItem(STORAGE_PREFIX + 'dpop', JSON.stringify(dpopKey)); 336 sessionStorage.setItem(STORAGE_PREFIX + 'meta', JSON.stringify({ 337 did, pdsUrl: authServer.pdsUrl, tokenEndpoint: authServer.metadata.token_endpoint 338 })); 339 340 // Build auth URL 341 const clientId = window.location.origin + CLIENT_METADATA_PATH; 342 const redirectUri = window.location.origin + window.location.pathname; 343 const params = new URLSearchParams({ 344 response_type: 'code', 345 client_id: clientId, 346 redirect_uri: redirectUri, 347 scope: 'atproto', 348 state, 349 code_challenge: challenge, 350 code_challenge_method: 'S256' 351 }); 352 353 window.location.href = `${authServer.metadata.authorization_endpoint}?${params}`; 354 } catch (e) { 355 showStatus(`Login failed: ${e.message}`, 'error'); 356 setLoading(false); 357 } 358} 359 360async function handleCallback() { 361 const params = new URLSearchParams(window.location.search); 362 const code = params.get('code'); 363 const state = params.get('state'); 364 const error = params.get('error'); 365 366 if (error) throw new Error(params.get('error_description') || error); 367 if (!code) return null; 368 369 // Verify state 370 const savedState = sessionStorage.getItem(STORAGE_PREFIX + 'state'); 371 if (savedState !== state) throw new Error('Invalid state'); 372 373 const verifier = sessionStorage.getItem(STORAGE_PREFIX + 'verifier'); 374 const dpopKey = safeJsonParse(sessionStorage.getItem(STORAGE_PREFIX + 'dpop')); 375 const meta = safeJsonParse(sessionStorage.getItem(STORAGE_PREFIX + 'meta')); 376 377 if (!dpopKey || !meta) throw new Error('Session data corrupted. Please try signing in again.'); 378 379 const clientId = window.location.origin + CLIENT_METADATA_PATH; 380 const redirectUri = window.location.origin + window.location.pathname; 381 const tokenBody = new URLSearchParams({ 382 grant_type: 'authorization_code', 383 code, 384 redirect_uri: redirectUri, 385 client_id: clientId, 386 code_verifier: verifier 387 }); 388 389 // Exchange code for tokens (with DPoP nonce retry) 390 let dpopProof = await createDPoPProof(dpopKey.privateKey, dpopKey.publicKey, 'POST', meta.tokenEndpoint); 391 let tokenRes = await fetch(meta.tokenEndpoint, { 392 method: 'POST', 393 headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': dpopProof }, 394 body: tokenBody 395 }); 396 397 // Handle use_dpop_nonce error - retry with nonce from response header 398 if (!tokenRes.ok) { 399 const errorData = await tokenRes.json().catch(() => ({})); 400 if (errorData.error === 'use_dpop_nonce') { 401 const nonce = tokenRes.headers.get('dpop-nonce'); 402 if (nonce) { 403 dpopProof = await createDPoPProof(dpopKey.privateKey, dpopKey.publicKey, 'POST', meta.tokenEndpoint, null, nonce); 404 tokenRes = await fetch(meta.tokenEndpoint, { 405 method: 'POST', 406 headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': dpopProof }, 407 body: tokenBody 408 }); 409 } 410 } 411 if (!tokenRes.ok) throw new Error(`Token exchange failed: ${JSON.stringify(errorData)}`); 412 } 413 const tokens = await tokenRes.json(); 414 415 // Store session 416 const session = { 417 did: meta.did, 418 pdsUrl: meta.pdsUrl, 419 accessToken: tokens.access_token, 420 refreshToken: tokens.refresh_token, 421 expiresAt: Date.now() + (tokens.expires_in * 1000), 422 dpopKey 423 }; 424 sessionStorage.setItem(STORAGE_PREFIX + 'session', JSON.stringify(session)); 425 426 // Cleanup 427 ['state', 'verifier', 'dpop', 'meta'].forEach(k => sessionStorage.removeItem(STORAGE_PREFIX + k)); 428 window.history.replaceState({}, '', window.location.pathname); 429 430 return session; 431} 432 433function getSession() { 434 const data = sessionStorage.getItem(STORAGE_PREFIX + 'session'); 435 return safeJsonParse(data); 436} 437 438// Make authenticated XRPC call to user's PDS 439async function xrpc(method, nsid, params = {}) { 440 const session = getSession(); 441 if (!session) throw new Error('Not logged in'); 442 443 const url = new URL(`/xrpc/${nsid}`, session.pdsUrl); 444 if (method === 'GET') { 445 Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); 446 } 447 448 const ath = base64UrlEncode(await sha256(session.accessToken)); 449 const dpop = await createDPoPProof(session.dpopKey.privateKey, session.dpopKey.publicKey, method, url.toString(), ath); 450 451 const res = await fetch(url, { 452 method, 453 headers: { 'Authorization': `DPoP ${session.accessToken}`, 'DPoP': dpop } 454 }); 455 456 if (!res.ok) throw new Error(`XRPC failed: ${res.status}`); 457 return await res.json(); 458} 459 460// Check supporter status via atprotofans.com 461async function checkSupporter() { 462 const session = getSession(); 463 if (!session) return showStatus('Please sign in first', 'error'); 464 465 showStatus('Checking supporter status...', 'info'); 466 467 try { 468 const url = new URL(ATPROTOFANS_XRPC); 469 url.searchParams.set('supporter', session.did); 470 url.searchParams.set('subject', SUBJECT_DID); 471 url.searchParams.set('signer', SIGNER_DID); 472 473 const res = await fetch(url); 474 if (!res.ok) { 475 const errText = await res.text(); 476 throw new Error(`API error: ${res.status} - ${errText}`); 477 } 478 479 const data = await res.json(); 480 showResult(data); 481 showStatus('', 'info'); 482 } catch (e) { 483 showStatus(`Check failed: ${e.message}`, 'error'); 484 } 485} 486 487function showResult(data) { 488 const card = document.getElementById('resultCard'); 489 const icon = document.getElementById('resultIcon'); 490 const title = document.getElementById('resultTitle'); 491 const detail = document.getElementById('resultDetail'); 492 493 card.classList.remove('hidden', 'supporter', 'not-supporter'); 494 495 if (data.valid) { 496 card.classList.add('supporter'); 497 icon.textContent = '✓'; 498 title.textContent = 'You are a supporter!'; 499 detail.textContent = data.profile?.displayName 500 ? `Supporting as ${data.profile.displayName}` 501 : 'Your support is verified'; 502 } else { 503 card.classList.add('not-supporter'); 504 icon.textContent = '✗'; 505 title.textContent = 'Not a supporter'; 506 // Use DOM manipulation instead of innerHTML for security 507 detail.textContent = ''; 508 detail.appendChild(document.createTextNode('Visit ')); 509 const link = document.createElement('a'); 510 link.href = 'https://atprotofans.com'; 511 link.target = '_blank'; 512 link.rel = 'noopener noreferrer'; 513 link.textContent = 'atprotofans.com'; 514 detail.appendChild(link); 515 detail.appendChild(document.createTextNode(' to become a supporter')); 516 } 517} 518 519function logout() { 520 sessionStorage.removeItem(STORAGE_PREFIX + 'session'); 521 document.getElementById('loginSection').style.display = 'block'; 522 document.getElementById('userSection').classList.remove('show'); 523 document.getElementById('resultCard').classList.add('hidden'); 524 document.getElementById('handle').value = ''; 525 showStatus('Signed out', 'success'); 526} 527 528// UI helpers 529function showStatus(msg, type) { 530 const el = document.getElementById('status'); 531 if (!msg) { el.classList.remove('show'); return; } 532 el.textContent = msg; 533 el.className = `status ${type} show`; 534} 535 536function setLoading(loading) { 537 const btn = document.getElementById('loginBtn'); 538 btn.disabled = loading; 539 btn.textContent = loading ? 'Signing in...' : 'Sign in with ATProtocol'; 540} 541 542async function showUserInfo(session) { 543 document.getElementById('loginSection').style.display = 'none'; 544 document.getElementById('userSection').classList.add('show'); 545 document.getElementById('userDid').textContent = session.did; 546 547 try { 548 const profile = await xrpc('GET', 'com.atproto.repo.describeRepo', { repo: session.did }); 549 document.getElementById('userHandle').textContent = `@${profile.handle}`; 550 } catch (e) { 551 document.getElementById('userHandle').textContent = session.did; 552 } 553} 554 555// Initialize 556(async function init() { 557 const params = new URLSearchParams(window.location.search); 558 559 if (params.has('code') || params.has('error')) { 560 showStatus('Processing authentication...', 'info'); 561 try { 562 const session = await handleCallback(); 563 if (session) { 564 showStatus('Signed in successfully!', 'success'); 565 await showUserInfo(session); 566 } 567 } catch (e) { 568 showStatus(`Authentication failed: ${e.message}`, 'error'); 569 } 570 } else { 571 const session = getSession(); 572 if (session) { 573 await showUserInfo(session); 574 } 575 } 576 577 // Enter key to login 578 document.getElementById('handle').addEventListener('keypress', e => { 579 if (e.key === 'Enter') login(); 580 }); 581})(); 582 </script> 583</body> 584</html>