borgmcp 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assimilate-cmd.js +39 -497
- package/dist/assimilate-deps.js +3 -177
- package/dist/assimilate-welcome.js +2 -24
- package/dist/auth-env.js +1 -107
- package/dist/auth.js +23 -612
- package/dist/claude.js +11 -281
- package/dist/cli-help.js +29 -50
- package/dist/cli-platform.js +4 -94
- package/dist/codex-app-server.js +4 -228
- package/dist/codex-app-wake.js +2 -122
- package/dist/codex-launch.js +1 -81
- package/dist/codex-remote.js +1 -250
- package/dist/config-utils.js +3 -385
- package/dist/config.js +1 -190
- package/dist/console-prefix.js +1 -86
- package/dist/cube-name.js +1 -65
- package/dist/cubes.js +4 -269
- package/dist/debug.js +1 -71
- package/dist/device-auth.js +1 -167
- package/dist/direct-log.js +1 -11
- package/dist/health-beat.js +1 -168
- package/dist/inbox-monitor.js +1 -129
- package/dist/index.js +26 -1378
- package/dist/lifecycle-log-guard.js +2 -93
- package/dist/list-roles-render.js +6 -39
- package/dist/log-audit.js +3 -186
- package/dist/log-stream.js +9 -848
- package/dist/name-validator.js +1 -22
- package/dist/parse-assimilate-args.js +1 -82
- package/dist/postinstall.js +8 -22
- package/dist/regen-format.js +11 -329
- package/dist/regen.js +5 -83
- package/dist/remote-client.js +1 -695
- package/dist/role-resolver.js +1 -36
- package/dist/role-section.js +8 -208
- package/dist/roster-render.js +3 -96
- package/dist/setup.js +36 -251
- package/dist/shell-escape.js +1 -22
- package/dist/spawn.js +10 -29
- package/dist/stale-version-check.js +1 -102
- package/dist/stream-owner.js +2 -202
- package/dist/stream-status.js +3 -211
- package/dist/subscription-retry.js +1 -23
- package/dist/sync-roles-render.js +3 -118
- package/dist/sync.js +22 -286
- package/dist/templates.js +120 -563
- package/dist/terminal-title.js +1 -68
- package/dist/token-crypto.js +1 -91
- package/dist/token-store.js +1 -222
- package/dist/types.js +0 -5
- package/dist/version.js +2 -78
- package/dist/worktree-lifecycle.js +2 -173
- package/package.json +11 -2
- package/dist/assimilate-cmd.d.ts.map +0 -1
- package/dist/assimilate-cmd.js.map +0 -1
- package/dist/assimilate-deps.d.ts.map +0 -1
- package/dist/assimilate-deps.js.map +0 -1
- package/dist/assimilate-welcome.d.ts.map +0 -1
- package/dist/assimilate-welcome.js.map +0 -1
- package/dist/auth-env.d.ts.map +0 -1
- package/dist/auth-env.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/claude.d.ts.map +0 -1
- package/dist/claude.js.map +0 -1
- package/dist/cli-help.d.ts.map +0 -1
- package/dist/cli-help.js.map +0 -1
- package/dist/cli-platform.d.ts.map +0 -1
- package/dist/cli-platform.js.map +0 -1
- package/dist/codex-app-server.d.ts.map +0 -1
- package/dist/codex-app-server.js.map +0 -1
- package/dist/codex-app-wake.d.ts.map +0 -1
- package/dist/codex-app-wake.js.map +0 -1
- package/dist/codex-launch.d.ts.map +0 -1
- package/dist/codex-launch.js.map +0 -1
- package/dist/codex-remote.d.ts.map +0 -1
- package/dist/codex-remote.js.map +0 -1
- package/dist/config-utils.d.ts.map +0 -1
- package/dist/config-utils.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/console-prefix.d.ts.map +0 -1
- package/dist/console-prefix.js.map +0 -1
- package/dist/cube-name.d.ts.map +0 -1
- package/dist/cube-name.js.map +0 -1
- package/dist/cubes.d.ts.map +0 -1
- package/dist/cubes.js.map +0 -1
- package/dist/debug.d.ts.map +0 -1
- package/dist/debug.js.map +0 -1
- package/dist/device-auth.d.ts.map +0 -1
- package/dist/device-auth.js.map +0 -1
- package/dist/direct-log.d.ts.map +0 -1
- package/dist/direct-log.js.map +0 -1
- package/dist/health-beat.d.ts.map +0 -1
- package/dist/health-beat.js.map +0 -1
- package/dist/inbox-monitor.d.ts.map +0 -1
- package/dist/inbox-monitor.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lifecycle-log-guard.d.ts.map +0 -1
- package/dist/lifecycle-log-guard.js.map +0 -1
- package/dist/list-roles-render.d.ts.map +0 -1
- package/dist/list-roles-render.js.map +0 -1
- package/dist/log-audit.d.ts.map +0 -1
- package/dist/log-audit.js.map +0 -1
- package/dist/log-stream.d.ts.map +0 -1
- package/dist/log-stream.js.map +0 -1
- package/dist/name-validator.d.ts.map +0 -1
- package/dist/name-validator.js.map +0 -1
- package/dist/parse-assimilate-args.d.ts.map +0 -1
- package/dist/parse-assimilate-args.js.map +0 -1
- package/dist/postinstall.d.ts.map +0 -1
- package/dist/postinstall.js.map +0 -1
- package/dist/regen-format.d.ts.map +0 -1
- package/dist/regen-format.js.map +0 -1
- package/dist/regen.d.ts.map +0 -1
- package/dist/regen.js.map +0 -1
- package/dist/remote-client.d.ts.map +0 -1
- package/dist/remote-client.js.map +0 -1
- package/dist/role-resolver.d.ts.map +0 -1
- package/dist/role-resolver.js.map +0 -1
- package/dist/role-section.d.ts.map +0 -1
- package/dist/role-section.js.map +0 -1
- package/dist/roster-render.d.ts.map +0 -1
- package/dist/roster-render.js.map +0 -1
- package/dist/setup.d.ts.map +0 -1
- package/dist/setup.js.map +0 -1
- package/dist/shell-escape.d.ts.map +0 -1
- package/dist/shell-escape.js.map +0 -1
- package/dist/spawn.d.ts.map +0 -1
- package/dist/spawn.js.map +0 -1
- package/dist/stale-version-check.d.ts.map +0 -1
- package/dist/stale-version-check.js.map +0 -1
- package/dist/stream-owner.d.ts.map +0 -1
- package/dist/stream-owner.js.map +0 -1
- package/dist/stream-status.d.ts.map +0 -1
- package/dist/stream-status.js.map +0 -1
- package/dist/subscription-retry.d.ts.map +0 -1
- package/dist/subscription-retry.js.map +0 -1
- package/dist/sync-roles-render.d.ts.map +0 -1
- package/dist/sync-roles-render.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/templates.d.ts.map +0 -1
- package/dist/templates.js.map +0 -1
- package/dist/terminal-title.d.ts.map +0 -1
- package/dist/terminal-title.js.map +0 -1
- package/dist/token-crypto.d.ts.map +0 -1
- package/dist/token-crypto.js.map +0 -1
- package/dist/token-store.d.ts.map +0 -1
- package/dist/token-store.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
- package/dist/worktree-lifecycle.d.ts.map +0 -1
- package/dist/worktree-lifecycle.js.map +0 -1
package/dist/auth.js
CHANGED
|
@@ -1,627 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
* Google OAuth 2.0 Authorization Code Flow with PKCE
|
|
3
|
-
* For Desktop/CLI applications
|
|
4
|
-
*
|
|
5
|
-
* Flow:
|
|
6
|
-
* 1. Generate PKCE code_verifier and code_challenge
|
|
7
|
-
* 2. Start local HTTP server for callback
|
|
8
|
-
* 3. Open browser to Google authorization URL
|
|
9
|
-
* 4. User authorizes in browser
|
|
10
|
-
* 5. Receive authorization code via localhost callback
|
|
11
|
-
* 6. Exchange code for tokens
|
|
12
|
-
* 7. Store tokens securely in OS keychain
|
|
13
|
-
*/
|
|
14
|
-
import { createServer } from 'http';
|
|
15
|
-
import { URL } from 'url';
|
|
16
|
-
import crypto from 'crypto';
|
|
17
|
-
import open from 'open';
|
|
18
|
-
import { storeIdToken, storeRefreshToken, getRefreshToken } from './config.js';
|
|
19
|
-
import { cerr } from './console-prefix.js';
|
|
20
|
-
import { isNoBrowserEnv } from './auth-env.js';
|
|
21
|
-
import { requestDeviceCode, pollForDeviceToken, } from './device-auth.js';
|
|
22
|
-
/**
|
|
23
|
-
* Refresh-token-revoked / expired — the user must re-run `borg setup`
|
|
24
|
-
* to recover. Anchored on Google's canonical signal: HTTP 400 + JSON
|
|
25
|
-
* body `{"error": "invalid_grant", ...}` from the OAuth token
|
|
26
|
-
* endpoint. Callers should `clearTokens()` on this class only —
|
|
27
|
-
* preserving keychain state on the transient class.
|
|
28
|
-
*
|
|
29
|
-
* Constructor stores parsed `error` + `error_description` fields
|
|
30
|
-
* only — never the request body (which contains the refresh_token)
|
|
31
|
-
* or the raw response body verbatim (per drone-8 SR axis b on
|
|
32
|
-
* token-material non-leakage in error messages).
|
|
33
|
-
*/
|
|
34
|
-
export class RefreshTokenInvalidError extends Error {
|
|
35
|
-
errorCode;
|
|
36
|
-
errorDescription;
|
|
37
|
-
constructor(errorCode, errorDescription) {
|
|
38
|
-
super(errorDescription
|
|
39
|
-
? `Refresh token invalid (${errorCode}): ${errorDescription}`
|
|
40
|
-
: `Refresh token invalid (${errorCode})`);
|
|
41
|
-
this.errorCode = errorCode;
|
|
42
|
-
this.errorDescription = errorDescription;
|
|
43
|
-
this.name = 'RefreshTokenInvalidError';
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Transient failure of the refresh path — network failure, Google
|
|
48
|
-
* 5xx, malformed response, non-`invalid_grant` 4xx, etc. The
|
|
49
|
-
* refresh_token in keychain is presumed still valid; callers MUST
|
|
50
|
-
* NOT `clearTokens()` on this class.
|
|
51
|
-
*
|
|
52
|
-
* The pre-gh#34 implementation classified all refresh failures
|
|
53
|
-
* uniformly and called `clearTokens()` unconditionally; a single
|
|
54
|
-
* transient blip would destroy the durable session. This typed
|
|
55
|
-
* class is the discrimination axis.
|
|
56
|
-
*/
|
|
57
|
-
export class RefreshTransientError extends Error {
|
|
58
|
-
constructor(message) {
|
|
59
|
-
super(message);
|
|
60
|
-
this.name = 'RefreshTransientError';
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
// Google OAuth Client credentials for Borg MCP CLI (Desktop app)
|
|
64
|
-
// Per Google's documentation: "the client secret is obviously not treated as a secret"
|
|
65
|
-
// for installed/desktop applications. This follows industry standard (AWS CLI, gcloud, GitHub CLI)
|
|
66
|
-
const GOOGLE_CLIENT_ID = '675073910799-41pbe12rfhqemidh64h09s4q3e0udpgp.apps.googleusercontent.com';
|
|
67
|
-
const GOOGLE_CLIENT_SECRET = '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';
|
|
68
|
-
// gh#557 ESCALATION-1: the device-grant flow needs a SEPARATE Google OAuth
|
|
69
|
-
// client of type "TVs & Limited Input devices" — the Desktop/loopback client
|
|
70
|
-
// above rejects POST /device/code with invalid_client. The operator/Queen
|
|
71
|
-
// created that client in Cloud Console (project 675073910799) and its
|
|
72
|
-
// credentials are baked in below so published clients do headless auth
|
|
73
|
-
// out-of-box. For "TVs & Limited Input devices" clients Google designs the
|
|
74
|
-
// secret to ship inside distributed apps (RFC 8628 limited-input client) — it
|
|
75
|
-
// is NOT a confidential credential, so baking it into the published package is
|
|
76
|
-
// the intended pattern, not a leak. GOOGLE_DEVICE_CLIENT_ID /
|
|
77
|
-
// GOOGLE_DEVICE_CLIENT_SECRET stay as optional env overrides for operators who
|
|
78
|
-
// point the device flow at their own client.
|
|
79
|
-
const BAKED_IN_DEVICE_CLIENT_ID = '675073910799-6qmi73v5106dj1v0l22j2qnkh5r3e8fq.apps.googleusercontent.com';
|
|
80
|
-
const BAKED_IN_DEVICE_CLIENT_SECRET = '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';
|
|
81
|
-
const GOOGLE_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
82
|
-
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
83
|
-
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
|
|
84
|
-
const SCOPES = ['openid', 'email', 'profile'];
|
|
85
|
-
// Port range for dynamic port selection (8000-9000)
|
|
86
|
-
const PORT_RANGE_START = 8000;
|
|
87
|
-
const PORT_RANGE_END = 9000;
|
|
88
|
-
/**
|
|
89
|
-
* Generate PKCE code_verifier and code_challenge
|
|
90
|
-
* Uses SHA256 hashing per OAuth 2.0 PKCE spec (RFC 7636)
|
|
91
|
-
*/
|
|
92
|
-
function generatePKCE() {
|
|
93
|
-
// Generate random code_verifier (43-128 characters)
|
|
94
|
-
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
95
|
-
// Generate code_challenge = BASE64URL(SHA256(code_verifier))
|
|
96
|
-
const challenge = crypto
|
|
97
|
-
.createHash('sha256')
|
|
98
|
-
.update(verifier)
|
|
99
|
-
.digest('base64url');
|
|
100
|
-
return { verifier, challenge };
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Find an available port in the specified range
|
|
104
|
-
*/
|
|
105
|
-
async function findAvailablePort() {
|
|
106
|
-
return new Promise((resolve, reject) => {
|
|
107
|
-
// Try to bind to port 0 to let the OS assign a free port
|
|
108
|
-
const testServer = createServer();
|
|
109
|
-
testServer.listen(0, () => {
|
|
110
|
-
const address = testServer.address();
|
|
111
|
-
if (address && typeof address === 'object') {
|
|
112
|
-
const port = address.port;
|
|
113
|
-
testServer.close(() => resolve(port));
|
|
114
|
-
}
|
|
115
|
-
else {
|
|
116
|
-
testServer.close(() => reject(new Error('Failed to get assigned port')));
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
testServer.on('error', reject);
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Start local HTTP server to receive OAuth callback
|
|
124
|
-
* Returns { server, port, codePromise }
|
|
125
|
-
*/
|
|
126
|
-
async function startCallbackServer() {
|
|
127
|
-
// Find available port first
|
|
128
|
-
const port = await findAvailablePort();
|
|
129
|
-
const codePromise = new Promise((resolve, reject) => {
|
|
130
|
-
const server = createServer((req, res) => {
|
|
131
|
-
const url = new URL(req.url, `http://localhost:${port}`);
|
|
132
|
-
if (url.pathname === '/callback') {
|
|
133
|
-
const code = url.searchParams.get('code');
|
|
134
|
-
const error = url.searchParams.get('error');
|
|
135
|
-
if (error) {
|
|
136
|
-
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
137
|
-
res.end(`
|
|
1
|
+
import{createServer as m}from"http";import{URL as k}from"url";import _ from"crypto";import b from"open";import{storeIdToken as u,storeRefreshToken as d,getRefreshToken as p}from"./config.js";import{cerr as r}from"./console-prefix.js";import{isNoBrowserEnv as R}from"./auth-env.js";import{requestDeviceCode as P,pollForDeviceToken as O}from"./device-auth.js";class x extends Error{errorCode;errorDescription;constructor(t,s){super(s?`Refresh token invalid (${t}): ${s}`:`Refresh token invalid (${t})`),this.errorCode=t,this.errorDescription=s,this.name="RefreshTokenInvalidError"}}class l extends Error{constructor(t){super(t),this.name="RefreshTransientError"}}const w="675073910799-41pbe12rfhqemidh64h09s4q3e0udpgp.apps.googleusercontent.com",g="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",I="675073910799-6qmi73v5106dj1v0l22j2qnkh5r3e8fq.apps.googleusercontent.com",G="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",S="https://accounts.google.com/o/oauth2/v2/auth",E="https://oauth2.googleapis.com/token",A="https://oauth2.googleapis.com/revoke",T=["openid","email","profile"],J=8e3,X=9e3;function L(){const e=_.randomBytes(32).toString("base64url"),t=_.createHash("sha256").update(e).digest("base64url");return{verifier:e,challenge:t}}async function D(){return new Promise((e,t)=>{const s=m();s.listen(0,()=>{const i=s.address();if(i&&typeof i=="object"){const o=i.port;s.close(()=>e(o))}else s.close(()=>t(new Error("Failed to get assigned port")))}),s.on("error",t)})}async function N(){const e=await D(),t=new Promise((s,i)=>{const o=m((n,c)=>{const a=new k(n.url,`http://localhost:${e}`);if(a.pathname==="/callback"){const h=a.searchParams.get("code"),f=a.searchParams.get("error");if(f){c.writeHead(400,{"Content-Type":"text/html"}),c.end(`
|
|
138
2
|
<html>
|
|
139
3
|
<body>
|
|
140
|
-
<h1
|
|
141
|
-
<p>Error: ${
|
|
4
|
+
<h1>\u25FC Authentication Failed</h1>
|
|
5
|
+
<p>Error: ${f}</p>
|
|
142
6
|
<p>You can close this window.</p>
|
|
143
7
|
</body>
|
|
144
8
|
</html>
|
|
145
|
-
`);
|
|
146
|
-
server.close();
|
|
147
|
-
reject(new Error(`OAuth error: ${error}`));
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
if (code) {
|
|
151
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
152
|
-
res.end(`
|
|
9
|
+
`),o.close(),i(new Error(`OAuth error: ${f}`));return}if(h){c.writeHead(200,{"Content-Type":"text/html"}),c.end(`
|
|
153
10
|
<html>
|
|
154
11
|
<body>
|
|
155
|
-
<h1
|
|
12
|
+
<h1>\u25FC Authentication Successful!</h1>
|
|
156
13
|
<p>You can close this window and return to your terminal.</p>
|
|
157
14
|
</body>
|
|
158
15
|
</html>
|
|
159
|
-
`);
|
|
160
|
-
server.close();
|
|
161
|
-
resolve(code);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
165
|
-
res.end(`
|
|
16
|
+
`),o.close(),s(h);return}c.writeHead(400,{"Content-Type":"text/html"}),c.end(`
|
|
166
17
|
<html>
|
|
167
18
|
<body>
|
|
168
|
-
<h1
|
|
19
|
+
<h1>\u25FC Invalid Request</h1>
|
|
169
20
|
<p>Missing authorization code.</p>
|
|
170
21
|
</body>
|
|
171
22
|
</html>
|
|
172
|
-
`);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
});
|
|
189
|
-
return { port, codePromise };
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Exchange authorization code for tokens
|
|
193
|
-
*/
|
|
194
|
-
async function exchangeCodeForTokens(code, codeVerifier, port) {
|
|
195
|
-
const redirectUri = `http://localhost:${port}/callback`;
|
|
196
|
-
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
197
|
-
method: 'POST',
|
|
198
|
-
headers: {
|
|
199
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
200
|
-
},
|
|
201
|
-
body: new URLSearchParams({
|
|
202
|
-
client_id: GOOGLE_CLIENT_ID,
|
|
203
|
-
client_secret: GOOGLE_CLIENT_SECRET,
|
|
204
|
-
code,
|
|
205
|
-
code_verifier: codeVerifier,
|
|
206
|
-
grant_type: 'authorization_code',
|
|
207
|
-
redirect_uri: redirectUri,
|
|
208
|
-
}),
|
|
209
|
-
});
|
|
210
|
-
if (!response.ok) {
|
|
211
|
-
const error = await response.text();
|
|
212
|
-
throw new Error(`Failed to exchange code for tokens: ${error}`);
|
|
213
|
-
}
|
|
214
|
-
return (await response.json());
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Best-effort revocation of a refresh_token at Google's revocation endpoint
|
|
218
|
-
* (RFC 7009). Failures are intentionally swallowed — revocation is opportunistic
|
|
219
|
-
* cleanup of Google's session-side state before requesting fresh consent. If the
|
|
220
|
-
* network is down or the token is already revoked, the subsequent consent flow
|
|
221
|
-
* still runs. The only consequence of skipping revocation is that Google MAY
|
|
222
|
-
* dedupe the new consent and decline to return a fresh refresh_token (the bug
|
|
223
|
-
* class this revocation step exists to prevent).
|
|
224
|
-
*/
|
|
225
|
-
async function revokeRefreshTokenAtGoogle(refreshToken) {
|
|
226
|
-
try {
|
|
227
|
-
// gh#55: per RFC 7009 §2.1, the token MUST be sent in the request
|
|
228
|
-
// body (application/x-www-form-urlencoded), not the query string.
|
|
229
|
-
// Google accepts both shapes in practice, but the prior query-string
|
|
230
|
-
// transport contradicted the declared `Content-Type` header and was
|
|
231
|
-
// a code-clarity issue drone-2 flagged in PR #38 retrospective.
|
|
232
|
-
await fetch(GOOGLE_REVOKE_URL, {
|
|
233
|
-
method: 'POST',
|
|
234
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
235
|
-
body: `token=${encodeURIComponent(refreshToken)}`,
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
// Intentional swallow — see docstring above. Revocation is opportunistic.
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
/**
|
|
243
|
-
* The browser/loopback OAuth flow (PKCE authorization-code). The DEFAULT
|
|
244
|
-
* path on a desktop with a browser; gh#557 dispatches here from
|
|
245
|
-
* `authenticateWithGoogle` unless the environment is browserless.
|
|
246
|
-
*
|
|
247
|
-
* Opens a browser for user authorization and stores tokens in the selected
|
|
248
|
-
* token backend on success.
|
|
249
|
-
*
|
|
250
|
-
* Force-fresh-consent discipline: before requesting new consent, this function
|
|
251
|
-
* revokes any existing refresh_token at Google's revocation endpoint AND uses
|
|
252
|
-
* `prompt=consent select_account` (multi-value prompt) to force both the
|
|
253
|
-
* consent screen and account picker. Together, these clear Google's
|
|
254
|
-
* session-side memory of prior consent, ensuring Google issues a fresh
|
|
255
|
-
* `refresh_token` rather than deduping the consent and returning only an
|
|
256
|
-
* id_token. (Without this, Google's dedup behavior can leave the user with
|
|
257
|
-
* an id_token but no refresh_token, forcing manual re-setup after the ~1h
|
|
258
|
-
* id_token TTL expires.)
|
|
259
|
-
*/
|
|
260
|
-
async function authenticateWithBrowser() {
|
|
261
|
-
cerr('\n◼ Borg MCP Authentication');
|
|
262
|
-
cerr('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
263
|
-
// Step 0: revoke any existing refresh_token to force Google to re-issue
|
|
264
|
-
// one in the fresh consent flow below (defeats Google's consent dedup
|
|
265
|
-
// behavior that leaves clients in a no-refresh-token state).
|
|
266
|
-
const existingRefreshToken = await getRefreshToken();
|
|
267
|
-
if (existingRefreshToken) {
|
|
268
|
-
cerr('Revoking previous refresh_token to force fresh consent...');
|
|
269
|
-
await revokeRefreshTokenAtGoogle(existingRefreshToken);
|
|
270
|
-
}
|
|
271
|
-
// Step 1: Generate PKCE pair
|
|
272
|
-
cerr('Generating PKCE challenge...');
|
|
273
|
-
const pkce = generatePKCE();
|
|
274
|
-
// Step 2: Start local callback server (gets dynamic port)
|
|
275
|
-
cerr('Starting local callback server...');
|
|
276
|
-
const { port, codePromise } = await startCallbackServer();
|
|
277
|
-
// Step 3: Build authorization URL with dynamic redirect URI
|
|
278
|
-
const redirectUri = `http://localhost:${port}/callback`;
|
|
279
|
-
const authUrl = new URL(GOOGLE_AUTHORIZE_URL);
|
|
280
|
-
authUrl.searchParams.set('client_id', GOOGLE_CLIENT_ID);
|
|
281
|
-
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
282
|
-
authUrl.searchParams.set('response_type', 'code');
|
|
283
|
-
authUrl.searchParams.set('scope', SCOPES.join(' '));
|
|
284
|
-
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
285
|
-
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
286
|
-
authUrl.searchParams.set('access_type', 'offline'); // Request refresh token
|
|
287
|
-
// Multi-value prompt forces both consent screen AND account picker. Combined
|
|
288
|
-
// with the pre-revocation step above, this reliably forces Google to issue
|
|
289
|
-
// a fresh refresh_token instead of deduping the consent.
|
|
290
|
-
authUrl.searchParams.set('prompt', 'consent select_account');
|
|
291
|
-
// Step 4: Open browser
|
|
292
|
-
cerr('\n📱 Opening browser for authorization...');
|
|
293
|
-
cerr('If browser does not open, visit:');
|
|
294
|
-
cerr(`${authUrl.toString()}\n`);
|
|
295
|
-
await open(authUrl.toString());
|
|
296
|
-
// Step 5: Wait for authorization code
|
|
297
|
-
cerr('Waiting for authorization...');
|
|
298
|
-
const code = await codePromise;
|
|
299
|
-
// Step 6: Exchange code for tokens
|
|
300
|
-
cerr('Exchanging authorization code for tokens...');
|
|
301
|
-
const tokenData = await exchangeCodeForTokens(code, pkce.verifier, port);
|
|
302
|
-
// Step 7: Store tokens securely
|
|
303
|
-
const expiresAt = Date.now() + tokenData.expires_in * 1000;
|
|
304
|
-
await storeIdToken(tokenData.id_token, expiresAt);
|
|
305
|
-
if (tokenData.refresh_token) {
|
|
306
|
-
await storeRefreshToken(tokenData.refresh_token);
|
|
307
|
-
}
|
|
308
|
-
else {
|
|
309
|
-
// No refresh_token means auto-refresh-on-expiry will fail and force
|
|
310
|
-
// the user back through `borg setup` after ~1 hour. Google
|
|
311
|
-
// sometimes dedupes consent and skips the refresh_token even when
|
|
312
|
-
// prompt=consent is set. Surface this so the user can revoke and
|
|
313
|
-
// reconsent if they want durable sessions.
|
|
314
|
-
cerr('\n⚠ No refresh_token returned by Google.');
|
|
315
|
-
cerr(' Your session will expire after ~1 hour and require');
|
|
316
|
-
cerr(' re-running `borg setup`. To enable auto-refresh:');
|
|
317
|
-
cerr(' 1. Visit https://myaccount.google.com/permissions');
|
|
318
|
-
cerr(' 2. Find "Borg MCP" and click "Remove access"');
|
|
319
|
-
cerr(' 3. Re-run `borg setup`');
|
|
320
|
-
cerr(' (Google will then issue a fresh refresh_token.)\n');
|
|
321
|
-
}
|
|
322
|
-
cerr('\n◼ Authentication successful!\n');
|
|
323
|
-
}
|
|
324
|
-
/**
|
|
325
|
-
* Decide whether to use the no-browser device-grant flow. An explicit
|
|
326
|
-
* `--no-browser`/`--device` (surfaced as opts.noBrowser) wins; otherwise
|
|
327
|
-
* auto-detect via isNoBrowserEnv (SSH session, container, headless Linux).
|
|
328
|
-
*/
|
|
329
|
-
export function shouldUseDeviceFlow(opts) {
|
|
330
|
-
return opts?.noBrowser ?? isNoBrowserEnv();
|
|
331
|
-
}
|
|
332
|
-
/**
|
|
333
|
-
* Assemble the device-grant OAuth config from the environment. Enforces the
|
|
334
|
-
* gh#557 ESCALATION-1 gate: a "TVs & Limited Input devices" client id must be
|
|
335
|
-
* available (baked-in once the operator creates it, or via GOOGLE_DEVICE_CLIENT_ID).
|
|
336
|
-
* Without one we fail with an actionable error instead of hitting Google with
|
|
337
|
-
* the Desktop client, which rejects /device/code as invalid_client.
|
|
338
|
-
*/
|
|
339
|
-
export function buildDeviceAuthConfig(env = process.env) {
|
|
340
|
-
// Pair the secret with the id SOURCE so an env override never inherits the
|
|
341
|
-
// baked-in client's secret: an operator pointing GOOGLE_DEVICE_CLIENT_ID at
|
|
342
|
-
// their own client supplies their own (optional) secret; the baked-in secret
|
|
343
|
-
// applies only to the baked-in id.
|
|
344
|
-
const envClientId = env.GOOGLE_DEVICE_CLIENT_ID?.trim();
|
|
345
|
-
const envClientSecret = env.GOOGLE_DEVICE_CLIENT_SECRET?.trim() || undefined;
|
|
346
|
-
let clientId;
|
|
347
|
-
let clientSecret;
|
|
348
|
-
if (envClientId) {
|
|
349
|
-
clientId = envClientId;
|
|
350
|
-
clientSecret = envClientSecret;
|
|
351
|
-
}
|
|
352
|
-
else {
|
|
353
|
-
// Baked-in id ALWAYS pairs with the baked-in secret. A stray
|
|
354
|
-
// GOOGLE_DEVICE_CLIENT_SECRET set WITHOUT an id override must NOT re-pair
|
|
355
|
-
// the baked id with a foreign secret ({baked id, wrong secret} →
|
|
356
|
-
// invalid_client). Override is all-or-nothing with the id.
|
|
357
|
-
clientId = BAKED_IN_DEVICE_CLIENT_ID;
|
|
358
|
-
clientSecret = BAKED_IN_DEVICE_CLIENT_SECRET || undefined;
|
|
359
|
-
}
|
|
360
|
-
if (!clientId) {
|
|
361
|
-
throw new Error('No-browser (device-grant) auth needs a Google "TVs & Limited Input devices" ' +
|
|
362
|
-
'OAuth client. Set GOOGLE_DEVICE_CLIENT_ID in the environment, or run ' +
|
|
363
|
-
'`borg setup` on a machine with a browser. See docs/REMOTE_TERMINAL_AUTH.md.');
|
|
364
|
-
}
|
|
365
|
-
return { clientId, clientSecret, scopes: SCOPES };
|
|
366
|
-
}
|
|
367
|
-
/** Real sleep for the production device-poll loop. */
|
|
368
|
-
function defaultSleep(ms) {
|
|
369
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
370
|
-
}
|
|
371
|
-
/**
|
|
372
|
-
* The RFC 8628 device-grant flow (no browser). Prints a verification URL +
|
|
373
|
-
* user_code for the human to open on ANY device, polls Google until they
|
|
374
|
-
* authorize, then stores tokens in the selected backend. Network deps are
|
|
375
|
-
* injectable for tests; the device-poll state machine itself lives in
|
|
376
|
-
* device-auth.ts (fully unit-tested).
|
|
377
|
-
*/
|
|
378
|
-
export async function authenticateWithDeviceFlow(deps = { fetch, sleep: defaultSleep }, env = process.env) {
|
|
379
|
-
cerr('\n◼ Borg MCP Authentication (no-browser mode)');
|
|
380
|
-
cerr('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
381
|
-
const config = buildDeviceAuthConfig(env);
|
|
382
|
-
// Same force-fresh-consent discipline as the browser flow: revoke any
|
|
383
|
-
// existing refresh_token so Google re-issues one in the new grant.
|
|
384
|
-
const existingRefreshToken = await getRefreshToken();
|
|
385
|
-
if (existingRefreshToken) {
|
|
386
|
-
cerr('Revoking previous refresh_token to force fresh consent...');
|
|
387
|
-
await revokeRefreshTokenAtGoogle(existingRefreshToken);
|
|
388
|
-
}
|
|
389
|
-
const deviceCode = await requestDeviceCode(config, deps);
|
|
390
|
-
cerr('To authorize Borg MCP on this machine:');
|
|
391
|
-
cerr(` 1. On any device with a browser, open: ${deviceCode.verification_url}`);
|
|
392
|
-
cerr(` 2. Enter this code: ${deviceCode.user_code}\n`);
|
|
393
|
-
cerr('Waiting for authorization (this page can be open on your phone or laptop)...');
|
|
394
|
-
const tokens = await pollForDeviceToken(deviceCode, config, deps);
|
|
395
|
-
const expiresAt = Date.now() + tokens.expires_in * 1000;
|
|
396
|
-
await storeIdToken(tokens.id_token, expiresAt);
|
|
397
|
-
if (tokens.refresh_token) {
|
|
398
|
-
await storeRefreshToken(tokens.refresh_token);
|
|
399
|
-
}
|
|
400
|
-
else {
|
|
401
|
-
cerr('\n⚠ No refresh_token returned by Google.');
|
|
402
|
-
cerr(' Your session will expire after ~1 hour and require re-running');
|
|
403
|
-
cerr(' `borg setup`. Re-consent at https://myaccount.google.com/permissions');
|
|
404
|
-
cerr(' (remove "Borg MCP") then re-run setup to restore automatic token refresh.\n');
|
|
405
|
-
}
|
|
406
|
-
cerr('\n◼ Authentication successful!\n');
|
|
407
|
-
}
|
|
408
|
-
/**
|
|
409
|
-
* Perform the complete OAuth flow, choosing the browser/loopback flow or the
|
|
410
|
-
* no-browser device-grant flow based on the environment (gh#557). Stores
|
|
411
|
-
* tokens in the selected backend on success.
|
|
412
|
-
*
|
|
413
|
-
* @param opts.noBrowser force the device flow (`--no-browser`/`--device`);
|
|
414
|
-
* when omitted, the environment is auto-detected.
|
|
415
|
-
*/
|
|
416
|
-
export async function authenticateWithGoogle(opts) {
|
|
417
|
-
if (shouldUseDeviceFlow(opts)) {
|
|
418
|
-
return authenticateWithDeviceFlow();
|
|
419
|
-
}
|
|
420
|
-
return authenticateWithBrowser();
|
|
421
|
-
}
|
|
422
|
-
/**
|
|
423
|
-
* Attempt a single refresh_token → id_token exchange against ONE OAuth
|
|
424
|
-
* client (gh#34 hardened). The exported `refreshIdToken` below orchestrates
|
|
425
|
-
* the web-then-device cascade over this helper (gh#691); a refresh_token can
|
|
426
|
-
* only be redeemed by its issuing client and we don't persist which flow
|
|
427
|
-
* minted the stored token.
|
|
428
|
-
*
|
|
429
|
-
* Returns a RefreshAttemptResult (never throws on a classified failure) so
|
|
430
|
-
* the orchestrator can weigh BOTH client attempts before deciding whether
|
|
431
|
-
* the token is genuinely revoked (clear keychain) or should be retried.
|
|
432
|
-
*
|
|
433
|
-
* Three substantive properties carried from the pre-gh#34 shape:
|
|
434
|
-
*
|
|
435
|
-
* 1. **Typed discrimination.** Classifies as `kind:'invalid'` (carrying
|
|
436
|
-
* `RefreshTokenInvalidError`) only when Google's response is the
|
|
437
|
-
* canonical revoked/expired signal (HTTP 400 + JSON body
|
|
438
|
-
* `{"error": "invalid_grant"}`). Every other failure mode
|
|
439
|
-
* (network/DNS/timeout, Google 5xx, malformed response, other
|
|
440
|
-
* 4xx error codes like `invalid_request` or `unauthorized_client`,
|
|
441
|
-
* non-JSON body) becomes `kind:'transient'`. The orchestrator gates
|
|
442
|
-
* `clearTokens()` on BOTH attempts returning Invalid — transient
|
|
443
|
-
* (or single-client invalid) outcomes preserve the keychain so a
|
|
444
|
-
* network blip or wrong-client rejection doesn't destroy a durable
|
|
445
|
-
* session.
|
|
446
|
-
*
|
|
447
|
-
* 2. **refresh_token rotation handling.** If Google's response
|
|
448
|
-
* includes a new `refresh_token` (token rotation feature),
|
|
449
|
-
* store the new value before storing the id_token. Ordering
|
|
450
|
-
* matters: refresh_token write FIRST so a subsequent id_token
|
|
451
|
-
* write failure leaves us with `(new refresh_token, stale
|
|
452
|
-
* id_token)` — a recoverable state where the next refresh
|
|
453
|
-
* attempt uses the new refresh_token to fetch a fresh id_token.
|
|
454
|
-
* The reverse ordering would leave `(new id_token, stale
|
|
455
|
-
* refresh_token)`, and Google's rotation eagerly invalidates
|
|
456
|
-
* the stale refresh_token server-side, so the next refresh
|
|
457
|
-
* fails with `invalid_grant` and locks the user out (drone-8 SR
|
|
458
|
-
* axis c, 14:07:28).
|
|
459
|
-
*
|
|
460
|
-
* 3. **Classification anchored on the parsed body**, not on the
|
|
461
|
-
* HTTP status alone or substring-matched against a thrown JS
|
|
462
|
-
* error string. Per drone-8 SR axis (a): status-code-alone is
|
|
463
|
-
* fragile (Google can return 400 for `invalid_request` /
|
|
464
|
-
* `unauthorized_client` which are NOT revocation); substring
|
|
465
|
-
* matching is spoofable. We parse the JSON `error` field and
|
|
466
|
-
* match on its exact value.
|
|
467
|
-
*/
|
|
468
|
-
async function attemptRefreshWithClient(refreshToken, clientId, clientSecret) {
|
|
469
|
-
const refreshParams = {
|
|
470
|
-
client_id: clientId,
|
|
471
|
-
refresh_token: refreshToken,
|
|
472
|
-
grant_type: 'refresh_token',
|
|
473
|
-
};
|
|
474
|
-
// Device-code clients ("TVs & Limited Input devices") may be configured
|
|
475
|
-
// without a secret; only send one when we have it (mirrors the device
|
|
476
|
-
// token request in device-auth.ts).
|
|
477
|
-
if (clientSecret) {
|
|
478
|
-
refreshParams.client_secret = clientSecret;
|
|
479
|
-
}
|
|
480
|
-
let response;
|
|
481
|
-
try {
|
|
482
|
-
response = await fetch(GOOGLE_TOKEN_URL, {
|
|
483
|
-
method: 'POST',
|
|
484
|
-
headers: {
|
|
485
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
486
|
-
},
|
|
487
|
-
body: new URLSearchParams(refreshParams),
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
catch (err) {
|
|
491
|
-
// Network-layer failure: DNS, connection refused, TLS handshake
|
|
492
|
-
// failure, timeout, etc. None of these signal refresh_token
|
|
493
|
-
// revocation — treat as transient.
|
|
494
|
-
return {
|
|
495
|
-
ok: false,
|
|
496
|
-
kind: 'transient',
|
|
497
|
-
error: new RefreshTransientError(`Network failure during token refresh: ${err?.message ?? 'unknown'}`),
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
if (!response.ok) {
|
|
501
|
-
// Parse the response body. Fall back to Transient on non-JSON
|
|
502
|
-
// (proxy error page, HTML error from misrouted request, etc.) —
|
|
503
|
-
// when we can't tell, preserve tokens (safer default).
|
|
504
|
-
let errBody = null;
|
|
505
|
-
try {
|
|
506
|
-
errBody = (await response.json());
|
|
507
|
-
}
|
|
508
|
-
catch {
|
|
509
|
-
return {
|
|
510
|
-
ok: false,
|
|
511
|
-
kind: 'transient',
|
|
512
|
-
error: new RefreshTransientError(`Token refresh failed with HTTP ${response.status} (non-JSON body)`),
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
// Canonical Google OAuth revocation signal: HTTP 400 + parsed
|
|
516
|
-
// body `error === 'invalid_grant'`. Everything else (other 4xx
|
|
517
|
-
// error codes, 5xx, missing/unexpected error field) is Transient.
|
|
518
|
-
// NB: a refresh_token redeemed against the WRONG client can also
|
|
519
|
-
// yield invalid_grant — the orchestrator only treats it as terminal
|
|
520
|
-
// when BOTH clients reject it (gh#691).
|
|
521
|
-
if (response.status === 400 && errBody?.error === 'invalid_grant') {
|
|
522
|
-
return {
|
|
523
|
-
ok: false,
|
|
524
|
-
kind: 'invalid',
|
|
525
|
-
error: new RefreshTokenInvalidError('invalid_grant', errBody.error_description),
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
return {
|
|
529
|
-
ok: false,
|
|
530
|
-
kind: 'transient',
|
|
531
|
-
error: new RefreshTransientError(`Token refresh failed with HTTP ${response.status}${errBody?.error ? ` (${errBody.error})` : ''}`),
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
let data;
|
|
535
|
-
try {
|
|
536
|
-
data = (await response.json());
|
|
537
|
-
}
|
|
538
|
-
catch (err) {
|
|
539
|
-
return {
|
|
540
|
-
ok: false,
|
|
541
|
-
kind: 'transient',
|
|
542
|
-
error: new RefreshTransientError(`Token refresh response unparseable: ${err?.message ?? 'unknown'}`),
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
if (!data.id_token || typeof data.expires_in !== 'number') {
|
|
546
|
-
return {
|
|
547
|
-
ok: false,
|
|
548
|
-
kind: 'transient',
|
|
549
|
-
error: new RefreshTransientError('Token refresh response missing id_token or expires_in'),
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
const expiresAt = Date.now() + data.expires_in * 1000;
|
|
553
|
-
// Rotation case: Google returned a new refresh_token. Store it
|
|
554
|
-
// FIRST so a subsequent id_token write failure leaves us in the
|
|
555
|
-
// recoverable state (new refresh_token + stale id_token) rather
|
|
556
|
-
// than the locked-out state (new id_token + invalidated old
|
|
557
|
-
// refresh_token). The previous-token snapshot lets us rollback
|
|
558
|
-
// the refresh_token write itself if the id_token write fails —
|
|
559
|
-
// keeping the keychain consistent with what the caller perceives.
|
|
560
|
-
if (data.refresh_token) {
|
|
561
|
-
const previousRefreshToken = await getRefreshToken();
|
|
562
|
-
await storeRefreshToken(data.refresh_token);
|
|
563
|
-
try {
|
|
564
|
-
await storeIdToken(data.id_token, expiresAt);
|
|
565
|
-
}
|
|
566
|
-
catch (err) {
|
|
567
|
-
// Rollback the refresh_token write so we don't end up with a
|
|
568
|
-
// half-rotated keychain on a transient keychain-layer failure.
|
|
569
|
-
// Best-effort: if the rollback itself fails, the caller still
|
|
570
|
-
// sees the original error and the keychain is in the new-
|
|
571
|
-
// refresh-token-only state, which is still recoverable on the
|
|
572
|
-
// next refresh attempt.
|
|
573
|
-
if (previousRefreshToken) {
|
|
574
|
-
try {
|
|
575
|
-
await storeRefreshToken(previousRefreshToken);
|
|
576
|
-
}
|
|
577
|
-
catch {
|
|
578
|
-
// intentional swallow — original error takes precedence
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
throw err;
|
|
582
|
-
}
|
|
583
|
-
return { ok: true };
|
|
584
|
-
}
|
|
585
|
-
// No rotation — just refresh the id_token.
|
|
586
|
-
await storeIdToken(data.id_token, expiresAt);
|
|
587
|
-
return { ok: true };
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* Refresh the stored id_token, redeeming the refresh_token against the
|
|
591
|
-
* OAuth client that ISSUED it (gh#691).
|
|
592
|
-
*
|
|
593
|
-
* A refresh_token can only be redeemed by its issuing client. The browser
|
|
594
|
-
* flow issues tokens under the web client (GOOGLE_CLIENT_ID); the device
|
|
595
|
-
* flow (`borg setup --no-browser`, gh#557) issues them under the device
|
|
596
|
-
* client (buildDeviceAuthConfig). We don't persist which flow minted the
|
|
597
|
-
* stored token, so try the web client first, then the device client.
|
|
598
|
-
*
|
|
599
|
-
* Before this fix the refresh hard-coded the web client, so every
|
|
600
|
-
* device-flow user's silent refresh failed at id_token expiry — forcing a
|
|
601
|
-
* full re-auth (which revokes the working refresh_token to force fresh
|
|
602
|
-
* consent) on the next command. That was the friction in gh#691.
|
|
603
|
-
*
|
|
604
|
-
* Keychain safety: clear-on-revocation (RefreshTokenInvalidError, which
|
|
605
|
-
* callers gate `clearTokens()` on) is surfaced ONLY when BOTH clients
|
|
606
|
-
* reject the token with invalid_grant. Any inconclusive/transient outcome
|
|
607
|
-
* on either attempt preserves the keychain for retry — a wrong-client
|
|
608
|
-
* invalid_grant must not be mistaken for a genuine revocation.
|
|
609
|
-
*/
|
|
610
|
-
export async function refreshIdToken(refreshToken) {
|
|
611
|
-
const web = await attemptRefreshWithClient(refreshToken, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
|
612
|
-
if (web.ok)
|
|
613
|
-
return;
|
|
614
|
-
const device = buildDeviceAuthConfig();
|
|
615
|
-
const dev = await attemptRefreshWithClient(refreshToken, device.clientId, device.clientSecret);
|
|
616
|
-
if (dev.ok)
|
|
617
|
-
return;
|
|
618
|
-
// Neither client could redeem the token. Surface the canonical
|
|
619
|
-
// revocation signal (→ clearTokens) ONLY when BOTH clients rejected it
|
|
620
|
-
// as invalid_grant; otherwise surface the transient error so the
|
|
621
|
-
// keychain is preserved and the next attempt can retry.
|
|
622
|
-
if (web.kind === 'invalid' && dev.kind === 'invalid') {
|
|
623
|
-
throw dev.error;
|
|
624
|
-
}
|
|
625
|
-
throw web.kind === 'transient' ? web.error : dev.error;
|
|
626
|
-
}
|
|
627
|
-
//# sourceMappingURL=auth.js.map
|
|
23
|
+
`),o.close(),i(new Error("Missing authorization code"))}});o.listen(e,()=>{r(`Callback server listening on http://localhost:${e}`)}),setTimeout(()=>{o.close(),i(new Error("Authentication timeout - no response received"))},300*1e3).unref()});return{port:e,codePromise:t}}async function $(e,t,s){const i=`http://localhost:${s}/callback`,o=await fetch(E,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({client_id:w,client_secret:g,code:e,code_verifier:t,grant_type:"authorization_code",redirect_uri:i})});if(!o.ok){const n=await o.text();throw new Error(`Failed to exchange code for tokens: ${n}`)}return await o.json()}async function v(e){try{await fetch(A,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:`token=${encodeURIComponent(e)}`})}catch{}}async function U(){r(`
|
|
24
|
+
\u25FC Borg MCP Authentication`),r(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
25
|
+
`);const e=await p();e&&(r("Revoking previous refresh_token to force fresh consent..."),await v(e)),r("Generating PKCE challenge...");const t=L();r("Starting local callback server...");const{port:s,codePromise:i}=await N(),o=`http://localhost:${s}/callback`,n=new k(S);n.searchParams.set("client_id",w),n.searchParams.set("redirect_uri",o),n.searchParams.set("response_type","code"),n.searchParams.set("scope",T.join(" ")),n.searchParams.set("code_challenge",t.challenge),n.searchParams.set("code_challenge_method","S256"),n.searchParams.set("access_type","offline"),n.searchParams.set("prompt","consent select_account"),r(`
|
|
26
|
+
\u{1F4F1} Opening browser for authorization...`),r("If browser does not open, visit:"),r(`${n.toString()}
|
|
27
|
+
`),await b(n.toString()),r("Waiting for authorization...");const c=await i;r("Exchanging authorization code for tokens...");const a=await $(c,t.verifier,s),h=Date.now()+a.expires_in*1e3;await u(a.id_token,h),a.refresh_token?await d(a.refresh_token):(r(`
|
|
28
|
+
\u26A0 No refresh_token returned by Google.`),r(" Your session will expire after ~1 hour and require"),r(" re-running `borg setup`. To enable auto-refresh:"),r(" 1. Visit https://myaccount.google.com/permissions"),r(' 2. Find "Borg MCP" and click "Remove access"'),r(" 3. Re-run `borg setup`"),r(` (Google will then issue a fresh refresh_token.)
|
|
29
|
+
`)),r(`
|
|
30
|
+
\u25FC Authentication successful!
|
|
31
|
+
`)}function B(e){return e?.noBrowser??R()}function y(e=process.env){const t=e.GOOGLE_DEVICE_CLIENT_ID?.trim(),s=e.GOOGLE_DEVICE_CLIENT_SECRET?.trim()||void 0;let i,o;if(t?(i=t,o=s):(i=I,o=G||void 0),!i)throw new Error('No-browser (device-grant) auth needs a Google "TVs & Limited Input devices" OAuth client. Set GOOGLE_DEVICE_CLIENT_ID in the environment, or run `borg setup` on a machine with a browser. See docs/REMOTE_TERMINAL_AUTH.md.');return{clientId:i,clientSecret:o,scopes:T}}function M(e){return new Promise(t=>setTimeout(t,e))}async function q(e={fetch,sleep:M},t=process.env){r(`
|
|
32
|
+
\u25FC Borg MCP Authentication (no-browser mode)`),r(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
33
|
+
`);const s=y(t),i=await p();i&&(r("Revoking previous refresh_token to force fresh consent..."),await v(i));const o=await P(s,e);r("To authorize Borg MCP on this machine:"),r(` 1. On any device with a browser, open: ${o.verification_url}`),r(` 2. Enter this code: ${o.user_code}
|
|
34
|
+
`),r("Waiting for authorization (this page can be open on your phone or laptop)...");const n=await O(o,s,e),c=Date.now()+n.expires_in*1e3;await u(n.id_token,c),n.refresh_token?await d(n.refresh_token):(r(`
|
|
35
|
+
\u26A0 No refresh_token returned by Google.`),r(" Your session will expire after ~1 hour and require re-running"),r(" `borg setup`. Re-consent at https://myaccount.google.com/permissions"),r(` (remove "Borg MCP") then re-run setup to restore automatic token refresh.
|
|
36
|
+
`)),r(`
|
|
37
|
+
\u25FC Authentication successful!
|
|
38
|
+
`)}async function Q(e){return B(e)?q():U()}async function C(e,t,s){const i={client_id:t,refresh_token:e,grant_type:"refresh_token"};s&&(i.client_secret=s);let o;try{o=await fetch(E,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams(i)})}catch(a){return{ok:!1,kind:"transient",error:new l(`Network failure during token refresh: ${a?.message??"unknown"}`)}}if(!o.ok){let a=null;try{a=await o.json()}catch{return{ok:!1,kind:"transient",error:new l(`Token refresh failed with HTTP ${o.status} (non-JSON body)`)}}return o.status===400&&a?.error==="invalid_grant"?{ok:!1,kind:"invalid",error:new x("invalid_grant",a.error_description)}:{ok:!1,kind:"transient",error:new l(`Token refresh failed with HTTP ${o.status}${a?.error?` (${a.error})`:""}`)}}let n;try{n=await o.json()}catch(a){return{ok:!1,kind:"transient",error:new l(`Token refresh response unparseable: ${a?.message??"unknown"}`)}}if(!n.id_token||typeof n.expires_in!="number")return{ok:!1,kind:"transient",error:new l("Token refresh response missing id_token or expires_in")};const c=Date.now()+n.expires_in*1e3;if(n.refresh_token){const a=await p();await d(n.refresh_token);try{await u(n.id_token,c)}catch(h){if(a)try{await d(a)}catch{}throw h}return{ok:!0}}return await u(n.id_token,c),{ok:!0}}async function Z(e){const t=await C(e,w,g);if(t.ok)return;const s=y(),i=await C(e,s.clientId,s.clientSecret);if(!i.ok)throw t.kind==="invalid"&&i.kind==="invalid"?i.error:t.kind==="transient"?t.error:i.error}export{x as RefreshTokenInvalidError,l as RefreshTransientError,q as authenticateWithDeviceFlow,Q as authenticateWithGoogle,y as buildDeviceAuthConfig,Z as refreshIdToken,B as shouldUseDeviceFlow};
|