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