javascript-solid-server 0.0.76 → 0.0.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -2
- package/package.json +2 -1
- package/src/idp/accounts.js +133 -0
- package/src/idp/index.js +83 -0
- package/src/idp/interactions.js +236 -9
- package/src/idp/passkey.js +311 -0
- package/src/idp/views.js +414 -1
- package/.claude/settings.local.json +0 -262
package/src/idp/views.js
CHANGED
|
@@ -105,6 +105,55 @@ const styles = `
|
|
|
105
105
|
.btn-secondary:hover {
|
|
106
106
|
background: #e0e0e0;
|
|
107
107
|
}
|
|
108
|
+
.btn-passkey {
|
|
109
|
+
background: #1a73e8;
|
|
110
|
+
color: white;
|
|
111
|
+
width: 100%;
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
justify-content: center;
|
|
115
|
+
gap: 8px;
|
|
116
|
+
}
|
|
117
|
+
.btn-passkey:hover {
|
|
118
|
+
background: #1557b0;
|
|
119
|
+
}
|
|
120
|
+
.btn-passkey svg {
|
|
121
|
+
width: 20px;
|
|
122
|
+
height: 20px;
|
|
123
|
+
}
|
|
124
|
+
.btn-schnorr {
|
|
125
|
+
background: #7b1fa2;
|
|
126
|
+
color: white;
|
|
127
|
+
width: 100%;
|
|
128
|
+
display: flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
gap: 8px;
|
|
132
|
+
margin-top: 12px;
|
|
133
|
+
}
|
|
134
|
+
.btn-schnorr:hover {
|
|
135
|
+
background: #6a1b9a;
|
|
136
|
+
}
|
|
137
|
+
.btn-schnorr svg {
|
|
138
|
+
width: 20px;
|
|
139
|
+
height: 20px;
|
|
140
|
+
}
|
|
141
|
+
.divider {
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
margin: 20px 0;
|
|
145
|
+
color: #666;
|
|
146
|
+
font-size: 14px;
|
|
147
|
+
}
|
|
148
|
+
.divider::before,
|
|
149
|
+
.divider::after {
|
|
150
|
+
content: '';
|
|
151
|
+
flex: 1;
|
|
152
|
+
border-bottom: 1px solid #ddd;
|
|
153
|
+
}
|
|
154
|
+
.divider span {
|
|
155
|
+
padding: 0 12px;
|
|
156
|
+
}
|
|
108
157
|
.scopes {
|
|
109
158
|
margin: 20px 0;
|
|
110
159
|
}
|
|
@@ -149,6 +198,18 @@ const solidLogo = `
|
|
|
149
198
|
</svg>
|
|
150
199
|
`;
|
|
151
200
|
|
|
201
|
+
const passkeyIcon = `
|
|
202
|
+
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
203
|
+
<path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
|
|
204
|
+
</svg>
|
|
205
|
+
`;
|
|
206
|
+
|
|
207
|
+
const schnorrIcon = `
|
|
208
|
+
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
209
|
+
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
|
|
210
|
+
</svg>
|
|
211
|
+
`;
|
|
212
|
+
|
|
152
213
|
const scopeDescriptions = {
|
|
153
214
|
openid: 'Access your identity',
|
|
154
215
|
webid: 'Access your WebID',
|
|
@@ -157,11 +218,203 @@ const scopeDescriptions = {
|
|
|
157
218
|
offline_access: 'Stay logged in',
|
|
158
219
|
};
|
|
159
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Escape string for safe use in JavaScript
|
|
223
|
+
*/
|
|
224
|
+
function escapeJs(text) {
|
|
225
|
+
if (!text) return '';
|
|
226
|
+
return String(text)
|
|
227
|
+
.replace(/\\/g, '\\\\')
|
|
228
|
+
.replace(/'/g, "\\'")
|
|
229
|
+
.replace(/"/g, '\\"')
|
|
230
|
+
.replace(/</g, '\\x3c')
|
|
231
|
+
.replace(/>/g, '\\x3e')
|
|
232
|
+
.replace(/\n/g, '\\n')
|
|
233
|
+
.replace(/\r/g, '\\r');
|
|
234
|
+
}
|
|
235
|
+
|
|
160
236
|
/**
|
|
161
237
|
* Login page HTML
|
|
162
238
|
*/
|
|
163
|
-
export function loginPage(uid, clientId, error = null) {
|
|
239
|
+
export function loginPage(uid, clientId, error = null, passkeyEnabled = true, schnorrEnabled = true) {
|
|
164
240
|
const appName = clientId || 'An application';
|
|
241
|
+
const safeUid = escapeJs(uid);
|
|
242
|
+
|
|
243
|
+
const passkeySection = passkeyEnabled ? `
|
|
244
|
+
<button type="button" class="btn btn-passkey" onclick="loginWithPasskey()">
|
|
245
|
+
${passkeyIcon}
|
|
246
|
+
Sign in with Passkey
|
|
247
|
+
</button>
|
|
248
|
+
` : '';
|
|
249
|
+
|
|
250
|
+
const schnorrSection = schnorrEnabled ? `
|
|
251
|
+
<button type="button" class="btn btn-schnorr" onclick="loginWithSchnorr()" id="schnorrBtn">
|
|
252
|
+
${schnorrIcon}
|
|
253
|
+
Sign in with Schnorr
|
|
254
|
+
</button>
|
|
255
|
+
` : '';
|
|
256
|
+
|
|
257
|
+
const ssoSection = (passkeyEnabled || schnorrEnabled) ? `
|
|
258
|
+
${passkeySection}
|
|
259
|
+
${schnorrSection}
|
|
260
|
+
<div class="divider"><span>or</span></div>
|
|
261
|
+
` : '';
|
|
262
|
+
|
|
263
|
+
const passkeyScript = passkeyEnabled ? `
|
|
264
|
+
<script>
|
|
265
|
+
var INTERACTION_UID = '${safeUid}';
|
|
266
|
+
|
|
267
|
+
async function loginWithPasskey() {
|
|
268
|
+
try {
|
|
269
|
+
// Get authentication options
|
|
270
|
+
const optionsRes = await fetch('/idp/passkey/login/options', {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: { 'Content-Type': 'application/json' },
|
|
273
|
+
body: JSON.stringify({ visitorId: crypto.randomUUID() })
|
|
274
|
+
});
|
|
275
|
+
const options = await optionsRes.json();
|
|
276
|
+
if (options.error) {
|
|
277
|
+
alert('Error: ' + options.error);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Convert base64url to ArrayBuffer
|
|
282
|
+
options.challenge = base64urlToBuffer(options.challenge);
|
|
283
|
+
if (options.allowCredentials) {
|
|
284
|
+
options.allowCredentials = options.allowCredentials.map(c => ({
|
|
285
|
+
...c,
|
|
286
|
+
id: base64urlToBuffer(c.id)
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Prompt user for passkey
|
|
291
|
+
const credential = await navigator.credentials.get({ publicKey: options });
|
|
292
|
+
|
|
293
|
+
// Send response to server
|
|
294
|
+
const verifyRes = await fetch('/idp/passkey/login/verify', {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: { 'Content-Type': 'application/json' },
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
challengeKey: options.challengeKey,
|
|
299
|
+
credential: {
|
|
300
|
+
id: credential.id,
|
|
301
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
302
|
+
type: credential.type,
|
|
303
|
+
response: {
|
|
304
|
+
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
|
305
|
+
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
|
|
306
|
+
signature: bufferToBase64url(credential.response.signature),
|
|
307
|
+
userHandle: credential.response.userHandle
|
|
308
|
+
? bufferToBase64url(credential.response.userHandle)
|
|
309
|
+
: null
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await verifyRes.json();
|
|
316
|
+
if (result.success) {
|
|
317
|
+
// Complete the OIDC interaction - build URL safely
|
|
318
|
+
const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(result.accountId);
|
|
319
|
+
window.location.href = redirectUrl;
|
|
320
|
+
} else {
|
|
321
|
+
alert('Passkey authentication failed: ' + (result.error || 'Unknown error'));
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err.name === 'NotAllowedError') {
|
|
325
|
+
// User cancelled - do nothing
|
|
326
|
+
} else {
|
|
327
|
+
console.error('Passkey error:', err);
|
|
328
|
+
alert('Passkey authentication failed: ' + err.message);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function base64urlToBuffer(base64url) {
|
|
334
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
335
|
+
const padLen = (4 - base64.length % 4) % 4;
|
|
336
|
+
const padded = base64 + '='.repeat(padLen);
|
|
337
|
+
const binary = atob(padded);
|
|
338
|
+
const bytes = new Uint8Array(binary.length);
|
|
339
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
340
|
+
return bytes.buffer;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function bufferToBase64url(buffer) {
|
|
344
|
+
const bytes = new Uint8Array(buffer);
|
|
345
|
+
let binary = '';
|
|
346
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
347
|
+
return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, '');
|
|
348
|
+
}
|
|
349
|
+
</script>
|
|
350
|
+
` : '';
|
|
351
|
+
|
|
352
|
+
const schnorrScript = schnorrEnabled ? `
|
|
353
|
+
<script>
|
|
354
|
+
async function loginWithSchnorr() {
|
|
355
|
+
const btn = document.getElementById('schnorrBtn');
|
|
356
|
+
|
|
357
|
+
// Check for NIP-07 extension (window.nostr)
|
|
358
|
+
if (typeof window.nostr === 'undefined') {
|
|
359
|
+
alert('No Schnorr signer found. Please install a NIP-07 compatible extension like Podkey, nos2x, or Alby.');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
btn.disabled = true;
|
|
364
|
+
btn.textContent = 'Signing...';
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
// Get the current URL for the auth event
|
|
368
|
+
const authUrl = window.location.origin + '/idp/interaction/${safeUid}/schnorr-login';
|
|
369
|
+
|
|
370
|
+
// Create NIP-98 event (kind 27235)
|
|
371
|
+
const event = {
|
|
372
|
+
kind: 27235,
|
|
373
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
374
|
+
tags: [
|
|
375
|
+
['u', authUrl],
|
|
376
|
+
['method', 'POST']
|
|
377
|
+
],
|
|
378
|
+
content: ''
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// Sign with NIP-07 extension
|
|
382
|
+
const signedEvent = await window.nostr.signEvent(event);
|
|
383
|
+
|
|
384
|
+
// Send to server
|
|
385
|
+
const response = await fetch(authUrl, {
|
|
386
|
+
method: 'POST',
|
|
387
|
+
headers: {
|
|
388
|
+
'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent))
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const result = await response.json();
|
|
393
|
+
|
|
394
|
+
if (result.success && result.redirectUrl) {
|
|
395
|
+
window.location.href = result.redirectUrl;
|
|
396
|
+
} else if (result.error) {
|
|
397
|
+
alert('Schnorr login failed: ' + result.error);
|
|
398
|
+
btn.disabled = false;
|
|
399
|
+
btn.textContent = 'Sign in with Schnorr';
|
|
400
|
+
} else {
|
|
401
|
+
alert('Schnorr login failed: Unknown error');
|
|
402
|
+
btn.disabled = false;
|
|
403
|
+
btn.textContent = 'Sign in with Schnorr';
|
|
404
|
+
}
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error('Schnorr login error:', err);
|
|
407
|
+
if (err.message && err.message.includes('User rejected')) {
|
|
408
|
+
// User cancelled signing - do nothing
|
|
409
|
+
} else {
|
|
410
|
+
alert('Schnorr login failed: ' + err.message);
|
|
411
|
+
}
|
|
412
|
+
btn.disabled = false;
|
|
413
|
+
btn.textContent = 'Sign in with Schnorr';
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
</script>
|
|
417
|
+
` : '';
|
|
165
418
|
|
|
166
419
|
return `
|
|
167
420
|
<!DOCTYPE html>
|
|
@@ -185,6 +438,8 @@ export function loginPage(uid, clientId, error = null) {
|
|
|
185
438
|
|
|
186
439
|
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
187
440
|
|
|
441
|
+
${ssoSection}
|
|
442
|
+
|
|
188
443
|
<form method="POST" action="/idp/interaction/${uid}/login">
|
|
189
444
|
<label for="username">Username</label>
|
|
190
445
|
<input type="text" id="username" name="username" required autofocus placeholder="Your username">
|
|
@@ -203,6 +458,8 @@ export function loginPage(uid, clientId, error = null) {
|
|
|
203
458
|
Don't have an account? <a href="/idp/register?uid=${uid}" style="color: #0066cc;">Register</a>
|
|
204
459
|
</p>
|
|
205
460
|
</div>
|
|
461
|
+
${passkeyScript}
|
|
462
|
+
${schnorrScript}
|
|
206
463
|
</body>
|
|
207
464
|
</html>
|
|
208
465
|
`;
|
|
@@ -349,6 +606,162 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
|
|
|
349
606
|
`;
|
|
350
607
|
}
|
|
351
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Passkey prompt page - shown after password login to encourage passkey setup
|
|
611
|
+
*/
|
|
612
|
+
export function passkeyPromptPage(uid, accountId) {
|
|
613
|
+
const safeUid = escapeJs(uid);
|
|
614
|
+
const safeAccountId = escapeJs(accountId);
|
|
615
|
+
// Pre-escape the SVG for innerHTML assignment (no user data, just static SVG)
|
|
616
|
+
const passkeyIconEscaped = passkeyIcon.replace(/'/g, "\\'").replace(/\n/g, '');
|
|
617
|
+
|
|
618
|
+
return `
|
|
619
|
+
<!DOCTYPE html>
|
|
620
|
+
<html lang="en">
|
|
621
|
+
<head>
|
|
622
|
+
<meta charset="UTF-8">
|
|
623
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
624
|
+
<title>Add a Passkey - Solid IdP</title>
|
|
625
|
+
<style>${styles}</style>
|
|
626
|
+
</head>
|
|
627
|
+
<body>
|
|
628
|
+
<div class="container">
|
|
629
|
+
<div class="logo">${solidLogo}</div>
|
|
630
|
+
<h1>Add a Passkey?</h1>
|
|
631
|
+
<p class="subtitle">Sign in faster next time</p>
|
|
632
|
+
|
|
633
|
+
<div class="client-info">
|
|
634
|
+
<div class="client-name">Passkeys are more secure</div>
|
|
635
|
+
<div class="client-uri">Use Touch ID, Face ID, or a security key instead of your password</div>
|
|
636
|
+
</div>
|
|
637
|
+
|
|
638
|
+
<button type="button" class="btn btn-passkey" onclick="registerPasskey()" id="addBtn">
|
|
639
|
+
${passkeyIcon}
|
|
640
|
+
Add Passkey
|
|
641
|
+
</button>
|
|
642
|
+
|
|
643
|
+
<form method="GET" action="/idp/interaction/${escapeHtml(uid)}/passkey-skip">
|
|
644
|
+
<button type="submit" class="btn btn-secondary">Skip for now</button>
|
|
645
|
+
</form>
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
<script>
|
|
649
|
+
var INTERACTION_UID = '${safeUid}';
|
|
650
|
+
var ACCOUNT_ID = '${safeAccountId}';
|
|
651
|
+
var PASSKEY_ICON = '${passkeyIconEscaped}';
|
|
652
|
+
|
|
653
|
+
async function registerPasskey() {
|
|
654
|
+
const btn = document.getElementById('addBtn');
|
|
655
|
+
btn.disabled = true;
|
|
656
|
+
btn.textContent = 'Setting up...';
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
// Get registration options
|
|
660
|
+
const optionsRes = await fetch('/idp/passkey/register/options', {
|
|
661
|
+
method: 'POST',
|
|
662
|
+
headers: { 'Content-Type': 'application/json' },
|
|
663
|
+
body: JSON.stringify({ accountId: ACCOUNT_ID })
|
|
664
|
+
});
|
|
665
|
+
const options = await optionsRes.json();
|
|
666
|
+
if (options.error) {
|
|
667
|
+
alert('Error: ' + options.error);
|
|
668
|
+
btn.disabled = false;
|
|
669
|
+
btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Save challengeKey for verification
|
|
674
|
+
const challengeKey = options.challengeKey;
|
|
675
|
+
|
|
676
|
+
// Convert base64url to ArrayBuffer
|
|
677
|
+
options.challenge = base64urlToBuffer(options.challenge);
|
|
678
|
+
options.user.id = base64urlToBuffer(options.user.id);
|
|
679
|
+
if (options.excludeCredentials) {
|
|
680
|
+
options.excludeCredentials = options.excludeCredentials.map(c => ({
|
|
681
|
+
...c,
|
|
682
|
+
id: base64urlToBuffer(c.id)
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Prompt user to create passkey
|
|
687
|
+
const credential = await navigator.credentials.create({ publicKey: options });
|
|
688
|
+
|
|
689
|
+
// Send response to server
|
|
690
|
+
const verifyRes = await fetch('/idp/passkey/register/verify', {
|
|
691
|
+
method: 'POST',
|
|
692
|
+
headers: { 'Content-Type': 'application/json' },
|
|
693
|
+
body: JSON.stringify({
|
|
694
|
+
accountId: ACCOUNT_ID,
|
|
695
|
+
challengeKey: challengeKey,
|
|
696
|
+
credential: {
|
|
697
|
+
id: credential.id,
|
|
698
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
699
|
+
type: credential.type,
|
|
700
|
+
response: {
|
|
701
|
+
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
|
702
|
+
attestationObject: bufferToBase64url(credential.response.attestationObject),
|
|
703
|
+
transports: credential.response.getTransports ? credential.response.getTransports() : []
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
name: detectDeviceName()
|
|
707
|
+
})
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
const result = await verifyRes.json();
|
|
711
|
+
if (result.success) {
|
|
712
|
+
// Passkey added, continue to app - build URL safely
|
|
713
|
+
const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(ACCOUNT_ID);
|
|
714
|
+
window.location.href = redirectUrl;
|
|
715
|
+
} else {
|
|
716
|
+
alert('Failed to add passkey: ' + (result.error || 'Unknown error'));
|
|
717
|
+
btn.disabled = false;
|
|
718
|
+
btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
|
|
719
|
+
}
|
|
720
|
+
} catch (err) {
|
|
721
|
+
if (err.name === 'NotAllowedError') {
|
|
722
|
+
// User cancelled
|
|
723
|
+
} else {
|
|
724
|
+
console.error('Passkey error:', err);
|
|
725
|
+
alert('Failed to add passkey: ' + err.message);
|
|
726
|
+
}
|
|
727
|
+
btn.disabled = false;
|
|
728
|
+
btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function detectDeviceName() {
|
|
733
|
+
const ua = navigator.userAgent;
|
|
734
|
+
if (/iPhone/.test(ua)) return 'iPhone';
|
|
735
|
+
if (/iPad/.test(ua)) return 'iPad';
|
|
736
|
+
if (/Mac/.test(ua)) return 'Mac';
|
|
737
|
+
if (/Android/.test(ua)) return 'Android';
|
|
738
|
+
if (/Windows/.test(ua)) return 'Windows';
|
|
739
|
+
if (/Linux/.test(ua)) return 'Linux';
|
|
740
|
+
return 'Security Key';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function base64urlToBuffer(base64url) {
|
|
744
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
745
|
+
const padLen = (4 - base64.length % 4) % 4;
|
|
746
|
+
const padded = base64 + '='.repeat(padLen);
|
|
747
|
+
const binary = atob(padded);
|
|
748
|
+
const bytes = new Uint8Array(binary.length);
|
|
749
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
750
|
+
return bytes.buffer;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function bufferToBase64url(buffer) {
|
|
754
|
+
const bytes = new Uint8Array(buffer);
|
|
755
|
+
let binary = '';
|
|
756
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
757
|
+
return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, '');
|
|
758
|
+
}
|
|
759
|
+
</script>
|
|
760
|
+
</body>
|
|
761
|
+
</html>
|
|
762
|
+
`;
|
|
763
|
+
}
|
|
764
|
+
|
|
352
765
|
/**
|
|
353
766
|
* Escape HTML to prevent XSS
|
|
354
767
|
*/
|