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