@ractive-ch/tab-mcp 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0";
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_",
@@ -1711,7 +1860,8 @@ async function issueTokenPair(store, grant, options) {
1711
1860
  scopes: grant.scopes,
1712
1861
  grantCreatedAt: grant.grantCreatedAt,
1713
1862
  issuedAt: now,
1714
- ...grant.subject === undefined ? {} : { subject: grant.subject }
1863
+ ...grant.subject === undefined ? {} : { subject: grant.subject },
1864
+ ...grant.clientAuth === undefined ? {} : { clientAuth: grant.clientAuth }
1715
1865
  };
1716
1866
  const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
1717
1867
  const refreshStored = await store.put({
@@ -1737,6 +1887,75 @@ async function issueTokenPair(store, grant, options) {
1737
1887
  };
1738
1888
  }
1739
1889
 
1890
+ // src/oauth/domain-cache.ts
1891
+ class DomainCache {
1892
+ #entries = new Map;
1893
+ #counts = new Map;
1894
+ #max;
1895
+ #perDomain;
1896
+ #isDead;
1897
+ constructor(max, perDomain, isDead = () => false) {
1898
+ this.#max = max;
1899
+ this.#perDomain = perDomain;
1900
+ this.#isDead = isDead;
1901
+ }
1902
+ get size() {
1903
+ return this.#entries.size;
1904
+ }
1905
+ get(key) {
1906
+ const entry = this.#entries.get(key);
1907
+ if (entry === undefined)
1908
+ return;
1909
+ this.#entries.delete(key);
1910
+ this.#entries.set(key, entry);
1911
+ return entry.value;
1912
+ }
1913
+ delete(key) {
1914
+ const entry = this.#entries.get(key);
1915
+ if (entry === undefined)
1916
+ return;
1917
+ this.#entries.delete(key);
1918
+ const left = (this.#counts.get(entry.domain) ?? 1) - 1;
1919
+ if (left > 0)
1920
+ this.#counts.set(entry.domain, left);
1921
+ else
1922
+ this.#counts.delete(entry.domain);
1923
+ }
1924
+ set(key, domain, value, now) {
1925
+ this.delete(key);
1926
+ if ((this.#counts.get(domain) ?? 0) >= this.#perDomain) {
1927
+ this.#evictOldestOf(domain);
1928
+ } else if (this.#entries.size >= this.#max) {
1929
+ this.#evict(now);
1930
+ }
1931
+ this.#entries.set(key, { domain, value });
1932
+ this.#counts.set(domain, (this.#counts.get(domain) ?? 0) + 1);
1933
+ }
1934
+ #evict(now) {
1935
+ for (const [key, entry] of this.#entries) {
1936
+ if (this.#isDead(entry.value, now)) {
1937
+ this.delete(key);
1938
+ return;
1939
+ }
1940
+ }
1941
+ const most = Math.max(0, ...this.#counts.values());
1942
+ for (const [key, entry] of this.#entries) {
1943
+ if (this.#counts.get(entry.domain) === most) {
1944
+ this.delete(key);
1945
+ return;
1946
+ }
1947
+ }
1948
+ }
1949
+ #evictOldestOf(domain) {
1950
+ for (const [key, entry] of this.#entries) {
1951
+ if (entry.domain === domain) {
1952
+ this.delete(key);
1953
+ return;
1954
+ }
1955
+ }
1956
+ }
1957
+ }
1958
+
1740
1959
  // src/oauth/cimd.ts
1741
1960
  class ClientMetadataError extends Error {
1742
1961
  name = "ClientMetadataError";
@@ -1746,6 +1965,16 @@ class ClientMetadataError extends Error {
1746
1965
  this.reason = reason;
1747
1966
  }
1748
1967
  }
1968
+ var TRANSIENT_REASONS = new Set([
1969
+ "rate_limited",
1970
+ "fetch_failed",
1971
+ "unresolvable",
1972
+ "too_large"
1973
+ ]);
1974
+ function isTransient(error) {
1975
+ return TRANSIENT_REASONS.has(error.reason);
1976
+ }
1977
+ var STALE_GRACE_MS = 60 * 60000;
1749
1978
  var MAX_CLIENT_ID_LENGTH = 2048;
1750
1979
  var MAX_NAME_LENGTH = 64;
1751
1980
  var MAX_REDIRECT_URIS = 20;
@@ -1753,6 +1982,9 @@ var MIN_CACHE_MS = 5 * 60000;
1753
1982
  var DEFAULT_CACHE_MS = 60 * 60000;
1754
1983
  var MAX_CACHE_MS = 24 * 60 * 60000;
1755
1984
  var MAX_CACHE_ENTRIES = 1000;
1985
+ var MAX_CACHE_PER_DOMAIN = 100;
1986
+ var FAILURE_CACHE_MS = 60000;
1987
+ var MAX_FAILURES = 1000;
1756
1988
  async function defaultLookup(hostname) {
1757
1989
  const records = await Bun.dns.lookup(hostname, {});
1758
1990
  return records.map((r) => r.address);
@@ -1786,12 +2018,22 @@ function limitKey(hostname) {
1786
2018
  return h;
1787
2019
  return h.split(".").slice(-2).join(".");
1788
2020
  }
2021
+ function knownBudget() {
2022
+ return {
2023
+ perHost: new RateLimiter({ capacity: 5, refillPerSecond: 0.2 }),
2024
+ perDomain: new RateLimiter({ capacity: 15, refillPerSecond: 0.6 }),
2025
+ global: new RateLimiter({ capacity: 100, refillPerSecond: 10 })
2026
+ };
2027
+ }
1789
2028
 
1790
2029
  class ClientMetadataFetcher {
1791
- #cache = new Map;
2030
+ #cache = new DomainCache(MAX_CACHE_ENTRIES, MAX_CACHE_PER_DOMAIN, (entry, now) => entry.until + STALE_GRACE_MS <= now);
2031
+ #failures = new Map;
1792
2032
  #perHost = new RateLimiter({ capacity: 5, refillPerSecond: 0.2 });
1793
2033
  #perDomain = new RateLimiter({ capacity: 15, refillPerSecond: 0.6 });
1794
2034
  #global = new RateLimiter({ capacity: 30, refillPerSecond: 1 });
2035
+ #refreshes = knownBudget();
2036
+ #keys = knownBudget();
1795
2037
  #allowedHosts;
1796
2038
  #logger;
1797
2039
  #timeoutMs;
@@ -1810,34 +2052,106 @@ class ClientMetadataFetcher {
1810
2052
  this.#testMode = options.testMode;
1811
2053
  this.#now = options.now ?? Date.now;
1812
2054
  }
1813
- async get(clientId) {
2055
+ async get(clientId, options = {}) {
1814
2056
  const url = this.#checkUrl(clientId);
1815
2057
  const now = this.#now();
1816
2058
  const cached = this.#cache.get(clientId);
1817
- if (cached !== undefined && cached.until > now) {
1818
- this.#cache.delete(clientId);
1819
- this.#cache.set(clientId, cached);
2059
+ if (cached !== undefined && cached.until > now)
1820
2060
  return cached.meta;
2061
+ const known = cached !== undefined && now < cached.until + STALE_GRACE_MS;
2062
+ const stale = options.allowStale === true && known ? cached?.meta : undefined;
2063
+ try {
2064
+ const failed = this.#failures.get(clientId);
2065
+ if (failed !== undefined && failed.until > now)
2066
+ throw failed.error;
2067
+ return await this.#refresh(clientId, url, now, known);
2068
+ } catch (error) {
2069
+ const transient = error instanceof ClientMetadataError && isTransient(error);
2070
+ if (!transient)
2071
+ this.#cache.delete(clientId);
2072
+ if (stale === undefined || !transient)
2073
+ throw error;
2074
+ this.#logger?.info("oauth.cimd_stale", {
2075
+ host: url.host,
2076
+ reason: error.reason
2077
+ });
2078
+ return stale;
2079
+ }
2080
+ }
2081
+ async#refresh(clientId, url, now, known) {
2082
+ this.#admit(url, now, known ? "refresh" : "new");
2083
+ let meta;
2084
+ let ttlMs;
2085
+ try {
2086
+ const address = await this.#vettedAddress(url);
2087
+ const response = await this.#fetchJson(url, address, [
2088
+ "application/json"
2089
+ ]);
2090
+ meta = validateDocument(response.doc, clientId, url, {
2091
+ test: this.#testMode !== undefined,
2092
+ onFallback: (reason) => this.#logger?.info("oauth.cimd_auth_fallback", {
2093
+ host: url.host,
2094
+ reason
2095
+ })
2096
+ });
2097
+ ttlMs = cacheMs(response.cacheControl);
2098
+ } catch (error) {
2099
+ if (error instanceof ClientMetadataError) {
2100
+ this.#rememberFailure(clientId, error, now);
2101
+ }
2102
+ throw error;
1821
2103
  }
