| [d49b60b] | 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 |
|
|---|
| 6 | import { test } from 'node:test';
|
|---|
| 7 | import assert from 'node:assert/strict';
|
|---|
| 8 | import crypto from 'crypto';
|
|---|
| 9 |
|
|---|
| 10 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 11 | process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
|
|---|
| 12 |
|
|---|
| 13 | const dbMod = await import('../src/config/database.js');
|
|---|
| 14 | const db = dbMod.default;
|
|---|
| 15 | const OAuth = await import('../src/services/OAuthService.js');
|
|---|
| 16 | dbMod.initializeDatabase();
|
|---|
| 17 |
|
|---|
| 18 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
|
|---|
| 19 | .run('u1', 'robin', 'r@test', 'x', 'god');
|
|---|
| 20 | db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
|
|---|
| 21 |
|
|---|
| 22 | const b64url = (b) => b.toString('base64url');
|
|---|
| 23 | function pkce() {
|
|---|
| 24 | const verifier = b64url(crypto.randomBytes(32));
|
|---|
| 25 | const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
|---|
| 26 | return { verifier, challenge };
|
|---|
| 27 | }
|
|---|
| 28 |
|
|---|
| 29 | test('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 |
|
|---|
| 37 | test('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 |
|
|---|
| 42 | test('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 |
|
|---|
| 67 | test('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 |
|
|---|
| 72 | test('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 |
|
|---|
| 80 | test('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 | });
|
|---|