@better-auth/api-key 1.6.15 → 1.6.17
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/dist/client.mjs +1 -1
- package/dist/index.mjs +298 -116
- package/dist/{version-brpjhtFx.mjs → version-ZL7h_G4d.mjs} +1 -1
- package/package.json +6 -6
package/dist/client.mjs
CHANGED
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as API_KEY_ERROR_CODES, t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { n as API_KEY_ERROR_CODES, t as PACKAGE_VERSION } from "./version-ZL7h_G4d.mjs";
|
|
2
2
|
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
|
|
3
3
|
import { base64Url } from "@better-auth/utils/base64";
|
|
4
4
|
import { createHash } from "@better-auth/utils/hash";
|
|
@@ -167,21 +167,47 @@ async function getApiKeyByIdFromStorage(ctx, id, storage) {
|
|
|
167
167
|
return deserializeApiKey(await storage.get(key));
|
|
168
168
|
}
|
|
169
169
|
/**
|
|
170
|
+
* Serializes reference-list mutations per `refKey` within a single process.
|
|
171
|
+
* Each new mutation chains onto the previous one for the same key, so the
|
|
172
|
+
* read/modify/write below never interleaves with another mutation of the same
|
|
173
|
+
* list. This closes the lost-update race in secondary-storage-only mode, where
|
|
174
|
+
* the serialized list is the source of truth for listing.
|
|
175
|
+
*
|
|
176
|
+
* Limitation: the lock is in-process only. Across multiple server instances
|
|
177
|
+
* sharing one secondary storage, concurrent writers can still lose updates.
|
|
178
|
+
* Secondary storage with `fallbackToDatabase` avoids this by treating the list
|
|
179
|
+
* as an invalidate-only cache with the database as the source of truth.
|
|
180
|
+
* FIXME(api-key-reflist-durable): on `next`, drop the source-of-truth reference
|
|
181
|
+
* list entirely and make the database authoritative for listing, removing this
|
|
182
|
+
* in-process lock.
|
|
183
|
+
*/
|
|
184
|
+
const refListLocks = /* @__PURE__ */ new Map();
|
|
185
|
+
function withRefListLock(refKey, task) {
|
|
186
|
+
const run = (refListLocks.get(refKey) ?? Promise.resolve()).then(task, task);
|
|
187
|
+
const tracked = run.finally(() => {
|
|
188
|
+
if (refListLocks.get(refKey) === tracked) refListLocks.delete(refKey);
|
|
189
|
+
});
|
|
190
|
+
refListLocks.set(refKey, tracked);
|
|
191
|
+
return run;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
170
194
|
* Read-modify-write the ref list:
|
|
171
195
|
* used only when the list is the source of truth.
|
|
172
196
|
*/
|
|
173
197
|
async function modifyRefList(storage, refKey, modify) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
198
|
+
await withRefListLock(refKey, async () => {
|
|
199
|
+
const refListData = await storage.get(refKey);
|
|
200
|
+
let keyIds = [];
|
|
201
|
+
if (refListData && typeof refListData === "string") try {
|
|
202
|
+
keyIds = JSON.parse(refListData);
|
|
203
|
+
} catch {
|
|
204
|
+
keyIds = [];
|
|
205
|
+
}
|
|
206
|
+
else if (Array.isArray(refListData)) keyIds = refListData;
|
|
207
|
+
const next = modify(keyIds);
|
|
208
|
+
if (next.length === 0) await storage.delete(refKey);
|
|
209
|
+
else await storage.set(refKey, JSON.stringify(next));
|
|
210
|
+
});
|
|
185
211
|
}
|
|
186
212
|
async function setApiKeyInStorage(_ctx, apiKey, storage, ttl, opts) {
|
|
187
213
|
const serialized = serializeApiKey(apiKey);
|
|
@@ -720,7 +746,7 @@ function createApiKey({ defaultKeyGenerator, configurations, schema, deleteAllEx
|
|
|
720
746
|
const { configId, name, expiresIn, prefix, remaining, metadata, refillAmount, refillInterval, permissions, rateLimitMax, rateLimitTimeWindow, rateLimitEnabled } = ctx.body;
|
|
721
747
|
const opts = resolveConfiguration(ctx.context, configurations, configId);
|
|
722
748
|
const keyGenerator = opts.customKeyGenerator || defaultKeyGenerator;
|
|
723
|
-
const session = await getSessionFromCtx(ctx);
|
|
749
|
+
const session = await getSessionFromCtx(ctx, { disableCookieCache: true });
|
|
724
750
|
const isClientRequest = ctx.request || ctx.headers;
|
|
725
751
|
if (isClientRequest && (refillAmount !== void 0 || refillInterval !== void 0 || rateLimitMax !== void 0 || rateLimitTimeWindow !== void 0 || rateLimitEnabled !== void 0 || permissions !== void 0 || remaining !== null)) throw APIError$1.from("BAD_REQUEST", API_KEY_ERROR_CODES.SERVER_ONLY_PROPERTY);
|
|
726
752
|
if (ctx.request && ctx.body.userId !== void 0) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.UNAUTHORIZED_SESSION);
|
|
@@ -852,7 +878,7 @@ function createApiKey({ defaultKeyGenerator, configurations, schema, deleteAllEx
|
|
|
852
878
|
//#endregion
|
|
853
879
|
//#region src/routes/delete-all-expired-api-keys.ts
|
|
854
880
|
function deleteAllExpiredApiKeysEndpoint({ deleteAllExpiredApiKeys }) {
|
|
855
|
-
return createAuthEndpoint({ method: "POST" }, async (ctx) => {
|
|
881
|
+
return createAuthEndpoint.serverOnly({ method: "POST" }, async (ctx) => {
|
|
856
882
|
try {
|
|
857
883
|
await deleteAllExpiredApiKeys(ctx.context, true);
|
|
858
884
|
} catch (error) {
|
|
@@ -1467,7 +1493,7 @@ function updateApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
|
|
|
1467
1493
|
} }
|
|
1468
1494
|
}, async (ctx) => {
|
|
1469
1495
|
const { configId, keyId, expiresIn, enabled, metadata, refillAmount, refillInterval, remaining, name, permissions, rateLimitEnabled, rateLimitTimeWindow, rateLimitMax } = ctx.body;
|
|
1470
|
-
const session = await getSessionFromCtx(ctx);
|
|
1496
|
+
const session = await getSessionFromCtx(ctx, { disableCookieCache: true });
|
|
1471
1497
|
const authRequired = ctx.request || ctx.headers;
|
|
1472
1498
|
const user = authRequired && !session ? null : session?.user || { id: ctx.body.userId };
|
|
1473
1499
|
if (!user?.id) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.UNAUTHORIZED_SESSION);
|
|
@@ -1565,75 +1591,54 @@ function updateApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
|
|
|
1565
1591
|
//#endregion
|
|
1566
1592
|
//#region src/rate-limit.ts
|
|
1567
1593
|
/**
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
* a message and updated ApiKey data.
|
|
1594
|
+
* Decides how the current request affects the per-key rate-limit counter, based
|
|
1595
|
+
* on the read-in-memory ApiKey. The verify route applies the result atomically;
|
|
1596
|
+
* this function performs no writes.
|
|
1572
1597
|
*/
|
|
1573
|
-
function
|
|
1598
|
+
function evaluateRateLimit(apiKey, opts) {
|
|
1574
1599
|
const now = /* @__PURE__ */ new Date();
|
|
1575
1600
|
const lastRequest = apiKey.lastRequest;
|
|
1576
1601
|
const rateLimitTimeWindow = apiKey.rateLimitTimeWindow;
|
|
1577
1602
|
const rateLimitMax = apiKey.rateLimitMax;
|
|
1578
|
-
let requestCount = apiKey.requestCount;
|
|
1579
1603
|
if (opts.rateLimit.enabled === false) return {
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
update: { lastRequest: now },
|
|
1583
|
-
tryAgainIn: null
|
|
1604
|
+
type: "skip",
|
|
1605
|
+
lastRequest: now
|
|
1584
1606
|
};
|
|
1585
1607
|
if (apiKey.rateLimitEnabled === false) return {
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
update: { lastRequest: now },
|
|
1589
|
-
tryAgainIn: null
|
|
1608
|
+
type: "skip",
|
|
1609
|
+
lastRequest: now
|
|
1590
1610
|
};
|
|
1591
1611
|
if (rateLimitTimeWindow === null || rateLimitMax === null) return {
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
update: null,
|
|
1595
|
-
tryAgainIn: null
|
|
1612
|
+
type: "skip",
|
|
1613
|
+
lastRequest: null
|
|
1596
1614
|
};
|
|
1597
1615
|
if (lastRequest === null) return {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
update: {
|
|
1601
|
-
lastRequest: now,
|
|
1602
|
-
requestCount: 1
|
|
1603
|
-
},
|
|
1604
|
-
tryAgainIn: null
|
|
1616
|
+
type: "start",
|
|
1617
|
+
now
|
|
1605
1618
|
};
|
|
1606
1619
|
const timeSinceLastRequest = now.getTime() - new Date(lastRequest).getTime();
|
|
1607
1620
|
if (timeSinceLastRequest > rateLimitTimeWindow) return {
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
lastRequest: now,
|
|
1612
|
-
requestCount: 1
|
|
1613
|
-
},
|
|
1614
|
-
tryAgainIn: null
|
|
1621
|
+
type: "reset",
|
|
1622
|
+
now,
|
|
1623
|
+
windowStart: new Date(now.getTime() - rateLimitTimeWindow)
|
|
1615
1624
|
};
|
|
1616
|
-
if (requestCount >= rateLimitMax) return {
|
|
1617
|
-
|
|
1625
|
+
if (apiKey.requestCount >= rateLimitMax) return {
|
|
1626
|
+
type: "deny",
|
|
1618
1627
|
message: API_KEY_ERROR_CODES.RATE_LIMIT_EXCEEDED.message,
|
|
1619
|
-
update: null,
|
|
1620
1628
|
tryAgainIn: Math.ceil(rateLimitTimeWindow - timeSinceLastRequest)
|
|
1621
1629
|
};
|
|
1622
|
-
requestCount++;
|
|
1623
1630
|
return {
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
lastRequest: now,
|
|
1629
|
-
requestCount
|
|
1630
|
-
}
|
|
1631
|
+
type: "increment",
|
|
1632
|
+
now,
|
|
1633
|
+
max: rateLimitMax,
|
|
1634
|
+
windowStart: new Date(now.getTime() - rateLimitTimeWindow)
|
|
1631
1635
|
};
|
|
1632
1636
|
}
|
|
1633
1637
|
//#endregion
|
|
1634
1638
|
//#region src/routes/verify-api-key.ts
|
|
1635
1639
|
async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, permissions, expectedConfigId, runCustomValidator }) {
|
|
1636
|
-
const
|
|
1640
|
+
const hashedKey = lookupOpts.disableKeyHashing ? key : await defaultKeyHasher(key);
|
|
1641
|
+
const apiKey = await getApiKey$1(ctx, hashedKey, lookupOpts);
|
|
1637
1642
|
if (!apiKey) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
|
|
1638
1643
|
if (expectedConfigId !== void 0 && !configIdMatches(apiKey.configId, expectedConfigId)) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
|
|
1639
1644
|
const opts = resolveConfiguration(ctx.context, configurations, apiKey.configId);
|
|
@@ -1677,8 +1682,6 @@ async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, pe
|
|
|
1677
1682
|
if (!apiKeyPermissions) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
|
|
1678
1683
|
if (!role(apiKeyPermissions).authorize(permissions).success) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
|
|
1679
1684
|
}
|
|
1680
|
-
let remaining = apiKey.remaining;
|
|
1681
|
-
let lastRefillAt = apiKey.lastRefillAt;
|
|
1682
1685
|
if (apiKey.remaining === 0 && apiKey.refillAmount === null) {
|
|
1683
1686
|
const deleteExhaustedKey = async () => {
|
|
1684
1687
|
if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
|
|
@@ -1704,78 +1707,257 @@ async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, pe
|
|
|
1704
1707
|
}));
|
|
1705
1708
|
else await deleteExhaustedKey();
|
|
1706
1709
|
throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1710
|
+
}
|
|
1711
|
+
return {
|
|
1712
|
+
apiKey: opts.storage === "database" || opts.storage === "secondary-storage" && opts.fallbackToDatabase ? await claimUsageInDatabase({
|
|
1713
|
+
ctx,
|
|
1714
|
+
apiKey,
|
|
1715
|
+
opts,
|
|
1716
|
+
hashedKey
|
|
1717
|
+
}) : await claimUsageInSecondaryStorage({
|
|
1718
|
+
ctx,
|
|
1719
|
+
apiKey,
|
|
1720
|
+
opts,
|
|
1721
|
+
hashedKey
|
|
1722
|
+
}),
|
|
1723
|
+
opts
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Atomically consume quota and a rate-limit slot against the database row, the
|
|
1728
|
+
* source of truth for `database` and `secondary-storage` + `fallbackToDatabase`
|
|
1729
|
+
* modes. Each guarded `incrementOne` only mutates the row while the guard still
|
|
1730
|
+
* holds, so concurrent verifications cannot drive `remaining` below zero or push
|
|
1731
|
+
* `requestCount` past the configured max. The cache (when present) is refreshed
|
|
1732
|
+
* from the resulting row.
|
|
1733
|
+
*/
|
|
1734
|
+
async function claimUsageInDatabase({ ctx, apiKey, opts, hashedKey }) {
|
|
1735
|
+
let row = apiKey;
|
|
1736
|
+
if (apiKey.remaining !== null) row = await consumeRemaining(ctx, apiKey);
|
|
1737
|
+
row = await consumeRateLimit(ctx, row, opts);
|
|
1738
|
+
const finalRow = await ctx.context.adapter.update({
|
|
1739
|
+
model: API_KEY_TABLE_NAME,
|
|
1740
|
+
where: [{
|
|
1741
|
+
field: "id",
|
|
1742
|
+
value: row.id
|
|
1743
|
+
}],
|
|
1744
|
+
update: { updatedAt: /* @__PURE__ */ new Date() }
|
|
1745
|
+
});
|
|
1746
|
+
if (!finalRow) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
|
|
1747
|
+
if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) await setApiKey(ctx, finalRow, opts);
|
|
1748
|
+
return finalRow;
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Guarded quota consumption. When a refill is due, exactly one verification wins
|
|
1752
|
+
* the refill (compare-and-swap on the observed `lastRefillAt`); any concurrent
|
|
1753
|
+
* verification falls through to the plain guarded decrement against the refilled
|
|
1754
|
+
* value. The decrement only applies while `remaining > 0`, so it can never go
|
|
1755
|
+
* negative. Returns the updated row; throws when the quota is exhausted.
|
|
1756
|
+
*/
|
|
1757
|
+
async function consumeRemaining(ctx, apiKey) {
|
|
1758
|
+
const now = /* @__PURE__ */ new Date();
|
|
1759
|
+
const { refillInterval, refillAmount } = apiKey;
|
|
1760
|
+
if (refillInterval && refillAmount) {
|
|
1761
|
+
const lastTime = new Date(apiKey.lastRefillAt ?? apiKey.createdAt).getTime();
|
|
1762
|
+
if (now.getTime() - lastTime > refillInterval) {
|
|
1763
|
+
const refilled = await ctx.context.adapter.incrementOne({
|
|
1764
|
+
model: API_KEY_TABLE_NAME,
|
|
1765
|
+
where: [{
|
|
1766
|
+
field: "id",
|
|
1767
|
+
value: apiKey.id
|
|
1768
|
+
}, {
|
|
1769
|
+
field: "lastRefillAt",
|
|
1770
|
+
value: apiKey.lastRefillAt
|
|
1771
|
+
}],
|
|
1772
|
+
increment: {},
|
|
1773
|
+
set: {
|
|
1774
|
+
remaining: refillAmount - 1,
|
|
1775
|
+
lastRefillAt: now
|
|
1776
|
+
}
|
|
1777
|
+
});
|
|
1778
|
+
if (refilled) return refilled;
|
|
1717
1779
|
}
|
|
1718
|
-
if (remaining === 0) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
|
|
1719
|
-
else remaining--;
|
|
1720
1780
|
}
|
|
1721
|
-
const
|
|
1722
|
-
|
|
1723
|
-
|
|
1781
|
+
const decremented = await ctx.context.adapter.incrementOne({
|
|
1782
|
+
model: API_KEY_TABLE_NAME,
|
|
1783
|
+
where: [{
|
|
1784
|
+
field: "id",
|
|
1785
|
+
value: apiKey.id
|
|
1786
|
+
}, {
|
|
1787
|
+
field: "remaining",
|
|
1788
|
+
operator: "gt",
|
|
1789
|
+
value: 0
|
|
1790
|
+
}],
|
|
1791
|
+
increment: { remaining: -1 }
|
|
1792
|
+
});
|
|
1793
|
+
if (!decremented) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
|
|
1794
|
+
return decremented;
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Guarded rate-limit consumption. The common in-window path increments
|
|
1798
|
+
* `requestCount` only while it is below the max (compare-and-swap), so a burst
|
|
1799
|
+
* of concurrent verifications can never exceed the limit. Window resets and the
|
|
1800
|
+
* first request in a window are guarded conditional sets; a request that loses
|
|
1801
|
+
* every guard within an active window is rejected. Returns the updated row, or
|
|
1802
|
+
* the unchanged row when rate limiting does not apply.
|
|
1803
|
+
*/
|
|
1804
|
+
async function consumeRateLimit(ctx, apiKey, opts) {
|
|
1805
|
+
const decision = evaluateRateLimit(apiKey, opts);
|
|
1806
|
+
if (decision.type === "deny") throw new APIError$1("TOO_MANY_REQUESTS", {
|
|
1807
|
+
message: decision.message,
|
|
1724
1808
|
code: "RATE_LIMITED",
|
|
1725
|
-
details: { tryAgainIn }
|
|
1809
|
+
details: { tryAgainIn: decision.tryAgainIn }
|
|
1726
1810
|
});
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
lastRefillAt,
|
|
1732
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1733
|
-
};
|
|
1734
|
-
const performUpdate = async () => {
|
|
1735
|
-
if (opts.storage === "database") return ctx.context.adapter.update({
|
|
1736
|
-
model: API_KEY_TABLE_NAME,
|
|
1811
|
+
if (decision.type === "skip") {
|
|
1812
|
+
if (decision.lastRequest === null) return apiKey;
|
|
1813
|
+
return await ctx.context.adapter.update({
|
|
1814
|
+
model: "apikey",
|
|
1737
1815
|
where: [{
|
|
1738
1816
|
field: "id",
|
|
1739
1817
|
value: apiKey.id
|
|
1740
1818
|
}],
|
|
1741
|
-
update: {
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
where: [{
|
|
1819
|
+
update: { lastRequest: decision.lastRequest }
|
|
1820
|
+
}) ?? apiKey;
|
|
1821
|
+
}
|
|
1822
|
+
if (decision.type === "increment") {
|
|
1823
|
+
const incremented = await ctx.context.adapter.incrementOne({
|
|
1824
|
+
model: API_KEY_TABLE_NAME,
|
|
1825
|
+
where: [
|
|
1826
|
+
{
|
|
1750
1827
|
field: "id",
|
|
1751
1828
|
value: apiKey.id
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1829
|
+
},
|
|
1830
|
+
{
|
|
1831
|
+
field: "lastRequest",
|
|
1832
|
+
operator: "gt",
|
|
1833
|
+
value: decision.windowStart
|
|
1834
|
+
},
|
|
1835
|
+
{
|
|
1836
|
+
field: "requestCount",
|
|
1837
|
+
operator: "lt",
|
|
1838
|
+
value: decision.max
|
|
1756
1839
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
|
|
1840
|
+
],
|
|
1841
|
+
increment: { requestCount: 1 },
|
|
1842
|
+
set: { lastRequest: decision.now }
|
|
1843
|
+
});
|
|
1844
|
+
if (incremented) return incremented;
|
|
1845
|
+
const fresh = await ctx.context.adapter.findOne({
|
|
1846
|
+
model: API_KEY_TABLE_NAME,
|
|
1847
|
+
where: [{
|
|
1848
|
+
field: "id",
|
|
1849
|
+
value: apiKey.id
|
|
1850
|
+
}]
|
|
1851
|
+
});
|
|
1852
|
+
if (!fresh) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
|
|
1853
|
+
return consumeRateLimit(ctx, fresh, opts);
|
|
1854
|
+
}
|
|
1855
|
+
const windowGuard = decision.type === "reset" ? {
|
|
1856
|
+
field: "lastRequest",
|
|
1857
|
+
operator: "lte",
|
|
1858
|
+
value: decision.windowStart
|
|
1859
|
+
} : {
|
|
1860
|
+
field: "lastRequest",
|
|
1861
|
+
operator: "eq",
|
|
1862
|
+
value: null
|
|
1863
|
+
};
|
|
1864
|
+
const started = await ctx.context.adapter.incrementOne({
|
|
1865
|
+
model: API_KEY_TABLE_NAME,
|
|
1866
|
+
where: [{
|
|
1867
|
+
field: "id",
|
|
1868
|
+
value: apiKey.id
|
|
1869
|
+
}, windowGuard],
|
|
1870
|
+
increment: {},
|
|
1871
|
+
set: {
|
|
1872
|
+
requestCount: 1,
|
|
1873
|
+
lastRequest: decision.now
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
if (started) return started;
|
|
1877
|
+
const fresh = await ctx.context.adapter.findOne({
|
|
1878
|
+
model: API_KEY_TABLE_NAME,
|
|
1879
|
+
where: [{
|
|
1880
|
+
field: "id",
|
|
1881
|
+
value: apiKey.id
|
|
1882
|
+
}]
|
|
1883
|
+
});
|
|
1884
|
+
if (!fresh) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
|
|
1885
|
+
return consumeRateLimit(ctx, fresh, opts);
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Secondary-storage-only mode has no database row to guard, so quota and
|
|
1889
|
+
* rate-limit consumption stays a read-modify-write merge over the serialized
|
|
1890
|
+
* key. This is the residual non-atomic path; strict enforcement requires the
|
|
1891
|
+
* database (use `fallbackToDatabase`) or an atomic secondary-storage primitive.
|
|
1892
|
+
* FIXME(api-key-secondary-atomic): back this with SecondaryStorage.increment on
|
|
1893
|
+
* `next` so secondary-storage-only mode enforces quota and rate limits atomically.
|
|
1894
|
+
*/
|
|
1895
|
+
async function claimUsageInSecondaryStorage({ ctx, apiKey, opts, hashedKey }) {
|
|
1896
|
+
let remaining = apiKey.remaining;
|
|
1897
|
+
let lastRefillAt = apiKey.lastRefillAt;
|
|
1898
|
+
if (remaining !== null) {
|
|
1899
|
+
const now = Date.now();
|
|
1900
|
+
const { refillInterval, refillAmount } = apiKey;
|
|
1901
|
+
const lastTime = new Date(lastRefillAt ?? apiKey.createdAt).getTime();
|
|
1902
|
+
if (refillInterval && refillAmount && now - lastTime > refillInterval) {
|
|
1903
|
+
remaining = refillAmount;
|
|
1904
|
+
lastRefillAt = /* @__PURE__ */ new Date();
|
|
1763
1905
|
}
|
|
1906
|
+
if (remaining === 0) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
|
|
1907
|
+
remaining--;
|
|
1908
|
+
}
|
|
1909
|
+
const mutations = {
|
|
1910
|
+
...applyRateLimitToSnapshot(apiKey, opts),
|
|
1911
|
+
remaining,
|
|
1912
|
+
lastRefillAt,
|
|
1913
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1914
|
+
};
|
|
1915
|
+
const performUpdate = async () => {
|
|
1916
|
+
const fresh = await getApiKey$1(ctx, hashedKey, opts);
|
|
1917
|
+
if (!fresh) return null;
|
|
1918
|
+
const merged = {
|
|
1919
|
+
...fresh,
|
|
1920
|
+
...mutations
|
|
1921
|
+
};
|
|
1922
|
+
await setApiKey(ctx, merged, opts);
|
|
1923
|
+
return merged;
|
|
1764
1924
|
};
|
|
1765
|
-
let newApiKey = null;
|
|
1766
1925
|
if (opts.deferUpdates) {
|
|
1767
1926
|
ctx.context.runInBackground(performUpdate().catch((error) => {
|
|
1768
1927
|
ctx.context.logger.error("Failed to update API key:", error);
|
|
1769
1928
|
}));
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1929
|
+
return {
|
|
1930
|
+
...apiKey,
|
|
1931
|
+
...mutations
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
const updated = await performUpdate();
|
|
1935
|
+
if (!updated) throw APIError$1.from("INTERNAL_SERVER_ERROR", API_KEY_ERROR_CODES.FAILED_TO_UPDATE_API_KEY);
|
|
1936
|
+
return updated;
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Translate a rate-limit decision into a counter snapshot for the
|
|
1940
|
+
* secondary-storage merge write. Denials throw before any write.
|
|
1941
|
+
*/
|
|
1942
|
+
function applyRateLimitToSnapshot(apiKey, opts) {
|
|
1943
|
+
const decision = evaluateRateLimit(apiKey, opts);
|
|
1944
|
+
switch (decision.type) {
|
|
1945
|
+
case "deny": throw new APIError$1("TOO_MANY_REQUESTS", {
|
|
1946
|
+
message: decision.message,
|
|
1947
|
+
code: "RATE_LIMITED",
|
|
1948
|
+
details: { tryAgainIn: decision.tryAgainIn }
|
|
1949
|
+
});
|
|
1950
|
+
case "skip": return decision.lastRequest === null ? {} : { lastRequest: decision.lastRequest };
|
|
1951
|
+
case "start":
|
|
1952
|
+
case "reset": return {
|
|
1953
|
+
lastRequest: decision.now,
|
|
1954
|
+
requestCount: 1
|
|
1955
|
+
};
|
|
1956
|
+
case "increment": return {
|
|
1957
|
+
lastRequest: decision.now,
|
|
1958
|
+
requestCount: apiKey.requestCount + 1
|
|
1959
|
+
};
|
|
1774
1960
|
}
|
|
1775
|
-
return {
|
|
1776
|
-
apiKey: newApiKey,
|
|
1777
|
-
opts
|
|
1778
|
-
};
|
|
1779
1961
|
}
|
|
1780
1962
|
const verifyApiKeyBodySchema = z.object({
|
|
1781
1963
|
configId: z.string().meta({ description: "Configuration ID to scope verification to. When omitted, the key is validated against its own configuration." }).optional(),
|
|
@@ -1783,7 +1965,7 @@ const verifyApiKeyBodySchema = z.object({
|
|
|
1783
1965
|
permissions: z.record(z.string(), z.array(z.string())).meta({ description: "The permissions to verify." }).optional()
|
|
1784
1966
|
});
|
|
1785
1967
|
function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
|
|
1786
|
-
return createAuthEndpoint({
|
|
1968
|
+
return createAuthEndpoint.serverOnly({
|
|
1787
1969
|
method: "POST",
|
|
1788
1970
|
body: verifyApiKeyBodySchema
|
|
1789
1971
|
}, async (ctx) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/api-key",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.17",
|
|
4
4
|
"description": "API Key plugin for Better Auth.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,14 +61,14 @@
|
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"tsdown": "0.21.1",
|
|
64
|
-
"@better-auth/core": "1.6.
|
|
65
|
-
"better-auth": "1.6.
|
|
64
|
+
"@better-auth/core": "1.6.17",
|
|
65
|
+
"better-auth": "1.6.17"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@better-auth/utils": "0.4.1",
|
|
69
|
-
"better-call": "1.3.
|
|
70
|
-
"@better-auth/core": "^1.6.
|
|
71
|
-
"better-auth": "^1.6.
|
|
69
|
+
"better-call": "1.3.6",
|
|
70
|
+
"@better-auth/core": "^1.6.17",
|
|
71
|
+
"better-auth": "^1.6.17"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsdown",
|