calendar-oauth-connector-security.test.mjs
171 lines 5.9 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Tier 7 — SECURITY: no secret egress, state tamper, gate off, allowlist.
3 */
4 import { describe, it, beforeEach, afterEach } from 'node:test';
5 import assert from 'node:assert/strict';
6 import fs from 'node:fs';
7 import path from 'node:path';
8 import { fileURLToPath } from 'node:url';
9 import {
10 handleBeginGoogleConnector,
11 handleGoogleConnectorCallback,
12 handleListGoogleConnectors,
13 handleRevokeGoogleConnector,
14 createFakeGoogleClient,
15 isReturnUrlAllowed,
16 isRedirectUriAllowed,
17 } from '../lib/calendar/google-oauth-connector.mjs';
18 import { buildAuthorizationUrl, PKCE_METHOD_S256 } from '../lib/companion-oauth-pkce.mjs';
19
20 const __dirname = path.dirname(fileURLToPath(import.meta.url));
21 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-calendar-oauth-security');
22 const RETURN_URL = 'https://app.scooling.local:5174/settings/calendar/connect/return';
23
24 describe('calendar oauth security', () => {
25 let dataDir;
26
27 beforeEach(() => {
28 fs.rmSync(tmpRoot, { recursive: true, force: true });
29 dataDir = path.join(tmpRoot, 'data');
30 fs.mkdirSync(dataDir, { recursive: true });
31 });
32
33 afterEach(() => {
34 fs.rmSync(tmpRoot, { recursive: true, force: true });
35 });
36
37 it('gate off returns NOT_AUTHORIZED without network side effects', () => {
38 const result = handleBeginGoogleConnector({
39 dataDir,
40 vaultId: 'default',
41 body: { provider: 'google', return_url: RETURN_URL },
42 env: {},
43 authorizedOverride: false,
44 });
45 assert.equal(result.ok, false);
46 assert.equal(result.code, 'NOT_AUTHORIZED');
47 });
48
49 it('list response never includes tokens or oauth_pending', () => {
50 const testEnv = {
51 GOOGLE_CALENDAR_OAUTH_CLIENT_ID: 'cid',
52 GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET: 'sec',
53 KNOWTATION_CALENDAR_OAUTH_SECRET: 'k'.repeat(40),
54 CALENDAR_OAUTH_REDIRECT_URI: 'https://hub.local/callback',
55 SCOOLING_RETURN_URL_ALLOWLIST: RETURN_URL,
56 };
57 const begin = handleBeginGoogleConnector({
58 dataDir,
59 vaultId: 'default',
60 body: { provider: 'google', return_url: RETURN_URL },
61 env: testEnv,
62 authorizedOverride: true,
63 });
64 const listed = handleListGoogleConnectors({ dataDir, vaultId: 'default', authorizedOverride: true });
65 const json = JSON.stringify(listed.payload);
66 assert.doesNotMatch(json, /refresh_token/);
67 assert.doesNotMatch(json, /code_verifier/);
68 assert.doesNotMatch(json, /oauth_pending/);
69 assert.doesNotMatch(json, /client_secret/);
70 assert.ok(begin.payload.authorization_url.startsWith('https://'));
71 });
72
73 it('rejects open return_url and non-exact redirect', () => {
74 assert.equal(isReturnUrlAllowed('https://evil.example/steal', [RETURN_URL]), false);
75 assert.equal(isReturnUrlAllowed(RETURN_URL, [RETURN_URL]), true);
76 assert.equal(isRedirectUriAllowed('https://evil/cb', 'https://hub.local/cb'), false);
77 });
78
79 it('PKCE plain method rejected in companion core', () => {
80 assert.throws(
81 () => buildAuthorizationUrl({
82 authorizationEndpoint: 'https://auth.example/authorize',
83 clientId: 'c',
84 redirectUri: 'http://127.0.0.1:8765/cb',
85 scopes: ['openid'],
86 state: 's',
87 codeChallenge: 'ch',
88 codeChallengeMethod: 'plain',
89 }),
90 );
91 assert.equal(PKCE_METHOD_S256, 'S256');
92 });
93
94 it('state replay denied on callback', async () => {
95 const testEnv = {
96 GOOGLE_CALENDAR_OAUTH_CLIENT_ID: 'cid',
97 GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET: 'sec',
98 KNOWTATION_CALENDAR_OAUTH_SECRET: 'k'.repeat(40),
99 CALENDAR_OAUTH_REDIRECT_URI: 'https://hub.local/callback',
100 SCOOLING_RETURN_URL_ALLOWLIST: RETURN_URL,
101 };
102 const begin = handleBeginGoogleConnector({
103 dataDir,
104 vaultId: 'default',
105 body: { provider: 'google', return_url: RETURN_URL },
106 env: testEnv,
107 authorizedOverride: true,
108 });
109 const state = new URL(begin.payload.authorization_url).searchParams.get('state');
110 const googleClient = createFakeGoogleClient();
111 const first = await handleGoogleConnectorCallback({
112 dataDir,
113 query: { code: 'c1', state },
114 googleClient,
115 env: testEnv,
116 authorizedOverride: true,
117 });
118 assert.equal(first.ok, true);
119 const replay = await handleGoogleConnectorCallback({
120 dataDir,
121 query: { code: 'c2', state },
122 googleClient,
123 env: testEnv,
124 authorizedOverride: true,
125 });
126 assert.equal(replay.ok, false);
127 });
128
129 it('revoke calls Google revoke endpoint', async () => {
130 const testEnv = {
131 GOOGLE_CALENDAR_OAUTH_CLIENT_ID: 'cid',
132 GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET: 'sec',
133 KNOWTATION_CALENDAR_OAUTH_SECRET: 'k'.repeat(40),
134 CALENDAR_OAUTH_REDIRECT_URI: 'https://hub.local/callback',
135 SCOOLING_RETURN_URL_ALLOWLIST: RETURN_URL,
136 };
137 const begin = handleBeginGoogleConnector({
138 dataDir,
139 vaultId: 'default',
140 body: { provider: 'google', return_url: RETURN_URL },
141 env: testEnv,
142 authorizedOverride: true,
143 });
144 const state = new URL(begin.payload.authorization_url).searchParams.get('state');
145 const googleClient = createFakeGoogleClient();
146 const { findPendingConnectorByState } = await import('../lib/calendar/google-oauth-connector.mjs');
147 const located = findPendingConnectorByState(dataDir, state);
148 await handleGoogleConnectorCallback({
149 dataDir,
150 query: { code: 'c', state: located.connector.oauth_pending.state },
151 googleClient,
152 env: testEnv,
153 authorizedOverride: true,
154 });
155 let revokeCalled = false;
156 const trackingClient = createFakeGoogleClient();
157 trackingClient.fetch = async (input) => {
158 if (input.url.includes('revoke')) revokeCalled = true;
159 return { ok: true, status: 200, json: async () => ({}) };
160 };
161 await handleRevokeGoogleConnector({
162 dataDir,
163 vaultId: 'default',
164 connectorId: begin.payload.connector_id,
165 googleClient: trackingClient,
166 env: testEnv,
167 authorizedOverride: true,
168 });
169 assert.equal(revokeCalled, true);
170 });
171 });
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago