@ractive-ch/tab-mcp 0.5.1 → 0.6.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/main.js CHANGED
@@ -603,7 +603,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
603
603
  };
604
604
  }
605
605
  // package.json
606
- var version = "0.5.1";
606
+ var version = "0.6.0";
607
607
 
608
608
  // src/address.ts
609
609
  var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
@@ -1008,6 +1008,7 @@ var MAX_JWT_BYTES = 8192;
1008
1008
  var MAX_JWKS_BYTES = 65536;
1009
1009
  var JWKS_TTL_MS = 10 * 60000;
1010
1010
  var JWKS_REFETCH_MS = 30000;
1011
+ var UNKNOWN_KID_REFETCH_MS = 5000;
1011
1012
  var JWKS_GRACE_MS = 60 * 60000;
1012
1013
  var FETCH_TIMEOUT_MS = 5000;
1013
1014
 
@@ -1172,6 +1173,7 @@ class HttpJsonError extends Error {
1172
1173
  class JwksCache {
1173
1174
  #url;
1174
1175
  #now;
1176
+ #load;
1175
1177
  #keys = [];
1176
1178
  #fetchedAt = 0;
1177
1179
  #attemptedAt = Number.NEGATIVE_INFINITY;
@@ -1180,14 +1182,17 @@ class JwksCache {
1180
1182
  constructor(url, options = {}) {
1181
1183
  this.#url = typeof url === "string" ? () => Promise.resolve(url) : url;
1182
1184
  this.#now = options.now ?? Date.now;
1185
+ this.#load = options.load ?? ((u) => fetchJson(u));
1183
1186
  }
1184
1187
  async keys(kid) {
1188
+ const entered = this.#now();
1185
1189
  if (this.#inflight !== undefined)
1186
1190
  await this.#inflight.catch(() => {});
1187
1191
  const now = this.#now();
1188
1192
  const stale = now - this.#fetchedAt >= JWKS_TTL_MS;
1189
- const unknown = kid !== undefined && !this.#keys.some((k) => k.kid === kid);
1190
- if ((stale || unknown) && now - this.#attemptedAt >= JWKS_REFETCH_MS) {
1193
+ const unknown = kid !== undefined && !this.#has(kid);
1194
+ const floor = unknown && this.#lastError === undefined ? UNKNOWN_KID_REFETCH_MS : JWKS_REFETCH_MS;
1195
+ if ((stale || unknown) && now - this.#attemptedAt >= floor) {
1191
1196
  try {
1192
1197
  await this.#refresh();
1193
1198
  } catch (error) {
@@ -1197,13 +1202,21 @@ class JwksCache {
1197
1202
  if (this.#now() - this.#fetchedAt >= JWKS_TTL_MS + JWKS_GRACE_MS) {
1198
1203
  throw this.#lastError ?? new Error("the JWKS could not be loaded");
1199
1204
  }
1200
- return kid === undefined ? this.#keys : this.#keys.filter((k) => k.kid === kid);
1205
+ if (kid === undefined)
1206
+ return this.#keys;
1207
+ if (!this.#has(kid) && this.#fetchedAt < entered) {
1208
+ throw this.#lastError ?? new Error("an unknown key id; the JWKS was fetched too recently");
1209
+ }
1210
+ return this.#keys.filter((k) => k.kid === kid);
1211
+ }
1212
+ #has(kid) {
1213
+ return this.#keys.some((k) => k.kid === kid);
1201
1214
  }
1202
1215
  #refresh() {
1203
1216
  this.#inflight ??= (async () => {
1204
1217
  this.#attemptedAt = this.#now();
1205
1218
  try {
1206
- const doc = await fetchJson(await this.#url());
1219
+ const doc = await this.#load(await this.#url());
1207
1220
  const keys = doc?.keys;
1208
1221
  if (!Array.isArray(keys))
1209
1222
  throw new Error("a JWKS needs keys");
@@ -1688,6 +1701,142 @@ class RateLimiter {
1688
1701
  }
1689
1702
  }
1690
1703
 
1704
+ // src/store/memory.ts
1705
+ function copy(record) {
1706
+ return { ...record, scopes: [...record.scopes] };
1707
+ }
1708
+ function matches(record, selector) {
1709
+ return "grantId" in selector ? record.grantId === selector.grantId : record.addressId === selector.addressId;
1710
+ }
1711
+ function isLive(record, now) {
1712
+ return record.expiresAt > now && (record.kind === "access" || record.usedAt === undefined);
1713
+ }
1714
+ function summarizeGrants(records, now) {
1715
+ const grants = new Map;
1716
+ for (const r of records) {
1717
+ if (!isLive(r, now) || grants.has(r.grantId))
1718
+ continue;
1719
+ const summary = {
1720
+ grantId: r.grantId,
1721
+ clientId: r.clientId,
1722
+ clientName: r.clientName,
1723
+ scopes: [...r.scopes],
1724
+ createdAt: r.grantCreatedAt
1725
+ };
1726
+ if (r.subject !== undefined)
1727
+ summary.subject = r.subject;
1728
+ grants.set(r.grantId, summary);
1729
+ }
1730
+ return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
1731
+ }
1732
+
1733
+ class NonceSet {
1734
+ #until = new Map;
1735
+ get size() {
1736
+ return this.#until.size;
1737
+ }
1738
+ claim(key, expiresAt, now) {
1739
+ const until = this.#until.get(key);
1740
+ if (until !== undefined && until > now)
1741
+ return false;
1742
+ this.#until.set(key, expiresAt);
1743
+ return true;
1744
+ }
1745
+ sweep(now) {
1746
+ let n = 0;
1747
+ for (const [key, until] of this.#until) {
1748
+ if (until <= now) {
1749
+ this.#until.delete(key);
1750
+ n++;
1751
+ }
1752
+ }
1753
+ return n;
1754
+ }
1755
+ clear() {
1756
+ this.#until.clear();
1757
+ }
1758
+ }
1759
+
1760
+ class MemoryTokenStore {
1761
+ #records = new Map;
1762
+ #nonces = new NonceSet;
1763
+ #revokedGrants = new Map;
1764
+ #revokedAddresses = new Map;
1765
+ async put(record) {
1766
+ if (this.#revokedGrants.has(record.grantId))
1767
+ return false;
1768
+ const address = this.#revokedAddresses.get(record.addressId);
1769
+ if (address !== undefined && record.grantCreatedAt <= address.at) {
1770
+ return false;
1771
+ }
1772
+ this.#records.set(record.hash, copy(record));
1773
+ return true;
1774
+ }
1775
+ async get(hash) {
1776
+ const r = this.#records.get(hash);
1777
+ return r === undefined ? undefined : copy(r);
1778
+ }
1779
+ async markUsed(hash, now, keepUntil) {
1780
+ const r = this.#records.get(hash);
1781
+ if (r === undefined || r.usedAt !== undefined)
1782
+ return false;
1783
+ r.usedAt = now;
1784
+ r.expiresAt = Math.min(r.expiresAt, keepUntil);
1785
+ return true;
1786
+ }
1787
+ async revoke(selector, options) {
1788
+ if ("grantId" in selector) {
1789
+ this.#revokedGrants.set(selector.grantId, options.keepUntil);
1790
+ } else {
1791
+ const old = this.#revokedAddresses.get(selector.addressId);
1792
+ this.#revokedAddresses.set(selector.addressId, {
1793
+ at: Math.max(old?.at ?? 0, options.now),
1794
+ until: Math.max(old?.until ?? 0, options.keepUntil)
1795
+ });
1796
+ }
1797
+ let n = 0;
1798
+ for (const [hash, r] of this.#records) {
1799
+ if (matches(r, selector)) {
1800
+ this.#records.delete(hash);
1801
+ n++;
1802
+ }
1803
+ }
1804
+ return n;
1805
+ }
1806
+ async listGrants(addressId, now) {
1807
+ const own = [...this.#records.values()].filter((r) => r.addressId === addressId);
1808
+ return summarizeGrants(own, now);
1809
+ }
1810
+ async deleteExpired(now) {
1811
+ for (const [id, until] of this.#revokedGrants) {
1812
+ if (until <= now)
1813
+ this.#revokedGrants.delete(id);
1814
+ }
1815
+ for (const [id, t] of this.#revokedAddresses) {
1816
+ if (t.until <= now)
1817
+ this.#revokedAddresses.delete(id);
1818
+ }
1819
+ this.#nonces.sweep(now);
1820
+ let n = 0;
1821
+ for (const [hash, r] of this.#records) {
1822
+ if (r.expiresAt <= now) {
1823
+ this.#records.delete(hash);
1824
+ n++;
1825
+ }
1826
+ }
1827
+ return n;
1828
+ }
1829
+ async claimNonce(key, expiresAt, now) {
1830
+ return this.#nonces.claim(key, expiresAt, now);
1831
+ }
1832
+ async close() {
1833
+ this.#nonces.clear();
1834
+ this.#records.clear();
1835
+ this.#revokedGrants.clear();
1836
+ this.#revokedAddresses.clear();
1837
+ }
1838
+ }
1839
+
1691
1840
  // src/tokens.ts
1692
1841
  var PREFIX = {
1693
1842
  access: "tmcp_at_",
@@ -1717,7 +1866,8 @@ async function issueTokenPair(store, grant, options) {
1717
1866
  scopes: grant.scopes,
1718
1867
  grantCreatedAt: grant.grantCreatedAt,
1719
1868
  issuedAt: now,
1720
- ...grant.subject === undefined ? {} : { subject: grant.subject }
1869
+ ...grant.subject === undefined ? {} : { subject: grant.subject },
1870
+ ...grant.clientAuth === undefined ? {} : { clientAuth: grant.clientAuth }
1721
1871
  };
1722
1872
  const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
1723
1873
  const refreshStored = await store.put({
@@ -1743,6 +1893,75 @@ async function issueTokenPair(store, grant, options) {
1743
1893
  };
1744
1894
  }
1745
1895
 
1896
+ // src/oauth/domain-cache.ts
1897
+ class DomainCache {
1898
+ #entries = new Map;
1899
+ #counts = new Map;
1900
+ #max;
1901
+ #perDomain;
1902
+ #isDead;
1903
+ constructor(max, perDomain, isDead = () => false) {
1904
+ this.#max = max;
1905
+ this.#perDomain = perDomain;
1906
+ this.#isDead = isDead;
1907
+ }
1908
+ get size() {
1909
+ return this.#entries.size;
1910
+ }
1911
+ get(key) {
1912
+ const entry = this.#entries.get(key);
1913
+ if (entry === undefined)
1914
+ return;
1915
+ this.#entries.delete(key);
1916
+ this.#entries.set(key, entry);
1917
+ return entry.value;
1918
+ }
1919
+ delete(key) {
1920
+ const entry = this.#entries.get(key);
1921
+ if (entry === undefined)
1922
+ return;
1923
+ this.#entries.delete(key);
1924
+ const left = (this.#counts.get(entry.domain) ?? 1) - 1;
1925
+ if (left > 0)
1926
+ this.#counts.set(entry.domain, left);
1927
+ else
1928
+ this.#counts.delete(entry.domain);
1929
+ }
1930
+ set(key, domain, value, now) {
1931
+ this.delete(key);
1932
+ if ((this.#counts.get(domain) ?? 0) >= this.#perDomain) {
1933
+ this.#evictOldestOf(domain);
1934
+ } else if (this.#entries.size >= this.#max) {
1935
+ this.#evict(now);
1936
+ }
1937
+ this.#entries.set(key, { domain, value });
1938
+ this.#counts.set(domain, (this.#counts.get(domain) ?? 0) + 1);
1939
+ }
1940
+ #evict(now) {
1941
+ for (const [key, entry] of this.#entries) {
1942
+ if (this.#isDead(entry.value, now)) {
1943
+ this.delete(key);
1944
+ return;
1945
+ }
1946
+ }
1947
+ const most = Math.max(0, ...this.#counts.values());
1948
+ for (const [key, entry] of this.#entries) {
1949
+ if (this.#counts.get(entry.domain) === most) {
1950
+ this.delete(key);
1951
+ return;
1952
+ }
1953
+ }
1954
+ }
1955
+ #evictOldestOf(domain) {
1956
+ for (const [key, entry] of this.#entries) {
1957
+ if (entry.domain === domain) {
1958
+ this.delete(key);
1959
+ return;
1960
+ }
1961
+ }
1962
+ }
1963
+ }
1964
+
1746
1965
  // src/oauth/cimd.ts
1747
1966
  class ClientMetadataError extends Error {
1748
1967
  name = "ClientMetadataError";
@@ -1752,6 +1971,16 @@ class ClientMetadataError extends Error {
1752
1971
  this.reason = reason;
1753
1972
  }
1754
1973
  }
1974
+ var TRANSIENT_REASONS = new Set([
1975
+ "rate_limited",
1976
+ "fetch_failed",
1977
+ "unresolvable",
1978
+ "too_large"
1979
+ ]);
1980
+ function isTransient(error) {
1981
+ return TRANSIENT_REASONS.has(error.reason);
1982
+ }
1983
+ var STALE_GRACE_MS = 60 * 60000;
1755
1984
  var MAX_CLIENT_ID_LENGTH = 2048;
1756
1985
  var MAX_NAME_LENGTH = 64;
1757
1986
  var MAX_REDIRECT_URIS = 20;
@@ -1759,6 +1988,9 @@ var MIN_CACHE_MS = 5 * 60000;
1759
1988
  var DEFAULT_CACHE_MS = 60 * 60000;
1760
1989
  var MAX_CACHE_MS = 24 * 60 * 60000;
1761
1990
  var MAX_CACHE_ENTRIES = 1000;
1991
+ var MAX_CACHE_PER_DOMAIN = 100;
1992
+ var FAILURE_CACHE_MS = 60000;
1993
+ var MAX_FAILURES = 1000;
1762
1994
  async function defaultLookup(hostname) {
1763
1995
  const records = await Bun.dns.lookup(hostname, {});
1764
1996
  return records.map((r) => r.address);
@@ -1792,12 +2024,22 @@ function limitKey(hostname) {
1792
2024
  return h;
1793
2025
  return h.split(".").slice(-2).join(".");
1794
2026
  }
2027
+ function knownBudget() {
2028
+ return {
2029
+ perHost: new RateLimiter({ capacity: 5, refillPerSecond: 0.2 }),
2030
+ perDomain: new RateLimiter({ capacity: 15, refillPerSecond: 0.6 }),
2031
+ global: new RateLimiter({ capacity: 100, refillPerSecond: 10 })
2032
+ };
2033
+ }
1795
2034
 
1796
2035
  class ClientMetadataFetcher {
1797
- #cache = new Map;
2036
+ #cache = new DomainCache(MAX_CACHE_ENTRIES, MAX_CACHE_PER_DOMAIN, (entry, now) => entry.until + STALE_GRACE_MS <= now);
2037
+ #failures = new Map;
1798
2038
  #perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
1799
2039
  #perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
1800
2040
  #global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
2041
+ #refreshes = knownBudget();
2042
+ #keys = knownBudget();
1801
2043
  #allowedHosts;
1802
2044
  #logger;
1803
2045
  #timeoutMs;
@@ -1816,34 +2058,106 @@ class ClientMetadataFetcher {
1816
2058
  this.#testMode = options.testMode;
1817
2059
  this.#now = options.now ?? Date.now;
1818
2060
  }
1819
- async get(clientId) {
2061
+ async get(clientId, options = {}) {
1820
2062
  const url = this.#checkUrl(clientId);
1821
2063
  const now = this.#now();
1822
2064
  const cached = this.#cache.get(clientId);
1823
- if (cached !== undefined && cached.until > now) {
1824
- this.#cache.delete(clientId);
1825
- this.#cache.set(clientId, cached);
2065
+ if (cached !== undefined && cached.until > now)
1826
2066
  return cached.meta;
2067
+ const known = cached !== undefined && now < cached.until + STALE_GRACE_MS;
2068
+ const stale = options.allowStale === true && known ? cached?.meta : undefined;
2069
+ try {
2070
+ const failed = this.#failures.get(clientId);
2071
+ if (failed !== undefined && failed.until > now)
2072
+ throw failed.error;
2073
+ return await this.#refresh(clientId, url, now, known);
2074
+ } catch (error) {
2075
+ const transient = error instanceof ClientMetadataError && isTransient(error);
2076
+ if (!transient)
2077
+ this.#cache.delete(clientId);
2078
+ if (stale === undefined || !transient)
2079
+ throw error;
2080
+ this.#logger?.info("oauth.cimd_stale", {
2081
+ host: url.host,
2082
+ reason: error.reason
2083
+ });
2084
+ return stale;
2085
+ }
2086
+ }
2087
+ async#refresh(clientId, url, now, known) {
2088
+ this.#admit(url, now, known ? "refresh" : "new");
2089
+ let meta;
2090
+ let ttlMs;
2091
+ try {
2092
+ const address = await this.#vettedAddress(url);
2093
+ const response = await this.#fetchJson(url, address, [
2094
+ "application/json"
2095
+ ]);
2096
+ meta = validateDocument(response.doc, clientId, url, {
2097
+ test: this.#testMode !== undefined,
2098
+ onFallback: (reason) => this.#logger?.info("oauth.cimd_auth_fallback", {
2099
+ host: url.host,
2100
+ reason
2101
+ })
2102
+ });
2103
+ ttlMs = cacheMs(response.cacheControl);
2104
+ } catch (error) {
2105
+ if (error instanceof ClientMetadataError) {
2106
+ this.#rememberFailure(clientId, error, now);
2107
+ }
2108
+ throw error;
1827
2109
  }
2110
+ this.#failures.delete(clientId);
2111
+ this.#cache.set(clientId, limitKey(url.hostname), { meta, until: now + ttlMs }, now);
2112
+ return meta;
2113
+ }
2114
+ #rememberFailure(clientId, error, now) {
2115
+ this.#failures.delete(clientId);
2116
+ if (this.#failures.size >= MAX_FAILURES) {
2117
+ const oldest = this.#failures.keys().next().value;
2118
+ if (oldest !== undefined)
2119
+ this.#failures.delete(oldest);
2120
+ }
2121
+ this.#failures.set(clientId, { error, until: now + FAILURE_CACHE_MS });
2122
+ }
2123
+ async fetchJwks(jwksUri) {
2124
+ const url = this.#checkJwksUri(jwksUri);
2125
+ this.#admit(url, this.#now(), "keys");
2126
+ const address = await this.#vettedAddress(url);
2127
+ const response = await this.#fetchJson(url, address, [
2128
+ "application/json",
2129
+ "application/jwk-set+json"
2130
+ ]);
2131
+ return response.doc;
2132
+ }
2133
+ #admit(url, now, budget) {
2134
+ const { perHost, perDomain, global } = budget === "refresh" ? this.#refreshes : budget === "keys" ? this.#keys : {
2135
+ perHost: this.#perHost,
2136
+ perDomain: this.#perDomain,
2137
+ global: this.#global
2138
+ };
1828
2139
  const key = limitKey(url.hostname);
1829
- if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
2140
+ if (!perHost.available(url.host, now) || !perDomain.available(key, now)) {
1830
2141
  throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
1831
2142
  }
1832
- if (!this.#global.take("all", now)) {
2143
+ if (!global.take("all", now)) {
1833
2144
  throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
1834
2145
  }
1835
- this.#perHost.take(url.host, now);
1836
- this.#perDomain.take(key, now);
1837
- const address = await this.#vettedAddress(url);
1838
- const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
1839
- this.#cache.delete(clientId);
1840
- if (this.#cache.size >= MAX_CACHE_ENTRIES) {
1841
- const oldest = this.#cache.keys().next().value;
1842
- if (oldest !== undefined)
1843
- this.#cache.delete(oldest);
2146
+ perHost.take(url.host, now);
2147
+ perDomain.take(key, now);
2148
+ }
2149
+ #checkJwksUri(jwksUri) {
2150
+ const url = parseJwksUri(jwksUri, this.#testMode !== undefined);
2151
+ if (url === undefined) {
2152
+ throw new ClientMetadataError("invalid_jwks_uri", "The AI app's jwks_uri is not accepted.");
1844
2153
  }
1845
- this.#cache.set(clientId, { meta, until: now + ttlMs });
1846
- return meta;
2154
+ if (this.#allowedHosts.length > 0 && !this.#allowedHosts.includes(url.host)) {
2155
+ throw new ClientMetadataError("host_not_allowed", `This relay does not accept AI apps from ${url.host}.`);
2156
+ }
2157
+ if (isIpLiteral(url.hostname) && !this.#addressAllowed(url.hostname)) {
2158
+ throw this.#notLoaded("private_address", url);
2159
+ }
2160
+ return url;
1847
2161
  }
1848
2162
  #checkUrl(clientId) {
1849
2163
  const refuse = (message) => new ClientMetadataError("invalid_client_id", message);
@@ -1905,9 +2219,9 @@ class ClientMetadataFetcher {
1905
2219
  }
1906
2220
  return addresses.find((a) => parseIpv4(a) !== undefined) ?? addresses[0] ?? "";
1907
2221
  }
1908
- async#fetchDocument(url, clientId, address) {
2222
+ async#fetchJson(url, address, types) {
1909
2223
  const fail = (detail) => this.#notLoaded("fetch_failed", url, detail);
1910
- const pinned = new URL(clientId);
2224
+ const pinned = new URL(url.href);
1911
2225
  pinned.hostname = address.includes(":") ? `[${address}]` : address;
1912
2226
  const hostname = url.hostname.replace(/^\[|\]$/g, "");
1913
2227
  const tls = url.protocol === "https:" ? {
@@ -1932,8 +2246,9 @@ class ClientMetadataFetcher {
1932
2246
  if (response.status !== 200)
1933
2247
  throw fail(`status_${response.status}`);
1934
2248
  const type = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
1935
- if (type !== "application/json")
2249
+ if (type === undefined || !types.includes(type)) {
1936
2250
  throw fail("not_json");
2251
+ }
1937
2252
  const text = await this.#readLimited(response, url);
1938
2253
  let doc;
1939
2254
  try {
@@ -1941,8 +2256,7 @@ class ClientMetadataFetcher {
1941
2256
  } catch {
1942
2257
  throw fail("bad_json");
1943
2258
  }
1944
- const meta = validateDocument(doc, clientId, url);
1945
- return { meta, ttlMs: cacheMs(response.headers.get("cache-control")) };
2259
+ return { doc, cacheControl: response.headers.get("cache-control") };
1946
2260
  } finally {
1947
2261
  if (!response.bodyUsed)
1948
2262
  response.body?.cancel().catch(() => {});
@@ -1997,7 +2311,7 @@ function foreignDomain(name, hostname) {
1997
2311
  }
1998
2312
  return false;
1999
2313
  }
2000
- function validateDocument(doc, clientId, url) {
2314
+ function validateDocument(doc, clientId, url, options = {}) {
2001
2315
  const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
2002
2316
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
2003
2317
  throw invalid("The AI app's details are not a JSON object.");
@@ -2023,11 +2337,7 @@ function validateDocument(doc, clientId, url) {
2023
2337
  if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
2024
2338
  throw invalid("The AI app's details have no valid redirect_uris.");
2025
2339
  }
2026
- const method = d["token_endpoint_auth_method"];
2027
- const supported = d["token_endpoint_auth_methods_supported"];
2028
- if (method !== undefined && method !== "none" && !(Array.isArray(supported) && supported.includes("none"))) {
2029
- throw invalid('The AI app must use token_endpoint_auth_method "none" with this relay, or list "none" in token_endpoint_auth_methods_supported.');
2030
- }
2340
+ const auth = clientAuth(d, url, options, invalid);
2031
2341
  const grants = d["grant_types"];
2032
2342
  if (grants !== undefined && (!Array.isArray(grants) || !grants.includes("authorization_code"))) {
2033
2343
  throw invalid("The AI app's details do not allow the authorization code grant.");
@@ -2040,9 +2350,81 @@ function validateDocument(doc, clientId, url) {
2040
2350
  clientId,
2041
2351
  name: displayName(name),
2042
2352
  host: url.host,
2043
- redirectUris: uris
2353
+ redirectUris: uris,
2354
+ auth
2044
2355
  };
2045
2356
  }
2357
+ function clientAuth(d, url, options, invalid) {
2358
+ const method = d["token_endpoint_auth_method"];
2359
+ const supported = d["token_endpoint_auth_methods_supported"];
2360
+ const listsNone = Array.isArray(supported) && supported.includes("none");
2361
+ if (method === undefined || method === "none")
2362
+ return { method: "none" };
2363
+ const checked = method === "private_key_jwt" ? privateKeyJwt(d, url, options.test === true) : {
2364
+ problem: "unsupported_method",
2365
+ message: 'The AI app must use token_endpoint_auth_method "none" or "private_key_jwt" with this relay, or list "none" in token_endpoint_auth_methods_supported.'
2366
+ };
2367
+ if ("jwksUri" in checked) {
2368
+ return { method: "private_key_jwt", ...checked, publicAllowed: listsNone };
2369
+ }
2370
+ if (listsNone) {
2371
+ options.onFallback?.(checked.problem);
2372
+ return { method: "none" };
2373
+ }
2374
+ throw invalid(checked.message);
2375
+ }
2376
+ function privateKeyJwt(d, url, test) {
2377
+ const jwksUri = d["jwks_uri"];
2378
+ if (jwksUri === undefined) {
2379
+ return {
2380
+ problem: "no_jwks_uri",
2381
+ message: "The AI app uses private_key_jwt but publishes no jwks_uri (an inline jwks is not accepted)."
2382
+ };
2383
+ }
2384
+ if (d["jwks"] !== undefined) {
2385
+ return {
2386
+ problem: "jwks_and_jwks_uri",
2387
+ message: "The AI app's details have both jwks and jwks_uri."
2388
+ };
2389
+ }
2390
+ const parsed = typeof jwksUri === "string" ? parseJwksUri(jwksUri, test) : undefined;
2391
+ if (parsed === undefined || parsed.protocol !== url.protocol || parsed.host !== url.host) {
2392
+ return {
2393
+ problem: "bad_jwks_uri",
2394
+ message: "The AI app's jwks_uri must be an https:// URL on the host of its client_id."
2395
+ };
2396
+ }
2397
+ const alg = d["token_endpoint_auth_signing_alg"];
2398
+ if (alg !== undefined && !isAlgorithm(alg)) {
2399
+ return {
2400
+ problem: "signing_alg",
2401
+ message: "The AI app's token_endpoint_auth_signing_alg is not one this relay accepts."
2402
+ };
2403
+ }
2404
+ return {
2405
+ jwksUri: parsed.href,
2406
+ algorithms: alg === undefined ? JWT_ALGORITHMS : [alg]
2407
+ };
2408
+ }
2409
+ function parseJwksUri(value, test) {
2410
+ if (value.length > MAX_CLIENT_ID_LENGTH)
2411
+ return;
2412
+ let url;
2413
+ try {
2414
+ url = new URL(value);
2415
+ } catch {
2416
+ return;
2417
+ }
2418
+ const scheme = url.protocol === "https:" || test && url.protocol === "http:";
2419
+ if (!scheme || url.href !== value)
2420
+ return;
2421
+ if (url.username !== "" || url.password !== "" || url.hash !== "") {
2422
+ return;
2423
+ }
2424
+ if (url.port !== "" && !test)
2425
+ return;
2426
+ return url;
2427
+ }
2046
2428
  var PRIVATE_USE_SCHEME = /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+:$/;
2047
2429
  function isRedirectUri(value) {
2048
2430
  if (value.length > 2048 || value.includes("#"))
@@ -2086,6 +2468,151 @@ function redirectUriMatches(requested, registered) {
2086
2468
  return a.href === b.href;
2087
2469
  }
2088
2470
 
2471
+ // src/oauth/client-auth.ts
2472
+ var CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
2473
+ var CLIENT_ASSERTION_MAX_LIFETIME_MS = 60 * 60000;
2474
+ var CLIENT_ASSERTION_MAX_AGE_MS = 5 * 60000;
2475
+ var CLIENT_ASSERTION_LEEWAY_MS = 30000;
2476
+ var MAX_JTI2 = 256;
2477
+ var MAX_KEY_SETS = 1000;
2478
+ var MAX_KEY_SETS_PER_DOMAIN = 100;
2479
+ var SWEEP_INTERVAL_MS = 1000;
2480
+ var ACCEPTED_TYPES = new Set(["jwt", "client-authentication+jwt"]);
2481
+ function assertionSubject(assertion) {
2482
+ try {
2483
+ const sub = decodeJwt(assertion).payload["sub"];
2484
+ return typeof sub === "string" && sub !== "" ? sub : undefined;
2485
+ } catch {
2486
+ return;
2487
+ }
2488
+ }
2489
+ function nonceKey(clientId, jti) {
2490
+ return hashToken(JSON.stringify(["client_assertion", clientId, jti]));
2491
+ }
2492
+ async function verifyClientAssertion(assertion, check) {
2493
+ const { header, payload } = await verifyJwt(assertion, check.keys, {
2494
+ algorithms: check.auth.algorithms
2495
+ });
2496
+ const typ = header["typ"];
2497
+ if (typ !== undefined && (typeof typ !== "string" || !ACCEPTED_TYPES.has(typ.toLowerCase()))) {
2498
+ throw new JwtError("typ", "the assertion's type is not a client assertion");
2499
+ }
2500
+ if (payload["iss"] !== check.clientId) {
2501
+ throw new JwtError("iss", "the assertion's issuer is not the client");
2502
+ }
2503
+ if (payload["sub"] !== check.clientId) {
2504
+ throw new JwtError("sub", "the assertion's subject is not the client");
2505
+ }
2506
+ if (!audiences(payload).some((a) => check.audiences.includes(a))) {
2507
+ throw new JwtError("aud", "the assertion is not for this relay");
2508
+ }
2509
+ const usableUntil = checkTimes(payload, check.now);
2510
+ const jti = payload["jti"];
2511
+ if (typeof jti !== "string" || jti === "" || jti.length > MAX_JTI2) {
2512
+ throw new JwtError("jti", "the assertion needs a jti");
2513
+ }
2514
+ const key = nonceKey(check.clientId, jti);
2515
+ if (!await check.claimNonce(key, usableUntil, check.now)) {
2516
+ throw new JwtError("replayed", "the assertion was already used");
2517
+ }
2518
+ }
2519
+ function checkTimes(payload, now) {
2520
+ const leeway = CLIENT_ASSERTION_LEEWAY_MS;
2521
+ const exp = numericDate(payload, "exp");
2522
+ if (exp === undefined)
2523
+ throw new JwtError("exp", "the assertion has no exp");
2524
+ if (exp <= now - leeway) {
2525
+ throw new JwtError("expired", "the assertion has expired");
2526
+ }
2527
+ if (exp > now + CLIENT_ASSERTION_MAX_LIFETIME_MS + leeway) {
2528
+ throw new JwtError("lifetime", "the assertion lives longer than an hour");
2529
+ }
2530
+ const iat = numericDate(payload, "iat");
2531
+ let end = exp;
2532
+ if (iat === undefined) {
2533
+ if (exp > now + CLIENT_ASSERTION_MAX_AGE_MS + leeway) {
2534
+ throw new JwtError("lifetime", "an assertion without iat may live five minutes");
2535
+ }
2536
+ } else {
2537
+ if (iat > now + leeway) {
2538
+ throw new JwtError("iat", "the assertion was issued in the future");
2539
+ }
2540
+ if (iat + CLIENT_ASSERTION_MAX_AGE_MS <= now - leeway) {
2541
+ throw new JwtError("too_old", "the assertion was issued too long ago");
2542
+ }
2543
+ end = Math.min(exp, iat + CLIENT_ASSERTION_MAX_AGE_MS);
2544
+ }
2545
+ const nbf = numericDate(payload, "nbf");
2546
+ if (nbf !== undefined && nbf > now + leeway) {
2547
+ throw new JwtError("nbf", "the assertion is not valid yet");
2548
+ }
2549
+ return end + leeway;
2550
+ }
2551
+ function quotaKey(clientId) {
2552
+ return ipPrefixKey(new URL(clientId).hostname.replace(/\.$/, ""));
2553
+ }
2554
+
2555
+ class NonceQuota {
2556
+ #until = new Map;
2557
+ #perDomain;
2558
+ #max;
2559
+ #total = 0;
2560
+ #sweptAt = Number.NEGATIVE_INFINITY;
2561
+ constructor(perDomain, max) {
2562
+ this.#perDomain = perDomain;
2563
+ this.#max = max;
2564
+ }
2565
+ refusal(domain, now) {
2566
+ this.sweep(now);
2567
+ if ((this.#until.get(domain)?.length ?? 0) >= this.#perDomain) {
2568
+ return "domain_full";
2569
+ }
2570
+ return this.#total >= this.#max ? "full" : undefined;
2571
+ }
2572
+ add(domain, until) {
2573
+ const list = this.#until.get(domain);
2574
+ if (list === undefined)
2575
+ this.#until.set(domain, [until]);
2576
+ else
2577
+ list.push(until);
2578
+ this.#total++;
2579
+ }
2580
+ sweep(now) {
2581
+ if (now - this.#sweptAt < SWEEP_INTERVAL_MS)
2582
+ return false;
2583
+ this.#sweptAt = now;
2584
+ this.#total = 0;
2585
+ for (const [domain, list] of this.#until) {
2586
+ const live = list.filter((until) => until > now);
2587
+ if (live.length === 0)
2588
+ this.#until.delete(domain);
2589
+ else
2590
+ this.#until.set(domain, live);
2591
+ this.#total += live.length;
2592
+ }
2593
+ return true;
2594
+ }
2595
+ }
2596
+
2597
+ class ClientKeySets {
2598
+ #sets = new DomainCache(MAX_KEY_SETS, MAX_KEY_SETS_PER_DOMAIN);
2599
+ #load;
2600
+ #now;
2601
+ constructor(load, now = Date.now) {
2602
+ this.#load = load;
2603
+ this.#now = now;
2604
+ }
2605
+ for(jwksUri) {
2606
+ const known = this.#sets.get(jwksUri);
2607
+ if (known !== undefined)
2608
+ return known;
2609
+ const set = new JwksCache(jwksUri, { load: this.#load, now: this.#now });
2610
+ const domain = limitKey(new URL(jwksUri).hostname);
2611
+ this.#sets.set(jwksUri, domain, set, this.#now());
2612
+ return set;
2613
+ }
2614
+ }
2615
+
2089
2616
  // src/oauth/html.ts
2090
2617
  var ESCAPES = {
2091
2618
  "&": "&amp;",
@@ -2655,6 +3182,12 @@ var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
2655
3182
  var MAX_CODES = 1e4;
2656
3183
  var MAX_FORM_BYTES = 8192;
2657
3184
  var MAX_STATE_LENGTH = 1024;
3185
+ var MAX_NONCES_PER_HOST = 1e4;
3186
+ var MAX_NONCES = 1e5;
3187
+ var CLIENT_AUTH_FAILED = "client authentication failed";
3188
+ function assertionRequired(auth) {
3189
+ return auth.method === "private_key_jwt" && !auth.publicAllowed;
3190
+ }
2658
3191
 
2659
3192
  class OAuthError extends Error {
2660
3193
  status;
@@ -2704,10 +3237,16 @@ function hostOf(url) {
2704
3237
  return url;
2705
3238
  }
2706
3239
  }
3240
+ function logHost(clientId) {
3241
+ return clientId !== undefined && URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)";
3242
+ }
2707
3243
 
2708
3244
  class AuthorizationServer {
2709
3245
  #host;
2710
3246
  #cimd;
3247
+ #clientKeys;
3248
+ #nonces = new NonceSet;
3249
+ #nonceQuota = new NonceQuota(MAX_NONCES_PER_HOST, MAX_NONCES);
2711
3250
  #pending = new Map;
2712
3251
  #pendingPerIp = new Map;
2713
3252
  #pendingPerAddress = new Map;
@@ -2758,6 +3297,8 @@ class AuthorizationServer {
2758
3297
  logger: host.logger,
2759
3298
  ...testMode?.cimd === undefined ? {} : { testMode: testMode.cimd }
2760
3299
  });
3300
+ const cimd = this.#cimd;
3301
+ this.#clientKeys = new ClientKeySets((url) => cimd.fetchJwks(url));
2761
3302
  }
2762
3303
  get #issuer() {
2763
3304
  return this.#host.publicUrl();
@@ -2811,8 +3352,10 @@ class AuthorizationServer {
2811
3352
  response_modes_supported: ["query"],
2812
3353
  grant_types_supported: ["authorization_code", "refresh_token"],
2813
3354
  code_challenge_methods_supported: ["S256"],
2814
- token_endpoint_auth_methods_supported: ["none"],
2815
- revocation_endpoint_auth_methods_supported: ["none"],
3355
+ token_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
3356
+ token_endpoint_auth_signing_alg_values_supported: JWT_ALGORITHMS,
3357
+ revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
3358
+ revocation_endpoint_auth_signing_alg_values_supported: JWT_ALGORITHMS,
2816
3359
  scopes_supported: [SCOPE],
2817
3360
  client_id_metadata_document_supported: true,
2818
3361
  authorization_response_iss_parameter_supported: true,
@@ -3311,20 +3854,21 @@ ${cancel}
3311
3854
  "code_verifier",
3312
3855
  "refresh_token",
3313
3856
  "resource",
3314
- "scope"
3857
+ "scope",
3858
+ "client_assertion",
3859
+ "client_assertion_type"
3315
3860
  ]);
3316
3861
  if ("duplicate" in p) {
3317
3862
  throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
3318
3863
  }
3319
- const clientId = p["client_id"];
3320
- if (clientId === undefined) {
3321
- throw new OAuthError(400, "invalid_request", "client_id is required");
3322
- }
3864
+ const client = await this.#authenticateClient(p, [
3865
+ `${this.#issuer}/token`
3866
+ ]);
3323
3867
  let body;
3324
3868
  if (p["grant_type"] === "authorization_code") {
3325
- body = await this.#codeGrant(p, clientId);
3869
+ body = await this.#codeGrant(p, client);
3326
3870
  } else if (p["grant_type"] === "refresh_token") {
3327
- body = await this.#refreshGrant(p, clientId);
3871
+ body = await this.#refreshGrant(p, client);
3328
3872
  } else if (p["grant_type"] === undefined) {
3329
3873
  throw new OAuthError(400, "invalid_request", "grant_type is required");
3330
3874
  } else {
@@ -3358,19 +3902,96 @@ ${cancel}
3358
3902
  throw new OAuthError(400, "invalid_request", "the body must be application/x-www-form-urlencoded");
3359
3903
  }
3360
3904
  if (req.headers.get("authorization") !== null) {
3361
- throw new OAuthError(401, "invalid_client", "client authentication is not supported (token_endpoint_auth_method none)");
3905
+ throw new OAuthError(401, "invalid_client", "HTTP authentication is not supported (token_endpoint_auth_method none or private_key_jwt)");
3362
3906
  }
3363
3907
  const text = await this.#readForm(req);
3364
3908
  if (text === undefined) {
3365
3909
  throw new OAuthError(413, "invalid_request", "the request is too large");
3366
3910
  }
3367
3911
  const params = new URLSearchParams(text);
3368
- if (params.has("client_secret") || params.has("client_assertion")) {
3369
- throw new OAuthError(400, "invalid_client", "client authentication is not supported (token_endpoint_auth_method none)");
3912
+ if (params.has("client_secret")) {
3913
+ throw new OAuthError(400, "invalid_client", "client secrets are not supported (token_endpoint_auth_method none or private_key_jwt)");
3370
3914
  }
3371
3915
  return params;
3372
3916
  }
3373
- async#codeGrant(p, clientId) {
3917
+ async#authenticateClient(p, endpoints) {
3918
+ const assertion = p["client_assertion"];
3919
+ const type = p["client_assertion_type"];
3920
+ if (assertion === undefined && type === undefined) {
3921
+ const clientId = p["client_id"];
3922
+ if (clientId === undefined) {
3923
+ throw new OAuthError(400, "invalid_request", "client_id is required");
3924
+ }
3925
+ return { clientId, assertion: false };
3926
+ }
3927
+ if (assertion === undefined || type === undefined) {
3928
+ throw new OAuthError(400, "invalid_request", "client_assertion and client_assertion_type go together");
3929
+ }
3930
+ const clientId = p["client_id"] ?? assertionSubject(assertion);
3931
+ if (type !== CLIENT_ASSERTION_TYPE) {
3932
+ throw this.#clientAuthFailed(clientId, "assertion_type");
3933
+ }
3934
+ if (clientId === undefined) {
3935
+ throw this.#clientAuthFailed(clientId, "no_client_id");
3936
+ }
3937
+ let client;
3938
+ try {
3939
+ client = await this.#cimd.get(clientId, { allowStale: true });
3940
+ } catch (error) {
3941
+ if (!(error instanceof ClientMetadataError))
3942
+ throw error;
3943
+ const reason = `metadata_${error.reason}`;
3944
+ throw isTransient(error) || error.reason === "private_address" ? this.#clientAuthUnavailable(clientId, reason) : this.#clientAuthFailed(clientId, reason);
3945
+ }
3946
+ if (client.auth.method !== "private_key_jwt") {
3947
+ throw this.#clientAuthFailed(clientId, "not_private_key_jwt");
3948
+ }
3949
+ const domain = quotaKey(clientId);
3950
+ try {
3951
+ await verifyClientAssertion(assertion, {
3952
+ clientId,
3953
+ auth: client.auth,
3954
+ keys: this.#clientKeys.for(client.auth.jwksUri),
3955
+ audiences: [this.#issuer, ...endpoints],
3956
+ claimNonce: (key, until, now) => this.#claimNonce(clientId, domain, key, until, now),
3957
+ now: Date.now()
3958
+ });
3959
+ } catch (error) {
3960
+ if (!(error instanceof JwtError))
3961
+ throw error;
3962
+ throw error.reason === "jwks_unavailable" ? this.#clientAuthUnavailable(clientId, error.reason) : this.#clientAuthFailed(clientId, error.reason);
3963
+ }
3964
+ return { clientId, assertion: true };
3965
+ }
3966
+ #clientAuthFailed(clientId, reason) {
3967
+ this.#host.logger.info("oauth.client_auth_failed", {
3968
+ client: logHost(clientId),
3969
+ reason
3970
+ });
3971
+ return new OAuthError(401, "invalid_client", CLIENT_AUTH_FAILED);
3972
+ }
3973
+ #clientAuthUnavailable(clientId, reason) {
3974
+ this.#host.logger.warn("oauth.client_auth_unavailable", {
3975
+ client: logHost(clientId),
3976
+ reason
3977
+ });
3978
+ return new OAuthError(503, "temporarily_unavailable", "the client cannot be authenticated right now; try again shortly");
3979
+ }
3980
+ async#claimNonce(clientId, domain, key, until, now) {
3981
+ if (this.#nonceQuota.sweep(now))
3982
+ this.#nonces.sweep(now);
3983
+ const refusal = this.#nonceQuota.refusal(domain, now);
3984
+ if (refusal !== undefined) {
3985
+ throw this.#clientAuthUnavailable(clientId, `nonces_${refusal}`);
3986
+ }
3987
+ const store = this.#host.store;
3988
+ const claimed = store.claimNonce === undefined ? this.#nonces.claim(key, until, now) : await store.claimNonce(key, until, now);
3989
+ if (claimed)
3990
+ this.#nonceQuota.add(domain, until);
3991
+ return claimed;
3992
+ }
3993
+ async#codeGrant(p, client) {
3994
+ const clientId = client.clientId;
3374
3995
  const code = p["code"];
3375
3996
  const redirectUri = p["redirect_uri"];
3376
3997
  const verifier = p["code_verifier"];
@@ -3382,6 +4003,12 @@ ${cancel}
3382
4003
  const invalid = (why) => new OAuthError(400, "invalid_grant", why);
3383
4004
  if (issued === undefined)
3384
4005
  throw invalid("the code is not valid");
4006
+ if (!constantTimeEqual(clientId, issued.client.clientId)) {
4007
+ throw invalid("the code was issued to another client");
4008
+ }
4009
+ if (assertionRequired(issued.client.auth) && !client.assertion) {
4010
+ throw this.#clientAuthFailed(clientId, "assertion_required");
4011
+ }
3385
4012
  if (issued.used) {
3386
4013
  if (issued.grantId !== undefined) {
3387
4014
  await this.#revokeGrant(issued.addressId, issued.grantId, "reuse_detected");
@@ -3391,9 +4018,6 @@ ${cancel}
3391
4018
  issued.used = true;
3392
4019
  if (issued.expiresAt <= Date.now())
3393
4020
  throw invalid("the code has expired");
3394
- if (!constantTimeEqual(clientId, issued.client.clientId)) {
3395
- throw invalid("the code was issued to another client");
3396
- }
3397
4021
  if (redirectUri !== issued.redirectUri) {
3398
4022
  throw invalid("redirect_uri does not match the authorization request");
3399
4023
  }
@@ -3417,7 +4041,8 @@ ${cancel}
3417
4041
  clientName: issued.client.name,
3418
4042
  scopes: issued.scopes,
3419
4043
  grantCreatedAt: now,
3420
- ...issued.subject === undefined ? {} : { subject: issued.subject }
4044
+ ...issued.subject === undefined ? {} : { subject: issued.subject },
4045
+ ...client.assertion ? { clientAuth: "private_key_jwt" } : {}
3421
4046
  }, {
3422
4047
  accessTtlMs: config.accessTokenTtlMs,
3423
4048
  refreshTtlMs: config.refreshTokenTtlMs,
@@ -3451,7 +4076,8 @@ ${cancel}
3451
4076
  scope: scopes.join(" ")
3452
4077
  };
3453
4078
  }
3454
- async#refreshGrant(p, clientId) {
4079
+ async#refreshGrant(p, client) {
4080
+ const clientId = client.clientId;
3455
4081
  const token = p["refresh_token"];
3456
4082
  if (token === undefined) {
3457
4083
  throw new OAuthError(400, "invalid_request", "refresh_token is required");
@@ -3465,6 +4091,9 @@ ${cancel}
3465
4091
  if (!constantTimeEqual(clientId, record.clientId)) {
3466
4092
  throw invalid("the refresh token was issued to another client");
3467
4093
  }
4094
+ if (record.clientAuth === "private_key_jwt" && !client.assertion) {
4095
+ throw this.#clientAuthFailed(clientId, "assertion_required");
4096
+ }
3468
4097
  const now = Date.now();
3469
4098
  if (record.expiresAt <= now)
3470
4099
  throw invalid("the refresh token has expired");
@@ -3496,7 +4125,8 @@ ${cancel}
3496
4125
  clientName: record.clientName,
3497
4126
  scopes: record.scopes,
3498
4127
  grantCreatedAt: record.grantCreatedAt,
3499
- ...record.subject === undefined ? {} : { subject: record.subject }
4128
+ ...record.subject === undefined ? {} : { subject: record.subject },
4129
+ ...record.clientAuth === "private_key_jwt" || client.assertion ? { clientAuth: "private_key_jwt" } : {}
3500
4130
  }, {
3501
4131
  accessTtlMs: this.#host.config.accessTokenTtlMs,
3502
4132
  refreshTtlMs: this.#host.config.refreshTokenTtlMs,
@@ -3532,24 +4162,36 @@ ${cancel}
3532
4162
  throw new OAuthError(429, "temporarily_unavailable", "too many requests; try again shortly");
3533
4163
  }
3534
4164
  const params = await this.#formParams(req);
3535
- const p = single(params, ["token", "client_id", "token_type_hint"]);
4165
+ const p = single(params, [
4166
+ "token",
4167
+ "client_id",
4168
+ "token_type_hint",
4169
+ "client_assertion",
4170
+ "client_assertion_type"
4171
+ ]);
3536
4172
  if ("duplicate" in p) {
3537
4173
  throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
3538
4174
  }
3539
4175
  const token = p["token"];
3540
- const clientId = p["client_id"];
3541
- if (token === undefined || clientId === undefined) {
3542
- throw new OAuthError(400, "invalid_request", "token and client_id are required");
4176
+ if (token === undefined) {
4177
+ throw new OAuthError(400, "invalid_request", "token is required");
3543
4178
  }
4179
+ const client = await this.#authenticateClient(p, [
4180
+ `${this.#issuer}/revoke`,
4181
+ `${this.#issuer}/token`
4182
+ ]);
3544
4183
  const record = await this.#host.store.get(hashToken(token));
3545
4184
  if (record === undefined)
3546
4185
  return new Response(null, {
3547
4186
  status: 200,
3548
4187
  headers: { ...this.#cors(), ...noStore }
3549
4188
  });
3550
- if (!constantTimeEqual(clientId, record.clientId)) {
4189
+ if (!constantTimeEqual(client.clientId, record.clientId)) {
3551
4190
  throw new OAuthError(400, "invalid_client", "the token was issued to another client");
3552
4191
  }
4192
+ if (record.clientAuth === "private_key_jwt" && !client.assertion) {
4193
+ throw this.#clientAuthFailed(client.clientId, "assertion_required");
4194
+ }
3553
4195
  await this.#revokeGrant(record.addressId, record.grantId, "revoked");
3554
4196
  this.#host.logger.info("oauth.revoked", {
3555
4197
  address: shortAddress(record.addressId),
@@ -3595,6 +4237,7 @@ ${cancel}
3595
4237
  this.#guessesPerAddress.sweep(now);
3596
4238
  this.#tokenPerIp.sweep(now);
3597
4239
  this.#refreshesPerGrant.sweep(now);
4240
+ this.#nonces.sweep(now);
3598
4241
  }
3599
4242
  }
3600
4243
 
@@ -4003,112 +4646,10 @@ class TabSession {
4003
4646
  }
4004
4647
  }
4005
4648
 
4006
- // src/store/memory.ts
4007
- function copy(record) {
4008
- return { ...record, scopes: [...record.scopes] };
4009
- }
4010
- function matches(record, selector) {
4011
- return "grantId" in selector ? record.grantId === selector.grantId : record.addressId === selector.addressId;
4012
- }
4013
- function isLive(record, now) {
4014
- return record.expiresAt > now && (record.kind === "access" || record.usedAt === undefined);
4015
- }
4016
- function summarizeGrants(records, now) {
4017
- const grants = new Map;
4018
- for (const r of records) {
4019
- if (!isLive(r, now) || grants.has(r.grantId))
4020
- continue;
4021
- const summary = {
4022
- grantId: r.grantId,
4023
- clientId: r.clientId,
4024
- clientName: r.clientName,
4025
- scopes: [...r.scopes],
4026
- createdAt: r.grantCreatedAt
4027
- };
4028
- if (r.subject !== undefined)
4029
- summary.subject = r.subject;
4030
- grants.set(r.grantId, summary);
4031
- }
4032
- return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
4033
- }
4034
-
4035
- class MemoryTokenStore {
4036
- #records = new Map;
4037
- #revokedGrants = new Map;
4038
- #revokedAddresses = new Map;
4039
- async put(record) {
4040
- if (this.#revokedGrants.has(record.grantId))
4041
- return false;
4042
- const address = this.#revokedAddresses.get(record.addressId);
4043
- if (address !== undefined && record.grantCreatedAt <= address.at) {
4044
- return false;
4045
- }
4046
- this.#records.set(record.hash, copy(record));
4047
- return true;
4048
- }
4049
- async get(hash) {
4050
- const r = this.#records.get(hash);
4051
- return r === undefined ? undefined : copy(r);
4052
- }
4053
- async markUsed(hash, now, keepUntil) {
4054
- const r = this.#records.get(hash);
4055
- if (r === undefined || r.usedAt !== undefined)
4056
- return false;
4057
- r.usedAt = now;
4058
- r.expiresAt = Math.min(r.expiresAt, keepUntil);
4059
- return true;
4060
- }
4061
- async revoke(selector, options) {
4062
- if ("grantId" in selector) {
4063
- this.#revokedGrants.set(selector.grantId, options.keepUntil);
4064
- } else {
4065
- const old = this.#revokedAddresses.get(selector.addressId);
4066
- this.#revokedAddresses.set(selector.addressId, {
4067
- at: Math.max(old?.at ?? 0, options.now),
4068
- until: Math.max(old?.until ?? 0, options.keepUntil)
4069
- });
4070
- }
4071
- let n = 0;
4072
- for (const [hash, r] of this.#records) {
4073
- if (matches(r, selector)) {
4074
- this.#records.delete(hash);
4075
- n++;
4076
- }
4077
- }
4078
- return n;
4079
- }
4080
- async listGrants(addressId, now) {
4081
- const own = [...this.#records.values()].filter((r) => r.addressId === addressId);
4082
- return summarizeGrants(own, now);
4083
- }
4084
- async deleteExpired(now) {
4085
- for (const [id, until] of this.#revokedGrants) {
4086
- if (until <= now)
4087
- this.#revokedGrants.delete(id);
4088
- }
4089
- for (const [id, t] of this.#revokedAddresses) {
4090
- if (t.until <= now)
4091
- this.#revokedAddresses.delete(id);
4092
- }
4093
- let n = 0;
4094
- for (const [hash, r] of this.#records) {
4095
- if (r.expiresAt <= now) {
4096
- this.#records.delete(hash);
4097
- n++;
4098
- }
4099
- }
4100
- return n;
4101
- }
4102
- async close() {
4103
- this.#records.clear();
4104
- this.#revokedGrants.clear();
4105
- this.#revokedAddresses.clear();
4106
- }
4107
- }
4108
-
4109
4649
  // src/store/sqlite.ts
4110
4650
  import { Database } from "bun:sqlite";
4111
- var SCHEMA_VERSION = 4;
4651
+ var SCHEMA_VERSION = 5;
4652
+ var MIN_READER_VERSION = 5;
4112
4653
  var REUSE_WINDOW_MS = 86400000;
4113
4654
  function toRecord(row) {
4114
4655
  const record = {
@@ -4127,6 +4668,8 @@ function toRecord(row) {
4127
4668
  record.usedAt = row.used_at;
4128
4669
  if (row.subject !== null)
4129
4670
  record.subject = row.subject;
4671
+ if (row.client_auth === "private_key_jwt")
4672
+ record.clientAuth = "private_key_jwt";
4130
4673
  return record;
4131
4674
  }
4132
4675
 
@@ -4137,7 +4680,24 @@ class SqliteTokenStore {
4137
4680
  this.#db.run("PRAGMA busy_timeout = 5000");
4138
4681
  if (path !== ":memory:")
4139
4682
  this.#db.run("PRAGMA journal_mode = WAL");
4683
+ this.#checkReader();
4140
4684
  this.#migrate();
4685
+ this.#db.run(`CREATE TABLE IF NOT EXISTS meta (
4686
+ key TEXT PRIMARY KEY,
4687
+ value INTEGER NOT NULL
4688
+ ) STRICT`);
4689
+ this.#db.query(`INSERT INTO meta (key, value) VALUES ('min_reader_version', $v)
4690
+ ON CONFLICT (key) DO UPDATE SET value = max(value, excluded.value)`).run({ v: MIN_READER_VERSION });
4691
+ }
4692
+ #checkReader() {
4693
+ const table = this.#db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
4694
+ if (table === null)
4695
+ return;
4696
+ const row = this.#db.query("SELECT value FROM meta WHERE key = 'min_reader_version'").get();
4697
+ if (row !== null && row.value > SCHEMA_VERSION) {
4698
+ this.#db.close();
4699
+ throw new Error(`the token store file needs a newer relay (schema ${row.value}; this relay knows ${SCHEMA_VERSION})`);
4700
+ }
4141
4701
  }
4142
4702
  #migrate() {
4143
4703
  const row = this.#db.query("PRAGMA user_version").get();
@@ -4163,12 +4723,20 @@ class SqliteTokenStore {
4163
4723
  issued_at INTEGER NOT NULL,
4164
4724
  expires_at INTEGER NOT NULL,
4165
4725
  used_at INTEGER,
4166
- subject TEXT
4726
+ subject TEXT,
4727
+ client_auth TEXT
4167
4728
  ) STRICT`);
4168
4729
  const columns = this.#db.query("PRAGMA table_info(tokens)").all();
4169
4730
  if (!columns.some((c) => c.name === "subject")) {
4170
4731
  this.#db.run("ALTER TABLE tokens ADD COLUMN subject TEXT");
4171
4732
  }
4733
+ if (!columns.some((c) => c.name === "client_auth")) {
4734
+ this.#db.run("ALTER TABLE tokens ADD COLUMN client_auth TEXT");
4735
+ }
4736
+ this.#db.run(`CREATE TABLE IF NOT EXISTS nonces (
4737
+ key TEXT PRIMARY KEY,
4738
+ expires_at INTEGER NOT NULL
4739
+ ) STRICT`);
4172
4740
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
4173
4741
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
4174
4742
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
@@ -4193,9 +4761,11 @@ class SqliteTokenStore {
4193
4761
  }
4194
4762
  #insert(r) {
4195
4763
  this.#db.query(`INSERT OR REPLACE INTO tokens (hash, kind, grant_id, address_id, client_id,
4196
- client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject)
4764
+ client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject,
4765
+ client_auth)
4197
4766
  VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
