@anyslate/cli 0.2.0 → 0.3.0
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/README.md +100 -16
- package/package.json +1 -1
- package/src/auth.mjs +310 -0
- package/src/commands/checkpoint.mjs +12 -9
- package/src/commands/doctor.mjs +98 -5
- package/src/commands/hook.mjs +15 -8
- package/src/commands/login.mjs +298 -53
- package/src/commands/logout.mjs +131 -0
- package/src/commands/upload-artifact.mjs +13 -9
- package/src/config.mjs +54 -3
- package/src/credentials.mjs +162 -0
- package/src/index.mjs +30 -9
- package/src/oauth.mjs +633 -0
package/src/oauth.mjs
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
// OAuth 2.1 authorization-code + PKCE + Dynamic Client Registration for
|
|
2
|
+
// `anyslate login`.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. `anyslate login --token as_mcp_…` required the user to open
|
|
5
|
+
// the desktop app, mint a token, and paste it into a terminal. That is four
|
|
6
|
+
// context switches for a first-run experience, and the pasted token never
|
|
7
|
+
// expires, so it is also the worst credential to leave lying in a shell
|
|
8
|
+
// history. The browser flow replaces it for humans; `--token` stays for CI.
|
|
9
|
+
//
|
|
10
|
+
// EVERY ENDPOINT IS DISCOVERED, NOTHING IS GUESSED.
|
|
11
|
+
// GET {root}/.well-known/oauth-authorization-server → issuer,
|
|
12
|
+
// authorization_endpoint, token_endpoint, registration_endpoint,
|
|
13
|
+
// code_challenge_methods_supported, grant_types_supported
|
|
14
|
+
// GET {root}/.well-known/oauth-protected-resource → resource
|
|
15
|
+
//
|
|
16
|
+
// The `resource` value is REQUIRED on /oauth/authorize and accepted on
|
|
17
|
+
// /oauth/token. It is read from the protected-resource document for the root
|
|
18
|
+
// being logged into — never hardcoded — because dev, prod and a local wrangler
|
|
19
|
+
// instance each publish a different one, and a wrong `resource` is rejected at
|
|
20
|
+
// the authorize step with no useful diagnostic.
|
|
21
|
+
//
|
|
22
|
+
// LOOPBACK PORTS AND THE REGISTERED REDIRECT URI. RFC 8252 §7.3 says a native
|
|
23
|
+
// app must be able to bind an ephemeral loopback port, so the server's
|
|
24
|
+
// redirect-URI comparison ignores the port for loopback URIs (it compares
|
|
25
|
+
// protocol + hostname + pathname). We therefore register ONE canonical
|
|
26
|
+
// `http://127.0.0.1/callback` and send the actual `http://127.0.0.1:<port>/callback`
|
|
27
|
+
// at authorize/exchange time. Registering the live port instead would burn a
|
|
28
|
+
// Dynamic Client Registration on every login — and DCR is rate limited to 10
|
|
29
|
+
// per hour, so that would lock a user out after ten logins.
|
|
30
|
+
|
|
31
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
32
|
+
import { createServer } from 'node:http';
|
|
33
|
+
import { spawn } from 'node:child_process';
|
|
34
|
+
import { USER_AGENT } from './version.mjs';
|
|
35
|
+
import { classifyNetworkError } from './mcp-client.mjs';
|
|
36
|
+
|
|
37
|
+
export const AS_METADATA_PATH = '/.well-known/oauth-authorization-server';
|
|
38
|
+
export const RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource';
|
|
39
|
+
|
|
40
|
+
export const LOOPBACK_HOST = '127.0.0.1';
|
|
41
|
+
export const CALLBACK_PATH = '/callback';
|
|
42
|
+
|
|
43
|
+
/** The URI we register once and reuse forever. The port is deliberately absent. */
|
|
44
|
+
export const REGISTERED_REDIRECT_URI = `http://${LOOPBACK_HOST}${CALLBACK_PATH}`;
|
|
45
|
+
|
|
46
|
+
export const CLIENT_NAME = 'AnySlate CLI';
|
|
47
|
+
|
|
48
|
+
/** How long `login` waits on the loopback listener, in seconds. */
|
|
49
|
+
export const DEFAULT_CALLBACK_TIMEOUT_S = 180;
|
|
50
|
+
|
|
51
|
+
/** Access tokens live 3600s. Refresh this far ahead of expiry. */
|
|
52
|
+
export const REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
53
|
+
|
|
54
|
+
/** Fallback when the token response omits `expires_in` (server sends 3600). */
|
|
55
|
+
export const DEFAULT_EXPIRES_IN_S = 3600;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// PKCE
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** @param {Buffer|Uint8Array|string} input */
|
|
62
|
+
export function base64url(input) {
|
|
63
|
+
return Buffer.from(input)
|
|
64
|
+
.toString('base64')
|
|
65
|
+
.replace(/\+/g, '-')
|
|
66
|
+
.replace(/\//g, '_')
|
|
67
|
+
.replace(/=+$/, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* S256: challenge = base64url(sha256(ASCII(verifier))).
|
|
72
|
+
*
|
|
73
|
+
* The hash is taken over the verifier's ASCII characters, NOT over the random
|
|
74
|
+
* bytes it was derived from. Hashing the raw bytes produces a challenge the
|
|
75
|
+
* server can never reproduce, and the failure surfaces only at the token
|
|
76
|
+
* endpoint as an opaque `invalid_grant`.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} verifier
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function deriveCodeChallenge(verifier) {
|
|
82
|
+
return base64url(createHash('sha256').update(verifier, 'ascii').digest());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 64 random bytes → 86 base64url chars, inside RFC 7636's 43–128 range.
|
|
87
|
+
* @param {number} [bytes]
|
|
88
|
+
* @returns {{verifier: string, challenge: string, method: 'S256'}}
|
|
89
|
+
*/
|
|
90
|
+
export function generatePkce(bytes = 64) {
|
|
91
|
+
const verifier = base64url(randomBytes(bytes));
|
|
92
|
+
return { verifier, challenge: deriveCodeChallenge(verifier), method: 'S256' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function generateState() {
|
|
96
|
+
return base64url(randomBytes(32));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// HTTP helpers
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function hostOf(url) {
|
|
104
|
+
try {
|
|
105
|
+
return new URL(url).host;
|
|
106
|
+
} catch {
|
|
107
|
+
return String(url);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function requestJson(url, { method = 'GET', body, headers = {}, fetchImpl = fetch, timeoutMs = 15_000 }) {
|
|
112
|
+
const ac = new AbortController();
|
|
113
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
114
|
+
let res;
|
|
115
|
+
try {
|
|
116
|
+
res = await fetchImpl(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers: { accept: 'application/json', 'user-agent': USER_AGENT, ...headers },
|
|
119
|
+
body,
|
|
120
|
+
signal: ac.signal,
|
|
121
|
+
});
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { ok: false, networkError: true, detail: classifyNetworkError(e, hostOf(url), timeoutMs) };
|
|
124
|
+
} finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let parsed = null;
|
|
129
|
+
let text = '';
|
|
130
|
+
try {
|
|
131
|
+
text = await res.text();
|
|
132
|
+
parsed = text ? JSON.parse(text) : null;
|
|
133
|
+
} catch {
|
|
134
|
+
parsed = null;
|
|
135
|
+
}
|
|
136
|
+
return { ok: res.ok, status: res.status, body: parsed, text, res };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `{error, error_description}` rendered for a human, without losing either half. */
|
|
140
|
+
export function oauthErrorText(body, fallback) {
|
|
141
|
+
if (body && typeof body === 'object') {
|
|
142
|
+
const err = typeof body.error === 'string' ? body.error : null;
|
|
143
|
+
const desc = typeof body.error_description === 'string' ? body.error_description : null;
|
|
144
|
+
if (err && desc) return `${err}: ${desc}`;
|
|
145
|
+
if (err) return err;
|
|
146
|
+
if (desc) return desc;
|
|
147
|
+
}
|
|
148
|
+
return fallback;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Discovery
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fetch BOTH well-known documents. Either one missing is a hard failure —
|
|
157
|
+
* falling back to guessed paths (`/oauth/authorize`, `/oauth/token`) would turn
|
|
158
|
+
* "this host does not speak OAuth" into a confusing 404 three steps later.
|
|
159
|
+
*
|
|
160
|
+
* @param {{root: string, fetchImpl?: typeof fetch, timeoutMs?: number}} opts
|
|
161
|
+
* @returns {Promise<{ok: true, issuer: string|null, authorizationEndpoint: string,
|
|
162
|
+
* tokenEndpoint: string, registrationEndpoint: string, revocationEndpoint: string|null,
|
|
163
|
+
* resource: string, codeChallengeMethods: string[], grantTypes: string[], root: string}
|
|
164
|
+
* | {ok: false, code: string, message: string}>}
|
|
165
|
+
*/
|
|
166
|
+
export async function discover({ root, fetchImpl = fetch, timeoutMs = 15_000 }) {
|
|
167
|
+
const host = hostOf(root);
|
|
168
|
+
|
|
169
|
+
const meta = await requestJson(`${root}${AS_METADATA_PATH}`, { fetchImpl, timeoutMs });
|
|
170
|
+
if (meta.networkError) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
code: 'discovery_unreachable',
|
|
174
|
+
message: `anyslate: cannot reach ${host} for OAuth discovery — ${meta.detail}. Check --api-url and your network.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (!meta.ok || !meta.body || typeof meta.body !== 'object') {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
code: 'discovery_failed',
|
|
181
|
+
message:
|
|
182
|
+
`anyslate: OAuth discovery failed at ${host} — GET ${AS_METADATA_PATH} returned HTTP ${meta.status}. ` +
|
|
183
|
+
`That host does not advertise an OAuth authorization server. Check --api-url, or sign in with \`anyslate login --token <BEARER>\`.`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const authorizationEndpoint = str(meta.body.authorization_endpoint);
|
|
188
|
+
const tokenEndpoint = str(meta.body.token_endpoint);
|
|
189
|
+
const registrationEndpoint = str(meta.body.registration_endpoint);
|
|
190
|
+
const missing = [
|
|
191
|
+
!authorizationEndpoint && 'authorization_endpoint',
|
|
192
|
+
!tokenEndpoint && 'token_endpoint',
|
|
193
|
+
!registrationEndpoint && 'registration_endpoint',
|
|
194
|
+
].filter(Boolean);
|
|
195
|
+
if (missing.length) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
code: 'discovery_incomplete',
|
|
199
|
+
message: `anyslate: OAuth discovery at ${host} is missing ${missing.join(', ')}. The CLI will not guess endpoint paths.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const codeChallengeMethods = Array.isArray(meta.body.code_challenge_methods_supported)
|
|
204
|
+
? meta.body.code_challenge_methods_supported.map(String)
|
|
205
|
+
: [];
|
|
206
|
+
if (codeChallengeMethods.length && !codeChallengeMethods.includes('S256')) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
code: 'pkce_unsupported',
|
|
210
|
+
message: `anyslate: ${host} does not advertise PKCE S256 (got [${codeChallengeMethods.join(',')}]). The CLI will not fall back to a weaker method.`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const resourceDoc = await requestJson(`${root}${RESOURCE_METADATA_PATH}`, { fetchImpl, timeoutMs });
|
|
215
|
+
if (resourceDoc.networkError) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
code: 'discovery_unreachable',
|
|
219
|
+
message: `anyslate: cannot reach ${host} for OAuth discovery — ${resourceDoc.detail}. Check --api-url and your network.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const resource = str(resourceDoc.body?.resource);
|
|
223
|
+
if (!resourceDoc.ok || !resource) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
code: 'resource_missing',
|
|
227
|
+
message:
|
|
228
|
+
`anyslate: OAuth discovery failed at ${host} — GET ${RESOURCE_METADATA_PATH} returned HTTP ${resourceDoc.status} with no \`resource\`. ` +
|
|
229
|
+
`That value is required on /oauth/authorize and the CLI reads it from this document rather than assuming one.`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
root,
|
|
236
|
+
issuer: str(meta.body.issuer) || null,
|
|
237
|
+
authorizationEndpoint,
|
|
238
|
+
tokenEndpoint,
|
|
239
|
+
registrationEndpoint,
|
|
240
|
+
revocationEndpoint: str(meta.body.revocation_endpoint) || null,
|
|
241
|
+
resource,
|
|
242
|
+
codeChallengeMethods,
|
|
243
|
+
grantTypes: Array.isArray(meta.body.grant_types_supported) ? meta.body.grant_types_supported.map(String) : [],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function str(v) {
|
|
248
|
+
return typeof v === 'string' && v.trim() ? v.trim() : '';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Dynamic Client Registration
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* DCR is rate limited to 10/hour. The caller MUST cache the returned client_id
|
|
257
|
+
* per root and reuse it; this function does no caching of its own.
|
|
258
|
+
*
|
|
259
|
+
* @param {{registrationEndpoint: string, redirectUri?: string, clientName?: string,
|
|
260
|
+
* fetchImpl?: typeof fetch, timeoutMs?: number}} opts
|
|
261
|
+
* @returns {Promise<{ok: true, clientId: string, raw: unknown} | {ok: false, code: string, message: string}>}
|
|
262
|
+
*/
|
|
263
|
+
export async function registerClient({
|
|
264
|
+
registrationEndpoint,
|
|
265
|
+
redirectUri = REGISTERED_REDIRECT_URI,
|
|
266
|
+
clientName = CLIENT_NAME,
|
|
267
|
+
fetchImpl = fetch,
|
|
268
|
+
timeoutMs = 15_000,
|
|
269
|
+
}) {
|
|
270
|
+
const host = hostOf(registrationEndpoint);
|
|
271
|
+
const res = await requestJson(registrationEndpoint, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'content-type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
client_name: clientName,
|
|
276
|
+
redirect_uris: [redirectUri],
|
|
277
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
278
|
+
}),
|
|
279
|
+
fetchImpl,
|
|
280
|
+
timeoutMs,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (res.networkError) {
|
|
284
|
+
return { ok: false, code: 'unreachable', message: `anyslate: cannot reach ${host} to register the CLI — ${res.detail}.` };
|
|
285
|
+
}
|
|
286
|
+
if (res.status === 429) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
code: 'registration_rate_limited',
|
|
290
|
+
message:
|
|
291
|
+
`anyslate: ${host} rate limited client registration (10 per hour). ` +
|
|
292
|
+
`Wait an hour, or sign in with \`anyslate login --token <BEARER>\`.`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const clientId = str(res.body?.client_id);
|
|
296
|
+
if (!res.ok || !clientId) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
code: 'registration_failed',
|
|
300
|
+
message: `anyslate: client registration at ${host} failed (HTTP ${res.status}) — ${oauthErrorText(res.body, 'no client_id in the response')}.`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return { ok: true, clientId, raw: res.body };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Authorize
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {{authorizationEndpoint: string, clientId: string, redirectUri: string,
|
|
312
|
+
* codeChallenge: string, state: string, resource: string, scope?: string}} opts
|
|
313
|
+
* @returns {string}
|
|
314
|
+
*/
|
|
315
|
+
export function buildAuthorizeUrl({ authorizationEndpoint, clientId, redirectUri, codeChallenge, state, resource, scope }) {
|
|
316
|
+
const url = new URL(authorizationEndpoint);
|
|
317
|
+
url.searchParams.set('response_type', 'code');
|
|
318
|
+
url.searchParams.set('client_id', clientId);
|
|
319
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
320
|
+
url.searchParams.set('code_challenge', codeChallenge);
|
|
321
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
322
|
+
url.searchParams.set('state', state);
|
|
323
|
+
url.searchParams.set('resource', resource);
|
|
324
|
+
if (scope) url.searchParams.set('scope', scope);
|
|
325
|
+
return url.toString();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Loopback listener
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
function callbackPage({ title, body, tone }) {
|
|
333
|
+
const accent = tone === 'error' ? '#c0392b' : '#2f7d4f';
|
|
334
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
335
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
336
|
+
<title>${title} · AnySlate</title>
|
|
337
|
+
<style>
|
|
338
|
+
:root{color-scheme:light dark}
|
|
339
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|
340
|
+
font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
341
|
+
background:#f6f7f9;color:#1c1f23}
|
|
342
|
+
@media (prefers-color-scheme:dark){body{background:#101214;color:#e6e8ea}
|
|
343
|
+
.card{background:#191c1f!important;box-shadow:none!important;border:1px solid #2a2f34}}
|
|
344
|
+
.card{background:#fff;border-radius:14px;padding:40px 44px;max-width:26rem;text-align:center;
|
|
345
|
+
box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 28px rgba(0,0,0,.06)}
|
|
346
|
+
h1{margin:0 0 .5rem;font-size:1.25rem;color:${accent}}
|
|
347
|
+
p{margin:0;opacity:.8;font-size:.95rem}
|
|
348
|
+
</style></head><body><div class="card"><h1>${title}</h1><p>${body}</p></div></body></html>`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Bind an ephemeral loopback port and wait for exactly one authorization
|
|
353
|
+
* callback.
|
|
354
|
+
*
|
|
355
|
+
* @param {{state: string, timeoutMs?: number, path?: string, host?: string}} opts
|
|
356
|
+
* @returns {Promise<{port: number, redirectUri: string,
|
|
357
|
+
* waitForResult: () => Promise<{ok: true, code: string} | {ok: false, code: string, message: string}>,
|
|
358
|
+
* close: () => Promise<void>}>}
|
|
359
|
+
*/
|
|
360
|
+
export async function startCallbackServer({
|
|
361
|
+
state,
|
|
362
|
+
timeoutMs = DEFAULT_CALLBACK_TIMEOUT_S * 1000,
|
|
363
|
+
path = CALLBACK_PATH,
|
|
364
|
+
host = LOOPBACK_HOST,
|
|
365
|
+
}) {
|
|
366
|
+
/** @type {(r: object) => void} */
|
|
367
|
+
let settle = () => {};
|
|
368
|
+
let settled = false;
|
|
369
|
+
const result = new Promise((resolve) => {
|
|
370
|
+
settle = (r) => {
|
|
371
|
+
if (settled) return;
|
|
372
|
+
settled = true;
|
|
373
|
+
resolve(r);
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const server = createServer((req, res) => {
|
|
378
|
+
let url;
|
|
379
|
+
try {
|
|
380
|
+
url = new URL(req.url, `http://${host}`);
|
|
381
|
+
} catch {
|
|
382
|
+
res.writeHead(400).end();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (url.pathname === '/favicon.ico') {
|
|
387
|
+
res.writeHead(204).end();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (url.pathname !== path) {
|
|
391
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end('not found');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const send = (status, page) => {
|
|
396
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
397
|
+
res.end(page);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const err = url.searchParams.get('error');
|
|
401
|
+
if (err) {
|
|
402
|
+
const desc = url.searchParams.get('error_description') || '';
|
|
403
|
+
send(400, callbackPage({ title: 'Sign-in failed', body: 'You can close this tab and return to your terminal.', tone: 'error' }));
|
|
404
|
+
settle({
|
|
405
|
+
ok: false,
|
|
406
|
+
code: 'authorize_error',
|
|
407
|
+
message: `anyslate: the authorization server returned "${err}"${desc ? ` — ${desc}` : ''}.`,
|
|
408
|
+
});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// State is checked BEFORE the code is touched. A callback whose state does
|
|
413
|
+
// not match ours is either a stale tab from a previous attempt or a
|
|
414
|
+
// cross-site request; either way the `code` in it is not ours to redeem.
|
|
415
|
+
const got = url.searchParams.get('state') || '';
|
|
416
|
+
if (got !== state) {
|
|
417
|
+
send(400, callbackPage({ title: 'Sign-in failed', body: 'This sign-in link did not match the one this terminal started.', tone: 'error' }));
|
|
418
|
+
settle({
|
|
419
|
+
ok: false,
|
|
420
|
+
code: 'state_mismatch',
|
|
421
|
+
message: 'anyslate: the callback `state` did not match the one this login started with — discarding it. Run `anyslate login` again.',
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const code = url.searchParams.get('code') || '';
|
|
427
|
+
if (!code) {
|
|
428
|
+
send(400, callbackPage({ title: 'Sign-in failed', body: 'The callback carried no authorization code.', tone: 'error' }));
|
|
429
|
+
settle({ ok: false, code: 'no_code', message: 'anyslate: the callback carried no authorization code.' });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
send(200, callbackPage({ title: 'Signed in to AnySlate', body: 'You can close this tab and return to your terminal.', tone: 'ok' }));
|
|
434
|
+
settle({ ok: true, code });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await new Promise((resolve, reject) => {
|
|
438
|
+
server.once('error', reject);
|
|
439
|
+
server.listen(0, host, resolve);
|
|
440
|
+
});
|
|
441
|
+
const { port } = server.address();
|
|
442
|
+
|
|
443
|
+
const timer = setTimeout(() => {
|
|
444
|
+
settle({
|
|
445
|
+
ok: false,
|
|
446
|
+
code: 'timeout',
|
|
447
|
+
message: `anyslate: timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser callback. Re-run \`anyslate login\`, or use --timeout <seconds>.`,
|
|
448
|
+
});
|
|
449
|
+
}, timeoutMs);
|
|
450
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
451
|
+
|
|
452
|
+
const close = () =>
|
|
453
|
+
new Promise((resolve) => {
|
|
454
|
+
clearTimeout(timer);
|
|
455
|
+
server.close(() => resolve());
|
|
456
|
+
server.closeAllConnections?.();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
port,
|
|
461
|
+
redirectUri: `http://${host}:${port}${path}`,
|
|
462
|
+
waitForResult: () => result,
|
|
463
|
+
close,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// Token endpoint
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Turn a token response into the shape we persist. `expires_at` is absolute so
|
|
473
|
+
* a config file read an hour later is still interpretable.
|
|
474
|
+
*
|
|
475
|
+
* @param {unknown} body
|
|
476
|
+
* @param {number} [now]
|
|
477
|
+
*/
|
|
478
|
+
export function normalizeTokenResponse(body, now = Date.now()) {
|
|
479
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
480
|
+
const expiresIn = Number.isFinite(Number(b.expires_in)) ? Number(b.expires_in) : DEFAULT_EXPIRES_IN_S;
|
|
481
|
+
return {
|
|
482
|
+
access_token: str(b.access_token),
|
|
483
|
+
refresh_token: str(b.refresh_token) || null,
|
|
484
|
+
token_type: str(b.token_type) || 'Bearer',
|
|
485
|
+
scope: str(b.scope) || null,
|
|
486
|
+
expires_in: expiresIn,
|
|
487
|
+
expires_at: new Date(now + expiresIn * 1000).toISOString(),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function postToken({ tokenEndpoint, params, fetchImpl, timeoutMs, now }) {
|
|
492
|
+
const host = hostOf(tokenEndpoint);
|
|
493
|
+
const form = new URLSearchParams();
|
|
494
|
+
for (const [k, v] of Object.entries(params)) {
|
|
495
|
+
if (v != null && v !== '') form.set(k, String(v));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const res = await requestJson(tokenEndpoint, {
|
|
499
|
+
method: 'POST',
|
|
500
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
501
|
+
body: form.toString(),
|
|
502
|
+
fetchImpl,
|
|
503
|
+
timeoutMs,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
if (res.networkError) {
|
|
507
|
+
return { ok: false, code: 'unreachable', message: `anyslate: cannot reach ${host} to exchange tokens — ${res.detail}.` };
|
|
508
|
+
}
|
|
509
|
+
if (!res.ok) {
|
|
510
|
+
return {
|
|
511
|
+
ok: false,
|
|
512
|
+
code: str(res.body?.error) || 'token_request_failed',
|
|
513
|
+
status: res.status,
|
|
514
|
+
message: `anyslate: ${host} rejected the token request (HTTP ${res.status}) — ${oauthErrorText(res.body, res.text?.slice(0, 200) || 'no detail')}.`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const tokens = normalizeTokenResponse(res.body, now);
|
|
518
|
+
if (!tokens.access_token) {
|
|
519
|
+
return { ok: false, code: 'no_access_token', message: `anyslate: ${host} returned no access_token.` };
|
|
520
|
+
}
|
|
521
|
+
return { ok: true, tokens, raw: res.body };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* @param {{tokenEndpoint: string, code: string, redirectUri: string, codeVerifier: string,
|
|
526
|
+
* clientId: string, resource: string, fetchImpl?: typeof fetch, timeoutMs?: number, now?: number}} opts
|
|
527
|
+
*/
|
|
528
|
+
export function exchangeCode({ tokenEndpoint, code, redirectUri, codeVerifier, clientId, resource, fetchImpl = fetch, timeoutMs = 15_000, now }) {
|
|
529
|
+
return postToken({
|
|
530
|
+
tokenEndpoint,
|
|
531
|
+
params: {
|
|
532
|
+
grant_type: 'authorization_code',
|
|
533
|
+
code,
|
|
534
|
+
redirect_uri: redirectUri,
|
|
535
|
+
code_verifier: codeVerifier,
|
|
536
|
+
client_id: clientId,
|
|
537
|
+
resource,
|
|
538
|
+
},
|
|
539
|
+
fetchImpl,
|
|
540
|
+
timeoutMs,
|
|
541
|
+
now,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Refresh rotation IS enabled server-side (oauth_refresh_tokens.replaced_by_id),
|
|
547
|
+
* so the caller MUST persist `tokens.refresh_token` immediately — the old one is
|
|
548
|
+
* dead the moment this resolves.
|
|
549
|
+
*
|
|
550
|
+
* @param {{tokenEndpoint: string, refreshToken: string, clientId: string, resource: string,
|
|
551
|
+
* fetchImpl?: typeof fetch, timeoutMs?: number, now?: number}} opts
|
|
552
|
+
*/
|
|
553
|
+
export function refreshAccessToken({ tokenEndpoint, refreshToken, clientId, resource, fetchImpl = fetch, timeoutMs = 15_000, now }) {
|
|
554
|
+
return postToken({
|
|
555
|
+
tokenEndpoint,
|
|
556
|
+
params: {
|
|
557
|
+
grant_type: 'refresh_token',
|
|
558
|
+
refresh_token: refreshToken,
|
|
559
|
+
client_id: clientId,
|
|
560
|
+
resource,
|
|
561
|
+
},
|
|
562
|
+
fetchImpl,
|
|
563
|
+
timeoutMs,
|
|
564
|
+
now,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Best effort — a revocation that fails must never stop `logout` from deleting
|
|
570
|
+
* the local credential, or the user is stuck with a file they cannot clear.
|
|
571
|
+
*
|
|
572
|
+
* @param {{revocationEndpoint: string, token: string, clientId?: string,
|
|
573
|
+
* tokenTypeHint?: string, fetchImpl?: typeof fetch, timeoutMs?: number}} opts
|
|
574
|
+
* @returns {Promise<{ok: boolean, status?: number, detail?: string}>}
|
|
575
|
+
*/
|
|
576
|
+
export async function revokeToken({ revocationEndpoint, token, clientId, tokenTypeHint, fetchImpl = fetch, timeoutMs = 8_000 }) {
|
|
577
|
+
const form = new URLSearchParams();
|
|
578
|
+
form.set('token', token);
|
|
579
|
+
if (clientId) form.set('client_id', clientId);
|
|
580
|
+
if (tokenTypeHint) form.set('token_type_hint', tokenTypeHint);
|
|
581
|
+
|
|
582
|
+
const res = await requestJson(revocationEndpoint, {
|
|
583
|
+
method: 'POST',
|
|
584
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
585
|
+
body: form.toString(),
|
|
586
|
+
fetchImpl,
|
|
587
|
+
timeoutMs,
|
|
588
|
+
});
|
|
589
|
+
if (res.networkError) return { ok: false, detail: res.detail };
|
|
590
|
+
return { ok: !!res.ok, status: res.status, detail: res.ok ? undefined : oauthErrorText(res.body, `HTTP ${res.status}`) };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** The conventional path when discovery does not advertise `revocation_endpoint`. */
|
|
594
|
+
export function revocationEndpointFor(discovery, root) {
|
|
595
|
+
return discovery?.revocationEndpoint || `${String(root).replace(/\/+$/, '')}/oauth/revoke`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// Browser
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* @param {string} url
|
|
604
|
+
* @param {{platform?: string, spawnImpl?: typeof spawn}} [deps]
|
|
605
|
+
* @returns {{ok: boolean, command?: string, error?: string}}
|
|
606
|
+
*/
|
|
607
|
+
export function openBrowser(url, deps = {}) {
|
|
608
|
+
const platform = deps.platform ?? process.platform;
|
|
609
|
+
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
610
|
+
|
|
611
|
+
let cmd;
|
|
612
|
+
let args;
|
|
613
|
+
if (platform === 'darwin') {
|
|
614
|
+
cmd = 'open';
|
|
615
|
+
args = [url];
|
|
616
|
+
} else if (platform === 'win32') {
|
|
617
|
+
// The empty "" is the window title `start` otherwise steals from the URL.
|
|
618
|
+
cmd = 'cmd';
|
|
619
|
+
args = ['/c', 'start', '', url];
|
|
620
|
+
} else {
|
|
621
|
+
cmd = 'xdg-open';
|
|
622
|
+
args = [url];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
const child = spawnImpl(cmd, args, { stdio: 'ignore', detached: true });
|
|
627
|
+
child?.unref?.();
|
|
628
|
+
child?.on?.('error', () => {});
|
|
629
|
+
return { ok: true, command: cmd };
|
|
630
|
+
} catch (e) {
|
|
631
|
+
return { ok: false, command: cmd, error: String(e?.message ?? e) };
|
|
632
|
+
}
|
|
633
|
+
}
|