native-oauth-c1-c6-data-integrity.test.mjs
201 lines 8.9 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Data-integrity tests for native OAuth C1–C6 changes.
3 * Tier 5 of 7 — docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §7.
4 *
5 * Verifies:
6 * - Single-use codes never double-spend (consume is atomic read+delete)
7 * - Refresh family invariants hold under the durable store
8 * - No scope drift on refresh (ceiling re-applied, never widens)
9 * - iss byte-stability vs discovery metadata
10 * - bindUserToCode is idempotent on the same code+userId
11 * - Corrupt/partial file gracefully normalizes to empty store (fail-closed)
12 */
13
14 import assert from 'node:assert/strict';
15 import { describe, it, before, after } from 'node:test';
16 import { createHash, randomUUID } from 'node:crypto';
17 import fs from 'node:fs/promises';
18 import path from 'node:path';
19 import os from 'node:os';
20
21 function sha256b64url(s) {
22 return createHash('sha256').update(s).digest('base64url');
23 }
24
25 describe('C1–C6 Data Integrity', () => {
26 let tmpDir;
27
28 before(async () => {
29 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-oauth-data-integrity-'));
30 process.env.KNOWTATION_GATEWAY_DATA_DIR = tmpDir;
31 });
32
33 after(async () => {
34 delete process.env.KNOWTATION_GATEWAY_DATA_DIR;
35 await fs.rm(tmpDir, { recursive: true, force: true });
36 });
37
38 // ── Single-use code invariant ─────────────────────────────────────────────
39
40 it('DI-1: authorization code is single-use (sequential consume: second always null)', async () => {
41 const { savePendingCode, consumePendingCode } = await import('../hub/gateway/native-as-store.mjs');
42 const code = 'di-single-use-' + randomUUID();
43 await savePendingCode(code, {
44 clientId: 'di-client',
45 codeChallenge: sha256b64url('di-verifier'),
46 redirectUri: 'http://127.0.0.1:1234/cb',
47 });
48
49 // First consume must succeed
50 const r1 = await consumePendingCode(code);
51 assert.ok(r1, 'first consume must return the entry');
52 assert.equal(r1.clientId, 'di-client');
53
54 // Second consume must always return null (code is single-use)
55 const r2 = await consumePendingCode(code);
56 assert.equal(r2, null, 'second consume must return null (code already consumed)');
57
58 // Third consume also null
59 const r3 = await consumePendingCode(code);
60 assert.equal(r3, null, 'third consume must return null');
61 });
62
63 it('DI-2: consuming a non-existent code returns null gracefully', async () => {
64 const { consumePendingCode } = await import('../hub/gateway/native-as-store.mjs');
65 const result = await consumePendingCode('definitely-not-a-code-' + randomUUID());
66 assert.equal(result, null);
67 });
68
69 // ── Refresh family invariants ─────────────────────────────────────────────
70
71 it('DI-3: refresh family expires_at is always <= original family ceiling', async () => {
72 const { issueToken, rotateToken } = await import('../hub/lib/refresh-token-core.mjs');
73 const now = Date.now();
74 const familyTtlMs = 90 * 24 * 60 * 60 * 1000;
75 const { records: r0, token: t0 } = issueToken({}, { sub: 'google:di-family', now, familyTtlMs });
76 const [firstId] = t0.split('.');
77 const originalFamilyExpiry = r0[firstId].family_expires_at;
78
79 let records = r0;
80 let token = t0;
81 for (let i = 0; i < 5; i++) {
82 const result = rotateToken(records, token, { now: now + i * 1000 });
83 assert.ok(result.ok);
84 const [newId] = result.token.split('.');
85 const newRecord = result.records[newId];
86 assert.equal(
87 newRecord.family_expires_at,
88 originalFamilyExpiry,
89 `rotation ${i + 1}: family_expires_at must not change`
90 );
91 records = result.records;
92 token = result.token;
93 }
94 });
95
96 it('DI-4: scope is never widened on refresh (ceiling re-applied from current role)', async () => {
97 // Simulate what the native refresh endpoint does: re-derive ceiling on every refresh
98 function grantedScopes(sub) {
99 if (sub.includes('admin')) return ['vault:read', 'vault:write', 'admin'];
100 return ['vault:read', 'vault:write'];
101 }
102 function applyScopeCeiling(requested, ceiling) {
103 if (!Array.isArray(requested) || requested.length === 0) return [...ceiling];
104 return requested.filter((s) => ceiling.includes(s));
105 }
106
107 // A member sub requests admin scope at refresh — must not receive it
108 const sub = 'google:plain-member-di4';
109 const ceiling = grantedScopes(sub);
110 const requestedScopes = ['admin', 'vault:read', 'vault:write'];
111 const result = applyScopeCeiling(requestedScopes, ceiling);
112 assert.ok(!result.includes('admin'), 'admin must not appear in refresh scope');
113 assert.deepEqual(result, ['vault:read', 'vault:write']);
114 });
115
116 it('DI-5: scope ceiling does not shrink scopes arbitrarily (member gets full vault access)', async () => {
117 function grantedScopes(sub) {
118 return ['vault:read', 'vault:write'];
119 }
120 function applyScopeCeiling(requested, ceiling) {
121 if (!Array.isArray(requested) || requested.length === 0) return [...ceiling];
122 return requested.filter((s) => ceiling.includes(s));
123 }
124 const sub = 'google:normal-member';
125 const ceiling = grantedScopes(sub);
126 // Empty requested → full ceiling
127 const result = applyScopeCeiling([], ceiling);
128 assert.deepEqual(result, ['vault:read', 'vault:write']);
129 });
130
131 // ── iss byte stability ────────────────────────────────────────────────────
132
133 it('DI-6: issuerUrl is byte-stable across multiple construction calls', () => {
134 const baseUrl = 'https://gateway.knowtation.ai';
135 const issuer1 = `${baseUrl.replace(/\/$/, '')}/api/v1/auth/native`;
136 const issuer2 = `${baseUrl.replace(/\/$/, '')}/api/v1/auth/native`;
137 assert.equal(issuer1, issuer2, 'issuerUrl must be deterministic');
138 assert.ok(!issuer1.endsWith('/'), 'issuerUrl must not have trailing slash');
139 });
140
141 it('DI-7: MCP provider _issuerUrl byte-stable vs SDK discovery issuer.href', async () => {
142 const { KnowtationOAuthProvider } = await import('../hub/gateway/mcp-oauth-provider.mjs');
143 const baseUrl = 'https://gateway.knowtation.ai';
144 const provider = new KnowtationOAuthProvider({ sessionSecret: 's', baseUrl });
145 // SDK uses: issuer.href where issuer = new URL(BASE_URL)
146 const sdkIssuer = new URL(baseUrl).href;
147 assert.equal(provider._issuerUrl, sdkIssuer, '_issuerUrl must match SDK discovery issuer');
148 });
149
150 // ── Store normalization (fail-closed) ─────────────────────────────────────
151
152 it('DI-8: corrupt JSON file normalizes to empty store (fail-closed, no crash)', async () => {
153 const filePath = path.join(tmpDir, 'native_pending_codes.json');
154 await fs.writeFile(filePath, '{ "codes": { corrupt json', 'utf8');
155
156 const { consumePendingCode } = await import('../hub/gateway/native-as-store.mjs');
157 const result = await consumePendingCode('any-code');
158 assert.equal(result, null, 'corrupt store must normalize to null (fail-closed)');
159 });
160
161 it('DI-9: completely empty file returns null gracefully', async () => {
162 const filePath = path.join(tmpDir, 'native_pending_codes.json');
163 await fs.writeFile(filePath, '', 'utf8');
164
165 const { consumePendingCode } = await import('../hub/gateway/native-as-store.mjs');
166 const result = await consumePendingCode('any-code');
167 assert.equal(result, null);
168 });
169
170 it('DI-10: bindUserToCode is idempotent for same code+userId', async () => {
171 const { savePendingCode, bindUserToCode, consumePendingCode } = await import('../hub/gateway/native-as-store.mjs');
172 const code = 'di-idempotent-' + randomUUID();
173 await savePendingCode(code, {
174 clientId: 'di-idem-client',
175 codeChallenge: sha256b64url('di-idem-v'),
176 redirectUri: 'http://127.0.0.1:1235/cb',
177 });
178
179 const sub = 'google:idempotent-user';
180 const b1 = await bindUserToCode(code, sub);
181 const b2 = await bindUserToCode(code, sub); // same userId again
182 assert.ok(b1, 'first bind must succeed');
183 assert.ok(b2, 'second bind with same userId must also succeed (idempotent)');
184 const entry = await consumePendingCode(code);
185 assert.equal(entry.userId, sub, 'userId must be correct after idempotent bind');
186 });
187
188 it('DI-11: file mode is 0o600 after write (no group/world read)', async () => {
189 const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs');
190 const code = 'di-mode-' + randomUUID();
191 await savePendingCode(code, {
192 clientId: 'mode-client',
193 codeChallenge: sha256b64url('mode-v'),
194 redirectUri: 'http://127.0.0.1:1236/cb',
195 });
196 const filePath = path.join(tmpDir, 'native_pending_codes.json');
197 const stat = await fs.stat(filePath);
198 const mode = stat.mode & 0o777;
199 assert.equal(mode, 0o600, 'store file must be mode 0o600 (owner only)');
200 });
201 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago