source: Klonkt/test/activitypub-as2.test.js@ 5190152

main
Last change on this file since 5190152 was d49b60b, checked in by Robin <roboburr@…>, 8 weeks ago

Feature: OAuth 2.0 for ActivityPub C2S (phase 1 — auth handshake)

First half of AP Client-to-Server: the auth layer native/web clients (Shaer)
need before they can drive a Klonkt account. The AP spec's own C2S half is what
keeps this inside-spec instead of cloning Mastodon's REST API.

  • OAuthService: public-client OAuth (RFC 8252), PKCE S256 REQUIRED, no secrets. Dynamic registration (RFC 7591 subset) with strict redirect_uri validation (https / loopback http / reverse-DNS custom scheme). Single-use 10-min codes; tokens stored sha256-hashed; a token is scoped to one user + one site.
  • routes/oauth.js: /oauth/register, /oauth/authorize (session-authed consent screen picking the site), /oauth/token, and RFC 8414 server metadata at /.well-known/oauth-authorization-server. Redirect params are appended to the registered URI verbatim (no new URL() round-trip that would mangle a native custom scheme). Pre-redirect validation errors never bounce to an unvalidated URI (open-redirect guard).
  • Actor doc advertises oauthAuthorizationEndpoint/oauthTokenEndpoint/uploadMedia in endpoints{} — all AP-spec terms, added to the AS2 conformance allowlist — so clients discover paths instead of hardcoding them (Klonkt's /ap/users/:slug differs from the daemon's /actors/:name; discovery makes that irrelevant).
  • oauth_clients/oauth_codes/oauth_tokens tables (additive).
  • i18n NL/EN/DE for the consent screen.

7 new OAuth tests (PKCE round-trip, replay protection, wrong-verifier reject,
bearer resolution incl. revoke, redirect-uri validation); 73 green. Verified
the full HTTP flow end to end (register → consent → code → token → bearer) and
that the raw Location header preserves the native redirect URI exactly. Beads:
klonkt-demo-srr. Next: klonkt-demo-1w4 (POST outbox accepts the activities).

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

  • Property mode set to 100644
File size: 4.9 KB
Line 
1// AS2 / JSON-LD validity guard.
2//
3// Every property key and `type` value our ActivityPub objects emit MUST be either an AS2-core
4// (or security/v1) term OR declared in the federation @context (AP_CONTEXT). A new feature that
5// emits an undeclared term fails this test → declare it in AP_CONTEXT (extensions) or add it to
6// the AS2 allowlist below. This keeps Klonkt's output valid AS2/JSON-LD forever — not just
7// "Mastodon tolerates it". No extra deps; in-memory SQLite. Run: npm test
8import { test } from 'node:test';
9import assert from 'node:assert/strict';
10
11process.env.DATABASE_PATH = ':memory:';
12process.env.PUBLIC_BASE_URL = 'https://test.example';
13
14const dbMod = await import('../src/config/database.js');
15const db = dbMod.default;
16dbMod.initializeDatabase();
17const AP = (await import('../src/services/ActivityPubService.js')).default;
18
19const BASE = 'https://test.example';
20
21// AS2-core + security/v1 vocabulary Klonkt uses (stable — only extend when AS2/security itself
22// adds a term we adopt). JSON-LD keywords included.
23const AS2 = new Set([
24 '@context', '@id', '@type',
25 'id', 'type', 'actor', 'object', 'target', 'to', 'cc',
26 'content', 'name', 'summary', 'url', 'href', 'mediaType',
27 'published', 'updated', 'attributedTo', 'inReplyTo', 'replies',
28 'attachment', 'tag', 'icon', 'image', 'duration',
29 'contentMap', 'nameMap', 'summaryMap', // AS2 @language-map counterparts of content/name/summary
30 'totalItems', 'orderedItems', 'items', 'first', 'last', 'partOf', 'next', 'prev',
31 'preferredUsername', 'inbox', 'outbox', 'followers', 'following', 'endpoints', 'sharedInbox',
32 // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
33 'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
34 'publicKey', 'owner', 'publicKeyPem',
35 'Note', 'Person', 'Create', 'Update', 'Delete', 'Tombstone', 'Announce', 'Like', 'Follow',
36 'Accept', 'Reject', 'Undo', 'Add', 'Remove', 'Flag', 'Document', 'Image', 'Audio', 'Video',
37 'Mention', 'Link', 'Collection', 'OrderedCollection', 'OrderedCollectionPage',
38]);
39
40// The extension terms = exactly the keys declared in AP_CONTEXT's term-definition object.
41const ctxTerms = new Set();
42for (const part of AP.AP_CONTEXT) if (part && typeof part === 'object') for (const k of Object.keys(part)) ctxTerms.add(k);
43const allowed = new Set([...AS2, ...ctxTerms]);
44
45// Collect every property key + every `type` string value, recursively.
46function collect(obj, keys = new Set()) {
47 if (Array.isArray(obj)) { for (const x of obj) collect(x, keys); return keys; }
48 if (obj && typeof obj === 'object') {
49 for (const [k, v] of Object.entries(obj)) {
50 keys.add(k);
51 if (k === 'type' && typeof v === 'string') keys.add(v);
52 if (/Map$/.test(k)) continue; // a @language map (contentMap/…): its keys are BCP-47 tags, not vocab terms
53 collect(v, keys);
54 }
55 }
56 return keys;
57}
58function assertValid(obj, label) {
59 const undeclared = [...collect(obj)].filter((k) => !allowed.has(k));
60 assert.deepEqual(undeclared, [],
61 `${label}: undeclared AS2/JSON-LD term(s) — declare in AP_CONTEXT (extension) or the AS2 allowlist: ${undeclared.join(', ')}`);
62}
63
64// Seed one site that exercises the extension-heavy actor fields (profile links → PropertyValue,
65// photo → icon, primary → featured).
66db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
67db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, profile_links, profile_photo) VALUES (?,?,?,?,?,?,?)')
68 .run('s1', 'demo', 'Demo', 'u1', 1, JSON.stringify([{ platform: 'website', url: 'https://x.test' }]), '/media/x.png');
69const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
70site.primary_slug = 'demo';
71
72// Kitchen-sink note: nsfw (→ sensitive + summary), a hashtag (→ Hashtag), a cover (→ attachment).
73const post = {
74 id: 'p1', slug: 'hello', title: 'Hi', content: '<p>hello #music</p>',
75 nsfw: 1, content_warning: 'cw', tags: JSON.stringify(['mood']),
76 cover_image_url: '/media/c.webp', cover_alt: 'A cover', language: 'en',
77 published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
78};
79
80test('actor is valid AS2 (every term declared)', () => assertValid(AP.buildActor(BASE, site), 'actor'));
81test('create+note is valid AS2 (every term declared)', () => assertValid(AP.buildCreate(BASE, site, post), 'create/note'));
82test('outbox/followers/featured collections are valid AS2', () => {
83 assertValid(AP.buildOutbox(BASE, site, [post]), 'outbox');
84 assertValid(AP.buildFollowers(BASE, site, 3), 'followers');
85 assertValid(AP.buildFollowing(BASE, site, 2), 'following');
86 assertValid(AP.buildFeatured(BASE, site, [post]), 'featured');
87});
88test('AP_CONTEXT declares every extension term we rely on', () => {
89 for (const t of ['sensitive', 'Hashtag', 'manuallyApprovesFollowers', 'discoverable', 'featured', 'PropertyValue', 'embedUrl'])
90 assert.ok(ctxTerms.has(t), `AP_CONTEXT must declare "${t}"`);
91});
Note: See TracBrowser for help on using the repository browser.