2104
+ this.#failures.delete(clientId);
2105
+ this.#cache.set(clientId, limitKey(url.hostname), { meta, until: now + ttlMs }, now);
2106
+ return meta;
2107
+ }
2108
+ #rememberFailure(clientId, error, now) {
2109
+ this.#failures.delete(clientId);
2110
+ if (this.#failures.size >= MAX_FAILURES) {
2111
+ const oldest = this.#failures.keys().next().value;
2112
+ if (oldest !== undefined)
2113
+ this.#failures.delete(oldest);
2114
+ }
2115
+ this.#failures.set(clientId, { error, until: now + FAILURE_CACHE_MS });
2116
+ }
2117
+ async fetchJwks(jwksUri) {
2118
+ const url = this.#checkJwksUri(jwksUri);
2119
+ this.#admit(url, this.#now(), "keys");
2120
+ const address = await this.#vettedAddress(url);
2121
+ const response = await this.#fetchJson(url, address, [
2122
+ "application/json",
2123
+ "application/jwk-set+json"
2124
+ ]);
2125
+ return response.doc;
2126
+ }
2127
+ #admit(url, now, budget) {
2128
+ const { perHost, perDomain, global } = budget === "refresh" ? this.#refreshes : budget === "keys" ? this.#keys : {
2129
+ perHost: this.#perHost,
2130
+ perDomain: this.#perDomain,
2131
+ global: this.#global
2132
+ };
1822
2133
  const key = limitKey(url.hostname);
1823
- if (!this.#perHost.available(url.host, now) || !this.#perDomain.available(key, now)) {
2134
+ if (!perHost.available(url.host, now) || !perDomain.available(key, now)) {
1824
2135
  throw new ClientMetadataError("rate_limited", "Too many requests for this AI app's details. Try again in a minute.");
1825
2136
  }
1826
- if (!this.#global.take("all", now)) {
2137
+ if (!global.take("all", now)) {
1827
2138
  throw new ClientMetadataError("rate_limited", "The relay is busy. Try again in a minute.");
1828
2139
  }
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);
2140
+ perHost.take(url.host, now);
2141
+ perDomain.take(key, now);
2142
+ }
2143
+ #checkJwksUri(jwksUri) {
2144
+ const url = parseJwksUri(jwksUri, this.#testMode !== undefined);
2145
+ if (url === undefined) {
2146
+ throw new ClientMetadataError("invalid_jwks_uri", "The AI app's jwks_uri is not accepted.");
1838
2147
  }
1839
- this.#cache.set(clientId, { meta, until: now + ttlMs });
1840
- return meta;
2148
+ if (this.#allowedHosts.length > 0 && !this.#allowedHosts.includes(url.host)) {
2149
+ throw new ClientMetadataError("host_not_allowed", `This relay does not accept AI apps from ${url.host}.`);
2150
+ }
2151
+ if (isIpLiteral(url.hostname) && !this.#addressAllowed(url.hostname)) {
2152
+ throw this.#notLoaded("private_address", url);
2153
+ }
2154
+ return url;
1841
2155
  }
1842
2156
  #checkUrl(clientId) {
1843
2157
  const refuse = (message) => new ClientMetadataError("invalid_client_id", message);
@@ -1899,9 +2213,9 @@ class ClientMetadataFetcher {
1899
2213
  }
1900
2214
  return addresses.find((a) => parseIpv4(a) !== undefined) ?? addresses[0] ?? "";
1901
2215
  }
1902
- async#fetchDocument(url, clientId, address) {
2216
+ async#fetchJson(url, address, types) {
1903
2217
  const fail = (detail) => this.#notLoaded("fetch_failed", url, detail);
1904
- const pinned = new URL(clientId);
2218
+ const pinned = new URL(url.href);
1905
2219
  pinned.hostname = address.includes(":") ? `[${address}]` : address;
1906
2220
  const hostname = url.hostname.replace(/^\[|\]$/g, "");
1907
2221
  const tls = url.protocol === "https:" ? {
@@ -1926,8 +2240,9 @@ class ClientMetadataFetcher {
1926
2240
  if (response.status !== 200)
1927
2241
  throw fail(`status_${response.status}`);
1928
2242
  const type = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
1929
- if (type !== "application/json")
2243
+ if (type === undefined || !types.includes(type)) {
1930
2244
  throw fail("not_json");
2245
+ }
1931
2246
  const text = await this.#readLimited(response, url);
1932
2247
  let doc;
1933
2248
  try {
@@ -1935,8 +2250,7 @@ class ClientMetadataFetcher {
1935
2250
  } catch {
1936
2251
  throw fail("bad_json");
1937
2252
  }
1938
- const meta = validateDocument(doc, clientId, url);
1939
- return { meta, ttlMs: cacheMs(response.headers.get("cache-control")) };
2253
+ return { doc, cacheControl: response.headers.get("cache-control") };
1940
2254
  } finally {
1941
2255
  if (!response.bodyUsed)
1942
2256
  response.body?.cancel().catch(() => {});
@@ -1991,7 +2305,7 @@ function foreignDomain(name, hostname) {
1991
2305
  }
1992
2306
  return false;
1993
2307
  }
1994
- function validateDocument(doc, clientId, url) {
2308
+ function validateDocument(doc, clientId, url, options = {}) {
1995
2309
  const invalid = (message) => new ClientMetadataError("invalid_metadata", message);
1996
2310
  if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
1997
2311
  throw invalid("The AI app's details are not a JSON object.");
@@ -2017,11 +2331,7 @@ function validateDocument(doc, clientId, url) {
2017
2331
  if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECT_URIS || !uris.every((u) => typeof u === "string" && isRedirectUri(u))) {
2018
2332
  throw invalid("The AI app's details have no valid redirect_uris.");
2019
2333
  }
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
- }
2334
+ const auth = clientAuth(d, url, options, invalid);
2025
2335
  const grants = d["grant_types"];
2026
2336
  if (grants !== undefined && (!Array.isArray(grants) || !grants.includes("authorization_code"))) {
2027
2337
  throw invalid("The AI app's details do not allow the authorization code grant.");
@@ -2034,9 +2344,81 @@ function validateDocument(doc, clientId, url) {
2034
2344
  clientId,
2035
2345
  name: displayName(name),
2036
2346
  host: url.host,
2037
- redirectUris: uris
2347
+ redirectUris: uris,
2348
+ auth
2038
2349
  };
2039
2350
  }
2351
+ function clientAuth(d, url, options, invalid) {
2352
+ const method = d["token_endpoint_auth_method"];
2353
+ const supported = d["token_endpoint_auth_methods_supported"];
2354
+ const listsNone = Array.isArray(supported) && supported.includes("none");
2355
+ if (method === undefined || method === "none")
2356
+ return { method: "none" };
2357
+ const checked = method === "private_key_jwt" ? privateKeyJwt(d, url, options.test === true) : {
2358
+ problem: "unsupported_method",
2359
+ 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.'
2360
+ };
2361
+ if ("jwksUri" in checked) {
2362
+ return { method: "private_key_jwt", ...checked, publicAllowed: listsNone };
2363
+ }
2364
+ if (listsNone) {
2365
+ options.onFallback?.(checked.problem);
2366
+ return { method: "none" };
2367
+ }
2368
+ throw invalid(checked.message);
2369
+ }
2370
+ function privateKeyJwt(d, url, test) {
2371
+ const jwksUri = d["jwks_uri"];
2372
+ if (jwksUri === undefined) {
2373
+ return {
2374
+ problem: "no_jwks_uri",
2375
+ message: "The AI app uses private_key_jwt but publishes no jwks_uri (an inline jwks is not accepted)."
2376
+ };
2377
+ }
2378
+ if (d["jwks"] !== undefined) {
2379
+ return {
2380
+ problem: "jwks_and_jwks_uri",
2381
+ message: "The AI app's details have both jwks and jwks_uri."
2382
+ };
2383
+ }
2384
+ const parsed = typeof jwksUri === "string" ? parseJwksUri(jwksUri, test) : undefined;
2385
+ if (parsed === undefined || parsed.protocol !== url.protocol || parsed.host !== url.host) {
2386
+ return {
2387
+ problem: "bad_jwks_uri",
2388
+ message: "The AI app's jwks_uri must be an https:// URL on the host of its client_id."
2389
+ };
2390
+ }
2391
+ const alg = d["token_endpoint_auth_signing_alg"];
2392
+ if (alg !== undefined && !isAlgorithm(alg)) {
2393
+ return {
2394
+ problem: "signing_alg",
2395
+ message: "The AI app's token_endpoint_auth_signing_alg is not one this relay accepts."
2396
+ };
2397
+ }
2398
+ return {
2399
+ jwksUri: parsed.href,
2400
+ algorithms: alg === undefined ? JWT_ALGORITHMS : [alg]
2401
+ };
2402
+ }
2403
+ function parseJwksUri(value, test) {
2404
+ if (value.length > MAX_CLIENT_ID_LENGTH)
2405
+ return;
2406
+ let url;
2407
+ try {
2408
+ url = new URL(value);
2409
+ } catch {
2410
+ return;
2411
+ }
2412
+ const scheme = url.protocol === "https:" || test && url.protocol === "http:";
2413
+ if (!scheme || url.href !== value)
2414
+ return;
2415
+ if (url.username !== "" || url.password !== "" || url.hash !== "") {
2416
+ return;
2417
+ }
2418
+ if (url.port !== "" && !test)
2419
+ return;
2420
+ return url;
2421
+ }
2040
2422
  var PRIVATE_USE_SCHEME = /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+:$/;
2041
2423
  function isRedirectUri(value) {
2042
2424
  if (value.length > 2048 || value.includes("#"))
@@ -2080,6 +2462,151 @@ function redirectUriMatches(requested, registered) {
2080
2462
  return a.href === b.href;
2081
2463
  }
2082
2464
 
2465
+ // src/oauth/client-auth.ts
2466
+ var CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
2467
+ var CLIENT_ASSERTION_MAX_LIFETIME_MS = 60 * 60000;
2468
+ var CLIENT_ASSERTION_MAX_AGE_MS = 5 * 60000;
2469
+ var CLIENT_ASSERTION_LEEWAY_MS = 30000;
2470
+ var MAX_JTI2 = 256;
2471
+ var MAX_KEY_SETS = 1000;
2472
+ var MAX_KEY_SETS_PER_DOMAIN = 100;
2473
+ var SWEEP_INTERVAL_MS = 1000;
2474
+ var ACCEPTED_TYPES = new Set(["jwt", "client-authentication+jwt"]);
2475
+ function assertionSubject(assertion) {
2476
+ try {
2477
+ const sub = decodeJwt(assertion).payload["sub"];
2478
+ return typeof sub === "string" && sub !== "" ? sub : undefined;
2479
+ } catch {
2480
+ return;
2481
+ }
2482
+ }
2483
+ function nonceKey(clientId, jti) {
2484
+ return hashToken(JSON.stringify(["client_assertion", clientId, jti]));
2485
+ }
2486
+ async function verifyClientAssertion(assertion, check) {
2487
+ const { header, payload } = await verifyJwt(assertion, check.keys, {
2488
+ algorithms: check.auth.algorithms
2489
+ });
2490
+ const typ = header["typ"];
2491
+ if (typ !== undefined && (typeof typ !== "string" || !ACCEPTED_TYPES.has(typ.toLowerCase()))) {
2492
+ throw new JwtError("typ", "the assertion's type is not a client assertion");
2493
+ }
2494
+ if (payload["iss"] !== check.clientId) {
2495
+ throw new JwtError("iss", "the assertion's issuer is not the client");
2496
+ }
2497
+ if (payload["sub"] !== check.clientId) {
2498
+ throw new JwtError("sub", "the assertion's subject is not the client");
2499
+ }
2500
+ if (!audiences(payload).some((a) => check.audiences.includes(a))) {
2501
+ throw new JwtError("aud", "the assertion is not for this relay");
2502
+ }
2503
+ const usableUntil = checkTimes(payload, check.now);
2504
+ const jti = payload["jti"];
2505
+ if (typeof jti !== "string" || jti === "" || jti.length > MAX_JTI2) {
2506
+ throw new JwtError("jti", "the assertion needs a jti");
2507
+ }
2508
+ const key = nonceKey(check.clientId, jti);
2509
+ if (!await check.claimNonce(key, usableUntil, check.now)) {
2510
+ throw new JwtError("replayed", "the assertion was already used");
2511
+ }
2512
+ }
2513
+ function checkTimes(payload, now) {
2514
+ const leeway = CLIENT_ASSERTION_LEEWAY_MS;
2515
+ const exp = numericDate(payload, "exp");
2516
+ if (exp === undefined)
2517
+ throw new JwtError("exp", "the assertion has no exp");
2518
+ if (exp <= now - leeway) {
2519
+ throw new JwtError("expired", "the assertion has expired");
2520
+ }
2521
+ if (exp > now + CLIENT_ASSERTION_MAX_LIFETIME_MS + leeway) {
2522
+ throw new JwtError("lifetime", "the assertion lives longer than an hour");
2523
+ }
2524
+ const iat = numericDate(payload, "iat");
2525
+ let end = exp;
2526
+ if (iat === undefined) {
2527
+ if (exp > now + CLIENT_ASSERTION_MAX_AGE_MS + leeway) {
2528
+ throw new JwtError("lifetime", "an assertion without iat may live five minutes");
2529
+ }
2530
+ } else {
2531
+ if (iat > now + leeway) {
2532
+ throw new JwtError("iat", "the assertion was issued in the future");
2533
+ }
2534
+ if (iat + CLIENT_ASSERTION_MAX_AGE_MS <= now - leeway) {
2535
+ throw new JwtError("too_old", "the assertion was issued too long ago");
2536
+ }
2537
+ end = Math.min(exp, iat + CLIENT_ASSERTION_MAX_AGE_MS);
2538
+ }
2539
+ const nbf = numericDate(payload, "nbf");
2540
+ if (nbf !== undefined && nbf > now + leeway) {
2541
+ throw new JwtError("nbf", "the assertion is not valid yet");
2542
+ }
2543
+ return end + leeway;
2544
+ }
2545
+ function quotaKey(clientId) {
2546
+ return ipPrefixKey(new URL(clientId).hostname.replace(/\.$/, ""));
2547
+ }
2548
+
2549
+ class NonceQuota {
2550
+ #until = new Map;
2551
+ #perDomain;
2552
+ #max;
2553
+ #total = 0;
2554
+ #sweptAt = Number.NEGATIVE_INFINITY;
2555
+ constructor(perDomain, max) {
2556
+ this.#perDomain = perDomain;
2557
+ this.#max = max;
2558
+ }
2559
+ refusal(domain, now) {
2560
+ this.sweep(now);
2561
+ if ((this.#until.get(domain)?.length ?? 0) >= this.#perDomain) {
2562
+ return "domain_full";
2563
+ }
2564
+ return this.#total >= this.#max ? "full" : undefined;
2565
+ }
2566
+ add(domain, until) {
2567
+ const list = this.#until.get(domain);
2568
+ if (list === undefined)
2569
+ this.#until.set(domain, [until]);
2570
+ else
2571
+ list.push(until);
2572
+ this.#total++;
2573
+ }
2574
+ sweep(now) {
2575
+ if (now - this.#sweptAt < SWEEP_INTERVAL_MS)
2576
+ return false;
2577
+ this.#sweptAt = now;
2578
+ this.#total = 0;
2579
+ for (const [domain, list] of this.#until) {
2580
+ const live = list.filter((until) => until > now);
2581
+ if (live.length === 0)
2582
+ this.#until.delete(domain);
2583
+ else
2584
+ this.#until.set(domain, live);
2585
+ this.#total += live.length;
2586
+ }
2587
+ return true;
2588
+ }
2589
+ }
2590
+
2591
+ class ClientKeySets {
2592
+ #sets = new DomainCache(MAX_KEY_SETS, MAX_KEY_SETS_PER_DOMAIN);
2593
+ #load;
2594
+ #now;
2595
+ constructor(load, now = Date.now) {
2596
+ this.#load = load;
2597
+ this.#now = now;
2598
+ }
2599
+ for(jwksUri) {
2600
+ const known = this.#sets.get(jwksUri);
2601
+ if (known !== undefined)
2602
+ return known;
2603
+ const set = new JwksCache(jwksUri, { load: this.#load, now: this.#now });
2604
+ const domain = limitKey(new URL(jwksUri).hostname);
2605
+ this.#sets.set(jwksUri, domain, set, this.#now());
2606
+ return set;
2607
+ }
2608
+ }
2609
+
2083
2610
  // src/oauth/html.ts
2084
2611
  var ESCAPES = {
2085
2612
  "&": "&amp;",
@@ -2649,6 +3176,12 @@ var REFRESH_REUSE_WINDOW_MS = 24 * 60 * 60000;
2649
3176
  var MAX_CODES = 1e4;
2650
3177
  var MAX_FORM_BYTES = 8192;
2651
3178
  var MAX_STATE_LENGTH = 1024;
3179
+ var MAX_NONCES_PER_HOST = 1e4;
3180
+ var MAX_NONCES = 1e5;
3181
+ var CLIENT_AUTH_FAILED = "client authentication failed";
3182
+ function assertionRequired(auth) {
3183
+ return auth.method === "private_key_jwt" && !auth.publicAllowed;
3184
+ }
2652
3185
 
2653
3186
  class OAuthError extends Error {
2654
3187
  status;
@@ -2698,10 +3231,16 @@ function hostOf(url) {
2698
3231
  return url;
2699
3232
  }
2700
3233
  }
3234
+ function logHost(clientId) {
3235
+ return clientId !== undefined && URL.canParse(clientId) ? hostOf(clientId).slice(0, 100) : "(not a URL)";
3236
+ }
2701
3237
 
2702
3238
  class AuthorizationServer {
2703
3239
  #host;
2704
3240
  #cimd;
3241
+ #clientKeys;
3242
+ #nonces = new NonceSet;
3243
+ #nonceQuota = new NonceQuota(MAX_NONCES_PER_HOST, MAX_NONCES);
2705
3244
  #pending = new Map;
2706
3245
  #pendingPerIp = new Map;
2707
3246
  #pendingPerAddress = new Map;
@@ -2752,6 +3291,8 @@ class AuthorizationServer {
2752
3291
  logger: host.logger,
2753
3292
  ...testMode?.cimd === undefined ? {} : { testMode: testMode.cimd }
2754
3293
  });
3294
+ const cimd = this.#cimd;
3295
+ this.#clientKeys = new ClientKeySets((url) => cimd.fetchJwks(url));
2755
3296
  }
2756
3297
  get #issuer() {
2757
3298
  return this.#host.publicUrl();
@@ -2805,8 +3346,10 @@ class AuthorizationServer {
2805
3346
  response_modes_supported: ["query"],
2806
3347
  grant_types_supported: ["authorization_code", "refresh_token"],
2807
3348
  code_challenge_methods_supported: ["S256"],
2808
- token_endpoint_auth_methods_supported: ["none"],
2809
- revocation_endpoint_auth_methods_supported: ["none"],
3349
+ token_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
3350
+ token_endpoint_auth_signing_alg_values_supported: JWT_ALGORITHMS,
3351
+ revocation_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
3352
+ revocation_endpoint_auth_signing_alg_values_supported: JWT_ALGORITHMS,
2810
3353
  scopes_supported: [SCOPE],
2811
3354
  client_id_metadata_document_supported: true,
2812
3355
  authorization_response_iss_parameter_supported: true,
@@ -3305,20 +3848,21 @@ ${cancel}
3305
3848
  "code_verifier",
3306
3849
  "refresh_token",
3307
3850
  "resource",
3308
- "scope"
3851
+ "scope",
3852
+ "client_assertion",
3853
+ "client_assertion_type"
3309
3854
  ]);
3310
3855
  if ("duplicate" in p) {
3311
3856
  throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
3312
3857
  }
3313
- const clientId = p["client_id"];
3314
- if (clientId === undefined) {
3315
- throw new OAuthError(400, "invalid_request", "client_id is required");
3316
- }
3858
+ const client = await this.#authenticateClient(p, [
3859
+ `${this.#issuer}/token`
3860
+ ]);
3317
3861
  let body;
3318
3862
  if (p["grant_type"] === "authorization_code") {
3319
- body = await this.#codeGrant(p, clientId);
3863
+ body = await this.#codeGrant(p, client);
3320
3864
  } else if (p["grant_type"] === "refresh_token") {
3321
- body = await this.#refreshGrant(p, clientId);
3865
+ body = await this.#refreshGrant(p, client);
3322
3866
  } else if (p["grant_type"] === undefined) {
3323
3867
  throw new OAuthError(400, "invalid_request", "grant_type is required");
3324
3868
  } else {
@@ -3352,19 +3896,96 @@ ${cancel}
3352
3896
  throw new OAuthError(400, "invalid_request", "the body must be application/x-www-form-urlencoded");
3353
3897
  }
3354
3898
  if (req.headers.get("authorization") !== null) {
3355
- throw new OAuthError(401, "invalid_client", "client authentication is not supported (token_endpoint_auth_method none)");
3899
+ throw new OAuthError(401, "invalid_client", "HTTP authentication is not supported (token_endpoint_auth_method none or private_key_jwt)");
3356
3900
  }
3357
3901
  const text = await this.#readForm(req);
3358
3902
  if (text === undefined) {
3359
3903
  throw new OAuthError(413, "invalid_request", "the request is too large");
3360
3904
  }
3361
3905
  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)");
3906
+ if (params.has("client_secret")) {
3907
+ throw new OAuthError(400, "invalid_client", "client secrets are not supported (token_endpoint_auth_method none or private_key_jwt)");
3364
3908
  }
3365
3909
  return params;
3366
3910
  }
3367
- async#codeGrant(p, clientId) {
3911
+ async#authenticateClient(p, endpoints) {
3912
+ const assertion = p["client_assertion"];
3913
+ const type = p["client_assertion_type"];
3914
+ if (assertion === undefined && type === undefined) {
3915
+ const clientId = p["client_id"];
3916
+ if (clientId === undefined) {
3917
+ throw new OAuthError(400, "invalid_request", "client_id is required");
3918
+ }
3919
+ return { clientId, assertion: false };
3920
+ }
3921
+ if (assertion === undefined || type === undefined) {
3922
+ throw new OAuthError(400, "invalid_request", "client_assertion and client_assertion_type go together");
3923
+ }
3924
+ const clientId = p["client_id"] ?? assertionSubject(assertion);
3925
+ if (type !== CLIENT_ASSERTION_TYPE) {
3926
+ throw this.#clientAuthFailed(clientId, "assertion_type");
3927
+ }
3928
+ if (clientId === undefined) {
3929
+ throw this.#clientAuthFailed(clientId, "no_client_id");
3930
+ }
3931
+ let client;
3932
+ try {
3933
+ client = await this.#cimd.get(clientId, { allowStale: true });
3934
+ } catch (error) {
3935
+ if (!(error instanceof ClientMetadataError))
3936
+ throw error;
3937
+ const reason = `metadata_${error.reason}`;
3938
+ throw isTransient(error) || error.reason === "private_address" ? this.#clientAuthUnavailable(clientId, reason) : this.#clientAuthFailed(clientId, reason);
3939
+ }
3940
+ if (client.auth.method !== "private_key_jwt") {
3941
+ throw this.#clientAuthFailed(clientId, "not_private_key_jwt");
3942
+ }
3943
+ const domain = quotaKey(clientId);
3944
+ try {
3945
+ await verifyClientAssertion(assertion, {
3946
+ clientId,
3947
+ auth: client.auth,
3948
+ keys: this.#clientKeys.for(client.auth.jwksUri),
3949
+ audiences: [this.#issuer, ...endpoints],
3950
+ claimNonce: (key, until, now) => this.#claimNonce(clientId, domain, key, until, now),
3951
+ now: Date.now()
3952
+ });
3953
+ } catch (error) {
3954
+ if (!(error instanceof JwtError))
3955
+ throw error;
3956
+ throw error.reason === "jwks_unavailable" ? this.#clientAuthUnavailable(clientId, error.reason) : this.#clientAuthFailed(clientId, error.reason);
3957
+ }
3958
+ return { clientId, assertion: true };
3959
+ }
3960
+ #clientAuthFailed(clientId, reason) {
3961
+ this.#host.logger.info("oauth.client_auth_failed", {
3962
+ client: logHost(clientId),
3963
+ reason
3964
+ });
3965
+ return new OAuthError(401, "invalid_client", CLIENT_AUTH_FAILED);
3966
+ }
3967
+ #clientAuthUnavailable(clientId, reason) {
3968
+ this.#host.logger.warn("oauth.client_auth_unavailable", {
3969
+ client: logHost(clientId),
3970
+ reason
3971
+ });
3972
+ return new OAuthError(503, "temporarily_unavailable", "the client cannot be authenticated right now; try again shortly");
3973
+ }
3974
+ async#claimNonce(clientId, domain, key, until, now) {
3975
+ if (this.#nonceQuota.sweep(now))
3976
+ this.#nonces.sweep(now);
3977
+ const refusal = this.#nonceQuota.refusal(domain, now);
3978
+ if (refusal !== undefined) {
3979
+ throw this.#clientAuthUnavailable(clientId, `nonces_${refusal}`);
3980
+ }
3981
+ const store = this.#host.store;
3982
+ const claimed = store.claimNonce === undefined ? this.#nonces.claim(key, until, now) : await store.claimNonce(key, until, now);
3983
+ if (claimed)
3984
+ this.#nonceQuota.add(domain, until);
3985
+ return claimed;
3986
+ }
3987
+ async#codeGrant(p, client) {
3988
+ const clientId = client.clientId;
3368
3989
  const code = p["code"];
3369
3990
  const redirectUri = p["redirect_uri"];
3370
3991
  const verifier = p["code_verifier"];
@@ -3376,6 +3997,12 @@ ${cancel}
3376
3997
  const invalid = (why) => new OAuthError(400, "invalid_grant", why);
3377
3998
  if (issued === undefined)
3378
3999
  throw invalid("the code is not valid");
4000
+ if (!constantTimeEqual(clientId, issued.client.clientId)) {
4001
+ throw invalid("the code was issued to another client");
4002
+ }
4003
+ if (assertionRequired(issued.client.auth) && !client.assertion) {
4004
+ throw this.#clientAuthFailed(clientId, "assertion_required");
4005
+ }
3379
4006
  if (issued.used) {
3380
4007
  if (issued.grantId !== undefined) {
3381
4008
  await this.#revokeGrant(issued.addressId, issued.grantId, "reuse_detected");
@@ -3385,9 +4012,6 @@ ${cancel}
3385
4012
  issued.used = true;
3386
4013
  if (issued.expiresAt <= Date.now())
3387
4014
  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
4015
  if (redirectUri !== issued.redirectUri) {
3392
4016
  throw invalid("redirect_uri does not match the authorization request");
3393
4017
  }
@@ -3411,7 +4035,8 @@ ${cancel}
3411
4035
  clientName: issued.client.name,
3412
4036
  scopes: issued.scopes,
3413
4037
  grantCreatedAt: now,
3414
- ...issued.subject === undefined ? {} : { subject: issued.subject }
4038
+ ...issued.subject === undefined ? {} : { subject: issued.subject },
4039
+ ...client.assertion ? { clientAuth: "private_key_jwt" } : {}
3415
4040
  }, {
3416
4041
  accessTtlMs: config.accessTokenTtlMs,
3417
4042
  refreshTtlMs: config.refreshTokenTtlMs,
@@ -3445,7 +4070,8 @@ ${cancel}
3445
4070
  scope: scopes.join(" ")
3446
4071
  };
3447
4072
  }
3448
- async#refreshGrant(p, clientId) {
4073
+ async#refreshGrant(p, client) {
4074
+ const clientId = client.clientId;
3449
4075
  const token = p["refresh_token"];
3450
4076
  if (token === undefined) {
3451
4077
  throw new OAuthError(400, "invalid_request", "refresh_token is required");
@@ -3459,6 +4085,9 @@ ${cancel}
3459
4085
  if (!constantTimeEqual(clientId, record.clientId)) {
3460
4086
  throw invalid("the refresh token was issued to another client");
3461
4087
  }
4088
+ if (record.clientAuth === "private_key_jwt" && !client.assertion) {
4089
+ throw this.#clientAuthFailed(clientId, "assertion_required");
4090
+ }
3462
4091
  const now = Date.now();
3463
4092
  if (record.expiresAt <= now)
3464
4093
  throw invalid("the refresh token has expired");
@@ -3490,7 +4119,8 @@ ${cancel}
3490
4119
  clientName: record.clientName,
3491
4120
  scopes: record.scopes,
3492
4121
  grantCreatedAt: record.grantCreatedAt,
3493
- ...record.subject === undefined ? {} : { subject: record.subject }
4122
+ ...record.subject === undefined ? {} : { subject: record.subject },
4123
+ ...record.clientAuth === "private_key_jwt" || client.assertion ? { clientAuth: "private_key_jwt" } : {}
3494
4124
  }, {
3495
4125
  accessTtlMs: this.#host.config.accessTokenTtlMs,
3496
4126
  refreshTtlMs: this.#host.config.refreshTokenTtlMs,
@@ -3526,24 +4156,36 @@ ${cancel}
3526
4156
  throw new OAuthError(429, "temporarily_unavailable", "too many requests; try again shortly");
3527
4157
  }
3528
4158
  const params = await this.#formParams(req);
3529
- const p = single(params, ["token", "client_id", "token_type_hint"]);
4159
+ const p = single(params, [
4160
+ "token",
4161
+ "client_id",
4162
+ "token_type_hint",
4163
+ "client_assertion",
4164
+ "client_assertion_type"
4165
+ ]);
3530
4166
  if ("duplicate" in p) {
3531
4167
  throw new OAuthError(400, "invalid_request", `${p.duplicate} was sent more than once`);
3532
4168
  }
3533
4169
  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");
4170
+ if (token === undefined) {
4171
+ throw new OAuthError(400, "invalid_request", "token is required");
3537
4172
  }
4173
+ const client = await this.#authenticateClient(p, [
4174
+ `${this.#issuer}/revoke`,
4175
+ `${this.#issuer}/token`
4176
+ ]);
3538
4177
  const record = await this.#host.store.get(hashToken(token));
3539
4178
  if (record === undefined)
3540
4179
  return new Response(null, {
3541
4180
  status: 200,
3542
4181
  headers: { ...this.#cors(), ...noStore }
3543
4182
  });
3544
- if (!constantTimeEqual(clientId, record.clientId)) {
4183
+ if (!constantTimeEqual(client.clientId, record.clientId)) {
3545
4184
  throw new OAuthError(400, "invalid_client", "the token was issued to another client");
3546
4185
  }
4186
+ if (record.clientAuth === "private_key_jwt" && !client.assertion) {
4187
+ throw this.#clientAuthFailed(client.clientId, "assertion_required");
4188
+ }
3547
4189
  await this.#revokeGrant(record.addressId, record.grantId, "revoked");
3548
4190
  this.#host.logger.info("oauth.revoked", {
3549
4191
  address: shortAddress(record.addressId),
@@ -3589,6 +4231,7 @@ ${cancel}
3589
4231
  this.#guessesPerAddress.sweep(now);
3590
4232
  this.#tokenPerIp.sweep(now);
3591
4233
  this.#refreshesPerGrant.sweep(now);
4234
+ this.#nonces.sweep(now);
3592
4235
  }
3593
4236
  }
