index.html
edited
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>