source: Klonkt/src/services/PasskeyService.js@ 9e9e6f9

main
Last change on this file since 9e9e6f9 was 9e9e6f9, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: paid posts slice 3, patron link + passkey (cookie-less)

The registration leg of the paid-posts flow (klonkt-demo-aki). A
visitor links once via Patreon and gets a pseudonymous passkey entitlement,
with no session and no patron identity stored.

  • Dependency (approved): @simplewebauthn/server for verification, plus @simplewebauthn/browser vendored (UMD) so the page loads it with no CDN.
  • New paid_entitlements table: {passkey, site, proven cents, expiry}. No name, e-mail or Patreon id, ever.
  • Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in signed blobs (CryptoBox), so nothing is kept between requests.
  • Flow: GET /paid/link -> Patreon authorize; GET /paid/callback verifies the patron (verifyPatron exchanges the code, reads identity?include=memberships.campaign, checks patron_status + currently_entitled_amount_cents against the post's price), then hands out registration options + a signed blob carrying the challenge and proven cents; POST /paid/register verifies the passkey and stores the entitlement. The patron token is used once and discarded.

The gate button and the per-post unlock (assertion) are slice 4; this
leg is what that flow calls to register a passkey on demand.

Changed files:
package.json, package-lock.json

  • @simplewebauthn/server + @simplewebauthn/browser

src/assets/vendor/simplewebauthn-browser.umd.min.js

  • vendored browser UMD (no CDN)

src/config/database.js

  • paid_entitlements table (no patron identity)

src/services/PaidPatreonService.js

  • pickCampaignMembership (pure), verifyPatron (exchange + identity)

src/server.js

  • mount /paid before the /:slug catch-all

New file:
src/services/PasskeyService.js

  • registration options + verify (lib) + entitlement store/prune

src/routes/paid.js

  • link / callback / register (cookie-less)

src/views/pages/paid-passkey.ejs, paid-result.ejs

  • passkey creation + not-a-supporter pages

test/paid-patron.test.js

  • membership parse, patron exchange (mock), options challenge, entitlement store/expiry/prune/delete, no-identity-columns

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

  • Property mode set to 100644
File size: 4.0 KB
Line 
1// Paid posts (klonkt-demo-aki) slice 3: passkey registration + verification for
2// pseudonymous entitlements. Uses @simplewebauthn/server. Cookie-less: the
3// challenge is not kept in a session but travels inside a signed blob
4// (CryptoBox.signBlob) that the client returns, so there is nothing to store
5// between the two requests. An entitlement is {passkey, site, cents, expiry}
6// with NO patron identity.
7import crypto from 'crypto';
8import db from '../config/database.js';
9import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
10
11const DEFAULT_TTL_DAYS = 32; // aligns with Patreon's monthly cycle; re-link after
12
13// rpID is the site host; origin is the full base URL.
14export function rpFor(base) {
15 let host = ''; try { host = new URL(base).host.split(':')[0]; } catch { /* keep empty */ }
16 return { rpID: host, origin: String(base).replace(/\/+$/, '') };
17}
18
19// Registration options for a fresh, discoverable (usernameless) passkey. The
20// user handle is random: the credential is pseudonymous by design.
21export async function registrationOptions(base, siteSlug) {
22 const { rpID } = rpFor(base);
23 return generateRegistrationOptions({
24 rpName: `Supporter of ${siteSlug}`,
25 rpID,
26 userName: 'supporter',
27 userDisplayName: 'Supporter',
28 userID: crypto.randomBytes(16),
29 attestationType: 'none',
30 authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
31 timeout: 120000,
32 });
33}
34
35// Verify a registration response against the challenge (read from the signed
36// blob by the caller). Returns the credential to store, or null.
37export async function verifyRegistration(base, response, expectedChallenge) {
38 const { rpID, origin } = rpFor(base);
39 let v;
40 try {
41 v = await verifyRegistrationResponse({
42 response,
43 expectedChallenge,
44 expectedOrigin: origin,
45 expectedRPID: rpID,
46 requireUserVerification: false,
47 });
48 } catch { return null; }
49 if (!v || !v.verified || !v.registrationInfo) return null;
50 const cred = v.registrationInfo.credential;
51 return {
52 credentialId: cred.id, // base64url string
53 publicKey: Buffer.from(cred.publicKey).toString('base64url'), // COSE key bytes
54 counter: cred.counter || 0,
55 transports: response.response && response.response.transports ? JSON.stringify(response.response.transports) : null,
56 };
57}
58
59// Store (or refresh) a pseudonymous entitlement for this passkey.
60export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
61 const expiresAt = Math.floor(Date.now() / 1000) + ttlDays * 86400;
62 db.prepare(`INSERT INTO paid_entitlements
63 (credential_id, site_id, public_key, counter, transports, min_cents, expires_at, created_at)
64 VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
65 ON CONFLICT(credential_id) DO UPDATE SET
66 public_key=excluded.public_key, counter=excluded.counter, transports=excluded.transports,
67 min_cents=excluded.min_cents, expires_at=excluded.expires_at`)
68 .run(credentialId, siteId, publicKey, counter || 0, transports || null, Math.max(0, minCents || 0), expiresAt);
69 return expiresAt;
70}
71
72// A valid, unexpired entitlement for this passkey on this site, else null.
73export function getEntitlement(credentialId, siteId) {
74 const row = db.prepare('SELECT * FROM paid_entitlements WHERE credential_id = ? AND site_id = ?').get(credentialId, siteId);
75 if (!row) return null;
76 if ((row.expires_at || 0) < Math.floor(Date.now() / 1000)) return null;
77 return row;
78}
79
80export function deleteEntitlement(credentialId) {
81 return db.prepare('DELETE FROM paid_entitlements WHERE credential_id = ?').run(credentialId).changes > 0;
82}
83
84// Prune expired entitlements (Scheduler, slice 5).
85export function pruneExpired() {
86 return db.prepare('DELETE FROM paid_entitlements WHERE expires_at < ?').run(Math.floor(Date.now() / 1000)).changes;
87}
88
89export default {
90 rpFor, registrationOptions, verifyRegistration, storeEntitlement,
91 getEntitlement, deleteEntitlement, pruneExpired,
92};
Note: See TracBrowser for help on using the repository browser.