companion-oauth-pkce-e2e.test.mjs
152 lines 6.7 KB
Raw
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