@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/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.1";
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_",
@@ -1705,6 +1854,26 @@ function newId(prefix) {
1705
1854
  function hashToken(token) {
1706
1855
  return new Bun.CryptoHasher("sha256").update(token).digest().toBase64({ alphabet: "base64url", omitPadding: true });
1707
1856
  }
1857
+ async function issueAccessToken(store, grant, options) {
1858
+ const now = options.now ?? Date.now();
1859
+ const token = newToken("access");
1860
+ const record = {
1861
+ hash: hashToken(token),
1862
+ kind: "access",
1863
+ grantId: grant.grantId ?? newId("g"),
1864
+ addressId: grant.addressId,
1865
+ clientId: grant.clientId,
1866
+ clientName: grant.clientName,
1867
+ scopes: grant.scopes ?? ["tools"],
1868
+ grantCreatedAt: grant.grantCreatedAt ?? now,
1869
+ issuedAt: now,
1870
+ expiresAt: now + options.ttlMs
1871
+ };
1872
+ if (grant.subject !== undefined)
1873
+ record.subject = grant.subject;
1874
+ await store.put(record);
1875
+ return { token, record };
1876
+ }
1708
1877
  async function issueTokenPair(store, grant, options) {
1709
1878
  const now = options.now ?? Date.now();
1710
1879
  const accessToken = newToken("access");
@@ -1717,7 +1886,8 @@ async function issueTokenPair(store, grant, options) {
1717
1886
  scopes: grant.scopes,
1718
1887
  grantCreatedAt: grant.grantCreatedAt,
1719
1888
  issuedAt: now,
1720
- ...grant.subject === undefined ? {} : { subject: grant.subject }
1889
+ ...grant.subject === undefined ? {} : { subject: grant.subject },
1890
+ ...grant.clientAuth === undefined ? {} : { clientAuth: grant.clientAuth }
1721
1891
  };
1722
1892
  const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
1723
1893
  const refreshStored = await store.put({
@@ -1743,6 +1913,75 @@ async function issueTokenPair(store, grant, options) {
1743
1913
  };
1744
1914
  }
1745
1915
 
1916
+ // src/oauth/domain-cache.ts
1917
+ class DomainCache {
1918
+ #entries = new Map;
1919
+ #counts = new Map;
1920
+ #max;
1921
+ #perDomain;
1922
+ #isDead;
1923
+ constructor(max, perDomain, isDead = () => false) {
1924
+ this.#max = max;
1925
+ this.#perDomain = perDomain;
1926
+ this.#isDead = isDead;
1927
+ }
1928
+ get size() {
1929
+ return this.#entries.size;
1930
+ }
1931
+ get(key) {
1932
+ const entry = this.#entries.get(key);
1933
+ if (entry === undefined)
1934
+ return;
1935
+ this.#entries.delete(key);
1936
+ this.#entries.set(key, entry);
1937
+ return entry.value;
1938
+ }
1939
+ delete(key) {
1940
+ const entry = this.#entries.get(key);
1941
+ if (entry === undefined)
1942
+ return;
1943
+ this.#entries.delete(key);
1944
+ const left = (this.#counts.get(entry.domain) ?? 1) - 1;
1945
+ if (left > 0)
1946
+ this.#counts.set(entry.domain, left);
1947
+ else
1948
+ this.#counts.delete(entry.domain);
1949
+ }
1950
+ set(key, domain, value, now) {
1951
+ this.delete(key);
1952
+ if ((this.#counts.get(domain) ?? 0) >= this.#perDomain) {
1953
+ this.#evictOldestOf(domain);
1954
+ } else if (this.#entries.size >= this.#max) {
1955
+ this.#evict(now);
1956
+ }
1957
+ this.#entries.set(key, { domain, value });
1958
+ this.#counts.set(domain, (this.#counts.get(domain) ?? 0) + 1);
1959
+ }
1960
+ #evict(now) {
1961
+ for (const [key, entry] of this.#entries) {
1962
+ if (this.#isDead(entry.value, now)) {
1963
+ this.delete(key);
1964
+ return;
1965
+ }
1966
+ }
1967
+ const most = Math.max(0, ...this.#counts.values());
1968
+ for (const [key, entry] of this.#entries) {
1969
+ if (this.#counts.get(entry.domain) === most) {
1970
+ this.delete(key);
1971
+ return;
1972
+ }
1973
+ }
1974
+ }
1975
+ #evictOldestOf(domain) {
1976
+ for (const [key, entry] of this.#entries) {
1977
+ if (entry.domain === domain) {
1978
+ this.delete(key);
1979
+ return;
1980
+ }
1981
+ }
1982
+ }
1983
+ }
1984
+
1746
1985
  // src/oauth/cimd.ts
1747
1986
  class ClientMetadataError extends Error {
1748
1987
  name = "ClientMetadataError";
@@ -1752,6 +1991,16 @@ class ClientMetadataError extends Error {
1752
1991
  this.reason = reason;
1753
1992
  }
1754
1993
  }
1994
+ var TRANSIENT_REASONS = new Set([
1995
+ "rate_limited",
1996
+ "fetch_failed",
1997
+ "unresolvable",
1998
+ "too_large"
1999
+ ]);
2000
+ function isTransient(error) {
2001
+ return TRANSIENT_REASONS.has(error.reason);
2002
+ }
2003
+ var STALE_GRACE_MS = 60 * 60000;
1755
2004
  var MAX_CLIENT_ID_LENGTH = 2048;
1756
2005
  var MAX_NAME_LENGTH = 64;
1757
2006
  var MAX_REDIRECT_URIS = 20;
@@ -1759,6 +2008,9 @@ var MIN_CACHE_MS = 5 * 60000;
1759
2008
  var DEFAULT_CACHE_MS = 60 * 60000;
1760
2009
  var MAX_CACHE_MS = 24 * 60 * 60000;
1761
2010
  var MAX_CACHE_ENTRIES = 1000;
2011
+ var MAX_CACHE_PER_DOMAIN = 100;
2012
+ var FAILURE_CACHE_MS = 60000;
2013
+ var MAX_FAILURES = 1000;
1762
2014
  async function defaultLookup(hostname) {
1763
2015
  const records = await Bun.dns.lookup(hostname, {});
1764
2016
  return records.map((r) => r.address);
@@ -1792,12 +2044,22 @@ function limitKey(hostname) {
1792
2044
  return h;
1793
2045
  return h.split(".").slice(-2).join(".");
1794
2046
  }
2047
+ function knownBudget() {
2048
+ return {
2049
+ perHost: new RateLimiter({ capacity: 5, refillPerSecond: 0.2 }),
2050
+ perDomain: new RateLimiter({ capacity: 15, refillPerSecond: 0.6 }),
2051
+ global: new RateLimiter({ capacity: 100, refillPerSecond: 10 })
2052
+ };
2053
+ }
1795
2054
 
1796
2055
  class ClientMetadataFetcher {
1797
- #cache = new Map;
2056
+ #cache = new DomainCache(MAX_CACHE_ENTRIES, MAX_CACHE_PER_DOMAIN, (entry, now) => entry.until + STALE_GRACE_MS <= now);
2057
+ #failures = new Map;
1798
2058
  #perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
1799
2059
  #perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
1800
2060
  #global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
2061
+ #refreshes = knownBudget();
2062
+ #keys = knownBudget();
1801
2063
  #allowedHosts;
1802
2064
  #logger;
1803
2065
  #timeoutMs;
@@ -1816,34 +2078,106 @@ class ClientMetadataFetcher {
1816
2078
  this.#testMode = options.testMode;
1817
2079
  this.#now = options.now ?? Date.now;
1818
2080
  }
1819
- async get(clientId) {
2081
+ async get(clientId, options = {}) {
1820
2082
  const url = this.#checkUrl(clientId);
1821
2083
  const now = this.#now();
1822
2084
  const cached = this.#cache.get(clientId);
1823
- if (cached !== undefined && cached.until > now) {
1824
- this.#cache.delete(clientId);
1825
- this.#cache.set(clientId, cached);
2085
+ if (cached !== undefined && cached.until > now)
1826
2086
  return cached.meta;
2087
+ const known = cached !== undefined && now < cached.until + STALE_GRACE_MS;
2088
+ const stale = options.allowStale === true && known ? cached?.meta : undefined;
2089
+ try {
2090
+ const failed = this.#failures.get(clientId);
2091
+ if (failed !== undefined && failed.until > now)
2092
+ throw failed.error;
2093
+ return await this.#refresh(clientId, url, now, known);
2094
+ } catch (error) {
2095
+ const transient = error instanceof ClientMetadataError && isTransient(error);
2096
+ if (!transient)
2097
+ this.#cache.delete(clientId);
2098
+ if (stale === undefined || !transient)
2099
+ throw error;
2100
+ this.#logger?.info("oauth.cimd_stale", {
2101
+ host: url.host,
2102
+ reason: error.reason
2103
+ });
2104
+ return stale;
2105
+ }
2106
+ }
2107
+ async#refresh(clientId, url, now, known) {
2108
+ this.#admit(url, now, known ? "refresh" : "new");
2109
+ let meta;
2110
+ let ttlMs;
2111
+ try {
2112
+ const address = await this.#vettedAddress(url);
2113
+ const response = await this.#fetchJson(url, address, [
2114
+ "application/json"
2115
+ ]);
2116
+ meta = validateDocument(response.doc, clientId, url, {
2117
+ test: this.#testMode !== undefined,
2118
+ onFallback: (reason) => this.#logger?.info("oauth.cimd_auth_fallback", {
2119
+ host: url.host,
2120
+ reason
2121
+ })
2122
+ });
2123
+ ttlMs = cacheMs(response.cacheControl);
2124
+ } catch (error) {
2125
+ if (error instanceof ClientMetadataError) {
2126
+ this.#rememberFailure(clientId, error, now);
2127
+ }
2128
+ throw error;
1827
2129
  }
2130
+ this.#failures.delete(clientId);
2131
+ this.#cache.set(clientId, limitKey(url.hostname), { meta, until: now + ttlMs }, now);
2132
+ return meta;
2133
+ }
2134
+ #rememberFailure(clientId, error, now) {
2135
+ this.#failures.delete(clientId);
2136
+ if (this.#failures.size >= MAX_FAILURES) {
2137
+ const oldest = this.#failures.keys().next().value;
2138
+ if (oldest !== undefined)
2139
+ this.#failures.delete(oldest);
2140
+ }
2141
+ this.#failures.set(clientId, { error, until: now + FAILURE_CACHE_MS });
2142
+ }
2143
+ async fetchJwks(jwksUri) {
2144
+ const url = this.#checkJwksUri(jwksUri);
2145
+ this.#admit(url, this.#now(), "keys");
2146
+ const address = await this.#vettedAddress(url);
2147
+ const response = await this.#fetchJson(url, address, [
2148
+ "application/json",
2149
+ "application/jwk-set+json"
2150
+ ]);
2151
+ return response.doc;
2152
+ }
2153
+ #admit(url, now, budget) {
2154
+ const { perHost, perDomain, global } = budget === "refresh" ? this.#refreshes : budget === "keys" ? this.#keys : {
2155
+ perHost: this.#perHost,
2156
+ perDomain: this.#perDomain,
2157
+ global: this.#global
2158
+ };
1828
2159
  const key = limitKey(url.hostname);
