companion-oauth-pkce-e2e.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Tier 3 — END-TO-END: drive the full client sequence against a simulated authorization server |
| 3 | * and token endpoint (pure, in-process — NO real sockets/network, which are Phase 5). This proves |
| 4 | * the client core interoperates with a server that enforces PKCE S256, state echo, and RFC 9207 |
| 5 | * iss, including the success path and representative failure paths. |
| 6 | */ |
| 7 | import { describe, it } from 'node:test'; |
| 8 | import assert from 'node:assert/strict'; |
| 9 | import crypto from 'node:crypto'; |
| 10 | import { |
| 11 | createPkcePair, |
| 12 | createOAuthState, |
| 13 | buildAuthorizationUrl, |
| 14 | validateAuthorizationResponse, |
| 15 | buildTokenRequest, |
| 16 | validateTokenResponse, |
| 17 | } from '../lib/companion-oauth-pkce.mjs'; |
| 18 | |
| 19 | const AUTH_EP = 'https://knowtation.store/authorize'; |
| 20 | const TOKEN_EP = 'https://knowtation.store/token'; |
| 21 | const ISSUER = 'https://knowtation.store'; |
| 22 | const CLIENT_ID = 'companion-public-client'; |
| 23 | const REDIRECT = 'http://127.0.0.1:49321/callback'; |
| 24 | const SCOPES = ['vault:read', 'vault:write']; |
| 25 | |
| 26 | /** |
| 27 | * A minimal simulated authorization server: it parses the authorization URL, stores the pending |
| 28 | * challenge by state, and on the token request verifies S256(code_verifier) === stored challenge |
| 29 | * (this is exactly what a correct PKCE server does — mirroring the MCP SDK token handler). |
| 30 | */ |
| 31 | function makeSimulatedServer() { |
| 32 | const pending = new Map(); // state -> { challenge, method, redirectUri } |
| 33 | const codes = new Map(); // code -> { state, challenge } |
| 34 | return { |
| 35 | authorize(authUrl, { approve = true } = {}) { |
| 36 | const u = new URL(authUrl); |
| 37 | assert.equal(u.searchParams.get('response_type'), 'code'); |
| 38 | assert.equal(u.searchParams.get('code_challenge_method'), 'S256'); |
| 39 | const state = u.searchParams.get('state'); |
| 40 | const challenge = u.searchParams.get('code_challenge'); |
| 41 | const redirectUri = u.searchParams.get('redirect_uri'); |
| 42 | pending.set(state, { challenge, redirectUri }); |
| 43 | if (!approve) { |
| 44 | return { code: undefined, error: 'access_denied', state, iss: ISSUER }; |
| 45 | } |
| 46 | const code = 'code-' + crypto.randomBytes(8).toString('hex'); |
| 47 | codes.set(code, { state, challenge }); |
| 48 | return { code, state, iss: ISSUER }; |
| 49 | }, |
| 50 | token(bodyParams) { |
| 51 | // Public client: never require/accept a client_secret. |
| 52 | assert.equal(bodyParams.client_secret, undefined); |
| 53 | const rec = codes.get(bodyParams.code); |
| 54 | if (!rec) return { error: 'invalid_grant' }; |
| 55 | codes.delete(bodyParams.code); // single-use code |
| 56 | const verifierChallenge = crypto.createHash('sha256').update(bodyParams.code_verifier, 'ascii').digest('base64url'); |
| 57 | if (verifierChallenge !== rec.challenge) return { error: 'invalid_grant' }; // PKCE mismatch |
| 58 | if (bodyParams.redirect_uri !== REDIRECT) return { error: 'invalid_grant' }; |
| 59 | return { |
| 60 | access_token: crypto.randomBytes(24).toString('base64url'), |
| 61 | token_type: 'Bearer', |
| 62 | expires_in: 3600, |
| 63 | refresh_token: crypto.randomBytes(24).toString('base64url'), |
| 64 | scope: SCOPES.join(' '), |
| 65 | }; |
| 66 | }, |
| 67 | }; |
| 68 | } |
| 69 | |
| 70 | describe('E2E — happy path: sign-in → code → token', () => { |
| 71 | it('completes a full PKCE + loopback authorization-code exchange', () => { |
| 72 | const server = makeSimulatedServer(); |
| 73 | const { codeVerifier, codeChallenge } = createPkcePair(); |
| 74 | const state = createOAuthState(); |
| 75 | |
| 76 | const authUrl = buildAuthorizationUrl({ |
| 77 | authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT, |
| 78 | scopes: SCOPES, state, codeChallenge, |
| 79 | }); |
| 80 | |
| 81 | const callback = server.authorize(authUrl); |
| 82 | const resp = validateAuthorizationResponse({ params: callback, expectedState: state, expectedIssuer: ISSUER }); |
| 83 | assert.equal(resp.ok, true); |
| 84 | |
| 85 | const req = buildTokenRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: resp.code, codeVerifier, redirectUri: REDIRECT }); |
| 86 | const tokenJson = server.token(req.bodyParams); |
| 87 | const tr = validateTokenResponse(tokenJson); |
| 88 | |
| 89 | assert.equal(tr.ok, true); |
| 90 | assert.ok(tr.accessToken.length > 0); |
| 91 | assert.ok(tr.refreshToken.length > 0); |
| 92 | assert.equal(tr.expiresIn, 3600); |
| 93 | }); |
| 94 | }); |
| 95 | |
| 96 | describe('E2E — PKCE interception attack fails at the token endpoint', () => { |
| 97 | it('an attacker who stole the code but lacks the verifier cannot exchange it', () => { |
| 98 | const server = makeSimulatedServer(); |
| 99 | const { codeChallenge } = createPkcePair(); |
| 100 | const state = createOAuthState(); |
| 101 | const authUrl = buildAuthorizationUrl({ |
| 102 | authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT, |
| 103 | scopes: SCOPES, state, codeChallenge, |
| 104 | }); |
| 105 | const callback = server.authorize(authUrl); |
| 106 | const resp = validateAuthorizationResponse({ params: callback, expectedState: state }); |
| 107 | assert.equal(resp.ok, true); |
| 108 | |
| 109 | // Attacker presents the stolen code with their OWN (wrong) verifier. |
| 110 | const attackerVerifier = createPkcePair().codeVerifier; |
| 111 | const req = buildTokenRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: resp.code, codeVerifier: attackerVerifier, redirectUri: REDIRECT }); |
| 112 | const tokenJson = server.token(req.bodyParams); |
| 113 | const tr = validateTokenResponse(tokenJson); |
| 114 | assert.equal(tr.ok, false); |
| 115 | assert.equal(tr.errorCode, 'invalid_grant'); |
| 116 | }); |
| 117 | }); |
| 118 | |
| 119 | describe('E2E — user denies consent', () => { |
| 120 | it('an access_denied callback aborts before any token exchange', () => { |
| 121 | const server = makeSimulatedServer(); |
| 122 | const { codeChallenge } = createPkcePair(); |
| 123 | const state = createOAuthState(); |
| 124 | const authUrl = buildAuthorizationUrl({ |
| 125 | authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT, |
| 126 | scopes: SCOPES, state, codeChallenge, |
| 127 | }); |
| 128 | const callback = server.authorize(authUrl, { approve: false }); |
| 129 | const resp = validateAuthorizationResponse({ params: callback, expectedState: state }); |
| 130 | assert.equal(resp.ok, false); |
| 131 | assert.equal(resp.errorCode, 'access_denied'); |
| 132 | }); |
| 133 | }); |
| 134 | |
| 135 | describe('E2E — single-use code: replaying the same code fails', () => { |
| 136 | it('a second exchange of an already-used code is rejected', () => { |
| 137 | const server = makeSimulatedServer(); |
| 138 | const { codeVerifier, codeChallenge } = createPkcePair(); |
| 139 | const state = createOAuthState(); |
| 140 | const authUrl = buildAuthorizationUrl({ |
| 141 | authorizationEndpoint: AUTH_EP, clientId: CLIENT_ID, redirectUri: REDIRECT, |
| 142 | scopes: SCOPES, state, codeChallenge, |
| 143 | }); |
| 144 | const callback = server.authorize(authUrl); |
| 145 | const resp = validateAuthorizationResponse({ params: callback, expectedState: state }); |
| 146 | const req = buildTokenRequest({ tokenEndpoint: TOKEN_EP, clientId: CLIENT_ID, code: resp.code, codeVerifier, redirectUri: REDIRECT }); |
| 147 | |
| 148 | assert.equal(validateTokenResponse(server.token(req.bodyParams)).ok, true); |
| 149 | // Replay the identical exchange. |
| 150 | assert.equal(validateTokenResponse(server.token(req.bodyParams)).ok, false); |
| 151 | }); |
| 152 | }); |
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