source: Klonkt/src/routes/paid.js@ 603d246

main
Last change on this file since 603d246 was 6cbd014, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: paid posts slice 4, cookie-less per-post unlock

The unlock leg of the paid-posts flow (klonkt-demo-3lz). A supporter who
already made a passkey (slice 3) opens a paid post and unlocks it with a
WebAuthn assertion, no account and no cookie.

  • Cookie-less: GET /paid/challenge hands out authentication options plus a short-lived (300s) signed blob carrying the challenge, the post slug and the post's required cents. The client returns both to POST /paid/unlock; nothing is kept between the two requests.
  • Discoverable credentials: allowCredentials is empty, so the browser offers the site's passkeys and the visitor stays pseudonymous.
  • Gate checks, in order: valid+unexpired entitlement for this passkey and site (else 403 -> the page sends the visitor to /paid/link to register), tier (entitlement cents >= post cents, else 403), then the assertion is verified and the signature counter bumped (clone detection).
  • The full post body is returned in that SAME response (renderPostBodyHtml, extracted from the page pipeline so unlocked HTML matches the normal render exactly). No unlock token becomes state.

Note: injected content covers text, images and external embeds; the
own-hosted audio player binds on load and is not re-initialised in
injected HTML yet (follow-up).

Changed files:
src/routes/posts.js

  • export renderPostBodyHtml (shared by the page and the unlock route)

src/services/PasskeyService.js

  • authenticationOptions, verifyAssertion, bumpCounter

src/routes/paid.js

  • GET /paid/challenge, POST /paid/unlock (cookie-less)

src/views/pages/paid-gate.ejs

  • Ontgrendel button + vendored SimpleWebAuthnBrowser assertion script; swaps the gate for the post on success, links to Patreon on 403

