companion-oauth-pkce-unit.test.mjs
294 lines 13.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * Tier 1 — UNIT: each pure function of the Phase 3 OAuth/PKCE core in isolation.
3 *
4 * Reference: docs/COMPANION-APP-PHASE-3-OAUTH-PKCE.md; RFC 7636 (PKCE, S256), RFC 8252 (native
5 * apps / loopback redirect), RFC 9207 (iss). The RFC 7636 Appendix B vector is asserted directly.
6 */
7 import { describe, it } from 'node:test';
8 import assert from 'node:assert/strict';
9 import {
10 OAUTH_PKCE_REASONS,
11 PKCE_METHOD_S256,
12 CODE_VERIFIER_MIN_LEN,
13 CODE_VERIFIER_MAX_LEN,
14 constantTimeEqual,
15 computeCodeChallenge,
16 createPkcePair,
17 createOAuthState,
18 createNonce,
19 validateRedirectUri,
20 buildAuthorizationUrl,
21 validateAuthorizationResponse,
22 buildTokenRequest,
23 buildRefreshRequest,
24 validateTokenResponse,
25 decideTokenRefresh,
26 } from '../lib/companion-oauth-pkce.mjs';
27
28 const AUTH_EP = 'https://knowtation.store/authorize';
29 const TOKEN_EP = 'https://knowtation.store/token';
30 const CLIENT_ID = 'companion-public-client';
31 const REDIRECT = 'http://127.0.0.1:49321/callback';
32 const SCOPES = ['vault:read', 'vault:write'];
33
34 // RFC 7636 Appendix B canonical test vector.
35 const RFC7636_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
36 const RFC7636_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
37
38 describe('computeCodeChallenge — RFC 7636 §4.2 S256', () => {
39 it('matches the RFC 7636 Appendix B test vector', () => {
40 assert.equal(computeCodeChallenge(RFC7636_VERIFIER), RFC7636_CHALLENGE);
41 });
42 it('is deterministic', () => {
43 assert.equal(computeCodeChallenge(RFC7636_VERIFIER), computeCodeChallenge(RFC7636_VERIFIER));
44 });
45 it('throws (no secret in message) on a too-short verifier', () => {
46 assert.throws(() => computeCodeChallenge('short'), /valid RFC 7636/);
47 });
48 it('throws on a verifier with a disallowed character', () => {
49 const bad = 'a'.repeat(42) + ' '; // space is not in the unreserved set
50 assert.throws(() => computeCodeChallenge(bad));
51 });
52 });
53
54 describe('createPkcePair', () => {
55 it('returns a verifier within RFC 7636 length bounds and method S256', () => {
56 const { codeVerifier, codeChallenge, method } = createPkcePair();
57 assert.equal(method, PKCE_METHOD_S256);
58 assert.ok(codeVerifier.length >= CODE_VERIFIER_MIN_LEN && codeVerifier.length <= CODE_VERIFIER_MAX_LEN);
59 assert.match(codeVerifier, /^[A-Za-z0-9\-._~]+$/);
60 assert.equal(codeChallenge, computeCodeChallenge(codeVerifier));
61 });
62 it('produces a unique pair each call', () => {
63 const a = createPkcePair();
64 const b = createPkcePair();
65 assert.notEqual(a.codeVerifier, b.codeVerifier);
66 assert.notEqual(a.codeChallenge, b.codeChallenge);
67 });
68 });
69
70 describe('createOAuthState / createNonce', () => {
71 it('return base64url high-entropy values', () => {
72 for (const fn of [createOAuthState, createNonce]) {
73 const v = fn();
74 assert.match(v, /^[A-Za-z0-9\-_]+$/);
75 assert.ok(v.length >= 43, 'at least 256-bit base64url');
76 }
77 });
78 it('are unique per call', () => {
79 assert.notEqual(createOAuthState(), createOAuthState());
80 assert.notEqual(createNonce(), createNonce());
81 });
82 });
83
84 describe('validateRedirectUri — RFC 8252 loopback rules', () => {
85 it('accepts an http loopback URI with an explicit port', () => {
86 const r = validateRedirectUri(REDIRECT);
87 assert.equal(r.ok, true);
88 assert.equal(r.host, '127.0.0.1');
89 assert.equal(r.port, 49321);
90 assert.equal(r.pathname, '/callback');
91 });
92 it('accepts IPv6 loopback [::1]', () => {
93 const r = validateRedirectUri('http://[::1]:51000/callback');
94 assert.equal(r.ok, true);
95 assert.equal(r.host, '::1');
96 });
97 it('rejects https-to-loopback (loopback redirect is plain http)', () => {
98 assert.equal(validateRedirectUri('https://127.0.0.1:49321/callback').ok, false);
99 });
100 it('rejects a non-loopback host', () => {
101 assert.equal(validateRedirectUri('http://192.168.0.5:49321/callback').ok, false);
102 assert.equal(validateRedirectUri('http://evil.example:49321/callback').ok, false);
103 });
104 it('rejects localhost by default (resolution depends on local config)', () => {
105 assert.equal(validateRedirectUri('http://localhost:49321/callback').ok, false);
106 });
107 it('rejects a missing port (non-exact redirect)', () => {
108 assert.equal(validateRedirectUri('http://127.0.0.1/callback').ok, false);
109 });
110 it('rejects userinfo, query, and fragment', () => {
111 assert.equal(validateRedirectUri('http://user:[email protected]:49321/callback').ok, false);
112 assert.equal(validateRedirectUri('http://127.0.0.1:49321/callback?x=1').ok, false);
113 assert.equal(validateRedirectUri('http://127.0.0.1:49321/callback#frag').ok, false);
114 });
115 it('honors a caller-supplied allowedHosts list', () => {
116 assert.equal(validateRedirectUri('http://localhost:49321/callback', { allowedHosts: ['localhost'] }).ok, true);
117 });
118 it('returns a fixed reason that never contains the URI', () => {
119 const r = validateRedirectUri('http://evil.example:1/x');
120 assert.equal(r.reason, OAUTH_PKCE_REASONS.INVALID_REDIRECT_URI);
121 });
122 });
123
124 describe('buildAuthorizationUrl', () => {
125 const base = {
126 authorizationEndpoint: AUTH_EP,
127 clientId: CLIENT_ID,
128 redirectUri: REDIRECT,
129 scopes: SCOPES,
130 state: 'state-abc',
131 codeChallenge: RFC7636_CHALLENGE,
132 };
133 it('builds an RFC 6749 §4.1.1 + RFC 7636 §4.3 authorization request', () => {
134 const url = new URL(buildAuthorizationUrl(base));
135 assert.equal(url.searchParams.get('response_type'), 'code');
136 assert.equal(url.searchParams.get('client_id'), CLIENT_ID);
137 assert.equal(url.searchParams.get('redirect_uri'), REDIRECT);
138 assert.equal(url.searchParams.get('scope'), 'vault:read vault:write');
139 assert.equal(url.searchParams.get('state'), 'state-abc');
140 assert.equal(url.searchParams.get('code_challenge'), RFC7636_CHALLENGE);
141 assert.equal(url.searchParams.get('code_challenge_method'), 'S256');
142 });
143 it('includes a nonce when provided', () => {
144 const url = new URL(buildAuthorizationUrl({ ...base, nonce: 'n-1' }));
145 assert.equal(url.searchParams.get('nonce'), 'n-1');
146 });
147 it('throws on a non-S256 method (no plain downgrade)', () => {
148 assert.throws(() => buildAuthorizationUrl({ ...base, codeChallengeMethod: 'plain' }), /S256/);
149 });
150 it('throws when the authorization endpoint is not https', () => {
151 assert.throws(() => buildAuthorizationUrl({ ...base, authorizationEndpoint: 'http://knowtation.store/authorize' }), /https/);
152 });
153 it('throws on an invalid loopback redirect', () => {
154 assert.throws(() => buildAuthorizationUrl({ ...base, redirectUri: 'https://evil.example/cb' }), /RFC 8252/);
155 });
156 it('cannot be made to inject a client_secret via extraParams', () => {
157 const url = new URL(buildAuthorizationUrl({ ...base, extraParams: { client_secret: 'leak', prompt: 'consent' } }));
158 assert.equal(url.searchParams.get('client_secret'), null);
159 assert.equal(url.searchParams.get('prompt'), 'consent');
160 });
161 });
162
163 describe('validateAuthorizationResponse', () => {
164 it('accepts a matching state and extracts the code', () => {
165 const r = validateAuthorizationResponse({ params: { code: 'authcode', state: 's' }, expectedState: 's' });
166 assert.equal(r.ok, true);
167 assert.equal(r.code, 'authcode');
168 });
169 it('rejects a state mismatch with a fixed reason', () => {
170 const r = validateAuthorizationResponse({ params: { code: 'c', state: 'x' }, expectedState: 's' });
171 assert.equal(r.ok, false);
172 assert.equal(r.reason, OAUTH_PKCE_REASONS.STATE_MISMATCH);
173 });
174 it('rejects a missing code', () => {
175 const r = validateAuthorizationResponse({ params: { state: 's' }, expectedState: 's' });
176 assert.equal(r.reason, OAUTH_PKCE_REASONS.MISSING_CODE);
177 });
178 it('surfaces a known authorization-server error code without free text', () => {
179 const r = validateAuthorizationResponse({ params: { error: 'access_denied', error_description: 'user said no <script>' }, expectedState: 's' });
180 assert.equal(r.ok, false);
181 assert.equal(r.reason, OAUTH_PKCE_REASONS.AUTHORIZATION_SERVER_ERROR);
182 assert.equal(r.errorCode, 'access_denied');
183 });
184 it('does not surface an unknown/forged error code', () => {
185 const r = validateAuthorizationResponse({ params: { error: '<script>alert(1)</script>' }, expectedState: 's' });
186 assert.equal(r.reason, OAUTH_PKCE_REASONS.AUTHORIZATION_SERVER_ERROR);
187 assert.equal(r.errorCode, undefined);
188 });
189 it('validates iss when expectedIssuer + iss both present (RFC 9207)', () => {
190 const ok = validateAuthorizationResponse({ params: { code: 'c', state: 's', iss: 'https://knowtation.store' }, expectedState: 's', expectedIssuer: 'https://knowtation.store' });
191 assert.equal(ok.ok, true);
192 const bad = validateAuthorizationResponse({ params: { code: 'c', state: 's', iss: 'https://evil.example' }, expectedState: 's', expectedIssuer: 'https://knowtation.store' });
193 assert.equal(bad.reason, OAUTH_PKCE_REASONS.ISSUER_MISMATCH);
194 });
195 it('tolerates a missing iss for back-compat when expectedIssuer is set', () => {
196 const r = validateAuthorizationResponse({ params: { code: 'c', state: 's' }, expectedState: 's', expectedIssuer: 'https://knowtation.store' });
197 assert.equal(r.ok, true);
198 });
199 });
200
201 describe('buildTokenRequest', () => {
202 it('builds an authorization_code grant carrying the code_verifier (PKCE binding) and no secret', () => {
203 const req = buildTokenRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: 'authcode', codeVerifier: RFC7636_VERIFIER, redirectUri: REDIRECT });
204 assert.equal(req.method, 'POST');
205 assert.equal(req.headers['Content-Type'], 'application/x-www-form-urlencoded');
206 assert.equal(req.bodyParams.grant_type, 'authorization_code');
207 assert.equal(req.bodyParams.code, 'authcode');
208 assert.equal(req.bodyParams.code_verifier, RFC7636_VERIFIER);
209 assert.equal(req.bodyParams.redirect_uri, REDIRECT);
210 assert.equal(req.bodyParams.client_id, CLIENT_ID);
211 assert.equal(req.bodyParams.client_secret, undefined);
212 assert.ok(!req.body.includes('client_secret'));
213 });
214 it('throws on a non-https token endpoint', () => {
215 assert.throws(() => buildTokenRequest({ tokenEndpoint: 'http://x/token', clientId: CLIENT_ID, code: 'c', codeVerifier: RFC7636_VERIFIER, redirectUri: REDIRECT }), /https/);
216 });
217 it('throws on a malformed verifier', () => {
218 assert.throws(() => buildTokenRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: 'c', codeVerifier: 'tooshort', redirectUri: REDIRECT }), /verifier/);
219 });
220 });
221
222 describe('buildRefreshRequest', () => {
223 it('builds a refresh_token grant with no client_secret', () => {
224 const req = buildRefreshRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, refreshToken: 'r-1' });
225 assert.equal(req.bodyParams.grant_type, 'refresh_token');
226 assert.equal(req.bodyParams.refresh_token, 'r-1');
227 assert.equal(req.bodyParams.client_id, CLIENT_ID);
228 assert.equal(req.bodyParams.client_secret, undefined);
229 });
230 it('includes scope when provided', () => {
231 const req = buildRefreshRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, refreshToken: 'r-1', scopes: ['vault:read'] });
232 assert.equal(req.bodyParams.scope, 'vault:read');
233 });
234 });
235
236 describe('validateTokenResponse — RFC 6749 §5.1/§5.2', () => {
237 it('accepts a well-formed bearer response', () => {
238 const r = validateTokenResponse({ access_token: 'jwt', token_type: 'Bearer', expires_in: 3600, refresh_token: 'r', scope: 'vault:read vault:write' });
239 assert.equal(r.ok, true);
240 assert.equal(r.accessToken, 'jwt');
241 assert.equal(r.refreshToken, 'r');
242 assert.equal(r.expiresIn, 3600);
243 assert.equal(r.tokenType, 'Bearer');
244 assert.equal(r.scope, 'vault:read vault:write');
245 });
246 it('accepts a response without a refresh token', () => {
247 const r = validateTokenResponse({ access_token: 'jwt', token_type: 'bearer', expires_in: 60 });
248 assert.equal(r.ok, true);
249 assert.equal(r.refreshToken, null);
250 });
251 it('rejects a wrong token_type', () => {
252 assert.equal(validateTokenResponse({ access_token: 'jwt', token_type: 'mac', expires_in: 60 }).ok, false);
253 });
254 it('rejects a missing/zero/negative expires_in', () => {
255 assert.equal(validateTokenResponse({ access_token: 'jwt', token_type: 'Bearer' }).ok, false);
256 assert.equal(validateTokenResponse({ access_token: 'jwt', token_type: 'Bearer', expires_in: 0 }).ok, false);
257 assert.equal(validateTokenResponse({ access_token: 'jwt', token_type: 'Bearer', expires_in: -1 }).ok, false);
258 });
259 it('surfaces invalid_grant as an errorCode', () => {
260 const r = validateTokenResponse({ error: 'invalid_grant' });
261 assert.equal(r.ok, false);
262 assert.equal(r.errorCode, 'invalid_grant');
263 });
264 });
265
266 describe('decideTokenRefresh', () => {
267 it("returns 'valid' well before expiry", () => {
268 assert.equal(decideTokenRefresh({ expiresAt: 100_000, now: 0, skewMs: 1000 }), 'valid');
269 });
270 it("returns 'refresh' inside the skew window", () => {
271 assert.equal(decideTokenRefresh({ expiresAt: 1000, now: 990, skewMs: 30 }), 'refresh');
272 });
273 it("returns 'refresh' past expiry when a refresh window remains", () => {
274 assert.equal(decideTokenRefresh({ expiresAt: 1000, now: 2000, refreshExpiresAt: 10_000 }), 'refresh');
275 });
276 it("returns 'reauth' once the refresh window has elapsed", () => {
277 assert.equal(decideTokenRefresh({ expiresAt: 1000, now: 20_000, refreshExpiresAt: 10_000 }), 'reauth');
278 });
279 it("fails closed to 'reauth' on malformed input", () => {
280 assert.equal(decideTokenRefresh({ expiresAt: NaN, now: 0 }), 'reauth');
281 assert.equal(decideTokenRefresh({ expiresAt: 1000, now: 'x' }), 'reauth');
282 assert.equal(decideTokenRefresh(undefined), 'reauth');
283 });
284 });
285
286 describe('constantTimeEqual', () => {
287 it('is correct as a primitive', () => {
288 assert.equal(constantTimeEqual('abc', 'abc'), true);
289 assert.equal(constantTimeEqual('abc', 'abd'), false);
290 assert.equal(constantTimeEqual('', 'abc'), false);
291 assert.equal(constantTimeEqual('abc', 'abcd'), false);
292 assert.equal(constantTimeEqual(undefined, 'abc'), false);
293 });
294 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago