this repo has no description

feat: add profile card to OAuth consent page

Shows the authorizing user's avatar, display name, and handle on the
consent page. Fetches profile from Bluesky public API using the
login_hint parameter. Degrades gracefully if fetch fails.

- Uses JSON.stringify for safe JS string escaping (XSS prevention)
- Adds e2e tests for profile card rendering and XSS protection
- Adds retry logic for flaky wrangler dev errors in e2e tests
- Bumps version to 0.6.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+577 -77
docs
scripts
src
test
+9
CHANGELOG.md
··· 6 7 ## [Unreleased] 8 9 ## [0.5.0] - 2026-01-08 10 11 ### Added
··· 6 7 ## [Unreleased] 8 9 + ## [0.6.0] - 2026-01-09 10 + 11 + ### Added 12 + 13 + - **Profile card on OAuth consent page** showing authorizing user's identity 14 + - Displays avatar, display name, and handle from Bluesky public API 15 + - Fetches profile client-side using `login_hint` parameter 16 + - Graceful degradation if fetch fails (shows handle only) 17 + 18 ## [0.5.0] - 2026-01-08 19 20 ### Added
+1 -1
package.json
··· 1 { 2 "name": "pds.js", 3 - "version": "0.5.0", 4 "private": true, 5 "type": "module", 6 "scripts": {
··· 1 { 2 "name": "pds.js", 3 + "version": "0.6.0", 4 "private": true, 5 "type": "module", 6 "scripts": {
-1
scripts/setup.js
··· 64 return opts; 65 } 66 67 - 68 // === DID:KEY ENCODING === 69 70 // Multicodec prefix for P-256 public key (0x1200)
··· 64 return opts; 65 } 66 67 // === DID:KEY ENCODING === 68 69 // Multicodec prefix for P-256 public key (0x1200)
+44 -3
src/pds.js
··· 3956 clientId: clientId || '', 3957 scope: parameters.scope || 'atproto', 3958 requestUri: requestUri || '', 3959 }), 3960 { 3961 status: 200, ··· 4024 clientId: clientId, 4025 scope: scope || 'atproto', 4026 requestUri: newRequestUri, 4027 }), 4028 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 4029 ); ··· 5005 5006 /** 5007 * Render the OAuth consent page HTML. 5008 - * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 5009 * @returns {string} HTML page content 5010 */ 5011 function renderConsentPage({ ··· 5013 clientId, 5014 scope, 5015 requestUri, 5016 error = '', 5017 }) { 5018 const parsed = parseScopesForDisplay(scope); ··· 5052 .blob-list li{margin:4px 0} 5053 .warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0} 5054 .warning small{color:#d4a000;display:block;margin-top:4px} 5055 </style></head> 5056 - <body><h2>Sign in to authorize</h2> 5057 <p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p> 5058 ${renderPermissionsHtml(parsed)} 5059 ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''} ··· 5063 <label>Password</label><input type="password" name="password" required autofocus> 5064 <div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 5065 <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 5066 - </form></body></html>`; 5067 } 5068 5069 /**
··· 3956 clientId: clientId || '', 3957 scope: parameters.scope || 'atproto', 3958 requestUri: requestUri || '', 3959 + loginHint: parameters.login_hint || '', 3960 }), 3961 { 3962 status: 200, ··· 4025 clientId: clientId, 4026 scope: scope || 'atproto', 4027 requestUri: newRequestUri, 4028 + loginHint: loginHint || '', 4029 }), 4030 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 4031 ); ··· 5007 5008 /** 5009 * Render the OAuth consent page HTML. 5010 + * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string }} params 5011 * @returns {string} HTML page content 5012 */ 5013 function renderConsentPage({ ··· 5015 clientId, 5016 scope, 5017 requestUri, 5018 + loginHint = '', 5019 error = '', 5020 }) { 5021 const parsed = parseScopesForDisplay(scope); ··· 5055 .blob-list li{margin:4px 0} 5056 .warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0} 5057 .warning small{color:#d4a000;display:block;margin-top:4px} 5058 + .profile-card{display:flex;align-items:center;gap:12px;padding:16px;background:#2a2a2a;border-radius:8px;margin-bottom:20px} 5059 + .profile-card.loading .avatar{background:#404040;animation:pulse 1.5s infinite} 5060 + .profile-card .avatar{width:48px;height:48px;border-radius:50%;background:#404040;flex-shrink:0} 5061 + .profile-card .avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover} 5062 + .profile-card .info{min-width:0} 5063 + .profile-card .name{color:#fff;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 5064 + .profile-card .handle{color:#808080;font-size:14px} 5065 + @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}} 5066 </style></head> 5067 + <body> 5068 + ${ 5069 + loginHint 5070 + ? `<div class="profile-card loading" id="profile-card"> 5071 + <div class="avatar" id="profile-avatar"></div> 5072 + <div class="info"><div class="name" id="profile-name">Loading...</div> 5073 + <div class="handle" id="profile-handle">${escapeHtml(loginHint.startsWith('did:') ? loginHint : `@${loginHint}`)}</div></div> 5074 + </div>` 5075 + : '' 5076 + } 5077 + <h2>Sign in to authorize</h2> 5078 <p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p> 5079 ${renderPermissionsHtml(parsed)} 5080 ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''} ··· 5084 <label>Password</label><input type="password" name="password" required autofocus> 5085 <div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 5086 <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 5087 + </form> 5088 + ${ 5089 + loginHint 5090 + ? `<script> 5091 + (async()=>{ 5092 + const card=document.getElementById('profile-card'); 5093 + if(!card)return; 5094 + try{ 5095 + const r=await fetch('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor='+encodeURIComponent(${JSON.stringify(loginHint)})); 5096 + if(!r.ok)throw new Error(); 5097 + const p=await r.json(); 5098 + document.getElementById('profile-avatar').innerHTML=p.avatar?'<img src="'+p.avatar+'" alt="">':''; 5099 + document.getElementById('profile-name').textContent=p.displayName||p.handle; 5100 + document.getElementById('profile-handle').textContent='@'+p.handle; 5101 + card.classList.remove('loading'); 5102 + }catch(e){card.classList.remove('loading')} 5103 + })(); 5104 + </script>` 5105 + : '' 5106 + } 5107 + </body></html>`; 5108 } 5109 5110 /**
+147 -23
test/e2e.test.js
··· 40 } 41 42 /** 43 - * Make JSON request helper 44 */ 45 async function jsonPost(path, body, headers = {}) { 46 - const res = await fetch(`${BASE}${path}`, { 47 - method: 'POST', 48 - headers: { 'Content-Type': 'application/json', ...headers }, 49 - body: JSON.stringify(body), 50 - }); 51 - return { status: res.status, data: res.ok ? await res.json() : null }; 52 } 53 54 /** 55 - * Make form-encoded POST 56 */ 57 async function formPost(path, params, headers = {}) { 58 - const res = await fetch(`${BASE}${path}`, { 59 - method: 'POST', 60 - headers: { 61 - 'Content-Type': 'application/x-www-form-urlencoded', 62 - ...headers, 63 - }, 64 - body: new URLSearchParams(params).toString(), 65 - }); 66 - const text = await res.text(); 67 - let data = null; 68 - try { 69 - data = JSON.parse(text); 70 - } catch { 71 - data = text; 72 } 73 - return { status: res.status, data }; 74 } 75 76 describe('E2E Tests', () => { ··· 1562 const tokenData = await tokenRes.json(); 1563 assert.ok(tokenData.access_token, 'Should have access_token'); 1564 assert.strictEqual(tokenData.token_type, 'DPoP'); 1565 }); 1566 }); 1567
··· 40 } 41 42 /** 43 + * Make JSON request helper (with retry for flaky wrangler dev 5xx errors) 44 */ 45 async function jsonPost(path, body, headers = {}) { 46 + for (let attempt = 0; attempt < 3; attempt++) { 47 + const res = await fetch(`${BASE}${path}`, { 48 + method: 'POST', 49 + headers: { 'Content-Type': 'application/json', ...headers }, 50 + body: JSON.stringify(body), 51 + }); 52 + // Retry on 5xx errors (wrangler dev flakiness) 53 + if (res.status >= 500 && attempt < 2) { 54 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 55 + continue; 56 + } 57 + return { status: res.status, data: res.ok ? await res.json() : null }; 58 + } 59 } 60 61 /** 62 + * Make form-encoded POST (with retry for flaky wrangler dev 5xx errors) 63 */ 64 async function formPost(path, params, headers = {}) { 65 + for (let attempt = 0; attempt < 3; attempt++) { 66 + const res = await fetch(`${BASE}${path}`, { 67 + method: 'POST', 68 + headers: { 69 + 'Content-Type': 'application/x-www-form-urlencoded', 70 + ...headers, 71 + }, 72 + body: new URLSearchParams(params).toString(), 73 + }); 74 + // Retry on 5xx errors (wrangler dev flakiness) 75 + if (res.status >= 500 && attempt < 2) { 76 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 77 + continue; 78 + } 79 + const text = await res.text(); 80 + let data = null; 81 + try { 82 + data = JSON.parse(text); 83 + } catch { 84 + data = text; 85 + } 86 + return { status: res.status, data }; 87 } 88 } 89 90 describe('E2E Tests', () => { ··· 1576 const tokenData = await tokenRes.json(); 1577 assert.ok(tokenData.access_token, 'Should have access_token'); 1578 assert.strictEqual(tokenData.token_type, 'DPoP'); 1579 + }); 1580 + 1581 + it('consent page shows profile card when login_hint is provided', async () => { 1582 + const clientId = 'http://localhost:3000'; 1583 + const redirectUri = 'http://localhost:3000/callback'; 1584 + const codeVerifier = 'test-verifier-for-profile-card-test-min-43-chars!!'; 1585 + const challengeBuffer = await crypto.subtle.digest( 1586 + 'SHA-256', 1587 + new TextEncoder().encode(codeVerifier), 1588 + ); 1589 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1590 + 1591 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1592 + authorizeUrl.searchParams.set('client_id', clientId); 1593 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1594 + authorizeUrl.searchParams.set('response_type', 'code'); 1595 + authorizeUrl.searchParams.set('scope', 'atproto'); 1596 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1597 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1598 + authorizeUrl.searchParams.set('state', 'test-state'); 1599 + authorizeUrl.searchParams.set('login_hint', 'test.handle.example'); 1600 + 1601 + const res = await fetch(authorizeUrl.toString()); 1602 + const html = await res.text(); 1603 + 1604 + assert.ok( 1605 + html.includes('profile-card'), 1606 + 'Should include profile card element', 1607 + ); 1608 + assert.ok( 1609 + html.includes('@test.handle.example'), 1610 + 'Should show handle with @ prefix', 1611 + ); 1612 + assert.ok( 1613 + html.includes('app.bsky.actor.getProfile'), 1614 + 'Should include profile fetch script', 1615 + ); 1616 + }); 1617 + 1618 + it('consent page does not show profile card when login_hint is omitted', async () => { 1619 + const clientId = 'http://localhost:3000'; 1620 + const redirectUri = 'http://localhost:3000/callback'; 1621 + const codeVerifier = 'test-verifier-for-no-profile-test-min-43-chars!!'; 1622 + const challengeBuffer = await crypto.subtle.digest( 1623 + 'SHA-256', 1624 + new TextEncoder().encode(codeVerifier), 1625 + ); 1626 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1627 + 1628 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1629 + authorizeUrl.searchParams.set('client_id', clientId); 1630 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1631 + authorizeUrl.searchParams.set('response_type', 'code'); 1632 + authorizeUrl.searchParams.set('scope', 'atproto'); 1633 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1634 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1635 + authorizeUrl.searchParams.set('state', 'test-state'); 1636 + // No login_hint parameter 1637 + 1638 + const res = await fetch(authorizeUrl.toString()); 1639 + const html = await res.text(); 1640 + 1641 + // Check for the actual element (id="profile-card"), not the CSS class selector 1642 + assert.ok( 1643 + !html.includes('id="profile-card"'), 1644 + 'Should NOT include profile card element', 1645 + ); 1646 + assert.ok( 1647 + !html.includes('app.bsky.actor.getProfile'), 1648 + 'Should NOT include profile fetch script', 1649 + ); 1650 + }); 1651 + 1652 + it('consent page escapes dangerous characters in login_hint', async () => { 1653 + const clientId = 'http://localhost:3000'; 1654 + const redirectUri = 'http://localhost:3000/callback'; 1655 + const codeVerifier = 'test-verifier-for-xss-test-minimum-43-chars!!!!!'; 1656 + const challengeBuffer = await crypto.subtle.digest( 1657 + 'SHA-256', 1658 + new TextEncoder().encode(codeVerifier), 1659 + ); 1660 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1661 + 1662 + // Attempt XSS via login_hint with double quotes to break out of JSON.stringify 1663 + const maliciousHint = 'user");alert("xss'; 1664 + 1665 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1666 + authorizeUrl.searchParams.set('client_id', clientId); 1667 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1668 + authorizeUrl.searchParams.set('response_type', 'code'); 1669 + authorizeUrl.searchParams.set('scope', 'atproto'); 1670 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1671 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1672 + authorizeUrl.searchParams.set('state', 'test-state'); 1673 + authorizeUrl.searchParams.set('login_hint', maliciousHint); 1674 + 1675 + const res = await fetch(authorizeUrl.toString()); 1676 + const html = await res.text(); 1677 + 1678 + // JSON.stringify escapes double quotes, so the payload should be escaped 1679 + // The raw ");alert(" should NOT appear - it should be escaped as \");alert(\" 1680 + assert.ok( 1681 + !html.includes('");alert("'), 1682 + 'Should escape double quotes to prevent XSS breakout', 1683 + ); 1684 + // Verify the escaped version is present (backslash before the quote) 1685 + assert.ok( 1686 + html.includes('\\"'), 1687 + 'Should contain escaped characters from JSON.stringify', 1688 + ); 1689 }); 1690 }); 1691
+121 -49
test/helpers/oauth.js
··· 8 const BASE = 'http://localhost:8787'; 9 10 /** 11 * Get an OAuth token with a specific scope via full PAR -> authorize -> token flow 12 * @param {string} scope - The scope to request 13 * @param {string} did - The DID to authenticate as ··· 25 ); 26 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 27 28 - // PAR request 29 - const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 30 - const parRes = await fetch(`${BASE}/oauth/par`, { 31 - method: 'POST', 32 - headers: { 33 - 'Content-Type': 'application/x-www-form-urlencoded', 34 - DPoP: parProof, 35 - }, 36 - body: new URLSearchParams({ 37 - client_id: clientId, 38 - redirect_uri: redirectUri, 39 - response_type: 'code', 40 - scope: scope, 41 - code_challenge: codeChallenge, 42 - code_challenge_method: 'S256', 43 - login_hint: did, 44 - }).toString(), 45 - }); 46 - const parData = await parRes.json(); 47 48 - // Authorize 49 - const authRes = await fetch(`${BASE}/oauth/authorize`, { 50 - method: 'POST', 51 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 52 - body: new URLSearchParams({ 53 - request_uri: parData.request_uri, 54 - client_id: clientId, 55 - password: password, 56 - }).toString(), 57 - redirect: 'manual', 58 - }); 59 - const location = authRes.headers.get('location'); 60 - const authCode = new URL(location).searchParams.get('code'); 61 62 - // Token exchange 63 - const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 64 - const tokenRes = await fetch(`${BASE}/oauth/token`, { 65 - method: 'POST', 66 - headers: { 67 - 'Content-Type': 'application/x-www-form-urlencoded', 68 - DPoP: tokenProof, 69 - }, 70 - body: new URLSearchParams({ 71 - grant_type: 'authorization_code', 72 - code: authCode, 73 - client_id: clientId, 74 - redirect_uri: redirectUri, 75 - code_verifier: codeVerifier, 76 - }).toString(), 77 - }); 78 - const tokenData = await tokenRes.json(); 79 80 return { 81 accessToken: tokenData.access_token,
··· 8 const BASE = 'http://localhost:8787'; 9 10 /** 11 + * Fetch with retry for flaky wrangler dev 12 + * @param {string} url 13 + * @param {RequestInit} options 14 + * @param {number} maxAttempts 15 + * @returns {Promise<Response>} 16 + */ 17 + async function fetchWithRetry(url, options, maxAttempts = 3) { 18 + let lastError; 19 + for (let attempt = 0; attempt < maxAttempts; attempt++) { 20 + try { 21 + const res = await fetch(url, options); 22 + // Check if we got an HTML error page instead of expected response 23 + const contentType = res.headers.get('content-type') || ''; 24 + if (!res.ok && contentType.includes('text/html')) { 25 + // Wrangler dev error page - retry 26 + if (attempt < maxAttempts - 1) { 27 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 28 + continue; 29 + } 30 + } 31 + return res; 32 + } catch (err) { 33 + lastError = err; 34 + if (attempt < maxAttempts - 1) { 35 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 36 + } 37 + } 38 + } 39 + throw lastError || new Error('Fetch failed after retries'); 40 + } 41 + 42 + /** 43 * Get an OAuth token with a specific scope via full PAR -> authorize -> token flow 44 * @param {string} scope - The scope to request 45 * @param {string} did - The DID to authenticate as ··· 57 ); 58 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 59 60 + // PAR request (with retry for flaky wrangler dev) 61 + let parData; 62 + for (let attempt = 0; attempt < 3; attempt++) { 63 + // Generate fresh DPoP proof for each attempt 64 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 65 + const parRes = await fetchWithRetry(`${BASE}/oauth/par`, { 66 + method: 'POST', 67 + headers: { 68 + 'Content-Type': 'application/x-www-form-urlencoded', 69 + DPoP: parProof, 70 + }, 71 + body: new URLSearchParams({ 72 + client_id: clientId, 73 + redirect_uri: redirectUri, 74 + response_type: 'code', 75 + scope: scope, 76 + code_challenge: codeChallenge, 77 + code_challenge_method: 'S256', 78 + login_hint: did, 79 + }).toString(), 80 + }); 81 + if (parRes.ok) { 82 + parData = await parRes.json(); 83 + break; 84 + } 85 + if (attempt < 2) { 86 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 87 + } else { 88 + const text = await parRes.text(); 89 + throw new Error( 90 + `PAR request failed: ${parRes.status} - ${text.slice(0, 100)}`, 91 + ); 92 + } 93 + } 94 95 + // Authorize (with retry) 96 + let authCode; 97 + for (let attempt = 0; attempt < 3; attempt++) { 98 + const authRes = await fetchWithRetry(`${BASE}/oauth/authorize`, { 99 + method: 'POST', 100 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 101 + body: new URLSearchParams({ 102 + request_uri: parData.request_uri, 103 + client_id: clientId, 104 + password: password, 105 + }).toString(), 106 + redirect: 'manual', 107 + }); 108 + const location = authRes.headers.get('location'); 109 + if (location) { 110 + authCode = new URL(location).searchParams.get('code'); 111 + if (authCode) break; 112 + } 113 + if (attempt < 2) { 114 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 115 + } else { 116 + throw new Error('Authorize request failed to return code'); 117 + } 118 + } 119 120 + // Token exchange (with retry and fresh DPoP proof) 121 + let tokenData; 122 + for (let attempt = 0; attempt < 3; attempt++) { 123 + const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 124 + const tokenRes = await fetchWithRetry(`${BASE}/oauth/token`, { 125 + method: 'POST', 126 + headers: { 127 + 'Content-Type': 'application/x-www-form-urlencoded', 128 + DPoP: tokenProof, 129 + }, 130 + body: new URLSearchParams({ 131 + grant_type: 'authorization_code', 132 + code: authCode, 133 + client_id: clientId, 134 + redirect_uri: redirectUri, 135 + code_verifier: codeVerifier, 136 + }).toString(), 137 + }); 138 + if (tokenRes.ok) { 139 + tokenData = await tokenRes.json(); 140 + break; 141 + } 142 + if (attempt < 2) { 143 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 144 + } else { 145 + const text = await tokenRes.text(); 146 + throw new Error( 147 + `Token request failed: ${tokenRes.status} - ${text.slice(0, 100)}`, 148 + ); 149 + } 150 + } 151 152 return { 153 accessToken: tokenData.access_token,