+9
CHANGELOG.md
+9
CHANGELOG.md
···
6
6
7
7
## [Unreleased]
8
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
+
9
18
## [0.5.0] - 2026-01-08
10
19
11
20
### Added
+31
docker-compose.yml
+31
docker-compose.yml
···
1
+
services:
2
+
plc:
3
+
build:
4
+
context: https://github.com/did-method-plc/did-method-plc.git
5
+
dockerfile: packages/server/Dockerfile
6
+
ports:
7
+
- "2582:2582"
8
+
environment:
9
+
- DATABASE_URL=postgres://plc:plc@postgres:5432/plc
10
+
- PORT=2582
11
+
command: ["dumb-init", "node", "--enable-source-maps", "../dist/bin.js"]
12
+
depends_on:
13
+
postgres:
14
+
condition: service_healthy
15
+
16
+
postgres:
17
+
image: postgres:16-alpine
18
+
environment:
19
+
- POSTGRES_USER=plc
20
+
- POSTGRES_PASSWORD=plc
21
+
- POSTGRES_DB=plc
22
+
volumes:
23
+
- plc_data:/var/lib/postgresql/data
24
+
healthcheck:
25
+
test: ["CMD-SHELL", "pg_isready -U plc"]
26
+
interval: 2s
27
+
timeout: 5s
28
+
retries: 10
29
+
30
+
volumes:
31
+
plc_data:
+255
docs/plans/2026-01-09-consent-profile-card.md
+255
docs/plans/2026-01-09-consent-profile-card.md
···
1
+
# Consent Page Profile Card Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Show the authorizing user's Bluesky profile (avatar, name, handle) on the OAuth consent page.
6
+
7
+
**Architecture:** Add inline HTML/CSS/JS to the consent page. Profile is fetched client-side from Bluesky's public API using the `login_hint` parameter. Graceful degradation if fetch fails.
8
+
9
+
**Tech Stack:** Vanilla JS, Bluesky public API (`app.bsky.actor.getProfile`)
10
+
11
+
---
12
+
13
+
### Task 1: Update renderConsentPage signature
14
+
15
+
**Files:**
16
+
- Modify: `src/pds.js:5008-5017` (function signature and JSDoc)
17
+
18
+
**Step 1: Add loginHint to JSDoc and parameters**
19
+
20
+
Change the function signature from:
21
+
```javascript
22
+
/**
23
+
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params
24
+
* @returns {string} HTML page content
25
+
*/
26
+
function renderConsentPage({
27
+
clientName,
28
+
clientId,
29
+
scope,
30
+
requestUri,
31
+
error = '',
32
+
}) {
33
+
```
34
+
35
+
To:
36
+
```javascript
37
+
/**
38
+
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string }} params
39
+
* @returns {string} HTML page content
40
+
*/
41
+
function renderConsentPage({
42
+
clientName,
43
+
clientId,
44
+
scope,
45
+
requestUri,
46
+
loginHint = '',
47
+
error = '',
48
+
}) {
49
+
```
50
+
51
+
**Step 2: Verify syntax is correct**
52
+
53
+
Run: `node --check src/pds.js`
54
+
Expected: No output (success)
55
+
56
+
---
57
+
58
+
### Task 2: Add profile card CSS
59
+
60
+
**Files:**
61
+
- Modify: `src/pds.js:5027-5055` (inside the `<style>` block)
62
+
63
+
**Step 1: Add profile card styles after existing styles**
64
+
65
+
Add before `</style></head>`:
66
+
```css
67
+
.profile-card{display:flex;align-items:center;gap:12px;padding:16px;background:#2a2a2a;border-radius:8px;margin-bottom:20px}
68
+
.profile-card.loading .avatar{background:#404040;animation:pulse 1.5s infinite}
69
+
.profile-card .avatar{width:48px;height:48px;border-radius:50%;background:#404040;flex-shrink:0}
70
+
.profile-card .avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover}
71
+
.profile-card .info{min-width:0}
72
+
.profile-card .name{color:#fff;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
73
+
.profile-card .handle{color:#808080;font-size:14px}
74
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}
75
+
```
76
+
77
+
**Step 2: Verify syntax is correct**
78
+
79
+
Run: `node --check src/pds.js`
80
+
Expected: No output (success)
81
+
82
+
---
83
+
84
+
### Task 3: Add profile card HTML
85
+
86
+
**Files:**
87
+
- Modify: `src/pds.js:5056-5057` (after `<body>` opening, before `<h2>`)
88
+
89
+
**Step 1: Add profile card HTML conditionally**
90
+
91
+
Replace:
92
+
```javascript
93
+
<body><h2>Sign in to authorize</h2>
94
+
```
95
+
96
+
With:
97
+
```javascript
98
+
<body>
99
+
${loginHint ? `<div class="profile-card loading" id="profile-card">
100
+
<div class="avatar" id="profile-avatar"></div>
101
+
<div class="info"><div class="name" id="profile-name">Loading...</div>
102
+
<div class="handle" id="profile-handle">${escapeHtml(loginHint.startsWith('did:') ? loginHint : '@' + loginHint)}</div></div>
103
+
</div>` : ''}
104
+
<h2>Sign in to authorize</h2>
105
+
```
106
+
107
+
**Step 2: Verify syntax is correct**
108
+
109
+
Run: `node --check src/pds.js`
110
+
Expected: No output (success)
111
+
112
+
---
113
+
114
+
### Task 4: Add profile fetch script
115
+
116
+
**Files:**
117
+
- Modify: `src/pds.js:5066` (before `</body></html>`)
118
+
119
+
**Step 1: Add inline script to fetch profile**
120
+
121
+
Replace:
122
+
```javascript
123
+
</form></body></html>`;
124
+
```
125
+
126
+
With:
127
+
```javascript
128
+
</form>
129
+
${loginHint ? `<script>
130
+
(async()=>{
131
+
const card=document.getElementById('profile-card');
132
+
if(!card)return;
133
+
try{
134
+
const r=await fetch('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor='+encodeURIComponent('${escapeHtml(loginHint)}'));
135
+
if(!r.ok)throw new Error();
136
+
const p=await r.json();
137
+
document.getElementById('profile-avatar').innerHTML=p.avatar?'<img src="'+p.avatar+'" alt="">':'';
138
+
document.getElementById('profile-name').textContent=p.displayName||p.handle;
139
+
document.getElementById('profile-handle').textContent='@'+p.handle;
140
+
card.classList.remove('loading');
141
+
}catch(e){card.classList.remove('loading')}
142
+
})();
143
+
</script>` : ''}
144
+
</body></html>`;
145
+
```
146
+
147
+
**Step 2: Verify syntax is correct**
148
+
149
+
Run: `node --check src/pds.js`
150
+
Expected: No output (success)
151
+
152
+
---
153
+
154
+
### Task 5: Pass loginHint from PAR flow
155
+
156
+
**Files:**
157
+
- Modify: `src/pds.js:3954-3959` (PAR flow renderConsentPage call)
158
+
159
+
**Step 1: Add loginHint to renderConsentPage call**
160
+
161
+
Change:
162
+
```javascript
163
+
return new Response(
164
+
renderConsentPage({
165
+
clientName: clientMetadata.client_name || clientId,
166
+
clientId: clientId || '',
167
+
scope: parameters.scope || 'atproto',
168
+
requestUri: requestUri || '',
169
+
}),
170
+
```
171
+
172
+
To:
173
+
```javascript
174
+
return new Response(
175
+
renderConsentPage({
176
+
clientName: clientMetadata.client_name || clientId,
177
+
clientId: clientId || '',
178
+
scope: parameters.scope || 'atproto',
179
+
requestUri: requestUri || '',
180
+
loginHint: parameters.login_hint || '',
181
+
}),
182
+
```
183
+
184
+
**Step 2: Verify syntax is correct**
185
+
186
+
Run: `node --check src/pds.js`
187
+
Expected: No output (success)
188
+
189
+
---
190
+
191
+
### Task 6: Pass loginHint from direct flow
192
+
193
+
**Files:**
194
+
- Modify: `src/pds.js:4022-4027` (direct flow renderConsentPage call)
195
+
196
+
**Step 1: Add loginHint to renderConsentPage call**
197
+
198
+
Change:
199
+
```javascript
200
+
return new Response(
201
+
renderConsentPage({
202
+
clientName: clientMetadata.client_name || clientId,
203
+
clientId: clientId,
204
+
scope: scope || 'atproto',
205
+
requestUri: newRequestUri,
206
+
}),
207
+
```
208
+
209
+
To:
210
+
```javascript
211
+
return new Response(
212
+
renderConsentPage({
213
+
clientName: clientMetadata.client_name || clientId,
214
+
clientId: clientId,
215
+
scope: scope || 'atproto',
216
+
requestUri: newRequestUri,
217
+
loginHint: loginHint || '',
218
+
}),
219
+
```
220
+
221
+
**Step 2: Verify syntax is correct**
222
+
223
+
Run: `node --check src/pds.js`
224
+
Expected: No output (success)
225
+
226
+
---
227
+
228
+
### Task 7: Run tests and commit
229
+
230
+
**Step 1: Run full test suite**
231
+
232
+
Run: `npm test`
233
+
Expected: All 126 tests pass
234
+
235
+
**Step 2: Commit changes**
236
+
237
+
```bash
238
+
git add src/pds.js docs/plans/2025-01-09-consent-profile-card.md
239
+
git commit -m "feat: add profile card to OAuth consent page
240
+
241
+
Shows the authorizing user's avatar, display name, and handle
242
+
on the consent page. Fetches from Bluesky public API using
243
+
the login_hint parameter. Degrades gracefully if fetch fails."
244
+
```
245
+
246
+
---
247
+
248
+
## Manual Testing
249
+
250
+
After implementation, test by:
251
+
252
+
1. Start local PDS: `npx wrangler dev`
253
+
2. Trigger OAuth flow with login_hint parameter
254
+
3. Verify profile card shows on consent page
255
+
4. Verify it degrades gracefully with invalid login_hint
+1
-1
package.json
+1
-1
package.json
+19
-215
scripts/setup.js
+19
-215
scripts/setup.js
···
4
4
* PDS Setup Script
5
5
*
6
6
* Registers a did:plc, initializes the PDS, and notifies the relay.
7
-
* Zero dependencies - uses Node.js built-ins only.
8
7
*
9
8
* Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev
10
9
*/
11
10
12
-
import { webcrypto } from 'node:crypto';
13
11
import { writeFileSync } from 'node:fs';
12
+
import {
13
+
base32Encode,
14
+
base64UrlEncode,
15
+
bytesToHex,
16
+
cborEncodeDagCbor,
17
+
generateKeyPair,
18
+
importPrivateKey,
19
+
sign,
20
+
} from '../src/pds.js';
14
21
15
22
// === ARGUMENT PARSING ===
16
23
···
57
64
return opts;
58
65
}
59
66
60
-
// === KEY GENERATION ===
61
-
62
-
async function generateP256Keypair() {
63
-
const keyPair = await webcrypto.subtle.generateKey(
64
-
{ name: 'ECDSA', namedCurve: 'P-256' },
65
-
true,
66
-
['sign', 'verify'],
67
-
);
68
-
69
-
// Export private key as raw 32 bytes
70
-
const privateJwk = await webcrypto.subtle.exportKey(
71
-
'jwk',
72
-
keyPair.privateKey,
73
-
);
74
-
const privateBytes = base64UrlDecode(privateJwk.d);
75
-
76
-
// Export public key as uncompressed point (65 bytes)
77
-
const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey);
78
-
const publicBytes = new Uint8Array(publicRaw);
79
-
80
-
// Compress public key to 33 bytes
81
-
const compressedPublic = compressPublicKey(publicBytes);
82
-
83
-
return {
84
-
privateKey: privateBytes,
85
-
publicKey: compressedPublic,
86
-
cryptoKey: keyPair.privateKey,
87
-
};
88
-
}
89
-
90
-
function compressPublicKey(uncompressed) {
91
-
// uncompressed is 65 bytes: 0x04 + x(32) + y(32)
92
-
const x = uncompressed.slice(1, 33);
93
-
const y = uncompressed.slice(33, 65);
94
-
const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03;
95
-
const compressed = new Uint8Array(33);
96
-
compressed[0] = prefix;
97
-
compressed.set(x, 1);
98
-
return compressed;
99
-
}
100
-
101
-
function base64UrlDecode(str) {
102
-
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
103
-
const binary = atob(base64);
104
-
const bytes = new Uint8Array(binary.length);
105
-
for (let i = 0; i < binary.length; i++) {
106
-
bytes[i] = binary.charCodeAt(i);
107
-
}
108
-
return bytes;
109
-
}
110
-
111
-
function bytesToHex(bytes) {
112
-
return Array.from(bytes)
113
-
.map((b) => b.toString(16).padStart(2, '0'))
114
-
.join('');
115
-
}
116
-
117
67
// === DID:KEY ENCODING ===
118
68
119
69
// Multicodec prefix for P-256 public key (0x1200)
···
164
114
return result;
165
115
}
166
116
167
-
// === CBOR ENCODING (dag-cbor compliant for PLC operations) ===
168
-
169
-
function cborEncodeKey(key) {
170
-
// Encode a string key to CBOR bytes (for sorting)
171
-
const bytes = new TextEncoder().encode(key);
172
-
const parts = [];
173
-
const mt = 3 << 5; // major type 3 = text string
174
-
if (bytes.length < 24) {
175
-
parts.push(mt | bytes.length);
176
-
} else if (bytes.length < 256) {
177
-
parts.push(mt | 24, bytes.length);
178
-
} else if (bytes.length < 65536) {
179
-
parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff);
180
-
}
181
-
parts.push(...bytes);
182
-
return new Uint8Array(parts);
183
-
}
184
-
185
-
function compareBytes(a, b) {
186
-
// dag-cbor: bytewise lexicographic order of encoded keys
187
-
const minLen = Math.min(a.length, b.length);
188
-
for (let i = 0; i < minLen; i++) {
189
-
if (a[i] !== b[i]) return a[i] - b[i];
190
-
}
191
-
return a.length - b.length;
192
-
}
193
-
194
-
function cborEncode(value) {
195
-
const parts = [];
196
-
197
-
function encode(val) {
198
-
if (val === null) {
199
-
parts.push(0xf6);
200
-
} else if (typeof val === 'string') {
201
-
const bytes = new TextEncoder().encode(val);
202
-
encodeHead(3, bytes.length);
203
-
parts.push(...bytes);
204
-
} else if (typeof val === 'number') {
205
-
if (Number.isInteger(val) && val >= 0) {
206
-
encodeHead(0, val);
207
-
}
208
-
} else if (val instanceof Uint8Array) {
209
-
encodeHead(2, val.length);
210
-
parts.push(...val);
211
-
} else if (Array.isArray(val)) {
212
-
encodeHead(4, val.length);
213
-
for (const item of val) encode(item);
214
-
} else if (typeof val === 'object') {
215
-
// dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic)
216
-
const keys = Object.keys(val);
217
-
const keysSorted = keys.sort((a, b) =>
218
-
compareBytes(cborEncodeKey(a), cborEncodeKey(b)),
219
-
);
220
-
encodeHead(5, keysSorted.length);
221
-
for (const key of keysSorted) {
222
-
encode(key);
223
-
encode(val[key]);
224
-
}
225
-
}
226
-
}
227
-
228
-
function encodeHead(majorType, length) {
229
-
const mt = majorType << 5;
230
-
if (length < 24) {
231
-
parts.push(mt | length);
232
-
} else if (length < 256) {
233
-
parts.push(mt | 24, length);
234
-
} else if (length < 65536) {
235
-
parts.push(mt | 25, length >> 8, length & 0xff);
236
-
}
237
-
}
238
-
239
-
encode(value);
240
-
return new Uint8Array(parts);
241
-
}
242
-
243
117
// === HASHING ===
244
118
245
119
async function sha256(data) {
246
-
const hash = await webcrypto.subtle.digest('SHA-256', data);
120
+
const hash = await crypto.subtle.digest('SHA-256', data);
247
121
return new Uint8Array(hash);
248
122
}
249
123
250
124
// === PLC OPERATIONS ===
251
125
252
-
async function signPlcOperation(operation, privateKey) {
126
+
async function signPlcOperation(operation, cryptoKey) {
253
127
// Encode operation without sig field
254
128
const { sig, ...opWithoutSig } = operation;
255
-
const encoded = cborEncode(opWithoutSig);
129
+
const encoded = cborEncodeDagCbor(opWithoutSig);
256
130
257
-
// Sign with P-256
258
-
const signature = await webcrypto.subtle.sign(
259
-
{ name: 'ECDSA', hash: 'SHA-256' },
260
-
privateKey,
261
-
encoded,
262
-
);
263
-
264
-
// Convert to low-S form and base64url encode
265
-
const sigBytes = ensureLowS(new Uint8Array(signature));
266
-
return base64UrlEncode(sigBytes);
267
-
}
268
-
269
-
function ensureLowS(sig) {
270
-
// P-256 order N
271
-
const N = BigInt(
272
-
'0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551',
273
-
);
274
-
const halfN = N / 2n;
275
-
276
-
const r = sig.slice(0, 32);
277
-
const s = sig.slice(32, 64);
278
-
279
-
// Convert s to BigInt
280
-
let sInt = BigInt(`0x${bytesToHex(s)}`);
281
-
282
-
// If s > N/2, replace with N - s
283
-
if (sInt > halfN) {
284
-
sInt = N - sInt;
285
-
const newS = hexToBytes(sInt.toString(16).padStart(64, '0'));
286
-
const result = new Uint8Array(64);
287
-
result.set(r);
288
-
result.set(newS, 32);
289
-
return result;
290
-
}
291
-
292
-
return sig;
293
-
}
294
-
295
-
function hexToBytes(hex) {
296
-
const bytes = new Uint8Array(hex.length / 2);
297
-
for (let i = 0; i < hex.length; i += 2) {
298
-
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
299
-
}
300
-
return bytes;
301
-
}
302
-
303
-
function base64UrlEncode(bytes) {
304
-
const binary = String.fromCharCode(...bytes);
305
-
return btoa(binary)
306
-
.replace(/\+/g, '-')
307
-
.replace(/\//g, '_')
308
-
.replace(/=+$/, '');
131
+
// Sign with P-256 (sign() handles low-S normalization)
132
+
const signature = await sign(cryptoKey, encoded);
133
+
return base64UrlEncode(signature);
309
134
}
310
135
311
136
async function createGenesisOperation(opts) {
···
339
164
340
165
async function deriveDidFromOperation(operation) {
341
166
// DID is computed from the FULL operation INCLUDING the signature
342
-
const encoded = cborEncode(operation);
167
+
const encoded = cborEncodeDagCbor(operation);
343
168
const hash = await sha256(encoded);
344
169
// DID is base32 of first 15 bytes of hash (= 24 base32 chars)
345
170
return `did:plc:${base32Encode(hash.slice(0, 15))}`;
346
-
}
347
-
348
-
function base32Encode(bytes) {
349
-
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
350
-
let result = '';
351
-
let bits = 0;
352
-
let value = 0;
353
-
354
-
for (const byte of bytes) {
355
-
value = (value << 8) | byte;
356
-
bits += 8;
357
-
while (bits >= 5) {
358
-
bits -= 5;
359
-
result += alphabet[(value >> bits) & 31];
360
-
}
361
-
}
362
-
363
-
if (bits > 0) {
364
-
result += alphabet[(value << (5 - bits)) & 31];
365
-
}
366
-
367
-
return result;
368
171
}
369
172
370
173
// === PLC DIRECTORY REGISTRATION ===
···
479
282
480
283
// Step 1: Generate keypair
481
284
console.log('Generating P-256 keypair...');
482
-
const keyPair = await generateP256Keypair();
285
+
const keyPair = await generateKeyPair();
286
+
const cryptoKey = await importPrivateKey(keyPair.privateKey);
483
287
const didKey = publicKeyToDidKey(keyPair.publicKey);
484
288
console.log(` did:key: ${didKey}`);
485
289
console.log('');
···
490
294
didKey,
491
295
handle: opts.handle,
492
296
pdsUrl: opts.pds,
493
-
cryptoKey: keyPair.cryptoKey,
297
+
cryptoKey,
494
298
});
495
299
const did = await deriveDidFromOperation(operation);
496
300
console.log(` DID: ${did}`);
+45
-4
src/pds.js
+45
-4
src/pds.js
···
795
795
* @param {*} value
796
796
* @returns {Uint8Array}
797
797
*/
798
-
function cborEncodeDagCbor(value) {
798
+
export function cborEncodeDagCbor(value) {
799
799
/** @type {number[]} */
800
800
const parts = [];
801
801
···
3956
3956
clientId: clientId || '',
3957
3957
scope: parameters.scope || 'atproto',
3958
3958
requestUri: requestUri || '',
3959
+
loginHint: parameters.login_hint || '',
3959
3960
}),
3960
3961
{
3961
3962
status: 200,
···
4024
4025
clientId: clientId,
4025
4026
scope: scope || 'atproto',
4026
4027
requestUri: newRequestUri,
4028
+
loginHint: loginHint || '',
4027
4029
}),
4028
4030
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
4029
4031
);
···
5005
5007
5006
5008
/**
5007
5009
* Render the OAuth consent page HTML.
5008
-
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params
5010
+
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string }} params
5009
5011
* @returns {string} HTML page content
5010
5012
*/
5011
5013
function renderConsentPage({
···
5013
5015
clientId,
5014
5016
scope,
5015
5017
requestUri,
5018
+
loginHint = '',
5016
5019
error = '',
5017
5020
}) {
5018
5021
const parsed = parseScopesForDisplay(scope);
···
5052
5055
.blob-list li{margin:4px 0}
5053
5056
.warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0}
5054
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}}
5055
5066
</style></head>
5056
-
<body><h2>Sign in to authorize</h2>
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>
5057
5078
<p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p>
5058
5079
${renderPermissionsHtml(parsed)}
5059
5080
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
···
5063
5084
<label>Password</label><input type="password" name="password" required autofocus>
5064
5085
<div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button>
5065
5086
<button type="submit" name="action" value="approve" class="approve">Authorize</button></div>
5066
-
</form></body></html>`;
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>`;
5067
5108
}
5068
5109
5069
5110
/**
+147
-23
test/e2e.test.js
+147
-23
test/e2e.test.js
···
40
40
}
41
41
42
42
/**
43
-
* Make JSON request helper
43
+
* Make JSON request helper (with retry for flaky wrangler dev 5xx errors)
44
44
*/
45
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 };
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
+
}
52
59
}
53
60
54
61
/**
55
-
* Make form-encoded POST
62
+
* Make form-encoded POST (with retry for flaky wrangler dev 5xx errors)
56
63
*/
57
64
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;
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 };
72
87
}
73
-
return { status: res.status, data };
74
88
}
75
89
76
90
describe('E2E Tests', () => {
···
1562
1576
const tokenData = await tokenRes.json();
1563
1577
assert.ok(tokenData.access_token, 'Should have access_token');
1564
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
+
);
1565
1689
});
1566
1690
});
1567
1691
+121
-49
test/helpers/oauth.js
+121
-49
test/helpers/oauth.js
···
8
8
const BASE = 'http://localhost:8787';
9
9
10
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
+
/**
11
43
* Get an OAuth token with a specific scope via full PAR -> authorize -> token flow
12
44
* @param {string} scope - The scope to request
13
45
* @param {string} did - The DID to authenticate as
···
25
57
);
26
58
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
27
59
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();
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
+
}
47
94
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');
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
+
}
61
119
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();
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
+
}
79
151
80
152
return {
81
153
accessToken: tokenData.access_token,