@ractive-ch/tab-mcp 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -462,6 +462,7 @@ var MAX_JWT_BYTES = 8192;
462
462
  var MAX_JWKS_BYTES = 65536;
463
463
  var JWKS_TTL_MS = 10 * 60000;
464
464
  var JWKS_REFETCH_MS = 30000;
465
+ var UNKNOWN_KID_REFETCH_MS = 5000;
465
466
  var JWKS_GRACE_MS = 60 * 60000;
466
467
  var FETCH_TIMEOUT_MS = 5000;
467
468
 
@@ -626,6 +627,7 @@ class HttpJsonError extends Error {
626
627
  class JwksCache {
627
628
  #url;
628
629
  #now;
630
+ #load;
629
631
  #keys = [];
630
632
  #fetchedAt = 0;
631
633
  #attemptedAt = Number.NEGATIVE_INFINITY;
@@ -634,14 +636,17 @@ class JwksCache {
634
636
  constructor(url, options = {}) {
635
637
  this.#url = typeof url === "string" ? () => Promise.resolve(url) : url;
636
638
  this.#now = options.now ?? Date.now;
639
+ this.#load = options.load ?? ((u) => fetchJson(u));
637
640
  }
638
641
  async keys(kid) {
642
+ const entered = this.#now();
639
643
  if (this.#inflight !== undefined)
640
644
  await this.#inflight.catch(() => {});
641
645
  const now = this.#now();
642
646
  const stale = now - this.#fetchedAt >= JWKS_TTL_MS;
643
- const unknown = kid !== undefined && !this.#keys.some((k) => k.kid === kid);
644
- if ((stale || unknown) && now - this.#attemptedAt >= JWKS_REFETCH_MS) {
647
+ const unknown = kid !== undefined && !this.#has(kid);
648
+ const floor = unknown && this.#lastError === undefined ? UNKNOWN_KID_REFETCH_MS : JWKS_REFETCH_MS;
649
+ if ((stale || unknown) && now - this.#attemptedAt >= floor) {
645
650
  try {
646
651
  await this.#refresh();
647
652
  } catch (error) {
@@ -651,13 +656,21 @@ class JwksCache {
651
656
  if (this.#now() - this.#fetchedAt >= JWKS_TTL_MS + JWKS_GRACE_MS) {
652
657
  throw this.#lastError ?? new Error("the JWKS could not be loaded");
653
658
  }
654
- return kid === undefined ? this.#keys : this.#keys.filter((k) => k.kid === kid);
659
+ if (kid === undefined)
660
+ return this.#keys;
661
+ if (!this.#has(kid) && this.#fetchedAt < entered) {
662
+ throw this.#lastError ?? new Error("an unknown key id; the JWKS was fetched too recently");
663
+ }
664
+ return this.#keys.filter((k) => k.kid === kid);
665
+ }
666
+ #has(kid) {
667
+ return this.#keys.some((k) => k.kid === kid);
655
668
  }
656
669
  #refresh() {
657
670
  this.#inflight ??= (async () => {
658
671
  this.#attemptedAt = this.#now();
659
672
  try {
660
- const doc = await fetchJson(await this.#url());
673
+ const doc = await this.#load(await this.#url());
661
674
  const keys = doc?.keys;
662
675
  if (!Array.isArray(keys))
663
676
  throw new Error("a JWKS needs keys");
@@ -1205,7 +1218,7 @@ function parseTabMessage(text) {
1205
1218
  }
1206
1219
  }
1207
1220
  // package.json
1208
- var version = "0.5.1";
1221
+ var version = "0.6.1";
1209
1222
 
1210
1223
  // src/exchange.ts
1211
1224
  var RelayErrorCode = {
@@ -1682,6 +1695,142 @@ class RateLimiter {
1682
1695
  }
1683
1696
  }
1684
1697
 
1698
+ // src/store/memory.ts
1699
+ function copy(record) {
1700
+ return { ...record, scopes: [...record.scopes] };
1701
+ }
1702
+ function matches(record, selector) {
1703
+ return "grantId" in selector ? record.grantId === selector.grantId : record.addressId === selector.addressId;
1704
+ }
1705
+ function isLive(record, now) {
1706
+ return record.expiresAt > now && (record.kind === "access" || record.usedAt === undefined);
1707
+ }
1708
+ function summarizeGrants(records, now) {
1709
+ const grants = new Map;
1710
+ for (const r of records) {
1711
+ if (!isLive(r, now) || grants.has(r.grantId))
1712
+ continue;
1713
+ const summary = {
1714
+ grantId: r.grantId,
1715
+ clientId: r.clientId,
1716
+ clientName: r.clientName,
1717
+ scopes: [...r.scopes],
1718
+ createdAt: r.grantCreatedAt
1719
+ };
1720
+ if (r.subject !== undefined)
1721
+ summary.subject = r.subject;
1722
+ grants.set(r.grantId, summary);
1723
+ }
1724
+ return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
1725
+ }
1726
+
1727
+ class NonceSet {
1728
+ #until = new Map;
1729
+ get size() {
1730
+ return this.#until.size;
1731
+ }
1732
+ claim(key, expiresAt, now) {
1733
+ const until = this.#until.get(key);
1734
+ if (until !== undefined && until > now)
1735
+ return false;
1736
+ this.#until.set(key, expiresAt);
1737
+ return true;
1738
+ }
1739
+ sweep(now) {
1740
+ let n = 0;
1741
+ for (const [key, until] of this.#until) {
1742
+ if (until <= now) {
1743
+ this.#until.delete(key);
1744
+ n++;
1745
+ }
1746
+ }
1747
+ return n;
1748
+ }
1749
+ clear() {
1750
+ this.#until.clear();
1751
+ }
1752
+ }
1753
+
1754
+ class MemoryTokenStore {
1755
+ #records = new Map;
1756
+ #nonces = new NonceSet;
1757
+ #revokedGrants = new Map;
1758
+ #revokedAddresses = new Map;
1759
+ async put(record) {
1760
+ if (this.#revokedGrants.has(record.grantId))
1761
+ return false;
1762
+ const address = this.#revokedAddresses.get(record.addressId);
1763
+ if (address !== undefined && record.grantCreatedAt <= address.at) {
1764
+ return false;
1765
+ }
1766
+ this.#records.set(record.hash, copy(record));
1767
+ return true;
1768
+ }
1769
+ async get(hash) {
1770
+ const r = this.#records.get(hash);
1771
+ return r === undefined ? undefined : copy(r);
1772
+ }
1773
+ async markUsed(hash, now, keepUntil) {
1774
+ const r = this.#records.get(hash);
1775
+ if (r === undefined || r.usedAt !== undefined)
1776
+ return false;
1777
+ r.usedAt = now;
1778
+ r.expiresAt = Math.min(r.expiresAt, keepUntil);
1779
+ return true;
1780
+ }
1781
+ async revoke(selector, options) {
1782
+ if ("grantId" in selector) {
1783
+ this.#revokedGrants.set(selector.grantId, options.keepUntil);
1784
+ } else {
1785
+ const old = this.#revokedAddresses.get(selector.addressId);
1786
+ this.#revokedAddresses.set(selector.addressId, {
1787
+ at: Math.max(old?.at ?? 0, options.now),
1788
+ until: Math.max(old?.until ?? 0, options.keepUntil)
1789
+ });
1790
+ }
1791
+ let n = 0;
1792
+ for (const [hash, r] of this.#records) {
1793
+ if (matches(r, selector)) {
1794
+ this.#records.delete(hash);
1795
+ n++;
1796
+ }
1797
+ }
1798
+ return n;
1799
+ }
1800
+ async listGrants(addressId, now) {
1801
+ const own = [...this.#records.values()].filter((r) => r.addressId === addressId);
1802
+ return summarizeGrants(own, now);
1803
+ }
1804
+ async deleteExpired(now) {
1805
+ for (const [id, until] of this.#revokedGrants) {
1806
+ if (until <= now)
1807
+ this.#revokedGrants.delete(id);
1808
+ }
1809
+ for (const [id, t] of this.#revokedAddresses) {
1810
+ if (t.until <= now)
1811
+ this.#revokedAddresses.delete(id);
1812
+ }
1813
+ this.#nonces.sweep(now);
1814
+ let n = 0;
1815
+ for (const [hash, r] of this.#records) {
1816
+ if (r.expiresAt <= now) {
1817
+ this.#records.delete(hash);
1818
+ n++;
1819
+ }
1820
+ }
1821
+ return n;
1822
+ }
1823
+ async claimNonce(key, expiresAt, now) {
1824
+ return this.#nonces.claim(key, expiresAt, now);
1825
+ }
1826
+ async close() {
1827
+ this.#nonces.clear();
1828
+ this.#records.clear();
1829
+ this.#revokedGrants.clear();
1830
+ this.#revokedAddresses.clear();
1831
+ }
1832
+ }
1833
+
1685
1834
  // src/tokens.ts
1686
1835
  var PREFIX = {
1687
1836
  access: "tmcp_at_",
@@ -1699,6 +1848,26 @@ function newId(prefix) {
1699
1848
  function hashToken(token) {
1700
1849
  return new Bun.CryptoHasher("sha256").update(token).digest().toBase64({ alphabet: "base64url", omitPadding: true });
1701
1850
  }
1851
+ async function issueAccessToken(store, grant, options) {
1852
+ const now = options.now ?? Date.now();
1853
+ const token = newToken("access");
1854
+ const record = {
1855
+ hash: hashToken(token),
1856
+ kind: "access",
1857
+ grantId: grant.grantId ?? newId("g"),
1858
+ addressId: grant.addressId,
1859
+ clientId: grant.clientId,
1860
+ clientName: grant.clientName,
1861
+ scopes: grant.scopes ?? ["tools"],
1862
+ grantCreatedAt: grant.grantCreatedAt ?? now,
1863
+ issuedAt: now,
1864
+ expiresAt: now + options.ttlMs
1865
+ };
1866
+ if (grant.subject !== undefined)
1867
+ record.subject = grant.subject;
1868
+ await store.put(record);
1869
+ return { token, record };
1870
+ }
1702
1871
  async function issueTokenPair(store, grant, options) {
1703
1872
  const now = options.now ?? Date.now();
1704
1873
  const accessToken = newToken("access");
@@ -1711,7 +1880,8 @@ async function issueTokenPair(store, grant, options) {
1711
1880
  scopes: grant.scopes,
1712
1881
  grantCreatedAt: grant.grantCreatedAt,
1713
1882
  issuedAt: now,
1714
- ...grant.subject === undefined ? {} : { subject: grant.subject }
1883
+ ...grant.subject === undefined ? {} : { subject: grant.subject },
1884
+ ...grant.clientAuth === undefined ? {} : { clientAuth: grant.clientAuth }
1715
1885
  };
1716
1886
  const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
1717
1887
  const refreshStored = await store.put({
@@ -1737,6 +1907,75 @@ async function issueTokenPair(store, grant, options) {
1737
1907
  };
1738
1908
  }
1739
1909
 
1910
+ // src/oauth/domain-cache.ts
1911
+ class DomainCache {
1912
+ #entries = new Map;
1913
+ #counts = new Map;
1914
+ #max;
1915
+ #perDomain;
1916
+ #isDead;
1917
+ constructor(max, perDomain, isDead = () => false) {
1918
+ this.#max = max;
1919
+ this.#perDomain = perDomain;
1920
+ this.#isDead = isDead;
1921
+ }
1922
+ get size() {
1923
+ return this.#entries.size;
1924
+ }
1925
+ get(key) {
1926
+ const entry = this.#entries.get(key);
1927
+ if (entry === undefined)
1928
+ return;
1929
+ this.#entries.delete(key);
1930
+ this.#entries.set(key, entry);
1931
+ return entry.value;
1932
+ }
1933
+ delete(key) {
1934
+ const entry = this.#entries.get(key);
1935
+ if (entry === undefined)
1936
+ return;
1937
+ this.#entries.delete(key);
1938
+ const left = (this.#counts.get(entry.domain) ?? 1) - 1;
1939
+ if (left > 0)
1940
+ this.#counts.set(entry.domain, left);
1941
+ else
1942
+ this.#counts.delete(entry.domain);
1943
+ }
1944
+ set(key, domain, value, now) {
1945
+ this.delete(key);
1946
+ if ((this.#counts.get(domain) ?? 0) >= this.#perDomain) {
1947
+ this.#evictOldestOf(domain);
1948
+ } else if (this.#entries.size >= this.#max) {
1949
+ this.#evict(now);
1950
+ }
1951
+ this.#entries.set(key, { domain, value });
1952
+ this.#counts.set(domain, (this.#counts.get(domain) ?? 0) + 1);
1953
+ }
1954
+ #evict(now) {
1955
+ for (const [key, entry] of this.#entries) {
1956
+ if (this.#isDead(entry.value, now)) {
1957
+ this.delete(key);
1958
+ return;
1959
+ }
1960
+ }
1961
+ const most = Math.max(0, ...this.#counts.values());
1962
+ for (const [key, entry] of this.#entries) {
1963
+ if (this.#counts.get(entry.domain) === most) {
1964
+ this.delete(key);
1965
+ return;
1966
+ }
1967
+ }
1968
+ }
1969
+ #evictOldestOf(domain) {
1970
+ for (const [key, entry] of this.#entries) {
1971
+ if (entry.domain === domain) {
1972
+ this.delete(key);
1973
+ return;
1974
+ }
1975
+ }
1976
+ }
1977
+ }
1978
+
1740
1979
  // src/oauth/cimd.ts
1741
1980
  class ClientMetadataError extends Error {
1742
1981
  name = "ClientMetadataError";
@@ -1746,6 +1985,16 @@ class ClientMetadataError extends Error {
1746
1985
  this.reason = reason;
1747
1986
  }
1748
1987
  }
1988
+ var TRANSIENT_REASONS = new Set([
1989
+ "rate_limited",
1990
+ "fetch_failed",
1991
+ "unresolvable",
1992
+ "too_large"
1993
+ ]);
1994
+ function isTransient(error) {
1995
+ return TRANSIENT_REASONS.has(error.reason);
1996
+ }
1997
+ var STALE_GRACE_MS = 60 * 60000;
1749
1998
  var MAX_CLIENT_ID_LENGTH = 2048;
1750
1999
  var MAX_NAME_LENGTH = 64;
1751
2000
  var MAX_REDIRECT_URIS = 20;
@@ -1753,6 +2002,9 @@ var MIN_CACHE_MS = 5 * 60000;
1753
2002
  var DEFAULT_CACHE_MS = 60 * 60000;
1754
2003
  var MAX_CACHE_MS = 24 * 60 * 60000;
1755
2004
  var MAX_CACHE_ENTRIES = 1000;
2005
+ var MAX_CACHE_PER_DOMAIN = 100;
2006
+ var FAILURE_CACHE_MS = 60000;
2007
+ var MAX_FAILURES = 1000;
1756
2008
  async function defaultLookup(hostname) {
1757
2009
  const records = await Bun.dns.lookup(hostname, {});
1758
2010
  return records.map((r) => r.address);
@@ -1786,12 +2038,22 @@ function limitKey(hostname) {
1786
2038
  return h;
1787
2039
  return h.split(".").slice(-2).join(".");
1788
2040
  }
2041
+ function knownBudget() {
2042
+ return {
2043
+ perHost: new RateLimiter({ capacity: 5, refillPerSecond: 0.2 }),
2044
+ perDomain: new RateLimiter({ capacity: 15, refillPerSecond: 0.6 }),
2045
+ global: new RateLimiter({ capacity: 100, refillPerSecond: 10 })
2046
+ };
2047
+ }
1789
2048
 
1790
2049
  class ClientMetadataFetcher {
1791
- #cache = new Map;
2050
+ #cache = new DomainCache(MAX_CACHE_ENTRIES, MAX_CACHE_PER_DOMAIN, (entry, now) => entry.until + STALE_GRACE_MS <= now);
2051
+ #failures = new Map;
1792
2052
  #perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
1793
2053
  #perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
1794
2054
  #global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
2055
+ #refreshes = knownBudget();
2056
+ #keys = knownBudget();
1795
2057
  #allowedHosts;
1796
2058
  #logger;
1797
2059
  #timeoutMs;
@@ -1810,34 +2072,106 @@ class ClientMetadataFetcher {
1810
2072
  this.#testMode = options.testMode;
1811
2073
  this.#now = options.now ?? Date.now;
1812
2074
  }
1813
- async get(clientId) {
2075
+ async get(clientId, options = {}) {
1814
2076
  const url = this.#checkUrl(clientId);
1815
2077
  const now = this.#now();
1816
2078
  const cached = this.#cache.get(clientId);
1817
- if (cached !== undefined && cached.until > now) {
1818
- this.#cache.delete(clientId);
1819
- this.#cache.set(clientId, cached);
2079
+ if (cached !== undefined && cached.until > now)
1820
2080
  return cached.meta;
2081
+ const known = cached !== undefined && now < cached.until + STALE_GRACE_MS;
2082
+ const stale = options.allowStale === true && known ? cached?.meta : undefined;
2083
+ try {
2084
+ const failed = this.#failures.get(clientId);
2085
+ if (failed !== undefined && failed.until > now)
2086
+ throw failed.error;
2087
+ return await this.#refresh(clientId, url, now, known);
2088
+ } catch (error) {
2089
+ const transient = error instanceof ClientMetadataError && isTransient(error);
2090
+ if (!transient)
2091
+ this.#cache.delete(clientId);
2092
+ if (stale === undefined || !transient)
2093
+ throw error;
2094
+ this.#logger?.info("oauth.cimd_stale", {
2095
+ host: url.host,
2096
+ reason: error.reason
2097
+ });
2098
+ return stale;
2099
+ }
2100
+ }
2101
+ async#refresh(clientId, url, now, known) {
2102
+ this.#admit(url, now, known ? "refresh" : "new");
2103
+ let meta;
2104
+ let ttlMs;
2105
+ try {
2106
+ const address = await this.#vettedAddress(url);
2107
+ const response = await this.#fetchJson(url, address, [
2108
+ "application/json"
2109
+ ]);
2110
+ meta = validateDocument(response.doc, clientId, url, {
2111
+ test: this.#testMode !== undefined,
2112
+ onFallback: (reason) => this.#logger?.info("oauth.cimd_auth_fallback", {
2113
+ host: url.host,
2114
+ reason
2115
+ })
2116
+ });
2117
+ ttlMs = cacheMs(response.cacheControl);
2118
+ } catch (error) {
2119
+ if (error instanceof ClientMetadataError) {
2120
+ this.#rememberFailure(clientId, error, now);
2121
+ }
2122
+ throw error;
1821
2123
  }
2124
+ this.#failures.delete(clientId);
2125
+ this.#cache.set(clientId, limitKey(url.hostname), { meta, until: now + ttlMs }, now);
2126
+ return meta;
2127
+ }
2128
+ #rememberFailure(clientId, error, now) {
2129
+ this.#failures.delete(clientId);
2130
+ if (this.#failures.size >= MAX_FAILURES) {
2131
+ const oldest = this.#failures.keys().next().value;
2132
+ if (oldest !== undefined)
2133
+ this.#failures.delete(oldest);
2134
+ }
2135
+ this.#failures.set(clientId, { error, until: now + FAILURE_CACHE_MS });
2136
+ }
2137
+ async fetchJwks(jwksUri) {
2138
+ const url = this.#checkJwksUri(jwksUri);
2139
+ this.#admit(url, this.#now(), "keys");
2140
+ const address = await this.#vettedAddress(url);
2141
+ const response = await this.#fetchJson(url, address, [
2142
+ "application/json",
2143
+ "application/jwk-set+json"
2144
+ ]);
2145
+ return response.doc;
2146
+ }
2147
+ #admit(url, now, budget) {
2148
+ const { perHost, perDomain, global } = budget === "refresh" ? this.#refreshes : budget === "keys" ? this.#keys : {
2149
+ perHost: this.#perHost,
2150
+ perDomain: this.#perDomain,
2151
+ global: this.#global
2152
+ };
1822
2153
  const key = limitKey(url.hostname);
1823
- if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
2154
+ if (!perHost.available(url.host, now) || !perDomain.available(key, now)) {
1824
2155
  throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
1825
2156
  }
1826
- if (!this.#global.take("all", now)) {
2157
+ if (!global.take("all", now)) {
1827
2158
  throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
1828
2159
  }
1829
- this.#perHost.take(url.host, now);
1830
- this.#perDomain.take(key, now);
1831
- const address = await this.#vettedAddress(url);
1832
- const { meta, ttlMs } = await this.#fetchDocument(url, clientId, address);
1833
- this.#cache.delete(clientId);
1834
- if (this.#cache.size >= MAX_CACHE_ENTRIES) {
1835
- const oldest = this.#cache.keys().next().value;
1836
- if (oldest !== undefined)
1837
- this.#cache.delete(oldest);
2160
+ perHost.take(url.host, now);
2161
+ perDomain.take(key, now);
2162
+ }
2163
+ #checkJwksUri(jwksUri) {
2164
+ const url = parseJwksUri(jwksUri, this.#testMode !== undefined);
2165
+ if (url === undefined) {
2166
+ throw new ClientMetadataError("invalid_jwks_uri", "The AI app's jwks_uri is not accepted.");
1838
2167
  }
1839
- this.#cache.set(clientId, { meta, until: now + ttlMs });
1840
- return meta;
2168
+ if (this.#allowedHosts.length > 0 && !this.#allowedHosts.includes(url.host)) {
2169
+ throw new ClientMetadataError("host_not_allowed", `This relay does not accept AI apps from ${url.host}.`);
2170
+ }
2171
+ if (isIpLiteral(url.hostname) && !this.#addressAllowed(url.hostname)) {
2172
+ throw this.#notLoaded("private_address", url);
2173
+ }
2174
+ return url;
1841
2175
  }
1842
2176
  #checkUrl(clientId) {
1843
2177
  const refuse = (message) => new ClientMetadataError("invalid_client_id", message);
@@ -1899,9 +2233,9 @@ class ClientMetadataFetcher {
1899
2233
  }
1900
2234
  return addresses.find((a) => parseIpv4(a) !== undefined) ?? addresses[0] ?? "";
1901
2235
  }
1902
- async#fetchDocument(url, clientId, address) {
2236
+ async#fetchJson(url, address, types) {
1903
2237
  const fail = (detail) => this.#notLoaded("fetch_failed", url, detail);
1904
- const pinned = new URL(clientId);
2238
+ const pinned = new URL(url.href);
1905
2239
  pinned.hostname = address.includes(":") ? `[${address}]` : address;
1906
2240
  const hostname = url.hostname.replace(/^\[|\]$/g, "");
1907
2241
  const tls = url.protocol === "https:" ? {
@@ -1926,8 +2260,9 @@ class ClientMetadataFetcher {
1926
2260
  if (response.status !== 200)
1927
2261
  throw fail(`status_${response.status}`);
1928
2262
  const type = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
1929
- if (type !== "application/json")
2263
+ if (type === undefined || !types.includes(type)) {
1930
2264
  throw fail("not_json");
2265
+ }
1931
2266
  const text = await this.#readLimited(response, url);
1932
2267
  let doc;
1933
2268
  try {
@@ -1935,8 +2270,7 @@ class ClientMetadataFetcher {
1935
2270
  } catch {
1936
2271
  throw fail("bad_json");
1937
2272
  }
1938
- const meta = validateDocument(doc, clientId, url);
1939
- return { meta, ttlMs: cacheMs(response.headers.get("cache-control")) };
2273
+ return { doc, cacheControl: response.headers.get("cache-control") };
1940
2274
  } finally {
1941
2275
  if (!response.bodyUsed)
1942
2276
  response.body?.cancel().catch(() => {});
@@ -1991,7 +2325,7 @@ function foreignDomain(name, hostname) {
1991
2325
  }
1992
2326
  return false;
1993
2327
  }
1994
- function validateDocument(doc, clientId, url) {
2328
+ function validateDocument(doc, clientId, url, options = {}) {
1995
2329
  const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
1996
2330
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
1997
2331
  throw invalid("The AI app's details are not a JSON object.");
@@ -2017,11 +2351,7 @@ function validateDocument(doc, clientId, url) {
2017
2351
  if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
2018
2352
  throw invalid("The AI app's details have no valid redirect_uris.");
2019
2353
  }
2020
- const method = d["token_endpoint_auth_method"];
2021
- const supported = d["token_endpoint_auth_methods_supported"];
2022
- if (method !== undefined && method !== "none" && !(Array.isArray(supported) && supported.includes("none"))) {
2023
- throw invalid('The AI app must use token_endpoint_auth_method "none" with this relay, or list "none" in token_endpoint_auth_methods_supported.');
2024
- }
2354
+ const auth = clientAuth(d, url, options, invalid);
2025
2355
  const grants = d["grant_types"];
2026
2356
  if (grants !== undefined && (!Array.isArray(grants) || !grants.includes("authorization_code"))) {
2027
2357
  throw invalid("The AI app's details do not allow the authorization code grant.");
@@ -2034,9 +2364,81 @@ function validateDocument(doc, clientId, url) {
2034
2364
  clientId,
2035
2365
  name: displayName(name),
2036
2366
  host: url.host,
2037
- redirectUris: uris
2367
+ redirectUris: uris,
2368
+ auth
2369
+ };
2370
+ }
2371
+ function clientAuth(d, url, options, invalid) {
2372
+ const method = d["token_endpoint_auth_method"];
2373
+ const supported = d["token_endpoint_auth_methods_supported"];
2374
+ const listsNone = Array.isArray(supported) && supported.includes("none");
2375
+ if (method === undefined || method === "none")
2376
+ return { method: "none" };
2377
+ const checked = method === "private_key_jwt" ? privateKeyJwt(d, url, options.test === true) : {
2378
+ problem: "unsupported_method",
2379
+ 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.'
2380
+ };
2381
+ if ("jwksUri" in checked) {
2382
+ return { method: "private_key_jwt", ...checked, publicAllowed: listsNone };
2383
+ }
2384
+ if (listsNone) {
2385
+ options.onFallback?.(checked.problem);
2386
+ return { method: "none" };
2387
+ }
2388
+ throw invalid(checked.message);
2389
+ }
2390
+ function privateKeyJwt(d, url, test) {
2391
+ const jwksUri = d["jwks_uri"];
2392
+ if (jwksUri === undefined) {
2393
+ return {
2394
+ problem: "no_jwks_uri",
2395
+ message: "The AI app uses private_key_jwt but publishes no jwks_uri (an inline jwks is not accepted)."
2396
+ };
2397
+ }
2398
+ if (d["jwks"] !== undefined) {
2399
+ return {
2400
+ problem: "jwks_and_jwks_uri",
2401
+ message: "The AI app's details have both jwks and jwks_uri."
2402
+ };
2403
+ }
2404
+ const parsed = typeof jwksUri === "string" ? parseJwksUri(jwksUri, test) : undefined;
2405
+ if (parsed === undefined || parsed.protocol !== url.protocol || parsed.host !== url.host) {
2406
+ return {
2407
+ problem: "bad_jwks_uri",
2408
+ message: "The AI app's jwks_uri must be an https:// URL on the host of its client_id."
2409
+ };
2410
+ }
2411
+ const alg = d["token_endpoint_auth_signing_alg"];
2412
+ if (alg !== undefined && !isAlgorithm(alg)) {
2413
+ return {
2414
+ problem: "signing_alg",
2415
+ message: "The AI app's token_endpoint_auth_signing_alg is not one this relay accepts."
2416
+ };
2417
+ }
2418
+ return {
2419
+ jwksUri: parsed.href,
2420
+ algorithms: alg === undefined ? JWT_ALGORITHMS : [alg]
2038
2421
  };
2039
2422
  }
2423
+ function parseJwksUri(value, test) {
2424
+ if (value.length > MAX_CLIENT_ID_LENGTH)
2425
+ return;
2426
+ let url;
2427
+ try {
2428
+ url = new URL(value);
2429
+ } catch {
2430
+ return;
2431
+ }
2432
+ const scheme = url.protocol === "https:" || test && url.protocol === "http:";
2433
+ if (!scheme || url.href !== value)
2434
+ return;
2435
+ if (url.username !== "" || url.password !== "" || url.hash !== "") {
2436
+ return;
2437
+ }
2438
+ if (url.port !== "" && !test)
2439
+ return;
2440
+ return url;
2441
+ }
2040
2442
  var PRIVATE_USE_SCHEME = /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+:$/;
2041
2443
  function isRedirectUri(value) {
2042
2444
  if (value.length > 2048 || value.includes("#"))
@@ -2080,6 +2482,151 @@ function redirectUriMatches(requested, registered) {
2080
2482
  return a.href === b.href;
2081
2483
  }
2082
2484
 
2485
+ // src/oauth/client-auth.ts
2486
+ var CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
2487
+ var CLIENT_ASSERTION_MAX_LIFETIME_MS = 60 * 60000;
2488
+ var CLIENT_ASSERTION_MAX_AGE_MS = 5 * 60000;
2489
+ var CLIENT_ASSERTION_LEEWAY_MS = 30000;
2490
+ var MAX_JTI2 = 256;
2491
+ var MAX_KEY_SETS = 1000;
2492
+ var MAX_KEY_SETS_PER_DOMAIN = 100;
2493
+ var SWEEP_INTERVAL_MS = 1000;
2494
+ var ACCEPTED_TYPES = new Set(["jwt", "client-authentication+jwt"]);
2495
+ function assertionSubject(assertion) {
2496
+ try {
2497
+ const sub = decodeJwt(assertion).payload["sub"];
2498
+ return typeof sub === "string" && sub !== "" ? sub : undefined;
2499
+ } catch {
2500
+ return;
2501
+ }
2502
+ }
2503
+ function nonceKey(clientId, jti) {
2504
+ return hashToken(JSON.stringify(["client_assertion", clientId, jti]));
2505
+ }
2506
+ async function verifyClientAssertion(assertion, check) {
2507
+ const { header, payload } = await verifyJwt(assertion, check.keys, {
2508
+ algorithms: check.auth.algorithms
2509
+ });
2510
+ const typ = header["typ"];
2511
+ if (typ !== undefined && (typeof typ !== "string" || !ACCEPTED_TYPES.has(typ.toLowerCase()))) {
2512
+ throw new JwtError("typ", "the assertion's type is not a client assertion");
2513
+ }
2514
+ if (payload["iss"] !== check.clientId) {
2515
+ throw new JwtError("iss", "the assertion's issuer is not the client");
2516
+ }
2517
+ if (payload["sub"] !== check.clientId) {
2518
+ throw new JwtError("sub", "the assertion's subject is not the client");
2519
+ }
2520
+ if (!audiences(payload).some((a) => check.audiences.includes(a))) {
2521
+ throw new JwtError("aud", "the assertion is not for this relay");
2522
+ }
2523
+ const usableUntil = checkTimes(payload, check.now);
2524
+ const jti = payload["jti"];
2525
+ if (typeof jti !== "string" || jti === "" || jti.length > MAX_JTI2) {
2526
+ throw new JwtError("jti", "the assertion needs a jti");
2527
+ }
2528
+ const key = nonceKey(check.clientId, jti);
2529
+ if (!await check.claimNonce(key, usableUntil, check.now)) {
2530
+ throw new JwtError("replayed", "the assertion was already used");
2531
+ }
2532
+ }
2533
+ function checkTimes(payload, now) {
2534
+ const leeway = CLIENT_ASSERTION_LEEWAY_MS;
2535
+ const exp = numericDate(payload, "exp");
2536
+ if (exp === undefined)
2537
+ throw new JwtError("exp", "the assertion has no exp");
2538
+ if (exp <= now - leeway) {
2539
+ throw new JwtError("expired", "the assertion has expired");
2540
+ }
2541
+ if (exp > now + CLIENT_ASSERTION_MAX_LIFETIME_MS + leeway) {
2542
+ throw new JwtError("lifetime", "the assertion lives longer than an hour");
2543
+ }
2544
+ const iat = numericDate(payload, "iat");
2545
+ let end = exp;
2546
+ if (iat === undefined) {
2547
+ if (exp > now + CLIENT_ASSERTION_MAX_AGE_MS + leeway) {
2548
+ throw new JwtError("lifetime", "an assertion without iat may live five minutes");
2549
+ }
2550
+ } else {
2551
+ if (iat > now + leeway) {
2552
+ throw new JwtError("iat", "the assertion was issued in the future");
2553
+ }
2554
+ if (iat + CLIENT_ASSERTION_MAX_AGE_MS <= now - leeway) {
2555
+ throw new JwtError("too_old", "the assertion was issued too long ago");
2556
+ }
2557
+ end = Math.min(exp, iat + CLIENT_ASSERTION_MAX_AGE_MS);
2558
+ }
2559
+ const nbf = numericDate(payload, "nbf");
2560
+ if (nbf !== undefined && nbf > now + leeway) {
2561
+ throw new JwtError("nbf", "the assertion is not valid yet");
2562
+ }
2563
+ return end + leeway;
2564
+ }
2565
+ function quotaKey(clientId) {
2566
+ return ipPrefixKey(new URL(clientId).hostname.replace(/\.$/, ""));
2567
+ }
2568
+
2569
+ class NonceQuota {
2570
+ #until = new Map;
2571
+ #perDomain;
2572
+ #max;
2573
+ #total = 0;
2574
+ #sweptAt = Number.NEGATIVE_INFINITY;
2575
+ constructor(perDomain, max) {
2576
+ this.#perDomain = perDomain;
2577
+ this.#max = max;
2578
+ }
2579
+ refusal(domain, now) {
2580
+ this.sweep(now);
2581
+ if ((this.#until.get(domain)?.length ?? 0) >= this.#perDomain) {
2582
+ return "domain_full";
2583
+ }
2584
+ return this.#total >= this.#max ? "full" : undefined;
2585
+ }
2586
+ add(domain, until) {
2587
+ const list = this.#until.get(domain);
2588
+ if (list === undefined)
2589
+ this.#until.set(domain, [until]);
2590
+ else
2591
+ list.push(until);
2592
+ this.#total++;
2593
+ }
2594
+ sweep(now) {
2595
+ if (now - this.#sweptAt < SWEEP_INTERVAL_MS)
2596
+ return false;
2597
+ this.#sweptAt = now;
2598
+ this.#total = 0;
2599
+ for (const [domain, list] of this.#until) {
2600
+ const live = list.filter((until) => until > now);
2601
+ if (live.length === 0)
2602
+ this.#until.delete(domain);
2603
+ else
2604
+ this.#until.set(domain, live);
2605
+ this.#total += live.length;
2606
+ }
2607
+ return true;
2608
+ }
2609
+ }
2610
+
2611
+ class ClientKeySets {
2612
+ #sets = new DomainCache(MAX_KEY_SETS, MAX_KEY_SETS_PER_DOMAIN);
2613
+ #load;
2614
+ #now;
2615
+ constructor(load, now = Date.now) {
2616
+ this.#load = load;
2617
+ this.#now = now;
2618
+ }
2619
+ for(jwksUri) {
2620
+ const known = this.#sets.get(jwksUri);
2621
+ if (known !== undefined)
2622
+ return known;
2623
+ const set = new JwksCache(jwksUri, { load: this.#load, now: this.#now });
2624
+ const domain = limitKey(new URL(jwksUri).hostname);
2625
+ this.#sets.set(jwksUri, domain, set, this.#now());
2626
+ return set;
2627
+ }
2628
+ }
2629
+
2083
2630
  // src/oauth/html.ts
2084
2631
  var ESCAPES = {
2085
2632
  "&": "&amp;",
@@ -2649,6 +3196,12 @@ var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
2649
3196
  var MAX_CODES = 1e4;
2650
3197
  var MAX_FORM_BYTES = 8192;
2651
3198
  var MAX_STATE_LENGTH = 1024;
3199
+ var MAX_NONCES_PER_HOST = 1e4;
3200
+ var MAX_NONCES = 1e5;
3201
+ var CLIENT_AUTH_FAILED = "client authentication failed";
3202
+ function assertionRequired(auth) {
3203
+ return auth.method === "private_key_jwt" && !auth.publicAllowed;
3204
+ }
2652
3205
 
2653
3206
  class OAuthError extends Error {
2654
3207
  status;
@@ -2698,10 +3251,16 @@ function hostOf(url) {
2698
3251
  return url;
2699
3252
  }
2700
3253
  }
3254
+ function logHost(clientId) {
3255
+ return clientId !== undefined && URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)";
3256
+ }
2701
3257
 
2702
3258
  class AuthorizationServer {
2703
3259
  #host;
2704
3260
  #cimd;
3261
+ #clientKeys;
3262
+ #nonces = new NonceSet;
3263
+ #nonceQuota = new NonceQuota(MAX_NONCES_PER_HOST, MAX_NONCES);
2705
3264
  #pending = new Map;
2706
3265
  #pendingPerIp = new Map;
2707
3266
  #pendingPerAddress = new Map;
@@ -2752,6 +3311,8 @@ class AuthorizationServer {
2752
3311
  logger: host.logger,
2753
3312
  ...testMode?.cimd === undefined ? {} : { testMode: testMode.cimd }
2754
3313
  });
3314
+ const cimd = this.#cimd;
3315
+ this.#clientKeys = new ClientKeySets((url) => cimd.fetchJwks(url));
2755
3316
  }
2756
3317
  get #issuer() {
2757
3318
  return this.#host.publicUrl();
@@ -2805,8 +3366,10 @@ class AuthorizationServer {
2805
3366
  response_modes_supported: ["query"],
2806
3367
  grant_types_supported: ["authorization_code", "refresh_token"],
2807
3368
  code_challenge_methods_supported: ["S256"],
2808
- token_endpoint_auth_methods_supported: ["none"],
2809
- revocation_endpoint_auth_methods_supported: ["none"],
3369
+ token_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
3370
+ token_endpoint_auth_signing_alg_values_supported: JWT_ALGORITHMS,
3371
+ revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
3372
+ revocation_endpoint_auth_signing_alg_values_supported: JWT_ALGORITHMS,
2810
3373
  scopes_supported: [SCOPE],
2811
3374
  client_id_metadata_document_supported: true,
2812
3375
  authorization_response_iss_parameter_supported: true,
@@ -3305,20 +3868,21 @@ ${cancel}
3305
3868
  "code_verifier",
3306
3869
  "refresh_token",
3307
3870
  "resource",
3308
- "scope"
3871
+ "scope",
3872
+ "client_assertion",
3873
+ "client_assertion_type"
3309
3874
  ]);
3310
3875
  if ("duplicate" in p) {
3311
3876
  throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
3312
3877
  }
3313
- const clientId = p["client_id"];
3314
- if (clientId === undefined) {
3315
- throw new OAuthError(400, "invalid_request", "client_id is required");
3316
- }
3878
+ const client = await this.#authenticateClient(p, [
3879
+ `${this.#issuer}/token`
3880
+ ]);
3317
3881
  let body;
3318
3882
  if (p["grant_type"] === "authorization_code") {
3319
- body = await this.#codeGrant(p, clientId);
3883
+ body = await this.#codeGrant(p, client);
3320
3884
  } else if (p["grant_type"] === "refresh_token") {
3321
- body = await this.#refreshGrant(p, clientId);
3885
+ body = await this.#refreshGrant(p, client);
3322
3886
  } else if (p["grant_type"] === undefined) {
3323
3887
  throw new OAuthError(400, "invalid_request", "grant_type is required");
3324
3888
  } else {
@@ -3352,19 +3916,96 @@ ${cancel}
3352
3916
  throw new OAuthError(400, "invalid_request", "the body must be application/x-www-form-urlencoded");
3353
3917
  }
3354
3918
  if (req.headers.get("authorization") !== null) {
3355
- throw new OAuthError(401, "invalid_client", "client authentication is not supported (token_endpoint_auth_method none)");
3919
+ throw new OAuthError(401, "invalid_client", "HTTP authentication is not supported (token_endpoint_auth_method none or private_key_jwt)");
3356
3920
  }
3357
3921
  const text = await this.#readForm(req);
3358
3922
  if (text === undefined) {
3359
3923
  throw new OAuthError(413, "invalid_request", "the request is too large");
3360
3924
  }
3361
3925
  const params = new URLSearchParams(text);
3362
- if (params.has("client_secret") || params.has("client_assertion")) {
3363
- throw new OAuthError(400, "invalid_client", "client authentication is not supported (token_endpoint_auth_method none)");
3926
+ if (params.has("client_secret")) {
3927
+ throw new OAuthError(400, "invalid_client", "client secrets are not supported (token_endpoint_auth_method none or private_key_jwt)");
3364
3928
  }
3365
3929
  return params;
3366
3930
  }
3367
- async#codeGrant(p, clientId) {
3931
+ async#authenticateClient(p, endpoints) {
3932
+ const assertion = p["client_assertion"];
3933
+ const type = p["client_assertion_type"];
3934
+ if (assertion === undefined && type === undefined) {
3935
+ const clientId = p["client_id"];
3936
+ if (clientId === undefined) {
3937
+ throw new OAuthError(400, "invalid_request", "client_id is required");
3938
+ }
3939
+ return { clientId, assertion: false };
3940
+ }
3941
+ if (assertion === undefined || type === undefined) {
3942
+ throw new OAuthError(400, "invalid_request", "client_assertion and client_assertion_type go together");
3943
+ }
3944
+ const clientId = p["client_id"] ?? assertionSubject(assertion);
3945
+ if (type !== CLIENT_ASSERTION_TYPE) {
3946
+ throw this.#clientAuthFailed(clientId, "assertion_type");
3947
+ }
3948
+ if (clientId === undefined) {
3949
+ throw this.#clientAuthFailed(clientId, "no_client_id");
3950
+ }
3951
+ let client;
3952
+ try {
3953
+ client = await this.#cimd.get(clientId, { allowStale: true });
3954
+ } catch (error) {
3955
+ if (!(error instanceof ClientMetadataError))
3956
+ throw error;
3957
+ const reason = `metadata_${error.reason}`;
3958
+ throw isTransient(error) || error.reason === "private_address" ? this.#clientAuthUnavailable(clientId, reason) : this.#clientAuthFailed(clientId, reason);
3959
+ }
3960
+ if (client.auth.method !== "private_key_jwt") {
3961
+ throw this.#clientAuthFailed(clientId, "not_private_key_jwt");
3962
+ }
3963
+ const domain = quotaKey(clientId);
3964
+ try {
3965
+ await verifyClientAssertion(assertion, {
3966
+ clientId,
3967
+ auth: client.auth,
3968
+ keys: this.#clientKeys.for(client.auth.jwksUri),
3969
+ audiences: [this.#issuer, ...endpoints],
3970
+ claimNonce: (key, until, now) => this.#claimNonce(clientId, domain, key, until, now),
3971
+ now: Date.now()
3972
+ });
3973
+ } catch (error) {
3974
+ if (!(error instanceof JwtError))
3975
+ throw error;
3976
+ throw error.reason === "jwks_unavailable" ? this.#clientAuthUnavailable(clientId, error.reason) : this.#clientAuthFailed(clientId, error.reason);
3977
+ }
3978
+ return { clientId, assertion: true };
3979
+ }
3980
+ #clientAuthFailed(clientId, reason) {
3981
+ this.#host.logger.info("oauth.client_auth_failed", {
3982
+ client: logHost(clientId),
3983
+ reason
3984
+ });
3985
+ return new OAuthError(401, "invalid_client", CLIENT_AUTH_FAILED);
3986
+ }
3987
+ #clientAuthUnavailable(clientId, reason) {
3988
+ this.#host.logger.warn("oauth.client_auth_unavailable", {
3989
+ client: logHost(clientId),
3990
+ reason
3991
+ });
3992
+ return new OAuthError(503, "temporarily_unavailable", "the client cannot be authenticated right now; try again shortly");
3993
+ }
3994
+ async#claimNonce(clientId, domain, key, until, now) {
3995
+ if (this.#nonceQuota.sweep(now))
3996
+ this.#nonces.sweep(now);
3997
+ const refusal = this.#nonceQuota.refusal(domain, now);
3998
+ if (refusal !== undefined) {
3999
+ throw this.#clientAuthUnavailable(clientId, `nonces_${refusal}`);
4000
+ }
4001
+ const store = this.#host.store;
4002
+ const claimed = store.claimNonce === undefined ? this.#nonces.claim(key, until, now) : await store.claimNonce(key, until, now);
4003
+ if (claimed)
4004
+ this.#nonceQuota.add(domain, until);
4005
+ return claimed;
4006
+ }
4007
+ async#codeGrant(p, client) {
4008
+ const clientId = client.clientId;
3368
4009
  const code = p["code"];
3369
4010
  const redirectUri = p["redirect_uri"];
3370
4011
  const verifier = p["code_verifier"];
@@ -3376,6 +4017,12 @@ ${cancel}
3376
4017
  const invalid = (why) => new OAuthError(400, "invalid_grant", why);
3377
4018
  if (issued === undefined)
3378
4019
  throw invalid("the code is not valid");
4020
+ if (!constantTimeEqual(clientId, issued.client.clientId)) {
4021
+ throw invalid("the code was issued to another client");
4022
+ }
4023
+ if (assertionRequired(issued.client.auth) && !client.assertion) {
4024
+ throw this.#clientAuthFailed(clientId, "assertion_required");
4025
+ }
3379
4026
  if (issued.used) {
3380
4027
  if (issued.grantId !== undefined) {
3381
4028
  await this.#revokeGrant(issued.addressId, issued.grantId, "reuse_detected");
@@ -3385,9 +4032,6 @@ ${cancel}
3385
4032
  issued.used = true;
3386
4033
  if (issued.expiresAt <= Date.now())
3387
4034
  throw invalid("the code has expired");
3388
- if (!constantTimeEqual(clientId, issued.client.clientId)) {
3389
- throw invalid("the code was issued to another client");
3390
- }
3391
4035
  if (redirectUri !== issued.redirectUri) {
3392
4036
  throw invalid("redirect_uri does not match the authorization request");
3393
4037
  }
@@ -3411,7 +4055,8 @@ ${cancel}
3411
4055
  clientName: issued.client.name,
3412
4056
  scopes: issued.scopes,
3413
4057
  grantCreatedAt: now,
3414
- ...issued.subject === undefined ? {} : { subject: issued.subject }
4058
+ ...issued.subject === undefined ? {} : { subject: issued.subject },
4059
+ ...client.assertion ? { clientAuth: "private_key_jwt" } : {}
3415
4060
  }, {
3416
4061
  accessTtlMs: config.accessTokenTtlMs,
3417
4062
  refreshTtlMs: config.refreshTokenTtlMs,
@@ -3445,7 +4090,8 @@ ${cancel}
3445
4090
  scope: scopes.join(" ")
3446
4091
  };
3447
4092
  }
3448
- async#refreshGrant(p, clientId) {
4093
+ async#refreshGrant(p, client) {
4094
+ const clientId = client.clientId;
3449
4095
  const token = p["refresh_token"];
3450
4096
  if (token === undefined) {
3451
4097
  throw new OAuthError(400, "invalid_request", "refresh_token is required");
@@ -3459,6 +4105,9 @@ ${cancel}
3459
4105
  if (!constantTimeEqual(clientId, record.clientId)) {
3460
4106
  throw invalid("the refresh token was issued to another client");
3461
4107
  }
4108
+ if (record.clientAuth === "private_key_jwt" && !client.assertion) {
4109
+ throw this.#clientAuthFailed(clientId, "assertion_required");
4110
+ }
3462
4111
  const now = Date.now();
3463
4112
  if (record.expiresAt <= now)
3464
4113
  throw invalid("the refresh token has expired");
@@ -3490,7 +4139,8 @@ ${cancel}
3490
4139
  clientName: record.clientName,
3491
4140
  scopes: record.scopes,
3492
4141
  grantCreatedAt: record.grantCreatedAt,
3493
- ...record.subject === undefined ? {} : { subject: record.subject }
4142
+ ...record.subject === undefined ? {} : { subject: record.subject },
4143
+ ...record.clientAuth === "private_key_jwt" || client.assertion ? { clientAuth: "private_key_jwt" } : {}
3494
4144
  }, {
3495
4145
  accessTtlMs: this.#host.config.accessTokenTtlMs,
3496
4146
  refreshTtlMs: this.#host.config.refreshTokenTtlMs,
@@ -3526,24 +4176,36 @@ ${cancel}
3526
4176
  throw new OAuthError(429, "temporarily_unavailable", "too many requests; try again shortly");
3527
4177
  }
3528
4178
  const params = await this.#formParams(req);
3529
- const p = single(params, ["token", "client_id", "token_type_hint"]);
4179
+ const p = single(params, [
4180
+ "token",
4181
+ "client_id",
4182
+ "token_type_hint",
4183
+ "client_assertion",
4184
+ "client_assertion_type"
4185
+ ]);
3530
4186
  if ("duplicate" in p) {
3531
4187
  throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
3532
4188
  }
3533
4189
  const token = p["token"];
3534
- const clientId = p["client_id"];
3535
- if (token === undefined || clientId === undefined) {
3536
- throw new OAuthError(400, "invalid_request", "token and client_id are required");
4190
+ if (token === undefined) {
4191
+ throw new OAuthError(400, "invalid_request", "token is required");
3537
4192
  }
4193
+ const client = await this.#authenticateClient(p, [
4194
+ `${this.#issuer}/revoke`,
4195
+ `${this.#issuer}/token`
4196
+ ]);
3538
4197
  const record = await this.#host.store.get(hashToken(token));
3539
4198
  if (record === undefined)
3540
4199
  return new Response(null, {
3541
4200
  status: 200,
3542
4201
  headers: { ...this.#cors(), ...noStore }
3543
4202
  });
3544
- if (!constantTimeEqual(clientId, record.clientId)) {
4203
+ if (!constantTimeEqual(client.clientId, record.clientId)) {
3545
4204
  throw new OAuthError(400, "invalid_client", "the token was issued to another client");
3546
4205
  }
4206
+ if (record.clientAuth === "private_key_jwt" && !client.assertion) {
4207
+ throw this.#clientAuthFailed(client.clientId, "assertion_required");
4208
+ }
3547
4209
  await this.#revokeGrant(record.addressId, record.grantId, "revoked");
3548
4210
  this.#host.logger.info("oauth.revoked", {
3549
4211
  address: shortAddress(record.addressId),
@@ -3589,6 +4251,7 @@ ${cancel}
3589
4251
  this.#guessesPerAddress.sweep(now);
3590
4252
  this.#tokenPerIp.sweep(now);
3591
4253
  this.#refreshesPerGrant.sweep(now);
4254
+ this.#nonces.sweep(now);
3592
4255
  }
3593
4256
  }
3594
4257
 
@@ -3997,112 +4660,10 @@ class TabSession {
3997
4660
  }
3998
4661
  }
3999
4662
 
4000
- // src/store/memory.ts
4001
- function copy(record) {
4002
- return { ...record, scopes: [...record.scopes] };
4003
- }
4004
- function matches(record, selector) {
4005
- return "grantId" in selector ? record.grantId === selector.grantId : record.addressId === selector.addressId;
4006
- }
4007
- function isLive(record, now) {
4008
- return record.expiresAt > now && (record.kind === "access" || record.usedAt === undefined);
4009
- }
4010
- function summarizeGrants(records, now) {
4011
- const grants = new Map;
4012
- for (const r of records) {
4013
- if (!isLive(r, now) || grants.has(r.grantId))
4014
- continue;
4015
- const summary = {
4016
- grantId: r.grantId,
4017
- clientId: r.clientId,
4018
- clientName: r.clientName,
4019
- scopes: [...r.scopes],
4020
- createdAt: r.grantCreatedAt
4021
- };
4022
- if (r.subject !== undefined)
4023
- summary.subject = r.subject;
4024
- grants.set(r.grantId, summary);
4025
- }
4026
- return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
4027
- }
4028
-
4029
- class MemoryTokenStore {
4030
- #records = new Map;
4031
- #revokedGrants = new Map;
4032
- #revokedAddresses = new Map;
4033
- async put(record) {
4034
- if (this.#revokedGrants.has(record.grantId))
4035
- return false;
4036
- const address = this.#revokedAddresses.get(record.addressId);
4037
- if (address !== undefined && record.grantCreatedAt <= address.at) {
4038
- return false;
4039
- }
4040
- this.#records.set(record.hash, copy(record));
4041
- return true;
4042
- }
4043
- async get(hash) {
4044
- const r = this.#records.get(hash);
4045
- return r === undefined ? undefined : copy(r);
4046
- }
4047
- async markUsed(hash, now, keepUntil) {
4048
- const r = this.#records.get(hash);
4049
- if (r === undefined || r.usedAt !== undefined)
4050
- return false;
4051
- r.usedAt = now;
4052
- r.expiresAt = Math.min(r.expiresAt, keepUntil);
4053
- return true;
4054
- }
4055
- async revoke(selector, options) {
4056
- if ("grantId" in selector) {
4057
- this.#revokedGrants.set(selector.grantId, options.keepUntil);
4058
- } else {
4059
- const old = this.#revokedAddresses.get(selector.addressId);
4060
- this.#revokedAddresses.set(selector.addressId, {
4061
- at: Math.max(old?.at ?? 0, options.now),
4062
- until: Math.max(old?.until ?? 0, options.keepUntil)
4063
- });
4064
- }
4065
- let n = 0;
4066
- for (const [hash, r] of this.#records) {
4067
- if (matches(r, selector)) {
4068
- this.#records.delete(hash);
4069
- n++;
4070
- }
4071
- }
4072
- return n;
4073
- }
4074
- async listGrants(addressId, now) {
4075
- const own = [...this.#records.values()].filter((r) => r.addressId === addressId);
4076
- return summarizeGrants(own, now);
4077
- }
4078
- async deleteExpired(now) {
4079
- for (const [id, until] of this.#revokedGrants) {
4080
- if (until <= now)
4081
- this.#revokedGrants.delete(id);
4082
- }
4083
- for (const [id, t] of this.#revokedAddresses) {
4084
- if (t.until <= now)
4085
- this.#revokedAddresses.delete(id);
4086
- }
4087
- let n = 0;
4088
- for (const [hash, r] of this.#records) {
4089
- if (r.expiresAt <= now) {
4090
- this.#records.delete(hash);
4091
- n++;
4092
- }
4093
- }
4094
- return n;
4095
- }
4096
- async close() {
4097
- this.#records.clear();
4098
- this.#revokedGrants.clear();
4099
- this.#revokedAddresses.clear();
4100
- }
4101
- }
4102
-
4103
4663
  // src/store/sqlite.ts
4104
4664
  import { Database } from "bun:sqlite";
4105
- var SCHEMA_VERSION = 4;
4665
+ var SCHEMA_VERSION = 5;
4666
+ var MIN_READER_VERSION = 5;
4106
4667
  var REUSE_WINDOW_MS = 86400000;
4107
4668
  function toRecord(row) {
4108
4669
  const record = {
@@ -4121,6 +4682,8 @@ function toRecord(row) {
4121
4682
  record.usedAt = row.used_at;
4122
4683
  if (row.subject !== null)
4123
4684
  record.subject = row.subject;
4685
+ if (row.client_auth === "private_key_jwt")
4686
+ record.clientAuth = "private_key_jwt";
4124
4687
  return record;
4125
4688
  }
4126
4689
 
@@ -4131,7 +4694,24 @@ class SqliteTokenStore {
4131
4694
  this.#db.run("PRAGMA busy_timeout = 5000");
4132
4695
  if (path !== ":memory:")
4133
4696
  this.#db.run("PRAGMA journal_mode = WAL");
4697
+ this.#checkReader();
4134
4698
  this.#migrate();
4699
+ this.#db.run(`CREATE TABLE IF NOT EXISTS meta (
4700
+ key TEXT PRIMARY KEY,
4701
+ value INTEGER NOT NULL
4702
+ ) STRICT`);
4703
+ this.#db.query(`INSERT INTO meta (key, value) VALUES ('min_reader_version', $v)
4704
+ ON CONFLICT (key) DO UPDATE SET value = max(value, excluded.value)`).run({ v: MIN_READER_VERSION });
4705
+ }
4706
+ #checkReader() {
4707
+ const table = this.#db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
4708
+ if (table === null)
4709
+ return;
4710
+ const row = this.#db.query("SELECT value FROM meta WHERE key = 'min_reader_version'").get();
4711
+ if (row !== null && row.value > SCHEMA_VERSION) {
4712
+ this.#db.close();
4713
+ throw new Error(`the token store file needs a newer relay (schema ${row.value}; this relay knows ${SCHEMA_VERSION})`);
4714
+ }
4135
4715
  }
4136
4716
  #migrate() {
4137
4717
  const row = this.#db.query("PRAGMA user_version").get();
@@ -4157,12 +4737,20 @@ class SqliteTokenStore {
4157
4737
  issued_at INTEGER NOT NULL,
4158
4738
  expires_at INTEGER NOT NULL,
4159
4739
  used_at INTEGER,
4160
- subject TEXT
4740
+ subject TEXT,
4741
+ client_auth TEXT
4161
4742
  ) STRICT`);
4162
4743
  const columns = this.#db.query("PRAGMA table_info(tokens)").all();
4163
4744
  if (!columns.some((c) => c.name === "subject")) {
4164
4745
  this.#db.run("ALTER TABLE tokens ADD COLUMN subject TEXT");
4165
4746
  }
4747
+ if (!columns.some((c) => c.name === "client_auth")) {
4748
+ this.#db.run("ALTER TABLE tokens ADD COLUMN client_auth TEXT");
4749
+ }
4750
+ this.#db.run(`CREATE TABLE IF NOT EXISTS nonces (
4751
+ key TEXT PRIMARY KEY,
4752
+ expires_at INTEGER NOT NULL
4753
+ ) STRICT`);
4166
4754
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
4167
4755
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
4168
4756
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
@@ -4187,9 +4775,11 @@ class SqliteTokenStore {
4187
4775
  }
4188
4776
  #insert(r) {
4189
4777
  this.#db.query(`INSERT OR REPLACE INTO tokens (hash, kind, grant_id, address_id, client_id,
4190
- client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject)
4778
+ client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject,
4779
+ client_auth)
4191
4780
  VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
4192
- $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject)`).run({
4781
+ $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject,
4782
+ $client_auth)`).run({
4193
4783
  hash: r.hash,
4194
4784
  kind: r.kind,
4195
4785
  grant_id: r.grantId,
@@ -4201,7 +4791,8 @@ class SqliteTokenStore {
4201
4791
  issued_at: r.issuedAt,
4202
4792
  expires_at: r.expiresAt,
4203
4793
  used_at: r.usedAt ?? null,
4204
- subject: r.subject ?? null
4794
+ subject: r.subject ?? null,
4795
+ client_auth: r.clientAuth ?? null
4205
4796
  });
4206
4797
  }