1829
- if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
2160
+ if (!perHost.available(url.host, now) || !perDomain.available(key, now)) {
1830
2161
  throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
1831
2162
  }
1832
- if (!this.#global.take("all", now)) {
2163
+ if (!global.take("all", now)) {
1833
2164
  throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
1834
2165
  }
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);
2166
+ perHost.take(url.host, now);
2167
+ perDomain.take(key, now);
2168
+ }
2169
+ #checkJwksUri(jwksUri) {
2170
+ const url = parseJwksUri(jwksUri, this.#testMode !== undefined);
2171
+ if (url === undefined) {
2172
+ throw new ClientMetadataError("invalid_jwks_uri", "The AI app's jwks_uri is not accepted.");
1844
2173
  }
1845
- this.#cache.set(clientId, { meta, until: now + ttlMs });
1846
- return meta;
2174
+ if (this.#allowedHosts.length > 0 && !this.#allowedHosts.includes(url.host)) {
2175
+ throw new ClientMetadataError("host_not_allowed", `This relay does not accept AI apps from ${url.host}.`);
2176
+ }
2177
+ if (isIpLiteral(url.hostname) && !this.#addressAllowed(url.hostname)) {
2178
+ throw this.#notLoaded("private_address", url);
2179
+ }
2180
+ return url;
1847
2181
  }