3594
4237
 
@@ -3997,112 +4640,10 @@ class TabSession {
3997
4640
  }
3998
4641
  }
3999
4642
 
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
4643
  // src/store/sqlite.ts
4104
4644
  import { Database } from "bun:sqlite";
4105
- var SCHEMA_VERSION = 4;
4645
+ var SCHEMA_VERSION = 5;
4646
+ var MIN_READER_VERSION = 5;
4106
4647
  var REUSE_WINDOW_MS = 86400000;
4107
4648
  function toRecord(row) {
4108
4649
  const record = {
@@ -4121,6 +4662,8 @@ function toRecord(row) {
4121
4662
  record.usedAt = row.used_at;
4122
4663
  if (row.subject !== null)
4123
4664
  record.subject = row.subject;
4665
+ if (row.client_auth === "private_key_jwt")
4666
+ record.clientAuth = "private_key_jwt";
4124
4667
  return record;
4125
4668
  }
4126
4669
 
@@ -4131,7 +4674,24 @@ class SqliteTokenStore {
4131
4674
  this.#db.run("PRAGMA busy_timeout = 5000");
4132
4675
  if (path !== ":memory:")
4133
4676
  this.#db.run("PRAGMA journal_mode = WAL");
4677
+ this.#checkReader();
4134
4678
  this.#migrate();
4679
+ this.#db.run(`CREATE TABLE IF NOT EXISTS meta (
4680
+ key TEXT PRIMARY KEY,
4681
+ value INTEGER NOT NULL
4682
+ ) STRICT`);
4683
+ this.#db.query(`INSERT INTO meta (key, value) VALUES ('min_reader_version', $v)
4684
+ ON CONFLICT (key) DO UPDATE SET value = max(value, excluded.value)`).run({ v: MIN_READER_VERSION });
4685
+ }
4686
+ #checkReader() {
4687
+ const table = this.#db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
4688
+ if (table === null)
4689
+ return;
4690
+ const row = this.#db.query("SELECT value FROM meta WHERE key = 'min_reader_version'").get();
4691
+ if (row !== null && row.value > SCHEMA_VERSION) {
4692
+ this.#db.close();
4693
+ throw new Error(`the token store file needs a newer relay (schema ${row.value}; this relay knows ${SCHEMA_VERSION})`);
4694
+ }
4135
4695
  }
4136
4696
  #migrate() {
4137
4697
  const row = this.#db.query("PRAGMA user_version").get();
@@ -4157,12 +4717,20 @@ class SqliteTokenStore {
4157
4717
  issued_at INTEGER NOT NULL,
4158
4718
  expires_at INTEGER NOT NULL,
4159
4719
  used_at INTEGER,
4160
- subject TEXT
4720
+ subject TEXT,
4721
+ client_auth TEXT
4161
4722
  ) STRICT`);
4162
4723
  const columns = this.#db.query("PRAGMA table_info(tokens)").all();
4163
4724
  if (!columns.some((c) => c.name === "subject")) {
4164
4725
  this.#db.run("ALTER TABLE tokens ADD COLUMN subject TEXT");
4165
4726
  }
4727
+ if (!columns.some((c) => c.name === "client_auth")) {
4728
+ this.#db.run("ALTER TABLE tokens ADD COLUMN client_auth TEXT");
4729
+ }
4730
+ this.#db.run(`CREATE TABLE IF NOT EXISTS nonces (
4731
+ key TEXT PRIMARY KEY,
4732
+ expires_at INTEGER NOT NULL
4733
+ ) STRICT`);
4166
4734
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
4167
4735
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
4168
4736
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
@@ -4187,9 +4755,11 @@ class SqliteTokenStore {
4187
4755
  }
4188
4756
  #insert(r) {
4189
4757
  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)
4758
+ client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject,
4759
+ client_auth)
4191
4760
  VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
4192
- $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject)`).run({
4761
+ $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject,
4762
+ $client_auth)`).run({
4193
4763
  hash: r.hash,
4194
4764
  kind: r.kind,
4195
4765
  grant_id: r.grantId,
@@ -4201,7 +4771,8 @@ class SqliteTokenStore {
4201
4771
  issued_at: r.issuedAt,
4202
4772
  expires_at: r.expiresAt,
4203
4773
  used_at: r.usedAt ?? null,
4204
- subject: r.subject ?? null
4774
+ subject: r.subject ?? null,
4775
+ client_auth: r.clientAuth ?? null
4205
4776
  });
4206
4777
  }
4207
4778
  async get(hash) {
@@ -4237,8 +4808,15 @@ class SqliteTokenStore {
4237
4808
  }
4238
4809
  async deleteExpired(now) {
4239
4810
  this.#db.query("DELETE FROM revocations WHERE keep_until <= $now").run({ now });
4811
+ this.#db.query("DELETE FROM nonces WHERE expires_at <= $now").run({ now });
4240
4812
  return this.#db.query("DELETE FROM tokens WHERE expires_at <= $now").run({ now }).changes;
4241
4813
  }
4814
+ async claimNonce(key, expiresAt, now) {
4815
+ const result = this.#db.query(`INSERT INTO nonces (key, expires_at) VALUES ($key, $expires_at)
4816
+ ON CONFLICT (key) DO UPDATE SET expires_at = excluded.expires_at
4817
+ WHERE nonces.expires_at <= $now`).run({ key, expires_at: expiresAt, now });
4818
+ return result.changes === 1;
4819
+ }
4242
4820
  async close() {
4243
4821
  this.#db.close();
4244
4822
  }
@@ -5206,5 +5784,5 @@ export {
5206
5784
  resolveConfig
5207
5785
  };
5208
5786
 
5209
- //# debugId=BA745D07E3DF06AF64756E2164756E21
5787
+ //# debugId=61513056506259C164756E2164756E21
5210
5788
  //# sourceMappingURL=index.js.map