4207
4798
  async get(hash) {
@@ -4237,8 +4828,15 @@ class SqliteTokenStore {
4237
4828
  }
4238
4829
  async deleteExpired(now) {
4239
4830
  this.#db.query("DELETE FROM revocations WHERE keep_until <= $now").run({ now });
4831
+ this.#db.query("DELETE FROM nonces WHERE expires_at <= $now").run({ now });
4240
4832
  return this.#db.query("DELETE FROM tokens WHERE expires_at <= $now").run({ now }).changes;
4241
4833
  }
4834
+ async claimNonce(key, expiresAt, now) {
4835
+ const result = this.#db.query(`INSERT INTO nonces (key, expires_at) VALUES ($key, $expires_at)
4836
+ ON CONFLICT (key) DO UPDATE SET expires_at = excluded.expires_at
4837
+ WHERE nonces.expires_at <= $now`).run({ key, expires_at: expiresAt, now });
4838
+ return result.changes === 1;
4839
+ }
4242
4840
  async close() {
4243
4841
  this.#db.close();
4244
4842
  }
@@ -4267,6 +4865,13 @@ var FORWARDED_HEADERS = new Set([
4267
4865
  "traceparent",
4268
4866
  "tracestate"
4269
4867
  ]);
4868
+ function developmentUrl(host, port) {
4869
+ if (host === "127.0.0.1")
4870
+ return `http://127.0.0.1:${port}`;
4871
+ if (host === "::1" || host === "[::1]")
4872
+ return `http://[::1]:${port}`;
4873
+ return `http://localhost:${port}`;
4874
+ }
4270
4875
  var HEALTH_STORE_TIMEOUT_MS = 1000;
4271
4876
  var HEALTH_REUSE_MS = 1000;
4272
4877
  var HEALTH_HUNG_RETRY_MS = 30000;
@@ -5133,7 +5738,7 @@ function startRelay(options, hooks) {
5133
5738
  let closing = false;
5134
5739
  const port = server.port ?? config.port;
5135
5740
  if (publicUrl === "")
5136
- publicUrl = `http://localhost:${port}`;
5741
+ publicUrl = developmentUrl(config.host, port);
5137
5742
  const sweep = setInterval(() => {
5138
5743
  auth.sweep();
5139
5744
  store.deleteExpired(Date.now()).then((n) => {
@@ -5206,5 +5811,5 @@ export {
5206
5811
  resolveConfig
5207
5812
  };
5208
5813
 
5209
- //# debugId=BA745D07E3DF06AF64756E2164756E21
5814
+ //# debugId=F0E5B49004089DE664756E2164756E21
5210
5815
  //# sourceMappingURL=index.js.map