1848
2182
  #checkUrl(clientId) {
1849
2183
  const refuse = (message) => new ClientMetadataError("invalid_client_id", message);
@@ -1905,9 +2239,9 @@ class ClientMetadataFetcher {
1905
2239
  }
1906
2240
  return addresses.find((a) => parseIpv4(a) !== undefined) ?? addresses[0] ?? "";
1907
2241
  }
1908
- async#fetchDocument(url, clientId, address) {
2242
+ async#fetchJson(url, address, types) {
1909
2243
  const fail = (detail) => this.#notLoaded("fetch_failed", url, detail);
1910
- const pinned = new URL(clientId);
2244
+ const pinned = new URL(url.href);
1911
2245
  pinned.hostname = address.includes(":") ? `[${address}]` : address;
1912
2246
  const hostname = url.hostname.replace(/^\[|\]$/g, "");
1913
2247
  const tls = url.protocol === "https:" ? {
@@ -1932,8 +2266,9 @@ class ClientMetadataFetcher {
1932
2266
  if (response.status !== 200)
1933
2267
  throw fail(`status_${response.status}`);
1934
2268
  const type = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
1935
- if (type !== "application/json")
2269
+ if (type === undefined || !types.includes(type)) {
1936
2270
  throw fail("not_json");
2271
+ }
1937
2272
  const text = await this.#readLimited(response, url);
1938
2273
  let doc;
1939
2274
  try {
@@ -1941,8 +2276,7 @@ class ClientMetadataFetcher {
1941
2276
  } catch {
1942
2277
  throw fail("bad_json");
1943
2278
  }
1944
- const meta = validateDocument(doc, clientId, url);
1945
- return { meta, ttlMs: cacheMs(response.headers.get("cache-control")) };
2279
+ return { doc, cacheControl: response.headers.get("cache-control") };
1946
2280
  } finally {
1947
2281
  if (!response.bodyUsed)
1948
2282
  response.body?.cancel().catch(() => {});
@@ -1997,7 +2331,7 @@ function foreignDomain(name, hostname) {
1997
2331
  }
1998
2332
  return false;
1999
2333
  }
2000
- function validateDocument(doc, clientId, url) {
2334
+ function validateDocument(doc, clientId, url, options = {}) {
2001
2335
  const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
2002
2336
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
2003
2337
  throw invalid("The AI app's details are not a JSON object.");
@@ -2023,11 +2357,7 @@ function validateDocument(doc, clientId, url) {
2023
2357
  if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
2024
2358
  throw invalid("The AI app's details have no valid redirect_uris.");
2025
2359
  }
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
- }
2360
+ const auth = clientAuth(d, url, options, invalid);
2031
2361
  const grants = d["grant_types"];
2032
2362
  if (grants !== undefined && (!Array.isArray(grants) || !grants.includes("authorization_code"))) {
2033
2363
  throw invalid("The AI app's details do not allow the authorization code grant.");
@@ -2040,9 +2370,81 @@ function validateDocument(doc, clientId, url) {
2040
2370
  clientId,
2041
2371
  name: displayName(name),
2042
2372
  host: url.host,
2043
- redirectUris: uris
2373
+ redirectUris: uris,
2374
+ auth
2375
+ };
2376
+ }
2377
+ function clientAuth(d, url, options, invalid) {
2378
+ const method = d["token_endpoint_auth_method"];
2379
+ const supported = d["token_endpoint_auth_methods_supported"];
2380
+ const listsNone = Array.isArray(supported) && supported.includes("none");
2381
+ if (method === undefined || method === "none")
2382
+ return { method: "none" };
2383
+ const checked = method === "private_key_jwt" ? privateKeyJwt(d, url, options.test === true) : {
2384
+ problem: "unsupported_method",
2385
+ 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.'
2386
+ };
2387
+ if ("jwksUri" in checked) {
2388
+ return { method: "private_key_jwt", ...checked, publicAllowed: listsNone };
2389
+ }
2390
+ if (listsNone) {
2391
+ options.onFallback?.(checked.problem);
2392
+ return { method: "none" };
2393
+ }
2394
+ throw invalid(checked.message);
2395
+ }
2396
+ function privateKeyJwt(d, url, test) {
2397
+ const jwksUri = d["jwks_uri"];
2398
+ if (jwksUri === undefined) {
2399
+ return {
2400
+ problem: "no_jwks_uri",
2401
+ message: "The AI app uses private_key_jwt but publishes no jwks_uri (an inline jwks is not accepted)."
2402
+ };
2403
+ }
2404
+ if (d["jwks"] !== undefined) {
2405
+ return {
2406
+ problem: "jwks_and_jwks_uri",
2407
+ message: "The AI app's details have both jwks and jwks_uri."
2408
+ };
2409
+ }
2410
+ const parsed = typeof jwksUri === "string" ? parseJwksUri(jwksUri, test) : undefined;
2411
+ if (parsed === undefined || parsed.protocol !== url.protocol || parsed.host !== url.host) {
2412
+ return {
2413
+ problem: "bad_jwks_uri",
2414
+ message: "The AI app's jwks_uri must be an https:// URL on the host of its client_id."
2415
+ };
2416
+ }
2417
+ const alg = d["token_endpoint_auth_signing_alg"];
2418
+ if (alg !== undefined && !isAlgorithm(alg)) {
2419
+ return {
2420
+ problem: "signing_alg",
2421
+ message: "The AI app's token_endpoint_auth_signing_alg is not one this relay accepts."
2422
+ };
2423
+ }
2424
+ return {
2425
+ jwksUri: parsed.href,
2426
+ algorithms: alg === undefined ? JWT_ALGORITHMS : [alg]
2044
2427
  };
2045
2428
  }
2429
+ function parseJwksUri(value, test) {
2430
+ if (value.length > MAX_CLIENT_ID_LENGTH)
2431
+ return;
2432
+ let url;
2433
+ try {
2434
+ url = new URL(value);
2435
+ } catch {
2436
+ return;
2437
+ }
2438
+ const scheme = url.protocol === "https:" || test && url.protocol === "http:";
2439
+ if (!scheme || url.href !== value)
2440
+ return;
2441
+ if (url.username !== "" || url.password !== "" || url.hash !== "") {
2442
+ return;
2443
+ }
2444
+ if (url.port !== "" && !test)
2445
+ return;
2446
+ return url;
2447
+ }
2046
2448
  var PRIVATE_USE_SCHEME = /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+:$/;
2047
2449
  function isRedirectUri(value) {
2048
2450
  if (value.length > 2048 || value.includes("#"))
@@ -2086,6 +2488,151 @@ function redirectUriMatches(requested, registered) {
2086
2488
  return a.href === b.href;
2087
2489
  }
2088
2490
 
2491
+ // src/oauth/client-auth.ts
2492
+ var CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
2493
+ var CLIENT_ASSERTION_MAX_LIFETIME_MS = 60 * 60000;
2494
+ var CLIENT_ASSERTION_MAX_AGE_MS = 5 * 60000;
2495
+ var CLIENT_ASSERTION_LEEWAY_MS = 30000;
2496
+ var MAX_JTI2 = 256;
2497
+ var MAX_KEY_SETS = 1000;
2498
+ var MAX_KEY_SETS_PER_DOMAIN = 100;
2499
+ var SWEEP_INTERVAL_MS = 1000;
2500
+ var ACCEPTED_TYPES = new Set(["jwt", "client-authentication+jwt"]);
2501
+ function assertionSubject(assertion) {
2502
+ try {
2503
+ const sub = decodeJwt(assertion).payload["sub"];
2504
+ return typeof sub === "string" && sub !== "" ? sub : undefined;
2505
+ } catch {
2506
+ return;
2507
+ }
2508
+ }
2509
+ function nonceKey(clientId, jti) {
2510
+ return hashToken(JSON.stringify(["client_assertion", clientId, jti]));
2511
+ }
2512
+ async function verifyClientAssertion(assertion, check) {
2513
+ const { header, payload } = await verifyJwt(assertion, check.keys, {
2514
+ algorithms: check.auth.algorithms
2515
+ });
2516
+ const typ = header["typ"];
2517
+ if (typ !== undefined && (typeof typ !== "string" || !ACCEPTED_TYPES.has(typ.toLowerCase()))) {
2518
+ throw new JwtError("typ", "the assertion's type is not a client assertion");
2519
+ }
2520
+ if (payload["iss"] !== check.clientId) {
2521
+ throw new JwtError("iss", "the assertion's issuer is not the client");
2522
+ }
2523
+ if (payload["sub"] !== check.clientId) {
2524
+ throw new JwtError("sub", "the assertion's subject is not the client");
2525
+ }
2526
+ if (!audiences(payload).some((a) => check.audiences.includes(a))) {
2527
+ throw new JwtError("aud", "the assertion is not for this relay");
2528
+ }
2529
+ const usableUntil = checkTimes(payload, check.now);
2530
+ const jti = payload["jti"];
2531
+ if (typeof jti !== "string" || jti === "" || jti.length > MAX_JTI2) {
2532
+ throw new JwtError("jti", "the assertion needs a jti");
2533
+ }
2534
+ const key = nonceKey(check.clientId, jti);
2535
+ if (!await check.claimNonce(key, usableUntil, check.now)) {
2536
+ throw new JwtError("replayed", "the assertion was already used");
2537
+ }
2538
+ }
2539
+ function checkTimes(payload, now) {
2540
+ const leeway = CLIENT_ASSERTION_LEEWAY_MS;
2541
+ const exp = numericDate(payload, "exp");
2542
+ if (exp === undefined)
2543
+ throw new JwtError("exp", "the assertion has no exp");
2544
+ if (exp <= now - leeway) {
2545
+ throw new JwtError("expired", "the assertion has expired");
2546
+ }
2547
+ if (exp > now + CLIENT_ASSERTION_MAX_LIFETIME_MS + leeway) {
2548
+ throw new JwtError("lifetime", "the assertion lives longer than an hour");
2549
+ }
2550
+ const iat = numericDate(payload, "iat");
2551
+ let end = exp;
2552
+ if (iat === undefined) {
2553
+ if (exp > now + CLIENT_ASSERTION_MAX_AGE_MS + leeway) {
2554
+ throw new JwtError("lifetime", "an assertion without iat may live five minutes");
2555
+ }
2556
+ } else {
2557
+ if (iat > now + leeway) {
2558
+ throw new JwtError("iat", "the assertion was issued in the future");
2559
+ }
2560
+ if (iat + CLIENT_ASSERTION_MAX_AGE_MS <= now - leeway) {
2561
+ throw new JwtError("too_old", "the assertion was issued too long ago");
2562
+ }
2563
+ end = Math.min(exp, iat + CLIENT_ASSERTION_MAX_AGE_MS);
2564
+ }
2565
+ const nbf = numericDate(payload, "nbf");
2566
+ if (nbf !== undefined && nbf > now + leeway) {
2567
+ throw new JwtError("nbf", "the assertion is not valid yet");
2568
+ }
2569
+ return end + leeway;
2570
+ }
2571
+ function quotaKey(clientId) {
2572
+ return ipPrefixKey(new URL(clientId).hostname.replace(/\.$/, ""));
2573
+ }
2574
+
2575
+ class NonceQuota {
2576
+ #until = new Map;
2577
+ #perDomain;
2578
+ #max;
2579
+ #total = 0;
2580
+ #sweptAt = Number.NEGATIVE_INFINITY;
2581
+ constructor(perDomain, max) {
2582
+ this.#perDomain = perDomain;
2583
+ this.#max = max;
2584
+ }
2585
+ refusal(domain, now) {
2586
+ this.sweep(now);
2587
+ if ((this.#until.get(domain)?.length ?? 0) >= this.#perDomain) {
2588
+ return "domain_full";
2589
+ }
2590
+ return this.#total >= this.#max ? "full" : undefined;
2591
+ }
2592
+ add(domain, until) {
2593
+ const list = this.#until.get(domain);
2594
+ if (list === undefined)
2595
+ this.#until.set(domain, [until]);
2596
+ else
2597
+ list.push(until);
2598
+ this.#total++;
2599
+ }
2600
+ sweep(now) {
2601
+ if (now - this.#sweptAt < SWEEP_INTERVAL_MS)
2602
+ return false;
2603
+ this.#sweptAt = now;
2604
+ this.#total = 0;
2605
+ for (const [domain, list] of this.#until) {
2606
+ const live = list.filter((until) => until > now);
2607
+ if (live.length === 0)
2608
+ this.#until.delete(domain);
2609
+ else
2610
+ this.#until.set(domain, live);
2611
+ this.#total += live.length;
2612
+ }
2613
+ return true;
2614
+ }
2615
+ }
2616
+
2617
+ class ClientKeySets {
2618
+ #sets = new DomainCache(MAX_KEY_SETS, MAX_KEY_SETS_PER_DOMAIN);
2619
+ #load;
2620
+ #now;
2621
+ constructor(load, now = Date.now) {
2622
+ this.#load = load;
2623
+ this.#now = now;
2624
+ }
2625
+ for(jwksUri) {
2626
+ const known = this.#sets.get(jwksUri);
2627
+ if (known !== undefined)
2628
+ return known;
2629
+ const set = new JwksCache(jwksUri, { load: this.#load, now: this.#now });
2630
+ const domain = limitKey(new URL(jwksUri).hostname);
2631
+ this.#sets.set(jwksUri, domain, set, this.#now());
2632
+ return set;
2633
+ }
2634
+ }
2635
+
2089
2636
  // src/oauth/html.ts
2090
2637
  var ESCAPES = {
2091
2638
  "&": "&amp;",
@@ -2655,6 +3202,12 @@ var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
2655
3202
  var MAX_CODES = 1e4;
2656
3203
  var MAX_FORM_BYTES = 8192;
2657
3204
  var MAX_STATE_LENGTH = 1024;
3205
+ var MAX_NONCES_PER_HOST = 1e4;
3206
+ var MAX_NONCES = 1e5;
3207
+ var CLIENT_AUTH_FAILED = "client authentication failed";
3208
+ function assertionRequired(auth) {
3209
+ return auth.method === "private_key_jwt" && !auth.publicAllowed;
3210
+ }
2658
3211
 
2659
3212
  class OAuthError extends Error {
2660
3213
  status;
@@ -2704,10 +3257,16 @@ function hostOf(url) {
2704
3257
  return url;
2705
3258
  }
2706
3259
  }
3260
+ function logHost(clientId) {
3261
+ return clientId !== undefined && URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)";
3262
+ }
2707
3263
 
2708
3264
  class AuthorizationServer {
2709
3265
  #host;
2710
3266
  #cimd;
3267
+ #clientKeys;
3268
+ #nonces = new NonceSet;
3269
+ #nonceQuota = new NonceQuota(MAX_NONCES_PER_HOST, MAX_NONCES);
2711
3270
  #pending = new Map;
2712
3271
  #pendingPerIp = new Map;
2713
3272
  #pendingPerAddress = new Map;
@@ -2758,6 +3317,8 @@ class AuthorizationServer {
2758
3317
  logger: host.logger,
2759
3318
  ...testMode?.cimd === undefined ? {} : { testMode: testMode.cimd }
2760
3319
  });
3320
+ const cimd = this.#cimd;
3321
+ this.#clientKeys = new ClientKeySets((url) => cimd.fetchJwks(url));
2761
3322
  }
2762
3323
  get #issuer() {
2763
3324
  return this.#host.publicUrl();
@@ -2811,8 +3372,10 @@ class AuthorizationServer {
2811
3372
  response_modes_supported: ["query"],
2812
3373
  grant_types_supported: ["authorization_code", "refresh_token"],
2813
3374
  code_challenge_methods_supported: ["S256"],
2814
- token_endpoint_auth_methods_supported: ["none"],
2815
- revocation_endpoint_auth_methods_supported: ["none"],
3375
+ token_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
3376
+ token_endpoint_auth_signing_alg_values_supported: JWT_ALGORITHMS,
3377
+ revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
3378
+ revocation_endpoint_auth_signing_alg_values_supported: JWT_ALGORITHMS,
2816
3379
  scopes_supported: [SCOPE],
2817
3380
  client_id_metadata_document_supported: true,
2818
3381
  authorization_response_iss_parameter_supported: true,
@@ -3311,20 +3874,21 @@ ${cancel}
3311
3874
  "code_verifier",
3312
3875
  "refresh_token",
3313
3876
  "resource",
3314
- "scope"
3877
+ "scope",
3878
+ "client_assertion",
3879
+ "client_assertion_type"
3315
3880
  ]);
3316
3881
  if ("duplicate" in p) {
3317
3882
  throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
3318
3883
  }
3319
- const clientId = p["client_id"];
3320
- if (clientId === undefined) {
3321
- throw new OAuthError(400, "invalid_request", "client_id is required");
3322
- }
3884
+ const client = await this.#authenticateClient(p, [
3885
+ `${this.#issuer}/token`
3886
+ ]);
3323
3887
  let body;
3324
3888
  if (p["grant_type"] === "authorization_code") {
3325
- body = await this.#codeGrant(p, clientId);
3889
+ body = await this.#codeGrant(p, client);
3326
3890
  } else if (p["grant_type"] === "refresh_token") {
3327
- body = await this.#refreshGrant(p, clientId);
3891
+ body = await this.#refreshGrant(p, client);
3328
3892
  } else if (p["grant_type"] === undefined) {
3329
3893
  throw new OAuthError(400, "invalid_request", "grant_type is required");
3330
3894
  } else {
@@ -3358,19 +3922,96 @@ ${cancel}
3358
3922
  throw new OAuthError(400, "invalid_request", "the body must be application/x-www-form-urlencoded");
3359
3923
  }
3360
3924
  if (req.headers.get("authorization") !== null) {
3361
- throw new OAuthError(401, "invalid_client", "client authentication is not supported (token_endpoint_auth_method none)");
3925
+ throw new OAuthError(401, "invalid_client", "HTTP authentication is not supported (token_endpoint_auth_method none or private_key_jwt)");
3362
3926
  }
3363
3927
  const text = await this.#readForm(req);
3364
3928
  if (text === undefined) {
3365
3929
  throw new OAuthError(413, "invalid_request", "the request is too large");
3366
3930
  }
3367
3931
  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)");
3932
+ if (params.has("client_secret")) {
3933
+ throw new OAuthError(400, "invalid_client", "client secrets are not supported (token_endpoint_auth_method none or private_key_jwt)");
3370
3934
  }
3371
3935
  return params;
3372
3936
  }
3373
- async#codeGrant(p, clientId) {
3937
+ async#authenticateClient(p, endpoints) {
3938
+ const assertion = p["client_assertion"];
3939
+ const type = p["client_assertion_type"];
3940
+ if (assertion === undefined && type === undefined) {
3941
+ const clientId = p["client_id"];
3942
+ if (clientId === undefined) {
3943
+ throw new OAuthError(400, "invalid_request", "client_id is required");
3944
+ }
3945
+ return { clientId, assertion: false };
3946
+ }
3947
+ if (assertion === undefined || type === undefined) {
3948
+ throw new OAuthError(400, "invalid_request", "client_assertion and client_assertion_type go together");
3949
+ }
3950
+ const clientId = p["client_id"] ?? assertionSubject(assertion);
3951
+ if (type !== CLIENT_ASSERTION_TYPE) {
3952
+ throw this.#clientAuthFailed(clientId, "assertion_type");
3953
+ }
3954
+ if (clientId === undefined) {
3955
+ throw this.#clientAuthFailed(clientId, "no_client_id");
3956
+ }
3957
+ let client;
3958
+ try {
3959
+ client = await this.#cimd.get(clientId, { allowStale: true });
3960
+ } catch (error) {
3961
+ if (!(error instanceof ClientMetadataError))
3962
+ throw error;
3963
+ const reason = `metadata_${error.reason}`;
3964
+ throw isTransient(error) || error.reason === "private_address" ? this.#clientAuthUnavailable(clientId, reason) : this.#clientAuthFailed(clientId, reason);
3965
+ }
3966
+ if (client.auth.method !== "private_key_jwt") {
3967
+ throw this.#clientAuthFailed(clientId, "not_private_key_jwt");
3968
+ }
3969
+ const domain = quotaKey(clientId);
3970
+ try {
3971
+ await verifyClientAssertion(assertion, {
3972
+ clientId,
3973
+ auth: client.auth,
3974
+ keys: this.#clientKeys.for(client.auth.jwksUri),
3975
+ audiences: [this.#issuer, ...endpoints],
3976
+ claimNonce: (key, until, now) => this.#claimNonce(clientId, domain, key, until, now),
3977
+ now: Date.now()
3978
+ });
3979
+ } catch (error) {
3980
+ if (!(error instanceof JwtError))
3981
+ throw error;
3982
+ throw error.reason === "jwks_unavailable" ? this.#clientAuthUnavailable(clientId, error.reason) : this.#clientAuthFailed(clientId, error.reason);
3983
+ }
3984
+ return { clientId, assertion: true };
3985
+ }
3986
+ #clientAuthFailed(clientId, reason) {
3987
+ this.#host.logger.info("oauth.client_auth_failed", {
3988
+ client: logHost(clientId),
3989
+ reason
3990
+ });
3991
+ return new OAuthError(401, "invalid_client", CLIENT_AUTH_FAILED);
3992
+ }
3993
+ #clientAuthUnavailable(clientId, reason) {
3994
+ this.#host.logger.warn("oauth.client_auth_unavailable", {
3995
+ client: logHost(clientId),
3996
+ reason
3997
+ });
3998
+ return new OAuthError(503, "temporarily_unavailable", "the client cannot be authenticated right now; try again shortly");
3999
+ }
4000
+ async#claimNonce(clientId, domain, key, until, now) {
4001
+ if (this.#nonceQuota.sweep(now))
4002
+ this.#nonces.sweep(now);
4003
+ const refusal = this.#nonceQuota.refusal(domain, now);
4004
+ if (refusal !== undefined) {
4005
+ throw this.#clientAuthUnavailable(clientId, `nonces_${refusal}`);
4006
+ }
4007
+ const store = this.#host.store;
4008
+ const claimed = store.claimNonce === undefined ? this.#nonces.claim(key, until, now) : await store.claimNonce(key, until, now);
4009
+ if (claimed)
4010
+ this.#nonceQuota.add(domain, until);
4011
+ return claimed;
4012
+ }
4013
+ async#codeGrant(p, client) {
4014
+ const clientId = client.clientId;
3374
4015
  const code = p["code"];
3375
4016
  const redirectUri = p["redirect_uri"];
3376
4017
  const verifier = p["code_verifier"];
@@ -3382,6 +4023,12 @@ ${cancel}
3382
4023
  const invalid = (why) => new OAuthError(400, "invalid_grant", why);
3383
4024
  if (issued === undefined)
3384
4025
  throw invalid("the code is not valid");
4026
+ if (!constantTimeEqual(clientId, issued.client.clientId)) {
4027
+ throw invalid("the code was issued to another client");
4028
+ }
4029
+ if (assertionRequired(issued.client.auth) && !client.assertion) {
4030
+ throw this.#clientAuthFailed(clientId, "assertion_required");
4031
+ }
3385
4032
  if (issued.used) {
3386
4033
  if (issued.grantId !== undefined) {
3387
4034
  await this.#revokeGrant(issued.addressId, issued.grantId, "reuse_detected");
@@ -3391,9 +4038,6 @@ ${cancel}
3391
4038
  issued.used = true;
3392
4039
  if (issued.expiresAt <= Date.now())
3393
4040
  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
4041
  if (redirectUri !== issued.redirectUri) {
3398
4042
  throw invalid("redirect_uri does not match the authorization request");
3399
4043
  }
@@ -3417,7 +4061,8 @@ ${cancel}
3417
4061
  clientName: issued.client.name,
3418
4062
  scopes: issued.scopes,
3419
4063
  grantCreatedAt: now,
3420
- ...issued.subject === undefined ? {} : { subject: issued.subject }
4064
+ ...issued.subject === undefined ? {} : { subject: issued.subject },
4065
+ ...client.assertion ? { clientAuth: "private_key_jwt" } : {}
3421
4066
  }, {
3422
4067
  accessTtlMs: config.accessTokenTtlMs,
3423
4068
  refreshTtlMs: config.refreshTokenTtlMs,
@@ -3451,7 +4096,8 @@ ${cancel}
3451
4096
  scope: scopes.join(" ")
3452
4097
  };
3453
4098
  }
