getbotons 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { VERSION } from '../../shared/app-version.mjs';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { hostOf, OutboundError, outbound } from './outbound.ts';
|
|
4
|
+
import { sanitize } from './secrets.ts';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The OAuth 2.1 client side of MCP authorization: protected resource metadata (RFC 9728), authorization server
|
|
8
|
+
* metadata (RFC 8414, OpenID discovery as a fallback, both path-aware), dynamic registration of a public client
|
|
9
|
+
* (RFC 7591), PKCE S256, the resource indicator (RFC 8707) on every authorization and token request, and the
|
|
10
|
+
* authorization response's iss (RFC 9207). Nothing here keeps state; OAuthConnections does.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const META_MAX = 128 * 1024;
|
|
14
|
+
const TOKEN_MAX = 8192;
|
|
15
|
+
|
|
16
|
+
export class OAuthError extends Error {
|
|
17
|
+
/** grant: the grant is gone (sign in again); transient: try later; setup: the server can't be used as it is. */
|
|
18
|
+
readonly kind: 'grant' | 'transient' | 'setup';
|
|
19
|
+
/** The token request may have been handled (a refresh token may have rotated). */
|
|
20
|
+
readonly ambiguous: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* `message` is the raw text, foreign parts and all; `secrets` the credentials known to be at stake (those a
|
|
23
|
+
* request sent). It is sanitized here, once: known secrets first, then token-shaped runs, then the cut. A known
|
|
24
|
+
* secret applied only after the token-shaped pass would no longer match (part of it is already "[redacted]"), and
|
|
25
|
+
* the rest of it would show, so pass the credentials here rather than redacting the message afterwards.
|
|
26
|
+
*/
|
|
27
|
+
constructor(kind: OAuthError['kind'], message: string, ambiguous = false, secrets: Iterable<string | null | undefined> = []) {
|
|
28
|
+
super(sanitize(message, secrets, 1000));
|
|
29
|
+
this.kind = kind;
|
|
30
|
+
this.ambiguous = ambiguous;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ServerMetadata {
|
|
35
|
+
issuer: string;
|
|
36
|
+
authorization_endpoint: string;
|
|
37
|
+
token_endpoint: string;
|
|
38
|
+
registration_endpoint?: string;
|
|
39
|
+
code_challenge_methods_supported: string[];
|
|
40
|
+
scopes_supported?: string[];
|
|
41
|
+
authorization_response_iss_parameter_supported?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Discovery {
|
|
45
|
+
resource: string;
|
|
46
|
+
issuer: string;
|
|
47
|
+
metadata: ServerMetadata;
|
|
48
|
+
scope: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A URL as RFC 8707 compares resources: no fragment, lowercase scheme and host, no trailing slash. */
|
|
52
|
+
export function canonicalResource(raw: string): string {
|
|
53
|
+
const u = new URL(raw);
|
|
54
|
+
u.hash = '';
|
|
55
|
+
const path = u.pathname.replace(/\/+$/, '');
|
|
56
|
+
return `${u.protocol.toLowerCase()}//${u.host.toLowerCase()}${path}${u.search}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseObject(body: string, what: string): Record<string, unknown> {
|
|
60
|
+
let v: unknown;
|
|
61
|
+
try {
|
|
62
|
+
v = JSON.parse(body);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new OAuthError('setup', `${what} is not JSON`);
|
|
65
|
+
}
|
|
66
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) throw new OAuthError('setup', `${what} is not a JSON object`);
|
|
67
|
+
return v as Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const httpsUrl = (v: unknown, field: string, allow: string[]): string => {
|
|
71
|
+
if (typeof v !== 'string') throw new OAuthError('setup', `The authorization server's metadata has no ${field}`);
|
|
72
|
+
let u: URL;
|
|
73
|
+
try {
|
|
74
|
+
u = new URL(v);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new OAuthError('setup', `The authorization server's ${field} is not a URL`);
|
|
77
|
+
}
|
|
78
|
+
const loopbackAllowed = u.protocol === 'http:' && /^(127\.\d+\.\d+\.\d+|\[::1\]|localhost)$/i.test(u.hostname) && allow.some((a) => a === u.hostname.replace(/^\[|\]$/g, '').toLowerCase());
|
|
79
|
+
if (u.protocol !== 'https:' && !loopbackAllowed) throw new OAuthError('setup', `The authorization server's ${field} is not HTTPS`);
|
|
80
|
+
if (u.hash || u.username || u.password) throw new OAuthError('setup', `The authorization server's ${field} is not a plain URL`);
|
|
81
|
+
return v;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** `Bearer resource_metadata="…", scope="…"`: the parameters, lowercase names. */
|
|
85
|
+
export function bearerChallenge(header: string | string[] | undefined): Record<string, string> {
|
|
86
|
+
const raw = Array.isArray(header) ? header.join(', ') : (header ?? '');
|
|
87
|
+
const m = /Bearer\s+(.*)$/is.exec(raw);
|
|
88
|
+
const out: Record<string, string> = {};
|
|
89
|
+
if (!m) return out;
|
|
90
|
+
for (const p of m[1].matchAll(/([A-Za-z_][\w-]*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/g)) out[p[1].toLowerCase()] = p[2] !== undefined ? p[2].replace(/\\(.)/g, '$1') : p[3];
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getJson(url: string, allow: string[], what: string): Promise<Record<string, unknown> | null> {
|
|
95
|
+
const res = await outbound(url, { allow, headers: { accept: 'application/json' }, maxBytes: META_MAX });
|
|
96
|
+
if (res.status === 404 || res.status === 405 || res.status === 400 || res.status === 401 || res.status === 403) return null;
|
|
97
|
+
if (res.status !== 200) throw new OAuthError('transient', `${hostOf(url)} answered ${res.status} for its ${what}`);
|
|
98
|
+
return parseObject(res.body, `The ${what} from ${hostOf(url)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** RFC 9728 well-known URLs for `resource`: with its path inserted, then at the root. */
|
|
102
|
+
export function resourceMetadataUrls(resource: string): string[] {
|
|
103
|
+
const u = new URL(resource);
|
|
104
|
+
const path = u.pathname.replace(/\/+$/, '');
|
|
105
|
+
const urls = path ? [`${u.origin}/.well-known/oauth-protected-resource${path}`] : [];
|
|
106
|
+
urls.push(`${u.origin}/.well-known/oauth-protected-resource`);
|
|
107
|
+
return urls;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** RFC 8414 then OpenID discovery, path-aware, in the order MCP authorization asks for. */
|
|
111
|
+
export function serverMetadataUrls(issuer: string): string[] {
|
|
112
|
+
const u = new URL(issuer);
|
|
113
|
+
const path = u.pathname.replace(/\/+$/, '');
|
|
114
|
+
if (!path) return [`${u.origin}/.well-known/oauth-authorization-server`, `${u.origin}/.well-known/openid-configuration`];
|
|
115
|
+
return [`${u.origin}/.well-known/oauth-authorization-server${path}`, `${u.origin}/.well-known/openid-configuration${path}`, `${u.origin}${path}/.well-known/openid-configuration`];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* From the MCP server's URL to its authorization server: probe it without a token for the 401's challenge, read
|
|
120
|
+
* the protected resource metadata (which must name this very resource), then the server metadata (which must name
|
|
121
|
+
* the issuer it was fetched for and offer S256).
|
|
122
|
+
*/
|
|
123
|
+
export async function discover(mcpUrl: string, allow: string[]): Promise<Discovery> {
|
|
124
|
+
const want = canonicalResource(mcpUrl);
|
|
125
|
+
let challenge: Record<string, string> = {};
|
|
126
|
+
try {
|
|
127
|
+
const probe = await outbound(mcpUrl, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
allow,
|
|
130
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
|
131
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'Botons', version: VERSION } } }),
|
|
132
|
+
maxBytes: META_MAX,
|
|
133
|
+
});
|
|
134
|
+
if (probe.status !== 401) throw new OAuthError('setup', `${hostOf(mcpUrl)} did not ask for sign-in (it answered ${probe.status}); add it as an MCP server with headers instead`);
|
|
135
|
+
challenge = bearerChallenge(probe.headers['www-authenticate']);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err instanceof OutboundError) throw new OAuthError(err.kind === 'refused' || err.kind === 'redirect' ? 'setup' : 'transient', err.message);
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
const candidates = challenge.resource_metadata ? [challenge.resource_metadata] : resourceMetadataUrls(mcpUrl);
|
|
141
|
+
let prm: Record<string, unknown> | null = null;
|
|
142
|
+
for (const url of candidates) {
|
|
143
|
+
prm = await wrap(() => getJson(url, allow, 'protected resource metadata'));
|
|
144
|
+
if (prm) break;
|
|
145
|
+
}
|
|
146
|
+
if (!prm) throw new OAuthError('setup', `${hostOf(mcpUrl)} publishes no OAuth protected resource metadata`);
|
|
147
|
+
if (typeof prm.resource !== 'string' || canonicalResource(prm.resource) !== want) {
|
|
148
|
+
throw new OAuthError('setup', `${hostOf(mcpUrl)}'s protected resource metadata is for another resource, so it can't be trusted for this URL`);
|
|
149
|
+
}
|
|
150
|
+
const servers = Array.isArray(prm.authorization_servers) ? prm.authorization_servers.filter((s): s is string => typeof s === 'string') : [];
|
|
151
|
+
if (!servers.length) throw new OAuthError('setup', `${hostOf(mcpUrl)} names no authorization server`);
|
|
152
|
+
const issuer = servers[0];
|
|
153
|
+
httpsUrl(issuer, 'issuer', allow);
|
|
154
|
+
let meta: Record<string, unknown> | null = null;
|
|
155
|
+
for (const url of serverMetadataUrls(issuer)) {
|
|
156
|
+
meta = await wrap(() => getJson(url, allow, 'authorization server metadata'));
|
|
157
|
+
if (meta) break;
|
|
158
|
+
}
|
|
159
|
+
if (!meta) throw new OAuthError('setup', `The authorization server ${hostOf(issuer)} publishes no metadata`);
|
|
160
|
+
if (meta.issuer !== issuer) throw new OAuthError('setup', `The authorization server's metadata names another issuer than ${hostOf(issuer)}, so it can't be trusted`);
|
|
161
|
+
const methods = Array.isArray(meta.code_challenge_methods_supported) ? meta.code_challenge_methods_supported.filter((m): m is string => typeof m === 'string') : [];
|
|
162
|
+
if (!methods.includes('S256')) throw new OAuthError('setup', `The authorization server ${hostOf(issuer)} does not offer PKCE with S256`);
|
|
163
|
+
const metadata: ServerMetadata = {
|
|
164
|
+
issuer,
|
|
165
|
+
authorization_endpoint: httpsUrl(meta.authorization_endpoint, 'authorization_endpoint', allow),
|
|
166
|
+
token_endpoint: httpsUrl(meta.token_endpoint, 'token_endpoint', allow),
|
|
167
|
+
...(meta.registration_endpoint !== undefined ? { registration_endpoint: httpsUrl(meta.registration_endpoint, 'registration_endpoint', allow) } : {}),
|
|
168
|
+
code_challenge_methods_supported: methods,
|
|
169
|
+
...(Array.isArray(meta.scopes_supported) ? { scopes_supported: meta.scopes_supported.filter((s): s is string => typeof s === 'string') } : {}),
|
|
170
|
+
...(meta.authorization_response_iss_parameter_supported === true ? { authorization_response_iss_parameter_supported: true } : {}),
|
|
171
|
+
};
|
|
172
|
+
// The challenge's scope first, else what the resource says it supports (MCP's scope selection).
|
|
173
|
+
const scopes = Array.isArray(prm.scopes_supported) ? prm.scopes_supported.filter((s): s is string => typeof s === 'string') : [];
|
|
174
|
+
const scope = (challenge.scope ?? scopes.join(' ')).trim().slice(0, 1000);
|
|
175
|
+
return { resource: prm.resource, issuer, metadata, scope };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function wrap<T>(fn: () => Promise<T>): Promise<T> {
|
|
179
|
+
try {
|
|
180
|
+
return await fn();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err instanceof OutboundError) throw new OAuthError(err.kind === 'refused' || err.kind === 'redirect' ? 'setup' : 'transient', err.message);
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** RFC 7591: a public client named Botons, for exactly this redirect URI. */
|
|
188
|
+
export async function register(meta: ServerMetadata, redirectUri: string, scope: string, allow: string[]): Promise<string> {
|
|
189
|
+
if (!meta.registration_endpoint) throw new OAuthError('setup', `The authorization server ${hostOf(meta.issuer)} does not let clients register themselves`);
|
|
190
|
+
const res = await wrap(() =>
|
|
191
|
+
outbound(meta.registration_endpoint!, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
allow,
|
|
194
|
+
maxBytes: META_MAX,
|
|
195
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
client_name: 'Botons',
|
|
198
|
+
redirect_uris: [redirectUri],
|
|
199
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
200
|
+
response_types: ['code'],
|
|
201
|
+
token_endpoint_auth_method: 'none',
|
|
202
|
+
application_type: 'web',
|
|
203
|
+
...(scope ? { scope } : {}),
|
|
204
|
+
}),
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
if (res.status !== 201 && res.status !== 200) throw new OAuthError(res.status >= 500 || res.status === 429 ? 'transient' : 'setup', `Registering with ${hostOf(meta.issuer)} failed (${res.status}${errorCode(res.body)})`);
|
|
208
|
+
const out = parseObject(res.body, 'The registration answer');
|
|
209
|
+
if (typeof out.client_id !== 'string' || !out.client_id || out.client_id.length > 500) throw new OAuthError('setup', 'The registration answer has no client_id');
|
|
210
|
+
if (out.token_endpoint_auth_method !== undefined && out.token_endpoint_auth_method !== 'none') throw new OAuthError('setup', `${hostOf(meta.issuer)} registered a confidential client; Botons only uses public ones`);
|
|
211
|
+
if (Array.isArray(out.redirect_uris) && !out.redirect_uris.includes(redirectUri)) throw new OAuthError('setup', `${hostOf(meta.issuer)} registered other redirect URIs`);
|
|
212
|
+
return out.client_id;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function pkce(): { verifier: string; challenge: string } {
|
|
216
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
217
|
+
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function authorizeUrl(meta: ServerMetadata, p: { clientId: string; redirectUri: string; scope: string; state: string; challenge: string; resource: string }): string {
|
|
221
|
+
const u = new URL(meta.authorization_endpoint);
|
|
222
|
+
u.searchParams.set('response_type', 'code');
|
|
223
|
+
u.searchParams.set('client_id', p.clientId);
|
|
224
|
+
u.searchParams.set('redirect_uri', p.redirectUri);
|
|
225
|
+
if (p.scope) u.searchParams.set('scope', p.scope);
|
|
226
|
+
u.searchParams.set('state', p.state);
|
|
227
|
+
u.searchParams.set('code_challenge', p.challenge);
|
|
228
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
229
|
+
u.searchParams.set('resource', p.resource);
|
|
230
|
+
return u.toString();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface Tokens {
|
|
234
|
+
accessToken: string;
|
|
235
|
+
/** Null: the server sent none, so the old one stays. */
|
|
236
|
+
refreshToken: string | null;
|
|
237
|
+
expiresInMs: number | null;
|
|
238
|
+
scope: string | null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function errorCode(body: string): string {
|
|
242
|
+
try {
|
|
243
|
+
const e = JSON.parse(body)?.error;
|
|
244
|
+
return typeof e === 'string' && /^[\w.-]{1,60}$/.test(e) ? `: ${e}` : '';
|
|
245
|
+
} catch {
|
|
246
|
+
return '';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Header-safe, bounded: it goes into an Authorization header. */
|
|
251
|
+
const tokenString = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= TOKEN_MAX && /^[\x21-\x7e]+$/.test(v);
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A token endpoint may echo what it was sent: no error from here holds the code, verifier or refresh token, nor
|
|
255
|
+
* `secrets` (credentials not sent but at stake, such as the access token being replaced). Every error is made from
|
|
256
|
+
* the raw text with them, in OAuthError's constructor.
|
|
257
|
+
*/
|
|
258
|
+
async function tokenRequest(meta: ServerMetadata, form: Record<string, string>, allow: string[], secrets: (string | null | undefined)[] = []): Promise<Tokens> {
|
|
259
|
+
const sent = [form.code, form.code_verifier, form.refresh_token, ...secrets];
|
|
260
|
+
try {
|
|
261
|
+
return await tokenRequestOnce(meta, form, allow, sent);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (err instanceof OAuthError) throw err;
|
|
264
|
+
throw new OAuthError('transient', String((err as Error)?.message ?? err), true, sent);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function tokenRequestOnce(meta: ServerMetadata, form: Record<string, string>, allow: string[], sent: (string | null | undefined)[]): Promise<Tokens> {
|
|
269
|
+
const fail = (kind: OAuthError['kind'], message: string, ambiguous = false) => new OAuthError(kind, message, ambiguous, sent);
|
|
270
|
+
let res;
|
|
271
|
+
try {
|
|
272
|
+
res = await outbound(meta.token_endpoint, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
allow,
|
|
275
|
+
maxBytes: META_MAX,
|
|
276
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
277
|
+
body: new URLSearchParams(form).toString(),
|
|
278
|
+
});
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (err instanceof OutboundError) throw fail(err.kind === 'refused' ? 'setup' : 'transient', err.message, err.sent);
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
if (res.status === 400 || res.status === 401) {
|
|
284
|
+
const code = errorCode(res.body);
|
|
285
|
+
// The grant itself is refused: only signing in again helps.
|
|
286
|
+
if (/^: (invalid_grant|invalid_client|unauthorized_client)$/.test(code) || res.status === 401) throw fail('grant', `${hostOf(meta.issuer)} refused the grant (${res.status}${code})`);
|
|
287
|
+
throw fail('setup', `${hostOf(meta.issuer)} refused the token request (${res.status}${code})`);
|
|
288
|
+
}
|
|
289
|
+
// An OAuth error answer (RFC 6749 §5.2) means nothing was issued; a 5xx without one may have come after it was.
|
|
290
|
+
if (res.status !== 200) throw fail('transient', `${hostOf(meta.issuer)} answered ${res.status} to a token request`, res.status >= 500 && !errorCode(res.body));
|
|
291
|
+
let out: Record<string, unknown>;
|
|
292
|
+
try {
|
|
293
|
+
out = parseObject(res.body, 'The token answer');
|
|
294
|
+
} catch {
|
|
295
|
+
throw fail('transient', 'The token answer is not JSON', true);
|
|
296
|
+
}
|
|
297
|
+
if (!tokenString(out.access_token)) throw fail('transient', 'The token answer has no usable access_token', true);
|
|
298
|
+
if (typeof out.token_type !== 'string' || out.token_type.toLowerCase() !== 'bearer') throw fail('setup', 'The token answer is not a bearer token', true);
|
|
299
|
+
if (out.refresh_token !== undefined && !tokenString(out.refresh_token)) throw fail('transient', 'The token answer has an unusable refresh_token', true);
|
|
300
|
+
const expires = typeof out.expires_in === 'number' && Number.isFinite(out.expires_in) && out.expires_in > 0 ? Math.min(out.expires_in, 10 * 365 * 86_400) * 1000 : null;
|
|
301
|
+
return { accessToken: out.access_token, refreshToken: (out.refresh_token as string | undefined) ?? null, expiresInMs: expires, scope: typeof out.scope === 'string' ? out.scope.slice(0, 1000) : null };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function exchangeCode(meta: ServerMetadata, p: { code: string; verifier: string; clientId: string; redirectUri: string; resource: string }, allow: string[]): Promise<Tokens> {
|
|
305
|
+
return tokenRequest(meta, { grant_type: 'authorization_code', code: p.code, redirect_uri: p.redirectUri, client_id: p.clientId, code_verifier: p.verifier, resource: p.resource }, allow);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** `accessToken`, the one being replaced, isn't sent, but is kept out of any error too. */
|
|
309
|
+
export function refreshGrant(meta: ServerMetadata, p: { refreshToken: string; accessToken?: string | null; clientId: string; resource: string }, allow: string[]): Promise<Tokens> {
|
|
310
|
+
return tokenRequest(meta, { grant_type: 'refresh_token', refresh_token: p.refreshToken, client_id: p.clientId, resource: p.resource }, allow, [p.accessToken]);
|
|
311
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { request as httpRequest, type IncomingHttpHeaders } from 'node:http';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import { allowance, guardedLookup, refusedAddress } from './ssrf.ts';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Every request the server makes for an OAuth connector (metadata, registration, tokens, MCP) goes through here:
|
|
8
|
+
* HTTPS only (plain HTTP just to an allowed loopback address, for a server on this machine), every address checked
|
|
9
|
+
* as it is connected to, an absolute deadline, a bounded body, and no redirects followed, so a token or code is
|
|
10
|
+
* never sent anywhere but where it was addressed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const OUTBOUND_TIMEOUT_MS = 10_000;
|
|
14
|
+
export const OUTBOUND_MAX_BYTES = 256 * 1024;
|
|
15
|
+
|
|
16
|
+
export type OutboundFailure = 'refused' | 'network' | 'timeout' | 'too_large' | 'redirect';
|
|
17
|
+
|
|
18
|
+
export class OutboundError extends Error {
|
|
19
|
+
readonly kind: OutboundFailure;
|
|
20
|
+
/** The request may have reached the server (it was sent in full): a token refresh may have happened. */
|
|
21
|
+
readonly sent: boolean;
|
|
22
|
+
constructor(kind: OutboundFailure, message: string, sent: boolean) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.kind = kind;
|
|
25
|
+
this.sent = sent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OutboundResponse {
|
|
30
|
+
status: number;
|
|
31
|
+
headers: IncomingHttpHeaders;
|
|
32
|
+
body: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface OutboundRequest {
|
|
36
|
+
method?: 'GET' | 'POST';
|
|
37
|
+
headers?: Record<string, string>;
|
|
38
|
+
body?: string;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
maxBytes?: number;
|
|
41
|
+
/** BOTONS_OAUTH_ALLOW: private hosts, addresses and CIDRs these servers may be on. */
|
|
42
|
+
allow: string[];
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const LOOPBACK = /^(127\.\d+\.\d+\.\d+|::1|localhost)$/i;
|
|
47
|
+
|
|
48
|
+
/** Why the server must not send a request to `raw`, or null. Names are checked again on what they resolve to. */
|
|
49
|
+
export function refusedOutbound(raw: string, allowEntries: string[]): string | null {
|
|
50
|
+
let u: URL;
|
|
51
|
+
try {
|
|
52
|
+
u = new URL(raw);
|
|
53
|
+
} catch {
|
|
54
|
+
return 'not a URL';
|
|
55
|
+
}
|
|
56
|
+
if (u.username || u.password) return 'a URL with a user name or password';
|
|
57
|
+
const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
58
|
+
const allow = allowance(allowEntries);
|
|
59
|
+
const allowed = allow.names.has(host) || (isIP(host) > 0 && refusedAddress(host, allow) === null && allowedIp(host, allowEntries));
|
|
60
|
+
if (u.protocol === 'http:') {
|
|
61
|
+
if (!(LOOPBACK.test(host) && allowed)) return 'not HTTPS (plain HTTP is only for a loopback address listed in BOTONS_OAUTH_ALLOW)';
|
|
62
|
+
} else if (u.protocol !== 'https:') return 'not HTTPS';
|
|
63
|
+
if (isIP(host)) {
|
|
64
|
+
const why = refusedAddress(host, allow);
|
|
65
|
+
return why ? `${host} is ${why} (BOTONS_OAUTH_ALLOW can allow it)` : null;
|
|
66
|
+
}
|
|
67
|
+
if (/(^|\.)localhost\.?$/i.test(host) && !allow.names.has(host)) return `${host} is this machine (BOTONS_OAUTH_ALLOW can allow it)`;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether an IP literal is listed itself (or in a listed range), not just public. */
|
|
72
|
+
function allowedIp(ip: string, entries: string[]): boolean {
|
|
73
|
+
const a = allowance(entries);
|
|
74
|
+
return a.addresses.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** For messages and logs: never the path's secrets or a query. */
|
|
78
|
+
export function hostOf(raw: string): string {
|
|
79
|
+
try {
|
|
80
|
+
return new URL(raw).host;
|
|
81
|
+
} catch {
|
|
82
|
+
return '(invalid URL)';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function outbound(raw: string, opts: OutboundRequest): Promise<OutboundResponse> {
|
|
87
|
+
const refused = refusedOutbound(raw, opts.allow);
|
|
88
|
+
if (refused) return Promise.reject(new OutboundError('refused', `Won't connect to ${hostOf(raw)}: ${refused}`, false));
|
|
89
|
+
const u = new URL(raw);
|
|
90
|
+
const max = opts.maxBytes ?? OUTBOUND_MAX_BYTES;
|
|
91
|
+
const send = u.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
let sent = false;
|
|
94
|
+
let done = false;
|
|
95
|
+
const fail = (err: OutboundError) => {
|
|
96
|
+
if (done) return;
|
|
97
|
+
done = true;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
100
|
+
req.destroy();
|
|
101
|
+
reject(err);
|
|
102
|
+
};
|
|
103
|
+
const req = send(u, {
|
|
104
|
+
method: opts.method ?? 'GET',
|
|
105
|
+
headers: { ...opts.headers, ...(opts.body !== undefined ? { 'content-length': String(Buffer.byteLength(opts.body)) } : {}) },
|
|
106
|
+
lookup: guardedLookup(allowance(opts.allow)),
|
|
107
|
+
agent: false,
|
|
108
|
+
});
|
|
109
|
+
const timer = setTimeout(() => fail(new OutboundError('timeout', `${u.host} did not answer within ${Math.round((opts.timeoutMs ?? OUTBOUND_TIMEOUT_MS) / 1000)} seconds`, sent)), opts.timeoutMs ?? OUTBOUND_TIMEOUT_MS);
|
|
110
|
+
const onAbort = () => fail(new OutboundError('network', 'Stopped', sent));
|
|
111
|
+
if (opts.signal?.aborted) return onAbort();
|
|
112
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
113
|
+
req.on('finish', () => (sent = true));
|
|
114
|
+
req.on('error', (err: NodeJS.ErrnoException) => {
|
|
115
|
+
const refusedAddr = err.code === 'EREFUSEDADDRESS';
|
|
116
|
+
fail(new OutboundError(refusedAddr ? 'refused' : 'network', refusedAddr ? `Won't connect: ${err.message.replace(/ \(BOTONS_CALLBACK_ALLOW can allow it\)/, ' (BOTONS_OAUTH_ALLOW can allow it)')}` : `Could not reach ${u.host}: ${err.code ?? err.message}`, sent));
|
|
117
|
+
});
|
|
118
|
+
req.on('response', (res) => {
|
|
119
|
+
const status = res.statusCode ?? 0;
|
|
120
|
+
if (status >= 300 && status < 400) {
|
|
121
|
+
res.resume();
|
|
122
|
+
return fail(new OutboundError('redirect', `${u.host} answered with a redirect (${status}), which is not followed`, true));
|
|
123
|
+
}
|
|
124
|
+
const chunks: Buffer[] = [];
|
|
125
|
+
let size = 0;
|
|
126
|
+
res.on('data', (c: Buffer) => {
|
|
127
|
+
size += c.length;
|
|
128
|
+
if (size > max) return fail(new OutboundError('too_large', `${u.host} sent more than ${Math.round(max / 1024)} KiB`, true));
|
|
129
|
+
chunks.push(c);
|
|
130
|
+
});
|
|
131
|
+
res.on('error', () => fail(new OutboundError('network', `${u.host} cut the answer short`, true)));
|
|
132
|
+
res.on('end', () => {
|
|
133
|
+
if (done) return;
|
|
134
|
+
done = true;
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
137
|
+
resolve({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
req.end(opts.body);
|
|
141
|
+
});
|
|
142
|
+
}
|