4198
- $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject)`).run({
4767
+ $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject,
4768
+ $client_auth)`).run({
4199
4769
  hash: r.hash,
4200
4770
  kind: r.kind,
4201
4771
  grant_id: r.grantId,
@@ -4207,7 +4777,8 @@ class SqliteTokenStore {
4207
4777
  issued_at: r.issuedAt,
4208
4778
  expires_at: r.expiresAt,
4209
4779
  used_at: r.usedAt ?? null,
4210
- subject: r.subject ?? null
4780
+ subject: r.subject ?? null,
4781
+ client_auth: r.clientAuth ?? null
4211
4782
  });
4212
4783
  }
4213
4784
  async get(hash) {
@@ -4243,8 +4814,15 @@ class SqliteTokenStore {
4243
4814
  }
4244
4815
  async deleteExpired(now) {
4245
4816
  this.#db.query("DELETE FROM revocations WHERE keep_until <= $now").run({ now });
4817
+ this.#db.query("DELETE FROM nonces WHERE expires_at <= $now").run({ now });
4246
4818
  return this.#db.query("DELETE FROM tokens WHERE expires_at <= $now").run({ now }).changes;
4247
4819
  }
4820
+ async claimNonce(key, expiresAt, now) {
4821
+ const result = this.#db.query(`INSERT INTO nonces (key, expires_at) VALUES ($key, $expires_at)
4822
+ ON CONFLICT (key) DO UPDATE SET expires_at = excluded.expires_at
4823
+ WHERE nonces.expires_at <= $now`).run({ key, expires_at: expiresAt, now });
4824
+ return result.changes === 1;
4825
+ }
4248
4826
  async close() {
4249
4827
  this.#db.close();
4250
4828
  }
@@ -5288,5 +5866,5 @@ var shutdown = () => {
5288
5866
  process.on("SIGTERM", shutdown);
5289
5867
  process.on("SIGINT", shutdown);
5290
5868
 
5291
- //# debugId=F77E87C19A21537264756E2164756E21
5869
+ //# debugId=0F130F38D40E142464756E2164756E21
5292
5870
  //# sourceMappingURL=main.js.map