@better-auth/api-key 1.7.0-beta.4 → 1.7.0-beta.6
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-CI6mGUwK.d.mts → index-BU-xUFsI.d.mts} +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +297 -116
- package/dist/{types-BR70O3Q3.d.mts → types-CbtANSbR.d.mts} +2 -14
- package/dist/types.d.mts +1 -1
- package/dist/{version-L3icplj1.mjs → version-rSzahEXu.mjs} +1 -1
- package/package.json +7 -7
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";
|
|
@@ -187,7 +187,7 @@ declare function apiKey(_configurations?: (ApiKeyConfigurationOptions & ApiKeyOp
|
|
|
187
187
|
image?: string | null | undefined;
|
|
188
188
|
} & Record<string, any>;
|
|
189
189
|
} | null) => void;
|
|
190
|
-
socialProviders: better_auth0.
|
|
190
|
+
socialProviders: better_auth0.UpstreamProvider[];
|
|
191
191
|
authCookies: better_auth0.BetterAuthCookies;
|
|
192
192
|
logger: ReturnType<typeof better_auth0.createLogger>;
|
|
193
193
|
rateLimit: {
|
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-rSzahEXu.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);
|
|
@@ -720,7 +745,7 @@ function createApiKey({ defaultKeyGenerator, configurations, schema, deleteAllEx
|
|
|
720
745
|
const { configId, name, expiresIn, prefix, remaining, metadata, refillAmount, refillInterval, permissions, rateLimitMax, rateLimitTimeWindow, rateLimitEnabled } = ctx.body;
|
|
721
746
|
const opts = resolveConfiguration(ctx.context, configurations, configId);
|
|
722
747
|
const keyGenerator = opts.customKeyGenerator || defaultKeyGenerator;
|
|
723
|
-
const session = await getSessionFromCtx(ctx);
|
|
748
|
+
const session = await getSessionFromCtx(ctx, { disableCookieCache: true });
|
|
724
749
|
const isClientRequest = ctx.request || ctx.headers;
|
|
725
750
|
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
751
|
if (ctx.request && ctx.body.userId !== void 0) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.UNAUTHORIZED_SESSION);
|
|
@@ -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,75 +1590,54 @@ 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
|
|
1634
1637
|
//#region src/routes/verify-api-key.ts
|
|
1635
1638
|
async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, permissions, expectedConfigId, runCustomValidator }) {
|
|
1636
|
-
const
|
|
1639
|
+
const hashedKey = lookupOpts.disableKeyHashing ? key : await defaultKeyHasher(key);
|
|
1640
|
+
const apiKey = await getApiKey$1(ctx, hashedKey, lookupOpts);
|
|
1637
1641
|
if (!apiKey) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
|
|
1638
1642
|
if (expectedConfigId !== void 0 && !configIdMatches(apiKey.configId, expectedConfigId)) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.INVALID_API_KEY);
|
|
1639
1643
|
const opts = resolveConfiguration(ctx.context, configurations, apiKey.configId);
|
|
@@ -1677,8 +1681,6 @@ async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, pe
|
|
|
1677
1681
|
if (!apiKeyPermissions) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
|
|
1678
1682
|
if (!role(apiKeyPermissions).authorize(permissions).success) throw APIError$1.from("UNAUTHORIZED", API_KEY_ERROR_CODES.KEY_NOT_FOUND);
|
|
1679
1683
|
}
|
|
1680
|
-
let remaining = apiKey.remaining;
|
|
1681
|
-
let lastRefillAt = apiKey.lastRefillAt;
|
|
1682
1684
|
if (apiKey.remaining === 0 && apiKey.refillAmount === null) {
|
|
1683
1685
|
const deleteExhaustedKey = async () => {
|
|
1684
1686
|
if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
|
|
@@ -1704,78 +1706,257 @@ async function validateApiKey({ key, ctx, lookupOpts, configurations, schema, pe
|
|
|
1704
1706
|
}));
|
|
1705
1707
|
else await deleteExhaustedKey();
|
|
1706
1708
|
throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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;
|
|
1717
1778
|
}
|
|
1718
|
-
if (remaining === 0) throw APIError$1.from("TOO_MANY_REQUESTS", API_KEY_ERROR_CODES.USAGE_EXCEEDED);
|
|
1719
|
-
else remaining--;
|
|
1720
1779
|
}
|
|
1721
|
-
const
|
|
1722
|
-
|
|
1723
|
-
|
|
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,
|
|
1724
1807
|
code: "RATE_LIMITED",
|
|
1725
|
-
details: { tryAgainIn }
|
|
1808
|
+
details: { tryAgainIn: decision.tryAgainIn }
|
|
1726
1809
|
});
|
|
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,
|
|
1810
|
+
if (decision.type === "skip") {
|
|
1811
|
+
if (decision.lastRequest === null) return apiKey;
|
|
1812
|
+
return await ctx.context.adapter.update({
|
|
1813
|
+
model: "apikey",
|
|
1737
1814
|
where: [{
|
|
1738
1815
|
field: "id",
|
|
1739
1816
|
value: apiKey.id
|
|
1740
1817
|
}],
|
|
1741
|
-
update: {
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
where: [{
|
|
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
|
+
{
|
|
1750
1826
|
field: "id",
|
|
1751
1827
|
value: apiKey.id
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
field: "lastRequest",
|
|
1831
|
+
operator: "gt",
|
|
1832
|
+
value: decision.windowStart
|
|
1833
|
+
},
|
|
1834
|
+
{
|
|
1835
|
+
field: "requestCount",
|
|
1836
|
+
operator: "lt",
|
|
1837
|
+
value: decision.max
|
|
1756
1838
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
|
|
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
|
|
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();
|
|
1763
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;
|
|
1764
1923
|
};
|
|
1765
|
-
let newApiKey = null;
|
|
1766
1924
|
if (opts.deferUpdates) {
|
|
1767
1925
|
ctx.context.runInBackground(performUpdate().catch((error) => {
|
|
1768
1926
|
ctx.context.logger.error("Failed to update API key:", error);
|
|
1769
1927
|
}));
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
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
|
+
};
|
|
1774
1959
|
}
|
|
1775
|
-
return {
|
|
1776
|
-
apiKey: newApiKey,
|
|
1777
|
-
opts
|
|
1778
|
-
};
|
|
1779
1960
|
}
|
|
1780
1961
|
const verifyApiKeyBodySchema = z.object({
|
|
1781
1962
|
configId: z.string().meta({ description: "Configuration ID to scope verification to. When omitted, the key is validated against its own configuration." }).optional(),
|
|
@@ -1783,7 +1964,7 @@ const verifyApiKeyBodySchema = z.object({
|
|
|
1783
1964
|
permissions: z.record(z.string(), z.array(z.string())).meta({ description: "The permissions to verify." }).optional()
|
|
1784
1965
|
});
|
|
1785
1966
|
function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) {
|
|
1786
|
-
return createAuthEndpoint({
|
|
1967
|
+
return createAuthEndpoint.serverOnly({
|
|
1787
1968
|
method: "POST",
|
|
1788
1969
|
body: verifyApiKeyBodySchema
|
|
1789
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.6",
|
|
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.6",
|
|
65
|
+
"better-auth": "1.7.0-beta.6"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"@better-auth/utils": "0.4.
|
|
69
|
-
"better-call": "1.3.
|
|
70
|
-
"@better-auth/core": "^1.7.0-beta.
|
|
71
|
-
"better-auth": "^1.7.0-beta.
|
|
68
|
+
"@better-auth/utils": "0.4.2",
|
|
69
|
+
"better-call": "1.3.6",
|
|
70
|
+
"@better-auth/core": "^1.7.0-beta.6",
|
|
71
|
+
"better-auth": "^1.7.0-beta.6"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsdown",
|