native-oauth-c1-c6-data-integrity.test.mjs
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