@ractive-ch/tab-mcp 0.2.1 → 0.3.1
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 +3 -2
- package/config.d.ts +27 -2
- package/exchange.d.ts +1 -0
- package/identity.d.ts +29 -0
- package/index.d.ts +2 -1
- package/index.js +1103 -43
- package/index.js.map +17 -14
- package/jwt.d.ts +75 -0
- package/main.js +1113 -43
- package/main.js.map +18 -15
- package/notopen.d.ts +7 -2
- package/oauth/oidc.d.ts +59 -0
- package/oauth/pairing.d.ts +31 -5
- package/oauth/server.d.ts +2 -0
- package/package.json +1 -1
- package/protocol.d.ts +25 -3
- package/relay.d.ts +3 -0
- package/session.d.ts +11 -0
- package/store/types.d.ts +8 -0
- package/testing.js +14 -1
- package/testing.js.map +3 -3
- package/tokens.d.ts +4 -1
package/index.js
CHANGED
|
@@ -45,6 +45,7 @@ var DEFAULT_CONFIG = {
|
|
|
45
45
|
host: "0.0.0.0",
|
|
46
46
|
allowedOrigins: [],
|
|
47
47
|
sites: [],
|
|
48
|
+
identity: "code",
|
|
48
49
|
clientIdHosts: [],
|
|
49
50
|
trustProxy: "none",
|
|
50
51
|
cors: true,
|
|
@@ -112,6 +113,7 @@ function resolveConfig(input = {}) {
|
|
|
112
113
|
}
|
|
113
114
|
seen.add(site.origin);
|
|
114
115
|
}
|
|
116
|
+
validateIdentity(config);
|
|
115
117
|
config.clientIdHosts = config.clientIdHosts.map((h) => {
|
|
116
118
|
const host = h.trim().toLowerCase();
|
|
117
119
|
if (!/^[a-z0-9.-]+(:\d+)?$/.test(host)) {
|
|
@@ -194,13 +196,12 @@ function validateSite(input) {
|
|
|
194
196
|
out.name = site["name"];
|
|
195
197
|
}
|
|
196
198
|
const pairing = site["pairing"];
|
|
197
|
-
if (pairing !== undefined
|
|
198
|
-
if (pairing
|
|
199
|
-
throw new ConfigError(`pairing "
|
|
199
|
+
if (pairing !== undefined) {
|
|
200
|
+
if (pairing !== "code" && pairing !== "oidc") {
|
|
201
|
+
throw new ConfigError(`pairing must be "code" or "oidc": ${out.origin}`);
|
|
200
202
|
}
|
|
201
|
-
|
|
203
|
+
out.pairing = pairing;
|
|
202
204
|
}
|
|
203
|
-
out.pairing = "code";
|
|
204
205
|
const limits = site["limits"];
|
|
205
206
|
if (limits !== undefined) {
|
|
206
207
|
if (typeof limits !== "object" || limits === null) {
|
|
@@ -224,6 +225,57 @@ function validateSite(input) {
|
|
|
224
225
|
}
|
|
225
226
|
return out;
|
|
226
227
|
}
|
|
228
|
+
function checkUrl(value, name, development) {
|
|
229
|
+
if (typeof value !== "string" || value === "") {
|
|
230
|
+
throw new ConfigError(`${name} is required for identity binding`);
|
|
231
|
+
}
|
|
232
|
+
let url;
|
|
233
|
+
try {
|
|
234
|
+
url = new URL(value);
|
|
235
|
+
} catch {
|
|
236
|
+
throw new ConfigError(`${name} is not a URL: ${value}`);
|
|
237
|
+
}
|
|
238
|
+
if (url.protocol !== "https:" && !(development && url.protocol === "http:")) {
|
|
239
|
+
throw new ConfigError(`${name} must be https: ${value}`);
|
|
240
|
+
}
|
|
241
|
+
if (url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") {
|
|
242
|
+
throw new ConfigError(`${name} has no query, fragment or credentials: ${value}`);
|
|
243
|
+
}
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
function validateIdentity(config) {
|
|
247
|
+
if (config.identity !== "code" && config.identity !== "oidc") {
|
|
248
|
+
throw new ConfigError('identity must be "code" or "oidc"');
|
|
249
|
+
}
|
|
250
|
+
const used = config.sites.some((s) => (s.pairing ?? config.identity) === "oidc") || config.identity === "oidc" && config.allowedOrigins !== "*" && config.allowedOrigins.length > 0;
|
|
251
|
+
if (config.identity === "oidc" && config.allowedOrigins === "*") {
|
|
252
|
+
throw new ConfigError("TAB_MCP_IDENTITY=oidc cannot be used with TAB_MCP_ALLOWED_ORIGINS=* (one provider cannot vouch for any website's users)");
|
|
253
|
+
}
|
|
254
|
+
const oidc = config.oidc;
|
|
255
|
+
if (oidc === undefined) {
|
|
256
|
+
if (used) {
|
|
257
|
+
throw new ConfigError("identity binding (oidc) needs TAB_MCP_OIDC_ISSUER, TAB_MCP_OIDC_CLIENT_ID, TAB_MCP_TAB_ASSERTION_ISSUER and TAB_MCP_TAB_ASSERTION_JWKS_URL");
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const dev = config.development;
|
|
262
|
+
checkUrl(oidc.issuer, "TAB_MCP_OIDC_ISSUER", dev);
|
|
263
|
+
checkUrl(oidc.assertionJwksUrl, "TAB_MCP_TAB_ASSERTION_JWKS_URL", dev);
|
|
264
|
+
if (typeof oidc.clientId !== "string" || oidc.clientId === "") {
|
|
265
|
+
throw new ConfigError("TAB_MCP_OIDC_CLIENT_ID is required for identity binding");
|
|
266
|
+
}
|
|
267
|
+
if (oidc.clientSecret !== undefined && typeof oidc.clientSecret !== "string") {
|
|
268
|
+
throw new ConfigError("TAB_MCP_OIDC_CLIENT_SECRET must be a string");
|
|
269
|
+
}
|
|
270
|
+
if (typeof oidc.assertionIssuer !== "string" || oidc.assertionIssuer === "") {
|
|
271
|
+
throw new ConfigError("TAB_MCP_TAB_ASSERTION_ISSUER is required for identity binding");
|
|
272
|
+
}
|
|
273
|
+
const claim = oidc.subjectClaim ?? "sub";
|
|
274
|
+
if (claim !== "sub" && claim !== "oid") {
|
|
275
|
+
throw new ConfigError('TAB_MCP_OIDC_SUBJECT_CLAIM must be "sub" or "oid" (a stable identifier, never email or a user name)');
|
|
276
|
+
}
|
|
277
|
+
config.oidc = { ...oidc, subjectClaim: claim };
|
|
278
|
+
}
|
|
227
279
|
function defaultLimits(config) {
|
|
228
280
|
return {
|
|
229
281
|
maxMessageBytes: config.maxMessageBytes,
|
|
@@ -241,7 +293,7 @@ function siteFor(config, origin) {
|
|
|
241
293
|
if (configured !== undefined) {
|
|
242
294
|
const site = {
|
|
243
295
|
origin: configured.origin,
|
|
244
|
-
pairing: configured.pairing ??
|
|
296
|
+
pairing: configured.pairing ?? config.identity,
|
|
245
297
|
limits: { ...defaults, ...configured.limits }
|
|
246
298
|
};
|
|
247
299
|
if (configured.name !== undefined)
|
|
@@ -251,7 +303,7 @@ function siteFor(config, origin) {
|
|
|
251
303
|
if (config.allowedOrigins === "*" || origin !== null && config.allowedOrigins.includes(origin)) {
|
|
252
304
|
return {
|
|
253
305
|
origin: config.allowedOrigins === "*" ? "*" : origin ?? "",
|
|
254
|
-
pairing: "code",
|
|
306
|
+
pairing: config.allowedOrigins === "*" ? "code" : config.identity,
|
|
255
307
|
limits: defaults
|
|
256
308
|
};
|
|
257
309
|
}
|
|
@@ -335,6 +387,30 @@ function configFromEnv(env) {
|
|
|
335
387
|
if (hosts !== undefined && hosts.trim() !== "") {
|
|
336
388
|
out.clientIdHosts = hosts.split(",").map((h) => h.trim()).filter((h) => h !== "");
|
|
337
389
|
}
|
|
390
|
+
set("identity", oneOf(env, "TAB_MCP_IDENTITY", ["code", "oidc"]));
|
|
391
|
+
const oidcVars = {
|
|
392
|
+
issuer: env["TAB_MCP_OIDC_ISSUER"],
|
|
393
|
+
clientId: env["TAB_MCP_OIDC_CLIENT_ID"],
|
|
394
|
+
clientSecret: env["TAB_MCP_OIDC_CLIENT_SECRET"],
|
|
395
|
+
subjectClaim: env["TAB_MCP_OIDC_SUBJECT_CLAIM"],
|
|
396
|
+
assertionIssuer: env["TAB_MCP_TAB_ASSERTION_ISSUER"],
|
|
397
|
+
assertionJwksUrl: env["TAB_MCP_TAB_ASSERTION_JWKS_URL"]
|
|
398
|
+
};
|
|
399
|
+
if (Object.values(oidcVars).some((v) => v !== undefined && v !== "")) {
|
|
400
|
+
const oidc = {
|
|
401
|
+
issuer: oidcVars.issuer ?? "",
|
|
402
|
+
clientId: oidcVars.clientId ?? "",
|
|
403
|
+
assertionIssuer: oidcVars.assertionIssuer ?? "",
|
|
404
|
+
assertionJwksUrl: oidcVars.assertionJwksUrl ?? ""
|
|
405
|
+
};
|
|
406
|
+
if (oidcVars.clientSecret !== undefined && oidcVars.clientSecret !== "") {
|
|
407
|
+
oidc.clientSecret = oidcVars.clientSecret;
|
|
408
|
+
}
|
|
409
|
+
if (oidcVars.subjectClaim !== undefined && oidcVars.subjectClaim !== "") {
|
|
410
|
+
oidc.subjectClaim = oidcVars.subjectClaim;
|
|
411
|
+
}
|
|
412
|
+
out.oidc = oidc;
|
|
413
|
+
}
|
|
338
414
|
set("trustProxy", oneOf(env, "TAB_MCP_TRUST_PROXY", [
|
|
339
415
|
"none",
|
|
340
416
|
"x-real-ip",
|
|
@@ -369,6 +445,385 @@ function configFromEnv(env) {
|
|
|
369
445
|
]));
|
|
370
446
|
return out;
|
|
371
447
|
}
|
|
448
|
+
// src/jwt.ts
|
|
449
|
+
var JWT_ALGORITHMS = [
|
|
450
|
+
"RS256",
|
|
451
|
+
"RS384",
|
|
452
|
+
"RS512",
|
|
453
|
+
"PS256",
|
|
454
|
+
"PS384",
|
|
455
|
+
"PS512",
|
|
456
|
+
"ES256",
|
|
457
|
+
"ES384",
|
|
458
|
+
"ES512",
|
|
459
|
+
"EdDSA"
|
|
460
|
+
];
|
|
461
|
+
var MAX_JWT_BYTES = 8192;
|
|
462
|
+
var MAX_JWKS_BYTES = 65536;
|
|
463
|
+
var JWKS_TTL_MS = 10 * 60000;
|
|
464
|
+
var JWKS_REFETCH_MS = 30000;
|
|
465
|
+
var JWKS_GRACE_MS = 60 * 60000;
|
|
466
|
+
var FETCH_TIMEOUT_MS = 5000;
|
|
467
|
+
|
|
468
|
+
class JwtError extends Error {
|
|
469
|
+
name = "JwtError";
|
|
470
|
+
reason;
|
|
471
|
+
constructor(reason, message) {
|
|
472
|
+
super(message);
|
|
473
|
+
this.reason = reason;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function fromBase64Url(part) {
|
|
477
|
+
if (!/^[A-Za-z0-9_-]*$/.test(part)) {
|
|
478
|
+
throw new JwtError("malformed", "the token is not base64url");
|
|
479
|
+
}
|
|
480
|
+
return Uint8Array.fromBase64(part, { alphabet: "base64url" });
|
|
481
|
+
}
|
|
482
|
+
function jsonPart(part, what) {
|
|
483
|
+
let value;
|
|
484
|
+
try {
|
|
485
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fromBase64Url(part)));
|
|
486
|
+
} catch {
|
|
487
|
+
throw new JwtError("malformed", `the token's ${what} is not JSON`);
|
|
488
|
+
}
|
|
489
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
490
|
+
throw new JwtError("malformed", `the token's ${what} is not a JSON object`);
|
|
491
|
+
}
|
|
492
|
+
return value;
|
|
493
|
+
}
|
|
494
|
+
function decodeJwt(token) {
|
|
495
|
+
if (typeof token !== "string" || token.length > MAX_JWT_BYTES) {
|
|
496
|
+
throw new JwtError("too_large", "the token is too large");
|
|
497
|
+
}
|
|
498
|
+
const parts = token.split(".");
|
|
499
|
+
if (parts.length !== 3) {
|
|
500
|
+
throw new JwtError("malformed", "the token is not a signed JWT");
|
|
501
|
+
}
|
|
502
|
+
const [h = "", p = "", s = ""] = parts;
|
|
503
|
+
return {
|
|
504
|
+
header: jsonPart(h, "header"),
|
|
505
|
+
payload: jsonPart(p, "payload"),
|
|
506
|
+
signingInput: new TextEncoder().encode(`${h}.${p}`),
|
|
507
|
+
signature: fromBase64Url(s)
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function spec(alg) {
|
|
511
|
+
const bits = alg.slice(2);
|
|
512
|
+
const hash = `SHA-${bits}`;
|
|
513
|
+
switch (alg.slice(0, 2)) {
|
|
514
|
+
case "RS":
|
|
515
|
+
return {
|
|
516
|
+
kty: "RSA",
|
|
517
|
+
importParams: { name: "RSASSA-PKCS1-v1_5", hash },
|
|
518
|
+
verifyParams: { name: "RSASSA-PKCS1-v1_5" }
|
|
519
|
+
};
|
|
520
|
+
case "PS":
|
|
521
|
+
return {
|
|
522
|
+
kty: "RSA",
|
|
523
|
+
importParams: { name: "RSA-PSS", hash },
|
|
524
|
+
verifyParams: {
|
|
525
|
+
name: "RSA-PSS",
|
|
526
|
+
saltLength: Number(bits) / 8
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
case "ES": {
|
|
530
|
+
const crv = bits === "512" ? "P-521" : `P-${bits}`;
|
|
531
|
+
return {
|
|
532
|
+
kty: "EC",
|
|
533
|
+
crv,
|
|
534
|
+
importParams: { name: "ECDSA", namedCurve: crv },
|
|
535
|
+
verifyParams: { name: "ECDSA", hash }
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
default:
|
|
539
|
+
return {
|
|
540
|
+
kty: "OKP",
|
|
541
|
+
crv: "Ed25519",
|
|
542
|
+
importParams: { name: "Ed25519" },
|
|
543
|
+
verifyParams: { name: "Ed25519" }
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function isAlgorithm(v) {
|
|
548
|
+
return JWT_ALGORITHMS.includes(v);
|
|
549
|
+
}
|
|
550
|
+
function keyFits(jwk, alg) {
|
|
551
|
+
const s = spec(alg);
|
|
552
|
+
if (jwk.kty !== s.kty)
|
|
553
|
+
return false;
|
|
554
|
+
if (s.crv !== undefined && jwk.crv !== s.crv)
|
|
555
|
+
return false;
|
|
556
|
+
if (jwk.use !== undefined && jwk.use !== "sig")
|
|
557
|
+
return false;
|
|
558
|
+
if (jwk.alg !== undefined && jwk.alg !== alg)
|
|
559
|
+
return false;
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
function publicJwk(jwk) {
|
|
563
|
+
const out = { kty: jwk.kty };
|
|
564
|
+
for (const k of ["n", "e", "crv", "x", "y"]) {
|
|
565
|
+
const v = jwk[k];
|
|
566
|
+
if (typeof v === "string")
|
|
567
|
+
out[k] = v;
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
async function fetchJson(url, init = {}, maxBytes = MAX_JWKS_BYTES) {
|
|
572
|
+
const res = await fetch(url, {
|
|
573
|
+
...init,
|
|
574
|
+
redirect: "error",
|
|
575
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
576
|
+
headers: { accept: "application/json", ...init.headers ?? {} }
|
|
577
|
+
});
|
|
578
|
+
const length = Number(res.headers.get("content-length") ?? "0");
|
|
579
|
+
if (length > maxBytes)
|
|
580
|
+
throw new Error(`response too large (${url})`);
|
|
581
|
+
const chunks = [];
|
|
582
|
+
let size = 0;
|
|
583
|
+
const reader = res.body?.getReader();
|
|
584
|
+
while (reader !== undefined) {
|
|
585
|
+
const { done, value } = await reader.read();
|
|
586
|
+
if (done)
|
|
587
|
+
break;
|
|
588
|
+
size += value.byteLength;
|
|
589
|
+
if (size > maxBytes) {
|
|
590
|
+
await reader.cancel();
|
|
591
|
+
throw new Error(`response too large (${url})`);
|
|
592
|
+
}
|
|
593
|
+
chunks.push(value);
|
|
594
|
+
}
|
|
595
|
+
const bytes = new Uint8Array(size);
|
|
596
|
+
let offset = 0;
|
|
597
|
+
for (const chunk of chunks) {
|
|
598
|
+
bytes.set(chunk, offset);
|
|
599
|
+
offset += chunk.byteLength;
|
|
600
|
+
}
|
|
601
|
+
const text = new TextDecoder().decode(bytes);
|
|
602
|
+
let value;
|
|
603
|
+
try {
|
|
604
|
+
value = JSON.parse(text);
|
|
605
|
+
} catch {
|
|
606
|
+
throw new Error(`not JSON (${url}, HTTP ${res.status})`);
|
|
607
|
+
}
|
|
608
|
+
if (!res.ok) {
|
|
609
|
+
const error = value?.["error"];
|
|
610
|
+
throw new HttpJsonError(res.status, typeof error === "string" ? error : "", url);
|
|
611
|
+
}
|
|
612
|
+
return value;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
class HttpJsonError extends Error {
|
|
616
|
+
name = "HttpJsonError";
|
|
617
|
+
status;
|
|
618
|
+
error;
|
|
619
|
+
constructor(status, error, url) {
|
|
620
|
+
super(`HTTP ${status}${error === "" ? "" : ` ${error}`} (${url})`);
|
|
621
|
+
this.status = status;
|
|
622
|
+
this.error = error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
class JwksCache {
|
|
627
|
+
#url;
|
|
628
|
+
#now;
|
|
629
|
+
#keys = [];
|
|
630
|
+
#fetchedAt = 0;
|
|
631
|
+
#attemptedAt = Number.NEGATIVE_INFINITY;
|
|
632
|
+
#lastError;
|
|
633
|
+
#inflight;
|
|
634
|
+
constructor(url, options = {}) {
|
|
635
|
+
this.#url = typeof url === "string" ? () => Promise.resolve(url) : url;
|
|
636
|
+
this.#now = options.now ?? Date.now;
|
|
637
|
+
}
|
|
638
|
+
async keys(kid) {
|
|
639
|
+
if (this.#inflight !== undefined)
|
|
640
|
+
await this.#inflight.catch(() => {});
|
|
641
|
+
const now = this.#now();
|
|
642
|
+
const stale = now - this.#fetchedAt >= JWKS_TTL_MS;
|
|
643
|
+
const unknown = kid !== undefined && !this.#keys.some((k) => k.kid === kid);
|
|
644
|
+
if ((stale || unknown) && now - this.#attemptedAt >= JWKS_REFETCH_MS) {
|
|
645
|
+
try {
|
|
646
|
+
await this.#refresh();
|
|
647
|
+
} catch (error) {
|
|
648
|
+
this.#lastError = error instanceof Error ? error : new Error("JWKS");
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (this.#now() - this.#fetchedAt >= JWKS_TTL_MS + JWKS_GRACE_MS) {
|
|
652
|
+
throw this.#lastError ?? new Error("the JWKS could not be loaded");
|
|
653
|
+
}
|
|
654
|
+
return kid === undefined ? this.#keys : this.#keys.filter((k) => k.kid === kid);
|
|
655
|
+
}
|
|
656
|
+
#refresh() {
|
|
657
|
+
this.#inflight ??= (async () => {
|
|
658
|
+
this.#attemptedAt = this.#now();
|
|
659
|
+
try {
|
|
660
|
+
const doc = await fetchJson(await this.#url());
|
|
661
|
+
const keys = doc?.keys;
|
|
662
|
+
if (!Array.isArray(keys))
|
|
663
|
+
throw new Error("a JWKS needs keys");
|
|
664
|
+
this.#keys = keys.filter((k) => typeof k === "object" && k !== null);
|
|
665
|
+
this.#fetchedAt = this.#now();
|
|
666
|
+
this.#lastError = undefined;
|
|
667
|
+
} finally {
|
|
668
|
+
this.#inflight = undefined;
|
|
669
|
+
}
|
|
670
|
+
})();
|
|
671
|
+
return this.#inflight;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
async function verifyJwt(token, source, options = {}) {
|
|
675
|
+
const jwt = decodeJwt(token);
|
|
676
|
+
const { header } = jwt;
|
|
677
|
+
const alg = header["alg"];
|
|
678
|
+
const allowed = options.algorithms ?? JWT_ALGORITHMS;
|
|
679
|
+
if (!isAlgorithm(alg) || !allowed.includes(alg)) {
|
|
680
|
+
throw new JwtError("alg", `the token's algorithm is not accepted (${String(alg).slice(0, 20)})`);
|
|
681
|
+
}
|
|
682
|
+
if (header["crit"] !== undefined) {
|
|
683
|
+
throw new JwtError("crit", "the token has critical header parameters");
|
|
684
|
+
}
|
|
685
|
+
if (options.typ !== undefined && header["typ"] !== options.typ) {
|
|
686
|
+
throw new JwtError("typ", `the token's type must be ${options.typ}`);
|
|
687
|
+
}
|
|
688
|
+
const kid = header["kid"];
|
|
689
|
+
if (kid !== undefined && typeof kid !== "string") {
|
|
690
|
+
throw new JwtError("malformed", "the token's kid is not a string");
|
|
691
|
+
}
|
|
692
|
+
let candidates;
|
|
693
|
+
try {
|
|
694
|
+
candidates = (await source.keys(kid)).filter((k) => keyFits(k, alg));
|
|
695
|
+
} catch (error) {
|
|
696
|
+
throw new JwtError("jwks_unavailable", `the signing keys could not be loaded: ${error instanceof Error ? error.message : "unknown"}`);
|
|
697
|
+
}
|
|
698
|
+
if (candidates.length === 0 || kid === undefined && candidates.length > 1) {
|
|
699
|
+
throw new JwtError("key", "no matching signing key");
|
|
700
|
+
}
|
|
701
|
+
const s = spec(alg);
|
|
702
|
+
for (const jwk of candidates) {
|
|
703
|
+
let key;
|
|
704
|
+
try {
|
|
705
|
+
key = await crypto.subtle.importKey("jwk", publicJwk(jwk), s.importParams, false, ["verify"]);
|
|
706
|
+
} catch {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const ok = await crypto.subtle.verify(s.verifyParams, key, jwt.signature, jwt.signingInput).catch(() => false);
|
|
710
|
+
if (ok)
|
|
711
|
+
return jwt;
|
|
712
|
+
}
|
|
713
|
+
throw new JwtError("signature", "the token's signature is not valid");
|
|
714
|
+
}
|
|
715
|
+
function numericDate(payload, name) {
|
|
716
|
+
const v = payload[name];
|
|
717
|
+
if (v === undefined)
|
|
718
|
+
return;
|
|
719
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
720
|
+
throw new JwtError("claims", `${name} must be a number`);
|
|
721
|
+
}
|
|
722
|
+
return v * 1000;
|
|
723
|
+
}
|
|
724
|
+
function audiences(payload) {
|
|
725
|
+
const aud = payload["aud"];
|
|
726
|
+
if (typeof aud === "string")
|
|
727
|
+
return [aud];
|
|
728
|
+
if (Array.isArray(aud) && aud.every((a) => typeof a === "string")) {
|
|
729
|
+
return aud;
|
|
730
|
+
}
|
|
731
|
+
throw new JwtError("claims", "aud is missing");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// src/identity.ts
|
|
735
|
+
var TAB_ASSERTION_TYP = "tab-mcp-identity+jwt";
|
|
736
|
+
var TAB_ASSERTION_MAX_LIFETIME_MS = 60000;
|
|
737
|
+
var CLOCK_LEEWAY_MS = 5000;
|
|
738
|
+
var MAX_SUBJECT = 255;
|
|
739
|
+
var MAX_JTI = 128;
|
|
740
|
+
var MAX_SEEN_JTI = 1e5;
|
|
741
|
+
var MAX_JTI_PER_PERSON = 120;
|
|
742
|
+
var MISMATCH_TEXT = "This AI tool is logged in as someone other than the page.";
|
|
743
|
+
function identityKey(issuer, subject) {
|
|
744
|
+
return new Bun.CryptoHasher("sha256").update(`${issuer}
|
|
745
|
+
${subject}`).digest().toBase64({ alphabet: "base64url", omitPadding: true });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
class TabAssertionVerifier {
|
|
749
|
+
#oidc;
|
|
750
|
+
#keys;
|
|
751
|
+
#seen = new Map;
|
|
752
|
+
#perPerson = new Map;
|
|
753
|
+
#sweptAt = Number.NEGATIVE_INFINITY;
|
|
754
|
+
constructor(oidc, keys) {
|
|
755
|
+
this.#oidc = oidc;
|
|
756
|
+
this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
|
|
757
|
+
}
|
|
758
|
+
async verify(token, address, now = Date.now()) {
|
|
759
|
+
const { payload } = await verifyJwt(token, this.#keys, {
|
|
760
|
+
typ: TAB_ASSERTION_TYP
|
|
761
|
+
});
|
|
762
|
+
if (payload["iss"] !== this.#oidc.assertionIssuer) {
|
|
763
|
+
throw new JwtError("iss", "the assertion's issuer is not the configured one");
|
|
764
|
+
}
|
|
765
|
+
const aud = audiences(payload);
|
|
766
|
+
if (aud.length !== 1 || aud[0] !== address) {
|
|
767
|
+
throw new JwtError("aud", "the assertion is not for this address");
|
|
768
|
+
}
|
|
769
|
+
const exp = numericDate(payload, "exp");
|
|
770
|
+
if (exp === undefined)
|
|
771
|
+
throw new JwtError("exp", "the assertion has no exp");
|
|
772
|
+
if (exp <= now - CLOCK_LEEWAY_MS) {
|
|
773
|
+
throw new JwtError("expired", "the assertion has expired");
|
|
774
|
+
}
|
|
775
|
+
if (exp > now + TAB_ASSERTION_MAX_LIFETIME_MS + CLOCK_LEEWAY_MS) {
|
|
776
|
+
throw new JwtError("lifetime", "the assertion lives longer than 60 seconds");
|
|
777
|
+
}
|
|
778
|
+
const iat = numericDate(payload, "iat");
|
|
779
|
+
if (iat !== undefined && iat > now + CLOCK_LEEWAY_MS) {
|
|
780
|
+
throw new JwtError("iat", "the assertion was issued in the future");
|
|
781
|
+
}
|
|
782
|
+
const nbf = numericDate(payload, "nbf");
|
|
783
|
+
if (nbf !== undefined && nbf > now + CLOCK_LEEWAY_MS) {
|
|
784
|
+
throw new JwtError("nbf", "the assertion is not valid yet");
|
|
785
|
+
}
|
|
786
|
+
const sub = payload["sub"];
|
|
787
|
+
if (typeof sub !== "string" || sub === "" || sub.length > MAX_SUBJECT) {
|
|
788
|
+
throw new JwtError("sub", "the assertion needs a subject");
|
|
789
|
+
}
|
|
790
|
+
const jti = payload["jti"];
|
|
791
|
+
if (typeof jti !== "string" || jti === "" || jti.length > MAX_JTI) {
|
|
792
|
+
throw new JwtError("jti", "the assertion needs a jti");
|
|
793
|
+
}
|
|
794
|
+
this.#sweep(now);
|
|
795
|
+
const key = identityKey(this.#oidc.issuer, sub);
|
|
796
|
+
const seenKey = `${key}
|
|
797
|
+
${jti}`;
|
|
798
|
+
if (this.#seen.has(seenKey)) {
|
|
799
|
+
throw new JwtError("replayed", "the assertion was already used");
|
|
800
|
+
}
|
|
801
|
+
const mine = this.#perPerson.get(key) ?? 0;
|
|
802
|
+
if (mine >= MAX_JTI_PER_PERSON || this.#seen.size >= MAX_SEEN_JTI) {
|
|
803
|
+
throw new JwtError("busy", "too many assertions at once");
|
|
804
|
+
}
|
|
805
|
+
this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
|
|
806
|
+
this.#perPerson.set(key, mine + 1);
|
|
807
|
+
return key;
|
|
808
|
+
}
|
|
809
|
+
#sweep(now) {
|
|
810
|
+
if (now - this.#sweptAt < 1000 && this.#seen.size < MAX_SEEN_JTI)
|
|
811
|
+
return;
|
|
812
|
+
this.#sweptAt = now;
|
|
813
|
+
for (const [seenKey, until] of this.#seen) {
|
|
814
|
+
if (until > now)
|
|
815
|
+
continue;
|
|
816
|
+
this.#seen.delete(seenKey);
|
|
817
|
+
const person = seenKey.slice(0, seenKey.indexOf(`
|
|
818
|
+
`));
|
|
819
|
+
const left = (this.#perPerson.get(person) ?? 1) - 1;
|
|
820
|
+
if (left > 0)
|
|
821
|
+
this.#perPerson.set(person, left);
|
|
822
|
+
else
|
|
823
|
+
this.#perPerson.delete(person);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
372
827
|
// src/ip.ts
|
|
373
828
|
function parseIpv4(ip) {
|
|
374
829
|
const parts = ip.split(".");
|
|
@@ -698,7 +1153,19 @@ function parseTabMessage(text) {
|
|
|
698
1153
|
case "pairing.start":
|
|
699
1154
|
case "pairing.stop":
|
|
700
1155
|
case "grants.revokeAll":
|
|
1156
|
+
case "identity.logout":
|
|
701
1157
|
return { ok: true, message: { type } };
|
|
1158
|
+
case "identity": {
|
|
1159
|
+
const assertion = v["assertion"];
|
|
1160
|
+
if (typeof assertion !== "string" || assertion === "" || assertion.length > MAX_JWT_BYTES) {
|
|
1161
|
+
return bad(`identity.assertion must be a JWT of at most ${MAX_JWT_BYTES} bytes`);
|
|
1162
|
+
}
|
|
1163
|
+
const round = v["round"];
|
|
1164
|
+
if (round !== undefined && !(Number.isSafeInteger(round) && round >= 0)) {
|
|
1165
|
+
return bad("identity.round must be a non-negative integer");
|
|
1166
|
+
}
|
|
1167
|
+
return round === undefined ? { ok: true, message: { type, assertion } } : { ok: true, message: { type, assertion, round } };
|
|
1168
|
+
}
|
|
702
1169
|
case "grant.revoke": {
|
|
703
1170
|
const grantId = v["grantId"];
|
|
704
1171
|
if (!isId(grantId))
|
|
@@ -724,7 +1191,7 @@ function parseTabMessage(text) {
|
|
|
724
1191
|
}
|
|
725
1192
|
}
|
|
726
1193
|
// package.json
|
|
727
|
-
var version = "0.
|
|
1194
|
+
var version = "0.3.1";
|
|
728
1195
|
|
|
729
1196
|
// src/exchange.ts
|
|
730
1197
|
var RelayErrorCode = {
|
|
@@ -735,7 +1202,8 @@ var RelayErrorCode = {
|
|
|
735
1202
|
sendFailed: -31005,
|
|
736
1203
|
tooLarge: -31006,
|
|
737
1204
|
badResponse: -31007,
|
|
738
|
-
storeUnavailable: -31008
|
|
1205
|
+
storeUnavailable: -31008,
|
|
1206
|
+
signedOut: -31009
|
|
739
1207
|
};
|
|
740
1208
|
var encoder = new TextEncoder;
|
|
741
1209
|
var NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
@@ -1056,6 +1524,12 @@ function pageName(appName) {
|
|
|
1056
1524
|
function notOpenText(appName) {
|
|
1057
1525
|
return `The page is not open, or not in front. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then try again.`;
|
|
1058
1526
|
}
|
|
1527
|
+
function signedOutText(appName) {
|
|
1528
|
+
return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
|
|
1529
|
+
}
|
|
1530
|
+
function phaseText(phase, appName) {
|
|
1531
|
+
return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
|
|
1532
|
+
}
|
|
1059
1533
|
function goneDuringCallText(appName) {
|
|
1060
1534
|
return `The page closed while the tool was running, so it may or may not have finished. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then check the result before trying again.`;
|
|
1061
1535
|
}
|
|
@@ -1065,7 +1539,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
|
1065
1539
|
const id = rpcId;
|
|
1066
1540
|
const method = body["method"];
|
|
1067
1541
|
if (method === "tools/call") {
|
|
1068
|
-
const text = phase === "gone" ? goneDuringCallText(appName) :
|
|
1542
|
+
const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
|
|
1069
1543
|
return JSON.stringify({
|
|
1070
1544
|
jsonrpc: "2.0",
|
|
1071
1545
|
id,
|
|
@@ -1084,7 +1558,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
|
1084
1558
|
resultType: "complete",
|
|
1085
1559
|
supportedVersions: [MCP_PROTOCOL_VERSION],
|
|
1086
1560
|
capabilities: { tools: { listChanged: true } },
|
|
1087
|
-
instructions:
|
|
1561
|
+
instructions: phaseText(phase, appName),
|
|
1088
1562
|
ttlMs: 0,
|
|
1089
1563
|
cacheScope: "private",
|
|
1090
1564
|
_meta: {
|
|
@@ -1101,7 +1575,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
|
1101
1575
|
id,
|
|
1102
1576
|
error: {
|
|
1103
1577
|
code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
|
|
1104
|
-
message:
|
|
1578
|
+
message: phaseText(phase, appName)
|
|
1105
1579
|
}
|
|
1106
1580
|
});
|
|
1107
1581
|
}
|
|
@@ -1178,7 +1652,8 @@ async function issueTokenPair(store, grant, options) {
|
|
|
1178
1652
|
clientName: grant.clientName,
|
|
1179
1653
|
scopes: grant.scopes,
|
|
1180
1654
|
grantCreatedAt: grant.grantCreatedAt,
|
|
1181
|
-
issuedAt: now
|
|
1655
|
+
issuedAt: now,
|
|
1656
|
+
...grant.subject === undefined ? {} : { subject: grant.subject }
|
|
1182
1657
|
};
|
|
1183
1658
|
const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
|
|
1184
1659
|
const refreshStored = await store.put({
|
|
@@ -1569,6 +2044,348 @@ ${body}
|
|
|
1569
2044
|
</html>`;
|
|
1570
2045
|
}
|
|
1571
2046
|
|
|
2047
|
+
// src/oauth/oidc.ts
|
|
2048
|
+
var FLOW_TTL_MS = 10 * 60000;
|
|
2049
|
+
var MAX_FLOWS = 1e4;
|
|
2050
|
+
var METADATA_TTL_MS = 60 * 60000;
|
|
2051
|
+
var MAX_TOKEN_RESPONSE_BYTES = 65536;
|
|
2052
|
+
|
|
2053
|
+
class OidcError extends Error {
|
|
2054
|
+
name = "OidcError";
|
|
2055
|
+
reason;
|
|
2056
|
+
constructor(reason, message) {
|
|
2057
|
+
super(message);
|
|
2058
|
+
this.reason = reason;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
function random2(bytes) {
|
|
2062
|
+
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
|
|
2063
|
+
}
|
|
2064
|
+
function s256(verifier) {
|
|
2065
|
+
return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
|
|
2066
|
+
}
|
|
2067
|
+
function str(v) {
|
|
2068
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
2069
|
+
}
|
|
2070
|
+
function originOf(url) {
|
|
2071
|
+
try {
|
|
2072
|
+
return new URL(url).origin;
|
|
2073
|
+
} catch {
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
class ProviderDiscovery {
|
|
2079
|
+
#issuer;
|
|
2080
|
+
#development;
|
|
2081
|
+
#metadata;
|
|
2082
|
+
#fetchedAt = 0;
|
|
2083
|
+
#inflight;
|
|
2084
|
+
constructor(issuer, development) {
|
|
2085
|
+
this.#issuer = issuer;
|
|
2086
|
+
this.#development = development;
|
|
2087
|
+
}
|
|
2088
|
+
get cached() {
|
|
2089
|
+
return this.#metadata;
|
|
2090
|
+
}
|
|
2091
|
+
async get() {
|
|
2092
|
+
if (this.#metadata !== undefined && Date.now() - this.#fetchedAt < METADATA_TTL_MS) {
|
|
2093
|
+
return this.#metadata;
|
|
2094
|
+
}
|
|
2095
|
+
this.#inflight ??= this.#load().finally(() => {
|
|
2096
|
+
this.#inflight = undefined;
|
|
2097
|
+
});
|
|
2098
|
+
return this.#inflight;
|
|
2099
|
+
}
|
|
2100
|
+
async#load() {
|
|
2101
|
+
const url = `${this.#issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
|
|
2102
|
+
const doc = await fetchJson(url);
|
|
2103
|
+
if (typeof doc !== "object" || doc === null) {
|
|
2104
|
+
throw new OidcError("discovery", "the provider's metadata is not an object");
|
|
2105
|
+
}
|
|
2106
|
+
if (doc["issuer"] !== this.#issuer) {
|
|
2107
|
+
throw new OidcError("discovery_issuer", "the provider's metadata names another issuer");
|
|
2108
|
+
}
|
|
2109
|
+
const endpoint = (name) => {
|
|
2110
|
+
const v = str(doc[name]);
|
|
2111
|
+
const ok = v !== undefined && (v.startsWith("https://") || this.#development && v.startsWith("http://"));
|
|
2112
|
+
if (!ok) {
|
|
2113
|
+
throw new OidcError("discovery", `the provider's metadata lacks ${name}`);
|
|
2114
|
+
}
|
|
2115
|
+
return v;
|
|
2116
|
+
};
|
|
2117
|
+
const methods = doc["token_endpoint_auth_methods_supported"];
|
|
2118
|
+
const tokenAuth = Array.isArray(methods) && !methods.includes("client_secret_basic") && methods.includes("client_secret_post") ? "client_secret_post" : "client_secret_basic";
|
|
2119
|
+
const metadata = {
|
|
2120
|
+
issuer: this.#issuer,
|
|
2121
|
+
authorizationEndpoint: endpoint("authorization_endpoint"),
|
|
2122
|
+
tokenEndpoint: endpoint("token_endpoint"),
|
|
2123
|
+
jwksUri: endpoint("jwks_uri"),
|
|
2124
|
+
tokenAuth
|
|
2125
|
+
};
|
|
2126
|
+
this.#metadata = metadata;
|
|
2127
|
+
this.#fetchedAt = Date.now();
|
|
2128
|
+
return metadata;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
class OidcPairing {
|
|
2133
|
+
kind = "oidc";
|
|
2134
|
+
#config;
|
|
2135
|
+
#publicUrl;
|
|
2136
|
+
#logger;
|
|
2137
|
+
#discovery;
|
|
2138
|
+
#jwks;
|
|
2139
|
+
#flows = new Map;
|
|
2140
|
+
#mismatched = new Set;
|
|
2141
|
+
constructor(options) {
|
|
2142
|
+
this.#config = options.config;
|
|
2143
|
+
this.#publicUrl = options.publicUrl;
|
|
2144
|
+
this.#logger = options.logger;
|
|
2145
|
+
this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
|
|
2146
|
+
this.#jwks = new JwksCache(async () => (await this.#discovery.get()).jwksUri);
|
|
2147
|
+
}
|
|
2148
|
+
get #providerHost() {
|
|
2149
|
+
try {
|
|
2150
|
+
return new URL(this.#config.issuer).host;
|
|
2151
|
+
} catch {
|
|
2152
|
+
return this.#config.issuer;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
get callbackUrl() {
|
|
2156
|
+
return `${this.#publicUrl()}/authorize/callback`;
|
|
2157
|
+
}
|
|
2158
|
+
async prepare() {
|
|
2159
|
+
await this.#discovery.get();
|
|
2160
|
+
}
|
|
2161
|
+
formTargets() {
|
|
2162
|
+
const out = new Set;
|
|
2163
|
+
const issuer = originOf(this.#config.issuer);
|
|
2164
|
+
if (issuer !== undefined)
|
|
2165
|
+
out.add(issuer);
|
|
2166
|
+
const auth = this.#discovery.cached?.authorizationEndpoint;
|
|
2167
|
+
const origin = auth === undefined ? undefined : originOf(auth);
|
|
2168
|
+
if (origin !== undefined)
|
|
2169
|
+
out.add(origin);
|
|
2170
|
+
return [...out];
|
|
2171
|
+
}
|
|
2172
|
+
render(target) {
|
|
2173
|
+
const host = escapeHtml(this.#providerHost);
|
|
2174
|
+
if (target.identity === undefined) {
|
|
2175
|
+
return `<p class="help">Nobody is signed in to the page. Sign in there first, then click Connect.</p>`;
|
|
2176
|
+
}
|
|
2177
|
+
return `<p>When you click Connect, sign in at <strong>${host}</strong> with the account you use in the page. No code is needed.</p>`;
|
|
2178
|
+
}
|
|
2179
|
+
async check(target, _form, _admit, context) {
|
|
2180
|
+
if (!target.pairingOpen) {
|
|
2181
|
+
return {
|
|
2182
|
+
ok: false,
|
|
2183
|
+
message: "The page is not connecting right now. In the page, open \u201CConnect your AI app\u201D, then click Connect here again.",
|
|
2184
|
+
reason: "inactive"
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
if (target.identity === undefined) {
|
|
2188
|
+
return {
|
|
2189
|
+
ok: false,
|
|
2190
|
+
message: "Nobody is signed in to the page. Sign in there first, then click Connect here again.",
|
|
2191
|
+
reason: "signed_out"
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
if (context === undefined) {
|
|
2195
|
+
return { ok: false, message: "Something went wrong.", reason: "context" };
|
|
2196
|
+
}
|
|
2197
|
+
let metadata;
|
|
2198
|
+
try {
|
|
2199
|
+
metadata = await this.#discovery.get();
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
return this.#unavailable(error);
|
|
2202
|
+
}
|
|
2203
|
+
this.#sweep();
|
|
2204
|
+
if (this.#flows.size >= MAX_FLOWS) {
|
|
2205
|
+
return {
|
|
2206
|
+
ok: false,
|
|
2207
|
+
message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
|
|
2208
|
+
reason: "busy"
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
const state = random2(24);
|
|
2212
|
+
const nonce = random2(24);
|
|
2213
|
+
const verifier = random2(32);
|
|
2214
|
+
this.#flows.set(state, {
|
|
2215
|
+
requestId: context.requestId,
|
|
2216
|
+
browserId: context.browserId,
|
|
2217
|
+
nonce,
|
|
2218
|
+
verifier,
|
|
2219
|
+
expiresAt: Date.now() + FLOW_TTL_MS
|
|
2220
|
+
});
|
|
2221
|
+
const url = new URL(metadata.authorizationEndpoint);
|
|
2222
|
+
const params = {
|
|
2223
|
+
response_type: "code",
|
|
2224
|
+
client_id: this.#config.clientId,
|
|
2225
|
+
redirect_uri: this.callbackUrl,
|
|
2226
|
+
scope: "openid",
|
|
2227
|
+
state,
|
|
2228
|
+
nonce,
|
|
2229
|
+
code_challenge: s256(verifier),
|
|
2230
|
+
code_challenge_method: "S256"
|
|
2231
|
+
};
|
|
2232
|
+
if (this.#mismatched.has(context.requestId))
|
|
2233
|
+
params["prompt"] = "login";
|
|
2234
|
+
for (const [k, v] of Object.entries(params))
|
|
2235
|
+
url.searchParams.set(k, v);
|
|
2236
|
+
return { ok: "redirect", location: url.href };
|
|
2237
|
+
}
|
|
2238
|
+
async callback(params, browserId, targetFor) {
|
|
2239
|
+
this.#sweep();
|
|
2240
|
+
const state = params.get("state") ?? "";
|
|
2241
|
+
const flow = this.#flows.get(state);
|
|
2242
|
+
if (flow === undefined || flow.expiresAt <= Date.now()) {
|
|
2243
|
+
return {
|
|
2244
|
+
result: {
|
|
2245
|
+
ok: false,
|
|
2246
|
+
message: "This sign-in has expired or was already used. Start again from your AI app.",
|
|
2247
|
+
reason: "unknown_state"
|
|
2248
|
+
}
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
this.#flows.delete(state);
|
|
2252
|
+
const requestId = flow.requestId;
|
|
2253
|
+
const fail = (reason, message) => ({
|
|
2254
|
+
requestId,
|
|
2255
|
+
result: { ok: false, message, reason }
|
|
2256
|
+
});
|
|
2257
|
+
if (flow.browserId !== browserId) {
|
|
2258
|
+
return fail("browser", "This sign-in was started in another browser. Start again from your AI app.");
|
|
2259
|
+
}
|
|
2260
|
+
const iss = params.get("iss");
|
|
2261
|
+
if (iss !== null && iss !== this.#config.issuer) {
|
|
2262
|
+
return fail("iss", "The sign-in came back from an unexpected provider.");
|
|
2263
|
+
}
|
|
2264
|
+
const error = params.get("error");
|
|
2265
|
+
if (error !== null) {
|
|
2266
|
+
this.#logger.info("oidc.provider_error", { error: error.slice(0, 64) });
|
|
2267
|
+
return fail(`provider_${error.slice(0, 32)}`, `The sign-in at ${this.#providerHost} did not finish. Click Connect to try again.`);
|
|
2268
|
+
}
|
|
2269
|
+
const code = params.get("code");
|
|
2270
|
+
if (code === null || code === "") {
|
|
2271
|
+
return fail("no_code", "The provider sent no sign-in result. Click Connect to try again.");
|
|
2272
|
+
}
|
|
2273
|
+
let subject;
|
|
2274
|
+
try {
|
|
2275
|
+
subject = await this.#signIn(code, flow);
|
|
2276
|
+
} catch (e) {
|
|
2277
|
+
const reason = e instanceof OidcError || e instanceof JwtError ? e.reason : "exchange";
|
|
2278
|
+
this.#logger.warn("oidc.sign_in_failed", {
|
|
2279
|
+
reason,
|
|
2280
|
+
message: e instanceof Error ? e.message.slice(0, 200) : "unknown"
|
|
2281
|
+
});
|
|
2282
|
+
return fail(reason, `The sign-in at ${this.#providerHost} could not be checked. Click Connect to try again.`);
|
|
2283
|
+
}
|
|
2284
|
+
const target = targetFor(requestId);
|
|
2285
|
+
if (target === undefined) {
|
|
2286
|
+
return fail("page_closed", "The page is not open any more. Open it and try again.");
|
|
2287
|
+
}
|
|
2288
|
+
if (target.identity === undefined) {
|
|
2289
|
+
return fail("signed_out", "Nobody is signed in to the page any more. Sign in there first, then click Connect here again.");
|
|
2290
|
+
}
|
|
2291
|
+
const key = identityKey(this.#config.issuer, subject);
|
|
2292
|
+
if (key !== target.identity) {
|
|
2293
|
+
this.#mismatched.add(requestId);
|
|
2294
|
+
if (this.#mismatched.size > MAX_FLOWS) {
|
|
2295
|
+
const oldest = this.#mismatched.values().next().value;
|
|
2296
|
+
if (oldest !== undefined)
|
|
2297
|
+
this.#mismatched.delete(oldest);
|
|
2298
|
+
}
|
|
2299
|
+
return fail("mismatch", `${MISMATCH_TEXT} Click Connect to sign in with the page's account, or Cancel.`);
|
|
2300
|
+
}
|
|
2301
|
+
this.#mismatched.delete(requestId);
|
|
2302
|
+
return { requestId, result: { ok: true, subject: key } };
|
|
2303
|
+
}
|
|
2304
|
+
async#signIn(code, flow) {
|
|
2305
|
+
const metadata = await this.#discovery.get();
|
|
2306
|
+
const body = new URLSearchParams({
|
|
2307
|
+
grant_type: "authorization_code",
|
|
2308
|
+
code,
|
|
2309
|
+
redirect_uri: this.callbackUrl,
|
|
2310
|
+
code_verifier: flow.verifier
|
|
2311
|
+
});
|
|
2312
|
+
const headers = {
|
|
2313
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
2314
|
+
};
|
|
2315
|
+
const secret = this.#config.clientSecret;
|
|
2316
|
+
if (secret !== undefined && metadata.tokenAuth === "client_secret_basic") {
|
|
2317
|
+
const enc = (v) => encodeURIComponent(v).replace(/%20/g, "+");
|
|
2318
|
+
headers["authorization"] = `Basic ${btoa(`${enc(this.#config.clientId)}:${enc(secret)}`)}`;
|
|
2319
|
+
} else {
|
|
2320
|
+
body.set("client_id", this.#config.clientId);
|
|
2321
|
+
if (secret !== undefined)
|
|
2322
|
+
body.set("client_secret", secret);
|
|
2323
|
+
}
|
|
2324
|
+
let response;
|
|
2325
|
+
try {
|
|
2326
|
+
response = await fetchJson(metadata.tokenEndpoint, { method: "POST", headers, body }, MAX_TOKEN_RESPONSE_BYTES);
|
|
2327
|
+
} catch (e) {
|
|
2328
|
+
if (e instanceof HttpJsonError) {
|
|
2329
|
+
throw new OidcError(`token_${e.error || e.status}`, e.message);
|
|
2330
|
+
}
|
|
2331
|
+
throw e;
|
|
2332
|
+
}
|
|
2333
|
+
const idToken = str(response?.["id_token"]);
|
|
2334
|
+
if (idToken === undefined) {
|
|
2335
|
+
throw new OidcError("no_id_token", "the token response has no id_token");
|
|
2336
|
+
}
|
|
2337
|
+
const { payload } = await verifyJwt(idToken, this.#jwks);
|
|
2338
|
+
const now = Date.now();
|
|
2339
|
+
if (payload["iss"] !== metadata.issuer) {
|
|
2340
|
+
throw new OidcError("id_token_iss", "the ID token's issuer is wrong");
|
|
2341
|
+
}
|
|
2342
|
+
const aud = audiences(payload);
|
|
2343
|
+
if (!aud.includes(this.#config.clientId)) {
|
|
2344
|
+
throw new OidcError("id_token_aud", "the ID token is not for this relay");
|
|
2345
|
+
}
|
|
2346
|
+
const azp = payload["azp"];
|
|
2347
|
+
if ((aud.length > 1 || azp !== undefined) && azp !== this.#config.clientId) {
|
|
2348
|
+
throw new OidcError("id_token_azp", "the ID token's azp is not this relay");
|
|
2349
|
+
}
|
|
2350
|
+
const exp = numericDate(payload, "exp");
|
|
2351
|
+
if (exp === undefined || exp <= now - CLOCK_LEEWAY_MS) {
|
|
2352
|
+
throw new OidcError("id_token_exp", "the ID token has expired");
|
|
2353
|
+
}
|
|
2354
|
+
const iat = numericDate(payload, "iat");
|
|
2355
|
+
if (iat === undefined || iat > now + CLOCK_LEEWAY_MS) {
|
|
2356
|
+
throw new OidcError("id_token_iat", "the ID token's iat is missing or in the future");
|
|
2357
|
+
}
|
|
2358
|
+
if (payload["nonce"] !== flow.nonce) {
|
|
2359
|
+
throw new OidcError("id_token_nonce", "the ID token's nonce does not match");
|
|
2360
|
+
}
|
|
2361
|
+
const claim = this.#config.subjectClaim ?? "sub";
|
|
2362
|
+
const subject = payload[claim];
|
|
2363
|
+
if (typeof subject !== "string" || subject === "" || subject.length > 255) {
|
|
2364
|
+
throw new OidcError("id_token_subject", `the ID token has no ${claim}`);
|
|
2365
|
+
}
|
|
2366
|
+
return subject;
|
|
2367
|
+
}
|
|
2368
|
+
#unavailable(error) {
|
|
2369
|
+
this.#logger.warn("oidc.discovery_failed", {
|
|
2370
|
+
reason: error instanceof OidcError ? error.reason : "fetch",
|
|
2371
|
+
message: error instanceof Error ? error.message.slice(0, 200) : "unknown"
|
|
2372
|
+
});
|
|
2373
|
+
return {
|
|
2374
|
+
ok: false,
|
|
2375
|
+
message: `The sign-in provider ${this.#providerHost} cannot be reached right now. Try again in a minute.`,
|
|
2376
|
+
reason: "provider_unavailable"
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
#sweep() {
|
|
2380
|
+
const now = Date.now();
|
|
2381
|
+
for (const [state, flow] of this.#flows) {
|
|
2382
|
+
if (flow.expiresAt > now)
|
|
2383
|
+
break;
|
|
2384
|
+
this.#flows.delete(state);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
1572
2389
|
// src/oauth/pairing.ts
|
|
1573
2390
|
import { timingSafeEqual } from "crypto";
|
|
1574
2391
|
var PAIRING_CODE_TTL_MS = 120000;
|
|
@@ -1720,7 +2537,7 @@ function constantTimeEqual(a, b) {
|
|
|
1720
2537
|
const y = Buffer.from(b);
|
|
1721
2538
|
return x.length === y.length && timingSafeEqual2(x, y);
|
|
1722
2539
|
}
|
|
1723
|
-
function
|
|
2540
|
+
function s2562(verifier) {
|
|
1724
2541
|
return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1725
2542
|
}
|
|
1726
2543
|
var VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
@@ -1735,7 +2552,7 @@ function single(params, names) {
|
|
|
1735
2552
|
}
|
|
1736
2553
|
return out;
|
|
1737
2554
|
}
|
|
1738
|
-
function
|
|
2555
|
+
function random3(bytes) {
|
|
1739
2556
|
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1740
2557
|
}
|
|
1741
2558
|
function formTarget(redirectUri) {
|
|
@@ -1764,7 +2581,7 @@ class AuthorizationServer {
|
|
|
1764
2581
|
#codes = new Map;
|
|
1765
2582
|
#authCodeTtlMs;
|
|
1766
2583
|
#methods;
|
|
1767
|
-
#csrfSecret =
|
|
2584
|
+
#csrfSecret = random3(32);
|
|
1768
2585
|
#viewsPerIp = new RateLimiter({
|
|
1769
2586
|
capacity: 30,
|
|
1770
2587
|
refillPerSecond: 0.5
|
|
@@ -1785,7 +2602,19 @@ class AuthorizationServer {
|
|
|
1785
2602
|
constructor(host, testMode) {
|
|
1786
2603
|
this.#host = host;
|
|
1787
2604
|
this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
|
|
1788
|
-
|
|
2605
|
+
const oidc = host.config.oidc;
|
|
2606
|
+
this.#methods = {
|
|
2607
|
+
...PAIRING_METHODS,
|
|
2608
|
+
...oidc === undefined ? {} : {
|
|
2609
|
+
oidc: new OidcPairing({
|
|
2610
|
+
config: oidc,
|
|
2611
|
+
publicUrl: () => host.publicUrl(),
|
|
2612
|
+
logger: host.logger,
|
|
2613
|
+
development: host.config.development
|
|
2614
|
+
})
|
|
2615
|
+
},
|
|
2616
|
+
...testMode?.pairingMethods
|
|
2617
|
+
};
|
|
1789
2618
|
this.#cimd = new ClientMetadataFetcher({
|
|
1790
2619
|
allowedHosts: host.config.clientIdHosts,
|
|
1791
2620
|
logger: host.logger,
|
|
@@ -1996,12 +2825,23 @@ class AuthorizationServer {
|
|
|
1996
2825
|
if (this.#host.session(addressId) === undefined) {
|
|
1997
2826
|
return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
|
|
1998
2827
|
}
|
|
2828
|
+
const method = this.#methods[this.#host.session(addressId)?.site.pairing ?? "code"];
|
|
2829
|
+
if (method?.prepare !== undefined) {
|
|
2830
|
+
try {
|
|
2831
|
+
await method.prepare();
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
this.#host.logger.warn("oidc.discovery_failed", {
|
|
2834
|
+
message: error instanceof Error ? error.message.slice(0, 200) : "unknown"
|
|
2835
|
+
});
|
|
2836
|
+
return this.#errorPage(503, "The sign-in provider of this page cannot be reached right now. Wait a minute and try again.");
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
1999
2839
|
this.#sweepPending();
|
|
2000
2840
|
if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
|
|
2001
2841
|
return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
|
|
2002
2842
|
}
|
|
2003
2843
|
const pending = {
|
|
2004
|
-
id:
|
|
2844
|
+
id: random3(24),
|
|
2005
2845
|
expiresAt: Date.now() + PENDING_TTL_MS,
|
|
2006
2846
|
ip,
|
|
2007
2847
|
client,
|
|
@@ -2098,7 +2938,25 @@ ${errorHtml}
|
|
|
2098
2938
|
return this.#errorPage(500, "This page's pairing method is not available.");
|
|
2099
2939
|
}
|
|
2100
2940
|
const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
|
|
2101
|
-
const result = await method.check(session, form, admit
|
|
2941
|
+
const result = await method.check(session, form, admit, {
|
|
2942
|
+
requestId: pending.id,
|
|
2943
|
+
browserId: browser.id
|
|
2944
|
+
});
|
|
2945
|
+
if (result.ok === "redirect") {
|
|
2946
|
+
this.#host.logger.info("oauth.pairing_redirect", {
|
|
2947
|
+
address: shortAddress(pending.addressId),
|
|
2948
|
+
client: pending.client.host,
|
|
2949
|
+
method: method.kind
|
|
2950
|
+
});
|
|
2951
|
+
return new Response(null, {
|
|
2952
|
+
status: 303,
|
|
2953
|
+
headers: {
|
|
2954
|
+
location: result.location,
|
|
2955
|
+
"cache-control": "no-store",
|
|
2956
|
+
"referrer-policy": "no-referrer"
|
|
2957
|
+
}
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2102
2960
|
if (!result.ok) {
|
|
2103
2961
|
this.#host.logger.info("oauth.pairing_failed", {
|
|
2104
2962
|
address: shortAddress(pending.addressId),
|
|
@@ -2107,20 +2965,24 @@ ${errorHtml}
|
|
|
2107
2965
|
});
|
|
2108
2966
|
return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
|
|
2109
2967
|
}
|
|
2968
|
+
return this.#paired(pending, method.kind, result);
|
|
2969
|
+
}
|
|
2970
|
+
#paired(pending, kind, result) {
|
|
2110
2971
|
this.#dropPending(pending);
|
|
2111
|
-
const code =
|
|
2972
|
+
const code = random3(32);
|
|
2112
2973
|
if (this.#codes.size >= MAX_CODES)
|
|
2113
2974
|
this.#sweepCodes(true);
|
|
2114
2975
|
const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
|
|
2115
2976
|
this.#codes.set(hashToken(code), {
|
|
2116
2977
|
...request,
|
|
2117
2978
|
expiresAt: Date.now() + this.#authCodeTtlMs,
|
|
2118
|
-
used: false
|
|
2979
|
+
used: false,
|
|
2980
|
+
...result.subject === undefined ? {} : { subject: result.subject }
|
|
2119
2981
|
});
|
|
2120
2982
|
this.#host.logger.info("oauth.paired", {
|
|
2121
2983
|
address: shortAddress(pending.addressId),
|
|
2122
2984
|
client: pending.client.host,
|
|
2123
|
-
method:
|
|
2985
|
+
method: kind
|
|
2124
2986
|
});
|
|
2125
2987
|
return this.#redirect(pending.redirectUri, {
|
|
2126
2988
|
code,
|
|
@@ -2128,6 +2990,40 @@ ${errorHtml}
|
|
|
2128
2990
|
iss: this.#issuer
|
|
2129
2991
|
});
|
|
2130
2992
|
}
|
|
2993
|
+
async authorizeCallback(req, ip) {
|
|
2994
|
+
if (!this.#viewsPerIp.take(ip)) {
|
|
2995
|
+
return this.#errorPage(429, "Too many requests from your network. Wait a minute and try again.");
|
|
2996
|
+
}
|
|
2997
|
+
const method = this.#methods.oidc;
|
|
2998
|
+
if (method?.callback === undefined) {
|
|
2999
|
+
return new Response("Not found", { status: 404 });
|
|
3000
|
+
}
|
|
3001
|
+
const browser = this.#browserId(req);
|
|
3002
|
+
if (browser.cookie !== undefined) {
|
|
3003
|
+
return this.#errorPage(403, "The sign-in could not be checked. Your browser may block cookies for this site; start again from your AI app.");
|
|
3004
|
+
}
|
|
3005
|
+
this.#sweepPending();
|
|
3006
|
+
const params = new URL(req.url).searchParams;
|
|
3007
|
+
const { requestId, result } = await method.callback(params, browser.id, (id) => {
|
|
3008
|
+
const p = this.#pending.get(id);
|
|
3009
|
+
return p === undefined ? undefined : this.#host.session(p.addressId);
|
|
3010
|
+
});
|
|
3011
|
+
const pending = requestId === undefined ? undefined : this.#pending.get(requestId);
|
|
3012
|
+
if (pending === undefined || pending.expiresAt <= Date.now()) {
|
|
3013
|
+
const message = result.ok === false ? result.message : "This connection request has expired or was already used.";
|
|
3014
|
+
return this.#errorPage(400, message);
|
|
3015
|
+
}
|
|
3016
|
+
if (result.ok === true)
|
|
3017
|
+
return this.#paired(pending, method.kind, result);
|
|
3018
|
+
const message = result.ok === false ? result.message : "Something went wrong.";
|
|
3019
|
+
const reason = result.ok === false ? result.reason : "unexpected";
|
|
3020
|
+
this.#host.logger.info("oauth.pairing_failed", {
|
|
3021
|
+
address: shortAddress(pending.addressId),
|
|
3022
|
+
client: pending.client.host,
|
|
3023
|
+
reason
|
|
3024
|
+
});
|
|
3025
|
+
return this.#pairingPage(pending, browser.id, message, reason === "mismatch" ? 403 : 400);
|
|
3026
|
+
}
|
|
2131
3027
|
#addressOf(resource) {
|
|
2132
3028
|
if (resource === undefined)
|
|
2133
3029
|
return;
|
|
@@ -2155,7 +3051,7 @@ ${errorHtml}
|
|
|
2155
3051
|
return { id: v };
|
|
2156
3052
|
}
|
|
2157
3053
|
}
|
|
2158
|
-
const id =
|
|
3054
|
+
const id = random3(16);
|
|
2159
3055
|
const secure = this.#issuer.startsWith("https:") ? "; Secure" : "";
|
|
2160
3056
|
return {
|
|
2161
3057
|
id,
|
|
@@ -2206,6 +3102,7 @@ ${cancel}
|
|
|
2206
3102
|
if (method === undefined) {
|
|
2207
3103
|
return this.#errorPage(500, "This page's pairing method is not available.");
|
|
2208
3104
|
}
|
|
3105
|
+
targets.push(...method.formTargets?.() ?? []);
|
|
2209
3106
|
const page = session.origin === "" ? "a page that did not say where it runs" : `<span class="origin">${escapeHtml(session.origin)}</span>`;
|
|
2210
3107
|
const target = session.appName === undefined ? `tools from ${page}` : `<strong>${escapeHtml(session.appName)}</strong> on ${page}`;
|
|
2211
3108
|
const heading = `Connect ${client.name} from ${client.host}`;
|
|
@@ -2328,9 +3225,12 @@ ${cancel}
|
|
|
2328
3225
|
if (p["resource"] !== undefined && p["resource"] !== issued.resource) {
|
|
2329
3226
|
throw new OAuthError(400, "invalid_target", "resource does not match the authorization request");
|
|
2330
3227
|
}
|
|
2331
|
-
if (!VERIFIER.test(verifier) || !constantTimeEqual(
|
|
3228
|
+
if (!VERIFIER.test(verifier) || !constantTimeEqual(s2562(verifier), issued.codeChallenge)) {
|
|
2332
3229
|
throw invalid("code_verifier does not match the code_challenge");
|
|
2333
3230
|
}
|
|
3231
|
+
if (issued.subject !== undefined && this.#host.session(issued.addressId)?.identity !== issued.subject) {
|
|
3232
|
+
throw invalid("the page's user signed out or changed");
|
|
3233
|
+
}
|
|
2334
3234
|
const now = Date.now();
|
|
2335
3235
|
const grantId = newId("g");
|
|
2336
3236
|
issued.grantId = grantId;
|
|
@@ -2341,7 +3241,8 @@ ${cancel}
|
|
|
2341
3241
|
clientId: issued.client.clientId,
|
|
2342
3242
|
clientName: issued.client.name,
|
|
2343
3243
|
scopes: issued.scopes,
|
|
2344
|
-
grantCreatedAt: now
|
|
3244
|
+
grantCreatedAt: now,
|
|
3245
|
+
...issued.subject === undefined ? {} : { subject: issued.subject }
|
|
2345
3246
|
}, {
|
|
2346
3247
|
accessTtlMs: config.accessTokenTtlMs,
|
|
2347
3248
|
refreshTtlMs: config.refreshTokenTtlMs,
|
|
@@ -2416,7 +3317,8 @@ ${cancel}
|
|
|
2416
3317
|
clientId: record.clientId,
|
|
2417
3318
|
clientName: record.clientName,
|
|
2418
3319
|
scopes: record.scopes,
|
|
2419
|
-
grantCreatedAt: record.grantCreatedAt
|
|
3320
|
+
grantCreatedAt: record.grantCreatedAt,
|
|
3321
|
+
...record.subject === undefined ? {} : { subject: record.subject }
|
|
2420
3322
|
}, {
|
|
2421
3323
|
accessTtlMs: this.#host.config.accessTokenTtlMs,
|
|
2422
3324
|
refreshTtlMs: this.#host.config.refreshTokenTtlMs,
|
|
@@ -2552,10 +3454,13 @@ class TabSession {
|
|
|
2552
3454
|
addressId;
|
|
2553
3455
|
appName;
|
|
2554
3456
|
codes;
|
|
3457
|
+
identity;
|
|
2555
3458
|
pending = new Map;
|
|
2556
3459
|
#ws;
|
|
2557
3460
|
#host;
|
|
2558
3461
|
#state = "new";
|
|
3462
|
+
#dialogOpen = false;
|
|
3463
|
+
#identityChain = Promise.resolve();
|
|
2559
3464
|
#lastRequestAt = Date.now();
|
|
2560
3465
|
#missedPongs = 0;
|
|
2561
3466
|
#nextRequest = 0;
|
|
@@ -2584,6 +3489,9 @@ class TabSession {
|
|
|
2584
3489
|
get ready() {
|
|
2585
3490
|
return this.#state === "ready";
|
|
2586
3491
|
}
|
|
3492
|
+
get pairingOpen() {
|
|
3493
|
+
return this.site.pairing === "code" ? this.codes.active : this.#dialogOpen;
|
|
3494
|
+
}
|
|
2587
3495
|
get limits() {
|
|
2588
3496
|
return this.site.limits;
|
|
2589
3497
|
}
|
|
@@ -2672,9 +3580,18 @@ class TabSession {
|
|
|
2672
3580
|
case "pairing.start":
|
|
2673
3581
|
if (this.site.pairing === "code")
|
|
2674
3582
|
this.codes.start();
|
|
3583
|
+
else
|
|
3584
|
+
this.#dialogOpen = true;
|
|
2675
3585
|
return;
|
|
2676
3586
|
case "pairing.stop":
|
|
2677
3587
|
this.codes.stop();
|
|
3588
|
+
this.#dialogOpen = false;
|
|
3589
|
+
return;
|
|
3590
|
+
case "identity":
|
|
3591
|
+
await this.#serialIdentity(() => this.#host.bindIdentity(this, msg.assertion, msg.round));
|
|
3592
|
+
return;
|
|
3593
|
+
case "identity.logout":
|
|
3594
|
+
await this.#serialIdentity(() => this.#host.signOut(this));
|
|
2678
3595
|
return;
|
|
2679
3596
|
case "grant.revoke":
|
|
2680
3597
|
if (!await this.#host.revokeGrant(this, msg.grantId)) {
|
|
@@ -2754,6 +3671,7 @@ class TabSession {
|
|
|
2754
3671
|
address: `${this.#host.publicUrl()}/a/${this.addressId}/mcp`,
|
|
2755
3672
|
origin: this.origin,
|
|
2756
3673
|
...this.appName === undefined ? {} : { appName: this.appName },
|
|
3674
|
+
pairing: this.site.pairing,
|
|
2757
3675
|
expiresAt: new Date(expiresAt).toISOString(),
|
|
2758
3676
|
limits: limitsOf(limits),
|
|
2759
3677
|
grants
|
|
@@ -2773,6 +3691,11 @@ class TabSession {
|
|
|
2773
3691
|
});
|
|
2774
3692
|
this.#host.ready?.(this);
|
|
2775
3693
|
}
|
|
3694
|
+
#serialIdentity(run) {
|
|
3695
|
+
const next = this.#identityChain.then(run);
|
|
3696
|
+
this.#identityChain = next.catch(() => {});
|
|
3697
|
+
return next;
|
|
3698
|
+
}
|
|
2776
3699
|
#tick() {
|
|
2777
3700
|
if (this.#state !== "ready")
|
|
2778
3701
|
return;
|
|
@@ -2847,6 +3770,7 @@ class TabSession {
|
|
|
2847
3770
|
const wasReady = this.#state === "ready" || this.#state === "hello";
|
|
2848
3771
|
this.#state = "ended";
|
|
2849
3772
|
this.codes.stop();
|
|
3773
|
+
this.#dialogOpen = false;
|
|
2850
3774
|
clearTimeout(this.#helloTimer);
|
|
2851
3775
|
clearInterval(this.#heartbeatTimer);
|
|
2852
3776
|
clearTimeout(this.#lifetimeTimer);
|
|
@@ -2886,13 +3810,16 @@ function summarizeGrants(records, now) {
|
|
|
2886
3810
|
for (const r of records) {
|
|
2887
3811
|
if (!isLive(r, now) || grants.has(r.grantId))
|
|
2888
3812
|
continue;
|
|
2889
|
-
|
|
3813
|
+
const summary = {
|
|
2890
3814
|
grantId: r.grantId,
|
|
2891
3815
|
clientId: r.clientId,
|
|
2892
3816
|
clientName: r.clientName,
|
|
2893
3817
|
scopes: [...r.scopes],
|
|
2894
3818
|
createdAt: r.grantCreatedAt
|
|
2895
|
-
}
|
|
3819
|
+
};
|
|
3820
|
+
if (r.subject !== undefined)
|
|
3821
|
+
summary.subject = r.subject;
|
|
3822
|
+
grants.set(r.grantId, summary);
|
|
2896
3823
|
}
|
|
2897
3824
|
return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
|
|
2898
3825
|
}
|
|
@@ -2972,7 +3899,7 @@ class MemoryTokenStore {
|
|
|
2972
3899
|
|
|
2973
3900
|
// src/store/sqlite.ts
|
|
2974
3901
|
import { Database } from "bun:sqlite";
|
|
2975
|
-
var SCHEMA_VERSION =
|
|
3902
|
+
var SCHEMA_VERSION = 3;
|
|
2976
3903
|
function toRecord(row) {
|
|
2977
3904
|
const record = {
|
|
2978
3905
|
hash: row.hash,
|
|
@@ -2988,6 +3915,8 @@ function toRecord(row) {
|
|
|
2988
3915
|
};
|
|
2989
3916
|
if (row.used_at !== null)
|
|
2990
3917
|
record.usedAt = row.used_at;
|
|
3918
|
+
if (row.subject !== null)
|
|
3919
|
+
record.subject = row.subject;
|
|
2991
3920
|
return record;
|
|
2992
3921
|
}
|
|
2993
3922
|
|
|
@@ -3023,8 +3952,13 @@ class SqliteTokenStore {
|
|
|
3023
3952
|
grant_created_at INTEGER NOT NULL,
|
|
3024
3953
|
issued_at INTEGER NOT NULL,
|
|
3025
3954
|
expires_at INTEGER NOT NULL,
|
|
3026
|
-
used_at INTEGER
|
|
3955
|
+
used_at INTEGER,
|
|
3956
|
+
subject TEXT
|
|
3027
3957
|
) STRICT`);
|
|
3958
|
+
const columns = this.#db.query("PRAGMA table_info(tokens)").all();
|
|
3959
|
+
if (!columns.some((c) => c.name === "subject")) {
|
|
3960
|
+
this.#db.run("ALTER TABLE tokens ADD COLUMN subject TEXT");
|
|
3961
|
+
}
|
|
3028
3962
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
|
|
3029
3963
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
|
|
3030
3964
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
|
|
@@ -3047,9 +3981,9 @@ class SqliteTokenStore {
|
|
|
3047
3981
|
}
|
|
3048
3982
|
#insert(r) {
|
|
3049
3983
|
this.#db.query(`INSERT OR REPLACE INTO tokens (hash, kind, grant_id, address_id, client_id,
|
|
3050
|
-
client_name, scopes, grant_created_at, issued_at, expires_at, used_at)
|
|
3984
|
+
client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject)
|
|
3051
3985
|
VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
|
|
3052
|
-
$scopes, $grant_created_at, $issued_at, $expires_at, $used_at)`).run({
|
|
3986
|
+
$scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject)`).run({
|
|
3053
3987
|
hash: r.hash,
|
|
3054
3988
|
kind: r.kind,
|
|
3055
3989
|
grant_id: r.grantId,
|
|
@@ -3060,7 +3994,8 @@ class SqliteTokenStore {
|
|
|
3060
3994
|
grant_created_at: r.grantCreatedAt,
|
|
3061
3995
|
issued_at: r.issuedAt,
|
|
3062
3996
|
expires_at: r.expiresAt,
|
|
3063
|
-
used_at: r.usedAt ?? null
|
|
3997
|
+
used_at: r.usedAt ?? null,
|
|
3998
|
+
subject: r.subject ?? null
|
|
3064
3999
|
});
|
|
3065
4000
|
}
|
|
3066
4001
|
async get(hash) {
|
|
@@ -3171,6 +4106,7 @@ function startRelay(options, hooks) {
|
|
|
3171
4106
|
const appNames = new Map;
|
|
3172
4107
|
let claudeWorkarounds = 0;
|
|
3173
4108
|
let publicUrl = config.publicUrl ?? "";
|
|
4109
|
+
const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
|
|
3174
4110
|
const corsHeaders = config.cors ? {
|
|
3175
4111
|
"access-control-allow-origin": "*",
|
|
3176
4112
|
"access-control-expose-headers": "www-authenticate, mcp-protocol-version"
|
|
@@ -3218,6 +4154,105 @@ function startRelay(options, hooks) {
|
|
|
3218
4154
|
for (const g of grants)
|
|
3219
4155
|
grantRemoved(addressId, g.grantId, "revoked");
|
|
3220
4156
|
},
|
|
4157
|
+
async bindIdentity(session, assertion, round) {
|
|
4158
|
+
const addressId = session.addressId;
|
|
4159
|
+
if (addressId === undefined)
|
|
4160
|
+
return;
|
|
4161
|
+
const echo = round === undefined ? {} : { round };
|
|
4162
|
+
const answer = (message) => {
|
|
4163
|
+
if (!session.send(message)) {
|
|
4164
|
+
logger.warn("identity.answer_dropped", {
|
|
4165
|
+
address: shortAddress(addressId),
|
|
4166
|
+
type: message.type
|
|
4167
|
+
});
|
|
4168
|
+
}
|
|
4169
|
+
};
|
|
4170
|
+
const refuse = (reason, message) => {
|
|
4171
|
+
if (session.identity !== undefined) {
|
|
4172
|
+
session.identity = undefined;
|
|
4173
|
+
failPending(session, "The page's user is no longer signed in.");
|
|
4174
|
+
}
|
|
4175
|
+
logger.info("identity.refused", {
|
|
4176
|
+
address: shortAddress(addressId),
|
|
4177
|
+
reason
|
|
4178
|
+
});
|
|
4179
|
+
answer({ type: "error", code: "identity_refused", message, ...echo });
|
|
4180
|
+
};
|
|
4181
|
+
if (session.site.pairing !== "oidc" || assertions === undefined) {
|
|
4182
|
+
refuse("not_oidc", "this page's site does not use identity binding");
|
|
4183
|
+
return;
|
|
4184
|
+
}
|
|
4185
|
+
let key;
|
|
4186
|
+
try {
|
|
4187
|
+
key = await assertions.verify(assertion, `${publicUrl}/a/${addressId}/mcp`);
|
|
4188
|
+
} catch (error) {
|
|
4189
|
+
refuse(error instanceof JwtError ? error.reason : "error", `the identity assertion was refused: ${error instanceof JwtError ? error.message : "the relay could not check it"}`);
|
|
4190
|
+
return;
|
|
4191
|
+
}
|
|
4192
|
+
if (!session.ready)
|
|
4193
|
+
return;
|
|
4194
|
+
const changed = session.identity !== key;
|
|
4195
|
+
if (changed && session.identity !== undefined) {
|
|
4196
|
+
session.identity = undefined;
|
|
4197
|
+
failPending(session, "The page's user changed.");
|
|
4198
|
+
}
|
|
4199
|
+
let revoked = 0;
|
|
4200
|
+
try {
|
|
4201
|
+
const grants = await store.listGrants(addressId, Date.now());
|
|
4202
|
+
for (const g of grants.filter((g) => g.subject !== key)) {
|
|
4203
|
+
await store.revoke({ grantId: g.grantId }, revokeOptions());
|
|
4204
|
+
grantRemoved(addressId, g.grantId, "revoked");
|
|
4205
|
+
revoked++;
|
|
4206
|
+
}
|
|
4207
|
+
} catch (error) {
|
|
4208
|
+
logger.error("store.error", {
|
|
4209
|
+
op: "identity",
|
|
4210
|
+
message: error instanceof Error ? error.message : "unknown"
|
|
4211
|
+
});
|
|
4212
|
+
refuse("store_unavailable", "the relay cannot check grants right now");
|
|
4213
|
+
return;
|
|
4214
|
+
}
|
|
4215
|
+
if (!session.ready)
|
|
4216
|
+
return;
|
|
4217
|
+
session.identity = key;
|
|
4218
|
+
answer({ type: "identity.bound", ...echo });
|
|
4219
|
+
logger.info("identity.bound", {
|
|
4220
|
+
address: shortAddress(addressId),
|
|
4221
|
+
changed,
|
|
4222
|
+
revoked
|
|
4223
|
+
});
|
|
4224
|
+
host.ready(session);
|
|
4225
|
+
},
|
|
4226
|
+
async signOut(session) {
|
|
4227
|
+
const addressId = session.addressId;
|
|
4228
|
+
if (addressId === undefined)
|
|
4229
|
+
return;
|
|
4230
|
+
if (session.site.pairing !== "oidc") {
|
|
4231
|
+
session.send({
|
|
4232
|
+
type: "error",
|
|
4233
|
+
code: "identity_refused",
|
|
4234
|
+
message: "this page's site does not use identity binding"
|
|
4235
|
+
});
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
session.identity = undefined;
|
|
4239
|
+
failPending(session, "The page's user signed out.");
|
|
4240
|
+
try {
|
|
4241
|
+
const grants = await store.listGrants(addressId, Date.now());
|
|
4242
|
+
await store.revoke({ addressId }, revokeOptions());
|
|
4243
|
+
logger.info("identity.signed_out", {
|
|
4244
|
+
address: shortAddress(addressId),
|
|
4245
|
+
grants: grants.length
|
|
4246
|
+
});
|
|
4247
|
+
for (const g of grants)
|
|
4248
|
+
grantRemoved(addressId, g.grantId, "revoked");
|
|
4249
|
+
} catch (error) {
|
|
4250
|
+
logger.error("store.error", {
|
|
4251
|
+
op: "signOut",
|
|
4252
|
+
message: error instanceof Error ? error.message : "unknown"
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4255
|
+
},
|
|
3221
4256
|
claimAddress(session) {
|
|
3222
4257
|
const id = session.addressId;
|
|
3223
4258
|
if (id === undefined)
|
|
@@ -3252,12 +4287,20 @@ function startRelay(options, hooks) {
|
|
|
3252
4287
|
wake(session);
|
|
3253
4288
|
}
|
|
3254
4289
|
};
|
|
3255
|
-
function
|
|
4290
|
+
function failPending(session, message) {
|
|
4291
|
+
for (const exchange of [...session.pending.values()]) {
|
|
4292
|
+
exchange.fail(401, RelayErrorCode.signedOut, message, true);
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
function readySession(addressId, usable = () => true) {
|
|
3256
4296
|
const s = sessions.get(addressId);
|
|
3257
|
-
return s?.ready === true ? s : undefined;
|
|
4297
|
+
return s?.ready === true && usable(s) ? s : undefined;
|
|
4298
|
+
}
|
|
4299
|
+
function usableFor(subject) {
|
|
4300
|
+
return (s) => subject === undefined && s.site.pairing === "code" || s.identity !== undefined;
|
|
3258
4301
|
}
|
|
3259
|
-
function waitForPage(addressId, signal, waitMs) {
|
|
3260
|
-
const now = readySession(addressId);
|
|
4302
|
+
function waitForPage(addressId, signal, waitMs, usable) {
|
|
4303
|
+
const now = readySession(addressId, usable);
|
|
3261
4304
|
if (now !== undefined)
|
|
3262
4305
|
return Promise.resolve(now);
|
|
3263
4306
|
if (waitMs === 0 || stopping !== undefined) {
|
|
@@ -3283,7 +4326,10 @@ function startRelay(options, hooks) {
|
|
|
3283
4326
|
}
|
|
3284
4327
|
resolve(result);
|
|
3285
4328
|
};
|
|
3286
|
-
const wake = (session) =>
|
|
4329
|
+
const wake = (session) => {
|
|
4330
|
+
if (session === undefined || usable(session))
|
|
4331
|
+
done(session);
|
|
4332
|
+
};
|
|
3287
4333
|
const onAbort = () => done("aborted");
|
|
3288
4334
|
set.add(wake);
|
|
3289
4335
|
waiters.set(addressId, set);
|
|
@@ -3441,7 +4487,7 @@ function startRelay(options, hooks) {
|
|
|
3441
4487
|
}
|
|
3442
4488
|
function checkVersion(req, body, rpcId) {
|
|
3443
4489
|
const header = req.headers.get("mcp-protocol-version");
|
|
3444
|
-
if (body["method"] === "initialize") {
|
|
4490
|
+
if (body["method"] === "initialize" && !(header === MCP_PROTOCOL_VERSION && metaVersion(body) === MCP_PROTOCOL_VERSION)) {
|
|
3445
4491
|
const params = body["params"];
|
|
3446
4492
|
const requested = typeof params?.["protocolVersion"] === "string" ? params["protocolVersion"] : header ?? null;
|
|
3447
4493
|
return rpcError(400, rpcId, -32022, `Unsupported protocol version: ${String(requested)}. This server supports only ${MCP_PROTOCOL_VERSION}.`, corsHeaders, { supported: [MCP_PROTOCOL_VERSION], requested });
|
|
@@ -3517,18 +4563,26 @@ function startRelay(options, hooks) {
|
|
|
3517
4563
|
logDone(499, method, { outcome: "client_closed" });
|
|
3518
4564
|
return new Response(null, { status: 499 });
|
|
3519
4565
|
};
|
|
3520
|
-
|
|
4566
|
+
const usable = usableFor(auth.subject);
|
|
4567
|
+
if (readySession(addressId, usable) === undefined && waitMs > 0) {
|
|
3521
4568
|
server.timeout(req, 0);
|
|
3522
4569
|
}
|
|
3523
4570
|
let retried = false;
|
|
3524
4571
|
for (;; ) {
|
|
3525
|
-
const found = await waitForPage(addressId, req.signal, waitMs);
|
|
4572
|
+
const found = await waitForPage(addressId, req.signal, waitMs, usable);
|
|
3526
4573
|
if (found === "aborted" || req.signal.aborted)
|
|
3527
4574
|
return closed();
|
|
3528
4575
|
if (found === undefined) {
|
|
3529
4576
|
return pageNotOpen(body.value, rpcId, addressId, logDone, method);
|
|
3530
4577
|
}
|
|
3531
4578
|
const session = found;
|
|
4579
|
+
if ((auth.subject !== undefined || session.site.pairing === "oidc") && session.identity !== auth.subject) {
|
|
4580
|
+
logDone(401, method, { reason: "identity_mismatch" });
|
|
4581
|
+
return json(401, { error: "invalid_token", error_description: MISMATCH_TEXT }, {
|
|
4582
|
+
...corsHeaders,
|
|
4583
|
+
"www-authenticate": `${wwwAuthenticate(addressId, true)}, error_description="${MISMATCH_TEXT}"`
|
|
4584
|
+
});
|
|
4585
|
+
}
|
|
3532
4586
|
if (Buffer.byteLength(body.text) > session.limits.maxMessageBytes) {
|
|
3533
4587
|
logDone(413, method);
|
|
3534
4588
|
return rpcError(413, rpcId, -32600, "Request body too large.", corsHeaders);
|
|
@@ -3584,7 +4638,9 @@ function startRelay(options, hooks) {
|
|
|
3584
4638
|
}
|
|
3585
4639
|
}
|
|
3586
4640
|
function pageNotOpen(value, rpcId, addressId, logDone, method) {
|
|
3587
|
-
const
|
|
4641
|
+
const open = sessions.get(addressId);
|
|
4642
|
+
const phase = open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
|
|
4643
|
+
const answer = notOpenAnswer(value, rpcId, open?.appName ?? appNames.get(addressId), phase, RELAY_INFO);
|
|
3588
4644
|
if (answer === undefined) {
|
|
3589
4645
|
logDone(503, method, { reason: "page_not_open" });
|
|
3590
4646
|
return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
|
|
@@ -3653,6 +4709,9 @@ function startRelay(options, hooks) {
|
|
|
3653
4709
|
GET: (req, srv) => auth.authorize(req, ipOf(req, srv)),
|
|
3654
4710
|
POST: (req, srv) => auth.authorizeSubmit(req, ipOf(req, srv))
|
|
3655
4711
|
},
|
|
4712
|
+
"/authorize/callback": {
|
|
4713
|
+
GET: (req, srv) => auth.authorizeCallback(req, ipOf(req, srv))
|
|
4714
|
+
},
|
|
3656
4715
|
"/authorize.css": { GET: () => auth.stylesheet() },
|
|
3657
4716
|
"/token": {
|
|
3658
4717
|
POST: (req, srv) => auth.token(req, ipOf(req, srv)),
|
|
@@ -3765,6 +4824,7 @@ export {
|
|
|
3765
4824
|
MemoryTokenStore,
|
|
3766
4825
|
SUBPROTOCOL,
|
|
3767
4826
|
SqliteTokenStore,
|
|
4827
|
+
TAB_ASSERTION_TYP,
|
|
3768
4828
|
VERSION,
|
|
3769
4829
|
addressIdFromSecret,
|
|
3770
4830
|
configFromEnv,
|
|
@@ -3774,5 +4834,5 @@ export {
|
|
|
3774
4834
|
resolveConfig
|
|
3775
4835
|
};
|
|
3776
4836
|
|
|
3777
|
-
//# debugId=
|
|
4837
|
+
//# debugId=00790963557D76A664756E2164756E21
|
|
3778
4838
|
//# sourceMappingURL=index.js.map
|