oauth-session.mjs
263 lines 8.5 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 25 days ago
1 import {
2 buildRefreshRequest,
3 validateTokenResponse,
4 } from '../../lib/companion-oauth-pkce.mjs';
5 import { runCompanionOAuthFlow } from '../../lib/companion-oauth-flow.mjs';
6 import { buildSessionMeta, createTokenCustody } from '../../lib/companion-token-custody.mjs';
7
8 export const COMPANION_HUB_ORIGIN = 'https://mcp.knowtation.store';
9 export const COMPANION_NATIVE_ISSUER = `${COMPANION_HUB_ORIGIN}/api/v1/auth/native`;
10 export const COMPANION_DISCOVERY_URL = `${COMPANION_NATIVE_ISSUER}/.well-known/oauth-authorization-server`;
11 export const COMPANION_SESSION_URL = `${COMPANION_HUB_ORIGIN}/api/v1/auth/session`;
12 export const COMPANION_SCOPES = Object.freeze(['vault:read', 'vault:write']);
13
14 const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
15 const MAX_JSON_BYTES = 64 * 1024;
16 const REQUEST_TIMEOUT_MS = 15_000;
17
18 function fixedError(code) {
19 const error = new Error(code);
20 error.code = code;
21 return error;
22 }
23
24 async function readJson(response, failureCode) {
25 const text = await response.text();
26 if (Buffer.byteLength(text) > MAX_JSON_BYTES) throw fixedError(failureCode);
27 try {
28 const json = JSON.parse(text);
29 if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error('invalid');
30 return json;
31 } catch {
32 throw fixedError(failureCode);
33 }
34 }
35
36 function requestSignal(timeoutMs = REQUEST_TIMEOUT_MS) {
37 return AbortSignal.timeout(timeoutMs);
38 }
39
40 function boundedFetch(fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS) {
41 return (input, options = {}) => fetchImpl(input, {
42 ...options,
43 redirect: 'error',
44 signal: options.signal ?? requestSignal(timeoutMs),
45 });
46 }
47
48 function requireExactEndpoint(value, expected) {
49 if (value !== expected) throw fixedError('companion_discovery_invalid');
50 let parsed;
51 try {
52 parsed = new URL(value);
53 } catch {
54 throw fixedError('companion_discovery_invalid');
55 }
56 if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.hash) {
57 throw fixedError('companion_discovery_invalid');
58 }
59 return value;
60 }
61
62 export async function discoverCompanionOAuth({
63 fetchImpl = globalThis.fetch,
64 timeoutMs = REQUEST_TIMEOUT_MS,
65 } = {}) {
66 if (typeof fetchImpl !== 'function') throw fixedError('companion_network_unavailable');
67 let response;
68 try {
69 response = await fetchImpl(COMPANION_DISCOVERY_URL, {
70 method: 'GET',
71 headers: { Accept: 'application/json' },
72 redirect: 'error',
73 signal: requestSignal(timeoutMs),
74 });
75 } catch {
76 throw fixedError('companion_discovery_unavailable');
77 }
78 if (!response.ok) throw fixedError('companion_discovery_unavailable');
79 const metadata = await readJson(response, 'companion_discovery_invalid');
80 requireExactEndpoint(metadata.issuer, COMPANION_NATIVE_ISSUER);
81 return Object.freeze({
82 issuer: metadata.issuer,
83 authorizationEndpoint: requireExactEndpoint(
84 metadata.authorization_endpoint,
85 `${COMPANION_NATIVE_ISSUER}/authorize`
86 ),
87 tokenEndpoint: requireExactEndpoint(metadata.token_endpoint, `${COMPANION_NATIVE_ISSUER}/token`),
88 registrationEndpoint: requireExactEndpoint(
89 metadata.registration_endpoint,
90 `${COMPANION_NATIVE_ISSUER}/register`
91 ),
92 revocationEndpoint: requireExactEndpoint(
93 metadata.revocation_endpoint,
94 `${COMPANION_NATIVE_ISSUER}/revoke`
95 ),
96 });
97 }
98
99 export async function signInCompanion({
100 keychain,
101 fetchImpl = globalThis.fetch,
102 openBrowser,
103 now = () => Date.now(),
104 } = {}) {
105 const previous = await createTokenCustody(keychain).loadSession();
106 if (previous) {
107 const signOut = await signOutCompanion({ keychain, fetchImpl });
108 if (signOut.remoteRevoked === false) throw fixedError('companion_sign_out_required');
109 }
110 const oauth = await discoverCompanionOAuth({ fetchImpl });
111 const fetchWithPolicy = boundedFetch(fetchImpl);
112 return runCompanionOAuthFlow({
113 authorizationEndpoint: oauth.authorizationEndpoint,
114 tokenEndpoint: oauth.tokenEndpoint,
115 registrationEndpoint: oauth.registrationEndpoint,
116 expectedIssuer: oauth.issuer,
117 scopes: [...COMPANION_SCOPES],
118 keychain,
119 fetch: fetchWithPolicy,
120 openBrowser,
121 now,
122 refreshTtlMs: REFRESH_TTL_MS,
123 });
124 }
125
126 export async function ensureHostedSession({
127 keychain,
128 fetchImpl = globalThis.fetch,
129 now = () => Date.now(),
130 } = {}) {
131 const custody = createTokenCustody(keychain);
132 const instant = now();
133 const session = await custody.loadSession();
134 if (!session || session.issuer !== COMPANION_NATIVE_ISSUER) {
135 if (session) await custody.clearSession();
136 throw fixedError('companion_sign_in_required');
137 }
138
139 const decision = await custody.decide({ now: instant });
140 if (decision === 'valid') return session;
141 if (
142 decision !== 'refresh' ||
143 !session.refreshToken ||
144 !session.clientId
145 ) {
146 await custody.clearSession();
147 throw fixedError('companion_sign_in_required');
148 }
149
150 const oauth = await discoverCompanionOAuth({ fetchImpl });
151 const descriptor = buildRefreshRequest({
152 tokenEndpoint: oauth.tokenEndpoint,
153 clientId: session.clientId,
154 refreshToken: session.refreshToken,
155 });
156 let response;
157 try {
158 response = await fetchImpl(descriptor.url, {
159 method: descriptor.method,
160 headers: descriptor.headers,
161 body: descriptor.body,
162 redirect: 'error',
163 signal: requestSignal(),
164 });
165 } catch {
166 throw fixedError('companion_refresh_unavailable');
167 }
168 const json = await readJson(response, 'companion_refresh_failed');
169 const token = validateTokenResponse(json);
170 if (!response.ok || !token.ok) {
171 if (json.error === 'invalid_grant' || json.error === 'invalid_client') {
172 await custody.clearSession();
173 throw fixedError('companion_sign_in_required');
174 }
175 throw fixedError('companion_refresh_failed');
176 }
177
178 const normalized = {
179 ...token,
180 refreshToken: token.refreshToken ?? session.refreshToken,
181 scope: token.scope ?? session.scope,
182 };
183 const meta = {
184 ...buildSessionMeta(normalized, {
185 now: instant,
186 issuer: COMPANION_NATIVE_ISSUER,
187 clientId: session.clientId,
188 }),
189 refreshExpiresAt: session.refreshExpiresAt,
190 };
191 await custody.updateAccessToken({
192 accessToken: token.accessToken,
193 refreshToken: token.refreshToken ?? undefined,
194 meta,
195 });
196 return custody.loadSession();
197 }
198
199 export async function inspectCompanionSession({ keychain, now = () => Date.now() } = {}) {
200 const custody = createTokenCustody(keychain);
201 const session = await custody.loadSession();
202 if (!session || session.issuer !== COMPANION_NATIVE_ISSUER) {
203 return { session: 'absent' };
204 }
205 const state = await custody.decide({ now: now() });
206 return {
207 session: state,
208 scopes: typeof session.scope === 'string' ? session.scope.split(/\s+/).filter(Boolean) : [],
209 };
210 }
211
212 export async function verifyHostedSession({ keychain, fetchImpl = globalThis.fetch, now } = {}) {
213 const session = await ensureHostedSession({ keychain, fetchImpl, now });
214 let response;
215 try {
216 response = await fetchImpl(COMPANION_SESSION_URL, {
217 method: 'GET',
218 headers: { Accept: 'application/json', Authorization: `Bearer ${session.accessToken}` },
219 redirect: 'error',
220 signal: requestSignal(),
221 });
222 } catch {
223 throw fixedError('companion_session_verification_unavailable');
224 }
225 if (!response.ok) throw fixedError('companion_session_verification_failed');
226 const json = await readJson(response, 'companion_session_verification_failed');
227 if (json.type !== 'session' || !Array.isArray(json.scopes)) {
228 throw fixedError('companion_session_verification_failed');
229 }
230 return {
231 authenticated: true,
232 scopes: json.scopes.filter((scope) => typeof scope === 'string'),
233 };
234 }
235
236 export async function signOutCompanion({ keychain, fetchImpl = globalThis.fetch } = {}) {
237 const custody = createTokenCustody(keychain);
238 const session = await custody.loadSession();
239 let remoteRevoked = session?.refreshToken ? false : null;
240 try {
241 if (session?.refreshToken) {
242 const oauth = await discoverCompanionOAuth({ fetchImpl });
243 const body = new URLSearchParams({ token: session.refreshToken });
244 if (session.clientId) body.set('client_id', session.clientId);
245 const response = await fetchImpl(oauth.revocationEndpoint, {
246 method: 'POST',
247 headers: {
248 Accept: 'application/json',
249 'Content-Type': 'application/x-www-form-urlencoded',
250 },
251 body: body.toString(),
252 redirect: 'error',
253 signal: requestSignal(),
254 });
255 remoteRevoked = response.ok;
256 }
257 } catch {
258 remoteRevoked = false;
259 } finally {
260 await custody.clearSession();
261 }
262 return { signedOut: true, remoteRevoked };
263 }
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 25 days ago