test/paid-unlock.test.js

  • auth options challenge + empty allowCredentials, counter bump, tier gate, expired entitlement not served

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 7.1 KB
Line 
1/**
2 * Paid posts (klonkt-demo-aki) slice 3: the patron link + passkey flow.
3 * Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in
4 * signed blobs (CryptoBox), never a session.
5 *
6 * GET /paid/link?post=<slug> -> redirect to Patreon authorize
7 * GET /paid/callback -> verify patron, render the passkey page
8 * POST /paid/register -> verify the passkey, store the entitlement
9 */
10import express from 'express';
11import db from '../config/database.js';
12import { renderPage } from '../middleware/render.js';
13import { premiumUnlocked } from '../services/PatreonService.js';
14import { signBlob, verifyBlob, cryptoBoxReady } from '../services/CryptoBox.js';
15import PaidPatreon from '../services/PaidPatreonService.js';
16import Passkey from '../services/PasskeyService.js';
17import { renderPostBodyHtml } from './posts.js';
18
19const router = express.Router();
20const AUTHORIZE = 'https://www.patreon.com/oauth2/authorize';
21
22const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
23
24// The feature is only live when premium is on, secrets can be encrypted, and the
25// owner has connected a campaign.
26function ready(req, res) {
27 const site = res.locals.site;
28 if (!site) { res.status(404).end(); return null; }
29 if (!premiumUnlocked() || !cryptoBoxReady()) { res.status(404).end(); return null; }
30 const cfg = PaidPatreon.getOwnerConfig(site.id);
31 if (!cfg || !cfg.clientId || !cfg.campaignId) { res.status(404).end(); return null; }
32 return { site, cfg };
33}
34
35// Step 1: send the visitor to Patreon.
36router.get('/link', (req, res) => {
37 const r = ready(req, res); if (!r) return;
38 const slug = String(req.query.post || '').trim();
39 const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
40 if (!post || !post.paid) return res.redirect((res.locals.siteUrlBase || '') + '/' + (slug || ''));
41 const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
42 const state = signBlob({ purpose: 'patron', siteId: r.site.id, cents, post: post.slug }, 900);
43 const url = `${AUTHORIZE}?response_type=code&client_id=${encodeURIComponent(r.cfg.clientId)}`
44 + `&redirect_uri=${encodeURIComponent(baseUrl(req) + '/paid/callback')}`
45 + `&scope=${encodeURIComponent('identity identity.memberships')}`
46 + `&state=${encodeURIComponent(state)}`;
47 res.redirect(url);
48});
49
50// Step 2: Patreon returns. Verify the patron; if a supporter at the right tier,
51// render the passkey-creation page.
52router.get('/callback', async (req, res) => {
53 const r = ready(req, res); if (!r) return;
54 const payload = verifyBlob(String(req.query.state || ''));
55 if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
56 return res.status(400).send('Ongeldige of verlopen aanvraag. Probeer opnieuw vanaf de post.');
57 }
58 const code = String(req.query.code || '');
59 if (req.query.error || !code) {
60 return renderPage(req, res, 'pages/paid-result', { pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'declined', postSlug: payload.post });
61 }
62 const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
63 const cents = membership ? (membership.cents || 0) : 0;
64 const active = membership && membership.status === 'active_patron';
65 if (!active || cents < payload.cents) {
66 return renderPage(req, res, 'pages/paid-result', {
67 pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false,
68 reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post,
69 });
70 }
71 // Supporter at the right tier. Hand out registration options + a signed blob
72 // carrying the challenge and the proven cents; the passkey page returns both.
73 const options = await Passkey.registrationOptions(baseUrl(req), r.site.slug);
74 const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
75 renderPage(req, res, 'pages/paid-passkey', {
76 pageTitle: 'Maak je passkey', bodyClass: 'on-special',
77 optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
78 });
79});
80
81// Step 3: verify the passkey and store the pseudonymous entitlement.
82router.post('/register', express.json({ limit: '64kb' }), async (req, res) => {
83 const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
84 const { response, blob } = req.body || {};
85 const payload = verifyBlob(String(blob || ''));
86 if (!payload || payload.purpose !== 'reg' || payload.siteId !== r.site.id) {
87 return res.status(400).json({ error: 'bad_challenge' });
88 }
89 const cred = await Passkey.verifyRegistration(baseUrl(req), response, payload.challenge);
90 if (!cred) return res.status(400).json({ error: 'verify_failed' });
91 Passkey.storeEntitlement({
92 credentialId: cred.credentialId, siteId: r.site.id, publicKey: cred.publicKey,
93 counter: cred.counter, transports: cred.transports, minCents: payload.cents,
94 });
95 res.json({ ok: true });
96});
97
98// Step 4 (unlock): hand out authentication options for a passkey assertion.
99router.get('/challenge', async (req, res) => {
100 const r = ready(req, res); if (!r) return;
101 const slug = String(req.query.post || '').trim();
102 const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
103 if (!post || !post.paid) return res.status(404).json({ error: 'not_paid' });
104 const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
105 const options = await Passkey.authenticationOptions(baseUrl(req));
106 const blob = signBlob({ purpose: 'auth', siteId: r.site.id, cents, post: post.slug, challenge: options.challenge }, 300);
107 res.json({ options, blob });
108});
109
110// Verify the assertion, check the entitlement, and return the full post body in
111// the SAME response. No unlock token becomes state (design decision).
112router.post('/unlock', express.json({ limit: '64kb' }), async (req, res) => {
113 const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
114 const { response, blob } = req.body || {};
115 const payload = verifyBlob(String(blob || ''));
116 if (!payload || payload.purpose !== 'auth' || payload.siteId !== r.site.id) return res.status(400).json({ error: 'bad_challenge' });
117 const credId = response && response.id;
118 const ent = credId ? Passkey.getEntitlement(credId, r.site.id) : null;
119 if (!ent) return res.status(403).json({ error: 'no_entitlement' }); // unknown/expired passkey
120 if ((ent.min_cents || 0) < payload.cents) return res.status(403).json({ error: 'tier' });
121 const vr = await Passkey.verifyAssertion(baseUrl(req), response, payload.challenge, ent);
122 if (!vr) return res.status(400).json({ error: 'verify_failed' });
123 Passkey.bumpCounter(credId, vr.newCounter);
124 const post = db.prepare("SELECT * FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(r.site.id, String(payload.post || ''));
125 if (!post || !post.paid) return res.status(404).json({ error: 'gone' });
126 res.json({ ok: true, title: post.title || '', html: renderPostBodyHtml(r.site, post, req) });
127});
128
129export default router;
Note: See TracBrowser for help on using the repository browser.