@ractive-ch/tab-mcp 0.2.1 → 0.3.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 +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 +1102 -42
- package/index.js.map +17 -14
- package/jwt.d.ts +75 -0
- package/main.js +1112 -42
- 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/main.js
CHANGED
|
@@ -10,6 +10,7 @@ var DEFAULT_CONFIG = {
|
|
|
10
10
|
host: "0.0.0.0",
|
|
11
11
|
allowedOrigins: [],
|
|
12
12
|
sites: [],
|
|
13
|
+
identity: "code",
|
|
13
14
|
clientIdHosts: [],
|
|
14
15
|
trustProxy: "none",
|
|
15
16
|
cors: true,
|
|
@@ -77,6 +78,7 @@ function resolveConfig(input = {}) {
|
|
|
77
78
|
}
|
|
78
79
|
seen.add(site.origin);
|
|
79
80
|
}
|
|
81
|
+
validateIdentity(config);
|
|
80
82
|
config.clientIdHosts = config.clientIdHosts.map((h) => {
|
|
81
83
|
const host = h.trim().toLowerCase();
|
|
82
84
|
if (!/^[a-z0-9.-]+(:\d+)?$/.test(host)) {
|
|
@@ -159,13 +161,12 @@ function validateSite(input) {
|
|
|
159
161
|
out.name = site["name"];
|
|
160
162
|
}
|
|
161
163
|
const pairing = site["pairing"];
|
|
162
|
-
if (pairing !== undefined
|
|
163
|
-
if (pairing
|
|
164
|
-
throw new ConfigError(`pairing "
|
|
164
|
+
if (pairing !== undefined) {
|
|
165
|
+
if (pairing !== "code" && pairing !== "oidc") {
|
|
166
|
+
throw new ConfigError(`pairing must be "code" or "oidc": ${out.origin}`);
|
|
165
167
|
}
|
|
166
|
-
|
|
168
|
+
out.pairing = pairing;
|
|
167
169
|
}
|
|
168
|
-
out.pairing = "code";
|
|
169
170
|
const limits = site["limits"];
|
|
170
171
|
if (limits !== undefined) {
|
|
171
172
|
if (typeof limits !== "object" || limits === null) {
|
|
@@ -189,6 +190,57 @@ function validateSite(input) {
|
|
|
189
190
|
}
|
|
190
191
|
return out;
|
|
191
192
|
}
|
|
193
|
+
function checkUrl(value, name, development) {
|
|
194
|
+
if (typeof value !== "string" || value === "") {
|
|
195
|
+
throw new ConfigError(`${name} is required for identity binding`);
|
|
196
|
+
}
|
|
197
|
+
let url;
|
|
198
|
+
try {
|
|
199
|
+
url = new URL(value);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new ConfigError(`${name} is not a URL: ${value}`);
|
|
202
|
+
}
|
|
203
|
+
if (url.protocol !== "https:" && !(development && url.protocol === "http:")) {
|
|
204
|
+
throw new ConfigError(`${name} must be https: ${value}`);
|
|
205
|
+
}
|
|
206
|
+
if (url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") {
|
|
207
|
+
throw new ConfigError(`${name} has no query, fragment or credentials: ${value}`);
|
|
208
|
+
}
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
function validateIdentity(config) {
|
|
212
|
+
if (config.identity !== "code" && config.identity !== "oidc") {
|
|
213
|
+
throw new ConfigError('identity must be "code" or "oidc"');
|
|
214
|
+
}
|
|
215
|
+
const used = config.sites.some((s) => (s.pairing ?? config.identity) === "oidc") || config.identity === "oidc" && config.allowedOrigins !== "*" && config.allowedOrigins.length > 0;
|
|
216
|
+
if (config.identity === "oidc" && config.allowedOrigins === "*") {
|
|
217
|
+
throw new ConfigError("TAB_MCP_IDENTITY=oidc cannot be used with TAB_MCP_ALLOWED_ORIGINS=* (one provider cannot vouch for any website's users)");
|
|
218
|
+
}
|
|
219
|
+
const oidc = config.oidc;
|
|
220
|
+
if (oidc === undefined) {
|
|
221
|
+
if (used) {
|
|
222
|
+
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");
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const dev = config.development;
|
|
227
|
+
checkUrl(oidc.issuer, "TAB_MCP_OIDC_ISSUER", dev);
|
|
228
|
+
checkUrl(oidc.assertionJwksUrl, "TAB_MCP_TAB_ASSERTION_JWKS_URL", dev);
|
|
229
|
+
if (typeof oidc.clientId !== "string" || oidc.clientId === "") {
|
|
230
|
+
throw new ConfigError("TAB_MCP_OIDC_CLIENT_ID is required for identity binding");
|
|
231
|
+
}
|
|
232
|
+
if (oidc.clientSecret !== undefined && typeof oidc.clientSecret !== "string") {
|
|
233
|
+
throw new ConfigError("TAB_MCP_OIDC_CLIENT_SECRET must be a string");
|
|
234
|
+
}
|
|
235
|
+
if (typeof oidc.assertionIssuer !== "string" || oidc.assertionIssuer === "") {
|
|
236
|
+
throw new ConfigError("TAB_MCP_TAB_ASSERTION_ISSUER is required for identity binding");
|
|
237
|
+
}
|
|
238
|
+
const claim = oidc.subjectClaim ?? "sub";
|
|
239
|
+
if (claim !== "sub" && claim !== "oid") {
|
|
240
|
+
throw new ConfigError('TAB_MCP_OIDC_SUBJECT_CLAIM must be "sub" or "oid" (a stable identifier, never email or a user name)');
|
|
241
|
+
}
|
|
242
|
+
config.oidc = { ...oidc, subjectClaim: claim };
|
|
243
|
+
}
|
|
192
244
|
function defaultLimits(config) {
|
|
193
245
|
return {
|
|
194
246
|
maxMessageBytes: config.maxMessageBytes,
|
|
@@ -206,7 +258,7 @@ function siteFor(config, origin) {
|
|
|
206
258
|
if (configured !== undefined) {
|
|
207
259
|
const site = {
|
|
208
260
|
origin: configured.origin,
|
|
209
|
-
pairing: configured.pairing ??
|
|
261
|
+
pairing: configured.pairing ?? config.identity,
|
|
210
262
|
limits: { ...defaults, ...configured.limits }
|
|
211
263
|
};
|
|
212
264
|
if (configured.name !== undefined)
|
|
@@ -216,7 +268,7 @@ function siteFor(config, origin) {
|
|
|
216
268
|
if (config.allowedOrigins === "*" || origin !== null && config.allowedOrigins.includes(origin)) {
|
|
217
269
|
return {
|
|
218
270
|
origin: config.allowedOrigins === "*" ? "*" : origin ?? "",
|
|
219
|
-
pairing: "code",
|
|
271
|
+
pairing: config.allowedOrigins === "*" ? "code" : config.identity,
|
|
220
272
|
limits: defaults
|
|
221
273
|
};
|
|
222
274
|
}
|
|
@@ -300,6 +352,30 @@ function configFromEnv(env) {
|
|
|
300
352
|
if (hosts !== undefined && hosts.trim() !== "") {
|
|
301
353
|
out.clientIdHosts = hosts.split(",").map((h) => h.trim()).filter((h) => h !== "");
|
|
302
354
|
}
|
|
355
|
+
set("identity", oneOf(env, "TAB_MCP_IDENTITY", ["code", "oidc"]));
|
|
356
|
+
const oidcVars = {
|
|
357
|
+
issuer: env["TAB_MCP_OIDC_ISSUER"],
|
|
358
|
+
clientId: env["TAB_MCP_OIDC_CLIENT_ID"],
|
|
359
|
+
clientSecret: env["TAB_MCP_OIDC_CLIENT_SECRET"],
|
|
360
|
+
subjectClaim: env["TAB_MCP_OIDC_SUBJECT_CLAIM"],
|
|
361
|
+
assertionIssuer: env["TAB_MCP_TAB_ASSERTION_ISSUER"],
|
|
362
|
+
assertionJwksUrl: env["TAB_MCP_TAB_ASSERTION_JWKS_URL"]
|
|
363
|
+
};
|
|
364
|
+
if (Object.values(oidcVars).some((v) => v !== undefined && v !== "")) {
|
|
365
|
+
const oidc = {
|
|
366
|
+
issuer: oidcVars.issuer ?? "",
|
|
367
|
+
clientId: oidcVars.clientId ?? "",
|
|
368
|
+
assertionIssuer: oidcVars.assertionIssuer ?? "",
|
|
369
|
+
assertionJwksUrl: oidcVars.assertionJwksUrl ?? ""
|
|
370
|
+
};
|
|
371
|
+
if (oidcVars.clientSecret !== undefined && oidcVars.clientSecret !== "") {
|
|
372
|
+
oidc.clientSecret = oidcVars.clientSecret;
|
|
373
|
+
}
|
|
374
|
+
if (oidcVars.subjectClaim !== undefined && oidcVars.subjectClaim !== "") {
|
|
375
|
+
oidc.subjectClaim = oidcVars.subjectClaim;
|
|
376
|
+
}
|
|
377
|
+
out.oidc = oidc;
|
|
378
|
+
}
|
|
303
379
|
set("trustProxy", oneOf(env, "TAB_MCP_TRUST_PROXY", [
|
|
304
380
|
"none",
|
|
305
381
|
"x-real-ip",
|
|
@@ -525,7 +601,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
|
|
|
525
601
|
};
|
|
526
602
|
}
|
|
527
603
|
// package.json
|
|
528
|
-
var version = "0.
|
|
604
|
+
var version = "0.3.0";
|
|
529
605
|
|
|
530
606
|
// src/address.ts
|
|
531
607
|
var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
@@ -574,7 +650,8 @@ var RelayErrorCode = {
|
|
|
574
650
|
sendFailed: -31005,
|
|
575
651
|
tooLarge: -31006,
|
|
576
652
|
badResponse: -31007,
|
|
577
|
-
storeUnavailable: -31008
|
|
653
|
+
storeUnavailable: -31008,
|
|
654
|
+
signedOut: -31009
|
|
578
655
|
};
|
|
579
656
|
var encoder = new TextEncoder;
|
|
580
657
|
var NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
@@ -884,6 +961,386 @@ class Exchange {
|
|
|
884
961
|
}
|
|
885
962
|
}
|
|
886
963
|
|
|
964
|
+
// src/jwt.ts
|
|
965
|
+
var JWT_ALGORITHMS = [
|
|
966
|
+
"RS256",
|
|
967
|
+
"RS384",
|
|
968
|
+
"RS512",
|
|
969
|
+
"PS256",
|
|
970
|
+
"PS384",
|
|
971
|
+
"PS512",
|
|
972
|
+
"ES256",
|
|
973
|
+
"ES384",
|
|
974
|
+
"ES512",
|
|
975
|
+
"EdDSA"
|
|
976
|
+
];
|
|
977
|
+
var MAX_JWT_BYTES = 8192;
|
|
978
|
+
var MAX_JWKS_BYTES = 65536;
|
|
979
|
+
var JWKS_TTL_MS = 10 * 60000;
|
|
980
|
+
var JWKS_REFETCH_MS = 30000;
|
|
981
|
+
var JWKS_GRACE_MS = 60 * 60000;
|
|
982
|
+
var FETCH_TIMEOUT_MS = 5000;
|
|
983
|
+
|
|
984
|
+
class JwtError extends Error {
|
|
985
|
+
name = "JwtError";
|
|
986
|
+
reason;
|
|
987
|
+
constructor(reason, message) {
|
|
988
|
+
super(message);
|
|
989
|
+
this.reason = reason;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
function fromBase64Url(part) {
|
|
993
|
+
if (!/^[A-Za-z0-9_-]*$/.test(part)) {
|
|
994
|
+
throw new JwtError("malformed", "the token is not base64url");
|
|
995
|
+
}
|
|
996
|
+
return Uint8Array.fromBase64(part, { alphabet: "base64url" });
|
|
997
|
+
}
|
|
998
|
+
function jsonPart(part, what) {
|
|
999
|
+
let value;
|
|
1000
|
+
try {
|
|
1001
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fromBase64Url(part)));
|
|
1002
|
+
} catch {
|
|
1003
|
+
throw new JwtError("malformed", `the token's ${what} is not JSON`);
|
|
1004
|
+
}
|
|
1005
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1006
|
+
throw new JwtError("malformed", `the token's ${what} is not a JSON object`);
|
|
1007
|
+
}
|
|
1008
|
+
return value;
|
|
1009
|
+
}
|
|
1010
|
+
function decodeJwt(token) {
|
|
1011
|
+
if (typeof token !== "string" || token.length > MAX_JWT_BYTES) {
|
|
1012
|
+
throw new JwtError("too_large", "the token is too large");
|
|
1013
|
+
}
|
|
1014
|
+
const parts = token.split(".");
|
|
1015
|
+
if (parts.length !== 3) {
|
|
1016
|
+
throw new JwtError("malformed", "the token is not a signed JWT");
|
|
1017
|
+
}
|
|
1018
|
+
const [h = "", p = "", s = ""] = parts;
|
|
1019
|
+
return {
|
|
1020
|
+
header: jsonPart(h, "header"),
|
|
1021
|
+
payload: jsonPart(p, "payload"),
|
|
1022
|
+
signingInput: new TextEncoder().encode(`${h}.${p}`),
|
|
1023
|
+
signature: fromBase64Url(s)
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
function spec(alg) {
|
|
1027
|
+
const bits = alg.slice(2);
|
|
1028
|
+
const hash = `SHA-${bits}`;
|
|
1029
|
+
switch (alg.slice(0, 2)) {
|
|
1030
|
+
case "RS":
|
|
1031
|
+
return {
|
|
1032
|
+
kty: "RSA",
|
|
1033
|
+
importParams: { name: "RSASSA-PKCS1-v1_5", hash },
|
|
1034
|
+
verifyParams: { name: "RSASSA-PKCS1-v1_5" }
|
|
1035
|
+
};
|
|
1036
|
+
case "PS":
|
|
1037
|
+
return {
|
|
1038
|
+
kty: "RSA",
|
|
1039
|
+
importParams: { name: "RSA-PSS", hash },
|
|
1040
|
+
verifyParams: {
|
|
1041
|
+
name: "RSA-PSS",
|
|
1042
|
+
saltLength: Number(bits) / 8
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
case "ES": {
|
|
1046
|
+
const crv = bits === "512" ? "P-521" : `P-${bits}`;
|
|
1047
|
+
return {
|
|
1048
|
+
kty: "EC",
|
|
1049
|
+
crv,
|
|
1050
|
+
importParams: { name: "ECDSA", namedCurve: crv },
|
|
1051
|
+
verifyParams: { name: "ECDSA", hash }
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
default:
|
|
1055
|
+
return {
|
|
1056
|
+
kty: "OKP",
|
|
1057
|
+
crv: "Ed25519",
|
|
1058
|
+
importParams: { name: "Ed25519" },
|
|
1059
|
+
verifyParams: { name: "Ed25519" }
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
function isAlgorithm(v) {
|
|
1064
|
+
return JWT_ALGORITHMS.includes(v);
|
|
1065
|
+
}
|
|
1066
|
+
function keyFits(jwk, alg) {
|
|
1067
|
+
const s = spec(alg);
|
|
1068
|
+
if (jwk.kty !== s.kty)
|
|
1069
|
+
return false;
|
|
1070
|
+
if (s.crv !== undefined && jwk.crv !== s.crv)
|
|
1071
|
+
return false;
|
|
1072
|
+
if (jwk.use !== undefined && jwk.use !== "sig")
|
|
1073
|
+
return false;
|
|
1074
|
+
if (jwk.alg !== undefined && jwk.alg !== alg)
|
|
1075
|
+
return false;
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
function publicJwk(jwk) {
|
|
1079
|
+
const out = { kty: jwk.kty };
|
|
1080
|
+
for (const k of ["n", "e", "crv", "x", "y"]) {
|
|
1081
|
+
const v = jwk[k];
|
|
1082
|
+
if (typeof v === "string")
|
|
1083
|
+
out[k] = v;
|
|
1084
|
+
}
|
|
1085
|
+
return out;
|
|
1086
|
+
}
|
|
1087
|
+
async function fetchJson(url, init = {}, maxBytes = MAX_JWKS_BYTES) {
|
|
1088
|
+
const res = await fetch(url, {
|
|
1089
|
+
...init,
|
|
1090
|
+
redirect: "error",
|
|
1091
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
1092
|
+
headers: { accept: "application/json", ...init.headers ?? {} }
|
|
1093
|
+
});
|
|
1094
|
+
const length = Number(res.headers.get("content-length") ?? "0");
|
|
1095
|
+
if (length > maxBytes)
|
|
1096
|
+
throw new Error(`response too large (${url})`);
|
|
1097
|
+
const chunks = [];
|
|
1098
|
+
let size = 0;
|
|
1099
|
+
const reader = res.body?.getReader();
|
|
1100
|
+
while (reader !== undefined) {
|
|
1101
|
+
const { done, value } = await reader.read();
|
|
1102
|
+
if (done)
|
|
1103
|
+
break;
|
|
1104
|
+
size += value.byteLength;
|
|
1105
|
+
if (size > maxBytes) {
|
|
1106
|
+
await reader.cancel();
|
|
1107
|
+
throw new Error(`response too large (${url})`);
|
|
1108
|
+
}
|
|
1109
|
+
chunks.push(value);
|
|
1110
|
+
}
|
|
1111
|
+
const bytes = new Uint8Array(size);
|
|
1112
|
+
let offset = 0;
|
|
1113
|
+
for (const chunk of chunks) {
|
|
1114
|
+
bytes.set(chunk, offset);
|
|
1115
|
+
offset += chunk.byteLength;
|
|
1116
|
+
}
|
|
1117
|
+
const text = new TextDecoder().decode(bytes);
|
|
1118
|
+
let value;
|
|
1119
|
+
try {
|
|
1120
|
+
value = JSON.parse(text);
|
|
1121
|
+
} catch {
|
|
1122
|
+
throw new Error(`not JSON (${url}, HTTP ${res.status})`);
|
|
1123
|
+
}
|
|
1124
|
+
if (!res.ok) {
|
|
1125
|
+
const error = value?.["error"];
|
|
1126
|
+
throw new HttpJsonError(res.status, typeof error === "string" ? error : "", url);
|
|
1127
|
+
}
|
|
1128
|
+
return value;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
class HttpJsonError extends Error {
|
|
1132
|
+
name = "HttpJsonError";
|
|
1133
|
+
status;
|
|
1134
|
+
error;
|
|
1135
|
+
constructor(status, error, url) {
|
|
1136
|
+
super(`HTTP ${status}${error === "" ? "" : ` ${error}`} (${url})`);
|
|
1137
|
+
this.status = status;
|
|
1138
|
+
this.error = error;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
class JwksCache {
|
|
1143
|
+
#url;
|
|
1144
|
+
#now;
|
|
1145
|
+
#keys = [];
|
|
1146
|
+
#fetchedAt = 0;
|
|
1147
|
+
#attemptedAt = Number.NEGATIVE_INFINITY;
|
|
1148
|
+
#lastError;
|
|
1149
|
+
#inflight;
|
|
1150
|
+
constructor(url, options = {}) {
|
|
1151
|
+
this.#url = typeof url === "string" ? () => Promise.resolve(url) : url;
|
|
1152
|
+
this.#now = options.now ?? Date.now;
|
|
1153
|
+
}
|
|
1154
|
+
async keys(kid) {
|
|
1155
|
+
if (this.#inflight !== undefined)
|
|
1156
|
+
await this.#inflight.catch(() => {});
|
|
1157
|
+
const now = this.#now();
|
|
1158
|
+
const stale = now - this.#fetchedAt >= JWKS_TTL_MS;
|
|
1159
|
+
const unknown = kid !== undefined && !this.#keys.some((k) => k.kid === kid);
|
|
1160
|
+
if ((stale || unknown) && now - this.#attemptedAt >= JWKS_REFETCH_MS) {
|
|
1161
|
+
try {
|
|
1162
|
+
await this.#refresh();
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
this.#lastError = error instanceof Error ? error : new Error("JWKS");
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (this.#now() - this.#fetchedAt >= JWKS_TTL_MS + JWKS_GRACE_MS) {
|
|
1168
|
+
throw this.#lastError ?? new Error("the JWKS could not be loaded");
|
|
1169
|
+
}
|
|
1170
|
+
return kid === undefined ? this.#keys : this.#keys.filter((k) => k.kid === kid);
|
|
1171
|
+
}
|
|
1172
|
+
#refresh() {
|
|
1173
|
+
this.#inflight ??= (async () => {
|
|
1174
|
+
this.#attemptedAt = this.#now();
|
|
1175
|
+
try {
|
|
1176
|
+
const doc = await fetchJson(await this.#url());
|
|
1177
|
+
const keys = doc?.keys;
|
|
1178
|
+
if (!Array.isArray(keys))
|
|
1179
|
+
throw new Error("a JWKS needs keys");
|
|
1180
|
+
this.#keys = keys.filter((k) => typeof k === "object" && k !== null);
|
|
1181
|
+
this.#fetchedAt = this.#now();
|
|
1182
|
+
this.#lastError = undefined;
|
|
1183
|
+
} finally {
|
|
1184
|
+
this.#inflight = undefined;
|
|
1185
|
+
}
|
|
1186
|
+
})();
|
|
1187
|
+
return this.#inflight;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
async function verifyJwt(token, source, options = {}) {
|
|
1191
|
+
const jwt = decodeJwt(token);
|
|
1192
|
+
const { header } = jwt;
|
|
1193
|
+
const alg = header["alg"];
|
|
1194
|
+
const allowed = options.algorithms ?? JWT_ALGORITHMS;
|
|
1195
|
+
if (!isAlgorithm(alg) || !allowed.includes(alg)) {
|
|
1196
|
+
throw new JwtError("alg", `the token's algorithm is not accepted (${String(alg).slice(0, 20)})`);
|
|
1197
|
+
}
|
|
1198
|
+
if (header["crit"] !== undefined) {
|
|
1199
|
+
throw new JwtError("crit", "the token has critical header parameters");
|
|
1200
|
+
}
|
|
1201
|
+
if (options.typ !== undefined && header["typ"] !== options.typ) {
|
|
1202
|
+
throw new JwtError("typ", `the token's type must be ${options.typ}`);
|
|
1203
|
+
}
|
|
1204
|
+
const kid = header["kid"];
|
|
1205
|
+
if (kid !== undefined && typeof kid !== "string") {
|
|
1206
|
+
throw new JwtError("malformed", "the token's kid is not a string");
|
|
1207
|
+
}
|
|
1208
|
+
let candidates;
|
|
1209
|
+
try {
|
|
1210
|
+
candidates = (await source.keys(kid)).filter((k) => keyFits(k, alg));
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
throw new JwtError("jwks_unavailable", `the signing keys could not be loaded: ${error instanceof Error ? error.message : "unknown"}`);
|
|
1213
|
+
}
|
|
1214
|
+
if (candidates.length === 0 || kid === undefined && candidates.length > 1) {
|
|
1215
|
+
throw new JwtError("key", "no matching signing key");
|
|
1216
|
+
}
|
|
1217
|
+
const s = spec(alg);
|
|
1218
|
+
for (const jwk of candidates) {
|
|
1219
|
+
let key;
|
|
1220
|
+
try {
|
|
1221
|
+
key = await crypto.subtle.importKey("jwk", publicJwk(jwk), s.importParams, false, ["verify"]);
|
|
1222
|
+
} catch {
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const ok = await crypto.subtle.verify(s.verifyParams, key, jwt.signature, jwt.signingInput).catch(() => false);
|
|
1226
|
+
if (ok)
|
|
1227
|
+
return jwt;
|
|
1228
|
+
}
|
|
1229
|
+
throw new JwtError("signature", "the token's signature is not valid");
|
|
1230
|
+
}
|
|
1231
|
+
function numericDate(payload, name) {
|
|
1232
|
+
const v = payload[name];
|
|
1233
|
+
if (v === undefined)
|
|
1234
|
+
return;
|
|
1235
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
1236
|
+
throw new JwtError("claims", `${name} must be a number`);
|
|
1237
|
+
}
|
|
1238
|
+
return v * 1000;
|
|
1239
|
+
}
|
|
1240
|
+
function audiences(payload) {
|
|
1241
|
+
const aud = payload["aud"];
|
|
1242
|
+
if (typeof aud === "string")
|
|
1243
|
+
return [aud];
|
|
1244
|
+
if (Array.isArray(aud) && aud.every((a) => typeof a === "string")) {
|
|
1245
|
+
return aud;
|
|
1246
|
+
}
|
|
1247
|
+
throw new JwtError("claims", "aud is missing");
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// src/identity.ts
|
|
1251
|
+
var TAB_ASSERTION_TYP = "tab-mcp-identity+jwt";
|
|
1252
|
+
var TAB_ASSERTION_MAX_LIFETIME_MS = 60000;
|
|
1253
|
+
var CLOCK_LEEWAY_MS = 5000;
|
|
1254
|
+
var MAX_SUBJECT = 255;
|
|
1255
|
+
var MAX_JTI = 128;
|
|
1256
|
+
var MAX_SEEN_JTI = 1e5;
|
|
1257
|
+
var MAX_JTI_PER_PERSON = 120;
|
|
1258
|
+
var MISMATCH_TEXT = "This AI tool is logged in as someone other than the page.";
|
|
1259
|
+
function identityKey(issuer, subject) {
|
|
1260
|
+
return new Bun.CryptoHasher("sha256").update(`${issuer}
|
|
1261
|
+
${subject}`).digest().toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
class TabAssertionVerifier {
|
|
1265
|
+
#oidc;
|
|
1266
|
+
#keys;
|
|
1267
|
+
#seen = new Map;
|
|
1268
|
+
#perPerson = new Map;
|
|
1269
|
+
#sweptAt = Number.NEGATIVE_INFINITY;
|
|
1270
|
+
constructor(oidc, keys) {
|
|
1271
|
+
this.#oidc = oidc;
|
|
1272
|
+
this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
|
|
1273
|
+
}
|
|
1274
|
+
async verify(token, address, now = Date.now()) {
|
|
1275
|
+
const { payload } = await verifyJwt(token, this.#keys, {
|
|
1276
|
+
typ: TAB_ASSERTION_TYP
|
|
1277
|
+
});
|
|
1278
|
+
if (payload["iss"] !== this.#oidc.assertionIssuer) {
|
|
1279
|
+
throw new JwtError("iss", "the assertion's issuer is not the configured one");
|
|
1280
|
+
}
|
|
1281
|
+
const aud = audiences(payload);
|
|
1282
|
+
if (aud.length !== 1 || aud[0] !== address) {
|
|
1283
|
+
throw new JwtError("aud", "the assertion is not for this address");
|
|
1284
|
+
}
|
|
1285
|
+
const exp = numericDate(payload, "exp");
|
|
1286
|
+
if (exp === undefined)
|
|
1287
|
+
throw new JwtError("exp", "the assertion has no exp");
|
|
1288
|
+
if (exp <= now - CLOCK_LEEWAY_MS) {
|
|
1289
|
+
throw new JwtError("expired", "the assertion has expired");
|
|
1290
|
+
}
|
|
1291
|
+
if (exp > now + TAB_ASSERTION_MAX_LIFETIME_MS + CLOCK_LEEWAY_MS) {
|
|
1292
|
+
throw new JwtError("lifetime", "the assertion lives longer than 60 seconds");
|
|
1293
|
+
}
|
|
1294
|
+
const iat = numericDate(payload, "iat");
|
|
1295
|
+
if (iat !== undefined && iat > now + CLOCK_LEEWAY_MS) {
|
|
1296
|
+
throw new JwtError("iat", "the assertion was issued in the future");
|
|
1297
|
+
}
|
|
1298
|
+
const nbf = numericDate(payload, "nbf");
|
|
1299
|
+
if (nbf !== undefined && nbf > now + CLOCK_LEEWAY_MS) {
|
|
1300
|
+
throw new JwtError("nbf", "the assertion is not valid yet");
|
|
1301
|
+
}
|
|
1302
|
+
const sub = payload["sub"];
|
|
1303
|
+
if (typeof sub !== "string" || sub === "" || sub.length > MAX_SUBJECT) {
|
|
1304
|
+
throw new JwtError("sub", "the assertion needs a subject");
|
|
1305
|
+
}
|
|
1306
|
+
const jti = payload["jti"];
|
|
1307
|
+
if (typeof jti !== "string" || jti === "" || jti.length > MAX_JTI) {
|
|
1308
|
+
throw new JwtError("jti", "the assertion needs a jti");
|
|
1309
|
+
}
|
|
1310
|
+
this.#sweep(now);
|
|
1311
|
+
const key = identityKey(this.#oidc.issuer, sub);
|
|
1312
|
+
const seenKey = `${key}
|
|
1313
|
+
${jti}`;
|
|
1314
|
+
if (this.#seen.has(seenKey)) {
|
|
1315
|
+
throw new JwtError("replayed", "the assertion was already used");
|
|
1316
|
+
}
|
|
1317
|
+
const mine = this.#perPerson.get(key) ?? 0;
|
|
1318
|
+
if (mine >= MAX_JTI_PER_PERSON || this.#seen.size >= MAX_SEEN_JTI) {
|
|
1319
|
+
throw new JwtError("busy", "too many assertions at once");
|
|
1320
|
+
}
|
|
1321
|
+
this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
|
|
1322
|
+
this.#perPerson.set(key, mine + 1);
|
|
1323
|
+
return key;
|
|
1324
|
+
}
|
|
1325
|
+
#sweep(now) {
|
|
1326
|
+
if (now - this.#sweptAt < 1000 && this.#seen.size < MAX_SEEN_JTI)
|
|
1327
|
+
return;
|
|
1328
|
+
this.#sweptAt = now;
|
|
1329
|
+
for (const [seenKey, until] of this.#seen) {
|
|
1330
|
+
if (until > now)
|
|
1331
|
+
continue;
|
|
1332
|
+
this.#seen.delete(seenKey);
|
|
1333
|
+
const person = seenKey.slice(0, seenKey.indexOf(`
|
|
1334
|
+
`));
|
|
1335
|
+
const left = (this.#perPerson.get(person) ?? 1) - 1;
|
|
1336
|
+
if (left > 0)
|
|
1337
|
+
this.#perPerson.set(person, left);
|
|
1338
|
+
else
|
|
1339
|
+
this.#perPerson.delete(person);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
887
1344
|
// src/protocol.ts
|
|
888
1345
|
var SUBPROTOCOL = "tab-mcp.v1";
|
|
889
1346
|
var MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
@@ -1024,7 +1481,19 @@ function parseTabMessage(text) {
|
|
|
1024
1481
|
case "pairing.start":
|
|
1025
1482
|
case "pairing.stop":
|
|
1026
1483
|
case "grants.revokeAll":
|
|
1484
|
+
case "identity.logout":
|
|
1027
1485
|
return { ok: true, message: { type } };
|
|
1486
|
+
case "identity": {
|
|
1487
|
+
const assertion = v["assertion"];
|
|
1488
|
+
if (typeof assertion !== "string" || assertion === "" || assertion.length > MAX_JWT_BYTES) {
|
|
1489
|
+
return bad(`identity.assertion must be a JWT of at most ${MAX_JWT_BYTES} bytes`);
|
|
1490
|
+
}
|
|
1491
|
+
const round = v["round"];
|
|
1492
|
+
if (round !== undefined && !(Number.isSafeInteger(round) && round >= 0)) {
|
|
1493
|
+
return bad("identity.round must be a non-negative integer");
|
|
1494
|
+
}
|
|
1495
|
+
return round === undefined ? { ok: true, message: { type, assertion } } : { ok: true, message: { type, assertion, round } };
|
|
1496
|
+
}
|
|
1028
1497
|
case "grant.revoke": {
|
|
1029
1498
|
const grantId = v["grantId"];
|
|
1030
1499
|
if (!isId(grantId))
|
|
@@ -1061,6 +1530,12 @@ function pageName(appName) {
|
|
|
1061
1530
|
function notOpenText(appName) {
|
|
1062
1531
|
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.`;
|
|
1063
1532
|
}
|
|
1533
|
+
function signedOutText(appName) {
|
|
1534
|
+
return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
|
|
1535
|
+
}
|
|
1536
|
+
function phaseText(phase, appName) {
|
|
1537
|
+
return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
|
|
1538
|
+
}
|
|
1064
1539
|
function goneDuringCallText(appName) {
|
|
1065
1540
|
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.`;
|
|
1066
1541
|
}
|
|
@@ -1070,7 +1545,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
|
1070
1545
|
const id = rpcId;
|
|
1071
1546
|
const method = body["method"];
|
|
1072
1547
|
if (method === "tools/call") {
|
|
1073
|
-
const text = phase === "gone" ? goneDuringCallText(appName) :
|
|
1548
|
+
const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
|
|
1074
1549
|
return JSON.stringify({
|
|
1075
1550
|
jsonrpc: "2.0",
|
|
1076
1551
|
id,
|
|
@@ -1089,7 +1564,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
|
1089
1564
|
resultType: "complete",
|
|
1090
1565
|
supportedVersions: [MCP_PROTOCOL_VERSION],
|
|
1091
1566
|
capabilities: { tools: { listChanged: true } },
|
|
1092
|
-
instructions:
|
|
1567
|
+
instructions: phaseText(phase, appName),
|
|
1093
1568
|
ttlMs: 0,
|
|
1094
1569
|
cacheScope: "private",
|
|
1095
1570
|
_meta: {
|
|
@@ -1106,7 +1581,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
|
|
|
1106
1581
|
id,
|
|
1107
1582
|
error: {
|
|
1108
1583
|
code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
|
|
1109
|
-
message:
|
|
1584
|
+
message: phaseText(phase, appName)
|
|
1110
1585
|
}
|
|
1111
1586
|
});
|
|
1112
1587
|
}
|
|
@@ -1183,7 +1658,8 @@ async function issueTokenPair(store, grant, options) {
|
|
|
1183
1658
|
clientName: grant.clientName,
|
|
1184
1659
|
scopes: grant.scopes,
|
|
1185
1660
|
grantCreatedAt: grant.grantCreatedAt,
|
|
1186
|
-
issuedAt: now
|
|
1661
|
+
issuedAt: now,
|
|
1662
|
+
...grant.subject === undefined ? {} : { subject: grant.subject }
|
|
1187
1663
|
};
|
|
1188
1664
|
const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
|
|
1189
1665
|
const refreshStored = await store.put({
|
|
@@ -1574,6 +2050,348 @@ ${body}
|
|
|
1574
2050
|
</html>`;
|
|
1575
2051
|
}
|
|
1576
2052
|
|
|
2053
|
+
// src/oauth/oidc.ts
|
|
2054
|
+
var FLOW_TTL_MS = 10 * 60000;
|
|
2055
|
+
var MAX_FLOWS = 1e4;
|
|
2056
|
+
var METADATA_TTL_MS = 60 * 60000;
|
|
2057
|
+
var MAX_TOKEN_RESPONSE_BYTES = 65536;
|
|
2058
|
+
|
|
2059
|
+
class OidcError extends Error {
|
|
2060
|
+
name = "OidcError";
|
|
2061
|
+
reason;
|
|
2062
|
+
constructor(reason, message) {
|
|
2063
|
+
super(message);
|
|
2064
|
+
this.reason = reason;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
function random2(bytes) {
|
|
2068
|
+
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
|
|
2069
|
+
}
|
|
2070
|
+
function s256(verifier) {
|
|
2071
|
+
return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
|
|
2072
|
+
}
|
|
2073
|
+
function str(v) {
|
|
2074
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
2075
|
+
}
|
|
2076
|
+
function originOf(url) {
|
|
2077
|
+
try {
|
|
2078
|
+
return new URL(url).origin;
|
|
2079
|
+
} catch {
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
class ProviderDiscovery {
|
|
2085
|
+
#issuer;
|
|
2086
|
+
#development;
|
|
2087
|
+
#metadata;
|
|
2088
|
+
#fetchedAt = 0;
|
|
2089
|
+
#inflight;
|
|
2090
|
+
constructor(issuer, development) {
|
|
2091
|
+
this.#issuer = issuer;
|
|
2092
|
+
this.#development = development;
|
|
2093
|
+
}
|
|
2094
|
+
get cached() {
|
|
2095
|
+
return this.#metadata;
|
|
2096
|
+
}
|
|
2097
|
+
async get() {
|
|
2098
|
+
if (this.#metadata !== undefined && Date.now() - this.#fetchedAt < METADATA_TTL_MS) {
|
|
2099
|
+
return this.#metadata;
|
|
2100
|
+
}
|
|
2101
|
+
this.#inflight ??= this.#load().finally(() => {
|
|
2102
|
+
this.#inflight = undefined;
|
|
2103
|
+
});
|
|
2104
|
+
return this.#inflight;
|
|
2105
|
+
}
|
|
2106
|
+
async#load() {
|
|
2107
|
+
const url = `${this.#issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
|
|
2108
|
+
const doc = await fetchJson(url);
|
|
2109
|
+
if (typeof doc !== "object" || doc === null) {
|
|
2110
|
+
throw new OidcError("discovery", "the provider's metadata is not an object");
|
|
2111
|
+
}
|
|
2112
|
+
if (doc["issuer"] !== this.#issuer) {
|
|
2113
|
+
throw new OidcError("discovery_issuer", "the provider's metadata names another issuer");
|
|
2114
|
+
}
|
|
2115
|
+
const endpoint = (name) => {
|
|
2116
|
+
const v = str(doc[name]);
|
|
2117
|
+
const ok = v !== undefined && (v.startsWith("https://") || this.#development && v.startsWith("http://"));
|
|
2118
|
+
if (!ok) {
|
|
2119
|
+
throw new OidcError("discovery", `the provider's metadata lacks ${name}`);
|
|
2120
|
+
}
|
|
2121
|
+
return v;
|
|
2122
|
+
};
|
|
2123
|
+
const methods = doc["token_endpoint_auth_methods_supported"];
|
|
2124
|
+
const tokenAuth = Array.isArray(methods) && !methods.includes("client_secret_basic") && methods.includes("client_secret_post") ? "client_secret_post" : "client_secret_basic";
|
|
2125
|
+
const metadata = {
|
|
2126
|
+
issuer: this.#issuer,
|
|
2127
|
+
authorizationEndpoint: endpoint("authorization_endpoint"),
|
|
2128
|
+
tokenEndpoint: endpoint("token_endpoint"),
|
|
2129
|
+
jwksUri: endpoint("jwks_uri"),
|
|
2130
|
+
tokenAuth
|
|
2131
|
+
};
|
|
2132
|
+
this.#metadata = metadata;
|
|
2133
|
+
this.#fetchedAt = Date.now();
|
|
2134
|
+
return metadata;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
class OidcPairing {
|
|
2139
|
+
kind = "oidc";
|
|
2140
|
+
#config;
|
|
2141
|
+
#publicUrl;
|
|
2142
|
+
#logger;
|
|
2143
|
+
#discovery;
|
|
2144
|
+
#jwks;
|
|
2145
|
+
#flows = new Map;
|
|
2146
|
+
#mismatched = new Set;
|
|
2147
|
+
constructor(options) {
|
|
2148
|
+
this.#config = options.config;
|
|
2149
|
+
this.#publicUrl = options.publicUrl;
|
|
2150
|
+
this.#logger = options.logger;
|
|
2151
|
+
this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
|
|
2152
|
+
this.#jwks = new JwksCache(async () => (await this.#discovery.get()).jwksUri);
|
|
2153
|
+
}
|
|
2154
|
+
get #providerHost() {
|
|
2155
|
+
try {
|
|
2156
|
+
return new URL(this.#config.issuer).host;
|
|
2157
|
+
} catch {
|
|
2158
|
+
return this.#config.issuer;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
get callbackUrl() {
|
|
2162
|
+
return `${this.#publicUrl()}/authorize/callback`;
|
|
2163
|
+
}
|
|
2164
|
+
async prepare() {
|
|
2165
|
+
await this.#discovery.get();
|
|
2166
|
+
}
|
|
2167
|
+
formTargets() {
|
|
2168
|
+
const out = new Set;
|
|
2169
|
+
const issuer = originOf(this.#config.issuer);
|
|
2170
|
+
if (issuer !== undefined)
|
|
2171
|
+
out.add(issuer);
|
|
2172
|
+
const auth = this.#discovery.cached?.authorizationEndpoint;
|
|
2173
|
+
const origin = auth === undefined ? undefined : originOf(auth);
|
|
2174
|
+
if (origin !== undefined)
|
|
2175
|
+
out.add(origin);
|
|
2176
|
+
return [...out];
|
|
2177
|
+
}
|
|
2178
|
+
render(target) {
|
|
2179
|
+
const host = escapeHtml(this.#providerHost);
|
|
2180
|
+
if (target.identity === undefined) {
|
|
2181
|
+
return `<p class="help">Nobody is signed in to the page. Sign in there first, then click Connect.</p>`;
|
|
2182
|
+
}
|
|
2183
|
+
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>`;
|
|
2184
|
+
}
|
|
2185
|
+
async check(target, _form, _admit, context) {
|
|
2186
|
+
if (!target.pairingOpen) {
|
|
2187
|
+
return {
|
|
2188
|
+
ok: false,
|
|
2189
|
+
message: "The page is not connecting right now. In the page, open \u201CConnect your AI app\u201D, then click Connect here again.",
|
|
2190
|
+
reason: "inactive"
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
if (target.identity === undefined) {
|
|
2194
|
+
return {
|
|
2195
|
+
ok: false,
|
|
2196
|
+
message: "Nobody is signed in to the page. Sign in there first, then click Connect here again.",
|
|
2197
|
+
reason: "signed_out"
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
if (context === undefined) {
|
|
2201
|
+
return { ok: false, message: "Something went wrong.", reason: "context" };
|
|
2202
|
+
}
|
|
2203
|
+
let metadata;
|
|
2204
|
+
try {
|
|
2205
|
+
metadata = await this.#discovery.get();
|
|
2206
|
+
} catch (error) {
|
|
2207
|
+
return this.#unavailable(error);
|
|
2208
|
+
}
|
|
2209
|
+
this.#sweep();
|
|
2210
|
+
if (this.#flows.size >= MAX_FLOWS) {
|
|
2211
|
+
return {
|
|
2212
|
+
ok: false,
|
|
2213
|
+
message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
|
|
2214
|
+
reason: "busy"
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
const state = random2(24);
|
|
2218
|
+
const nonce = random2(24);
|
|
2219
|
+
const verifier = random2(32);
|
|
2220
|
+
this.#flows.set(state, {
|
|
2221
|
+
requestId: context.requestId,
|
|
2222
|
+
browserId: context.browserId,
|
|
2223
|
+
nonce,
|
|
2224
|
+
verifier,
|
|
2225
|
+
expiresAt: Date.now() + FLOW_TTL_MS
|
|
2226
|
+
});
|
|
2227
|
+
const url = new URL(metadata.authorizationEndpoint);
|
|
2228
|
+
const params = {
|
|
2229
|
+
response_type: "code",
|
|
2230
|
+
client_id: this.#config.clientId,
|
|
2231
|
+
redirect_uri: this.callbackUrl,
|
|
2232
|
+
scope: "openid",
|
|
2233
|
+
state,
|
|
2234
|
+
nonce,
|
|
2235
|
+
code_challenge: s256(verifier),
|
|
2236
|
+
code_challenge_method: "S256"
|
|
2237
|
+
};
|
|
2238
|
+
if (this.#mismatched.has(context.requestId))
|
|
2239
|
+
params["prompt"] = "login";
|
|
2240
|
+
for (const [k, v] of Object.entries(params))
|
|
2241
|
+
url.searchParams.set(k, v);
|
|
2242
|
+
return { ok: "redirect", location: url.href };
|
|
2243
|
+
}
|
|
2244
|
+
async callback(params, browserId, targetFor) {
|
|
2245
|
+
this.#sweep();
|
|
2246
|
+
const state = params.get("state") ?? "";
|
|
2247
|
+
const flow = this.#flows.get(state);
|
|
2248
|
+
if (flow === undefined || flow.expiresAt <= Date.now()) {
|
|
2249
|
+
return {
|
|
2250
|
+
result: {
|
|
2251
|
+
ok: false,
|
|
2252
|
+
message: "This sign-in has expired or was already used. Start again from your AI app.",
|
|
2253
|
+
reason: "unknown_state"
|
|
2254
|
+
}
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
this.#flows.delete(state);
|
|
2258
|
+
const requestId = flow.requestId;
|
|
2259
|
+
const fail = (reason, message) => ({
|
|
2260
|
+
requestId,
|
|
2261
|
+
result: { ok: false, message, reason }
|
|
2262
|
+
});
|
|
2263
|
+
if (flow.browserId !== browserId) {
|
|
2264
|
+
return fail("browser", "This sign-in was started in another browser. Start again from your AI app.");
|
|
2265
|
+
}
|
|
2266
|
+
const iss = params.get("iss");
|
|
2267
|
+
if (iss !== null && iss !== this.#config.issuer) {
|
|
2268
|
+
return fail("iss", "The sign-in came back from an unexpected provider.");
|
|
2269
|
+
}
|
|
2270
|
+
const error = params.get("error");
|
|
2271
|
+
if (error !== null) {
|
|
2272
|
+
this.#logger.info("oidc.provider_error", { error: error.slice(0, 64) });
|
|
2273
|
+
return fail(`provider_${error.slice(0, 32)}`, `The sign-in at ${this.#providerHost} did not finish. Click Connect to try again.`);
|
|
2274
|
+
}
|
|
2275
|
+
const code = params.get("code");
|
|
2276
|
+
if (code === null || code === "") {
|
|
2277
|
+
return fail("no_code", "The provider sent no sign-in result. Click Connect to try again.");
|
|
2278
|
+
}
|
|
2279
|
+
let subject;
|
|
2280
|
+
try {
|
|
2281
|
+
subject = await this.#signIn(code, flow);
|
|
2282
|
+
} catch (e) {
|
|
2283
|
+
const reason = e instanceof OidcError || e instanceof JwtError ? e.reason : "exchange";
|
|
2284
|
+
this.#logger.warn("oidc.sign_in_failed", {
|
|
2285
|
+
reason,
|
|
2286
|
+
message: e instanceof Error ? e.message.slice(0, 200) : "unknown"
|
|
2287
|
+
});
|
|
2288
|
+
return fail(reason, `The sign-in at ${this.#providerHost} could not be checked. Click Connect to try again.`);
|
|
2289
|
+
}
|
|
2290
|
+
const target = targetFor(requestId);
|
|
2291
|
+
if (target === undefined) {
|
|
2292
|
+
return fail("page_closed", "The page is not open any more. Open it and try again.");
|
|
2293
|
+
}
|
|
2294
|
+
if (target.identity === undefined) {
|
|
2295
|
+
return fail("signed_out", "Nobody is signed in to the page any more. Sign in there first, then click Connect here again.");
|
|
2296
|
+
}
|
|
2297
|
+
const key = identityKey(this.#config.issuer, subject);
|
|
2298
|
+
if (key !== target.identity) {
|
|
2299
|
+
this.#mismatched.add(requestId);
|
|
2300
|
+
if (this.#mismatched.size > MAX_FLOWS) {
|
|
2301
|
+
const oldest = this.#mismatched.values().next().value;
|
|
2302
|
+
if (oldest !== undefined)
|
|
2303
|
+
this.#mismatched.delete(oldest);
|
|
2304
|
+
}
|
|
2305
|
+
return fail("mismatch", `${MISMATCH_TEXT} Click Connect to sign in with the page's account, or Cancel.`);
|
|
2306
|
+
}
|
|
2307
|
+
this.#mismatched.delete(requestId);
|
|
2308
|
+
return { requestId, result: { ok: true, subject: key } };
|
|
2309
|
+
}
|
|
2310
|
+
async#signIn(code, flow) {
|
|
2311
|
+
const metadata = await this.#discovery.get();
|
|
2312
|
+
const body = new URLSearchParams({
|
|
2313
|
+
grant_type: "authorization_code",
|
|
2314
|
+
code,
|
|
2315
|
+
redirect_uri: this.callbackUrl,
|
|
2316
|
+
code_verifier: flow.verifier
|
|
2317
|
+
});
|
|
2318
|
+
const headers = {
|
|
2319
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
2320
|
+
};
|
|
2321
|
+
const secret = this.#config.clientSecret;
|
|
2322
|
+
if (secret !== undefined && metadata.tokenAuth === "client_secret_basic") {
|
|
2323
|
+
const enc = (v) => encodeURIComponent(v).replace(/%20/g, "+");
|
|
2324
|
+
headers["authorization"] = `Basic ${btoa(`${enc(this.#config.clientId)}:${enc(secret)}`)}`;
|
|
2325
|
+
} else {
|
|
2326
|
+
body.set("client_id", this.#config.clientId);
|
|
2327
|
+
if (secret !== undefined)
|
|
2328
|
+
body.set("client_secret", secret);
|
|
2329
|
+
}
|
|
2330
|
+
let response;
|
|
2331
|
+
try {
|
|
2332
|
+
response = await fetchJson(metadata.tokenEndpoint, { method: "POST", headers, body }, MAX_TOKEN_RESPONSE_BYTES);
|
|
2333
|
+
} catch (e) {
|
|
2334
|
+
if (e instanceof HttpJsonError) {
|
|
2335
|
+
throw new OidcError(`token_${e.error || e.status}`, e.message);
|
|
2336
|
+
}
|
|
2337
|
+
throw e;
|
|
2338
|
+
}
|
|
2339
|
+
const idToken = str(response?.["id_token"]);
|
|
2340
|
+
if (idToken === undefined) {
|
|
2341
|
+
throw new OidcError("no_id_token", "the token response has no id_token");
|
|
2342
|
+
}
|
|
2343
|
+
const { payload } = await verifyJwt(idToken, this.#jwks);
|
|
2344
|
+
const now = Date.now();
|
|
2345
|
+
if (payload["iss"] !== metadata.issuer) {
|
|
2346
|
+
throw new OidcError("id_token_iss", "the ID token's issuer is wrong");
|
|
2347
|
+
}
|
|
2348
|
+
const aud = audiences(payload);
|
|
2349
|
+
if (!aud.includes(this.#config.clientId)) {
|
|
2350
|
+
throw new OidcError("id_token_aud", "the ID token is not for this relay");
|
|
2351
|
+
}
|
|
2352
|
+
const azp = payload["azp"];
|
|
2353
|
+
if ((aud.length > 1 || azp !== undefined) && azp !== this.#config.clientId) {
|
|
2354
|
+
throw new OidcError("id_token_azp", "the ID token's azp is not this relay");
|
|
2355
|
+
}
|
|
2356
|
+
const exp = numericDate(payload, "exp");
|
|
2357
|
+
if (exp === undefined || exp <= now - CLOCK_LEEWAY_MS) {
|
|
2358
|
+
throw new OidcError("id_token_exp", "the ID token has expired");
|
|
2359
|
+
}
|
|
2360
|
+
const iat = numericDate(payload, "iat");
|
|
2361
|
+
if (iat === undefined || iat > now + CLOCK_LEEWAY_MS) {
|
|
2362
|
+
throw new OidcError("id_token_iat", "the ID token's iat is missing or in the future");
|
|
2363
|
+
}
|
|
2364
|
+
if (payload["nonce"] !== flow.nonce) {
|
|
2365
|
+
throw new OidcError("id_token_nonce", "the ID token's nonce does not match");
|
|
2366
|
+
}
|
|
2367
|
+
const claim = this.#config.subjectClaim ?? "sub";
|
|
2368
|
+
const subject = payload[claim];
|
|
2369
|
+
if (typeof subject !== "string" || subject === "" || subject.length > 255) {
|
|
2370
|
+
throw new OidcError("id_token_subject", `the ID token has no ${claim}`);
|
|
2371
|
+
}
|
|
2372
|
+
return subject;
|
|
2373
|
+
}
|
|
2374
|
+
#unavailable(error) {
|
|
2375
|
+
this.#logger.warn("oidc.discovery_failed", {
|
|
2376
|
+
reason: error instanceof OidcError ? error.reason : "fetch",
|
|
2377
|
+
message: error instanceof Error ? error.message.slice(0, 200) : "unknown"
|
|
2378
|
+
});
|
|
2379
|
+
return {
|
|
2380
|
+
ok: false,
|
|
2381
|
+
message: `The sign-in provider ${this.#providerHost} cannot be reached right now. Try again in a minute.`,
|
|
2382
|
+
reason: "provider_unavailable"
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
#sweep() {
|
|
2386
|
+
const now = Date.now();
|
|
2387
|
+
for (const [state, flow] of this.#flows) {
|
|
2388
|
+
if (flow.expiresAt > now)
|
|
2389
|
+
break;
|
|
2390
|
+
this.#flows.delete(state);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
1577
2395
|
// src/oauth/pairing.ts
|
|
1578
2396
|
import { timingSafeEqual } from "crypto";
|
|
1579
2397
|
var PAIRING_CODE_TTL_MS = 120000;
|
|
@@ -1725,7 +2543,7 @@ function constantTimeEqual(a, b) {
|
|
|
1725
2543
|
const y = Buffer.from(b);
|
|
1726
2544
|
return x.length === y.length && timingSafeEqual2(x, y);
|
|
1727
2545
|
}
|
|
1728
|
-
function
|
|
2546
|
+
function s2562(verifier) {
|
|
1729
2547
|
return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1730
2548
|
}
|
|
1731
2549
|
var VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
@@ -1740,7 +2558,7 @@ function single(params, names) {
|
|
|
1740
2558
|
}
|
|
1741
2559
|
return out;
|
|
1742
2560
|
}
|
|
1743
|
-
function
|
|
2561
|
+
function random3(bytes) {
|
|
1744
2562
|
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
|
|
1745
2563
|
}
|
|
1746
2564
|
function formTarget(redirectUri) {
|
|
@@ -1769,7 +2587,7 @@ class AuthorizationServer {
|
|
|
1769
2587
|
#codes = new Map;
|
|
1770
2588
|
#authCodeTtlMs;
|
|
1771
2589
|
#methods;
|
|
1772
|
-
#csrfSecret =
|
|
2590
|
+
#csrfSecret = random3(32);
|
|
1773
2591
|
#viewsPerIp = new RateLimiter({
|
|
1774
2592
|
capacity: 30,
|
|
1775
2593
|
refillPerSecond: 0.5
|
|
@@ -1790,7 +2608,19 @@ class AuthorizationServer {
|
|
|
1790
2608
|
constructor(host, testMode) {
|
|
1791
2609
|
this.#host = host;
|
|
1792
2610
|
this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
|
|
1793
|
-
|
|
2611
|
+
const oidc = host.config.oidc;
|
|
2612
|
+
this.#methods = {
|
|
2613
|
+
...PAIRING_METHODS,
|
|
2614
|
+
...oidc === undefined ? {} : {
|
|
2615
|
+
oidc: new OidcPairing({
|
|
2616
|
+
config: oidc,
|
|
2617
|
+
publicUrl: () => host.publicUrl(),
|
|
2618
|
+
logger: host.logger,
|
|
2619
|
+
development: host.config.development
|
|
2620
|
+
})
|
|
2621
|
+
},
|
|
2622
|
+
...testMode?.pairingMethods
|
|
2623
|
+
};
|
|
1794
2624
|
this.#cimd = new ClientMetadataFetcher({
|
|
1795
2625
|
allowedHosts: host.config.clientIdHosts,
|
|
1796
2626
|
logger: host.logger,
|
|
@@ -2001,12 +2831,23 @@ class AuthorizationServer {
|
|
|
2001
2831
|
if (this.#host.session(addressId) === undefined) {
|
|
2002
2832
|
return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
|
|
2003
2833
|
}
|
|
2834
|
+
const method = this.#methods[this.#host.session(addressId)?.site.pairing ?? "code"];
|
|
2835
|
+
if (method?.prepare !== undefined) {
|
|
2836
|
+
try {
|
|
2837
|
+
await method.prepare();
|
|
2838
|
+
} catch (error) {
|
|
2839
|
+
this.#host.logger.warn("oidc.discovery_failed", {
|
|
2840
|
+
message: error instanceof Error ? error.message.slice(0, 200) : "unknown"
|
|
2841
|
+
});
|
|
2842
|
+
return this.#errorPage(503, "The sign-in provider of this page cannot be reached right now. Wait a minute and try again.");
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2004
2845
|
this.#sweepPending();
|
|
2005
2846
|
if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
|
|
2006
2847
|
return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
|
|
2007
2848
|
}
|
|
2008
2849
|
const pending = {
|
|
2009
|
-
id:
|
|
2850
|
+
id: random3(24),
|
|
2010
2851
|
expiresAt: Date.now() + PENDING_TTL_MS,
|
|
2011
2852
|
ip,
|
|
2012
2853
|
client,
|
|
@@ -2103,7 +2944,25 @@ ${errorHtml}
|
|
|
2103
2944
|
return this.#errorPage(500, "This page's pairing method is not available.");
|
|
2104
2945
|
}
|
|
2105
2946
|
const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
|
|
2106
|
-
const result = await method.check(session, form, admit
|
|
2947
|
+
const result = await method.check(session, form, admit, {
|
|
2948
|
+
requestId: pending.id,
|
|
2949
|
+
browserId: browser.id
|
|
2950
|
+
});
|
|
2951
|
+
if (result.ok === "redirect") {
|
|
2952
|
+
this.#host.logger.info("oauth.pairing_redirect", {
|
|
2953
|
+
address: shortAddress(pending.addressId),
|
|
2954
|
+
client: pending.client.host,
|
|
2955
|
+
method: method.kind
|
|
2956
|
+
});
|
|
2957
|
+
return new Response(null, {
|
|
2958
|
+
status: 303,
|
|
2959
|
+
headers: {
|
|
2960
|
+
location: result.location,
|
|
2961
|
+
"cache-control": "no-store",
|
|
2962
|
+
"referrer-policy": "no-referrer"
|
|
2963
|
+
}
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2107
2966
|
if (!result.ok) {
|
|
2108
2967
|
this.#host.logger.info("oauth.pairing_failed", {
|
|
2109
2968
|
address: shortAddress(pending.addressId),
|
|
@@ -2112,20 +2971,24 @@ ${errorHtml}
|
|
|
2112
2971
|
});
|
|
2113
2972
|
return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
|
|
2114
2973
|
}
|
|
2974
|
+
return this.#paired(pending, method.kind, result);
|
|
2975
|
+
}
|
|
2976
|
+
#paired(pending, kind, result) {
|
|
2115
2977
|
this.#dropPending(pending);
|
|
2116
|
-
const code =
|
|
2978
|
+
const code = random3(32);
|
|
2117
2979
|
if (this.#codes.size >= MAX_CODES)
|
|
2118
2980
|
this.#sweepCodes(true);
|
|
2119
2981
|
const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
|
|
2120
2982
|
this.#codes.set(hashToken(code), {
|
|
2121
2983
|
...request,
|
|
2122
2984
|
expiresAt: Date.now() + this.#authCodeTtlMs,
|
|
2123
|
-
used: false
|
|
2985
|
+
used: false,
|
|
2986
|
+
...result.subject === undefined ? {} : { subject: result.subject }
|
|
2124
2987
|
});
|
|
2125
2988
|
this.#host.logger.info("oauth.paired", {
|
|
2126
2989
|
address: shortAddress(pending.addressId),
|
|
2127
2990
|
client: pending.client.host,
|
|
2128
|
-
method:
|
|
2991
|
+
method: kind
|
|
2129
2992
|
});
|
|
2130
2993
|
return this.#redirect(pending.redirectUri, {
|
|
2131
2994
|
code,
|
|
@@ -2133,6 +2996,40 @@ ${errorHtml}
|
|
|
2133
2996
|
iss: this.#issuer
|
|
2134
2997
|
});
|
|
2135
2998
|
}
|
|
2999
|
+
async authorizeCallback(req, ip) {
|
|
3000
|
+
if (!this.#viewsPerIp.take(ip)) {
|
|
3001
|
+
return this.#errorPage(429, "Too many requests from your network. Wait a minute and try again.");
|
|
3002
|
+
}
|
|
3003
|
+
const method = this.#methods.oidc;
|
|
3004
|
+
if (method?.callback === undefined) {
|
|
3005
|
+
return new Response("Not found", { status: 404 });
|
|
3006
|
+
}
|
|
3007
|
+
const browser = this.#browserId(req);
|
|
3008
|
+
if (browser.cookie !== undefined) {
|
|
3009
|
+
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.");
|
|
3010
|
+
}
|
|
3011
|
+
this.#sweepPending();
|
|
3012
|
+
const params = new URL(req.url).searchParams;
|
|
3013
|
+
const { requestId, result } = await method.callback(params, browser.id, (id) => {
|
|
3014
|
+
const p = this.#pending.get(id);
|
|
3015
|
+
return p === undefined ? undefined : this.#host.session(p.addressId);
|
|
3016
|
+
});
|
|
3017
|
+
const pending = requestId === undefined ? undefined : this.#pending.get(requestId);
|
|
3018
|
+
if (pending === undefined || pending.expiresAt <= Date.now()) {
|
|
3019
|
+
const message = result.ok === false ? result.message : "This connection request has expired or was already used.";
|
|
3020
|
+
return this.#errorPage(400, message);
|
|
3021
|
+
}
|
|
3022
|
+
if (result.ok === true)
|
|
3023
|
+
return this.#paired(pending, method.kind, result);
|
|
3024
|
+
const message = result.ok === false ? result.message : "Something went wrong.";
|
|
3025
|
+
const reason = result.ok === false ? result.reason : "unexpected";
|
|
3026
|
+
this.#host.logger.info("oauth.pairing_failed", {
|
|
3027
|
+
address: shortAddress(pending.addressId),
|
|
3028
|
+
client: pending.client.host,
|
|
3029
|
+
reason
|
|
3030
|
+
});
|
|
3031
|
+
return this.#pairingPage(pending, browser.id, message, reason === "mismatch" ? 403 : 400);
|
|
3032
|
+
}
|
|
2136
3033
|
#addressOf(resource) {
|
|
2137
3034
|
if (resource === undefined)
|
|
2138
3035
|
return;
|
|
@@ -2160,7 +3057,7 @@ ${errorHtml}
|
|
|
2160
3057
|
return { id: v };
|
|
2161
3058
|
}
|
|
2162
3059
|
}
|
|
2163
|
-
const id =
|
|
3060
|
+
const id = random3(16);
|
|
2164
3061
|
const secure = this.#issuer.startsWith("https:") ? "; Secure" : "";
|
|
2165
3062
|
return {
|
|
2166
3063
|
id,
|
|
@@ -2211,6 +3108,7 @@ ${cancel}
|
|
|
2211
3108
|
if (method === undefined) {
|
|
2212
3109
|
return this.#errorPage(500, "This page's pairing method is not available.");
|
|
2213
3110
|
}
|
|
3111
|
+
targets.push(...method.formTargets?.() ?? []);
|
|
2214
3112
|
const page = session.origin === "" ? "a page that did not say where it runs" : `<span class="origin">${escapeHtml(session.origin)}</span>`;
|
|
2215
3113
|
const target = session.appName === undefined ? `tools from ${page}` : `<strong>${escapeHtml(session.appName)}</strong> on ${page}`;
|
|
2216
3114
|
const heading = `Connect ${client.name} from ${client.host}`;
|
|
@@ -2333,9 +3231,12 @@ ${cancel}
|
|
|
2333
3231
|
if (p["resource"] !== undefined && p["resource"] !== issued.resource) {
|
|
2334
3232
|
throw new OAuthError(400, "invalid_target", "resource does not match the authorization request");
|
|
2335
3233
|
}
|
|
2336
|
-
if (!VERIFIER.test(verifier) || !constantTimeEqual(
|
|
3234
|
+
if (!VERIFIER.test(verifier) || !constantTimeEqual(s2562(verifier), issued.codeChallenge)) {
|
|
2337
3235
|
throw invalid("code_verifier does not match the code_challenge");
|
|
2338
3236
|
}
|
|
3237
|
+
if (issued.subject !== undefined && this.#host.session(issued.addressId)?.identity !== issued.subject) {
|
|
3238
|
+
throw invalid("the page's user signed out or changed");
|
|
3239
|
+
}
|
|
2339
3240
|
const now = Date.now();
|
|
2340
3241
|
const grantId = newId("g");
|
|
2341
3242
|
issued.grantId = grantId;
|
|
@@ -2346,7 +3247,8 @@ ${cancel}
|
|
|
2346
3247
|
clientId: issued.client.clientId,
|
|
2347
3248
|
clientName: issued.client.name,
|
|
2348
3249
|
scopes: issued.scopes,
|
|
2349
|
-
grantCreatedAt: now
|
|
3250
|
+
grantCreatedAt: now,
|
|
3251
|
+
...issued.subject === undefined ? {} : { subject: issued.subject }
|
|
2350
3252
|
}, {
|
|
2351
3253
|
accessTtlMs: config.accessTokenTtlMs,
|
|
2352
3254
|
refreshTtlMs: config.refreshTokenTtlMs,
|
|
@@ -2421,7 +3323,8 @@ ${cancel}
|
|
|
2421
3323
|
clientId: record.clientId,
|
|
2422
3324
|
clientName: record.clientName,
|
|
2423
3325
|
scopes: record.scopes,
|
|
2424
|
-
grantCreatedAt: record.grantCreatedAt
|
|
3326
|
+
grantCreatedAt: record.grantCreatedAt,
|
|
3327
|
+
...record.subject === undefined ? {} : { subject: record.subject }
|
|
2425
3328
|
}, {
|
|
2426
3329
|
accessTtlMs: this.#host.config.accessTokenTtlMs,
|
|
2427
3330
|
refreshTtlMs: this.#host.config.refreshTokenTtlMs,
|
|
@@ -2557,10 +3460,13 @@ class TabSession {
|
|
|
2557
3460
|
addressId;
|
|
2558
3461
|
appName;
|
|
2559
3462
|
codes;
|
|
3463
|
+
identity;
|
|
2560
3464
|
pending = new Map;
|
|
2561
3465
|
#ws;
|
|
2562
3466
|
#host;
|
|
2563
3467
|
#state = "new";
|
|
3468
|
+
#dialogOpen = false;
|
|
3469
|
+
#identityChain = Promise.resolve();
|
|
2564
3470
|
#lastRequestAt = Date.now();
|
|
2565
3471
|
#missedPongs = 0;
|
|
2566
3472
|
#nextRequest = 0;
|
|
@@ -2589,6 +3495,9 @@ class TabSession {
|
|
|
2589
3495
|
get ready() {
|
|
2590
3496
|
return this.#state === "ready";
|
|
2591
3497
|
}
|
|
3498
|
+
get pairingOpen() {
|
|
3499
|
+
return this.site.pairing === "code" ? this.codes.active : this.#dialogOpen;
|
|
3500
|
+
}
|
|
2592
3501
|
get limits() {
|
|
2593
3502
|
return this.site.limits;
|
|
2594
3503
|
}
|
|
@@ -2677,9 +3586,18 @@ class TabSession {
|
|
|
2677
3586
|
case "pairing.start":
|
|
2678
3587
|
if (this.site.pairing === "code")
|
|
2679
3588
|
this.codes.start();
|
|
3589
|
+
else
|
|
3590
|
+
this.#dialogOpen = true;
|
|
2680
3591
|
return;
|
|
2681
3592
|
case "pairing.stop":
|
|
2682
3593
|
this.codes.stop();
|
|
3594
|
+
this.#dialogOpen = false;
|
|
3595
|
+
return;
|
|
3596
|
+
case "identity":
|
|
3597
|
+
await this.#serialIdentity(() => this.#host.bindIdentity(this, msg.assertion, msg.round));
|
|
3598
|
+
return;
|
|
3599
|
+
case "identity.logout":
|
|
3600
|
+
await this.#serialIdentity(() => this.#host.signOut(this));
|
|
2683
3601
|
return;
|
|
2684
3602
|
case "grant.revoke":
|
|
2685
3603
|
if (!await this.#host.revokeGrant(this, msg.grantId)) {
|
|
@@ -2759,6 +3677,7 @@ class TabSession {
|
|
|
2759
3677
|
address: `${this.#host.publicUrl()}/a/${this.addressId}/mcp`,
|
|
2760
3678
|
origin: this.origin,
|
|
2761
3679
|
...this.appName === undefined ? {} : { appName: this.appName },
|
|
3680
|
+
pairing: this.site.pairing,
|
|
2762
3681
|
expiresAt: new Date(expiresAt).toISOString(),
|
|
2763
3682
|
limits: limitsOf(limits),
|
|
2764
3683
|
grants
|
|
@@ -2778,6 +3697,11 @@ class TabSession {
|
|
|
2778
3697
|
});
|
|
2779
3698
|
this.#host.ready?.(this);
|
|
2780
3699
|
}
|
|
3700
|
+
#serialIdentity(run) {
|
|
3701
|
+
const next = this.#identityChain.then(run);
|
|
3702
|
+
this.#identityChain = next.catch(() => {});
|
|
3703
|
+
return next;
|
|
3704
|
+
}
|
|
2781
3705
|
#tick() {
|
|
2782
3706
|
if (this.#state !== "ready")
|
|
2783
3707
|
return;
|
|
@@ -2852,6 +3776,7 @@ class TabSession {
|
|
|
2852
3776
|
const wasReady = this.#state === "ready" || this.#state === "hello";
|
|
2853
3777
|
this.#state = "ended";
|
|
2854
3778
|
this.codes.stop();
|
|
3779
|
+
this.#dialogOpen = false;
|
|
2855
3780
|
clearTimeout(this.#helloTimer);
|
|
2856
3781
|
clearInterval(this.#heartbeatTimer);
|
|
2857
3782
|
clearTimeout(this.#lifetimeTimer);
|
|
@@ -2891,13 +3816,16 @@ function summarizeGrants(records, now) {
|
|
|
2891
3816
|
for (const r of records) {
|
|
2892
3817
|
if (!isLive(r, now) || grants.has(r.grantId))
|
|
2893
3818
|
continue;
|
|
2894
|
-
|
|
3819
|
+
const summary = {
|
|
2895
3820
|
grantId: r.grantId,
|
|
2896
3821
|
clientId: r.clientId,
|
|
2897
3822
|
clientName: r.clientName,
|
|
2898
3823
|
scopes: [...r.scopes],
|
|
2899
3824
|
createdAt: r.grantCreatedAt
|
|
2900
|
-
}
|
|
3825
|
+
};
|
|
3826
|
+
if (r.subject !== undefined)
|
|
3827
|
+
summary.subject = r.subject;
|
|
3828
|
+
grants.set(r.grantId, summary);
|
|
2901
3829
|
}
|
|
2902
3830
|
return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
|
|
2903
3831
|
}
|
|
@@ -2977,7 +3905,7 @@ class MemoryTokenStore {
|
|
|
2977
3905
|
|
|
2978
3906
|
// src/store/sqlite.ts
|
|
2979
3907
|
import { Database } from "bun:sqlite";
|
|
2980
|
-
var SCHEMA_VERSION =
|
|
3908
|
+
var SCHEMA_VERSION = 3;
|
|
2981
3909
|
function toRecord(row) {
|
|
2982
3910
|
const record = {
|
|
2983
3911
|
hash: row.hash,
|
|
@@ -2993,6 +3921,8 @@ function toRecord(row) {
|
|
|
2993
3921
|
};
|
|
2994
3922
|
if (row.used_at !== null)
|
|
2995
3923
|
record.usedAt = row.used_at;
|
|
3924
|
+
if (row.subject !== null)
|
|
3925
|
+
record.subject = row.subject;
|
|
2996
3926
|
return record;
|
|
2997
3927
|
}
|
|
2998
3928
|
|
|
@@ -3028,8 +3958,13 @@ class SqliteTokenStore {
|
|
|
3028
3958
|
grant_created_at INTEGER NOT NULL,
|
|
3029
3959
|
issued_at INTEGER NOT NULL,
|
|
3030
3960
|
expires_at INTEGER NOT NULL,
|
|
3031
|
-
used_at INTEGER
|
|
3961
|
+
used_at INTEGER,
|
|
3962
|
+
subject TEXT
|
|
3032
3963
|
) STRICT`);
|
|
3964
|
+
const columns = this.#db.query("PRAGMA table_info(tokens)").all();
|
|
3965
|
+
if (!columns.some((c) => c.name === "subject")) {
|
|
3966
|
+
this.#db.run("ALTER TABLE tokens ADD COLUMN subject TEXT");
|
|
3967
|
+
}
|
|
3033
3968
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
|
|
3034
3969
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
|
|
3035
3970
|
this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
|
|
@@ -3052,9 +3987,9 @@ class SqliteTokenStore {
|
|
|
3052
3987
|
}
|
|
3053
3988
|
#insert(r) {
|
|
3054
3989
|
this.#db.query(`INSERT OR REPLACE INTO tokens (hash, kind, grant_id, address_id, client_id,
|
|
3055
|
-
client_name, scopes, grant_created_at, issued_at, expires_at, used_at)
|
|
3990
|
+
client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject)
|
|
3056
3991
|
VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
|
|
3057
|
-
$scopes, $grant_created_at, $issued_at, $expires_at, $used_at)`).run({
|
|
3992
|
+
$scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject)`).run({
|
|
3058
3993
|
hash: r.hash,
|
|
3059
3994
|
kind: r.kind,
|
|
3060
3995
|
grant_id: r.grantId,
|
|
@@ -3065,7 +4000,8 @@ class SqliteTokenStore {
|
|
|
3065
4000
|
grant_created_at: r.grantCreatedAt,
|
|
3066
4001
|
issued_at: r.issuedAt,
|
|
3067
4002
|
expires_at: r.expiresAt,
|
|
3068
|
-
used_at: r.usedAt ?? null
|
|
4003
|
+
used_at: r.usedAt ?? null,
|
|
4004
|
+
subject: r.subject ?? null
|
|
3069
4005
|
});
|
|
3070
4006
|
}
|
|
3071
4007
|
async get(hash) {
|
|
@@ -3176,6 +4112,7 @@ function startRelay(options, hooks) {
|
|
|
3176
4112
|
const appNames = new Map;
|
|
3177
4113
|
let claudeWorkarounds = 0;
|
|
3178
4114
|
let publicUrl = config.publicUrl ?? "";
|
|
4115
|
+
const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
|
|
3179
4116
|
const corsHeaders = config.cors ? {
|
|
3180
4117
|
"access-control-allow-origin": "*",
|
|
3181
4118
|
"access-control-expose-headers": "www-authenticate, mcp-protocol-version"
|
|
@@ -3223,6 +4160,105 @@ function startRelay(options, hooks) {
|
|
|
3223
4160
|
for (const g of grants)
|
|
3224
4161
|
grantRemoved(addressId, g.grantId, "revoked");
|
|
3225
4162
|
},
|
|
4163
|
+
async bindIdentity(session, assertion, round) {
|
|
4164
|
+
const addressId = session.addressId;
|
|
4165
|
+
if (addressId === undefined)
|
|
4166
|
+
return;
|
|
4167
|
+
const echo = round === undefined ? {} : { round };
|
|
4168
|
+
const answer = (message) => {
|
|
4169
|
+
if (!session.send(message)) {
|
|
4170
|
+
logger.warn("identity.answer_dropped", {
|
|
4171
|
+
address: shortAddress(addressId),
|
|
4172
|
+
type: message.type
|
|
4173
|
+
});
|
|
4174
|
+
}
|
|
4175
|
+
};
|
|
4176
|
+
const refuse = (reason, message) => {
|
|
4177
|
+
if (session.identity !== undefined) {
|
|
4178
|
+
session.identity = undefined;
|
|
4179
|
+
failPending(session, "The page's user is no longer signed in.");
|
|
4180
|
+
}
|
|
4181
|
+
logger.info("identity.refused", {
|
|
4182
|
+
address: shortAddress(addressId),
|
|
4183
|
+
reason
|
|
4184
|
+
});
|
|
4185
|
+
answer({ type: "error", code: "identity_refused", message, ...echo });
|
|
4186
|
+
};
|
|
4187
|
+
if (session.site.pairing !== "oidc" || assertions === undefined) {
|
|
4188
|
+
refuse("not_oidc", "this page's site does not use identity binding");
|
|
4189
|
+
return;
|
|
4190
|
+
}
|
|
4191
|
+
let key;
|
|
4192
|
+
try {
|
|
4193
|
+
key = await assertions.verify(assertion, `${publicUrl}/a/${addressId}/mcp`);
|
|
4194
|
+
} catch (error) {
|
|
4195
|
+
refuse(error instanceof JwtError ? error.reason : "error", `the identity assertion was refused: ${error instanceof JwtError ? error.message : "the relay could not check it"}`);
|
|
4196
|
+
return;
|
|
4197
|
+
}
|
|
4198
|
+
if (!session.ready)
|
|
4199
|
+
return;
|
|
4200
|
+
const changed = session.identity !== key;
|
|
4201
|
+
if (changed && session.identity !== undefined) {
|
|
4202
|
+
session.identity = undefined;
|
|
4203
|
+
failPending(session, "The page's user changed.");
|
|
4204
|
+
}
|
|
4205
|
+
let revoked = 0;
|
|
4206
|
+
try {
|
|
4207
|
+
const grants = await store.listGrants(addressId, Date.now());
|
|
4208
|
+
for (const g of grants.filter((g) => g.subject !== key)) {
|
|
4209
|
+
await store.revoke({ grantId: g.grantId }, revokeOptions());
|
|
4210
|
+
grantRemoved(addressId, g.grantId, "revoked");
|
|
4211
|
+
revoked++;
|
|
4212
|
+
}
|
|
4213
|
+
} catch (error) {
|
|
4214
|
+
logger.error("store.error", {
|
|
4215
|
+
op: "identity",
|
|
4216
|
+
message: error instanceof Error ? error.message : "unknown"
|
|
4217
|
+
});
|
|
4218
|
+
refuse("store_unavailable", "the relay cannot check grants right now");
|
|
4219
|
+
return;
|
|
4220
|
+
}
|
|
4221
|
+
if (!session.ready)
|
|
4222
|
+
return;
|
|
4223
|
+
session.identity = key;
|
|
4224
|
+
answer({ type: "identity.bound", ...echo });
|
|
4225
|
+
logger.info("identity.bound", {
|
|
4226
|
+
address: shortAddress(addressId),
|
|
4227
|
+
changed,
|
|
4228
|
+
revoked
|
|
4229
|
+
});
|
|
4230
|
+
host.ready(session);
|
|
4231
|
+
},
|
|
4232
|
+
async signOut(session) {
|
|
4233
|
+
const addressId = session.addressId;
|
|
4234
|
+
if (addressId === undefined)
|
|
4235
|
+
return;
|
|
4236
|
+
if (session.site.pairing !== "oidc") {
|
|
4237
|
+
session.send({
|
|
4238
|
+
type: "error",
|
|
4239
|
+
code: "identity_refused",
|
|
4240
|
+
message: "this page's site does not use identity binding"
|
|
4241
|
+
});
|
|
4242
|
+
return;
|
|
4243
|
+
}
|
|
4244
|
+
session.identity = undefined;
|
|
4245
|
+
failPending(session, "The page's user signed out.");
|
|
4246
|
+
try {
|
|
4247
|
+
const grants = await store.listGrants(addressId, Date.now());
|
|
4248
|
+
await store.revoke({ addressId }, revokeOptions());
|
|
4249
|
+
logger.info("identity.signed_out", {
|
|
4250
|
+
address: shortAddress(addressId),
|
|
4251
|
+
grants: grants.length
|
|
4252
|
+
});
|
|
4253
|
+
for (const g of grants)
|
|
4254
|
+
grantRemoved(addressId, g.grantId, "revoked");
|
|
4255
|
+
} catch (error) {
|
|
4256
|
+
logger.error("store.error", {
|
|
4257
|
+
op: "signOut",
|
|
4258
|
+
message: error instanceof Error ? error.message : "unknown"
|
|
4259
|
+
});
|
|
4260
|
+
}
|
|
4261
|
+
},
|
|
3226
4262
|
claimAddress(session) {
|
|
3227
4263
|
const id = session.addressId;
|
|
3228
4264
|
if (id === undefined)
|
|
@@ -3257,12 +4293,20 @@ function startRelay(options, hooks) {
|
|
|
3257
4293
|
wake(session);
|
|
3258
4294
|
}
|
|
3259
4295
|
};
|
|
3260
|
-
function
|
|
4296
|
+
function failPending(session, message) {
|
|
4297
|
+
for (const exchange of [...session.pending.values()]) {
|
|
4298
|
+
exchange.fail(401, RelayErrorCode.signedOut, message, true);
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
function readySession(addressId, usable = () => true) {
|
|
3261
4302
|
const s = sessions.get(addressId);
|
|
3262
|
-
return s?.ready === true ? s : undefined;
|
|
4303
|
+
return s?.ready === true && usable(s) ? s : undefined;
|
|
3263
4304
|
}
|
|
3264
|
-
function
|
|
3265
|
-
|
|
4305
|
+
function usableFor(subject) {
|
|
4306
|
+
return (s) => subject === undefined && s.site.pairing === "code" || s.identity !== undefined;
|
|
4307
|
+
}
|
|
4308
|
+
function waitForPage(addressId, signal, waitMs, usable) {
|
|
4309
|
+
const now = readySession(addressId, usable);
|
|
3266
4310
|
if (now !== undefined)
|
|
3267
4311
|
return Promise.resolve(now);
|
|
3268
4312
|
if (waitMs === 0 || stopping !== undefined) {
|
|
@@ -3288,7 +4332,10 @@ function startRelay(options, hooks) {
|
|
|
3288
4332
|
}
|
|
3289
4333
|
resolve(result);
|
|
3290
4334
|
};
|
|
3291
|
-
const wake = (session) =>
|
|
4335
|
+
const wake = (session) => {
|
|
4336
|
+
if (session === undefined || usable(session))
|
|
4337
|
+
done(session);
|
|
4338
|
+
};
|
|
3292
4339
|
const onAbort = () => done("aborted");
|
|
3293
4340
|
set.add(wake);
|
|
3294
4341
|
waiters.set(addressId, set);
|
|
@@ -3522,18 +4569,26 @@ function startRelay(options, hooks) {
|
|
|
3522
4569
|
logDone(499, method, { outcome: "client_closed" });
|
|
3523
4570
|
return new Response(null, { status: 499 });
|
|
3524
4571
|
};
|
|
3525
|
-
|
|
4572
|
+
const usable = usableFor(auth.subject);
|
|
4573
|
+
if (readySession(addressId, usable) === undefined && waitMs > 0) {
|
|
3526
4574
|
server.timeout(req, 0);
|
|
3527
4575
|
}
|
|
3528
4576
|
let retried = false;
|
|
3529
4577
|
for (;; ) {
|
|
3530
|
-
const found = await waitForPage(addressId, req.signal, waitMs);
|
|
4578
|
+
const found = await waitForPage(addressId, req.signal, waitMs, usable);
|
|
3531
4579
|
if (found === "aborted" || req.signal.aborted)
|
|
3532
4580
|
return closed();
|
|
3533
4581
|
if (found === undefined) {
|
|
3534
4582
|
return pageNotOpen(body.value, rpcId, addressId, logDone, method);
|
|
3535
4583
|
}
|
|
3536
4584
|
const session = found;
|
|
4585
|
+
if ((auth.subject !== undefined || session.site.pairing === "oidc") && session.identity !== auth.subject) {
|
|
4586
|
+
logDone(401, method, { reason: "identity_mismatch" });
|
|
4587
|
+
return json(401, { error: "invalid_token", error_description: MISMATCH_TEXT }, {
|
|
4588
|
+
...corsHeaders,
|
|
4589
|
+
"www-authenticate": `${wwwAuthenticate(addressId, true)}, error_description="${MISMATCH_TEXT}"`
|
|
4590
|
+
});
|
|
4591
|
+
}
|
|
3537
4592
|
if (Buffer.byteLength(body.text) > session.limits.maxMessageBytes) {
|
|
3538
4593
|
logDone(413, method);
|
|
3539
4594
|
return rpcError(413, rpcId, -32600, "Request body too large.", corsHeaders);
|
|
@@ -3589,7 +4644,9 @@ function startRelay(options, hooks) {
|
|
|
3589
4644
|
}
|
|
3590
4645
|
}
|
|
3591
4646
|
function pageNotOpen(value, rpcId, addressId, logDone, method) {
|
|
3592
|
-
const
|
|
4647
|
+
const open = sessions.get(addressId);
|
|
4648
|
+
const phase = open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
|
|
4649
|
+
const answer = notOpenAnswer(value, rpcId, open?.appName ?? appNames.get(addressId), phase, RELAY_INFO);
|
|
3593
4650
|
if (answer === undefined) {
|
|
3594
4651
|
logDone(503, method, { reason: "page_not_open" });
|
|
3595
4652
|
return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
|
|
@@ -3658,6 +4715,9 @@ function startRelay(options, hooks) {
|
|
|
3658
4715
|
GET: (req, srv) => auth.authorize(req, ipOf(req, srv)),
|
|
3659
4716
|
POST: (req, srv) => auth.authorizeSubmit(req, ipOf(req, srv))
|
|
3660
4717
|
},
|
|
4718
|
+
"/authorize/callback": {
|
|
4719
|
+
GET: (req, srv) => auth.authorizeCallback(req, ipOf(req, srv))
|
|
4720
|
+
},
|
|
3661
4721
|
"/authorize.css": { GET: () => auth.stylesheet() },
|
|
3662
4722
|
"/token": {
|
|
3663
4723
|
POST: (req, srv) => auth.token(req, ipOf(req, srv)),
|
|
@@ -3808,6 +4868,16 @@ Configuration is read from environment variables:
|
|
|
3808
4868
|
go beyond it (7776000 = 90 days)
|
|
3809
4869
|
TAB_MCP_LOG_LEVEL error | warn | info | debug (info); bodies are never logged
|
|
3810
4870
|
|
|
4871
|
+
Identity binding (pairing by the same sign-in instead of a code; docs/configuration.md):
|
|
4872
|
+
TAB_MCP_IDENTITY code | oidc: pairing of sites without their own (code)
|
|
4873
|
+
TAB_MCP_OIDC_ISSUER the OpenID Connect provider's issuer (required for oidc)
|
|
4874
|
+
TAB_MCP_OIDC_CLIENT_ID the relay's client at the provider (required for oidc);
|
|
4875
|
+
register <public URL>/authorize/callback as redirect URI
|
|
4876
|
+
TAB_MCP_OIDC_CLIENT_SECRET its secret (empty = public client, PKCE only)
|
|
4877
|
+
TAB_MCP_OIDC_SUBJECT_CLAIM sub | oid: the ID token claim compared (sub; oid for Entra ID)
|
|
4878
|
+
TAB_MCP_TAB_ASSERTION_ISSUER iss of the web app's tab assertions (required for oidc)
|
|
4879
|
+
TAB_MCP_TAB_ASSERTION_JWKS_URL JWKS of the web app's assertion keys (required for oidc)
|
|
4880
|
+
|
|
3811
4881
|
Stops on SIGTERM or SIGINT: tabs get "bye" and close code 4004.
|
|
3812
4882
|
`;
|
|
3813
4883
|
var args = process.argv.slice(2);
|
|
@@ -3846,5 +4916,5 @@ var shutdown = () => {
|
|
|
3846
4916
|
process.on("SIGTERM", shutdown);
|
|
3847
4917
|
process.on("SIGINT", shutdown);
|
|
3848
4918
|
|
|
3849
|
-
//# debugId=
|
|
4919
|
+
//# debugId=16AE1B87E7D1BB3C64756E2164756E21
|
|
3850
4920
|
//# sourceMappingURL=main.js.map
|