@tokenoftrust/cli 1.0.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/LICENSE +202 -0
- package/README.md +50 -0
- package/bin/tot.mjs +124 -0
- package/package.json +41 -0
- package/src/auth.mjs +132 -0
- package/src/commands/checkout.mjs +233 -0
- package/src/commands/dev.mjs +564 -0
- package/src/commands/doctor.mjs +172 -0
- package/src/commands/ideas.mjs +45 -0
- package/src/commands/login.mjs +107 -0
- package/src/commands/start.mjs +450 -0
- package/src/commands/submit.mjs +284 -0
- package/src/commands/validate.mjs +99 -0
- package/src/commands/whoami.mjs +69 -0
- package/src/context.mjs +97 -0
- package/src/errors.mjs +64 -0
- package/src/last-tenant.mjs +49 -0
- package/src/mcp.mjs +100 -0
- package/src/oauth.mjs +409 -0
- package/src/open.mjs +63 -0
- package/src/token-store.mjs +65 -0
- package/src/validate.mjs +291 -0
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MCP JSON-RPC client — the transport `tot` uses to talk to the Token
|
|
3
|
+
* of Trust MCP (default https://mcp.tokenoftrust.com/mcp).
|
|
4
|
+
*
|
|
5
|
+
* Ported verbatim in behaviour from scripts/tenant/checkout.mjs (which proved
|
|
6
|
+
* this transport live against qa): a single streamable-HTTP endpoint that may
|
|
7
|
+
* answer as JSON or as an SSE stream, with the session id carried in the
|
|
8
|
+
* `Mcp-Session-Id` header across calls. Dependency-free (global fetch, Node 20+).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} baseUrl - MCP base URL; `/mcp` is appended if absent.
|
|
13
|
+
* @param {{ token?: string }} [opts] - optional developer OAuth bearer to attach.
|
|
14
|
+
* @returns a small client: { mcpUrl, initialize, callRaw, callTool, sessionId(), setToken }
|
|
15
|
+
*/
|
|
16
|
+
export function createMcpClient(baseUrl, opts = {}) {
|
|
17
|
+
const trimmed = String(baseUrl).replace(/\/+$/, "");
|
|
18
|
+
const mcpUrl = trimmed.endsWith("/mcp") ? trimmed : `${trimmed}/mcp`;
|
|
19
|
+
let rpcId = 0;
|
|
20
|
+
let sessionId = null;
|
|
21
|
+
// The developer OAuth bearer (set at login-resolve time via setToken, or up
|
|
22
|
+
// front via opts.token). Absent for the operator path, which authenticates by
|
|
23
|
+
// credential_validate + the Mcp-Session-Id below.
|
|
24
|
+
let bearer = typeof opts.token === "string" ? opts.token : null;
|
|
25
|
+
|
|
26
|
+
/** Attach (or clear) the developer bearer for subsequent calls. */
|
|
27
|
+
function setToken(token) {
|
|
28
|
+
bearer = token || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function callRaw(method, params) {
|
|
32
|
+
const envelope = { jsonrpc: "2.0", id: ++rpcId, method, params };
|
|
33
|
+
const headers = {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
Accept: "application/json, text/event-stream",
|
|
36
|
+
};
|
|
37
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
38
|
+
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
|
|
39
|
+
|
|
40
|
+
const res = await fetch(mcpUrl, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers,
|
|
43
|
+
body: JSON.stringify(envelope),
|
|
44
|
+
});
|
|
45
|
+
const sid = res.headers.get("Mcp-Session-Id");
|
|
46
|
+
if (sid) sessionId = sid;
|
|
47
|
+
|
|
48
|
+
const ct = res.headers.get("content-type") || "";
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
if (!res.ok && !text) {
|
|
51
|
+
throw new Error(`${method} failed: HTTP ${res.status} ${res.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let parsed;
|
|
55
|
+
if (ct.includes("text/event-stream")) {
|
|
56
|
+
const lines = text
|
|
57
|
+
.split(/\r?\n/)
|
|
58
|
+
.filter((l) => l.startsWith("data:"))
|
|
59
|
+
.map((l) => l.slice(5).trim())
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
parsed = lines.length ? JSON.parse(lines[lines.length - 1]) : null;
|
|
62
|
+
} else {
|
|
63
|
+
parsed = text ? JSON.parse(text) : null;
|
|
64
|
+
}
|
|
65
|
+
if (parsed?.error) {
|
|
66
|
+
throw new Error(`${method} failed: ${JSON.stringify(parsed.error)}`);
|
|
67
|
+
}
|
|
68
|
+
return parsed?.result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Call an MCP tool and unwrap its structured / text result to a plain object. */
|
|
72
|
+
async function callTool(name, args) {
|
|
73
|
+
const r = await callRaw("tools/call", { name, arguments: args });
|
|
74
|
+
if (r?.structuredContent) return r.structuredContent;
|
|
75
|
+
const tb = Array.isArray(r?.content)
|
|
76
|
+
? r.content.find((c) => c?.type === "text")
|
|
77
|
+
: null;
|
|
78
|
+
if (tb?.text) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(tb.text);
|
|
81
|
+
} catch {
|
|
82
|
+
return { raw: tb.text };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return r;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Complete the MCP handshake. Call once before any tool call. */
|
|
89
|
+
async function initialize(clientInfo = { name: "tot-cli", version: "0.1.0" }) {
|
|
90
|
+
await callRaw("initialize", {
|
|
91
|
+
protocolVersion: "2025-06-18",
|
|
92
|
+
capabilities: {},
|
|
93
|
+
clientInfo,
|
|
94
|
+
});
|
|
95
|
+
// Best-effort — some servers don't require the notification.
|
|
96
|
+
await callRaw("notifications/initialized", undefined).catch(() => {});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { mcpUrl, initialize, callRaw, callTool, sessionId: () => sessionId, setToken };
|
|
100
|
+
}
|
package/src/oauth.mjs
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP OAuth 2.1 (PKCE) client for `tot login` — the SAME loopback flow that
|
|
3
|
+
* `claude mcp add` runs against the Token of Trust MCP. The developer's identity
|
|
4
|
+
* is a normal MCP OAuth token, minted by the MCP's own authorization server, and
|
|
5
|
+
* entitlement is derived server-side from the developer's ToT memberships (see
|
|
6
|
+
* 04-q1-foundation-analysis.md).
|
|
7
|
+
*
|
|
8
|
+
* The shape (RFC 8252 native-app + OAuth 2.1 + PKCE S256):
|
|
9
|
+
* 1. discover the AS metadata (authorize/token/registration endpoints),
|
|
10
|
+
* 2. dynamically register a PUBLIC client (token_endpoint_auth_method "none"),
|
|
11
|
+
* 3. bind a 127.0.0.1 loopback listener on an ephemeral port,
|
|
12
|
+
* 4. open the browser to /authorize (dev signs in with their ToT identity +
|
|
13
|
+
* consents), capture code on the loopback callback,
|
|
14
|
+
* 5. exchange code -> { access_token, refresh_token, expires_in } with the PKCE
|
|
15
|
+
* verifier, and hand back a credentials record the token-store persists.
|
|
16
|
+
*
|
|
17
|
+
* B3 adds the RFC 8628 device-authorization grant (deviceLoginFlow, below) for
|
|
18
|
+
* headless/SSH/no-browser boxes where the loopback can never be reached — same
|
|
19
|
+
* dynamically-registered client_id, same credentials shape, just a different
|
|
20
|
+
* dance: print a code, poll the token endpoint until it's approved elsewhere.
|
|
21
|
+
*
|
|
22
|
+
* The pieces are exported individually so the flow is unit-testable against a
|
|
23
|
+
* mocked `fetch` with no network. Dependency-free (node:http/crypto/timers + open.mjs).
|
|
24
|
+
*/
|
|
25
|
+
import http from "node:http";
|
|
26
|
+
import crypto from "node:crypto";
|
|
27
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
28
|
+
import { openBrowser } from "./open.mjs";
|
|
29
|
+
|
|
30
|
+
const CLIENT_NAME = "Token of Trust CLI (tot)";
|
|
31
|
+
const SCOPE = "mcp offline_access"; // offline_access → the refresh token
|
|
32
|
+
// Portless loopback: RFC 8252 §7.3 + the MCP's loopback exception — we register
|
|
33
|
+
// this once and reuse the clientId across logins; each login binds a fresh
|
|
34
|
+
// ephemeral port that the MCP matches port-agnostically.
|
|
35
|
+
const LOOPBACK_REDIRECT = "http://127.0.0.1/callback";
|
|
36
|
+
|
|
37
|
+
const b64url = (buf) =>
|
|
38
|
+
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
39
|
+
|
|
40
|
+
/** Thrown by loginFlow() when no browser opener exists on this box. Callers
|
|
41
|
+
* catch it to fall through to deviceLoginFlow() (B3) instead of hanging. */
|
|
42
|
+
export class NoOpenerError extends Error {
|
|
43
|
+
constructor(authorizeUrl) {
|
|
44
|
+
super("no browser opener available on this machine");
|
|
45
|
+
this.name = "NoOpenerError";
|
|
46
|
+
this.authorizeUrl = authorizeUrl;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A PKCE verifier + its S256 challenge (OAuth 2.1 mandates S256). */
|
|
51
|
+
export function generatePkce() {
|
|
52
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
53
|
+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
54
|
+
return { verifier, challenge };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An opaque anti-CSRF state nonce for the authorize round-trip. */
|
|
58
|
+
export function randomState() {
|
|
59
|
+
return b64url(crypto.randomBytes(16));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fetch the Authorization Server metadata (RFC 8414). */
|
|
63
|
+
export async function discoverMetadata(mcpUrl, fetchImpl = fetch) {
|
|
64
|
+
const root = String(mcpUrl).replace(/\/+$/, "");
|
|
65
|
+
const url = `${root}/.well-known/oauth-authorization-server`;
|
|
66
|
+
const res = await fetchImpl(url, { headers: { Accept: "application/json" } });
|
|
67
|
+
if (!res.ok) throw new Error(`could not read the MCP's OAuth metadata (HTTP ${res.status} at ${url})`);
|
|
68
|
+
const meta = await res.json();
|
|
69
|
+
if (!meta?.authorization_endpoint || !meta?.token_endpoint) {
|
|
70
|
+
throw new Error("the MCP's OAuth metadata is missing authorization/token endpoints");
|
|
71
|
+
}
|
|
72
|
+
return meta;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Dynamic Client Registration (RFC 7591) for a public PKCE client → clientId. */
|
|
76
|
+
export async function registerClient(
|
|
77
|
+
registrationEndpoint,
|
|
78
|
+
redirectUri = LOOPBACK_REDIRECT,
|
|
79
|
+
fetchImpl = fetch,
|
|
80
|
+
) {
|
|
81
|
+
if (!registrationEndpoint) throw new Error("the MCP does not advertise a registration endpoint");
|
|
82
|
+
const res = await fetchImpl(registrationEndpoint, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
client_name: CLIENT_NAME,
|
|
87
|
+
redirect_uris: [redirectUri],
|
|
88
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
89
|
+
response_types: ["code"],
|
|
90
|
+
token_endpoint_auth_method: "none",
|
|
91
|
+
scope: SCOPE,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) throw new Error(`client registration failed (HTTP ${res.status})`);
|
|
95
|
+
const body = await res.json();
|
|
96
|
+
if (!body?.client_id) throw new Error("client registration returned no client_id");
|
|
97
|
+
return body.client_id;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Build the /authorize URL carrying PKCE + state (no `resource` — the AS binds
|
|
101
|
+
* its own canonical MCP audience, and naming the wrong one is an invalid_target). */
|
|
102
|
+
export function buildAuthorizeUrl(authorizationEndpoint, {
|
|
103
|
+
clientId, redirectUri, challenge, state, scope = SCOPE,
|
|
104
|
+
}) {
|
|
105
|
+
const u = new URL(authorizationEndpoint);
|
|
106
|
+
u.searchParams.set("response_type", "code");
|
|
107
|
+
u.searchParams.set("client_id", clientId);
|
|
108
|
+
u.searchParams.set("redirect_uri", redirectUri);
|
|
109
|
+
u.searchParams.set("scope", scope);
|
|
110
|
+
u.searchParams.set("state", state);
|
|
111
|
+
u.searchParams.set("code_challenge", challenge);
|
|
112
|
+
u.searchParams.set("code_challenge_method", "S256");
|
|
113
|
+
return u.toString();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function tokenRequest(tokenEndpoint, params, fetchImpl) {
|
|
117
|
+
const res = await fetchImpl(tokenEndpoint, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
121
|
+
Accept: "application/json",
|
|
122
|
+
},
|
|
123
|
+
body: new URLSearchParams(params).toString(),
|
|
124
|
+
});
|
|
125
|
+
const text = await res.text();
|
|
126
|
+
let body;
|
|
127
|
+
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
|
|
130
|
+
throw new Error(`token request failed (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
|
|
131
|
+
}
|
|
132
|
+
if (!body.access_token) throw new Error("token endpoint returned no access_token");
|
|
133
|
+
return body;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Exchange the authorization code for tokens (PKCE verifier proves the client). */
|
|
137
|
+
export function exchangeCode(tokenEndpoint, { code, verifier, clientId, redirectUri }, fetchImpl = fetch) {
|
|
138
|
+
return tokenRequest(tokenEndpoint, {
|
|
139
|
+
grant_type: "authorization_code",
|
|
140
|
+
code,
|
|
141
|
+
redirect_uri: redirectUri,
|
|
142
|
+
client_id: clientId,
|
|
143
|
+
code_verifier: verifier,
|
|
144
|
+
}, fetchImpl);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Refresh an expired access token. Public client → no secret, just the refresh token. */
|
|
148
|
+
export function refreshAccessToken(tokenEndpoint, { refreshToken, clientId }, fetchImpl = fetch) {
|
|
149
|
+
return tokenRequest(tokenEndpoint, {
|
|
150
|
+
grant_type: "refresh_token",
|
|
151
|
+
refresh_token: refreshToken,
|
|
152
|
+
client_id: clientId,
|
|
153
|
+
}, fetchImpl);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Normalize a token-endpoint response into the credentials record we persist.
|
|
158
|
+
* Refresh responses often omit a new refresh_token — the caller passes the prior
|
|
159
|
+
* one through so we never drop the ability to refresh again.
|
|
160
|
+
*/
|
|
161
|
+
export function credentialsFromToken({ mcpUrl, clientId, tokenEndpoint, scope = SCOPE, token, now = Date.now() }) {
|
|
162
|
+
const expiresIn = Number(token.expires_in) || 0;
|
|
163
|
+
return {
|
|
164
|
+
mcpUrl,
|
|
165
|
+
clientId,
|
|
166
|
+
tokenEndpoint,
|
|
167
|
+
scope: token.scope || scope,
|
|
168
|
+
accessToken: token.access_token,
|
|
169
|
+
refreshToken: token.refresh_token || null,
|
|
170
|
+
expiresAt: expiresIn ? now + expiresIn * 1000 : null,
|
|
171
|
+
obtainedAt: now,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A 127.0.0.1 loopback listener for the OAuth redirect. Resolves `waitForCallback`
|
|
177
|
+
* with { code, state } on /callback, or rejects on an ?error= response. Binds an
|
|
178
|
+
* ephemeral port (`.ready()` → the chosen port).
|
|
179
|
+
*/
|
|
180
|
+
export function startLoopbackListener({ host = "127.0.0.1" } = {}) {
|
|
181
|
+
let settle, reject;
|
|
182
|
+
const callback = new Promise((res, rej) => { settle = res; reject = rej; });
|
|
183
|
+
const server = http.createServer((req, res) => {
|
|
184
|
+
const u = new URL(req.url, `http://${host}`);
|
|
185
|
+
if (u.pathname !== "/callback") {
|
|
186
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
187
|
+
res.end("not found");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const error = u.searchParams.get("error");
|
|
191
|
+
res.writeHead(error ? 400 : 200, { "Content-Type": "text/html; charset=utf-8" });
|
|
192
|
+
res.end(resultPage(error, u.searchParams.get("error_description")));
|
|
193
|
+
if (error) {
|
|
194
|
+
const d = u.searchParams.get("error_description");
|
|
195
|
+
reject(new Error(`authorization was denied (${error}${d ? `: ${d}` : ""})`));
|
|
196
|
+
} else {
|
|
197
|
+
settle({ code: u.searchParams.get("code"), state: u.searchParams.get("state") });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
const listening = new Promise((res, rej) => {
|
|
201
|
+
server.once("error", rej);
|
|
202
|
+
server.listen(0, host, () => res());
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
async ready() { await listening; return server.address().port; },
|
|
206
|
+
waitForCallback() { return callback; },
|
|
207
|
+
close() { try { server.close(); } catch { /* already closed */ } },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resultPage(error, detail) {
|
|
212
|
+
const ok = !error;
|
|
213
|
+
const title = ok ? "Signed in" : "Sign-in failed";
|
|
214
|
+
const body = ok
|
|
215
|
+
? "You're signed in to Token of Trust. You can close this tab and return to your terminal."
|
|
216
|
+
: `Sign-in didn't complete${detail ? `: ${escapeHtml(detail)}` : ""}. Close this tab and run <code>tot login</code> again.`;
|
|
217
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
218
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title} · Token of Trust</title>
|
|
219
|
+
<style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|
220
|
+
background:#044a38;color:#eafff9;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;padding:24px}
|
|
221
|
+
.card{max-width:30rem;background:#06392c;border:1px solid #1d6b54;border-radius:12px;padding:28px;text-align:center}
|
|
222
|
+
h1{font-size:1.2rem;margin:.2rem 0 .8rem}p{color:#bfe9df;line-height:1.5}code{background:#053f30;padding:1px 6px;border-radius:4px}</style>
|
|
223
|
+
</head><body><div class="card"><h1>${title}</h1><p>${body}</p></div></body></html>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function escapeHtml(s) {
|
|
227
|
+
return String(s).replace(/[&<>"']/g, (c) => (
|
|
228
|
+
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
|
229
|
+
));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Run the full loopback login and return a persistable credentials record.
|
|
234
|
+
* Injectable (`fetchImpl`, `open`, `log`) so it can be driven in a test or a
|
|
235
|
+
* headless environment. Reuses `clientId` when the caller cached one for this MCP.
|
|
236
|
+
* @returns {Promise<object>} credentials to hand to writeCredentials()
|
|
237
|
+
*/
|
|
238
|
+
export async function loginFlow({
|
|
239
|
+
mcpUrl,
|
|
240
|
+
clientId,
|
|
241
|
+
fetchImpl = fetch,
|
|
242
|
+
open = openBrowser,
|
|
243
|
+
log = () => {},
|
|
244
|
+
now = () => Date.now(),
|
|
245
|
+
}) {
|
|
246
|
+
const meta = await discoverMetadata(mcpUrl, fetchImpl);
|
|
247
|
+
const listener = startLoopbackListener();
|
|
248
|
+
try {
|
|
249
|
+
const port = await listener.ready();
|
|
250
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
251
|
+
const resolvedClientId = clientId || (await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl));
|
|
252
|
+
const { verifier, challenge } = generatePkce();
|
|
253
|
+
const state = randomState();
|
|
254
|
+
const authorizeUrl = buildAuthorizeUrl(meta.authorization_endpoint, {
|
|
255
|
+
clientId: resolvedClientId, redirectUri, challenge, state,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
log(" opening your browser to sign in …");
|
|
259
|
+
const opened = open(authorizeUrl);
|
|
260
|
+
if (!opened) {
|
|
261
|
+
// No opener on this box (headless/SSH) — the loopback can never be hit
|
|
262
|
+
// from here, so waiting on it would hang forever. Let the caller (B3:
|
|
263
|
+
// login.mjs#loginAndCache) fall through to deviceLoginFlow() instead.
|
|
264
|
+
throw new NoOpenerError(authorizeUrl);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { code, state: returnedState } = await listener.waitForCallback();
|
|
268
|
+
if (!code) throw new Error("no authorization code came back from the browser");
|
|
269
|
+
if (returnedState !== state) throw new Error("state mismatch on the OAuth callback — aborting");
|
|
270
|
+
|
|
271
|
+
const token = await exchangeCode(
|
|
272
|
+
meta.token_endpoint,
|
|
273
|
+
{ code, verifier, clientId: resolvedClientId, redirectUri },
|
|
274
|
+
fetchImpl,
|
|
275
|
+
);
|
|
276
|
+
return credentialsFromToken({
|
|
277
|
+
mcpUrl,
|
|
278
|
+
clientId: resolvedClientId,
|
|
279
|
+
tokenEndpoint: meta.token_endpoint,
|
|
280
|
+
token,
|
|
281
|
+
now: now(),
|
|
282
|
+
});
|
|
283
|
+
} finally {
|
|
284
|
+
listener.close();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── B3: device-code grant (RFC 8628) — headless/SSH/no-browser sign-in ────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* RFC 8628 §3.1 — request a device_code + user_code to display. Same request
|
|
292
|
+
* shape as any other token-endpoint-adjacent POST (form-urlencoded, JSON back).
|
|
293
|
+
* @returns {Promise<{device_code, user_code, verification_uri,
|
|
294
|
+
* verification_uri_complete?, expires_in, interval}>}
|
|
295
|
+
*/
|
|
296
|
+
export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, scope = SCOPE }, fetchImpl = fetch) {
|
|
297
|
+
const res = await fetchImpl(deviceAuthorizationEndpoint, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
300
|
+
body: new URLSearchParams({ client_id: clientId, scope }).toString(),
|
|
301
|
+
});
|
|
302
|
+
const text = await res.text();
|
|
303
|
+
let body;
|
|
304
|
+
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
|
|
305
|
+
if (!res.ok) {
|
|
306
|
+
const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
|
|
307
|
+
throw new Error(`device authorization request failed (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
|
|
308
|
+
}
|
|
309
|
+
if (!body.device_code || !body.user_code) {
|
|
310
|
+
throw new Error("device authorization response is missing device_code/user_code");
|
|
311
|
+
}
|
|
312
|
+
return body;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* One device-flow poll attempt (RFC 8628 §3.4/§3.5). Unlike tokenRequest(),
|
|
317
|
+
* `authorization_pending` and `slow_down` are NOT failures — they're the
|
|
318
|
+
* normal "keep waiting" responses — so this resolves `{ pending: true }`
|
|
319
|
+
* (with `slowDown` set) for those instead of throwing.
|
|
320
|
+
*/
|
|
321
|
+
async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl) {
|
|
322
|
+
const res = await fetchImpl(tokenEndpoint, {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
325
|
+
body: new URLSearchParams({
|
|
326
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
327
|
+
device_code: deviceCode,
|
|
328
|
+
client_id: clientId,
|
|
329
|
+
}).toString(),
|
|
330
|
+
});
|
|
331
|
+
const text = await res.text();
|
|
332
|
+
let body;
|
|
333
|
+
try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
|
|
334
|
+
if (res.ok) {
|
|
335
|
+
if (!body.access_token) throw new Error("token endpoint returned no access_token");
|
|
336
|
+
return { pending: false, token: body };
|
|
337
|
+
}
|
|
338
|
+
if (body.error === "authorization_pending") return { pending: true, slowDown: false };
|
|
339
|
+
if (body.error === "slow_down") return { pending: true, slowDown: true };
|
|
340
|
+
const reason = body.error === "access_denied" ? "was denied" : body.error === "expired_token" ? "code expired" : "failed";
|
|
341
|
+
const detail = body.error_description ? ` — ${body.error_description}` : "";
|
|
342
|
+
throw new Error(`sign-in ${reason}${detail}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Poll the token endpoint until the device code is approved (or denied/
|
|
347
|
+
* expires), honoring the server's `interval` and the `slow_down` backoff
|
|
348
|
+
* (RFC 8628 §3.5: +5s, keep polling — not a failure). Injectable `sleep`/
|
|
349
|
+
* `now` so it's testable with no real waiting.
|
|
350
|
+
* @returns {Promise<object>} the raw token response (→ credentialsFromToken)
|
|
351
|
+
*/
|
|
352
|
+
export async function pollDeviceToken(
|
|
353
|
+
tokenEndpoint,
|
|
354
|
+
{ deviceCode, clientId, intervalSec, expiresInSec },
|
|
355
|
+
fetchImpl = fetch,
|
|
356
|
+
{ sleep = delay, now = () => Date.now() } = {},
|
|
357
|
+
) {
|
|
358
|
+
let intervalMs = Math.max(1, Number(intervalSec) || 5) * 1000;
|
|
359
|
+
const deadline = now() + Math.max(1, Number(expiresInSec) || 600) * 1000;
|
|
360
|
+
for (;;) {
|
|
361
|
+
await sleep(intervalMs);
|
|
362
|
+
if (now() >= deadline) throw new Error("the device code expired before it was approved");
|
|
363
|
+
const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl);
|
|
364
|
+
if (!r.pending) return r.token;
|
|
365
|
+
if (r.slowDown) intervalMs += 5000;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Run the full device-code login (RFC 8628) and return a persistable
|
|
371
|
+
* credentials record — the headless/SSH-friendly sibling of loginFlow(),
|
|
372
|
+
* same credentials shape, same dynamically-registered client_id. Injectable
|
|
373
|
+
* (`fetchImpl`, `log`, `sleep`, `now`) so it's testable with no network and
|
|
374
|
+
* no real waiting.
|
|
375
|
+
* @returns {Promise<object>} credentials to hand to writeCredentials()
|
|
376
|
+
*/
|
|
377
|
+
export async function deviceLoginFlow({
|
|
378
|
+
mcpUrl,
|
|
379
|
+
clientId,
|
|
380
|
+
fetchImpl = fetch,
|
|
381
|
+
log = () => {},
|
|
382
|
+
sleep = delay,
|
|
383
|
+
now = () => Date.now(),
|
|
384
|
+
}) {
|
|
385
|
+
const meta = await discoverMetadata(mcpUrl, fetchImpl);
|
|
386
|
+
if (!meta.device_authorization_endpoint) {
|
|
387
|
+
throw new Error("this MCP doesn't support device-code sign-in yet — try `tot login` (browser) from a machine with one");
|
|
388
|
+
}
|
|
389
|
+
const resolvedClientId = clientId || (await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl));
|
|
390
|
+
const auth = await deviceAuthorize(meta.device_authorization_endpoint, { clientId: resolvedClientId }, fetchImpl);
|
|
391
|
+
|
|
392
|
+
log(` Go to: ${auth.verification_uri_complete || auth.verification_uri}`);
|
|
393
|
+
if (!auth.verification_uri_complete) log(` Enter code: ${auth.user_code}`);
|
|
394
|
+
log(" Waiting for approval …");
|
|
395
|
+
|
|
396
|
+
const token = await pollDeviceToken(
|
|
397
|
+
meta.token_endpoint,
|
|
398
|
+
{ deviceCode: auth.device_code, clientId: resolvedClientId, intervalSec: auth.interval, expiresInSec: auth.expires_in },
|
|
399
|
+
fetchImpl,
|
|
400
|
+
{ sleep, now },
|
|
401
|
+
);
|
|
402
|
+
return credentialsFromToken({
|
|
403
|
+
mcpUrl,
|
|
404
|
+
clientId: resolvedClientId,
|
|
405
|
+
tokenEndpoint: meta.token_endpoint,
|
|
406
|
+
token,
|
|
407
|
+
now: now(),
|
|
408
|
+
});
|
|
409
|
+
}
|
package/src/open.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny cross-platform helpers for the "and then it just opens" moment:
|
|
3
|
+
* - openBrowser(url) — hand the URL to the OS (macOS `open` / Linux `xdg-open`
|
|
4
|
+
* / Windows `start`), detached, so `tot` doesn't babysit the browser.
|
|
5
|
+
* - waitForServer(url) — poll until the dev server answers, so we open the tab
|
|
6
|
+
* the instant the site is live (not before, not on a fixed sleep).
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free (global fetch, node:child_process, Node 20+).
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Open `url` in the user's default browser. Best-effort and non-blocking: the
|
|
15
|
+
* child is detached + unref'd so it never holds `tot` open, and any failure
|
|
16
|
+
* (headless box, no opener) returns false instead of throwing.
|
|
17
|
+
* @returns {boolean} whether we managed to launch an opener.
|
|
18
|
+
*/
|
|
19
|
+
export function openBrowser(url, platform = process.platform) {
|
|
20
|
+
let cmd, args, shell = false;
|
|
21
|
+
if (platform === "darwin") {
|
|
22
|
+
cmd = "open";
|
|
23
|
+
args = [url];
|
|
24
|
+
} else if (platform === "win32") {
|
|
25
|
+
// `start` is a shell builtin; the empty first arg is the window title.
|
|
26
|
+
cmd = "start";
|
|
27
|
+
args = ["", url];
|
|
28
|
+
shell = true;
|
|
29
|
+
} else {
|
|
30
|
+
cmd = "xdg-open";
|
|
31
|
+
args = [url];
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell });
|
|
35
|
+
child.on("error", () => {}); // swallow "opener not found" async errors
|
|
36
|
+
child.unref();
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve true once `url` responds to a GET (any HTTP status counts — a 404
|
|
45
|
+
* still means the server is up), or false if it hasn't within `timeoutMs`.
|
|
46
|
+
* @param {string} url
|
|
47
|
+
* @param {{ timeoutMs?: number, intervalMs?: number, until?: () => boolean }} [opts]
|
|
48
|
+
* until - optional early-stop predicate (e.g. "the dev process already died").
|
|
49
|
+
*/
|
|
50
|
+
export async function waitForServer(url, { timeoutMs = 60000, intervalMs = 400, until } = {}) {
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
if (until && until()) return false;
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(url, { method: "GET", signal: AbortSignal.timeout(2500) });
|
|
56
|
+
if (res) return true;
|
|
57
|
+
} catch {
|
|
58
|
+
// not up yet — keep polling
|
|
59
|
+
}
|
|
60
|
+
await delay(intervalMs);
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The developer credential cache for `tot` — where `tot login` writes the MCP
|
|
3
|
+
* OAuth token and every developer-session command reads it back.
|
|
4
|
+
*
|
|
5
|
+
* ONE file, `~/.tot/credentials.json` (0600 in a 0700 dir), holding exactly what
|
|
6
|
+
* a later command needs to authenticate AND to silently refresh:
|
|
7
|
+
*
|
|
8
|
+
* { mcpUrl, clientId, tokenEndpoint, scope,
|
|
9
|
+
* accessToken, refreshToken, expiresAt (epoch ms), obtainedAt }
|
|
10
|
+
*
|
|
11
|
+
* We persist `clientId` + `tokenEndpoint` so a refresh needs no re-discovery /
|
|
12
|
+
* re-registration, and `mcpUrl` so we never present a token minted for one MCP
|
|
13
|
+
* to a different one. Dependency-free (node:fs/os/path).
|
|
14
|
+
*
|
|
15
|
+
* `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join, dirname } from "node:path";
|
|
22
|
+
|
|
23
|
+
/** Absolute path to the credential file for this environment. */
|
|
24
|
+
export function defaultCredentialsPath(env = process.env) {
|
|
25
|
+
const home = env.TOT_HOME || homedir();
|
|
26
|
+
return join(home, ".tot", "credentials.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read + parse the cached credentials, or null if absent/unreadable/malformed.
|
|
31
|
+
* Never throws — a missing or corrupt cache simply means "not signed in".
|
|
32
|
+
*/
|
|
33
|
+
export function readCredentials(filePath) {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
36
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write credentials atomically with owner-only permissions: the containing
|
|
44
|
+
* `~/.tot` is created 0700, the file lands 0600 via a temp-file rename so a
|
|
45
|
+
* reader never sees a half-written token, and we chmod after rename to force
|
|
46
|
+
* 0600 even when the file already existed.
|
|
47
|
+
*/
|
|
48
|
+
export function writeCredentials(filePath, creds) {
|
|
49
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
50
|
+
const tmp = `${filePath}.tmp`;
|
|
51
|
+
writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
|
|
52
|
+
renameSync(tmp, filePath);
|
|
53
|
+
chmodSync(filePath, 0o600);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is the access token expired (or within `skewMs` of it)? A cache with no
|
|
58
|
+
* `expiresAt` is treated as NOT expired — we can't know, so we present it and
|
|
59
|
+
* let the server reject it rather than force an unnecessary re-login.
|
|
60
|
+
* @param {{expiresAt?: number|null}} creds
|
|
61
|
+
*/
|
|
62
|
+
export function isExpired(creds, { now = Date.now(), skewMs = 60_000 } = {}) {
|
|
63
|
+
if (!creds || !creds.expiresAt) return false;
|
|
64
|
+
return now >= creds.expiresAt - skewMs;
|
|
65
|
+
}
|