@corenel/mcp 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 +93 -0
- package/dist/asAgentTools.d.ts +12 -0
- package/dist/asAgentTools.d.ts.map +1 -0
- package/dist/asAgentTools.js +71 -0
- package/dist/asAgentTools.js.map +1 -0
- package/dist/auth.d.ts +25 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +97 -0
- package/dist/auth.js.map +1 -0
- package/dist/cache.d.ts +17 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +81 -0
- package/dist/cache.js.map +1 -0
- package/dist/config.d.ts +71 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +239 -0
- package/dist/config.js.map +1 -0
- package/dist/gatedAgentTools.d.ts +12 -0
- package/dist/gatedAgentTools.d.ts.map +1 -0
- package/dist/gatedAgentTools.js +36 -0
- package/dist/gatedAgentTools.js.map +1 -0
- package/dist/httpClient.d.ts +47 -0
- package/dist/httpClient.d.ts.map +1 -0
- package/dist/httpClient.js +196 -0
- package/dist/httpClient.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/discovery.d.ts +55 -0
- package/dist/oauth/discovery.d.ts.map +1 -0
- package/dist/oauth/discovery.js +211 -0
- package/dist/oauth/discovery.js.map +1 -0
- package/dist/oauth/login.d.ts +41 -0
- package/dist/oauth/login.d.ts.map +1 -0
- package/dist/oauth/login.js +206 -0
- package/dist/oauth/login.js.map +1 -0
- package/dist/oauth/loopback.d.ts +58 -0
- package/dist/oauth/loopback.d.ts.map +1 -0
- package/dist/oauth/loopback.js +165 -0
- package/dist/oauth/loopback.js.map +1 -0
- package/dist/oauth/pkce.d.ts +45 -0
- package/dist/oauth/pkce.d.ts.map +1 -0
- package/dist/oauth/pkce.js +67 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/register.d.ts +34 -0
- package/dist/oauth/register.d.ts.map +1 -0
- package/dist/oauth/register.js +57 -0
- package/dist/oauth/register.js.map +1 -0
- package/dist/oauth/tokens.d.ts +62 -0
- package/dist/oauth/tokens.d.ts.map +1 -0
- package/dist/oauth/tokens.js +118 -0
- package/dist/oauth/tokens.js.map +1 -0
- package/dist/policy.d.ts +10 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +71 -0
- package/dist/policy.js.map +1 -0
- package/dist/registry.d.ts +124 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +265 -0
- package/dist/registry.js.map +1 -0
- package/dist/timedFetch.d.ts +10 -0
- package/dist/timedFetch.d.ts.map +1 -0
- package/dist/timedFetch.js +39 -0
- package/dist/timedFetch.js.map +1 -0
- package/dist/toolNames.d.ts +2 -0
- package/dist/toolNames.d.ts.map +1 -0
- package/dist/toolNames.js +14 -0
- package/dist/toolNames.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface AuthMetadata {
|
|
2
|
+
authorizationEndpoint: string;
|
|
3
|
+
tokenEndpoint: string;
|
|
4
|
+
registrationEndpoint?: string;
|
|
5
|
+
scopesSupported?: string[];
|
|
6
|
+
}
|
|
7
|
+
/** Shape of a fetch Response this module actually uses -- narrow on purpose
|
|
8
|
+
* so the injected fetch in tests can return a minimal stub. */
|
|
9
|
+
type MinimalResponse = {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
status: number;
|
|
12
|
+
headers: {
|
|
13
|
+
get(name: string): string | null;
|
|
14
|
+
};
|
|
15
|
+
json(): Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
type FetchLike = (url: string, init?: {
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
}) => Promise<MinimalResponse>;
|
|
20
|
+
/**
|
|
21
|
+
* Discover the authorization server an MCP server trusts.
|
|
22
|
+
*
|
|
23
|
+
* Tries, in order:
|
|
24
|
+
* 1. GET `<origin>/.well-known/oauth-protected-resource` directly.
|
|
25
|
+
* 2. GET the MCP URL itself; if it 401s with a `WWW-Authenticate:
|
|
26
|
+
* Bearer resource_metadata="<url>"` header, follow that URL instead --
|
|
27
|
+
* this is the MORE RELIABLE path when present, since it is the server
|
|
28
|
+
* naming its own document rather than us guessing the well-known path
|
|
29
|
+
* relative to its origin.
|
|
30
|
+
*
|
|
31
|
+
* Returns `null` when NEITHER path yields a document. That is not an error:
|
|
32
|
+
* it means the server takes a static token (headers credential), which is a
|
|
33
|
+
* fully supported configuration -- see McpHeadersCredential in ../types.ts.
|
|
34
|
+
* Callers must not report a null return as a failure.
|
|
35
|
+
*/
|
|
36
|
+
export interface DiscoveredAuthServer {
|
|
37
|
+
authServer: string;
|
|
38
|
+
resource: string;
|
|
39
|
+
/** The RESOURCE's `scopes_supported` (RFC 9728 s2) -- authoritative for this
|
|
40
|
+
* resource, and not the same list as the authorization server's own. */
|
|
41
|
+
scopes?: string[];
|
|
42
|
+
}
|
|
43
|
+
export declare function discoverAuthServer(mcpUrl: string, fetchImpl?: FetchLike): Promise<DiscoveredAuthServer | null>;
|
|
44
|
+
/**
|
|
45
|
+
* Fetch and validate an authorization server's own metadata (RFC 8414).
|
|
46
|
+
*
|
|
47
|
+
* A document missing `authorization_endpoint` or `token_endpoint` is rejected
|
|
48
|
+
* outright, naming `authServer` in the error, rather than returned as a
|
|
49
|
+
* partial `AuthMetadata` -- an authorization server we cannot get a token
|
|
50
|
+
* from is useless, and failing here with a clear message beats failing later
|
|
51
|
+
* inside a token exchange with no context about which server was at fault.
|
|
52
|
+
*/
|
|
53
|
+
export declare function discoverAuthMetadata(authServer: string, fetchImpl?: FetchLike): Promise<AuthMetadata>;
|
|
54
|
+
export {};
|
|
55
|
+
//# sourceMappingURL=discovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../src/oauth/discovery.ts"],"names":[],"mappings":"AAmBA,MAAM,WAAW,YAAY;IAC3B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B;AAED;gEACgE;AAChE,KAAK,eAAe,GAAG;IACrB,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IAC9C,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1B,CAAC;AAEF,KAAK,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;AA8ExG;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB;6EACyE;IACzE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,SAAyC,GACnD,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CA4CtC;AAiCD;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,EAClB,SAAS,GAAE,SAAyC,GACnD,OAAO,CAAC,YAAY,CAAC,CAmCvB"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/* FINDING AN MCP SERVER'S AUTHORIZATION SERVER, AND THAT SERVER'S ENDPOINTS.
|
|
2
|
+
*
|
|
3
|
+
* This is discovery ONLY -- it answers "who do I ask for a token, and where".
|
|
4
|
+
* It does not register a client, run PKCE, or exchange/refresh a token; those
|
|
5
|
+
* are later tasks that consume `AuthMetadata`'s `authorizationEndpoint` /
|
|
6
|
+
* `tokenEndpoint`. Keeping this module to just the two GETs means a later
|
|
7
|
+
* change to the login flow never has to touch the part that already works.
|
|
8
|
+
*
|
|
9
|
+
* `fetch` is injected (last parameter, default global fetch) so tests never
|
|
10
|
+
* open a socket -- see discovery.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
/** RFC 9728 well-known path, resolved against the MCP server's own origin. */
|
|
13
|
+
const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource';
|
|
14
|
+
/** RFC 8414 well-known path, resolved against the authorization server's
|
|
15
|
+
* issuer URL. */
|
|
16
|
+
const AUTH_SERVER_METADATA_PATH = '/.well-known/oauth-authorization-server';
|
|
17
|
+
/** GET `url` as JSON, or null on any failure (network error, non-2xx, not
|
|
18
|
+
* JSON, not an object). Discovery documents are best-effort by design --
|
|
19
|
+
* everywhere this is called, a missing document is a legitimate outcome
|
|
20
|
+
* (see the design note on `discoverAuthServer`), not a bug to surface. */
|
|
21
|
+
async function tryFetchJson(url, fetchImpl) {
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await fetchImpl(url);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
return null;
|
|
31
|
+
let body;
|
|
32
|
+
try {
|
|
33
|
+
body = await res.json();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
39
|
+
return null;
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
/** Pull the first `authorization_servers` entry out of a parsed protected-
|
|
43
|
+
* resource document, or null if the document doesn't have one. */
|
|
44
|
+
function firstAuthServer(doc) {
|
|
45
|
+
const list = doc.authorization_servers;
|
|
46
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
47
|
+
return null;
|
|
48
|
+
const first = list[0];
|
|
49
|
+
return typeof first === 'string' && first.length > 0 ? first : null;
|
|
50
|
+
}
|
|
51
|
+
/** A discovery document's `scopes_supported`, or undefined unless it is a
|
|
52
|
+
* non-empty list of strings.
|
|
53
|
+
*
|
|
54
|
+
* Undefined rather than `[]` on purpose: callers distinguish "this resource
|
|
55
|
+
* advertises no scopes" (monday, legitimately) from "it advertises these",
|
|
56
|
+
* and an empty array would make the first look like the second and put an
|
|
57
|
+
* empty `scope=` on the authorization URL. */
|
|
58
|
+
function scopesFrom(doc) {
|
|
59
|
+
const list = doc.scopes_supported;
|
|
60
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
return list.every((s) => typeof s === 'string') ? list : undefined;
|
|
63
|
+
}
|
|
64
|
+
/** Build the discovery result, carrying the resource's own scope list. */
|
|
65
|
+
function resourceResult(doc, authServer, mcpUrl) {
|
|
66
|
+
const scopes = scopesFrom(doc);
|
|
67
|
+
return { authServer, resource: resourceFor(doc, mcpUrl), ...(scopes ? { scopes } : {}) };
|
|
68
|
+
}
|
|
69
|
+
/** Extract the `resource_metadata` URL from a `WWW-Authenticate: Bearer
|
|
70
|
+
* resource_metadata="..."` header value, or null if the header is absent or
|
|
71
|
+
* doesn't carry that parameter. RFC 9728 section 5.1. */
|
|
72
|
+
function resourceMetadataUrlFromHeader(headerValue) {
|
|
73
|
+
if (!headerValue)
|
|
74
|
+
return null;
|
|
75
|
+
const match = /resource_metadata="([^"]+)"/.exec(headerValue);
|
|
76
|
+
return match ? match[1] : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The RFC 8707 resource indicator to bind a token to -- NEVER the address of
|
|
80
|
+
* the metadata document that was just fetched.
|
|
81
|
+
*
|
|
82
|
+
* A protected-resource document may name itself via its own `resource` field
|
|
83
|
+
* (RFC 9728 section 2, the canonical identifier for the resource server).
|
|
84
|
+
* When it does, that value wins. When it doesn't, `mcpUrl` -- the MCP server
|
|
85
|
+
* address `discoverAuthServer` was actually asked about -- is the only other
|
|
86
|
+
* value that identifies the resource server itself; the `.well-known`
|
|
87
|
+
* document URL identifies the METADATA, not the resource, and a token bound
|
|
88
|
+
* to it is a token the resource server has no reason to recognize as its
|
|
89
|
+
* own.
|
|
90
|
+
*/
|
|
91
|
+
function resourceFor(doc, mcpUrl) {
|
|
92
|
+
return typeof doc.resource === 'string' && doc.resource.length > 0 ? doc.resource : mcpUrl;
|
|
93
|
+
}
|
|
94
|
+
export async function discoverAuthServer(mcpUrl, fetchImpl = fetch) {
|
|
95
|
+
const origin = new URL(mcpUrl).origin;
|
|
96
|
+
const wellKnownUrl = new URL(PROTECTED_RESOURCE_PATH, origin).toString();
|
|
97
|
+
const direct = await tryFetchJson(wellKnownUrl, fetchImpl);
|
|
98
|
+
if (direct) {
|
|
99
|
+
const authServer = firstAuthServer(direct);
|
|
100
|
+
if (authServer)
|
|
101
|
+
return resourceResult(direct, authServer, mcpUrl);
|
|
102
|
+
}
|
|
103
|
+
// No document at the well-known path (or it didn't name an auth server) --
|
|
104
|
+
// fall back to asking the MCP endpoint itself and reading the 401 hint.
|
|
105
|
+
const viaHint = await hintedResource(mcpUrl, fetchImpl);
|
|
106
|
+
if (viaHint) {
|
|
107
|
+
const authServer = firstAuthServer(viaHint);
|
|
108
|
+
if (authServer)
|
|
109
|
+
return resourceResult(viaHint, authServer, mcpUrl);
|
|
110
|
+
}
|
|
111
|
+
/* LAST RESORT: the resource may BE its own authorization server.
|
|
112
|
+
*
|
|
113
|
+
* Nothing above found a protected-resource document, which used to end the
|
|
114
|
+
* search and report null -- and a null return means "static token only", so
|
|
115
|
+
* the user was told the service has no automatic sign-in. Measured
|
|
116
|
+
* 2026-09-14, that was false for two of our catalogue: Atlassian and
|
|
117
|
+
* Intercom publish no RFC 9728 document and hint at none on their 401, yet
|
|
118
|
+
* both serve RFC 8414 metadata at their own origin complete with a
|
|
119
|
+
* registration_endpoint.
|
|
120
|
+
*
|
|
121
|
+
* Tried ONLY here, after both RFC 9728 routes have failed, so a server that
|
|
122
|
+
* names its authorization server is always believed over this guess. That
|
|
123
|
+
* ordering is load-bearing: Trello's issuer is a per-tenant path on
|
|
124
|
+
* auth.atlassian.com, and that host ALSO answers the bare root well-known
|
|
125
|
+
* with a different issuer carrying no registration_endpoint. Letting this
|
|
126
|
+
* pre-empt a named authorization server would cost Trello its DCR.
|
|
127
|
+
*
|
|
128
|
+
* The origin is used as the issuer only when the document actually proves
|
|
129
|
+
* itself one, by carrying an authorization_endpoint. */
|
|
130
|
+
const own = await tryFetchJson(authServerMetadataUrl(origin), fetchImpl);
|
|
131
|
+
if (own && typeof own.authorization_endpoint === 'string' && own.authorization_endpoint.length > 0) {
|
|
132
|
+
const issuer = typeof own.issuer === 'string' && own.issuer.length > 0 ? own.issuer : origin;
|
|
133
|
+
return resourceResult(own, issuer, mcpUrl);
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/** The protected-resource document an MCP endpoint points at from its own 401
|
|
138
|
+
* (RFC 9728 s5.1), or null if it does not 401, does not carry the hint, or
|
|
139
|
+
* the hinted document cannot be read. */
|
|
140
|
+
async function hintedResource(mcpUrl, fetchImpl) {
|
|
141
|
+
let probe;
|
|
142
|
+
try {
|
|
143
|
+
probe = await fetchImpl(mcpUrl);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (probe.status !== 401)
|
|
149
|
+
return null;
|
|
150
|
+
const hinted = resourceMetadataUrlFromHeader(probe.headers.get('www-authenticate'));
|
|
151
|
+
if (!hinted)
|
|
152
|
+
return null;
|
|
153
|
+
return tryFetchJson(hinted, fetchImpl);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The RFC 8414 metadata URL for an issuer, with the well-known segment inserted
|
|
157
|
+
* ahead of the issuer's path.
|
|
158
|
+
*
|
|
159
|
+
* `https://host` -> `https://host/.well-known/oauth-authorization-server`
|
|
160
|
+
* `https://host/mcp` -> `https://host/.well-known/oauth-authorization-server/mcp`
|
|
161
|
+
*/
|
|
162
|
+
function authServerMetadataUrl(authServer) {
|
|
163
|
+
const u = new URL(authServer);
|
|
164
|
+
// Trailing slashes are not part of the issuer's path for this purpose; a
|
|
165
|
+
// doubled slash in the result is a 404 on servers that route strictly.
|
|
166
|
+
const issuerPath = u.pathname.replace(/\/+$/, '');
|
|
167
|
+
return `${u.origin}${AUTH_SERVER_METADATA_PATH}${issuerPath}`;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Fetch and validate an authorization server's own metadata (RFC 8414).
|
|
171
|
+
*
|
|
172
|
+
* A document missing `authorization_endpoint` or `token_endpoint` is rejected
|
|
173
|
+
* outright, naming `authServer` in the error, rather than returned as a
|
|
174
|
+
* partial `AuthMetadata` -- an authorization server we cannot get a token
|
|
175
|
+
* from is useless, and failing here with a clear message beats failing later
|
|
176
|
+
* inside a token exchange with no context about which server was at fault.
|
|
177
|
+
*/
|
|
178
|
+
export async function discoverAuthMetadata(authServer, fetchImpl = fetch) {
|
|
179
|
+
/* RFC 8414 section 3.1: the well-known segment is INSERTED between the
|
|
180
|
+
* issuer's host and its path, not appended and not substituted. For issuer
|
|
181
|
+
* `https://auth.monday.com/mcp` the document is at
|
|
182
|
+
* `https://auth.monday.com/.well-known/oauth-authorization-server/mcp`.
|
|
183
|
+
*
|
|
184
|
+
* `new URL('/.well-known/...', issuer)` gets this wrong in the quiet way: a
|
|
185
|
+
* LEADING SLASH is root-relative, so the issuer's own `/mcp` is discarded and
|
|
186
|
+
* we ask the root for a document scoped to something else. monday serves both
|
|
187
|
+
* with identical endpoints, so it resolved by luck; a host serving per-tenant
|
|
188
|
+
* documents would have sent the user to another tenant's authorize endpoint.
|
|
189
|
+
*
|
|
190
|
+
* The root form is still tried second, because an issuer with no path
|
|
191
|
+
* produces the same URL either way and some servers only publish there. */
|
|
192
|
+
const doc = await tryFetchJson(authServerMetadataUrl(authServer), fetchImpl)
|
|
193
|
+
?? await tryFetchJson(new URL(AUTH_SERVER_METADATA_PATH, authServer).toString(), fetchImpl);
|
|
194
|
+
if (!doc) {
|
|
195
|
+
throw new Error(`No OAuth authorization server metadata found at ${authServer}`);
|
|
196
|
+
}
|
|
197
|
+
const authorizationEndpoint = doc.authorization_endpoint;
|
|
198
|
+
const tokenEndpoint = doc.token_endpoint;
|
|
199
|
+
if (typeof authorizationEndpoint !== 'string' || authorizationEndpoint.length === 0) {
|
|
200
|
+
throw new Error(`Authorization server ${authServer} metadata is missing authorization_endpoint`);
|
|
201
|
+
}
|
|
202
|
+
if (typeof tokenEndpoint !== 'string' || tokenEndpoint.length === 0) {
|
|
203
|
+
throw new Error(`Authorization server ${authServer} metadata is missing token_endpoint`);
|
|
204
|
+
}
|
|
205
|
+
const registrationEndpoint = typeof doc.registration_endpoint === 'string' ? doc.registration_endpoint : undefined;
|
|
206
|
+
const scopesSupported = Array.isArray(doc.scopes_supported) && doc.scopes_supported.every((s) => typeof s === 'string')
|
|
207
|
+
? doc.scopes_supported
|
|
208
|
+
: undefined;
|
|
209
|
+
return { authorizationEndpoint, tokenEndpoint, registrationEndpoint, scopesSupported };
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.js","sourceRoot":"","sources":["../../src/oauth/discovery.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { McpServer } from '../types';
|
|
2
|
+
/** Why a sign-in did not happen, when it did not. */
|
|
3
|
+
export type McpLoginOutcome = {
|
|
4
|
+
ok: true;
|
|
5
|
+
}
|
|
6
|
+
/** The server advertises no authorization server: it wants a static token. */
|
|
7
|
+
| {
|
|
8
|
+
ok: false;
|
|
9
|
+
reason: 'no-oauth';
|
|
10
|
+
message: string;
|
|
11
|
+
authServer?: undefined;
|
|
12
|
+
}
|
|
13
|
+
/** It advertises one, but that server will not register a client for us. */
|
|
14
|
+
| {
|
|
15
|
+
ok: false;
|
|
16
|
+
reason: 'no-dcr';
|
|
17
|
+
message: string;
|
|
18
|
+
authServer: string;
|
|
19
|
+
}
|
|
20
|
+
/** Something broke mid-flow. */
|
|
21
|
+
| {
|
|
22
|
+
ok: false;
|
|
23
|
+
reason: 'failed';
|
|
24
|
+
message: string;
|
|
25
|
+
authServer?: undefined;
|
|
26
|
+
};
|
|
27
|
+
export interface McpLoginDeps {
|
|
28
|
+
fetchImpl?: typeof fetch;
|
|
29
|
+
openBrowser?: (url: string) => void | Promise<void>;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
/** The consent URL, as soon as it is built and before the browser opens.
|
|
32
|
+
*
|
|
33
|
+
* Safe to show in full: it carries a client id, a redirect URI, a code
|
|
34
|
+
* CHALLENGE (never the verifier) and a one-time state. Callers surface it so
|
|
35
|
+
* a user whose browser did not open has something to click -- and so a user
|
|
36
|
+
* driving a REMOTE daemon can see the address they must open on THAT
|
|
37
|
+
* machine, since the redirect lands on its loopback and nowhere else. */
|
|
38
|
+
onAuthUrl?: (url: string) => void;
|
|
39
|
+
}
|
|
40
|
+
export declare function loginToMcpServer(stateDir: string, server: McpServer, deps?: McpLoginDeps): Promise<McpLoginOutcome>;
|
|
41
|
+
//# sourceMappingURL=login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/oauth/login.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAuB1C,qDAAqD;AACrD,MAAM,MAAM,eAAe,GACvB;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE;AACd,8EAA8E;GAC5E;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,SAAS,CAAA;CAAE;AAC5E,4EAA4E;GAC1E;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE;AACtE,gCAAgC;GAC9B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,SAAS,CAAA;CAAE,CAAC;AAE7E,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB;;;;;;8EAM0E;IAC1E,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,wBAAsB,gBAAgB,CACpC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,SAAS,EACjB,IAAI,GAAE,YAAiB,GACtB,OAAO,CAAC,eAAe,CAAC,CAiN1B"}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { writeMcpCredential, readMcpClient, writeMcpClient, clearMcpClient } from '../config.js';
|
|
2
|
+
import { discoverAuthServer, discoverAuthMetadata } from './discovery.js';
|
|
3
|
+
import { registerClient } from './register.js';
|
|
4
|
+
import { makePkce, makeState, authorizationUrl } from './pkce.js';
|
|
5
|
+
import { awaitRedirect, getFreePort, portFree } from './loopback.js';
|
|
6
|
+
import { exchangeCode } from './tokens.js';
|
|
7
|
+
/* IMPORTED LAZILY, AND THAT IS LOAD-BEARING. This module is reached from
|
|
8
|
+
* wirePort.ts, which the DAEMON constructs at startup -- so a module-scope
|
|
9
|
+
* `import ... from '@corenel/tools-node'` puts node:child_process into the
|
|
10
|
+
* daemon's startup graph, for a function only ever called once a human clicks
|
|
11
|
+
* Sign in.
|
|
12
|
+
*
|
|
13
|
+
* Measured, not assumed: adding it at module scope took p2c.e2e from 3/3 to
|
|
14
|
+
* 1/3 on this machine. The daemon is transpiled on the fly, so every module on
|
|
15
|
+
* the boot path costs real milliseconds, and a timing-sensitive e2e is the
|
|
16
|
+
* thing that notices first. */
|
|
17
|
+
const defaultOpenBrowser = async (url) => {
|
|
18
|
+
const { openBrowser } = await import('@corenel/tools-node');
|
|
19
|
+
await openBrowser(url);
|
|
20
|
+
};
|
|
21
|
+
export async function loginToMcpServer(stateDir, server, deps = {}) {
|
|
22
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
23
|
+
const openBrowserFn = deps.openBrowser ?? defaultOpenBrowser;
|
|
24
|
+
const now = deps.now ?? Date.now;
|
|
25
|
+
/* Tears the redirect listener down on every exit path that is NOT a
|
|
26
|
+
* completed redirect -- see the catch block below, and loopback.ts's
|
|
27
|
+
* `signal` doc. Aborting before `awaitRedirect` is ever called (nothing
|
|
28
|
+
* has listened yet) or after it has already settled (the listener is
|
|
29
|
+
* already closed) is a harmless no-op either way. */
|
|
30
|
+
const redirectAbort = new AbortController();
|
|
31
|
+
/* SIGN-IN ADDRESS, which is not always the call address. For a relayed
|
|
32
|
+
* server, `url` points at the Corenel relay -- a POST-only route whose
|
|
33
|
+
* upstream answers 405 to the GET that discovery probes with, so probing it
|
|
34
|
+
* concludes the server offers no OAuth at all. `authUrl` is the hosted
|
|
35
|
+
* service's own endpoint, whose gate leaves the OAuth paths public precisely
|
|
36
|
+
* so this can happen directly. Every other server has one address and this
|
|
37
|
+
* falls back to it. */
|
|
38
|
+
const signInUrl = server.authUrl ?? server.url;
|
|
39
|
+
try {
|
|
40
|
+
const discovered = await discoverAuthServer(signInUrl, fetchImpl);
|
|
41
|
+
if (!discovered) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: 'no-oauth',
|
|
45
|
+
message: `"${server.name}" does not advertise an OAuth authorization server. If it takes a static token instead, store that as a header.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const asMeta = await discoverAuthMetadata(discovered.authServer, fetchImpl);
|
|
49
|
+
/* THE RESOURCE'S SCOPE LIST WINS over the authorization server's.
|
|
50
|
+
*
|
|
51
|
+
* RFC 9728 s2: a protected-resource document's `scopes_supported` is the
|
|
52
|
+
* list for THAT resource. RFC 8414's is everything the authorization
|
|
53
|
+
* server offers across every resource it fronts. Reading only the latter
|
|
54
|
+
* was wrong in both directions at once -- Trello, Canva, PayPal and Asana
|
|
55
|
+
* advertise scopes only on the resource, so they were sent to consent with
|
|
56
|
+
* NO scope at all, while Dropbox's authorization server lists 39 and we
|
|
57
|
+
* asked for all of them.
|
|
58
|
+
*
|
|
59
|
+
* The empty-list case is already excluded upstream (`scopes` is undefined,
|
|
60
|
+
* never `[]`), so a resource that genuinely advertises none -- monday --
|
|
61
|
+
* still falls through to the authorization server's list and then to no
|
|
62
|
+
* `scope` parameter, which is what it had before. */
|
|
63
|
+
const meta = discovered.scopes
|
|
64
|
+
? { ...asMeta, scopesSupported: discovered.scopes }
|
|
65
|
+
: asMeta;
|
|
66
|
+
/* The port must be known BEFORE the authorization URL is built:
|
|
67
|
+
* `redirect_uri` has to name the exact address the listener will bind, and
|
|
68
|
+
* the authorization server will refuse a mismatch. */
|
|
69
|
+
/* A PINNED port when the stored client carries one, an ephemeral port
|
|
70
|
+
* otherwise. RFC 8252 s7.3 obliges an authorization server to accept any
|
|
71
|
+
* port here, and most do -- Entra ignores it entirely, which is why one
|
|
72
|
+
* registered `http://localhost` covers every attempt. Slack matches
|
|
73
|
+
* redirect URLs exactly, so an operator who registered one URL needs the
|
|
74
|
+
* daemon to keep landing on that exact port. */
|
|
75
|
+
const cached = await readMcpClient(stateDir, server.name);
|
|
76
|
+
const port = cached?.redirectPort ?? await getFreePort();
|
|
77
|
+
/* A pinned port that something else already holds cannot be quietly swapped
|
|
78
|
+
* for a free one: the whole reason it is pinned is that a server matches the
|
|
79
|
+
* redirect URI exactly, so a different port fails the same way, one step
|
|
80
|
+
* later and less legibly. Say which port and why, rather than letting the
|
|
81
|
+
* listener reject with a bare EADDRINUSE from inside the flow. */
|
|
82
|
+
if (cached?.redirectPort !== undefined && !(await portFree(cached.redirectPort))) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: 'failed',
|
|
86
|
+
message: `the redirect port ${cached.redirectPort} is in use, and "${server.name}" is registered against exactly that port `
|
|
87
|
+
+ '-- free it and sign in again',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/* `localhost`, NOT `127.0.0.1`, and the difference is entirely about what
|
|
91
|
+
* authorization servers will let you register.
|
|
92
|
+
*
|
|
93
|
+
* Entra's portal REFUSES to add an `http://127.0.0.1` reply URL at all --
|
|
94
|
+
* it can only be added by hand-editing the application manifest's
|
|
95
|
+
* `replyUrlsWithType`. `http://localhost` it accepts directly, and it
|
|
96
|
+
* IGNORES THE PORT when matching, so a single registered
|
|
97
|
+
* `http://localhost` covers every ephemeral port this picks. Sending
|
|
98
|
+
* 127.0.0.1 meant every user had to do manifest surgery before a sign-in
|
|
99
|
+
* could work, and a mismatch there bounces the browser back to the account
|
|
100
|
+
* picker with nothing explaining why.
|
|
101
|
+
*
|
|
102
|
+
* The listener still binds loopback and nothing else -- see loopback.ts,
|
|
103
|
+
* which now answers on BOTH 127.0.0.1 and ::1 so it does not matter which
|
|
104
|
+
* one the browser resolves `localhost` to. */
|
|
105
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
106
|
+
/* REUSE THE CLIENT WE ALREADY REGISTERED, if any.
|
|
107
|
+
*
|
|
108
|
+
* This used to mint a brand-new OAuth client on EVERY sign-in, so each
|
|
109
|
+
* click on Sign in created another client at the vendor. Servers rate-limit
|
|
110
|
+
* that -- Figma starts answering 403 after a few -- and the refusal arrives
|
|
111
|
+
* as a sign-in failure on a service that works perfectly, with nothing to
|
|
112
|
+
* suggest our own retrying caused it.
|
|
113
|
+
*
|
|
114
|
+
* Safe across the random loopback port picked per attempt: RFC 8252 s7.3
|
|
115
|
+
* requires an authorization server to accept ANY port for a loopback
|
|
116
|
+
* redirect URI. The stored `authServer` guards the one case where reuse
|
|
117
|
+
* would be wrong -- a server that later points somewhere else, where a
|
|
118
|
+
* cached client_id would be presented to something that never issued it. */
|
|
119
|
+
const registered = cached && cached.authServer === discovered.authServer
|
|
120
|
+
? { clientId: cached.clientId, ...(cached.clientSecret ? { clientSecret: cached.clientSecret } : {}) }
|
|
121
|
+
: await registerClient(meta, redirectUri, fetchImpl);
|
|
122
|
+
if (registered && !cached) {
|
|
123
|
+
await writeMcpClient(stateDir, server.name, {
|
|
124
|
+
clientId: registered.clientId,
|
|
125
|
+
...(registered.clientSecret ? { clientSecret: registered.clientSecret } : {}),
|
|
126
|
+
authServer: discovered.authServer,
|
|
127
|
+
/* PIN THE PORT WE JUST REGISTERED WITH.
|
|
128
|
+
*
|
|
129
|
+
* RFC 8252 s7.3 obliges an authorization server to accept any port on a
|
|
130
|
+
* loopback redirect, and most do -- but not all, and the ones that do
|
|
131
|
+
* not fail in the worst possible way. Atlassian's `auth.atlassian.com`
|
|
132
|
+
* (Trello's authorization server, distinct from `mcp.atlassian.com`)
|
|
133
|
+
* matches the redirect URI exactly. Registering with one ephemeral port
|
|
134
|
+
* and then authorizing with a fresh one on the next sign-in produced
|
|
135
|
+
* "The app's callback URL is invalid", from the vendor's own page,
|
|
136
|
+
* BEFORE any token exchange -- so nothing came back for the
|
|
137
|
+
* invalid_client recovery below to catch, and the flow simply timed out
|
|
138
|
+
* waiting for a redirect that was never going to arrive.
|
|
139
|
+
*
|
|
140
|
+
* Persisting it costs nothing where the port is ignored, and is the
|
|
141
|
+
* whole difference where it is not. The field already existed for
|
|
142
|
+
* operator-set ports (`corenel mcp set-client --redirect-port`); it was
|
|
143
|
+
* read here and never written by this path. */
|
|
144
|
+
redirectPort: port,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (!registered) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: 'no-dcr',
|
|
151
|
+
authServer: discovered.authServer,
|
|
152
|
+
message: `"${server.name}"'s authorization server (${discovered.authServer}) supports OAuth but not dynamic client registration, so no client can be created on demand. Store a static token as a header instead -- a server in this position normally issues one from its own settings.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const pkce = makePkce();
|
|
156
|
+
const state = makeState();
|
|
157
|
+
const authUrl = authorizationUrl(meta, {
|
|
158
|
+
clientId: registered.clientId,
|
|
159
|
+
redirectUri,
|
|
160
|
+
codeChallenge: pkce.challenge,
|
|
161
|
+
state,
|
|
162
|
+
resource: discovered.resource,
|
|
163
|
+
});
|
|
164
|
+
deps.onAuthUrl?.(authUrl);
|
|
165
|
+
const redirectP = awaitRedirect(port, state, { signal: redirectAbort.signal });
|
|
166
|
+
/* A passive handler attached immediately: `openBrowserFn` below (a real
|
|
167
|
+
* browser, or a test's stand-in) can settle -- and reject -- this promise
|
|
168
|
+
* before the `await redirectP` subscribes to it, which Node otherwise
|
|
169
|
+
* flags as an unhandled rejection even though that later await consumes
|
|
170
|
+
* it. */
|
|
171
|
+
redirectP.catch(() => { });
|
|
172
|
+
await openBrowserFn(authUrl);
|
|
173
|
+
const { code } = await redirectP;
|
|
174
|
+
const cred = await exchangeCode(meta, {
|
|
175
|
+
clientId: registered.clientId,
|
|
176
|
+
...(registered.clientSecret ? { clientSecret: registered.clientSecret } : {}),
|
|
177
|
+
code,
|
|
178
|
+
verifier: pkce.verifier,
|
|
179
|
+
redirectUri,
|
|
180
|
+
resource: discovered.resource,
|
|
181
|
+
authServer: discovered.authServer,
|
|
182
|
+
}, fetchImpl, now);
|
|
183
|
+
await writeMcpCredential(stateDir, server.name, cred);
|
|
184
|
+
return { ok: true };
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
/* Whatever failed -- the browser never opened, the user closed the tab,
|
|
188
|
+
* the token exchange rejected -- the redirect listener must not be left
|
|
189
|
+
* bound. It is what a pinned `redirectPort` (persisted above) would find
|
|
190
|
+
* "in use" on the very next sign-in, mistaking our own leaked listener
|
|
191
|
+
* for a foreign process and refusing a retry that should just work. */
|
|
192
|
+
redirectAbort.abort();
|
|
193
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
194
|
+
/* A REJECTED CLIENT is the one failure a stored registration can cause, and
|
|
195
|
+
* it would otherwise be permanent: the server has forgotten (or revoked)
|
|
196
|
+
* the client we keep presenting, so every future attempt reproduces this.
|
|
197
|
+
* Dropping it means the next sign-in registers afresh. Not retried inline --
|
|
198
|
+
* the browser consent has already happened by this point and cannot be
|
|
199
|
+
* replayed without the user. */
|
|
200
|
+
if (/invalid_client|unauthorized_client|client.{0,20}not.{0,10}found/i.test(message)) {
|
|
201
|
+
await clearMcpClient(stateDir, server.name).catch(() => { });
|
|
202
|
+
}
|
|
203
|
+
return { ok: false, reason: 'failed', message };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.js","sourceRoot":"","sources":["../../src/oauth/login.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface RedirectResult {
|
|
2
|
+
code: string;
|
|
3
|
+
}
|
|
4
|
+
export interface AwaitRedirectOptions {
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
/** Test-only hook: called with the address/port the listener actually
|
|
7
|
+
* bound, before any redirect arrives. `awaitRedirect`'s return value
|
|
8
|
+
* never exposes this (a caller has no legitimate need to learn the bind
|
|
9
|
+
* address after the fact), so a test that must assert "bound to
|
|
10
|
+
* 127.0.0.1, not 0.0.0.0" has no way to observe it otherwise. */
|
|
11
|
+
onListening?: (address: string, port: number) => void;
|
|
12
|
+
/** Lets a caller tear the listener down when IT gives up -- the browser
|
|
13
|
+
* never opened, the user closed the tab, anything after `openBrowser`
|
|
14
|
+
* threw. Without this the two servers below stay bound until their own
|
|
15
|
+
* five-minute timeout, and the next sign-in on the same (now-pinned, see
|
|
16
|
+
* login.ts) port finds ITS OWN orphaned listener sitting on it and
|
|
17
|
+
* refuses, mistaking itself for a foreign process.
|
|
18
|
+
*
|
|
19
|
+
* Aborting after the promise has already settled is a no-op -- `finish`
|
|
20
|
+
* is idempotent -- so a caller can always abort on the way out without
|
|
21
|
+
* checking whether the redirect already landed. */
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Listen on 127.0.0.1:`port` for exactly one OAuth redirect, and resolve
|
|
26
|
+
* with its authorization code -- or reject. Either way the listener closes
|
|
27
|
+
* before the returned promise settles.
|
|
28
|
+
*
|
|
29
|
+
* `expectedState` must equal the redirect's `state` query parameter or the
|
|
30
|
+
* request is refused and the promise rejects, with the `code` parameter
|
|
31
|
+
* never read into a variable that could be exchanged. Without this check the
|
|
32
|
+
* listener would accept a code from anyone who can reach the port, which on
|
|
33
|
+
* a shared machine is everyone with a login shell.
|
|
34
|
+
*/
|
|
35
|
+
export declare function awaitRedirect(port: number, expectedState: string, opts?: AwaitRedirectOptions): Promise<RedirectResult>;
|
|
36
|
+
/**
|
|
37
|
+
* A free ephemeral port on 127.0.0.1, for a caller to build a `redirect_uri`
|
|
38
|
+
* from before starting `awaitRedirect` on the same port -- the authorization
|
|
39
|
+
* URL needs the real port before the browser opens.
|
|
40
|
+
*
|
|
41
|
+
* There is an inherent, unavoidable race between this probe closing and the
|
|
42
|
+
* listener re-binding the same port: any "find a free port, then use it"
|
|
43
|
+
* strategy on a shared loopback interface has it. Acceptable here because
|
|
44
|
+
* this is a local, single-user, one-shot login flow, not a long-lived
|
|
45
|
+
* service placing a hard dependency on the exact port.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Whether `port` can be bound on 127.0.0.1 right now.
|
|
49
|
+
*
|
|
50
|
+
* Only meaningful for a PINNED port -- one an operator registered with a
|
|
51
|
+
* service, or one a previous registration named. An ephemeral port comes from
|
|
52
|
+
* `getFreePort` and is free by construction. The same inherent race as
|
|
53
|
+
* `getFreePort` applies and is acceptable for the same reason: this answers
|
|
54
|
+
* "is something already sitting on it", not "reserve it for me".
|
|
55
|
+
*/
|
|
56
|
+
export declare function portFree(port: number): Promise<boolean>;
|
|
57
|
+
export declare function getFreePort(): Promise<number>;
|
|
58
|
+
//# sourceMappingURL=loopback.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loopback.d.ts","sourceRoot":"","sources":["../../src/oauth/loopback.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;CACd;AAcD,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;sEAIkE;IAClE,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD;;;;;;;;;wDASoD;IACpD,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,MAAM,EACrB,IAAI,GAAE,oBAAyB,GAC9B,OAAO,CAAC,cAAc,CAAC,CA2FzB;AAED;;;;;;;;;;GAUG;AACH;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAQvD;AAED,wBAAgB,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC,CAS7C"}
|