@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/README.md +22 -3
- package/index.js +775 -170
- package/index.js.map +12 -10
- package/jwt.d.ts +10 -2
- package/main.js +775 -170
- package/main.js.map +12 -10
- package/oauth/cimd.d.ts +49 -4
- package/oauth/client-auth.d.ts +86 -0
- package/oauth/domain-cache.d.ts +24 -0
- package/oauth/server.d.ts +1 -0
- package/package.json +2 -2
- package/relay.d.ts +6 -0
- package/store/memory.d.ts +13 -0
- package/store/sqlite.d.ts +2 -0
- package/store/types.d.ts +17 -0
- package/testing.d.ts +33 -2
- package/testing.js +5863 -1
- package/testing.js.map +28 -4
- package/tokens.d.ts +4 -1
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.
|
|
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.#
|
|
1190
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
this.#
|
|
1840
|
-
if (
|
|
1841
|
-
|
|
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.#
|
|
1846
|
-
|
|
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#
|
|
2242
|
+
async#fetchJson(url, address, types) {
|
|
1909
2243
|
const fail = (detail) => this.#notLoaded("fetch_failed", url, detail);
|
|
1910
|
-
const pinned = new URL(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
"&": "&",
|
|
@@ -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
|
-
|
|
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
|
|
3320
|
-
|
|
3321
|
-
|
|
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,
|
|
3889
|
+
body = await this.#codeGrant(p, client);
|
|
3326
3890
|
} else if (p["grant_type"] === "refresh_token") {
|
|
3327
|
-
body = await this.#refreshGrant(p,
|
|
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", "
|
|
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")
|
|
3369
|
-
throw new OAuthError(400, "invalid_client", "client
|
|
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#
|
|
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,
|
|
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, [
|
|
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
|
-
|
|
3541
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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=
|
|
5896
|
+
//# debugId=D1FDCC539AD03DF264756E2164756E21
|
|
5292
5897
|
//# sourceMappingURL=main.js.map
|