@wizzlethorpe/vaults 0.4.0 → 0.6.0
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/dist/build.js +253 -45
- package/dist/build.js.map +1 -1
- package/dist/commands/init.js +6 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/patreon.js +413 -0
- package/dist/commands/patreon.js.map +1 -0
- package/dist/commands/preview.js +7 -0
- package/dist/commands/preview.js.map +1 -1
- package/dist/commands/push.js +7 -0
- package/dist/commands/push.js.map +1 -1
- package/dist/config.js +79 -4
- package/dist/config.js.map +1 -1
- package/dist/dotenv.js +112 -0
- package/dist/dotenv.js.map +1 -0
- package/dist/favicon.js +8 -31
- package/dist/favicon.js.map +1 -1
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -1
- package/dist/render/auth-template.js +434 -37
- package/dist/render/auth-template.js.map +1 -1
- package/dist/render/bases.js +64 -5
- package/dist/render/bases.js.map +1 -1
- package/dist/render/callouts.js +4 -1
- package/dist/render/callouts.js.map +1 -1
- package/dist/render/cover.js +41 -0
- package/dist/render/cover.js.map +1 -0
- package/dist/render/layout.js +190 -31
- package/dist/render/layout.js.map +1 -1
- package/dist/render/patreon-match.js +42 -0
- package/dist/render/patreon-match.js.map +1 -0
- package/dist/render/pipeline.js +11 -5
- package/dist/render/pipeline.js.map +1 -1
- package/dist/render/styles.js +277 -41
- package/dist/render/styles.js.map +1 -1
- package/dist/sensitive.js +60 -0
- package/dist/sensitive.js.map +1 -0
- package/dist/settings.js +15 -0
- package/dist/settings.js.map +1 -1
- package/package.json +1 -1
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
export function renderAuthMiddleware(cfg) {
|
|
6
6
|
const rolesLiteral = JSON.stringify(cfg.roles);
|
|
7
7
|
const passwordsLiteral = JSON.stringify(cfg.rolePasswords);
|
|
8
|
+
const patreonLiteral = JSON.stringify(cfg.patreon ?? null);
|
|
8
9
|
return `// Auto-generated by the vaults CLI. Do not edit by hand.
|
|
9
10
|
// Roles, password hashes, and routing live here so the deployed Function
|
|
10
11
|
// is fully self-contained and doesn't need any other binding besides
|
|
11
|
-
// SESSION_SECRET (set via wrangler secret).
|
|
12
|
+
// SESSION_SECRET (set via wrangler secret). When Patreon is configured,
|
|
13
|
+
// PATREON_CLIENT_SECRET also has to be set as a Wrangler secret.
|
|
12
14
|
|
|
13
15
|
const ROLES = ${rolesLiteral};
|
|
14
16
|
const PASSWORDS = ${passwordsLiteral};
|
|
17
|
+
const PATREON = ${patreonLiteral};
|
|
15
18
|
const COOKIE_NAME = "vault_role";
|
|
16
19
|
// Non-HttpOnly companion cookie carrying the role name only; the auth check
|
|
17
20
|
// uses COOKIE_NAME (which is signed and HttpOnly), this one is purely for UI.
|
|
@@ -21,6 +24,12 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
|
|
21
24
|
// since refreshing means reopening a browser-based approval flow.
|
|
22
25
|
const BEARER_MAX_AGE = 60 * 60 * 24 * 90; // 90 days
|
|
23
26
|
const PBKDF2_DEFAULT_ITERATIONS = 100000;
|
|
27
|
+
// Same shape as a real PBKDF2 hash (iterations:saltHex:hashHex with the
|
|
28
|
+
// expected lengths) but with all-zero salt + hash. Used to keep the
|
|
29
|
+
// /login response time consistent when the role doesn't exist or has no
|
|
30
|
+
// password — running PBKDF2 against this dummy means an attacker can't
|
|
31
|
+
// distinguish "role exists" from "role doesn't exist" by timing.
|
|
32
|
+
const DUMMY_PASSWORD_HASH = "100000:" + "0".repeat(32) + ":" + "0".repeat(64);
|
|
24
33
|
|
|
25
34
|
// ── Public middleware entry ────────────────────────────────────────────────
|
|
26
35
|
|
|
@@ -76,6 +85,18 @@ export const onRequest = async (ctx) => {
|
|
|
76
85
|
return handleConnectApprove(request, env);
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
// /auth/patreon/* — only mounted when the build saw a Patreon config.
|
|
89
|
+
// Issues the same signed session cookie as password login on success;
|
|
90
|
+
// failure paths bounce back to /login with an error param.
|
|
91
|
+
if (PATREON) {
|
|
92
|
+
if (url.pathname === "/auth/patreon/start" && request.method === "GET") {
|
|
93
|
+
return handlePatreonStart(request, env);
|
|
94
|
+
}
|
|
95
|
+
if (url.pathname === "/auth/patreon/callback" && request.method === "GET") {
|
|
96
|
+
return handlePatreonCallback(request, env);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
// /_batch; bulk source fetch for sync clients (Foundry). Body is
|
|
80
101
|
// newline-separated paths under text/plain so the request stays CORS-
|
|
81
102
|
// simple (no preflight per file → no OPTIONS rate-limit). Response is
|
|
@@ -163,13 +184,20 @@ async function handleLogin(request, env) {
|
|
|
163
184
|
const password = String(form.get("password") || "");
|
|
164
185
|
const next = safeNext(form.get("next"));
|
|
165
186
|
|
|
166
|
-
|
|
167
|
-
|
|
187
|
+
// Generic "auth_failed" error for both "no such role / no password set"
|
|
188
|
+
// and "wrong password" branches — distinct codes would let an attacker
|
|
189
|
+
// enumerate which roles exist and which have passwords. Constant-time
|
|
190
|
+
// PBKDF2 comparison handles the timing side; this handles the message
|
|
191
|
+
// side. Run verifyPassword against a dummy hash on the no-role branch
|
|
192
|
+
// so the response time profile stays roughly the same.
|
|
193
|
+
const passwordHash = (ROLES.includes(role) && PASSWORDS[role])
|
|
194
|
+
? PASSWORDS[role]
|
|
195
|
+
: DUMMY_PASSWORD_HASH;
|
|
196
|
+
const ok = await verifyPassword(password, passwordHash);
|
|
197
|
+
if (!ok || !ROLES.includes(role) || !PASSWORDS[role]) {
|
|
198
|
+
return loginRedirect(next, "auth_failed");
|
|
168
199
|
}
|
|
169
200
|
|
|
170
|
-
const ok = await verifyPassword(password, PASSWORDS[role]);
|
|
171
|
-
if (!ok) return loginRedirect(next, "wrong_password");
|
|
172
|
-
|
|
173
201
|
const cookie = await signSessionCookie(role, env.SESSION_SECRET);
|
|
174
202
|
const headers = new Headers({ Location: next });
|
|
175
203
|
headers.append("Set-Cookie", cookie);
|
|
@@ -296,8 +324,14 @@ async function handleConnectGet(request, env) {
|
|
|
296
324
|
const returnTo = url.searchParams.get("return_to") || "";
|
|
297
325
|
const app = url.searchParams.get("app") || "an external app";
|
|
298
326
|
const state = url.searchParams.get("state") || "";
|
|
299
|
-
|
|
300
|
-
|
|
327
|
+
const delivery = url.searchParams.get("delivery") === "copy" ? "copy" : "redirect";
|
|
328
|
+
|
|
329
|
+
// The "redirect" delivery mode (legacy iframe/popup flow) round-trips a
|
|
330
|
+
// token to a host app via return_to. The "copy" delivery mode is the
|
|
331
|
+
// GitHub-CLI-style device flow: the user copy-pastes the token themselves,
|
|
332
|
+
// so return_to is irrelevant and Patreon login works because we're never
|
|
333
|
+
// inside an iframe.
|
|
334
|
+
if (delivery === "redirect" && (!returnTo || !isValidReturnTo(returnTo))) {
|
|
301
335
|
return new Response("Invalid or missing return_to. Must be an http(s) URL.", { status: 400 });
|
|
302
336
|
}
|
|
303
337
|
|
|
@@ -313,10 +347,10 @@ async function handleConnectGet(request, env) {
|
|
|
313
347
|
});
|
|
314
348
|
}
|
|
315
349
|
|
|
316
|
-
const returnHost = (() => {
|
|
350
|
+
const returnHost = delivery === "copy" ? "" : (() => {
|
|
317
351
|
try { return new URL(returnTo).host; } catch { return returnTo; }
|
|
318
352
|
})();
|
|
319
|
-
const html = renderApprovePage({ app, role, returnTo, returnHost, state });
|
|
353
|
+
const html = renderApprovePage({ app, role, returnTo, returnHost, state, delivery });
|
|
320
354
|
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
321
355
|
}
|
|
322
356
|
|
|
@@ -324,8 +358,10 @@ async function handleConnectApprove(request, env) {
|
|
|
324
358
|
const form = await request.formData();
|
|
325
359
|
const returnTo = String(form.get("return_to") || "");
|
|
326
360
|
const state = String(form.get("state") || "");
|
|
361
|
+
const delivery = String(form.get("delivery") || "") === "copy" ? "copy" : "redirect";
|
|
362
|
+
const app = String(form.get("app") || "an external app");
|
|
327
363
|
|
|
328
|
-
if (!returnTo || !isValidReturnTo(returnTo)) {
|
|
364
|
+
if (delivery === "redirect" && (!returnTo || !isValidReturnTo(returnTo))) {
|
|
329
365
|
return new Response("Invalid return_to.", { status: 400 });
|
|
330
366
|
}
|
|
331
367
|
const role = await readRole(request, env);
|
|
@@ -335,13 +371,12 @@ async function handleConnectApprove(request, env) {
|
|
|
335
371
|
}
|
|
336
372
|
|
|
337
373
|
const token = await signToken(role, env.SESSION_SECRET, BEARER_MAX_AGE);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const html = renderConnectDeliveryPage({ token, state, returnTo });
|
|
374
|
+
const html = delivery === "copy"
|
|
375
|
+
? renderConnectCopyPage({ token, role, app })
|
|
376
|
+
// Legacy delivery: postMessage to parent/opener; falls back to top-
|
|
377
|
+
// level redirect with the token in the query string. Kept for any
|
|
378
|
+
// existing iframe-based clients; new clients should use delivery=copy.
|
|
379
|
+
: renderConnectDeliveryPage({ token, state, returnTo });
|
|
345
380
|
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
346
381
|
}
|
|
347
382
|
|
|
@@ -413,12 +448,15 @@ function isValidReturnTo(s) {
|
|
|
413
448
|
} catch { return false; }
|
|
414
449
|
}
|
|
415
450
|
|
|
416
|
-
function renderApprovePage({ app, role, returnTo, returnHost, state }) {
|
|
451
|
+
function renderApprovePage({ app, role, returnTo, returnHost, state, delivery }) {
|
|
417
452
|
const escapedApp = escHtml(app);
|
|
418
453
|
const escapedRole = escHtml(role);
|
|
419
454
|
const escapedHost = escHtml(returnHost);
|
|
420
455
|
const escapedReturnTo = escAttr(returnTo);
|
|
421
456
|
const escapedState = escAttr(state);
|
|
457
|
+
const escapedDelivery = escAttr(delivery);
|
|
458
|
+
const escapedAppAttr = escAttr(app);
|
|
459
|
+
const isCopy = delivery === "copy";
|
|
422
460
|
return \`<!doctype html>
|
|
423
461
|
<html lang="en">
|
|
424
462
|
<head>
|
|
@@ -460,14 +498,15 @@ function renderApprovePage({ app, role, returnTo, returnHost, state }) {
|
|
|
460
498
|
<strong>\${escapedApp}</strong> wants access to your vault as
|
|
461
499
|
<strong>\${escapedRole}</strong>.
|
|
462
500
|
</p>
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
</p>
|
|
501
|
+
\${isCopy
|
|
502
|
+
? \`<p class="info">After approval, this page will display a token to copy and paste back into <strong>\${escapedApp}</strong>.</p>\`
|
|
503
|
+
: \`<p class="info">After approval, you'll be redirected to:</p>
|
|
504
|
+
<code class="return-host">\${escapedHost}</code>
|
|
505
|
+
<p class="warning">⚠ Verify the destination above looks right. If you didn't initiate this request, click Deny.</p>\`}
|
|
469
506
|
<input type="hidden" name="return_to" value="\${escapedReturnTo}">
|
|
470
507
|
<input type="hidden" name="state" value="\${escapedState}">
|
|
508
|
+
<input type="hidden" name="delivery" value="\${escapedDelivery}">
|
|
509
|
+
<input type="hidden" name="app" value="\${escapedAppAttr}">
|
|
471
510
|
<div class="actions">
|
|
472
511
|
<a class="deny" href="/">Deny</a>
|
|
473
512
|
<button type="submit">Approve</button>
|
|
@@ -477,9 +516,280 @@ function renderApprovePage({ app, role, returnTo, returnHost, state }) {
|
|
|
477
516
|
</html>\`;
|
|
478
517
|
}
|
|
479
518
|
|
|
519
|
+
function renderConnectCopyPage({ token, role, app }) {
|
|
520
|
+
const escapedApp = escHtml(app);
|
|
521
|
+
const escapedRole = escHtml(role);
|
|
522
|
+
const escapedToken = escAttr(token);
|
|
523
|
+
return \`<!doctype html>
|
|
524
|
+
<html lang="en">
|
|
525
|
+
<head>
|
|
526
|
+
<meta charset="utf-8">
|
|
527
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
528
|
+
<title>Authorised \${escapedApp}</title>
|
|
529
|
+
<link rel="stylesheet" href="/styles.css">
|
|
530
|
+
<style>
|
|
531
|
+
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
532
|
+
.copy-card {
|
|
533
|
+
max-width: 32rem; width: 90%; padding: 2rem;
|
|
534
|
+
border: 1px solid var(--rule); border-radius: 6px; background: var(--bg);
|
|
535
|
+
}
|
|
536
|
+
.copy-card h1 { margin: 0 0 0.75rem; font-size: 1.3rem; }
|
|
537
|
+
.copy-card .info { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.75rem; }
|
|
538
|
+
.copy-card .info strong { color: var(--accent); }
|
|
539
|
+
.copy-card .token {
|
|
540
|
+
display: block; width: 100%; box-sizing: border-box;
|
|
541
|
+
margin: 0.75rem 0;
|
|
542
|
+
padding: 0.75rem 0.85rem;
|
|
543
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
544
|
+
font-size: 0.82rem; line-height: 1.45;
|
|
545
|
+
background: var(--wikilink-bg);
|
|
546
|
+
color: var(--fg);
|
|
547
|
+
border: 1px solid var(--rule); border-radius: 4px;
|
|
548
|
+
word-break: break-all; resize: vertical;
|
|
549
|
+
}
|
|
550
|
+
.copy-card .copy-btn {
|
|
551
|
+
width: 100%; padding: 0.6rem 1rem; margin-top: 0.5rem; font: inherit; font-size: 0.95rem;
|
|
552
|
+
background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 4px; cursor: pointer;
|
|
553
|
+
}
|
|
554
|
+
.copy-card .copy-status {
|
|
555
|
+
text-align: center; color: var(--muted); font-size: 0.78rem; margin-top: 0.5rem; min-height: 1em;
|
|
556
|
+
}
|
|
557
|
+
.copy-card .copy-status.ok { color: #2a8b58; }
|
|
558
|
+
.copy-card ol { font-size: 0.88rem; color: var(--muted); padding-left: 1.25rem; margin: 0.75rem 0 0; }
|
|
559
|
+
.copy-card ol li { margin: 0.25rem 0; }
|
|
560
|
+
</style>
|
|
561
|
+
</head>
|
|
562
|
+
<body>
|
|
563
|
+
<div class="copy-card">
|
|
564
|
+
<h1>✓ Authorised \${escapedApp}</h1>
|
|
565
|
+
<p class="info">
|
|
566
|
+
A token for the <strong>\${escapedRole}</strong> tier has been generated.
|
|
567
|
+
Copy it back into <strong>\${escapedApp}</strong> to complete sign-in.
|
|
568
|
+
</p>
|
|
569
|
+
<textarea class="token" id="token" readonly rows="4" onclick="this.select()">\${escapedToken}</textarea>
|
|
570
|
+
<button type="button" class="copy-btn" id="copy-btn">Copy token</button>
|
|
571
|
+
<p class="copy-status" id="copy-status"></p>
|
|
572
|
+
<ol>
|
|
573
|
+
<li>Click <strong>Copy token</strong> above.</li>
|
|
574
|
+
<li>Switch back to \${escapedApp}.</li>
|
|
575
|
+
<li>Paste into the token field and confirm.</li>
|
|
576
|
+
</ol>
|
|
577
|
+
</div>
|
|
578
|
+
<script>
|
|
579
|
+
(function () {
|
|
580
|
+
var btn = document.getElementById('copy-btn');
|
|
581
|
+
var ta = document.getElementById('token');
|
|
582
|
+
var status = document.getElementById('copy-status');
|
|
583
|
+
btn.addEventListener('click', async function () {
|
|
584
|
+
try {
|
|
585
|
+
await navigator.clipboard.writeText(ta.value);
|
|
586
|
+
status.textContent = 'Copied to clipboard.';
|
|
587
|
+
status.className = 'copy-status ok';
|
|
588
|
+
} catch (e) {
|
|
589
|
+
ta.select();
|
|
590
|
+
status.textContent = 'Press Ctrl/Cmd-C to copy.';
|
|
591
|
+
status.className = 'copy-status';
|
|
592
|
+
}
|
|
593
|
+
setTimeout(function () { status.textContent = ''; }, 4000);
|
|
594
|
+
});
|
|
595
|
+
})();
|
|
596
|
+
</script>
|
|
597
|
+
</body>
|
|
598
|
+
</html>\`;
|
|
599
|
+
}
|
|
600
|
+
|
|
480
601
|
function escHtml(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); }
|
|
481
602
|
function escAttr(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
|
|
482
603
|
|
|
604
|
+
// ── Patreon OAuth (optional, additive to password login) ─────────────────
|
|
605
|
+
// Bound only when PATREON !== null (i.e. the build saw a configured
|
|
606
|
+
// patreon block with at least one role mapped to a tier). Issues the same
|
|
607
|
+
// signed session cookie as password login on success; bounces back to
|
|
608
|
+
// /login with an error param on failure. CSRF state rides through a short-
|
|
609
|
+
// lived signed cookie so the callback can verify the round-trip.
|
|
610
|
+
|
|
611
|
+
const PATREON_STATE_COOKIE = "vault_patreon_state";
|
|
612
|
+
const PATREON_STATE_TTL = 600; // 10 minutes — long enough for the user to authorise
|
|
613
|
+
|
|
614
|
+
async function handlePatreonStart(request, env) {
|
|
615
|
+
if (!env.PATREON_CLIENT_SECRET) {
|
|
616
|
+
return new Response("Patreon login is misconfigured: PATREON_CLIENT_SECRET secret is missing.", { status: 500 });
|
|
617
|
+
}
|
|
618
|
+
const url = new URL(request.url);
|
|
619
|
+
const next = safeNext(url.searchParams.get("next"));
|
|
620
|
+
// Bind state to next so a forged callback can't drop the user on an
|
|
621
|
+
// attacker-chosen URL post-login. The signed cookie carries both back.
|
|
622
|
+
const stateValue = crypto.randomUUID();
|
|
623
|
+
const stateCookie = await signStateCookie({ state: stateValue, next }, env.SESSION_SECRET);
|
|
624
|
+
|
|
625
|
+
const redirectUri = url.origin + "/auth/patreon/callback";
|
|
626
|
+
const authorize = new URL("https://www.patreon.com/oauth2/authorize");
|
|
627
|
+
authorize.searchParams.set("response_type", "code");
|
|
628
|
+
authorize.searchParams.set("client_id", PATREON.clientId);
|
|
629
|
+
authorize.searchParams.set("redirect_uri", redirectUri);
|
|
630
|
+
authorize.searchParams.set("scope", "identity identity.memberships");
|
|
631
|
+
authorize.searchParams.set("state", stateValue);
|
|
632
|
+
|
|
633
|
+
const headers = new Headers({ Location: authorize.toString() });
|
|
634
|
+
headers.append("Set-Cookie", stateCookie);
|
|
635
|
+
return new Response(null, { status: 302, headers });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function handlePatreonCallback(request, env) {
|
|
639
|
+
if (!env.PATREON_CLIENT_SECRET) {
|
|
640
|
+
return loginRedirect("/", "patreon_misconfigured");
|
|
641
|
+
}
|
|
642
|
+
const url = new URL(request.url);
|
|
643
|
+
const code = url.searchParams.get("code");
|
|
644
|
+
const state = url.searchParams.get("state");
|
|
645
|
+
if (!code || !state) return loginRedirect("/", "patreon_failed");
|
|
646
|
+
|
|
647
|
+
const stateData = await readStateCookie(request, env.SESSION_SECRET);
|
|
648
|
+
// Always clear the state cookie regardless of outcome.
|
|
649
|
+
const clearState = clearCookieVariants(PATREON_STATE_COOKIE);
|
|
650
|
+
|
|
651
|
+
if (!stateData || stateData.state !== state) {
|
|
652
|
+
const headers = new Headers({ Location: "/login?error=patreon_state_mismatch" });
|
|
653
|
+
for (const v of clearState) headers.append("Set-Cookie", v);
|
|
654
|
+
return new Response(null, { status: 302, headers });
|
|
655
|
+
}
|
|
656
|
+
const next = stateData.next || "/";
|
|
657
|
+
|
|
658
|
+
// Exchange code → access token. The token is short-lived; we don't store
|
|
659
|
+
// it. Only used once, immediately, to look up the visitor's memberships.
|
|
660
|
+
let access;
|
|
661
|
+
try {
|
|
662
|
+
access = await patreonExchangeCode(code, url.origin + "/auth/patreon/callback", env.PATREON_CLIENT_SECRET);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
console.warn("Patreon token exchange failed:", err);
|
|
665
|
+
const headers = new Headers({ Location: "/login?error=patreon_token_exchange" });
|
|
666
|
+
for (const v of clearState) headers.append("Set-Cookie", v);
|
|
667
|
+
return new Response(null, { status: 302, headers });
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
let identity;
|
|
671
|
+
try { identity = await patreonFetchIdentity(access); }
|
|
672
|
+
catch (err) {
|
|
673
|
+
console.warn("Patreon identity fetch failed:", err);
|
|
674
|
+
const headers = new Headers({ Location: "/login?error=patreon_identity" });
|
|
675
|
+
for (const v of clearState) headers.append("Set-Cookie", v);
|
|
676
|
+
return new Response(null, { status: 302, headers });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const role = matchHighestRole(identity, PATREON.campaignId, PATREON.tiers);
|
|
680
|
+
if (!role) {
|
|
681
|
+
// Authenticated, but no qualifying tier on this campaign. Send them
|
|
682
|
+
// back to /login with a specific error so the UI can explain.
|
|
683
|
+
const headers = new Headers({ Location: "/login?error=patreon_no_tier&next=" + encodeURIComponent(next) });
|
|
684
|
+
for (const v of clearState) headers.append("Set-Cookie", v);
|
|
685
|
+
return new Response(null, { status: 302, headers });
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const cookie = await signSessionCookie(role, env.SESSION_SECRET);
|
|
689
|
+
const headers = new Headers({ Location: next });
|
|
690
|
+
headers.append("Set-Cookie", cookie);
|
|
691
|
+
headers.append("Set-Cookie", DISPLAY_COOKIE_NAME + "=" + encodeURIComponent(role)
|
|
692
|
+
+ "; Path=/; Secure; SameSite=None; Partitioned; Max-Age=" + COOKIE_MAX_AGE);
|
|
693
|
+
for (const v of clearState) headers.append("Set-Cookie", v);
|
|
694
|
+
return new Response(null, { status: 302, headers });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Walk the visitor's identity payload, find memberships scoped to OUR
|
|
699
|
+
* campaign, and return the highest-ranked role whose mapped tier ID
|
|
700
|
+
* appears in the visitor's currently-entitled tiers. Returns null if no
|
|
701
|
+
* tier matches — the visitor may be a patron of someone else, or the
|
|
702
|
+
* creator's own account, or just not entitled to any mapped tier.
|
|
703
|
+
*/
|
|
704
|
+
function matchHighestRole(identity, campaignId, tiers) {
|
|
705
|
+
const memberships = (identity?.included || []).filter((it) => it.type === "member");
|
|
706
|
+
// Filter to memberships on our campaign (Patreon nests campaign under relationships).
|
|
707
|
+
const ourCampaignMemberships = memberships.filter((m) => {
|
|
708
|
+
const camp = m.relationships?.campaign?.data;
|
|
709
|
+
return camp && camp.type === "campaign" && String(camp.id) === String(campaignId);
|
|
710
|
+
});
|
|
711
|
+
const entitledTierIds = new Set();
|
|
712
|
+
for (const m of ourCampaignMemberships) {
|
|
713
|
+
const ents = m.relationships?.currently_entitled_tiers?.data || [];
|
|
714
|
+
for (const t of ents) if (t.type === "tier") entitledTierIds.add(String(t.id));
|
|
715
|
+
}
|
|
716
|
+
if (entitledTierIds.size === 0) return null;
|
|
717
|
+
// Roles are ordered low → high; iterate from highest to find the best match.
|
|
718
|
+
for (let i = ROLES.length - 1; i >= 0; i--) {
|
|
719
|
+
const r = ROLES[i];
|
|
720
|
+
const tier = tiers[r];
|
|
721
|
+
if (tier && entitledTierIds.has(String(tier))) return r;
|
|
722
|
+
}
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function patreonExchangeCode(code, redirectUri, clientSecret) {
|
|
727
|
+
const body = new URLSearchParams({
|
|
728
|
+
grant_type: "authorization_code",
|
|
729
|
+
code,
|
|
730
|
+
client_id: PATREON.clientId,
|
|
731
|
+
client_secret: clientSecret,
|
|
732
|
+
redirect_uri: redirectUri,
|
|
733
|
+
});
|
|
734
|
+
const res = await fetch("https://www.patreon.com/api/oauth2/token", {
|
|
735
|
+
method: "POST",
|
|
736
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
737
|
+
body: body.toString(),
|
|
738
|
+
});
|
|
739
|
+
if (!res.ok) throw new Error("token exchange status " + res.status);
|
|
740
|
+
const data = await res.json();
|
|
741
|
+
if (!data.access_token) throw new Error("no access_token in response");
|
|
742
|
+
return data.access_token;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function patreonFetchIdentity(accessToken) {
|
|
746
|
+
// We need the visitor's memberships AND, for each membership, which tiers
|
|
747
|
+
// they're currently entitled to. Patreon's JSON:API include syntax pulls
|
|
748
|
+
// both into one round-trip.
|
|
749
|
+
const url = new URL("https://www.patreon.com/api/oauth2/v2/identity");
|
|
750
|
+
url.searchParams.set("include", "memberships,memberships.currently_entitled_tiers,memberships.campaign");
|
|
751
|
+
url.searchParams.set("fields[member]", "patron_status");
|
|
752
|
+
const res = await fetch(url.toString(), {
|
|
753
|
+
headers: { Authorization: "Bearer " + accessToken },
|
|
754
|
+
});
|
|
755
|
+
if (!res.ok) throw new Error("identity status " + res.status);
|
|
756
|
+
return await res.json();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// State cookie: stores { state, next } signed with SESSION_SECRET, used to
|
|
760
|
+
// verify the OAuth callback came from our /start handler and not a forgery.
|
|
761
|
+
// Lifetime is short (PATREON_STATE_TTL); kept distinct from the long-lived
|
|
762
|
+
// session cookie so a stolen state cookie can't impersonate a session.
|
|
763
|
+
|
|
764
|
+
async function signStateCookie(payload, secret) {
|
|
765
|
+
const exp = Math.floor(Date.now() / 1000) + PATREON_STATE_TTL;
|
|
766
|
+
const data = JSON.stringify({ ...payload, exp });
|
|
767
|
+
const sig = await hmac(data, secret);
|
|
768
|
+
const value = btoa(data) + "." + sig;
|
|
769
|
+
return PATREON_STATE_COOKIE + "=" + value
|
|
770
|
+
+ "; Path=/; Secure; SameSite=Lax; HttpOnly; Max-Age=" + PATREON_STATE_TTL;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function readStateCookie(request, secret) {
|
|
774
|
+
const cookies = parseCookie(request.headers.get("Cookie") || "");
|
|
775
|
+
const raw = cookies[PATREON_STATE_COOKIE];
|
|
776
|
+
if (!raw) return null;
|
|
777
|
+
const dot = raw.lastIndexOf(".");
|
|
778
|
+
if (dot < 0) return null;
|
|
779
|
+
const dataB64 = raw.slice(0, dot);
|
|
780
|
+
const sig = raw.slice(dot + 1);
|
|
781
|
+
let data;
|
|
782
|
+
try { data = atob(dataB64); }
|
|
783
|
+
catch { return null; }
|
|
784
|
+
const expected = await hmac(data, secret);
|
|
785
|
+
if (sig !== expected) return null;
|
|
786
|
+
let parsed;
|
|
787
|
+
try { parsed = JSON.parse(data); }
|
|
788
|
+
catch { return null; }
|
|
789
|
+
if (!parsed || typeof parsed.exp !== "number" || parsed.exp < Math.floor(Date.now() / 1000)) return null;
|
|
790
|
+
return parsed;
|
|
791
|
+
}
|
|
792
|
+
|
|
483
793
|
// ── Cookie + role lookup ──────────────────────────────────────────────────
|
|
484
794
|
|
|
485
795
|
async function readRole(request, env) {
|
|
@@ -610,9 +920,19 @@ function parseCookie(header) {
|
|
|
610
920
|
|
|
611
921
|
function isSharedAsset(pathname) {
|
|
612
922
|
// Allowlist of root-served files that are intentionally public to every
|
|
613
|
-
// visitor (no role gate). Everything else
|
|
614
|
-
//
|
|
615
|
-
//
|
|
923
|
+
// visitor (no role gate). Everything else — including images, .body.html
|
|
924
|
+
// fragments, .preview.json, the search index — goes through the variant
|
|
925
|
+
// rewrite so role-restricted content is structurally unreachable on
|
|
926
|
+
// under-tier deploys.
|
|
927
|
+
//
|
|
928
|
+
// SYNCHRONISATION POINT: every file the build writes to the deploy
|
|
929
|
+
// ROOT (vs. into _variants/<role>/) must be listed here, otherwise it
|
|
930
|
+
// 404s for every visitor. Build code that places root-level files:
|
|
931
|
+
// - styles.css / user.css — build.ts (writeFile join(outputDir, ...))
|
|
932
|
+
// - login.html — build.ts (multi-role only)
|
|
933
|
+
// - favicon.ico — build.ts (buildFavicon)
|
|
934
|
+
// - functions/_middleware.js — build.ts (multi-role only; not served)
|
|
935
|
+
// If you add another, add it both here AND in build.ts.
|
|
616
936
|
if (pathname === "/styles.css") return true;
|
|
617
937
|
if (pathname === "/user.css") return true;
|
|
618
938
|
if (pathname === "/login.html") return true;
|
|
@@ -648,19 +968,62 @@ export const LOGIN_HTML = `<!doctype html>
|
|
|
648
968
|
background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 4px; cursor: pointer;
|
|
649
969
|
}
|
|
650
970
|
.login-error { color: #b94a3a; font-size: 0.85rem; margin: 0.75rem 0 0; }
|
|
971
|
+
.login-divider {
|
|
972
|
+
display: flex; align-items: center; gap: 0.6rem;
|
|
973
|
+
margin: 1.25rem 0 1rem;
|
|
974
|
+
font-size: 0.78rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em;
|
|
975
|
+
}
|
|
976
|
+
.login-divider::before, .login-divider::after {
|
|
977
|
+
content: ""; flex: 1; height: 1px; background: var(--rule);
|
|
978
|
+
}
|
|
979
|
+
.patreon-btn {
|
|
980
|
+
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
|
981
|
+
width: 100%; padding: 0.55rem 1rem; font: inherit; font-size: 0.95rem;
|
|
982
|
+
background: #ff424d; color: white; border: 0; border-radius: 4px;
|
|
983
|
+
cursor: pointer; text-decoration: none;
|
|
984
|
+
}
|
|
985
|
+
.patreon-btn:hover { filter: brightness(1.05); }
|
|
986
|
+
.patreon-hint { color: var(--muted); font-size: 0.78rem; margin-top: 0.6rem; text-align: center; }
|
|
987
|
+
.patreon-iframe-note {
|
|
988
|
+
color: var(--muted); font-size: 0.78rem; line-height: 1.45;
|
|
989
|
+
margin-top: 0.6rem; padding: 0.6rem 0.7rem;
|
|
990
|
+
border: 1px dashed var(--rule); border-radius: 4px;
|
|
991
|
+
}
|
|
651
992
|
</style>
|
|
652
993
|
</head>
|
|
653
994
|
<body>
|
|
654
|
-
<
|
|
995
|
+
<div class="login-card"__PATREON_ROLES_ATTR__>
|
|
655
996
|
<h1>Sign in</h1>
|
|
656
997
|
<p id="err" class="login-error" hidden></p>
|
|
657
|
-
<
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
</
|
|
998
|
+
<form method="POST" action="/login">
|
|
999
|
+
<label for="role">Role</label>
|
|
1000
|
+
<select id="role" name="role">__ROLE_OPTIONS__</select>
|
|
1001
|
+
<label for="password">Password</label>
|
|
1002
|
+
<input id="password" type="password" name="password" autocomplete="current-password" required>
|
|
1003
|
+
<input type="hidden" name="next" id="next">
|
|
1004
|
+
<button type="submit">Sign in</button>
|
|
1005
|
+
</form>
|
|
1006
|
+
<div id="patreon-section" hidden>
|
|
1007
|
+
<div class="login-divider">or</div>
|
|
1008
|
+
<a id="patreon-btn" class="patreon-btn" href="#">
|
|
1009
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
1010
|
+
<circle cx="14.5" cy="9.5" r="6.5"/>
|
|
1011
|
+
<rect x="2" y="3" width="3.5" height="18"/>
|
|
1012
|
+
</svg>
|
|
1013
|
+
Sign in with Patreon
|
|
1014
|
+
</a>
|
|
1015
|
+
<p class="patreon-hint">
|
|
1016
|
+
Active patrons of the configured tiers can sign in directly. The role
|
|
1017
|
+
selector above only matters for password sign-in.
|
|
1018
|
+
</p>
|
|
1019
|
+
<p class="patreon-iframe-note" hidden>
|
|
1020
|
+
Patreon refuses to be embedded in iframes, so its sign-in button is
|
|
1021
|
+
hidden when this page is loaded in one (e.g. a Foundry connect
|
|
1022
|
+
dialog). Use a password here, or open the wiki in a normal browser
|
|
1023
|
+
tab to sign in with Patreon.
|
|
1024
|
+
</p>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
664
1027
|
<script>
|
|
665
1028
|
const params = new URLSearchParams(location.search);
|
|
666
1029
|
const next = params.get("next") || "/";
|
|
@@ -668,9 +1031,43 @@ export const LOGIN_HTML = `<!doctype html>
|
|
|
668
1031
|
const err = params.get("error");
|
|
669
1032
|
if (err) {
|
|
670
1033
|
const el = document.getElementById("err");
|
|
671
|
-
|
|
1034
|
+
const messages = {
|
|
1035
|
+
// Single message for any password-path failure — distinct error
|
|
1036
|
+
// codes would let an attacker enumerate which roles exist.
|
|
1037
|
+
auth_failed: "Invalid role or password.",
|
|
1038
|
+
patreon_state_mismatch: "Patreon sign-in expired or was tampered with. Please try again.",
|
|
1039
|
+
patreon_token_exchange: "Couldn't exchange the Patreon authorisation. Try again.",
|
|
1040
|
+
patreon_identity: "Patreon authenticated but we couldn't load your tier information.",
|
|
1041
|
+
patreon_no_tier: "Signed in to Patreon, but your current pledge doesn't grant any of the configured tiers.",
|
|
1042
|
+
patreon_misconfigured: "Patreon login is misconfigured for this deploy.",
|
|
1043
|
+
patreon_failed: "Patreon sign-in failed.",
|
|
1044
|
+
};
|
|
1045
|
+
el.textContent = messages[err] || "Sign-in failed.";
|
|
672
1046
|
el.hidden = false;
|
|
673
1047
|
}
|
|
1048
|
+
// Show the Patreon button only if the build emitted a roles list for it
|
|
1049
|
+
// (i.e. patreon is configured AND at least one role has a tier mapping).
|
|
1050
|
+
// The Foundry module's connect flow now uses delivery=copy (paste-token),
|
|
1051
|
+
// not an iframe, so Patreon's X-Frame-Options is no longer a problem in
|
|
1052
|
+
// the supported flow. The iframe-detection note still fires defensively
|
|
1053
|
+
// for any third-party that embeds /login.html directly.
|
|
1054
|
+
const card = document.querySelector(".login-card");
|
|
1055
|
+
if (card.dataset.patreonRoles) {
|
|
1056
|
+
const section = document.getElementById("patreon-section");
|
|
1057
|
+
section.hidden = false;
|
|
1058
|
+
const inIframe = window.parent !== window;
|
|
1059
|
+
if (inIframe) {
|
|
1060
|
+
document.getElementById("patreon-btn").hidden = true;
|
|
1061
|
+
document.querySelector(".patreon-hint").hidden = true;
|
|
1062
|
+
document.querySelector(".patreon-iframe-note").hidden = false;
|
|
1063
|
+
} else {
|
|
1064
|
+
document.getElementById("patreon-btn").href = "/auth/patreon/start?next=" + encodeURIComponent(next);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// Autofocus moved here so it picks the right field whether the password
|
|
1068
|
+
// form is visible or the user is going for the Patreon button.
|
|
1069
|
+
const pwd = document.getElementById("password");
|
|
1070
|
+
if (pwd && !err) pwd.focus();
|
|
674
1071
|
</script>
|
|
675
1072
|
</body>
|
|
676
1073
|
</html>`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-template.js","sourceRoot":"","sources":["../../src/render/auth-template.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+EAA+E;AAC/E,2EAA2E;AAC3E,mEAAmE;
|
|
1
|
+
{"version":3,"file":"auth-template.js","sourceRoot":"","sources":["../../src/render/auth-template.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+EAA+E;AAC/E,2EAA2E;AAC3E,mEAAmE;AAsBnE,MAAM,UAAU,oBAAoB,CAAC,GAAuB;IAC1D,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAC/C,MAAM,gBAAgB,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAC3D,MAAM,cAAc,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC;IAE3D,OAAO;;;;;;gBAMO,YAAY;oBACR,gBAAgB;kBAClB,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA85B/B,CAAC;AACF,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAgIlB,CAAC"}
|