diff --git a/packages/better-auth/src/plugins/api-key/api-key.test.ts b/packages/better-auth/src/plugins/api-key/api-key.test.ts index 1408f8dccd..f1aa69caf2 100644 --- a/packages/better-auth/src/plugins/api-key/api-key.test.ts +++ b/packages/better-auth/src/plugins/api-key/api-key.test.ts @@ -2628,6 +2628,45 @@ describe("api-key", async () => { expect(retrievedKey?.id).toBe(createdKey?.id); }); + it("verifyApiKey should persist quota updates to the database when fallbackToDatabase is true", async () => { + const { headers, user } = await signInWithTestUser(); + + const createdKey = await auth.api.createApiKey({ + body: { + remaining: 1, + userId: user.id, + }, + }); + + const first = await auth.api.verifyApiKey({ + body: { + key: createdKey.key, + }, + }); + + expect(first.valid).toBe(true); + expect(first.key?.remaining).toBe(0); + + // Ensure the canonical DB row was updated (not just the cache). + const dbAfterFirst = await auth.api.getApiKey({ + query: { id: createdKey.id }, + headers, + }); + expect(dbAfterFirst.remaining).toBe(0); + + // Simulate cache eviction/deletion and ensure we don't repopulate stale allowances. + store.clear(); + + const second = await auth.api.verifyApiKey({ + body: { + key: createdKey.key, + }, + }); + + expect(second.valid).toBe(false); + expect(second.error?.code).toBe("USAGE_EXCEEDED"); + }); + it("should fallback to database when not found in storage and auto-populate storage", async () => { const { headers, user } = await signInWithTestUser(); diff --git a/packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts b/packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts index 7348f062d5..b4ed7c636a 100644 --- a/packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts +++ b/packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts @@ -109,8 +109,20 @@ export async function validateApiKey({ if (apiKey.remaining === 0 && apiKey.refillAmount === null) { // if there is no more remaining requests, and there is no refill amount, than the key is revoked try { - if (opts.storage === "secondary-storage") { - // Secondary storage mode: delete from storage + if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) { + // Secondary storage with fallback: delete from storage and database + await deleteApiKey(ctx, apiKey, opts); + await ctx.context.adapter.delete({ + model: API_KEY_TABLE_NAME, + where: [ + { + field: "id", + value: apiKey.id, + }, + ], + }); + } else if (opts.storage === "secondary-storage") { + // Secondary storage mode: delete from storage only await deleteApiKey(ctx, apiKey, opts); } else { // Database mode: delete from DB @@ -182,6 +194,22 @@ export async function validateApiKey({ ], update: updated, }); + } else if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) { + // Secondary storage with fallback: update database and then update storage + const dbUpdated = await ctx.context.adapter.update({ + model: API_KEY_TABLE_NAME, + where: [ + { + field: "id", + value: apiKey.id, + }, + ], + update: updated, + }); + if (dbUpdated) { + await setApiKey(ctx, dbUpdated, opts); + newApiKey = dbUpdated; + } } else { // Secondary storage mode: update in storage await setApiKey(ctx, updated, opts); diff --git a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts index e72f2995d3..c80947980a 100644 --- a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts +++ b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts @@ -326,6 +326,52 @@ describe("magic link verify", async () => { }); }); +describe("magic link verify origin validation", async () => { + it("should reject untrusted callbackURL on verify", async () => { + let verificationEmail: VerificationEmail = { + email: "", + token: "", + url: "", + }; + + const { customFetchImpl, testUser } = await getTestInstance({ + trustedOrigins: ["http://localhost:3000"], + plugins: [ + magicLink({ + async sendMagicLink(data) { + verificationEmail = data; + }, + }), + ], + }); + + const client = createAuthClient({ + plugins: [magicLinkClient()], + fetchOptions: { + customFetchImpl, + }, + baseURL: "http://localhost:3000", + basePath: "/api/auth", + }); + + await client.signIn.magicLink({ + email: testUser.email, + }); + + const token = + new URL(verificationEmail.url).searchParams.get("token") || ""; + const res = await client.magicLink.verify({ + query: { + token, + callbackURL: "http://malicious.com", + }, + }); + + expect(res.error?.status).toBe(403); + expect(res.error?.message).toBe("Invalid callbackURL"); + }); +}); + describe("magic link storeToken", async () => { it("should store token in hashed", async () => { let verificationEmail: VerificationEmail = {