3454
- async#refreshGrant(p, clientId) {
4099
+ async#refreshGrant(p, client) {
4100
+ const clientId = client.clientId;
3455
4101
  const token = p["refresh_token"];
3456
4102
  if (token === undefined) {
3457
4103
  throw new OAuthError(400, "invalid_request", "refresh_token is required");
@@ -3465,6 +4111,9 @@ ${cancel}
3465
4111
  if (!constantTimeEqual(clientId, record.clientId)) {
3466
4112
  throw invalid("the refresh token was issued to another client");
3467
4113
  }
4114
+ if (record.clientAuth === "private_key_jwt" && !client.assertion) {
4115
+ throw this.#clientAuthFailed(clientId, "assertion_required");
4116
+ }
3468
4117
  const now = Date.now();
3469
4118
  if (record.expiresAt <= now)
3470
4119
  throw invalid("the refresh token has expired");
@@ -3496,7 +4145,8 @@ ${cancel}
3496
4145
  clientName: record.clientName,
3497
4146
  scopes: record.scopes,
3498
4147
  grantCreatedAt: record.grantCreatedAt,
3499
- ...record.subject === undefined ? {} : { subject: record.subject }
4148
+ ...record.subject === undefined ? {} : { subject: record.subject },
4149
+ ...record.clientAuth === "private_key_jwt" || client.assertion ? { clientAuth: "private_key_jwt" } : {}
3500
4150
  }, {
3501
4151
  accessTtlMs: this.#host.config.accessTokenTtlMs,
3502
4152
  refreshTtlMs: this.#host.config.refreshTokenTtlMs,
@@ -3532,24 +4182,36 @@ ${cancel}
3532
4182
  throw new OAuthError(429, "temporarily_unavailable", "too many requests; try again shortly");
3533
4183
  }
