source: Klonkt/test/oauth-c2s.test.js@ a4b903a

main
Last change on this file since a4b903a 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.1 KB
Line 
1// OAuth 2.0 C2S: PKCE authorization-code round-trip, replay protection, bearer
2// resolution, and redirect-URI validation. In-memory DB.
3//
4// Run: npm test
5
6import { test } from 'node:test';
7import assert from 'node:assert/strict';
8import crypto from 'crypto';
9
10process.env.DATABASE_PATH = ':memory:';
11process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
12
13const dbMod = await import('../src/config/database.js');
14const db = dbMod.default;
15const OAuth = await import('../src/services/OAuthService.js');
16dbMod.initializeDatabase();
17
18db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
19 .run('u1', 'robin', 'r@test', 'x', 'god');
20db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
21
22const b64url = (b) => b.toString('base64url');
23function pkce() {
24 const verifier = b64url(crypto.randomBytes(32));
25 const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
26 return { verifier, challenge };
27}
28
29test('validRedirectUri: https, loopback and reverse-DNS ok; plain http not', () => {
30 assert.equal(OAuth.validRedirectUri('https://app.example/cb'), true);
31 assert.equal(OAuth.validRedirectUri('http://127.0.0.1:1234/cb'), true);
32 assert.equal(OAuth.validRedirectUri('com.shaer.app:/callback'), true);
33 assert.equal(OAuth.validRedirectUri('http://evil.example/cb'), false);
34 assert.equal(OAuth.validRedirectUri('not a url'), false);
35});
36
37test('register rejects a bad redirect_uri', () => {
38 const bad = OAuth.registerClient({ client_name: 'X', redirect_uris: ['http://evil.example/cb'] });
39 assert.equal(bad.error, 'invalid_redirect_uri');
40});
41
42test('full PKCE flow yields a working bearer token', () => {
43 const reg = OAuth.registerClient({ client_name: 'Shaer', redirect_uris: ['com.shaer.app:/cb'] });
44 assert.ok(reg.client_id);
45 assert.equal(reg.token_endpoint_auth_method, 'none');
46
47 const { verifier, challenge } = pkce();
48 const { code } = OAuth.createCode({
49 clientId: reg.client_id, userId: 'u1', siteSlug: 'me',
50 redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge, scope: 'c2s',
51 });
52 assert.ok(code);
53
54 const tok = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
55 assert.equal(tok.token_type, 'Bearer');
56 assert.ok(tok.access_token);
57
58 const who = OAuth.verifyBearer('Bearer ' + tok.access_token);
59 assert.equal(who.user.id, 'u1');
60 assert.equal(who.site.slug, 'me');
61
62 // Replay: the same code must not work twice.
63 const replay = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
64 assert.equal(replay.error, 'invalid_grant');
65});
66
67test('createCode requires a PKCE challenge', () => {
68 const r = OAuth.createCode({ clientId: 'c', userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: '' });
69 assert.equal(r.error, 'invalid_request');
70});
71
72test('wrong PKCE verifier is rejected', () => {
73 const reg = OAuth.registerClient({ client_name: 'Shaer2', redirect_uris: ['com.shaer.app:/cb'] });
74 const { challenge } = pkce();
75 const { code } = OAuth.createCode({ clientId: reg.client_id, userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge });
76 const bad = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: 'wrong-verifier' });
77 assert.equal(bad.error, 'invalid_grant');
78});
79
80test('verifyBearer returns null for garbage and revoked tokens', () => {
81 assert.equal(OAuth.verifyBearer('Bearer nope-nope-nope-nope-nope'), null);
82 assert.equal(OAuth.verifyBearer(''), null);
83 const reg = OAuth.registerClient({ client_name: 'Shaer3', redirect_uris: ['com.shaer.app:/cb'] });
84 const { verifier, challenge } = pkce();
85 const { code } = OAuth.createCode({ clientId: reg.client_id, userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge });
86 const tok = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
87 OAuth.revokeToken(tok.access_token);
88 assert.equal(OAuth.verifyBearer('Bearer ' + tok.access_token), null);
89});
Note: See TracBrowser for help on using the repository browser.