native-oauth-c1-c6-e2e.test.mjs
366 lines 14.8 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * End-to-end tests for native OAuth C1–C6 changes.
3 * Tier 3 of 7 — docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §7.
4 *
5 * Simulates the full companion sign-in flow:
6 * 1. Client registers (POST /register)
7 * 2. Client starts authorization (GET /authorize)
8 * 3. IDP callback binds userId (completeNativeAuthorization)
9 * 4. Client exchanges code for tokens (POST /token)
10 * 5. Client refreshes tokens (POST /token with grant_type=refresh_token)
11 * 6. Client revokes the refresh token (POST /revoke)
12 *
13 * Also verifies the mcp_access regression: MCP clients are UNAFFECTED by native changes.
14 * All flows run against the actual Express router (no mocking of router internals).
15 */
16
17 import assert from 'node:assert/strict';
18 import { describe, it, before, after } from 'node:test';
19 import { createHash, randomUUID } from 'node:crypto';
20 import express from 'express';
21 import fs from 'node:fs/promises';
22 import path from 'node:path';
23 import os from 'node:os';
24 import http from 'node:http';
25
26 function sha256b64url(s) {
27 return createHash('sha256').update(s).digest('base64url');
28 }
29
30 /**
31 * Minimal HTTP test client that calls a local express app.
32 */
33 function testClient(app) {
34 const server = http.createServer(app);
35 let baseUrl;
36
37 return {
38 start() {
39 return new Promise((resolve) => {
40 server.listen(0, '127.0.0.1', () => {
41 const port = server.address().port;
42 baseUrl = `http://127.0.0.1:${port}`;
43 resolve(baseUrl);
44 });
45 });
46 },
47 stop() {
48 return new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
49 },
50 async fetch(method, path, body, contentType = 'application/x-www-form-urlencoded') {
51 const url = new URL(path, baseUrl);
52 const bodyStr = body
53 ? contentType === 'application/json'
54 ? JSON.stringify(body)
55 : new URLSearchParams(body).toString()
56 : undefined;
57 const res = await fetch(url.toString(), {
58 method,
59 headers: {
60 'Content-Type': contentType,
61 'User-Agent': 'knowtation-companion-e2e-test',
62 },
63 body: bodyStr,
64 redirect: 'manual',
65 });
66 const text = await res.text();
67 let json = null;
68 try { json = JSON.parse(text); } catch (_) { /* not JSON */ }
69 return { status: res.status, headers: res.headers, json, text };
70 },
71 };
72 }
73
74 describe('C1–C6 E2E: full native companion sign-in flow', () => {
75 let tmpDir;
76 let client;
77 let completeNativeAuthorization;
78 let callbackApp;
79 const BASE_URL_PLACEHOLDER = 'http://localhost:0'; // replaced after server start
80
81 before(async () => {
82 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-oauth-e2e-'));
83 process.env.KNOWTATION_GATEWAY_DATA_DIR = tmpDir;
84
85 const jwt = (await import('jsonwebtoken')).default;
86 const SECRET = 'e2e-test-secret-native-oauth';
87
88 function issueAccessToken(sub) {
89 const idx = sub.indexOf(':');
90 const provider = idx > 0 ? sub.slice(0, idx) : '';
91 const id = idx > 0 ? sub.slice(idx + 1) : sub;
92 const role = sub.includes('admin') ? 'admin' : 'member';
93 return jwt.sign({ sub, provider, id, name: '', role }, SECRET, { expiresIn: '24h' });
94 }
95
96 function grantedScopes(sub) {
97 const role = sub.includes('admin') ? 'admin' : 'member';
98 if (role === 'admin') return ['vault:read', 'vault:write', 'admin'];
99 return ['vault:read', 'vault:write'];
100 }
101
102 // Minimal durable refresh store backed by refresh-token-core
103 const { createGatewayRefreshStore } = await import('../hub/gateway/refresh-token-store.mjs');
104 const refreshStore = createGatewayRefreshStore();
105
106 const { createNativeOAuthRouter } = await import('../hub/gateway/native-oauth-provider.mjs');
107 const result = createNativeOAuthRouter({
108 baseUrl: BASE_URL_PLACEHOLDER,
109 loginUrl: `${BASE_URL_PLACEHOLDER}/auth/login`,
110 issueAccessToken,
111 grantedScopes,
112 refreshStore,
113 });
114
115 callbackApp = express();
116 callbackApp.use('/api/v1/auth/native', result.router);
117 completeNativeAuthorization = result.completeNativeAuthorization;
118
119 client = testClient(callbackApp);
120 await client.start();
121 });
122
123 after(async () => {
124 await client.stop();
125 delete process.env.KNOWTATION_GATEWAY_DATA_DIR;
126 await fs.rm(tmpDir, { recursive: true, force: true });
127 });
128
129 it('C1/C2/C3/C5/C6 – full companion sign-in and refresh flow', async () => {
130 // Step 1: register as a native client
131 const regRes = await client.fetch('POST', '/api/v1/auth/native/register', {
132 redirect_uris: ['http://127.0.0.1:54380/callback'],
133 token_endpoint_auth_method: 'none',
134 grant_types: ['authorization_code', 'refresh_token'],
135 }, 'application/json');
136 assert.equal(regRes.status, 201, 'registration must succeed');
137 const registeredClient = regRes.json;
138 assert.ok(registeredClient.client_id, 'must get client_id');
139
140 // Step 2: start authorization
141 const verifier = 'e2e-code-verifier-long-enough-to-be-valid-abcdef';
142 const challenge = sha256b64url(verifier);
143 const redirectUri = 'http://127.0.0.1:54380/callback';
144 const state = 'e2e-state-' + randomUUID();
145
146 const authRes = await client.fetch(
147 'GET',
148 `/api/v1/auth/native/authorize?` +
149 `client_id=${encodeURIComponent(registeredClient.client_id)}&` +
150 `redirect_uri=${encodeURIComponent(redirectUri)}&` +
151 `code_challenge=${encodeURIComponent(challenge)}&` +
152 `code_challenge_method=S256&` +
153 `state=${encodeURIComponent(state)}&` +
154 `scope=vault%3Aread`
155 );
156 // The authorize endpoint redirects to the login page
157 assert.equal(authRes.status, 302, 'authorize must redirect to login');
158 const loginLocation = authRes.headers.get('location');
159 assert.ok(loginLocation && loginLocation.includes('native_state='), 'must include native_state');
160
161 // Extract native_state from the login URL
162 const loginUrl = new URL(loginLocation);
163 const nativeStateB64 = loginUrl.searchParams.get('native_state');
164 assert.ok(nativeStateB64, 'must have native_state param');
165
166 // Extract the code from the native_state
167 const nativeState = JSON.parse(Buffer.from(nativeStateB64, 'base64url').toString());
168 const code = nativeState.code;
169 assert.ok(code, 'native state must contain code');
170
171 // Step 3: simulate IDP callback (normally done by Google/GitHub)
172 const sub = 'google:e2e-user-001';
173 let capturedRedirectUrl = null;
174 const fakeRes = {
175 status(code) { this._code = code; return this; },
176 json(data) { this._body = data; },
177 redirect(loc) { capturedRedirectUrl = loc; },
178 };
179 await completeNativeAuthorization(nativeStateB64, sub, fakeRes);
180
181 assert.ok(capturedRedirectUrl, 'completeNativeAuthorization must redirect');
182 const callbackUrl = new URL(capturedRedirectUrl);
183 assert.equal(callbackUrl.searchParams.get('code'), code, 'code must be in callback redirect');
184 assert.equal(callbackUrl.searchParams.get('state'), state, 'state must be preserved');
185
186 // C3: iss must be present and correct
187 const issInRedirect = callbackUrl.searchParams.get('iss');
188 assert.ok(issInRedirect, 'iss must be present in redirect');
189 assert.ok(issInRedirect.endsWith('/api/v1/auth/native'), 'iss must end with /api/v1/auth/native');
190
191 // Step 4: exchange code for tokens
192 const tokenRes = await client.fetch('POST', '/api/v1/auth/native/token', {
193 grant_type: 'authorization_code',
194 client_id: registeredClient.client_id,
195 code: code,
196 code_verifier: verifier,
197 redirect_uri: redirectUri,
198 });
199 assert.equal(tokenRes.status, 200, 'token exchange must succeed');
200 const tokens = tokenRes.json;
201 assert.ok(tokens.access_token, 'must return access_token');
202 assert.ok(tokens.refresh_token, 'C2: must return refresh_token in body (not cookie)');
203 assert.equal(tokens.token_type, 'Bearer');
204 assert.ok(tokens.expires_in > 0);
205
206 // C1: decode and verify JWT shape
207 const jwt = (await import('jsonwebtoken')).default;
208 const decoded = jwt.decode(tokens.access_token);
209 assert.equal(decoded.sub, sub);
210 assert.equal(decoded.provider, 'google');
211 assert.ok(decoded.role, 'must have role claim');
212 assert.ok(!decoded.type, 'C1: must not have type:mcp_access claim');
213 assert.ok(!decoded.scopes, 'C1: must not embed scopes in JWT payload');
214
215 // C6: scope in response must not exceed member ceiling
216 const scopeInResponse = (tokens.scope || '').split(' ');
217 assert.ok(!scopeInResponse.includes('admin'), 'C6: member must not receive admin scope');
218 assert.ok(scopeInResponse.includes('vault:read'), 'must include vault:read');
219
220 // Step 5: refresh the token
221 const refreshRes = await client.fetch('POST', '/api/v1/auth/native/token', {
222 grant_type: 'refresh_token',
223 client_id: registeredClient.client_id,
224 refresh_token: tokens.refresh_token,
225 });
226 assert.equal(refreshRes.status, 200, 'C2: refresh must succeed');
227 const refreshed = refreshRes.json;
228 assert.ok(refreshed.access_token, 'must return new access_token');
229 assert.ok(refreshed.refresh_token, 'C2: must return new refresh_token in body');
230 assert.ok(refreshed.refresh_token !== tokens.refresh_token, 'must be a different token');
231
232 // Step 6: revoke the refresh token
233 const revokeRes = await client.fetch('POST', '/api/v1/auth/native/revoke', {
234 token: refreshed.refresh_token,
235 });
236 assert.equal(revokeRes.status, 200, 'revocation must return 200');
237
238 // Step 7: replay revoked token must fail
239 const replayRes = await client.fetch('POST', '/api/v1/auth/native/token', {
240 grant_type: 'refresh_token',
241 client_id: registeredClient.client_id,
242 refresh_token: refreshed.refresh_token,
243 });
244 assert.equal(replayRes.status, 401, 'revoked token must not rotate');
245 });
246
247 it('C5 – wrong redirect_uri at token exchange returns 400', async () => {
248 const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs');
249 const regRes = await client.fetch('POST', '/api/v1/auth/native/register', {
250 redirect_uris: ['http://127.0.0.1:54381/callback'],
251 token_endpoint_auth_method: 'none',
252 }, 'application/json');
253 const registeredClient = regRes.json;
254
255 const code = randomUUID();
256 const verifier = 'verifier-c5-e2e-test';
257 await savePendingCode(code, {
258 clientId: registeredClient.client_id,
259 codeChallenge: sha256b64url(verifier),
260 redirectUri: 'http://127.0.0.1:54381/callback',
261 scopes: [],
262 });
263 await bindUserToCode(code, 'google:user-c5');
264
265 const tokenRes = await client.fetch('POST', '/api/v1/auth/native/token', {
266 grant_type: 'authorization_code',
267 client_id: registeredClient.client_id,
268 code,
269 code_verifier: verifier,
270 redirect_uri: 'http://127.0.0.1:54382/callback', // wrong port
271 });
272 assert.equal(tokenRes.status, 400, 'C5: redirect_uri mismatch must return 400');
273 assert.equal(tokenRes.json.error, 'invalid_grant');
274 });
275
276 it('C5 – PKCE failure returns 400 invalid_grant', async () => {
277 const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs');
278 const regRes = await client.fetch('POST', '/api/v1/auth/native/register', {
279 redirect_uris: ['http://127.0.0.1:54383/callback'],
280 token_endpoint_auth_method: 'none',
281 }, 'application/json');
282 const registeredClient = regRes.json;
283
284 const code = randomUUID();
285 const correctVerifier = 'correct-verifier-string';
286 await savePendingCode(code, {
287 clientId: registeredClient.client_id,
288 codeChallenge: sha256b64url(correctVerifier),
289 redirectUri: 'http://127.0.0.1:54383/callback',
290 });
291 await bindUserToCode(code, 'google:pkce-test-user');
292
293 const tokenRes = await client.fetch('POST', '/api/v1/auth/native/token', {
294 grant_type: 'authorization_code',
295 client_id: registeredClient.client_id,
296 code,
297 code_verifier: 'wrong-verifier',
298 redirect_uri: 'http://127.0.0.1:54383/callback',
299 });
300 assert.equal(tokenRes.status, 400);
301 assert.equal(tokenRes.json.error, 'invalid_grant');
302 assert.ok(tokenRes.json.error_description.includes('PKCE'));
303 });
304
305 it('C6 – admin sub receives admin scope', async () => {
306 const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs');
307 const regRes = await client.fetch('POST', '/api/v1/auth/native/register', {
308 redirect_uris: ['http://127.0.0.1:54384/callback'],
309 token_endpoint_auth_method: 'none',
310 }, 'application/json');
311 const registeredClient = regRes.json;
312
313 const code = randomUUID();
314 const verifier = 'admin-verifier-c6';
315 await savePendingCode(code, {
316 clientId: registeredClient.client_id,
317 codeChallenge: sha256b64url(verifier),
318 redirectUri: 'http://127.0.0.1:54384/callback',
319 scopes: ['vault:read', 'vault:write', 'admin'],
320 });
321 await bindUserToCode(code, 'google:admin_user'); // contains 'admin' → admin role
322
323 const tokenRes = await client.fetch('POST', '/api/v1/auth/native/token', {
324 grant_type: 'authorization_code',
325 client_id: registeredClient.client_id,
326 code,
327 code_verifier: verifier,
328 redirect_uri: 'http://127.0.0.1:54384/callback',
329 });
330 assert.equal(tokenRes.status, 200);
331 const scopes = (tokenRes.json.scope || '').split(' ');
332 assert.ok(scopes.includes('admin'), 'C6: admin sub must receive admin scope');
333 });
334
335 it('Regression: non-loopback redirect_uri rejected at registration', async () => {
336 const regRes = await client.fetch('POST', '/api/v1/auth/native/register', {
337 redirect_uris: ['https://evil.com/callback'],
338 token_endpoint_auth_method: 'none',
339 }, 'application/json');
340 assert.equal(regRes.status, 400, 'non-loopback URI must be rejected at registration');
341 assert.ok(
342 regRes.json.error === 'invalid_redirect_uri' || regRes.json.error === 'invalid_client_metadata',
343 'error code must indicate invalid redirect'
344 );
345 });
346
347 it('Regression: localhost redirect_uri rejected at registration', async () => {
348 const regRes = await client.fetch('POST', '/api/v1/auth/native/register', {
349 redirect_uris: ['http://localhost:8080/callback'],
350 token_endpoint_auth_method: 'none',
351 }, 'application/json');
352 assert.equal(regRes.status, 400, 'localhost URI must be rejected (RFC 8252 §8.3)');
353 });
354
355 it('Discovery endpoint returns correct issuer and endpoints', async () => {
356 const discRes = await client.fetch('GET', '/api/v1/auth/native/.well-known/oauth-authorization-server');
357 assert.equal(discRes.status, 200);
358 const meta = discRes.json;
359 assert.ok(meta.issuer && meta.issuer.endsWith('/api/v1/auth/native'));
360 assert.ok(meta.authorization_endpoint);
361 assert.ok(meta.token_endpoint);
362 assert.ok(meta.registration_endpoint);
363 assert.deepEqual(meta.code_challenge_methods_supported, ['S256']);
364 assert.deepEqual(meta.token_endpoint_auth_methods_supported, ['none']);
365 });
366 });
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