3534
4184
  const params = await this.#formParams(req);
3535
- const p = single(params, ["token", "client_id", "token_type_hint"]);
4185
+ const p = single(params, [
4186
+ "token",
4187
+ "client_id",
4188
+ "token_type_hint",
4189
+ "client_assertion",
4190
+ "client_assertion_type"
4191
+ ]);
3536
4192
  if ("duplicate" in p) {
3537
4193
  throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
3538
4194
  }
3539
4195
  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");
4196
+ if (token === undefined) {
4197
+ throw new OAuthError(400, "invalid_request", "token is required");
3543
4198
  }
4199
+ const client = await this.#authenticateClient(p, [
4200
+ `${this.#issuer}/revoke`,
4201
+ `${this.#issuer}/token`
4202
+ ]);
3544
4203
  const record = await this.#host.store.get(hashToken(token));
3545
4204
  if (record === undefined)
3546
4205
  return new Response(null, {
3547
4206
  status: 200,
3548
4207
  headers: { ...this.#cors(), ...noStore }
3549
4208
  });
3550
- if (!constantTimeEqual(clientId, record.clientId)) {
4209
+ if (!constantTimeEqual(client.clientId, record.clientId)) {
3551
4210
  throw new OAuthError(400, "invalid_client", "the token was issued to another client");
3552
4211
  }
4212
+ if (record.clientAuth === "private_key_jwt" && !client.assertion) {
4213
+ throw this.#clientAuthFailed(client.clientId, "assertion_required");
4214
+ }
3553
4215
  await this.#revokeGrant(record.addressId, record.grantId, "revoked");
3554
4216
  this.#host.logger.info("oauth.revoked", {
3555
4217
  address: shortAddress(record.addressId),
@@ -3595,6 +4257,7 @@ ${cancel}
3595
4257
  this.#guessesPerAddress.sweep(now);
3596
4258
  this.#tokenPerIp.sweep(now);
3597
4259
  this.#refreshesPerGrant.sweep(now);
4260
+ this.#nonces.sweep(now);
3598
4261
  }
3599
4262
  }
