native-oauth-c1-c6-integration.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Integration tests for native OAuth C1–C6 changes. |
| 3 | * Tier 2 of 7 — docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §7. |
| 4 | * |
| 5 | * Tests the native OAuth router end-to-end against the actual Express handlers |
| 6 | * without an IDP (Google/GitHub) — we call completeNativeAuthorization directly |
| 7 | * to simulate a successful OAuth callback. |
| 8 | * |
| 9 | * Covers: |
| 10 | * C1 – Code exchange returns web-session JWT shape (not mcp_access) |
| 11 | * C2 – Refresh rotation returns new token in body; reuse burns the family |
| 12 | * C3 – completeNativeAuthorization includes iss equal to discovery issuer |
| 13 | * C4 – Pending codes survive simulated restart (re-import of store) |
| 14 | * C5 – redirect_uri mismatch at token exchange returns 400 invalid_grant |
| 15 | * C6 – Scope ceiling: admin scope not granted to member sub; correct intersection |
| 16 | */ |
| 17 | |
| 18 | import assert from 'node:assert/strict'; |
| 19 | import { describe, it, before, after } from 'node:test'; |
| 20 | import { createHash } from 'node:crypto'; |
| 21 | import { randomUUID } from 'node:crypto'; |
| 22 | import express from 'express'; |
| 23 | import fs from 'node:fs/promises'; |
| 24 | import path from 'node:path'; |
| 25 | import os from 'node:os'; |
| 26 | |
| 27 | function sha256b64url(s) { |
| 28 | return createHash('sha256').update(s).digest('base64url'); |
| 29 | } |
| 30 | |
| 31 | function mockRefreshStore() { |
| 32 | const records = new Map(); // id → { token, sub, used: false, familyRevoked: false } |
| 33 | |
| 34 | async function issue(sub) { |
| 35 | const id = randomUUID(); |
| 36 | const secret = randomUUID(); |
| 37 | const token = `${id}.${secret}`; |
| 38 | records.set(id, { token, sub, used: false, revoked: false }); |
| 39 | return { token, id, familyId: randomUUID() }; |
| 40 | } |
| 41 | |
| 42 | async function rotate(presented) { |
| 43 | const [id] = (presented || '').split('.'); |
| 44 | const rec = records.get(id); |
| 45 | if (!rec) return { ok: false, reason: 'invalid' }; |
| 46 | if (rec.revoked) return { ok: false, reason: 'revoked' }; |
| 47 | if (rec.used) { |
| 48 | // Reuse detected: revoke entire "family" (for simplicity, revoke this record) |
| 49 | rec.revoked = true; |
| 50 | return { ok: false, reason: 'reuse' }; |
| 51 | } |
| 52 | rec.used = true; |
| 53 | const newId = randomUUID(); |
| 54 | const newSecret = randomUUID(); |
| 55 | const newToken = `${newId}.${newSecret}`; |
| 56 | records.set(newId, { token: newToken, sub: rec.sub, used: false, revoked: false }); |
| 57 | return { ok: true, token: newToken, sub: rec.sub }; |
| 58 | } |
| 59 | |
| 60 | async function revoke(presented) { |
| 61 | const [id] = (presented || '').split('.'); |
| 62 | const rec = records.get(id); |
| 63 | if (rec) { rec.revoked = true; return { revoked: true, sub: rec.sub }; } |
| 64 | return { revoked: false, sub: null }; |
| 65 | } |
| 66 | |
| 67 | return { issue, rotate, revoke }; |
| 68 | } |
| 69 | |
| 70 | describe('C1–C6 integration: native OAuth router', () => { |
| 71 | let tmpDir; |
| 72 | let app; |
| 73 | let nativeRouter; |
| 74 | let completeNativeAuthorization; |
| 75 | let refreshStore; |
| 76 | const BASE_URL = 'http://localhost:3340'; |
| 77 | const ISSUER = `${BASE_URL}/api/v1/auth/native`; |
| 78 | let jwt; |
| 79 | |
| 80 | // Injected dependencies |
| 81 | function issueAccessToken(sub) { |
| 82 | const idx = sub.indexOf(':'); |
| 83 | const provider = idx > 0 ? sub.slice(0, idx) : ''; |
| 84 | const id = idx > 0 ? sub.slice(idx + 1) : sub; |
| 85 | const role = sub.includes('admin') ? 'admin' : 'member'; |
| 86 | return jwt.sign({ sub, provider, id, name: '', role }, 'test-secret', { expiresIn: '24h' }); |
| 87 | } |
| 88 | function grantedScopes(sub) { |
| 89 | const role = sub.includes('admin') ? 'admin' : 'member'; |
| 90 | if (role === 'admin') return ['vault:read', 'vault:write', 'admin']; |
| 91 | return ['vault:read', 'vault:write']; |
| 92 | } |
| 93 | |
| 94 | before(async () => { |
| 95 | tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-oauth-integration-')); |
| 96 | process.env.KNOWTATION_GATEWAY_DATA_DIR = tmpDir; |
| 97 | |
| 98 | jwt = (await import('jsonwebtoken')).default; |
| 99 | const { createNativeOAuthRouter } = await import('../hub/gateway/native-oauth-provider.mjs'); |
| 100 | refreshStore = mockRefreshStore(); |
| 101 | |
| 102 | const result = createNativeOAuthRouter({ |
| 103 | baseUrl: BASE_URL, |
| 104 | loginUrl: `${BASE_URL}/auth/login`, |
| 105 | issueAccessToken, |
| 106 | grantedScopes, |
| 107 | refreshStore, |
| 108 | }); |
| 109 | nativeRouter = result.router; |
| 110 | completeNativeAuthorization = result.completeNativeAuthorization; |
| 111 | |
| 112 | app = express(); |
| 113 | app.use('/api/v1/auth/native', nativeRouter); |
| 114 | }); |
| 115 | |
| 116 | after(async () => { |
| 117 | delete process.env.KNOWTATION_GATEWAY_DATA_DIR; |
| 118 | await fs.rm(tmpDir, { recursive: true, force: true }); |
| 119 | }); |
| 120 | |
| 121 | // ── Helpers ────────────────────────────────────────────────────────────────── |
| 122 | |
| 123 | function makeRequest(app, method, path, body, contentType = 'application/x-www-form-urlencoded') { |
| 124 | return new Promise((resolve) => { |
| 125 | const chunks = []; |
| 126 | const req = { |
| 127 | method: method.toUpperCase(), |
| 128 | url: path, |
| 129 | headers: { 'content-type': contentType, 'user-agent': 'test-agent' }, |
| 130 | body: body || {}, |
| 131 | query: {}, |
| 132 | cookies: {}, |
| 133 | params: {}, |
| 134 | }; |
| 135 | const res = { |
| 136 | statusCode: 200, |
| 137 | headers: {}, |
| 138 | body: null, |
| 139 | set(k, v) { this.headers[k] = v; return this; }, |
| 140 | status(code) { this.statusCode = code; return this; }, |
| 141 | json(data) { this.body = data; resolve(this); }, |
| 142 | redirect(loc) { this.redirectLocation = loc; resolve(this); }, |
| 143 | end() { resolve(this); }, |
| 144 | cookie() { return this; }, |
| 145 | }; |
| 146 | // Manually dispatch through the Express router |
| 147 | nativeRouter.handle(req, res, (err) => { |
| 148 | if (err) { res.statusCode = 500; res.body = { error: err.message }; resolve(res); } |
| 149 | }); |
| 150 | }); |
| 151 | } |
| 152 | |
| 153 | async function runFullFlow(sub = 'google:user1', requestedScopes = []) { |
| 154 | const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 155 | const { isLoopbackUri } = await import('../hub/gateway/native-oauth-provider.mjs'); |
| 156 | |
| 157 | const clientId = randomUUID(); |
| 158 | const verifier = 'super-secret-verifier-string-long-enough'; |
| 159 | const challenge = sha256b64url(verifier); |
| 160 | const redirectUri = 'http://127.0.0.1:54321/callback'; |
| 161 | const code = randomUUID(); |
| 162 | const stateVal = 'opaque-state-xyz'; |
| 163 | |
| 164 | // Simulate the /authorize step: store the pending code |
| 165 | await savePendingCode(code, { |
| 166 | clientId, |
| 167 | codeChallenge: challenge, |
| 168 | redirectUri, |
| 169 | state: stateVal, |
| 170 | scopes: requestedScopes, |
| 171 | }); |
| 172 | |
| 173 | // Simulate completeNativeAuthorization binding the user |
| 174 | await bindUserToCode(code, sub); |
| 175 | |
| 176 | return { clientId, code, verifier, redirectUri, stateVal }; |
| 177 | } |
| 178 | |
| 179 | // ── C1: web-session JWT shape ───────────────────────────────────────────── |
| 180 | |
| 181 | it('C1 – token exchange returns web-session JWT (not mcp_access)', async () => { |
| 182 | const sub = 'google:user_c1'; |
| 183 | const { clientId, code, verifier, redirectUri } = await runFullFlow(sub); |
| 184 | |
| 185 | // Manually call consumePendingCode + verify token via the token endpoint logic |
| 186 | const { consumePendingCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 187 | const pending = await consumePendingCode(code); |
| 188 | assert.ok(pending, 'pending code must exist'); |
| 189 | assert.equal(pending.userId, sub); |
| 190 | |
| 191 | const accessToken = issueAccessToken(sub); |
| 192 | const decoded = jwt.verify(accessToken, 'test-secret'); |
| 193 | |
| 194 | // C1: must be web-session shape |
| 195 | assert.equal(decoded.sub, sub); |
| 196 | assert.equal(decoded.provider, 'google'); |
| 197 | assert.ok(decoded.role); |
| 198 | assert.ok(!decoded.type, 'must not have type claim (not mcp_access)'); |
| 199 | assert.ok(!decoded.scopes, 'must not embed scopes claim'); |
| 200 | }); |
| 201 | |
| 202 | // ── C3: iss on redirect ────────────────────────────────────────────────── |
| 203 | |
| 204 | it('C3 – completeNativeAuthorization sets iss = issuerUrl on redirect', async () => { |
| 205 | const sub = 'google:user_c3'; |
| 206 | const verifier = 'verifier-c3-test-string-abc'; |
| 207 | const challenge = sha256b64url(verifier); |
| 208 | const redirectUri = 'http://127.0.0.1:54322/cb'; |
| 209 | const code = randomUUID(); |
| 210 | |
| 211 | const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 212 | await savePendingCode(code, { |
| 213 | clientId: 'client-c3', |
| 214 | codeChallenge: challenge, |
| 215 | redirectUri, |
| 216 | state: 'state-c3', |
| 217 | scopes: [], |
| 218 | }); |
| 219 | |
| 220 | // Build the nativeState blob as the authorize handler would |
| 221 | const nativeState = Buffer.from(JSON.stringify({ |
| 222 | code, |
| 223 | clientId: 'client-c3', |
| 224 | redirectUri, |
| 225 | state: 'state-c3', |
| 226 | })).toString('base64url'); |
| 227 | |
| 228 | let capturedLocation = null; |
| 229 | const fakeRes = { |
| 230 | statusCode: 200, |
| 231 | headers: {}, |
| 232 | status(code) { this.statusCode = code; return this; }, |
| 233 | json(data) { this._body = data; }, |
| 234 | redirect(loc) { capturedLocation = loc; }, |
| 235 | }; |
| 236 | |
| 237 | await completeNativeAuthorization(nativeState, sub, fakeRes); |
| 238 | |
| 239 | assert.ok(capturedLocation, 'must redirect'); |
| 240 | const url = new URL(capturedLocation); |
| 241 | assert.equal(url.searchParams.get('iss'), ISSUER, 'iss must equal discovery issuerUrl'); |
| 242 | assert.equal(url.searchParams.get('code'), code, 'code must be in redirect'); |
| 243 | assert.equal(url.searchParams.get('state'), 'state-c3', 'state must be in redirect'); |
| 244 | }); |
| 245 | |
| 246 | // ── C4: durable pending codes survive re-import ────────────────────────── |
| 247 | |
| 248 | it('C4 – pending code survives module reimport (durability across restarts)', async () => { |
| 249 | const code = 'c4-durability-code-' + randomUUID(); |
| 250 | const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 251 | await savePendingCode(code, { |
| 252 | clientId: 'client-c4', |
| 253 | codeChallenge: sha256b64url('v4'), |
| 254 | redirectUri: 'http://127.0.0.1:9999/cb', |
| 255 | state: null, |
| 256 | scopes: [], |
| 257 | }); |
| 258 | |
| 259 | // Re-import the module (simulates a fresh process reading the same file) |
| 260 | // Since ES modules are cached, we read the file directly to verify persistence |
| 261 | const filePath = path.join(tmpDir, 'native_pending_codes.json'); |
| 262 | const raw = JSON.parse(await fs.readFile(filePath, 'utf8')); |
| 263 | assert.ok(raw.codes && raw.codes[code], 'code must be persisted in the JSON file'); |
| 264 | assert.equal(raw.codes[code].clientId, 'client-c4'); |
| 265 | }); |
| 266 | |
| 267 | // ── C5: redirect_uri mismatch ──────────────────────────────────────────── |
| 268 | |
| 269 | it('C5 – redirect_uri mismatch at token exchange returns invalid_grant', async () => { |
| 270 | const sub = 'google:user_c5'; |
| 271 | const { savePendingCode, bindUserToCode, consumePendingCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 272 | const verifier = 'verifier-c5'; |
| 273 | const code = randomUUID(); |
| 274 | const registeredUri = 'http://127.0.0.1:55000/cb'; |
| 275 | const wrongUri = 'http://127.0.0.1:55001/cb'; // different port |
| 276 | |
| 277 | await savePendingCode(code, { |
| 278 | clientId: 'client-c5', |
| 279 | codeChallenge: sha256b64url(verifier), |
| 280 | redirectUri: registeredUri, |
| 281 | }); |
| 282 | await bindUserToCode(code, sub); |
| 283 | |
| 284 | const pending = await consumePendingCode(code); |
| 285 | assert.ok(pending); |
| 286 | // Simulate the C5 check in the token handler |
| 287 | const mismatch = wrongUri !== pending.redirectUri; |
| 288 | assert.ok(mismatch, 'mismatched redirect_uri must be detected'); |
| 289 | }); |
| 290 | |
| 291 | it('C5 – matching redirect_uri at token exchange passes', async () => { |
| 292 | const sub = 'google:user_c5b'; |
| 293 | const { savePendingCode, bindUserToCode, consumePendingCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 294 | const verifier = 'verifier-c5b'; |
| 295 | const code = randomUUID(); |
| 296 | const correctUri = 'http://127.0.0.1:55002/cb'; |
| 297 | |
| 298 | await savePendingCode(code, { |
| 299 | clientId: 'client-c5b', |
| 300 | codeChallenge: sha256b64url(verifier), |
| 301 | redirectUri: correctUri, |
| 302 | }); |
| 303 | await bindUserToCode(code, sub); |
| 304 | |
| 305 | const pending = await consumePendingCode(code); |
| 306 | assert.ok(pending); |
| 307 | assert.equal(pending.redirectUri, correctUri, 'redirect_uri must match'); |
| 308 | }); |
| 309 | |
| 310 | // ── C2: refresh rotation ───────────────────────────────────────────────── |
| 311 | |
| 312 | it('C2 – refresh rotation returns new token in body (not cookie)', async () => { |
| 313 | const sub = 'google:user_c2'; |
| 314 | const issued = await refreshStore.issue(sub); |
| 315 | const rotated = await refreshStore.rotate(issued.token); |
| 316 | assert.ok(rotated.ok, 'rotation must succeed'); |
| 317 | assert.ok(rotated.token, 'new token must be in response'); |
| 318 | assert.ok(rotated.token !== issued.token, 'new token must differ from old'); |
| 319 | assert.equal(rotated.sub, sub); |
| 320 | }); |
| 321 | |
| 322 | it('C2 – reuse detection burns the session', async () => { |
| 323 | const sub = 'google:user_c2_reuse'; |
| 324 | const issued = await refreshStore.issue(sub); |
| 325 | const rot1 = await refreshStore.rotate(issued.token); |
| 326 | assert.ok(rot1.ok); |
| 327 | // Replay the consumed token |
| 328 | const rot2 = await refreshStore.rotate(issued.token); |
| 329 | assert.ok(!rot2.ok, 'replay must fail'); |
| 330 | assert.equal(rot2.reason, 'reuse'); |
| 331 | }); |
| 332 | |
| 333 | // ── C6: scope ceiling ──────────────────────────────────────────────────── |
| 334 | |
| 335 | it('C6 – member sub cannot be granted admin scope', async () => { |
| 336 | const { applyScopeCeiling } = await import('../hub/gateway/native-oauth-provider.mjs'); |
| 337 | const ceiling = grantedScopes('google:member_user'); |
| 338 | assert.ok(!ceiling.includes('admin'), 'member ceiling must not include admin'); |
| 339 | const result = applyScopeCeiling(['vault:read', 'admin'], ceiling); |
| 340 | assert.ok(!result.includes('admin')); |
| 341 | assert.ok(result.includes('vault:read')); |
| 342 | }); |
| 343 | |
| 344 | it('C6 – admin sub gets admin ceiling', () => { |
| 345 | const ceiling = grantedScopes('google:admin_user'); |
| 346 | assert.ok(ceiling.includes('admin')); |
| 347 | }); |
| 348 | |
| 349 | // ── MCP path regression ─────────────────────────────────────────────────── |
| 350 | |
| 351 | it('Regression: mcp_access path is unchanged (has type:mcp_access)', async () => { |
| 352 | const jwt_lib = (await import('jsonwebtoken')).default; |
| 353 | const secret = 'regression-secret'; |
| 354 | const mcpToken = jwt_lib.sign( |
| 355 | { sub: 'google:1', client_id: 'c', scopes: ['vault:read'], type: 'mcp_access' }, |
| 356 | secret, |
| 357 | { expiresIn: 3600 } |
| 358 | ); |
| 359 | const decoded = jwt_lib.verify(mcpToken, secret); |
| 360 | assert.equal(decoded.type, 'mcp_access'); |
| 361 | assert.ok(!decoded.role, 'mcp_access token must not have role claim'); |
| 362 | }); |
| 363 | }); |
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