oauth-session.mjs
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