3600
4263
 
@@ -4003,112 +4666,10 @@ class TabSession {
4003
4666
  }
4004
4667
  }
4005
4668
 
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
4669
  // src/store/sqlite.ts
4110
4670
  import { Database } from "bun:sqlite";
4111
- var SCHEMA_VERSION = 4;
4671
+ var SCHEMA_VERSION = 5;
4672
+ var MIN_READER_VERSION = 5;
4112
4673
  var REUSE_WINDOW_MS = 86400000;
4113
4674
  function toRecord(row) {
4114
4675
  const record = {
@@ -4127,6 +4688,8 @@ function toRecord(row) {
4127
4688
  record.usedAt = row.used_at;
4128
4689
  if (row.subject !== null)
4129
4690
  record.subject = row.subject;
4691
+ if (row.client_auth === "private_key_jwt")
4692
+ record.clientAuth = "private_key_jwt";
4130
4693
  return record;
4131
4694
  }
4132
4695
 
@@ -4137,7 +4700,24 @@ class SqliteTokenStore {
4137
4700
  this.#db.run("PRAGMA busy_timeout = 5000");
4138
4701
  if (path !== ":memory:")
4139
4702
  this.#db.run("PRAGMA journal_mode = WAL");
4703
+ this.#checkReader();
4140
4704
  this.#migrate();
4705
+ this.#db.run(`CREATE TABLE IF NOT EXISTS meta (
4706
+ key TEXT PRIMARY KEY,
4707
+ value INTEGER NOT NULL
4708
+ ) STRICT`);
4709
+ this.#db.query(`INSERT INTO meta (key, value) VALUES ('min_reader_version', $v)
4710
+ ON CONFLICT (key) DO UPDATE SET value = max(value, excluded.value)`).run({ v: MIN_READER_VERSION });
4711
+ }
4712
+ #checkReader() {
4713
+ const table = this.#db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
4714
+ if (table === null)
4715
+ return;
4716
+ const row = this.#db.query("SELECT value FROM meta WHERE key = 'min_reader_version'").get();
4717
+ if (row !== null && row.value > SCHEMA_VERSION) {
4718
+ this.#db.close();
4719
+ throw new Error(`the token store file needs a newer relay (schema ${row.value}; this relay knows ${SCHEMA_VERSION})`);
4720
+ }
4141
4721
  }
4142
4722
  #migrate() {
4143
4723
  const row = this.#db.query("PRAGMA user_version").get();
@@ -4163,12 +4743,20 @@ class SqliteTokenStore {
4163
4743
  issued_at INTEGER NOT NULL,
4164
4744
  expires_at INTEGER NOT NULL,
4165
4745
  used_at INTEGER,
4166
- subject TEXT
4746
+ subject TEXT,
4747
+ client_auth TEXT
4167
4748
  ) STRICT`);
4168
4749
  const columns = this.#db.query("PRAGMA table_info(tokens)").all();
4169
4750
  if (!columns.some((c) => c.name === "subject")) {
4170
4751
  this.#db.run("ALTER TABLE tokens ADD COLUMN subject TEXT");
4171
4752
  }
4753
+ if (!columns.some((c) => c.name === "client_auth")) {
4754
+ this.#db.run("ALTER TABLE tokens ADD COLUMN client_auth TEXT");
4755
+ }
4756
+ this.#db.run(`CREATE TABLE IF NOT EXISTS nonces (
4757
+ key TEXT PRIMARY KEY,
4758
+ expires_at INTEGER NOT NULL
4759
+ ) STRICT`);
4172
4760
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
4173
4761
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
4174
4762
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
@@ -4193,9 +4781,11 @@ class SqliteTokenStore {
4193
4781
  }
4194
4782
  #insert(r) {
4195
4783
  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)
4784
+ client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject,
4785
+ client_auth)
4197
4786
  VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
4198
- $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject)`).run({
4787
+ $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject,
4788
+ $client_auth)`).run({
4199
4789
  hash: r.hash,
4200
4790
  kind: r.kind,
4201
4791
  grant_id: r.grantId,
@@ -4207,7 +4797,8 @@ class SqliteTokenStore {
4207
4797
  issued_at: r.issuedAt,
4208
4798
  expires_at: r.expiresAt,
4209
4799
  used_at: r.usedAt ?? null,
4210
- subject: r.subject ?? null
4800
+ subject: r.subject ?? null,
4801
+ client_auth: r.clientAuth ?? null
4211
4802
  });
4212
4803
  }
4213
4804
  async get(hash) {
@@ -4243,8 +4834,15 @@ class SqliteTokenStore {
4243
4834
  }
4244
4835
  async deleteExpired(now) {
4245
4836
  this.#db.query("DELETE FROM revocations WHERE keep_until <= $now").run({ now });
4837
+ this.#db.query("DELETE FROM nonces WHERE expires_at <= $now").run({ now });
4246
4838
  return this.#db.query("DELETE FROM tokens WHERE expires_at <= $now").run({ now }).changes;
4247
4839
  }
4840
+ async claimNonce(key, expiresAt, now) {
4841
+ const result = this.#db.query(`INSERT INTO nonces (key, expires_at) VALUES ($key, $expires_at)
4842
+ ON CONFLICT (key) DO UPDATE SET expires_at = excluded.expires_at
4843
+ WHERE nonces.expires_at <= $now`).run({ key, expires_at: expiresAt, now });
4844
+ return result.changes === 1;
4845
+ }
4248
4846
  async close() {
4249
4847
  this.#db.close();
4250
4848
  }
@@ -4273,6 +4871,13 @@ var FORWARDED_HEADERS = new Set([
4273
4871
  "traceparent",
4274
4872
  "tracestate"
4275
4873
  ]);
4874
+ function developmentUrl(host, port) {
4875
+ if (host === "127.0.0.1")
4876
+ return `http://127.0.0.1:${port}`;
4877
+ if (host === "::1" || host === "[::1]")
4878
+ return `http://[::1]:${port}`;
4879
+ return `http://localhost:${port}`;
4880
+ }
4276
4881
  var HEALTH_STORE_TIMEOUT_MS = 1000;
4277
4882
  var HEALTH_REUSE_MS = 1000;
4278
4883
  var HEALTH_HUNG_RETRY_MS = 30000;
@@ -5139,7 +5744,7 @@ function startRelay(options, hooks) {
5139
5744
  let closing = false;
5140
5745
  const port = server.port ?? config.port;
5141
5746
  if (publicUrl === "")
5142
- publicUrl = `http://localhost:${port}`;
5747
+ publicUrl = developmentUrl(config.host, port);
5143
5748
  const sweep = setInterval(() => {
5144
5749
  auth.sweep();
5145
5750
  store.deleteExpired(Date.now()).then((n) => {
@@ -5288,5 +5893,5 @@ var shutdown = () => {
5288
5893
  process.on("SIGTERM", shutdown);
5289
5894
  process.on("SIGINT", shutdown);
5290
5895
 
5291
- //# debugId=F77E87C19A21537264756E2164756E21
5896
+ //# debugId=D1FDCC539AD03DF264756E2164756E21
5292
5897
  //# sourceMappingURL=main.js.map