metergraph-cli 0.1.0 → 0.2.0-preview.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 +488 -22
- package/assets/skill/SKILL.md +17 -1
- package/assets/skill/manifest.json +3 -3
- package/package.json +2 -2
- package/src/args.js +323 -17
- package/src/auth-binding.js +202 -0
- package/src/auth-browser.js +74 -0
- package/src/auth-callback.js +177 -0
- package/src/auth-login.js +410 -0
- package/src/auth-oauth.js +462 -0
- package/src/auth-session.js +174 -0
- package/src/auth-store.js +397 -0
- package/src/cli.js +78 -2
- package/src/constants.js +99 -4
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +10 -0
- package/src/http.js +1 -1
- package/src/output.js +454 -2
- package/src/read-contract.js +601 -0
- package/src/read-output.js +216 -0
- package/src/read.js +325 -0
- package/src/setup-deployment.js +178 -0
- package/src/setup-env-acl.js +99 -0
- package/src/setup-env-git.js +103 -0
- package/src/setup-env-parse.js +169 -0
- package/src/setup-env.js +685 -0
- package/src/setup-state.js +141 -0
- package/src/setup.js +317 -0
- package/src/skill-bundle.js +1 -1
- package/src/trace-contract.js +129 -0
- package/src/trace-open.js +36 -0
- package/src/transport.js +171 -0
- package/src/verify-output.js +36 -0
- package/src/verify.js +120 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { AGENT_CONTRACT_VERSION, AUTH_PATHS, METADATA_SCOPE } from "./constants.js";
|
|
4
|
+
import { parseJsonObject } from "./http.js";
|
|
5
|
+
import { failureOf, send } from "./transport.js";
|
|
6
|
+
|
|
7
|
+
// OAuth client for the existing Metergraph authorization server. Everything
|
|
8
|
+
// here talks to one validated origin through fixed paths. Results are
|
|
9
|
+
// { ok: true, ... } or { ok: false, outcome, reason } with fixed tokens only:
|
|
10
|
+
// no response body, header, challenge or server text is ever passed on.
|
|
11
|
+
|
|
12
|
+
const METADATA_BYTES = 32 * 1024;
|
|
13
|
+
const TOKEN_BYTES = 32 * 1024;
|
|
14
|
+
const WORKSPACE_BYTES = 64 * 1024;
|
|
15
|
+
// Capability documents can list many tools, so they get a larger, still
|
|
16
|
+
// explicit, bound.
|
|
17
|
+
const CAPABILITIES_BYTES = 256 * 1024;
|
|
18
|
+
|
|
19
|
+
const CLIENT_NAME = "Metergraph CLI";
|
|
20
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
const CLIENT_ID = /^[\x21-\x7e]{1,256}$/;
|
|
22
|
+
const ACCESS_TOKEN = /^[\x21-\x7e]{16,8192}$/;
|
|
23
|
+
const REFRESH_TOKEN = /^[\x21-\x7e]{16,4096}$/;
|
|
24
|
+
const MAX_EXPIRES_IN = 366 * 24 * 60 * 60;
|
|
25
|
+
|
|
26
|
+
// The access token claim that names the workspace the grant is for. The
|
|
27
|
+
// claim is a sanity check only; the CLI does not verify token signatures.
|
|
28
|
+
// The server-authoritative check is GET /v1/agent/workspace.
|
|
29
|
+
export const TENANT_CLAIM = "tenant_id";
|
|
30
|
+
|
|
31
|
+
export function endpointsFor(origin) {
|
|
32
|
+
return {
|
|
33
|
+
origin,
|
|
34
|
+
resource: `${origin}${AUTH_PATHS.resource}`,
|
|
35
|
+
issuer: `${origin}${AUTH_PATHS.issuer}`,
|
|
36
|
+
authorization: `${origin}${AUTH_PATHS.authorization}`,
|
|
37
|
+
token: `${origin}${AUTH_PATHS.token}`,
|
|
38
|
+
registration: `${origin}${AUTH_PATHS.registration}`,
|
|
39
|
+
revocation: `${origin}${AUTH_PATHS.revocation}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stop = (outcome, reason) => ({ ok: false, outcome, reason });
|
|
44
|
+
|
|
45
|
+
// Fetches the protected resource metadata (RFC 9728, path-suffixed form, then
|
|
46
|
+
// the bare form) and the authorization server metadata (RFC 8414,
|
|
47
|
+
// path-inserted form, then the bare form). Every endpoint must equal the
|
|
48
|
+
// fixed path on this origin. A server that does not advertise the Metadata
|
|
49
|
+
// scope, S256 or public clients is unsupported; there is no fallback to
|
|
50
|
+
// another scope.
|
|
51
|
+
export async function discover(origin, signal) {
|
|
52
|
+
const expected = endpointsFor(origin);
|
|
53
|
+
|
|
54
|
+
const prm = await fetchMetadata(origin, [
|
|
55
|
+
`/.well-known/oauth-protected-resource${AUTH_PATHS.resource}`,
|
|
56
|
+
"/.well-known/oauth-protected-resource",
|
|
57
|
+
], signal);
|
|
58
|
+
if (!prm.ok) return prm;
|
|
59
|
+
const resource = prm.body;
|
|
60
|
+
if (resource.resource !== expected.resource) return stop("unsupported", "resource_mismatch");
|
|
61
|
+
if (
|
|
62
|
+
!Array.isArray(resource.authorization_servers) ||
|
|
63
|
+
resource.authorization_servers.length !== 1 ||
|
|
64
|
+
resource.authorization_servers[0] !== expected.issuer
|
|
65
|
+
) {
|
|
66
|
+
return stop("unsupported", "issuer_mismatch");
|
|
67
|
+
}
|
|
68
|
+
if (resource.scopes_supported !== undefined && !includes(resource.scopes_supported, METADATA_SCOPE)) {
|
|
69
|
+
return stop("unsupported", "metadata_scope_unsupported");
|
|
70
|
+
}
|
|
71
|
+
if (resource.bearer_methods_supported !== undefined && !includes(resource.bearer_methods_supported, "header")) {
|
|
72
|
+
return stop("unsupported", "oauth_metadata_invalid");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const asm = await fetchMetadata(origin, [
|
|
76
|
+
`/.well-known/oauth-authorization-server${AUTH_PATHS.issuer}`,
|
|
77
|
+
"/.well-known/oauth-authorization-server",
|
|
78
|
+
], signal);
|
|
79
|
+
if (!asm.ok) return asm;
|
|
80
|
+
const server = asm.body;
|
|
81
|
+
if (server.issuer !== expected.issuer) return stop("unsupported", "issuer_mismatch");
|
|
82
|
+
for (const [field, key] of [
|
|
83
|
+
["authorization_endpoint", "authorization"],
|
|
84
|
+
["token_endpoint", "token"],
|
|
85
|
+
["registration_endpoint", "registration"],
|
|
86
|
+
]) {
|
|
87
|
+
if (server[field] !== expected[key]) return stop("unsupported", "endpoint_not_allowed");
|
|
88
|
+
}
|
|
89
|
+
if (server.revocation_endpoint === undefined) return stop("unsupported", "revocation_unsupported");
|
|
90
|
+
if (server.revocation_endpoint !== expected.revocation) return stop("unsupported", "endpoint_not_allowed");
|
|
91
|
+
if (!includes(server.scopes_supported, METADATA_SCOPE)) {
|
|
92
|
+
return stop("unsupported", "metadata_scope_unsupported");
|
|
93
|
+
}
|
|
94
|
+
if (!includes(server.code_challenge_methods_supported, "S256")) return stop("unsupported", "pkce_unsupported");
|
|
95
|
+
if (!includes(server.token_endpoint_auth_methods_supported, "none")) {
|
|
96
|
+
return stop("unsupported", "public_client_unsupported");
|
|
97
|
+
}
|
|
98
|
+
if (!includes(server.response_types_supported, "code")) return stop("unsupported", "oauth_metadata_invalid");
|
|
99
|
+
if (
|
|
100
|
+
server.grant_types_supported !== undefined &&
|
|
101
|
+
!(includes(server.grant_types_supported, "authorization_code") &&
|
|
102
|
+
includes(server.grant_types_supported, "refresh_token"))
|
|
103
|
+
) {
|
|
104
|
+
return stop("unsupported", "oauth_metadata_invalid");
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
server.revocation_endpoint_auth_methods_supported !== undefined &&
|
|
108
|
+
!includes(server.revocation_endpoint_auth_methods_supported, "none")
|
|
109
|
+
) {
|
|
110
|
+
return stop("unsupported", "revocation_unsupported");
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
endpoints: expected,
|
|
115
|
+
requireIss: server.authorization_response_iss_parameter_supported === true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchMetadata(origin, paths, signal) {
|
|
120
|
+
for (const path of paths) {
|
|
121
|
+
const response = await send(origin, path, { signal, maxBytes: METADATA_BYTES });
|
|
122
|
+
const failure = failureOf(response);
|
|
123
|
+
if (failure !== null) return { ok: false, ...failure };
|
|
124
|
+
if (response.status === 404) continue;
|
|
125
|
+
if (response.status !== 200) return stop("unsupported", "unexpected_status");
|
|
126
|
+
const body = parseJsonObject(response);
|
|
127
|
+
if (body === null) return stop("unsupported", "invalid_response");
|
|
128
|
+
return { ok: true, body };
|
|
129
|
+
}
|
|
130
|
+
// A server without OAuth metadata predates CLI sign in.
|
|
131
|
+
return stop("unsupported", "oauth_metadata_missing");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function includes(list, value) {
|
|
135
|
+
return Array.isArray(list) && list.includes(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Dynamic client registration for a public client bound to one exact
|
|
139
|
+
// loopback redirect. A response that carries a client secret, a different
|
|
140
|
+
// redirect or another authentication method is refused.
|
|
141
|
+
export async function register(endpoints, redirectUri, signal) {
|
|
142
|
+
const response = await send(endpoints.origin, AUTH_PATHS.registration, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
signal,
|
|
145
|
+
maxBytes: METADATA_BYTES,
|
|
146
|
+
json: {
|
|
147
|
+
client_name: CLIENT_NAME,
|
|
148
|
+
redirect_uris: [redirectUri],
|
|
149
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
150
|
+
response_types: ["code"],
|
|
151
|
+
token_endpoint_auth_method: "none",
|
|
152
|
+
scope: METADATA_SCOPE,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const failure = failureOf(response);
|
|
156
|
+
if (failure !== null) return { ok: false, ...failure };
|
|
157
|
+
if (response.status === 400) return stop("unsupported", "registration_rejected");
|
|
158
|
+
if (response.status !== 201 && response.status !== 200) return stop("unsupported", "unexpected_status");
|
|
159
|
+
const body = parseJsonObject(response);
|
|
160
|
+
if (body === null || typeof body.client_id !== "string" || !CLIENT_ID.test(body.client_id)) {
|
|
161
|
+
return stop("unsupported", "registration_invalid");
|
|
162
|
+
}
|
|
163
|
+
if (body.client_secret !== undefined && body.client_secret !== null) {
|
|
164
|
+
return stop("unsupported", "registration_invalid");
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
!Array.isArray(body.redirect_uris) ||
|
|
168
|
+
body.redirect_uris.length !== 1 ||
|
|
169
|
+
body.redirect_uris[0] !== redirectUri
|
|
170
|
+
) {
|
|
171
|
+
return stop("unsupported", "registration_invalid");
|
|
172
|
+
}
|
|
173
|
+
if (body.token_endpoint_auth_method !== undefined && body.token_endpoint_auth_method !== "none") {
|
|
174
|
+
return stop("unsupported", "registration_invalid");
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
body.grant_types !== undefined &&
|
|
178
|
+
!(includes(body.grant_types, "authorization_code") && includes(body.grant_types, "refresh_token"))
|
|
179
|
+
) {
|
|
180
|
+
return stop("unsupported", "registration_invalid");
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, clientId: body.client_id };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function base64url(buffer) {
|
|
186
|
+
return buffer.toString("base64url");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 32 random bytes each: 256 bits for state, a 43 character verifier.
|
|
190
|
+
export function newPkce() {
|
|
191
|
+
const verifier = base64url(randomBytes(32));
|
|
192
|
+
return {
|
|
193
|
+
state: base64url(randomBytes(32)),
|
|
194
|
+
verifier,
|
|
195
|
+
challenge: base64url(createHash("sha256").update(verifier).digest()),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The authorization URL the browser opens. With signup, the browser starts
|
|
200
|
+
// at the service's own signup page, which returns to the same authorization
|
|
201
|
+
// request on this origin after the account exists.
|
|
202
|
+
export function authorizationUrl(endpoints, { clientId, redirectUri, state, challenge, signup }) {
|
|
203
|
+
const url = new URL(endpoints.authorization);
|
|
204
|
+
url.search = new URLSearchParams({
|
|
205
|
+
response_type: "code",
|
|
206
|
+
client_id: clientId,
|
|
207
|
+
redirect_uri: redirectUri,
|
|
208
|
+
scope: METADATA_SCOPE,
|
|
209
|
+
state,
|
|
210
|
+
code_challenge: challenge,
|
|
211
|
+
code_challenge_method: "S256",
|
|
212
|
+
resource: endpoints.resource,
|
|
213
|
+
}).toString();
|
|
214
|
+
if (!signup) return url.href;
|
|
215
|
+
const entry = new URL(`${endpoints.origin}${AUTH_PATHS.signup}`);
|
|
216
|
+
entry.search = new URLSearchParams({ return_to: `${url.pathname}${url.search}` }).toString();
|
|
217
|
+
return entry.href;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Exchanges an authorization code. Codes are single use, so there is never
|
|
221
|
+
// a retry.
|
|
222
|
+
export async function exchangeCode(endpoints, { clientId, redirectUri, code, verifier }, signal) {
|
|
223
|
+
const response = await send(endpoints.origin, AUTH_PATHS.token, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
signal,
|
|
226
|
+
maxBytes: TOKEN_BYTES,
|
|
227
|
+
form: {
|
|
228
|
+
grant_type: "authorization_code",
|
|
229
|
+
code,
|
|
230
|
+
redirect_uri: redirectUri,
|
|
231
|
+
client_id: clientId,
|
|
232
|
+
code_verifier: verifier,
|
|
233
|
+
resource: endpoints.resource,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
const failure = failureOf(response);
|
|
237
|
+
if (failure !== null) return { ok: false, ...failure };
|
|
238
|
+
if (response.status === 400 || response.status === 401) return stop("authorization_failed", "code_rejected");
|
|
239
|
+
if (response.status !== 200) return stop("unsupported", "unexpected_status");
|
|
240
|
+
return validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Refreshes a grant once. The result says whether the refresh token may have
|
|
244
|
+
// been consumed without the CLI learning its replacement (ambiguous), so the
|
|
245
|
+
// caller never retries with it.
|
|
246
|
+
export async function refreshGrant(endpoints, { clientId, refreshToken }, signal) {
|
|
247
|
+
const response = await send(endpoints.origin, AUTH_PATHS.token, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
signal,
|
|
250
|
+
maxBytes: TOKEN_BYTES,
|
|
251
|
+
form: {
|
|
252
|
+
grant_type: "refresh_token",
|
|
253
|
+
refresh_token: refreshToken,
|
|
254
|
+
client_id: clientId,
|
|
255
|
+
resource: endpoints.resource,
|
|
256
|
+
scope: METADATA_SCOPE,
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
if (response.kind === "error" && !response.sent) {
|
|
260
|
+
return { ok: false, outcome: "connection_failed", reason: response.reason, ambiguous: false };
|
|
261
|
+
}
|
|
262
|
+
if (response.kind === "response" && (response.status === 400 || response.status === 401)) {
|
|
263
|
+
const body = parseJsonObject(response);
|
|
264
|
+
if (body !== null && body.error === "invalid_grant") {
|
|
265
|
+
return { ok: false, outcome: "login_required", reason: "grant_rejected", ambiguous: false };
|
|
266
|
+
}
|
|
267
|
+
if (body !== null && (body.error === "invalid_client" || body.error === "unauthorized_client")) {
|
|
268
|
+
return { ok: false, outcome: "login_required", reason: "grant_rejected", ambiguous: false };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (response.kind !== "response" || response.status !== 200 || response.tooLarge) {
|
|
272
|
+
return { ok: false, outcome: "login_required", reason: "refresh_interrupted", ambiguous: true };
|
|
273
|
+
}
|
|
274
|
+
const result = validateGrant(parseJsonObject(response), endpoints, clientId, { requireRefresh: false });
|
|
275
|
+
// A 200 that cannot be used may still have rotated the refresh token.
|
|
276
|
+
return result.ok ? result : { ...result, ambiguous: true };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Accepts only a Bearer grant for exactly the Metadata scope with bounded,
|
|
280
|
+
// non-empty tokens and an expiry. The access token claims must name this
|
|
281
|
+
// issuer, resource and client and one workspace. These are sanity checks on
|
|
282
|
+
// what the server said, not a signature check.
|
|
283
|
+
export function validateGrant(body, endpoints, clientId, { requireRefresh }) {
|
|
284
|
+
if (body === null) return stop("verification_failed", "token_response_invalid");
|
|
285
|
+
if (typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer") {
|
|
286
|
+
return stop("verification_failed", "token_type_invalid");
|
|
287
|
+
}
|
|
288
|
+
if (typeof body.access_token !== "string" || !ACCESS_TOKEN.test(body.access_token)) {
|
|
289
|
+
return stop("verification_failed", "token_response_invalid");
|
|
290
|
+
}
|
|
291
|
+
// A refresh may keep the current refresh token by omitting it (RFC 6749
|
|
292
|
+
// section 6). A code exchange must return one.
|
|
293
|
+
const refreshMissing = body.refresh_token === undefined;
|
|
294
|
+
if (refreshMissing) {
|
|
295
|
+
if (requireRefresh) return stop("verification_failed", "token_response_invalid");
|
|
296
|
+
} else if (typeof body.refresh_token !== "string" || !REFRESH_TOKEN.test(body.refresh_token)) {
|
|
297
|
+
return stop("verification_failed", "token_response_invalid");
|
|
298
|
+
}
|
|
299
|
+
if (!Number.isInteger(body.expires_in) || body.expires_in < 1 || body.expires_in > MAX_EXPIRES_IN) {
|
|
300
|
+
return stop("verification_failed", "token_response_invalid");
|
|
301
|
+
}
|
|
302
|
+
if (typeof body.scope !== "string" || body.scope !== METADATA_SCOPE) {
|
|
303
|
+
return stop("verification_failed", "scope_mismatch");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const claims = decodeClaims(body.access_token);
|
|
307
|
+
if (claims === null) return stop("verification_failed", "token_claims_invalid");
|
|
308
|
+
if (claims.iss !== endpoints.issuer) return stop("verification_failed", "issuer_mismatch");
|
|
309
|
+
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
310
|
+
if (!audience.includes(endpoints.resource)) return stop("verification_failed", "resource_mismatch");
|
|
311
|
+
if (claims.client_id !== clientId) return stop("verification_failed", "client_mismatch");
|
|
312
|
+
if (claims.scope !== undefined && claims.scope !== METADATA_SCOPE) {
|
|
313
|
+
return stop("verification_failed", "scope_mismatch");
|
|
314
|
+
}
|
|
315
|
+
const tenant = normalizeUuid(claims[TENANT_CLAIM]);
|
|
316
|
+
if (tenant === null) return stop("verification_failed", "token_claims_invalid");
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
if (!Number.isFinite(claims.exp) || claims.exp * 1000 <= now) {
|
|
319
|
+
return stop("verification_failed", "token_claims_invalid");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
grant: {
|
|
325
|
+
accessToken: body.access_token,
|
|
326
|
+
refreshToken: refreshMissing ? null : body.refresh_token,
|
|
327
|
+
expiresAt: Math.min(now + body.expires_in * 1000, claims.exp * 1000),
|
|
328
|
+
tenant,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function decodeClaims(token) {
|
|
334
|
+
const parts = token.split(".");
|
|
335
|
+
if (parts.length !== 3 || !/^[A-Za-z0-9_-]+$/.test(parts[1])) return null;
|
|
336
|
+
try {
|
|
337
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(parts[1], "base64url"));
|
|
338
|
+
const value = JSON.parse(text);
|
|
339
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
340
|
+
return value;
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Asks the service, with the access token, which workspace and profile the
|
|
347
|
+
// grant is for and what it may do. The answers are server-authoritative:
|
|
348
|
+
// GET /v1/agent/workspace: workspace.id, provenance.workspace_id and the
|
|
349
|
+
// expected workspace must agree, provenance.deployment_profile must be
|
|
350
|
+
// the profile seen before sign in, access.scopes must be exactly the
|
|
351
|
+
// Metadata scope and content.included must be false. Capture being
|
|
352
|
+
// enabled for the workspace (content.captured) is not a reason to refuse.
|
|
353
|
+
// GET /v1/agent/capabilities: provenance.workspace_id and both
|
|
354
|
+
// provenance.deployment_profile and deployment_profile must match, and
|
|
355
|
+
// every capability that reads content or replays, or calls a configured
|
|
356
|
+
// provider, must be unavailable to this grant.
|
|
357
|
+
// Nothing else is read: no telemetry, content or provider requests.
|
|
358
|
+
// Returns { ok: true, workspaceId, documents } or { ok: false, outcome, reason, denied }
|
|
359
|
+
// where denied means the service refused the token (401 or 403).
|
|
360
|
+
export async function verifyContext(origin, accessToken, { profile, workspaceId }, signal) {
|
|
361
|
+
const workspace = await bearerJson(origin, AUTH_PATHS.workspace, accessToken, WORKSPACE_BYTES, signal, "workspace");
|
|
362
|
+
if (!workspace.ok) return workspace;
|
|
363
|
+
const checked = checkWorkspace(workspace.body, { profile, workspaceId });
|
|
364
|
+
if (!checked.ok) return checked;
|
|
365
|
+
|
|
366
|
+
const capabilities = await bearerJson(
|
|
367
|
+
origin,
|
|
368
|
+
AUTH_PATHS.capabilities,
|
|
369
|
+
accessToken,
|
|
370
|
+
CAPABILITIES_BYTES,
|
|
371
|
+
signal,
|
|
372
|
+
"capabilities",
|
|
373
|
+
);
|
|
374
|
+
if (!capabilities.ok) return capabilities;
|
|
375
|
+
const allowed = checkCapabilities(capabilities.body, { profile, workspaceId });
|
|
376
|
+
if (!allowed.ok) return allowed;
|
|
377
|
+
// The verified documents stay in memory for read commands, which print
|
|
378
|
+
// only fields they validate themselves.
|
|
379
|
+
return { ok: true, workspaceId, documents: { workspace: workspace.body, capabilities: capabilities.body } };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Both documents carry the service's contract version string in
|
|
383
|
+
// schema_version. Any other value, including a number, is a contract this
|
|
384
|
+
// CLI does not know.
|
|
385
|
+
export function checkWorkspace(body, { profile, workspaceId }) {
|
|
386
|
+
const id = normalizeUuid(body.workspace?.id);
|
|
387
|
+
if (body.schema_version !== AGENT_CONTRACT_VERSION || id === null || !isObject(body.provenance)) {
|
|
388
|
+
return stop("verification_failed", "workspace_response_invalid");
|
|
389
|
+
}
|
|
390
|
+
if (normalizeUuid(body.provenance.workspace_id) !== id || id !== workspaceId) {
|
|
391
|
+
return stop("verification_failed", "workspace_context_mismatch");
|
|
392
|
+
}
|
|
393
|
+
if (body.provenance.deployment_profile !== profile) return stop("verification_failed", "profile_mismatch");
|
|
394
|
+
const scopes = body.access?.scopes;
|
|
395
|
+
if (!Array.isArray(scopes) || scopes.length !== 1 || scopes[0] !== METADATA_SCOPE) {
|
|
396
|
+
return stop("verification_failed", "scope_mismatch");
|
|
397
|
+
}
|
|
398
|
+
if (!isObject(body.content) || body.content.included !== false) {
|
|
399
|
+
return stop("verification_failed", "content_access_granted");
|
|
400
|
+
}
|
|
401
|
+
return { ok: true };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function checkCapabilities(body, { profile, workspaceId }) {
|
|
405
|
+
if (body.schema_version !== AGENT_CONTRACT_VERSION || !isObject(body.provenance) || !isObject(body.agent)) {
|
|
406
|
+
return stop("verification_failed", "capabilities_response_invalid");
|
|
407
|
+
}
|
|
408
|
+
if (normalizeUuid(body.provenance.workspace_id) !== workspaceId) {
|
|
409
|
+
return stop("verification_failed", "workspace_context_mismatch");
|
|
410
|
+
}
|
|
411
|
+
if (body.provenance.deployment_profile !== profile || body.deployment_profile !== profile) {
|
|
412
|
+
return stop("verification_failed", "profile_mismatch");
|
|
413
|
+
}
|
|
414
|
+
for (const entry of Object.values(body.agent)) {
|
|
415
|
+
if (!isObject(entry)) return stop("verification_failed", "capabilities_response_invalid");
|
|
416
|
+
const sensitive =
|
|
417
|
+
entry.content !== false ||
|
|
418
|
+
entry.privacy_class !== "metadata" ||
|
|
419
|
+
entry.external_calls !== false;
|
|
420
|
+
// An entry that is not plainly metadata-only must be unavailable.
|
|
421
|
+
if (sensitive && entry.available !== false) return stop("verification_failed", "content_access_granted");
|
|
422
|
+
}
|
|
423
|
+
if (isObject(body.bounds) && body.bounds.content_included_by_default !== false) {
|
|
424
|
+
return stop("verification_failed", "content_access_granted");
|
|
425
|
+
}
|
|
426
|
+
return { ok: true };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function isObject(value) {
|
|
430
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Lower case form of a UUID, or null.
|
|
434
|
+
export function normalizeUuid(value) {
|
|
435
|
+
return typeof value === "string" && UUID.test(value) ? value.toLowerCase() : null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function bearerJson(origin, path, accessToken, maxBytes, signal, document) {
|
|
439
|
+
const response = await send(origin, path, { signal, maxBytes, bearer: accessToken });
|
|
440
|
+
const failure = failureOf(response);
|
|
441
|
+
if (failure !== null) return { ok: false, ...failure };
|
|
442
|
+
if (response.status === 401 || response.status === 403) {
|
|
443
|
+
return { ok: false, outcome: "verification_failed", reason: "access_rejected", denied: true };
|
|
444
|
+
}
|
|
445
|
+
if (response.status !== 200) return stop("unsupported", "unexpected_status");
|
|
446
|
+
const body = parseJsonObject(response);
|
|
447
|
+
if (body === null) return stop("verification_failed", `${document}_response_invalid`);
|
|
448
|
+
return { ok: true, body };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// RFC 7009 revocation of a refresh token as the public client that holds it.
|
|
452
|
+
// Returns "accepted" for a 200, which means the request was accepted, and
|
|
453
|
+
// "unconfirmed" for anything else.
|
|
454
|
+
export async function revoke(endpoints, { clientId, refreshToken }, signal) {
|
|
455
|
+
const response = await send(endpoints.origin, AUTH_PATHS.revocation, {
|
|
456
|
+
method: "POST",
|
|
457
|
+
signal,
|
|
458
|
+
maxBytes: TOKEN_BYTES,
|
|
459
|
+
form: { token: refreshToken, token_type_hint: "refresh_token", client_id: clientId },
|
|
460
|
+
});
|
|
461
|
+
return response.kind === "response" && response.status === 200 ? "accepted" : "unconfirmed";
|
|
462
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { endpointsFor, refreshGrant, verifyContext } from "./auth-oauth.js";
|
|
2
|
+
import { readBinding, resolveProject } from "./auth-binding.js";
|
|
3
|
+
import { Stop, openStore, readCredential, resolveConfigDir, withSlotLock, writeCredential } from "./auth-store.js";
|
|
4
|
+
import { AUTH_HTTP_TIMEOUT_MS, METADATA_SCOPE } from "./constants.js";
|
|
5
|
+
import { deadline } from "./transport.js";
|
|
6
|
+
|
|
7
|
+
// The verified session that later read commands build on. It never opens a
|
|
8
|
+
// browser and never writes the project binding. It returns an access token
|
|
9
|
+
// only to the calling code, never for output.
|
|
10
|
+
|
|
11
|
+
// Access tokens closer than this to expiry are refreshed first.
|
|
12
|
+
const REFRESH_MARGIN_MS = 60 * 1000;
|
|
13
|
+
// Long enough for another process to finish one refresh request.
|
|
14
|
+
export const LOCK_WAIT_MS = AUTH_HTTP_TIMEOUT_MS + 5000;
|
|
15
|
+
|
|
16
|
+
// Resolves with
|
|
17
|
+
// { ok: true, session: { origin, workspaceId, profile, scopes, accessToken }, documents, knownCredentials }
|
|
18
|
+
// { ok: false, outcome, reason }
|
|
19
|
+
// documents holds the workspace and capabilities documents the service
|
|
20
|
+
// returned during verification, already checked by verifyContext.
|
|
21
|
+
// for the project's binding. Throws nothing for expected failures.
|
|
22
|
+
export async function verifiedSession({ project = null, configDir = null, cancel = null } = {}) {
|
|
23
|
+
try {
|
|
24
|
+
const root = resolveProject(project);
|
|
25
|
+
const store = openStore(resolveConfigDir(configDir), { create: false });
|
|
26
|
+
const found = readBinding(root);
|
|
27
|
+
if (found === null) return stop("login_required", "not_signed_in");
|
|
28
|
+
return await sessionFor(found.binding, store, cancel);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof Stop) return stop(error.outcome, error.reason);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The same, for a binding and store already read by the caller. store may be
|
|
36
|
+
// null when the private store does not exist.
|
|
37
|
+
export async function sessionFor(binding, store, cancel) {
|
|
38
|
+
if (store === null) return stop("login_required", "credential_missing");
|
|
39
|
+
const slot = binding.credential_slot;
|
|
40
|
+
let record = readCredential(store, slot);
|
|
41
|
+
if (record === null) return stop("login_required", "credential_missing");
|
|
42
|
+
const context = checkIdentity(record, binding);
|
|
43
|
+
if (!context.ok) return context;
|
|
44
|
+
|
|
45
|
+
// Every token value this session has seen, including ones a refresh
|
|
46
|
+
// replaced, so a caller can refuse to print a response that echoes one.
|
|
47
|
+
const known = new Set([record.access_token, record.refresh_token]);
|
|
48
|
+
|
|
49
|
+
// A pending mark seen here may belong to a refresh another process is
|
|
50
|
+
// running right now, so it is judged only under the lock. A deadline or
|
|
51
|
+
// Ctrl+C stops the wait for another process's lock.
|
|
52
|
+
if (record.refresh_pending || record.expires_at - Date.now() <= REFRESH_MARGIN_MS) {
|
|
53
|
+
let refreshed;
|
|
54
|
+
try {
|
|
55
|
+
refreshed = await withSlotLock(store, slot, () => refreshLocked(store, slot, binding, cancel), {
|
|
56
|
+
waitMs: LOCK_WAIT_MS,
|
|
57
|
+
signal: cancel,
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof Stop && error.reason === "cancelled") return stop(error.outcome, error.reason);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (!refreshed.ok) return refreshed;
|
|
64
|
+
record = refreshed.record;
|
|
65
|
+
for (const seen of [refreshed.previous, record]) {
|
|
66
|
+
if (seen) known.add(seen.access_token).add(seen.refresh_token);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
|
|
71
|
+
const verified = await verifyContext(
|
|
72
|
+
binding.origin,
|
|
73
|
+
record.access_token,
|
|
74
|
+
{ profile: binding.deployment_profile, workspaceId: binding.workspace_id },
|
|
75
|
+
limit.signal,
|
|
76
|
+
);
|
|
77
|
+
if (!verified.ok) {
|
|
78
|
+
// The service refused a token that was valid when issued: the grant was
|
|
79
|
+
// revoked, or the user lost access to the workspace. Fail closed.
|
|
80
|
+
if (verified.denied) return stop("login_required", "access_revoked");
|
|
81
|
+
if (cancel?.aborted) return stop("authorization_failed", "cancelled");
|
|
82
|
+
return verified;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
session: {
|
|
87
|
+
origin: binding.origin,
|
|
88
|
+
workspaceId: binding.workspace_id,
|
|
89
|
+
profile: binding.deployment_profile,
|
|
90
|
+
scopes: [METADATA_SCOPE],
|
|
91
|
+
accessToken: record.access_token,
|
|
92
|
+
},
|
|
93
|
+
documents: verified.documents,
|
|
94
|
+
// Internal only: for checking output before it is printed. Never part
|
|
95
|
+
// of a result, envelope, log or file.
|
|
96
|
+
knownCredentials: [...known],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The stored grant must be for exactly the bound origin, workspace and
|
|
101
|
+
// profile.
|
|
102
|
+
function checkIdentity(record, binding) {
|
|
103
|
+
if (
|
|
104
|
+
record.origin !== binding.origin ||
|
|
105
|
+
record.workspace_id !== binding.workspace_id ||
|
|
106
|
+
record.deployment_profile !== binding.deployment_profile
|
|
107
|
+
) {
|
|
108
|
+
return stop("login_required", "credential_context_mismatch");
|
|
109
|
+
}
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Under the lock, a pending mark means a refresh that did not finish: its
|
|
114
|
+
// refresh token may have been used, so it is never sent again.
|
|
115
|
+
function checkContext(record, binding) {
|
|
116
|
+
const identity = checkIdentity(record, binding);
|
|
117
|
+
if (!identity.ok) return identity;
|
|
118
|
+
if (record.refresh_pending) return stop("login_required", "reconnect_required");
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Runs under the slot lock. Reads the record again, because another process
|
|
123
|
+
// may have refreshed it while this one waited. Before the refresh token is
|
|
124
|
+
// sent, the record is marked refresh_pending on disk. The mark is cleared
|
|
125
|
+
// only when the outcome is known: the new grant is saved, or the request was
|
|
126
|
+
// never sent. Any other ending (timeout after sending, a lost response, an
|
|
127
|
+
// unusable answer, a failed save or a killed process) leaves the mark, so the
|
|
128
|
+
// possibly consumed refresh token is never sent again and the user is asked
|
|
129
|
+
// to reconnect.
|
|
130
|
+
async function refreshLocked(store, slot, binding, cancel) {
|
|
131
|
+
const current = readCredential(store, slot);
|
|
132
|
+
if (current === null) return stop("login_required", "credential_missing");
|
|
133
|
+
const context = checkContext(current, binding);
|
|
134
|
+
if (!context.ok) return context;
|
|
135
|
+
if (current.expires_at - Date.now() > REFRESH_MARGIN_MS) return { ok: true, record: current };
|
|
136
|
+
|
|
137
|
+
writeCredential(store, slot, { ...current, refresh_pending: true });
|
|
138
|
+
const endpoints = endpointsFor(binding.origin);
|
|
139
|
+
const limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
|
|
140
|
+
const result = await refreshGrant(
|
|
141
|
+
endpoints,
|
|
142
|
+
{ clientId: current.client_id, refreshToken: current.refresh_token },
|
|
143
|
+
limit.signal,
|
|
144
|
+
);
|
|
145
|
+
if (!result.ok) {
|
|
146
|
+
if (!result.ambiguous && result.outcome === "connection_failed") {
|
|
147
|
+
writeCredential(store, slot, current);
|
|
148
|
+
return stop(cancel?.aborted ? "authorization_failed" : "connection_failed", cancel?.aborted ? "cancelled" : result.reason);
|
|
149
|
+
}
|
|
150
|
+
if (!result.ambiguous) return stop("login_required", "grant_rejected");
|
|
151
|
+
return stop("login_required", "refresh_interrupted");
|
|
152
|
+
}
|
|
153
|
+
if (result.grant.tenant !== binding.workspace_id) {
|
|
154
|
+
return stop("login_required", "credential_context_mismatch");
|
|
155
|
+
}
|
|
156
|
+
const next = {
|
|
157
|
+
...current,
|
|
158
|
+
access_token: result.grant.accessToken,
|
|
159
|
+
refresh_token: result.grant.refreshToken ?? current.refresh_token,
|
|
160
|
+
expires_at: result.grant.expiresAt,
|
|
161
|
+
refresh_pending: false,
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
writeCredential(store, slot, next);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof Stop) return stop("login_required", "refresh_not_saved");
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, record: next, previous: current };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stop(outcome, reason) {
|
|
173
|
+
return { ok: false, outcome, reason };
|
|
174
|
+
}
|