@haikit/mcp 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -1
- package/dist/auth.d.ts +151 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +334 -0
- package/dist/auth.js.map +1 -0
- package/dist/credential.d.ts +55 -0
- package/dist/credential.d.ts.map +1 -0
- package/dist/credential.js +62 -0
- package/dist/credential.js.map +1 -0
- package/dist/errors.d.ts +24 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +44 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +511 -144
- package/dist/index.js.map +1 -1
- package/dist/oauth.d.ts +193 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +558 -0
- package/dist/oauth.js.map +1 -0
- package/dist/postgres.d.ts +25 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +97 -0
- package/dist/postgres.js.map +1 -0
- package/dist/routes.d.ts +17 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +228 -0
- package/dist/routes.js.map +1 -0
- package/dist/seal.d.ts +25 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +60 -0
- package/dist/seal.js.map +1 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +83 -0
- package/dist/store.js.map +1 -0
- package/dist/transports.d.ts +17 -3
- package/dist/transports.d.ts.map +1 -1
- package/dist/transports.js +13 -3
- package/dist/transports.js.map +1 -1
- package/package.json +6 -2
- package/src/auth.ts +466 -0
- package/src/credential.ts +78 -0
- package/src/errors.ts +26 -2
- package/src/index.ts +555 -158
- package/src/oauth.ts +659 -0
- package/src/postgres.ts +127 -0
- package/src/routes.ts +243 -0
- package/src/seal.ts +62 -0
- package/src/store.ts +118 -0
- package/src/transports.ts +34 -4
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
OAuthError,
|
|
4
|
+
OAuthErrorCode,
|
|
5
|
+
assertSecureTokenEndpoint,
|
|
6
|
+
auth,
|
|
7
|
+
computeScopeUnion,
|
|
8
|
+
discoverOAuthServerInfo,
|
|
9
|
+
exchangeAuthorization,
|
|
10
|
+
extractWWWAuthenticateParams,
|
|
11
|
+
refreshAuthorization,
|
|
12
|
+
selectClientAuthMethod,
|
|
13
|
+
selectResourceURL,
|
|
14
|
+
type AuthProvider,
|
|
15
|
+
type AuthorizationServerMetadata,
|
|
16
|
+
type FetchLike,
|
|
17
|
+
type OAuthClientMetadata,
|
|
18
|
+
type OAuthClientProvider,
|
|
19
|
+
type OAuthDiscoveryState,
|
|
20
|
+
type StoredOAuthClientInformation,
|
|
21
|
+
type StoredOAuthTokens,
|
|
22
|
+
} from "@modelcontextprotocol/client";
|
|
23
|
+
import { LookupFailed, Lookup, Mismatch, NeedsAuth, type Credential } from "./credential.js";
|
|
24
|
+
import { binding, type Sealer } from "./seal.js";
|
|
25
|
+
import type { CredentialStore } from "./store.js";
|
|
26
|
+
|
|
27
|
+
/** How long a connect flow lasts: from the connect route to the callback. */
|
|
28
|
+
export const FLOW_MS = 600_000;
|
|
29
|
+
/** A refresh lease lasts this many `timeoutMs`: past the refresh request, which `timeoutMs` bounds, and the write after it. */
|
|
30
|
+
const LEASE_TIMEOUTS = 2;
|
|
31
|
+
/** How often the store is read while another process holds the refresh lease. */
|
|
32
|
+
const LEASE_POLL_MS = 250;
|
|
33
|
+
/** Tries at writing a refreshed credential while the store fails, and the pause between them. */
|
|
34
|
+
const WRITE_TRIES = 3;
|
|
35
|
+
const WRITE_PAUSE_MS = 250;
|
|
36
|
+
|
|
37
|
+
/** A wait that doesn't keep the process going. */
|
|
38
|
+
const pause = (ms: number) =>
|
|
39
|
+
new Promise<void>((resolve) => {
|
|
40
|
+
setTimeout(resolve, ms).unref();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export interface OAuthServerOptions {
|
|
44
|
+
/**
|
|
45
|
+
* The authorization server this server must use, as its metadata's
|
|
46
|
+
* `issuer`. Strongly advised for a third party's server: a server names its
|
|
47
|
+
* own authorization server, and one compromised could send users to a fake
|
|
48
|
+
* sign-in page.
|
|
49
|
+
*/
|
|
50
|
+
issuer?: string;
|
|
51
|
+
/** Asked for when connecting. Default: what the server's metadata asks for. */
|
|
52
|
+
scopes?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* A client the app registered with the authorization server. Needs
|
|
55
|
+
* `issuer`. Without it, one is registered for the app: by its metadata
|
|
56
|
+
* document's URL where the authorization server takes one, else by dynamic
|
|
57
|
+
* client registration.
|
|
58
|
+
*/
|
|
59
|
+
client?: { id: string; secret?: string };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What every `oauth()` server of one `createMcpAuth` shares. */
|
|
63
|
+
export interface OAuthCore {
|
|
64
|
+
readonly store: CredentialStore;
|
|
65
|
+
readonly sealer: Sealer;
|
|
66
|
+
readonly redirectUri: string;
|
|
67
|
+
/** What a client is registered with, and the metadata document says. */
|
|
68
|
+
readonly clientMetadata: OAuthClientMetadata;
|
|
69
|
+
/** The metadata document's URL, when the redirect URI is https: an OAuth client can then be identified by it. */
|
|
70
|
+
readonly clientMetadataUrl: string | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a user's credential holds, sealed. */
|
|
74
|
+
interface Kept {
|
|
75
|
+
/** As the authorization server issued them, stamped with its `issuer`. */
|
|
76
|
+
tokens: StoredOAuthTokens & { issuer: string };
|
|
77
|
+
/** The client they were issued to: refreshed and revoked as it, whatever the store keeps for new connects. */
|
|
78
|
+
client: StoredOAuthClientInformation;
|
|
79
|
+
/** The scope granted, or asked for when the authorization server didn't say. */
|
|
80
|
+
scope: string | null;
|
|
81
|
+
/** When the user connected, epoch ms. A refresh keeps it. */
|
|
82
|
+
connectedAt: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A credential as the store holds it: the sealed value, and what it opened to. */
|
|
86
|
+
interface Held {
|
|
87
|
+
sealed: string;
|
|
88
|
+
kept: Kept;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A connect flow on its way, sealed in the store under its `state`. */
|
|
92
|
+
export interface Flow {
|
|
93
|
+
user: string;
|
|
94
|
+
server: string;
|
|
95
|
+
/** The conversation it came from, if the user owns it: `onConnected` is told. */
|
|
96
|
+
conversation: string | null;
|
|
97
|
+
/** The PKCE verifier. */
|
|
98
|
+
verifier: string;
|
|
99
|
+
/** The client the code is issued to: exchanged as it, though another connect replaced the one the store keeps. */
|
|
100
|
+
client: StoredOAuthClientInformation;
|
|
101
|
+
/** The SDK's discovery state, from the connect route: the callback is bound to its authorization server. */
|
|
102
|
+
discovery: OAuthDiscoveryState;
|
|
103
|
+
/** The scope asked for. */
|
|
104
|
+
scope: string | null;
|
|
105
|
+
expiresAt: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A server's authorization server, as discovered and checked. */
|
|
109
|
+
interface Where {
|
|
110
|
+
url: string;
|
|
111
|
+
issuer: string;
|
|
112
|
+
metadata: AuthorizationServerMetadata | undefined;
|
|
113
|
+
/** The RFC 8707 resource indicator: tokens are bound to this server. */
|
|
114
|
+
resource: string | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What a refresh refused for good: the user must connect again. */
|
|
118
|
+
const REFUSED = new Set<string>([OAuthErrorCode.InvalidGrant, OAuthErrorCode.InvalidClient, OAuthErrorCode.UnauthorizedClient]);
|
|
119
|
+
|
|
120
|
+
/** Loopback hosts, exempt from https: what the SDK exempts, and RFC 8252 does. */
|
|
121
|
+
export const isLoopback = (host: string) =>
|
|
122
|
+
host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "[::1]" || host === "::1";
|
|
123
|
+
|
|
124
|
+
/** https, or http on this machine. */
|
|
125
|
+
export const isSecure = (url: string | URL) => {
|
|
126
|
+
try {
|
|
127
|
+
const { protocol, hostname } = new URL(url);
|
|
128
|
+
return protocol === "https:" || (protocol === "http:" && isLoopback(hostname));
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The same authorization server: one trailing slash apart at most, as the SDK allows. */
|
|
135
|
+
const sameIssuer = (a: string, b: string) => a === b || a === `${b}/` || b === `${a}/`;
|
|
136
|
+
|
|
137
|
+
/** The connect flow a `state` names, once: null if there's none, it has expired, or it doesn't open. */
|
|
138
|
+
export async function takeFlow(core: OAuthCore, state: string): Promise<Flow | null> {
|
|
139
|
+
const sealed = await core.store.takeFlow(state);
|
|
140
|
+
if (sealed === null) return null;
|
|
141
|
+
const flow = core.sealer.open(sealed, binding.flow(state)) as Partial<Flow> | null;
|
|
142
|
+
if (
|
|
143
|
+
typeof flow?.user !== "string" ||
|
|
144
|
+
typeof flow.server !== "string" ||
|
|
145
|
+
typeof flow.verifier !== "string" ||
|
|
146
|
+
typeof flow.client?.client_id !== "string" ||
|
|
147
|
+
typeof flow.discovery?.authorizationServerUrl !== "string" ||
|
|
148
|
+
typeof flow.expiresAt !== "number" ||
|
|
149
|
+
flow.expiresAt <= Date.now()
|
|
150
|
+
) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return flow as Flow;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One server's OAuth: its connect route and callback, each user's
|
|
158
|
+
* credential, refreshing, and revoking on disconnect.
|
|
159
|
+
*/
|
|
160
|
+
export class OAuthServer {
|
|
161
|
+
/** The authorization server, discovered on the first refresh or revocation, or seeded by a callback. */
|
|
162
|
+
private found: Promise<Where> | null = null;
|
|
163
|
+
/** Each user's refresh on its way, which every 401 of theirs shares until it settles. */
|
|
164
|
+
private readonly refreshing = new Map<string, Promise<Held>>();
|
|
165
|
+
|
|
166
|
+
constructor(
|
|
167
|
+
readonly core: OAuthCore,
|
|
168
|
+
private readonly options: OAuthServerOptions,
|
|
169
|
+
readonly name: string,
|
|
170
|
+
private readonly url: URL,
|
|
171
|
+
private readonly baseFetch: FetchLike,
|
|
172
|
+
readonly timeoutMs: number,
|
|
173
|
+
/** Closes the user's connection to this server, so their next call dials with what's stored now. */
|
|
174
|
+
readonly forget: (userId: string) => Promise<void>,
|
|
175
|
+
) {}
|
|
176
|
+
|
|
177
|
+
/** Every request to the authorization server, bounded by `timeoutMs`. */
|
|
178
|
+
readonly fetch: FetchLike = (url, init) => {
|
|
179
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
180
|
+
return this.baseFetch(url, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout });
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
credential(user: string): Credential {
|
|
184
|
+
return new OAuthCredential(this, user);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A user's credential as the store holds it, or null if there's none, or none that opens. `known` is given back as it is if the store still holds it. */
|
|
188
|
+
async read(user: string, known: Held | null = null): Promise<Held | null> {
|
|
189
|
+
const sealed = await this.core.store.get(user, this.name);
|
|
190
|
+
if (sealed === null) return null;
|
|
191
|
+
if (sealed === known?.sealed) return known;
|
|
192
|
+
const kept = this.core.sealer.open(sealed, binding.credential(user, this.name)) as Kept | null;
|
|
193
|
+
if (
|
|
194
|
+
typeof kept?.tokens?.access_token !== "string" ||
|
|
195
|
+
typeof kept.tokens.issuer !== "string" ||
|
|
196
|
+
typeof kept.client?.client_id !== "string" ||
|
|
197
|
+
typeof kept.connectedAt !== "number"
|
|
198
|
+
) {
|
|
199
|
+
console.error(`@haikit/mcp: ${this.name}: the credential of user ${user} doesn't open: sealed with a secret that's gone, or for another row`);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return { sealed, kept };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
seal(user: string, kept: Kept): string {
|
|
206
|
+
return this.core.sealer.seal(kept, binding.credential(user, this.name));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The client configured, stamped with the pinned issuer: the SDK refuses it for any other. */
|
|
210
|
+
private configured(): StoredOAuthClientInformation | undefined {
|
|
211
|
+
const { client, issuer } = this.options;
|
|
212
|
+
return client && { client_id: client.id, ...(client.secret === undefined ? {} : { client_secret: client.secret }), issuer: issuer! };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The client to connect with at an authorization server: the one
|
|
217
|
+
* configured, or the one registered for the app. One registered for
|
|
218
|
+
* another redirect URI or metadata document, or that doesn't open, is no
|
|
219
|
+
* use, and is registered again.
|
|
220
|
+
*/
|
|
221
|
+
private async client(issuer: string): Promise<StoredOAuthClientInformation | undefined> {
|
|
222
|
+
const configured = this.configured();
|
|
223
|
+
if (configured) return configured;
|
|
224
|
+
const sealed = await this.core.store.getClient(this.name, issuer);
|
|
225
|
+
if (sealed === null) return undefined;
|
|
226
|
+
const info = this.core.sealer.open(sealed, binding.client(this.name, issuer)) as StoredOAuthClientInformation | null;
|
|
227
|
+
if (typeof info?.client_id !== "string") return undefined;
|
|
228
|
+
const fits =
|
|
229
|
+
"redirect_uris" in info && Array.isArray(info.redirect_uris)
|
|
230
|
+
? info.redirect_uris.includes(this.core.redirectUri)
|
|
231
|
+
: info.client_id === this.core.clientMetadataUrl;
|
|
232
|
+
return fits ? info : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The client a flow or credential was issued to, as it's sent: the configured one with its secret as configured now. */
|
|
236
|
+
issuedTo(client: StoredOAuthClientInformation): StoredOAuthClientInformation {
|
|
237
|
+
return this.options.client?.id === client.client_id ? this.configured()! : client;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async saveClient(issuer: string, info: StoredOAuthClientInformation): Promise<void> {
|
|
241
|
+
await this.core.store.putClient(this.name, issuer, this.core.sealer.seal(info, binding.client(this.name, issuer)));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Checks what discovery found: the pinned issuer, and an authorization
|
|
246
|
+
* endpoint the browser can be sent to. Called by the SDK straight after
|
|
247
|
+
* discovery, before it looks up or registers a client, so a server that
|
|
248
|
+
* names another authorization server gets nothing from the app.
|
|
249
|
+
*/
|
|
250
|
+
async check(state: OAuthDiscoveryState): Promise<Where> {
|
|
251
|
+
const metadata = state.authorizationServerMetadata;
|
|
252
|
+
const issuer = metadata?.issuer ?? String(state.authorizationServerUrl);
|
|
253
|
+
const pinned = this.options.issuer;
|
|
254
|
+
if (pinned !== undefined && !sameIssuer(issuer, pinned)) {
|
|
255
|
+
throw new Mismatch(`${this.name} names ${JSON.stringify(issuer)} as its authorization server, not ${JSON.stringify(pinned)}`);
|
|
256
|
+
}
|
|
257
|
+
const endpoint = metadata?.authorization_endpoint ?? new URL("/authorize", state.authorizationServerUrl).href;
|
|
258
|
+
if (!isSecure(endpoint)) throw new Mismatch(`${this.name}'s authorization server signs users in at ${JSON.stringify(endpoint)}, which isn't https`);
|
|
259
|
+
// the indicator as the metadata has it, as the SDK's auth() sends it
|
|
260
|
+
const selected = await selectResourceURL(this.url, {} as OAuthClientProvider, state.resourceMetadata);
|
|
261
|
+
const resource = selected && state.resourceMetadata ? state.resourceMetadata.resource : selected?.href;
|
|
262
|
+
return { url: String(state.authorizationServerUrl), issuer, metadata, resource };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The authorization server, which must be the one that issued a credential:
|
|
267
|
+
* found again once if it isn't, in case the server moved since.
|
|
268
|
+
*/
|
|
269
|
+
async where(issuer: string, resourceMetadataUrl?: URL): Promise<Where> {
|
|
270
|
+
for (let fresh = false; ; fresh = true) {
|
|
271
|
+
this.found ??= discoverOAuthServerInfo(this.url, { fetchFn: this.fetch, ...(resourceMetadataUrl ? { resourceMetadataUrl } : {}) })
|
|
272
|
+
.then((info) => this.check(info))
|
|
273
|
+
.catch((err) => {
|
|
274
|
+
this.found = null;
|
|
275
|
+
throw err;
|
|
276
|
+
});
|
|
277
|
+
const where = await this.found;
|
|
278
|
+
if (sameIssuer(where.issuer, issuer)) return where;
|
|
279
|
+
this.found = null;
|
|
280
|
+
if (fresh) throw new Mismatch(`${this.name} now names ${JSON.stringify(where.issuer)} as its authorization server, not ${JSON.stringify(issuer)}, which issued the credential`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The connect route: where to send the browser, with the flow stored under
|
|
286
|
+
* its `state` meanwhile. The SDK's `auth()` discovers the authorization
|
|
287
|
+
* server, registers a client if none is kept, and makes the PKCE challenge.
|
|
288
|
+
*/
|
|
289
|
+
async start(user: string, conversation: string | null, asked: string | undefined): Promise<URL> {
|
|
290
|
+
const { core } = this;
|
|
291
|
+
const state = randomBytes(32).toString("base64url");
|
|
292
|
+
let discovery: OAuthDiscoveryState | undefined;
|
|
293
|
+
let verifier: string | undefined;
|
|
294
|
+
let to: URL | undefined;
|
|
295
|
+
// the client the code is issued to: the one auth() is given, or the one it registers
|
|
296
|
+
let client: StoredOAuthClientInformation | undefined;
|
|
297
|
+
const provider: OAuthClientProvider = {
|
|
298
|
+
redirectUrl: core.redirectUri,
|
|
299
|
+
clientMetadata: core.clientMetadata,
|
|
300
|
+
...(core.clientMetadataUrl === undefined ? {} : { clientMetadataUrl: core.clientMetadataUrl }),
|
|
301
|
+
clientInformation: async (ctx) => (client = await this.client(ctx?.issuer ?? "")),
|
|
302
|
+
// a configured client is never replaced by one registered on the fly
|
|
303
|
+
...(this.options.client
|
|
304
|
+
? {}
|
|
305
|
+
: {
|
|
306
|
+
saveClientInformation: async (info, ctx) => {
|
|
307
|
+
client = info;
|
|
308
|
+
await this.saveClient(ctx?.issuer ?? info.issuer ?? "", info);
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
state: () => state,
|
|
312
|
+
// a reconnect starts over, whatever is kept
|
|
313
|
+
tokens: () => undefined,
|
|
314
|
+
saveTokens: () => {
|
|
315
|
+
throw new Error("no tokens are saved before the user comes back");
|
|
316
|
+
},
|
|
317
|
+
redirectToAuthorization: (url) => {
|
|
318
|
+
to = url;
|
|
319
|
+
},
|
|
320
|
+
saveCodeVerifier: (v) => {
|
|
321
|
+
verifier = v;
|
|
322
|
+
},
|
|
323
|
+
codeVerifier: () => {
|
|
324
|
+
throw new Error("no code is exchanged before the user comes back");
|
|
325
|
+
},
|
|
326
|
+
saveDiscoveryState: async (found) => {
|
|
327
|
+
await this.check(found);
|
|
328
|
+
discovery = found;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
const scope = computeScopeUnion(this.options.scopes?.join(" "), asked);
|
|
332
|
+
const result = await auth(provider, { serverUrl: this.url, fetchFn: this.fetch, ...(scope ? { scope } : {}) });
|
|
333
|
+
if (result !== "REDIRECT" || !to || verifier === undefined || !discovery || !client) {
|
|
334
|
+
throw new Error(`the SDK's auth() answered ${result}, with nowhere to send the user`);
|
|
335
|
+
}
|
|
336
|
+
// a configured client's secret isn't kept: it's read from the options when used, so it can be changed
|
|
337
|
+
if (this.options.client) client = { client_id: client.client_id, ...(client.issuer === undefined ? {} : { issuer: client.issuer }) };
|
|
338
|
+
const expiresAt = Date.now() + FLOW_MS;
|
|
339
|
+
const flow: Flow = { user, server: this.name, conversation, verifier, client, discovery, scope: to.searchParams.get("scope"), expiresAt };
|
|
340
|
+
await core.store.putFlow(state, core.sealer.seal(flow, binding.flow(state)), expiresAt);
|
|
341
|
+
return to;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The callback: exchanges the code with the authorization server and as
|
|
346
|
+
* the client the flow was started with, which checks `iss` (RFC 9207)
|
|
347
|
+
* before it asks for any token, stores the credential, and closes the
|
|
348
|
+
* user's connection.
|
|
349
|
+
*/
|
|
350
|
+
async finish(flow: Flow, code: string, iss: string | undefined): Promise<void> {
|
|
351
|
+
const where = await this.check(flow.discovery);
|
|
352
|
+
const tokens = await exchangeAuthorization(where.url, {
|
|
353
|
+
metadata: where.metadata,
|
|
354
|
+
clientInformation: this.issuedTo(flow.client),
|
|
355
|
+
authorizationCode: code,
|
|
356
|
+
iss,
|
|
357
|
+
codeVerifier: flow.verifier,
|
|
358
|
+
redirectUri: this.core.redirectUri,
|
|
359
|
+
...(where.resource ? { resource: where.resource } : {}),
|
|
360
|
+
fetchFn: this.fetch,
|
|
361
|
+
});
|
|
362
|
+
const kept: Kept = { tokens: { ...tokens, issuer: where.issuer }, client: flow.client, scope: tokens.scope ?? flow.scope, connectedAt: Date.now() };
|
|
363
|
+
const sealed = this.seal(flow.user, kept);
|
|
364
|
+
// the newest connect wins over whatever was kept
|
|
365
|
+
let written = false;
|
|
366
|
+
for (let tries = 0; tries < 3 && !written; tries++) {
|
|
367
|
+
written = await this.core.store.put(flow.user, this.name, sealed, await this.core.store.get(flow.user, this.name));
|
|
368
|
+
}
|
|
369
|
+
if (!written) throw new Error("the user's credential kept changing as the new one was written");
|
|
370
|
+
this.found = Promise.resolve(where);
|
|
371
|
+
await this.forget(flow.user);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Revokes the user's tokens where the authorization server takes it, then deletes them and closes the connection. */
|
|
375
|
+
async disconnect(user: string): Promise<void> {
|
|
376
|
+
const held = await this.read(user);
|
|
377
|
+
if (held) {
|
|
378
|
+
try {
|
|
379
|
+
await this.revoke(held.kept);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error(`@haikit/mcp: ${this.name}: the tokens of user ${user} couldn't be revoked, and are deleted anyway:`, err);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
await this.core.store.delete(user, this.name);
|
|
385
|
+
await this.forget(user);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** RFC 7009, at the authorization server's revocation endpoint, if it has one. */
|
|
389
|
+
private async revoke(kept: Kept): Promise<void> {
|
|
390
|
+
const { tokens } = kept;
|
|
391
|
+
const where = await this.where(tokens.issuer);
|
|
392
|
+
const endpoint = (where.metadata as { revocation_endpoint?: string } | undefined)?.revocation_endpoint;
|
|
393
|
+
if (!endpoint) return;
|
|
394
|
+
const client = this.issuedTo(kept.client);
|
|
395
|
+
const url = assertSecureTokenEndpoint(endpoint);
|
|
396
|
+
const body = new URLSearchParams(
|
|
397
|
+
tokens.refresh_token ? { token: tokens.refresh_token, token_type_hint: "refresh_token" } : { token: tokens.access_token, token_type_hint: "access_token" },
|
|
398
|
+
);
|
|
399
|
+
const headers = new Headers({ "content-type": "application/x-www-form-urlencoded", accept: "application/json" });
|
|
400
|
+
const metadata = where.metadata as { revocation_endpoint_auth_methods_supported?: string[]; token_endpoint_auth_methods_supported?: string[] };
|
|
401
|
+
const method = selectClientAuthMethod(client, metadata.revocation_endpoint_auth_methods_supported ?? metadata.token_endpoint_auth_methods_supported ?? []);
|
|
402
|
+
if (method === "client_secret_basic" && client.client_secret !== undefined) {
|
|
403
|
+
// as the SDK's own token requests send it: the authorization server checks the client the same way at both
|
|
404
|
+
headers.set("authorization", `Basic ${btoa(`${client.client_id}:${client.client_secret}`)}`);
|
|
405
|
+
} else {
|
|
406
|
+
body.set("client_id", client.client_id);
|
|
407
|
+
if (method === "client_secret_post" && client.client_secret !== undefined) body.set("client_secret", client.client_secret);
|
|
408
|
+
}
|
|
409
|
+
const response = await this.fetch(url, { method: "POST", headers, body, redirect: "error" });
|
|
410
|
+
await response.text().catch(() => {});
|
|
411
|
+
if (!response.ok) throw new Error(`the revocation endpoint answered ${response.status}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* After a 401: the credential to use instead of `before`. One refresh for
|
|
416
|
+
* each user at a time, shared until it settles, however long that takes,
|
|
417
|
+
* on whichever connection of theirs: a second would spend a refresh token
|
|
418
|
+
* the first may have spent already, and delete the credential when it's
|
|
419
|
+
* refused. Across processes, the store's lease does the same.
|
|
420
|
+
*/
|
|
421
|
+
refresh(user: string, before: Held, resourceMetadataUrl: URL | undefined): Promise<Held> {
|
|
422
|
+
let refreshing = this.refreshing.get(user);
|
|
423
|
+
if (!refreshing) {
|
|
424
|
+
refreshing = this.renew(user, before, resourceMetadataUrl)
|
|
425
|
+
.catch((err) => {
|
|
426
|
+
if (err instanceof Mismatch) console.error(`@haikit/mcp: ${this.name}: no refresh for user ${user}: ${err.message}`);
|
|
427
|
+
throw err;
|
|
428
|
+
})
|
|
429
|
+
.finally(() => this.refreshing.delete(user));
|
|
430
|
+
this.refreshing.set(user, refreshing);
|
|
431
|
+
}
|
|
432
|
+
return refreshing;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The store's newer credential if another refresh wrote one, else a
|
|
437
|
+
* refresh of `before`, under the store's lease. A refusal deletes it,
|
|
438
|
+
* unless another write came first; an outage keeps it.
|
|
439
|
+
*/
|
|
440
|
+
private async renew(user: string, before: Held, resourceMetadataUrl: URL | undefined): Promise<Held> {
|
|
441
|
+
const moved = await this.newer(user, before);
|
|
442
|
+
if (moved) return moved;
|
|
443
|
+
const { tokens } = before.kept;
|
|
444
|
+
if (!tokens.refresh_token) {
|
|
445
|
+
await this.drop(user, before);
|
|
446
|
+
throw new NeedsAuth("reconnect");
|
|
447
|
+
}
|
|
448
|
+
const where = await this.where(tokens.issuer, resourceMetadataUrl);
|
|
449
|
+
const holder = randomBytes(16).toString("base64url");
|
|
450
|
+
const written = await this.lease(user, before, holder);
|
|
451
|
+
if (written) return written;
|
|
452
|
+
// given back when this refresh writes nothing; a write or delete ends it anyway
|
|
453
|
+
const giveBack = () => this.core.store.lease(user, this.name, before.sealed, holder, 0).catch(() => false);
|
|
454
|
+
let fresh: Awaited<ReturnType<typeof refreshAuthorization>>;
|
|
455
|
+
try {
|
|
456
|
+
fresh = await refreshAuthorization(where.url, {
|
|
457
|
+
metadata: where.metadata,
|
|
458
|
+
clientInformation: this.issuedTo(before.kept.client),
|
|
459
|
+
refreshToken: tokens.refresh_token,
|
|
460
|
+
...(where.resource ? { resource: where.resource } : {}),
|
|
461
|
+
fetchFn: this.fetch,
|
|
462
|
+
});
|
|
463
|
+
} catch (err) {
|
|
464
|
+
if (err instanceof OAuthError && REFUSED.has(err.code)) {
|
|
465
|
+
// Refused: unless another refresh spent the token first, after this
|
|
466
|
+
// one's lease lapsed, and stored what it got, which is then the one
|
|
467
|
+
// to use.
|
|
468
|
+
const moved = await this.newer(user, before);
|
|
469
|
+
if (moved) return moved;
|
|
470
|
+
await this.drop(user, before);
|
|
471
|
+
throw new NeedsAuth("reconnect");
|
|
472
|
+
}
|
|
473
|
+
// an outage, or an answer that isn't OAuth's: kept, for the next 401 to try
|
|
474
|
+
await giveBack();
|
|
475
|
+
throw new LookupFailed(err);
|
|
476
|
+
}
|
|
477
|
+
const kept: Kept = { ...before.kept, tokens: { ...fresh, issuer: tokens.issuer }, scope: fresh.scope ?? before.kept.scope };
|
|
478
|
+
const sealed = this.seal(user, kept);
|
|
479
|
+
if (await this.write(user, before, sealed)) return { sealed, kept };
|
|
480
|
+
// Another write came first, or a delete, as by a disconnect: the tokens
|
|
481
|
+
// just issued are no one's, so they're revoked rather than left live.
|
|
482
|
+
await this.orphaned(user, kept, tokens.refresh_token);
|
|
483
|
+
const stands = await this.newer(user, before);
|
|
484
|
+
if (stands) return stands;
|
|
485
|
+
await giveBack();
|
|
486
|
+
throw new Error("the store refused a write of what it still holds");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Writes a refresh's credential onto `before`, tried again while the
|
|
491
|
+
* store fails: the old refresh token is spent, so this is the only copy,
|
|
492
|
+
* and the lease is still held. False if another write or a delete came
|
|
493
|
+
* first. A try that landed, though its answer was lost, is written.
|
|
494
|
+
*/
|
|
495
|
+
private async write(user: string, before: Held, sealed: string): Promise<boolean> {
|
|
496
|
+
for (let tries = 1; ; tries++) {
|
|
497
|
+
try {
|
|
498
|
+
if (await this.core.store.put(user, this.name, sealed, before.sealed)) return true;
|
|
499
|
+
return tries > 1 && (await this.core.store.get(user, this.name)) === sealed;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
if (tries < WRITE_TRIES) {
|
|
502
|
+
await pause(WRITE_PAUSE_MS);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
// the last may have landed too, though its answer was lost
|
|
506
|
+
if ((await this.core.store.get(user, this.name).catch(() => null)) === sealed) return true;
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Revokes the tokens a refresh got but couldn't store: its refresh token
|
|
514
|
+
* if the authorization server rotated it, else only its access token, as
|
|
515
|
+
* the one refresh token may be what the store holds now.
|
|
516
|
+
*/
|
|
517
|
+
private async orphaned(user: string, kept: Kept, spent: string): Promise<void> {
|
|
518
|
+
const { refresh_token, ...access } = kept.tokens;
|
|
519
|
+
try {
|
|
520
|
+
await this.revoke(refresh_token === spent ? { ...kept, tokens: access } : kept);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
console.error(`@haikit/mcp: ${this.name}: the tokens refreshed for user ${user} weren't stored, and couldn't be revoked:`, err);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Takes the refresh lease of the user's credential, so one process at a
|
|
528
|
+
* time spends its refresh token. While another holds it: the credential
|
|
529
|
+
* that one writes, which is then the one to use, or the lease once it
|
|
530
|
+
* lapses, as it does when that process is gone.
|
|
531
|
+
*/
|
|
532
|
+
private async lease(user: string, before: Held, holder: string): Promise<Held | null> {
|
|
533
|
+
const ms = LEASE_TIMEOUTS * this.timeoutMs;
|
|
534
|
+
const giveUp = Date.now() + 2 * ms;
|
|
535
|
+
while (!(await this.core.store.lease(user, this.name, before.sealed, holder, ms))) {
|
|
536
|
+
const moved = await this.newer(user, before);
|
|
537
|
+
if (moved) return moved;
|
|
538
|
+
if (Date.now() >= giveUp) throw new Error(`other processes held the refresh lease for ${2 * ms} ms`);
|
|
539
|
+
await pause(LEASE_POLL_MS);
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** The store's credential if it's newer than `before`, or null if it's still `before`. With none, the server's refusal stands. */
|
|
545
|
+
private async newer(user: string, before: Held): Promise<Held | null> {
|
|
546
|
+
const now = await this.read(user);
|
|
547
|
+
if (now === null) throw new NeedsAuth("reconnect");
|
|
548
|
+
return now.sealed === before.sealed ? null : now;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Deletes the credential, if the store still holds `before`: never a newer one. */
|
|
552
|
+
private async drop(user: string, before: Held): Promise<void> {
|
|
553
|
+
await this.core.store.delete(user, this.name, before.sealed);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* One user's OAuth credential for one server. The transport is given an
|
|
559
|
+
* `AuthProvider` of this package's own, never the SDK's
|
|
560
|
+
* `OAuthClientProvider`: so a call never starts a sign-in or registers a
|
|
561
|
+
* client, refreshes once at a time, and tells an outage from a refusal.
|
|
562
|
+
*/
|
|
563
|
+
class OAuthCredential implements Credential {
|
|
564
|
+
/** What the store held when this read or wrote it. Only a lookup, a refresh or the store's newer value sets it. */
|
|
565
|
+
private held: Held | null = null;
|
|
566
|
+
/** The lookup on its way, which every caller shares. */
|
|
567
|
+
private looking: Promise<void> | null = null;
|
|
568
|
+
/** The account, by its `connectedAt`, that the last check found, and the user's connection is made as. */
|
|
569
|
+
private account: number | null = null;
|
|
570
|
+
private readonly lookups: Lookup;
|
|
571
|
+
private readonly refreshes: Lookup;
|
|
572
|
+
|
|
573
|
+
constructor(
|
|
574
|
+
private readonly server: OAuthServer,
|
|
575
|
+
private readonly user: string,
|
|
576
|
+
) {
|
|
577
|
+
this.lookups = new Lookup(server.timeoutMs, "credential");
|
|
578
|
+
this.refreshes = new Lookup(server.timeoutMs, "refresh");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Before every call: the store's credential, since another process may
|
|
583
|
+
* have refreshed it, connected it again, or deleted it. A refresh is used
|
|
584
|
+
* as it is; another connect says so, for the connection to be made again.
|
|
585
|
+
*/
|
|
586
|
+
async ensure(): Promise<boolean> {
|
|
587
|
+
await this.look();
|
|
588
|
+
const now = this.held;
|
|
589
|
+
if (now === null) {
|
|
590
|
+
this.account = null;
|
|
591
|
+
throw new NeedsAuth("connect");
|
|
592
|
+
}
|
|
593
|
+
const changed = this.account !== null && now.kept.connectedAt !== this.account;
|
|
594
|
+
this.account = now.kept.connectedAt;
|
|
595
|
+
return changed;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
close(): void {
|
|
599
|
+
// A refresh on its way isn't stopped: a provider that rotates refresh
|
|
600
|
+
// tokens has spent the old one, and what it gives back must be stored.
|
|
601
|
+
this.lookups.stop();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// The transport asks for the token before every request, and on a 401
|
|
605
|
+
// calls onUnauthorized, then tries once more with what it finds. Each
|
|
606
|
+
// connection has its own, bound to the account it was made as: once the
|
|
607
|
+
// user has connected again, as a refresh can find, nothing more is sent
|
|
608
|
+
// on it, and the next call makes it again.
|
|
609
|
+
get provider(): AuthProvider {
|
|
610
|
+
const account = this.account;
|
|
611
|
+
const mine = (held: Held | null): Held => {
|
|
612
|
+
// gone since this call began, as when another call's 401 was refused
|
|
613
|
+
if (held === null) throw new NeedsAuth("reconnect");
|
|
614
|
+
if (held.kept.connectedAt !== account) throw new LookupFailed(new Error("the user connected again since this connection was made"));
|
|
615
|
+
return held;
|
|
616
|
+
};
|
|
617
|
+
return {
|
|
618
|
+
token: async () => mine(this.held).kept.tokens.access_token,
|
|
619
|
+
onUnauthorized: async (ctx) => {
|
|
620
|
+
const before = mine(this.held);
|
|
621
|
+
// held when the refresh settles, though this 401 gave up waiting for it
|
|
622
|
+
const refreshing = this.server.refresh(this.user, before, extractWWWAuthenticateParams(ctx.response).resourceMetadataUrl).then(
|
|
623
|
+
(next) => this.take(before, next),
|
|
624
|
+
(err) => {
|
|
625
|
+
if (err instanceof NeedsAuth) this.take(before, null);
|
|
626
|
+
throw err;
|
|
627
|
+
},
|
|
628
|
+
);
|
|
629
|
+
try {
|
|
630
|
+
await this.refreshes.run(() => refreshing);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
throw err instanceof NeedsAuth || err instanceof Mismatch || err instanceof LookupFailed ? err : new LookupFailed(err);
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private look(): Promise<void> {
|
|
639
|
+
this.looking ??= this.lookups
|
|
640
|
+
.run(() => this.server.read(this.user, this.held))
|
|
641
|
+
.then(
|
|
642
|
+
(found) => {
|
|
643
|
+
this.held = found;
|
|
644
|
+
},
|
|
645
|
+
(err) => {
|
|
646
|
+
throw new LookupFailed(err);
|
|
647
|
+
},
|
|
648
|
+
)
|
|
649
|
+
.finally(() => {
|
|
650
|
+
this.looking = null;
|
|
651
|
+
});
|
|
652
|
+
return this.looking;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Holds `next`, unless something newer than `before` is held already, as by a refresh that outlived its wait. */
|
|
656
|
+
private take(before: Held, next: Held | null): void {
|
|
657
|
+
if (this.held === before) this.held = next;
|
|
658
|
+
}
|
|
659
|
+
}
|