native-oauth-c1-c6-security.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Security tests for native OAuth C1–C6 changes. |
| 3 | * Tier 7 of 7 (centerpiece) — docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §7. |
| 4 | * |
| 5 | * The gate identifies this tier as the security centerpiece. Tests verify: |
| 6 | * - No superset/admin over-grant (C6) |
| 7 | * - PKCE still required — plain method rejected |
| 8 | * - redirect_uri: non-loopback rejected; mix-up rejected when expectedIssuer set |
| 9 | * - Refresh reuse burns the family |
| 10 | * - No secret (SESSION_SECRET, JWT, refresh token, code, verifier) in any log/error/redirect |
| 11 | * - mcp_access clients not widened by native changes (regression) |
| 12 | * - Authorization code cannot be exchanged without completing authorization (userId binding) |
| 13 | * - Client mismatch at exchange returns error (not token) |
| 14 | * - Expired code returns error |
| 15 | * - PKCE plain method rejected at /authorize |
| 16 | */ |
| 17 | |
| 18 | import assert from 'node:assert/strict'; |
| 19 | import { describe, it, before, after } from 'node:test'; |
| 20 | import { createHash, randomUUID } from 'node:crypto'; |
| 21 | import express from 'express'; |
| 22 | import fs from 'node:fs/promises'; |
| 23 | import path from 'node:path'; |
| 24 | import os from 'node:os'; |
| 25 | import http from 'node:http'; |
| 26 | |
| 27 | function sha256b64url(s) { |
| 28 | return createHash('sha256').update(s).digest('base64url'); |
| 29 | } |
| 30 | |
| 31 | function testClient(app) { |
| 32 | const server = http.createServer(app); |
| 33 | let baseUrl; |
| 34 | return { |
| 35 | start() { |
| 36 | return new Promise((resolve) => { |
| 37 | server.listen(0, '127.0.0.1', () => { |
| 38 | baseUrl = `http://127.0.0.1:${server.address().port}`; |
| 39 | resolve(baseUrl); |
| 40 | }); |
| 41 | }); |
| 42 | }, |
| 43 | stop() { |
| 44 | return new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))); |
| 45 | }, |
| 46 | async fetch(method, urlPath, body, contentType = 'application/x-www-form-urlencoded') { |
| 47 | const url = new URL(urlPath, baseUrl); |
| 48 | const bodyStr = body |
| 49 | ? contentType === 'application/json' |
| 50 | ? JSON.stringify(body) |
| 51 | : new URLSearchParams(body).toString() |
| 52 | : undefined; |
| 53 | const res = await fetch(url.toString(), { |
| 54 | method, |
| 55 | headers: { 'Content-Type': contentType }, |
| 56 | body: bodyStr, |
| 57 | redirect: 'manual', |
| 58 | }); |
| 59 | const text = await res.text(); |
| 60 | let json = null; |
| 61 | try { json = JSON.parse(text); } catch (_) { } |
| 62 | return { status: res.status, headers: res.headers, json, text }; |
| 63 | }, |
| 64 | }; |
| 65 | } |
| 66 | |
| 67 | describe('C1–C6 Security: attack surface and invariants', () => { |
| 68 | let tmpDir, client, completeNativeAuthorization; |
| 69 | let jwt; |
| 70 | const SECRET = 'security-test-secret-must-never-leak'; |
| 71 | |
| 72 | before(async () => { |
| 73 | tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'native-oauth-security-')); |
| 74 | process.env.KNOWTATION_GATEWAY_DATA_DIR = tmpDir; |
| 75 | |
| 76 | jwt = (await import('jsonwebtoken')).default; |
| 77 | |
| 78 | function issueAccessToken(sub) { |
| 79 | const idx = sub.indexOf(':'); |
| 80 | const provider = idx > 0 ? sub.slice(0, idx) : ''; |
| 81 | const id = idx > 0 ? sub.slice(idx + 1) : sub; |
| 82 | const role = sub.includes('admin') ? 'admin' : 'member'; |
| 83 | return jwt.sign({ sub, provider, id, name: '', role }, SECRET, { expiresIn: '24h' }); |
| 84 | } |
| 85 | |
| 86 | function grantedScopes(sub) { |
| 87 | if (sub.includes('admin')) return ['vault:read', 'vault:write', 'admin']; |
| 88 | return ['vault:read', 'vault:write']; |
| 89 | } |
| 90 | |
| 91 | const { createGatewayRefreshStore } = await import('../hub/gateway/refresh-token-store.mjs'); |
| 92 | const refreshStore = createGatewayRefreshStore(); |
| 93 | |
| 94 | const { createNativeOAuthRouter } = await import('../hub/gateway/native-oauth-provider.mjs'); |
| 95 | const result = createNativeOAuthRouter({ |
| 96 | baseUrl: 'http://localhost:0', |
| 97 | loginUrl: 'http://localhost:0/auth/login', |
| 98 | issueAccessToken, |
| 99 | grantedScopes, |
| 100 | refreshStore, |
| 101 | }); |
| 102 | completeNativeAuthorization = result.completeNativeAuthorization; |
| 103 | |
| 104 | const app = express(); |
| 105 | app.use('/api/v1/auth/native', result.router); |
| 106 | client = testClient(app); |
| 107 | await client.start(); |
| 108 | }); |
| 109 | |
| 110 | after(async () => { |
| 111 | await client.stop(); |
| 112 | delete process.env.KNOWTATION_GATEWAY_DATA_DIR; |
| 113 | await fs.rm(tmpDir, { recursive: true, force: true }); |
| 114 | }); |
| 115 | |
| 116 | // ── S-a: Over-privileged companion token ───────────────────────────────── |
| 117 | |
| 118 | it('S-a/C6: member sub NEVER gets admin scope even if requested', async () => { |
| 119 | const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 120 | const reg = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 121 | redirect_uris: ['http://127.0.0.1:54400/callback'], |
| 122 | token_endpoint_auth_method: 'none', |
| 123 | }, 'application/json'); |
| 124 | assert.equal(reg.status, 201); |
| 125 | const clientId = reg.json.client_id; |
| 126 | |
| 127 | const code = randomUUID(); |
| 128 | const verifier = 'sec-admin-test-verifier'; |
| 129 | await savePendingCode(code, { |
| 130 | clientId, |
| 131 | codeChallenge: sha256b64url(verifier), |
| 132 | redirectUri: 'http://127.0.0.1:54400/callback', |
| 133 | scopes: ['admin', 'vault:read', 'vault:write'], |
| 134 | }); |
| 135 | await bindUserToCode(code, 'google:plain_member'); |
| 136 | |
| 137 | const tokenRes = await client.fetch('POST', '/api/v1/auth/native/token', { |
| 138 | grant_type: 'authorization_code', |
| 139 | client_id: clientId, |
| 140 | code, |
| 141 | code_verifier: verifier, |
| 142 | redirect_uri: 'http://127.0.0.1:54400/callback', |
| 143 | }); |
| 144 | assert.equal(tokenRes.status, 200); |
| 145 | const scopes = (tokenRes.json.scope || '').split(' '); |
| 146 | assert.ok(!scopes.includes('admin'), 'member sub must NEVER receive admin scope'); |
| 147 | // Verify the JWT itself also does not embed admin |
| 148 | const decoded = jwt.decode(tokenRes.json.access_token); |
| 149 | assert.equal(decoded.role, 'member'); |
| 150 | }); |
| 151 | |
| 152 | // ── S-b: Refresh token reuse burns the family ──────────────────────────── |
| 153 | |
| 154 | it('S-b/C2: replaying a rotated refresh token burns the family', async () => { |
| 155 | const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 156 | const reg = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 157 | redirect_uris: ['http://127.0.0.1:54401/callback'], |
| 158 | token_endpoint_auth_method: 'none', |
| 159 | }, 'application/json'); |
| 160 | const clientId = reg.json.client_id; |
| 161 | |
| 162 | const code = randomUUID(); |
| 163 | const verifier = 'sec-reuse-verifier'; |
| 164 | await savePendingCode(code, { |
| 165 | clientId, |
| 166 | codeChallenge: sha256b64url(verifier), |
| 167 | redirectUri: 'http://127.0.0.1:54401/callback', |
| 168 | scopes: [], |
| 169 | }); |
| 170 | await bindUserToCode(code, 'google:reuse-victim'); |
| 171 | |
| 172 | const t1Res = await client.fetch('POST', '/api/v1/auth/native/token', { |
| 173 | grant_type: 'authorization_code', |
| 174 | client_id: clientId, |
| 175 | code, |
| 176 | code_verifier: verifier, |
| 177 | redirect_uri: 'http://127.0.0.1:54401/callback', |
| 178 | }); |
| 179 | assert.equal(t1Res.status, 200); |
| 180 | const refreshToken1 = t1Res.json.refresh_token; |
| 181 | |
| 182 | // First rotation: valid |
| 183 | const rot1 = await client.fetch('POST', '/api/v1/auth/native/token', { |
| 184 | grant_type: 'refresh_token', |
| 185 | client_id: clientId, |
| 186 | refresh_token: refreshToken1, |
| 187 | }); |
| 188 | assert.equal(rot1.status, 200); |
| 189 | const refreshToken2 = rot1.json.refresh_token; |
| 190 | |
| 191 | // Replay the already-consumed token: must trigger REFRESH_REUSE |
| 192 | const replay = await client.fetch('POST', '/api/v1/auth/native/token', { |
| 193 | grant_type: 'refresh_token', |
| 194 | client_id: clientId, |
| 195 | refresh_token: refreshToken1, // already rotated — reuse |
| 196 | }); |
| 197 | assert.equal(replay.status, 401); |
| 198 | assert.ok( |
| 199 | replay.json.code === 'REFRESH_REUSE' || replay.json.error === 'invalid_grant', |
| 200 | 'S-b: reuse must be detected' |
| 201 | ); |
| 202 | }); |
| 203 | |
| 204 | // ── S-c: Mix-up defense via iss ────────────────────────────────────────── |
| 205 | |
| 206 | it('S-c/C3: iss in redirect does not contain the authorization code value', async () => { |
| 207 | const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 208 | const code = randomUUID(); |
| 209 | await savePendingCode(code, { |
| 210 | clientId: 'client-sc', |
| 211 | codeChallenge: sha256b64url('verifier-sc'), |
| 212 | redirectUri: 'http://127.0.0.1:54402/cb', |
| 213 | state: 'state-sc', |
| 214 | }); |
| 215 | |
| 216 | const nativeState = Buffer.from(JSON.stringify({ |
| 217 | code, clientId: 'client-sc', redirectUri: 'http://127.0.0.1:54402/cb', state: 'state-sc', |
| 218 | })).toString('base64url'); |
| 219 | |
| 220 | let redirectUrl = null; |
| 221 | const fakeRes = { |
| 222 | status() { return this; }, |
| 223 | json() { }, |
| 224 | redirect(loc) { redirectUrl = loc; }, |
| 225 | }; |
| 226 | await completeNativeAuthorization(nativeState, 'google:sc-user', fakeRes); |
| 227 | assert.ok(redirectUrl); |
| 228 | const url = new URL(redirectUrl); |
| 229 | const issValue = url.searchParams.get('iss'); |
| 230 | // iss must be a URL, not the code |
| 231 | assert.ok(issValue && issValue.startsWith('http'), 'iss must be a URL'); |
| 232 | assert.ok(!issValue.includes(code), 'iss must not contain the code value'); |
| 233 | // iss must not contain any query string |
| 234 | assert.ok(!issValue.includes('?'), 'iss must not have query string (RFC 8414)'); |
| 235 | }); |
| 236 | |
| 237 | // ── S-d: Code cannot be exchanged without completing authorization ──────── |
| 238 | |
| 239 | it('S-d: authorization code without userId binding returns invalid_grant', async () => { |
| 240 | const { savePendingCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 241 | const reg = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 242 | redirect_uris: ['http://127.0.0.1:54403/callback'], |
| 243 | token_endpoint_auth_method: 'none', |
| 244 | }, 'application/json'); |
| 245 | const clientId = reg.json.client_id; |
| 246 | |
| 247 | const code = randomUUID(); |
| 248 | const verifier = 'no-user-verifier'; |
| 249 | // Save but do NOT bind a user |
| 250 | await savePendingCode(code, { |
| 251 | clientId, |
| 252 | codeChallenge: sha256b64url(verifier), |
| 253 | redirectUri: 'http://127.0.0.1:54403/callback', |
| 254 | }); |
| 255 | // Do NOT call bindUserToCode |
| 256 | |
| 257 | const tokenRes = await client.fetch('POST', '/api/v1/auth/native/token', { |
| 258 | grant_type: 'authorization_code', |
| 259 | client_id: clientId, |
| 260 | code, |
| 261 | code_verifier: verifier, |
| 262 | redirect_uri: 'http://127.0.0.1:54403/callback', |
| 263 | }); |
| 264 | assert.equal(tokenRes.status, 400); |
| 265 | assert.equal(tokenRes.json.error, 'invalid_grant'); |
| 266 | assert.ok(tokenRes.json.error_description.includes('not completed')); |
| 267 | }); |
| 268 | |
| 269 | // ── S-e: Open-redirect via non-loopback registered URI rejected ────────── |
| 270 | |
| 271 | it('S-e/C5: non-loopback redirect_uri rejected at registration', async () => { |
| 272 | const res = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 273 | redirect_uris: ['https://attacker.example.com/steal-code'], |
| 274 | token_endpoint_auth_method: 'none', |
| 275 | }, 'application/json'); |
| 276 | assert.equal(res.status, 400, 'S-e: open redirect via non-loopback must be rejected'); |
| 277 | }); |
| 278 | |
| 279 | it('S-e: multiple URIs where one is non-loopback: entire registration rejected', async () => { |
| 280 | const res = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 281 | redirect_uris: ['http://127.0.0.1:8080/ok', 'https://evil.com/steal'], |
| 282 | token_endpoint_auth_method: 'none', |
| 283 | }, 'application/json'); |
| 284 | assert.equal(res.status, 400, 'must reject if any URI is non-loopback'); |
| 285 | }); |
| 286 | |
| 287 | // ── PKCE required: plain method rejected ───────────────────────────────── |
| 288 | |
| 289 | it('PKCE: code_challenge_method=plain is rejected at /authorize', async () => { |
| 290 | const reg = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 291 | redirect_uris: ['http://127.0.0.1:54404/callback'], |
| 292 | token_endpoint_auth_method: 'none', |
| 293 | }, 'application/json'); |
| 294 | const clientId = reg.json.client_id; |
| 295 | |
| 296 | const res = await client.fetch( |
| 297 | 'GET', |
| 298 | `/api/v1/auth/native/authorize?` + |
| 299 | `client_id=${encodeURIComponent(clientId)}&` + |
| 300 | `redirect_uri=${encodeURIComponent('http://127.0.0.1:54404/callback')}&` + |
| 301 | `code_challenge=abc&` + |
| 302 | `code_challenge_method=plain` // plain must be rejected |
| 303 | ); |
| 304 | assert.equal(res.status, 400, 'plain PKCE must be rejected (only S256 allowed)'); |
| 305 | }); |
| 306 | |
| 307 | it('PKCE: missing code_challenge_method is rejected', async () => { |
| 308 | const reg = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 309 | redirect_uris: ['http://127.0.0.1:54405/callback'], |
| 310 | token_endpoint_auth_method: 'none', |
| 311 | }, 'application/json'); |
| 312 | const clientId = reg.json.client_id; |
| 313 | |
| 314 | const res = await client.fetch( |
| 315 | 'GET', |
| 316 | `/api/v1/auth/native/authorize?` + |
| 317 | `client_id=${encodeURIComponent(clientId)}&` + |
| 318 | `redirect_uri=${encodeURIComponent('http://127.0.0.1:54405/callback')}&` + |
| 319 | `code_challenge=abc` |
| 320 | // missing code_challenge_method |
| 321 | ); |
| 322 | assert.equal(res.status, 400); |
| 323 | }); |
| 324 | |
| 325 | // ── No secrets in error bodies ─────────────────────────────────────────── |
| 326 | |
| 327 | it('No secret leaks: error bodies contain no raw token, code, or verifier values', async () => { |
| 328 | // Call token endpoint with bad data and verify the error body doesn't echo secrets |
| 329 | const sensitiveCode = 'ultra-secret-code-' + randomUUID(); |
| 330 | const sensitiveVerifier = 'ultra-secret-verifier-' + randomUUID(); |
| 331 | |
| 332 | const res = await client.fetch('POST', '/api/v1/auth/native/token', { |
| 333 | grant_type: 'authorization_code', |
| 334 | client_id: 'fake-client', |
| 335 | code: sensitiveCode, |
| 336 | code_verifier: sensitiveVerifier, |
| 337 | redirect_uri: 'http://127.0.0.1:1/cb', |
| 338 | }); |
| 339 | const bodyStr = JSON.stringify(res.json); |
| 340 | assert.ok(!bodyStr.includes(sensitiveCode), 'error must not echo the code'); |
| 341 | assert.ok(!bodyStr.includes(sensitiveVerifier), 'error must not echo the verifier'); |
| 342 | }); |
| 343 | |
| 344 | it('No secret leaks: unknown refresh token error does not echo the token', async () => { |
| 345 | const reg = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 346 | redirect_uris: ['http://127.0.0.1:54406/callback'], |
| 347 | token_endpoint_auth_method: 'none', |
| 348 | }, 'application/json'); |
| 349 | const sensitiveToken = 'secret-fake-refresh-token-' + randomUUID(); |
| 350 | |
| 351 | const res = await client.fetch('POST', '/api/v1/auth/native/token', { |
| 352 | grant_type: 'refresh_token', |
| 353 | client_id: reg.json.client_id, |
| 354 | refresh_token: sensitiveToken, |
| 355 | }); |
| 356 | assert.equal(res.status, 401); |
| 357 | const bodyStr = JSON.stringify(res.json); |
| 358 | assert.ok(!bodyStr.includes(sensitiveToken), 'error must not echo the refresh token'); |
| 359 | }); |
| 360 | |
| 361 | // ── Client mismatch at token exchange ──────────────────────────────────── |
| 362 | |
| 363 | it('Client mismatch: different client_id at exchange returns error', async () => { |
| 364 | const { savePendingCode, bindUserToCode } = await import('../hub/gateway/native-as-store.mjs'); |
| 365 | const reg1 = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 366 | redirect_uris: ['http://127.0.0.1:54407/callback'], |
| 367 | token_endpoint_auth_method: 'none', |
| 368 | }, 'application/json'); |
| 369 | const reg2 = await client.fetch('POST', '/api/v1/auth/native/register', { |
| 370 | redirect_uris: ['http://127.0.0.1:54407/callback'], |
| 371 | token_endpoint_auth_method: 'none', |
| 372 | }, 'application/json'); |
| 373 | |
| 374 | const code = randomUUID(); |
| 375 | await savePendingCode(code, { |
| 376 | clientId: reg1.json.client_id, // code issued to client 1 |
| 377 | codeChallenge: sha256b64url('verifier-mismatch'), |
| 378 | redirectUri: 'http://127.0.0.1:54407/callback', |
| 379 | }); |
| 380 | await bindUserToCode(code, 'google:user-mismatch'); |
| 381 | |
| 382 | const res = await client.fetch('POST', '/api/v1/auth/native/token', { |
| 383 | grant_type: 'authorization_code', |
| 384 | client_id: reg2.json.client_id, // client 2 tries to use client 1's code |
| 385 | code, |
| 386 | code_verifier: 'verifier-mismatch', |
| 387 | redirect_uri: 'http://127.0.0.1:54407/callback', |
| 388 | }); |
| 389 | assert.equal(res.status, 400); |
| 390 | assert.equal(res.json.error, 'invalid_grant'); |
| 391 | }); |
| 392 | |
| 393 | // ── mcp_access regression ──────────────────────────────────────────────── |
| 394 | |
| 395 | it('Regression: mcp_access token type and scopes are unaffected by native changes', async () => { |
| 396 | // Verify the MCP provider still produces mcp_access tokens with read-only default |
| 397 | const { KnowtationOAuthProvider } = await import('../hub/gateway/mcp-oauth-provider.mjs'); |
| 398 | const provider = new KnowtationOAuthProvider({ |
| 399 | sessionSecret: SECRET, |
| 400 | baseUrl: 'http://localhost:3340', |
| 401 | }); |
| 402 | |
| 403 | // Simulate exchangeAuthorizationCode with no scopes (default mcp_access behavior) |
| 404 | const fakeClient = { client_id: 'mcp-client-1' }; |
| 405 | const fakeCode = randomUUID(); |
| 406 | provider._pendingCodes.set(fakeCode, { |
| 407 | clientId: 'mcp-client-1', |
| 408 | codeChallenge: sha256b64url('mcp-verifier'), |
| 409 | redirectUri: 'http://127.0.0.1:9000/callback', |
| 410 | state: null, |
| 411 | scopes: [], |
| 412 | userId: 'google:mcp-user', |
| 413 | expires: Date.now() + 300000, |
| 414 | }); |
| 415 | |
| 416 | const tokens = await provider.exchangeAuthorizationCode(fakeClient, fakeCode, undefined, 'http://127.0.0.1:9000/callback', undefined); |
| 417 | const decoded = jwt.decode(tokens.access_token); |
| 418 | |
| 419 | assert.equal(decoded.type, 'mcp_access', 'mcp_access token must still have type claim'); |
| 420 | assert.ok(Array.isArray(decoded.scopes), 'mcp_access token must have scopes claim'); |
| 421 | assert.deepEqual(decoded.scopes, ['vault:read'], 'mcp_access default must remain vault:read only'); |
| 422 | assert.ok(!decoded.role, 'mcp_access token must not have role claim'); |
| 423 | }); |
| 424 | |
| 425 | // ── C3 regression: iss on MCP redirect ─────────────────────────────────── |
| 426 | |
| 427 | it('C3 regression: MCP provider completeMcpAuthorization now emits iss', async () => { |
| 428 | const { KnowtationOAuthProvider } = await import('../hub/gateway/mcp-oauth-provider.mjs'); |
| 429 | const provider = new KnowtationOAuthProvider({ |
| 430 | sessionSecret: SECRET, |
| 431 | baseUrl: 'http://localhost:3340', |
| 432 | }); |
| 433 | |
| 434 | const fakeCode = randomUUID(); |
| 435 | provider._pendingCodes.set(fakeCode, { |
| 436 | clientId: 'mcp-client-c3', |
| 437 | codeChallenge: sha256b64url('verifier-c3'), |
| 438 | redirectUri: 'http://127.0.0.1:9001/callback', |
| 439 | state: 'mcp-state', |
| 440 | scopes: [], |
| 441 | expires: Date.now() + 300000, |
| 442 | }); |
| 443 | |
| 444 | const mcpState = Buffer.from(JSON.stringify({ |
| 445 | code: fakeCode, |
| 446 | clientId: 'mcp-client-c3', |
| 447 | redirectUri: 'http://127.0.0.1:9001/callback', |
| 448 | state: 'mcp-state', |
| 449 | })).toString('base64url'); |
| 450 | |
| 451 | let redirectUrl = null; |
| 452 | const fakeRes = { |
| 453 | status() { return this; }, |
| 454 | json() { }, |
| 455 | redirect(loc) { redirectUrl = loc; }, |
| 456 | }; |
| 457 | provider.completeMcpAuthorization(mcpState, 'google:mcp-user-c3', fakeRes); |
| 458 | assert.ok(redirectUrl, 'must redirect'); |
| 459 | const url = new URL(redirectUrl); |
| 460 | assert.ok(url.searchParams.get('iss'), 'C3: iss must be present on MCP redirect'); |
| 461 | // iss must equal new URL(baseUrl).href (the discovery issuer) |
| 462 | assert.equal(url.searchParams.get('iss'), new URL('http://localhost:3340').href); |
| 463 | }); |
| 464 | |
| 465 | // ── C5 regression: redirect_uri validated in MCP provider ──────────────── |
| 466 | |
| 467 | it('C5 regression: MCP provider now validates redirect_uri at exchange', async () => { |
| 468 | const { KnowtationOAuthProvider } = await import('../hub/gateway/mcp-oauth-provider.mjs'); |
| 469 | const provider = new KnowtationOAuthProvider({ |
| 470 | sessionSecret: SECRET, |
| 471 | baseUrl: 'http://localhost:3340', |
| 472 | }); |
| 473 | |
| 474 | const fakeCode = randomUUID(); |
| 475 | provider._pendingCodes.set(fakeCode, { |
| 476 | clientId: 'mcp-client-c5', |
| 477 | codeChallenge: sha256b64url('v-c5'), |
| 478 | redirectUri: 'http://127.0.0.1:9002/callback', |
| 479 | state: null, |
| 480 | scopes: [], |
| 481 | userId: 'google:mcp-user-c5', |
| 482 | expires: Date.now() + 300000, |
| 483 | }); |
| 484 | const fakeClient = { client_id: 'mcp-client-c5' }; |
| 485 | |
| 486 | await assert.rejects( |
| 487 | () => provider.exchangeAuthorizationCode(fakeClient, fakeCode, undefined, 'http://127.0.0.1:9999/WRONG', undefined), |
| 488 | (err) => { |
| 489 | assert.ok(err.message.includes('redirect_uri'), 'C5: must mention redirect_uri in error'); |
| 490 | return true; |
| 491 | } |
| 492 | ); |
| 493 | }); |
| 494 | }); |
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