companion-runtime-oauth-session.test.mjs
172 lines 6.3 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 24 days ago
1 import assert from 'node:assert/strict';
2 import { describe, it } from 'node:test';
3
4 import {
5 COMPANION_DISCOVERY_URL,
6 COMPANION_HUB_ORIGIN,
7 COMPANION_NATIVE_ISSUER,
8 COMPANION_SESSION_URL,
9 discoverCompanionOAuth,
10 ensureHostedSession,
11 inspectCompanionSession,
12 signOutCompanion,
13 verifyHostedSession,
14 } from '../companion/runtime/oauth-session.mjs';
15 import { buildSessionMeta, createTokenCustody } from '../lib/companion-token-custody.mjs';
16 import { makeSyncKeychain } from './helpers/companion-keychain-fake.mjs';
17
18 function jsonResponse(status, value) {
19 return {
20 ok: status >= 200 && status < 300,
21 status,
22 text: async () => JSON.stringify(value),
23 };
24 }
25
26 function discovery(overrides = {}) {
27 return {
28 issuer: COMPANION_NATIVE_ISSUER,
29 authorization_endpoint: `${COMPANION_NATIVE_ISSUER}/authorize`,
30 token_endpoint: `${COMPANION_NATIVE_ISSUER}/token`,
31 registration_endpoint: `${COMPANION_NATIVE_ISSUER}/register`,
32 revocation_endpoint: `${COMPANION_NATIVE_ISSUER}/revoke`,
33 ...overrides,
34 };
35 }
36
37 async function storeSession(keychain, {
38 now = 0,
39 expiresIn = 60,
40 accessToken = 'access-old',
41 refreshToken = 'refresh-old',
42 clientId = 'native-client',
43 } = {}) {
44 const custody = createTokenCustody(keychain);
45 const meta = buildSessionMeta(
46 { expiresIn, refreshToken, scope: 'vault:read vault:write', tokenType: 'Bearer' },
47 {
48 now,
49 refreshTtlMs: 30 * 24 * 60 * 60 * 1000,
50 issuer: COMPANION_NATIVE_ISSUER,
51 clientId,
52 },
53 );
54 await custody.storeSession({ accessToken, refreshToken, meta });
55 }
56
57 describe('standalone companion hosted OAuth session', () => {
58 it('pins discovery to the production HTTPS issuer and exact endpoints', async () => {
59 const oauth = await discoverCompanionOAuth({
60 fetchImpl: async (url, options) => {
61 assert.equal(url, COMPANION_DISCOVERY_URL);
62 assert.equal(options.redirect, 'error');
63 return jsonResponse(200, discovery());
64 },
65 });
66 assert.equal(oauth.issuer, COMPANION_NATIVE_ISSUER);
67 assert.equal(oauth.tokenEndpoint, `${COMPANION_NATIVE_ISSUER}/token`);
68
69 await assert.rejects(
70 discoverCompanionOAuth({
71 fetchImpl: async () => jsonResponse(200, discovery({ token_endpoint: 'https://attacker.example/token' })),
72 }),
73 { code: 'companion_discovery_invalid' },
74 );
75 });
76
77 it('returns a still-valid stored session without network access', async () => {
78 const keychain = makeSyncKeychain();
79 await storeSession(keychain, { now: 1_000, expiresIn: 3600 });
80 const session = await ensureHostedSession({
81 keychain,
82 now: () => 2_000,
83 fetchImpl: async () => { throw new Error('unexpected network'); },
84 });
85 assert.equal(session.accessToken, 'access-old');
86 assert.equal((await inspectCompanionSession({ keychain, now: () => 2_000 })).session, 'valid');
87 });
88
89 it('refreshes an expired session with the durable public client id and rotates custody', async () => {
90 const keychain = makeSyncKeychain();
91 await storeSession(keychain);
92 let refreshBody = '';
93 const fetchImpl = async (url, options) => {
94 if (url === COMPANION_DISCOVERY_URL) return jsonResponse(200, discovery());
95 if (url === `${COMPANION_NATIVE_ISSUER}/token`) {
96 refreshBody = options.body;
97 return jsonResponse(200, {
98 access_token: 'access-new',
99 refresh_token: 'refresh-new',
100 token_type: 'Bearer',
101 expires_in: 900,
102 scope: 'vault:read',
103 });
104 }
105 throw new Error('unexpected URL');
106 };
107 const session = await ensureHostedSession({ keychain, fetchImpl, now: () => 70_000 });
108 const params = new URLSearchParams(refreshBody);
109 assert.equal(params.get('client_id'), 'native-client');
110 assert.equal(params.get('grant_type'), 'refresh_token');
111 assert.equal(session.accessToken, 'access-new');
112 assert.equal(session.refreshToken, 'refresh-new');
113 assert.equal(session.clientId, 'native-client');
114 });
115
116 it('clears custody when the authorization server rejects refresh', async () => {
117 const keychain = makeSyncKeychain();
118 await storeSession(keychain);
119 const fetchImpl = async (url) => {
120 if (url === COMPANION_DISCOVERY_URL) return jsonResponse(200, discovery());
121 return jsonResponse(401, { error: 'invalid_grant' });
122 };
123 await assert.rejects(
124 ensureHostedSession({ keychain, fetchImpl, now: () => 70_000 }),
125 { code: 'companion_sign_in_required' },
126 );
127 assert.equal(await createTokenCustody(keychain).loadSession(), null);
128 });
129
130 it('verifies authenticated caller posture without returning identity or tokens', async () => {
131 const keychain = makeSyncKeychain();
132 await storeSession(keychain, { now: 1_000, expiresIn: 3600 });
133 const result = await verifyHostedSession({
134 keychain,
135 now: () => 2_000,
136 fetchImpl: async (url, options) => {
137 assert.equal(url, COMPANION_SESSION_URL);
138 assert.match(options.headers.Authorization, /^Bearer /);
139 return jsonResponse(200, {
140 type: 'session',
141 sub: 'private-user-id',
142 scopes: ['vault:read', 'vault:write'],
143 });
144 },
145 });
146 assert.deepEqual(result, { authenticated: true, scopes: ['vault:read', 'vault:write'] });
147 assert.doesNotMatch(JSON.stringify(result), /private-user-id|access-old/);
148 });
149
150 it('attempts remote revocation and always clears local custody', async () => {
151 const keychain = makeSyncKeychain();
152 await storeSession(keychain);
153 let revokeBody = '';
154 const result = await signOutCompanion({
155 keychain,
156 fetchImpl: async (url, options) => {
157 if (url === COMPANION_DISCOVERY_URL) return jsonResponse(200, discovery());
158 assert.equal(url, `${COMPANION_NATIVE_ISSUER}/revoke`);
159 revokeBody = options.body;
160 return jsonResponse(200, { ok: true });
161 },
162 });
163 assert.deepEqual(result, { signedOut: true, remoteRevoked: true });
164 assert.equal(new URLSearchParams(revokeBody).get('client_id'), 'native-client');
165 assert.equal(await createTokenCustody(keychain).loadSession(), null);
166 });
167
168 it('keeps the hosted origin fixed and rejects caller environment substitution', () => {
169 assert.equal(COMPANION_HUB_ORIGIN, 'https://mcp.knowtation.store');
170 assert.equal(COMPANION_SESSION_URL, `${COMPANION_HUB_ORIGIN}/api/v1/auth/session`);
171 });
172 });
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 24 days ago