Index: test/activitypub-as2.test.js
===================================================================
--- test/activitypub-as2.test.js	(revision f1a23b8f4305f375087b6c69adc090e77dbd6de6)
+++ test/activitypub-as2.test.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -30,4 +30,6 @@
   'totalItems', 'orderedItems', 'items', 'first', 'last', 'partOf', 'next', 'prev',
   'preferredUsername', 'inbox', 'outbox', 'followers', 'following', 'endpoints', 'sharedInbox',
+  // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
+  'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
   'publicKey', 'owner', 'publicKeyPem',
   'Note', 'Person', 'Create', 'Update', 'Delete', 'Tombstone', 'Announce', 'Like', 'Follow',
Index: test/oauth-c2s.test.js
===================================================================
--- test/oauth-c2s.test.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
+++ test/oauth-c2s.test.js	(revision d49b60b14f6ee15516f465adfeee2a51294134c7)
@@ -0,0 +1,89 @@
+// OAuth 2.0 C2S: PKCE authorization-code round-trip, replay protection, bearer
+// resolution, and redirect-URI validation. In-memory DB.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+const OAuth = await import('../src/services/OAuthService.js');
+dbMod.initializeDatabase();
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'robin', 'r@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+const b64url = (b) => b.toString('base64url');
+function pkce() {
+  const verifier = b64url(crypto.randomBytes(32));
+  const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
+  return { verifier, challenge };
+}
+
+test('validRedirectUri: https, loopback and reverse-DNS ok; plain http not', () => {
+  assert.equal(OAuth.validRedirectUri('https://app.example/cb'), true);
+  assert.equal(OAuth.validRedirectUri('http://127.0.0.1:1234/cb'), true);
+  assert.equal(OAuth.validRedirectUri('com.shaer.app:/callback'), true);
+  assert.equal(OAuth.validRedirectUri('http://evil.example/cb'), false);
+  assert.equal(OAuth.validRedirectUri('not a url'), false);
+});
+
+test('register rejects a bad redirect_uri', () => {
+  const bad = OAuth.registerClient({ client_name: 'X', redirect_uris: ['http://evil.example/cb'] });
+  assert.equal(bad.error, 'invalid_redirect_uri');
+});
+
+test('full PKCE flow yields a working bearer token', () => {
+  const reg = OAuth.registerClient({ client_name: 'Shaer', redirect_uris: ['com.shaer.app:/cb'] });
+  assert.ok(reg.client_id);
+  assert.equal(reg.token_endpoint_auth_method, 'none');
+
+  const { verifier, challenge } = pkce();
+  const { code } = OAuth.createCode({
+    clientId: reg.client_id, userId: 'u1', siteSlug: 'me',
+    redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge, scope: 'c2s',
+  });
+  assert.ok(code);
+
+  const tok = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
+  assert.equal(tok.token_type, 'Bearer');
+  assert.ok(tok.access_token);
+
+  const who = OAuth.verifyBearer('Bearer ' + tok.access_token);
+  assert.equal(who.user.id, 'u1');
+  assert.equal(who.site.slug, 'me');
+
+  // Replay: the same code must not work twice.
+  const replay = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
+  assert.equal(replay.error, 'invalid_grant');
+});
+
+test('createCode requires a PKCE challenge', () => {
+  const r = OAuth.createCode({ clientId: 'c', userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: '' });
+  assert.equal(r.error, 'invalid_request');
+});
+
+test('wrong PKCE verifier is rejected', () => {
+  const reg = OAuth.registerClient({ client_name: 'Shaer2', redirect_uris: ['com.shaer.app:/cb'] });
+  const { challenge } = pkce();
+  const { code } = OAuth.createCode({ clientId: reg.client_id, userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge });
+  const bad = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: 'wrong-verifier' });
+  assert.equal(bad.error, 'invalid_grant');
+});
+
+test('verifyBearer returns null for garbage and revoked tokens', () => {
+  assert.equal(OAuth.verifyBearer('Bearer nope-nope-nope-nope-nope'), null);
+  assert.equal(OAuth.verifyBearer(''), null);
+  const reg = OAuth.registerClient({ client_name: 'Shaer3', redirect_uris: ['com.shaer.app:/cb'] });
+  const { verifier, challenge } = pkce();
+  const { code } = OAuth.createCode({ clientId: reg.client_id, userId: 'u1', siteSlug: 'me', redirectUri: 'com.shaer.app:/cb', codeChallenge: challenge });
+  const tok = OAuth.exchangeCode({ code, client_id: reg.client_id, redirect_uri: 'com.shaer.app:/cb', code_verifier: verifier });
+  OAuth.revokeToken(tok.access_token);
+  assert.equal(OAuth.verifyBearer('Bearer ' + tok.access_token), null);
+});
