perf(auth): use Better Auth API key storage

This commit is contained in:
saltbo
2026-07-26 16:03:56 -04:00
parent e7e7c4481b
commit 05f41ef606
7 changed files with 62 additions and 75 deletions
+22 -2
View File
@@ -1,11 +1,31 @@
import type { DistributedCacheBackend } from '../../usecases/ports/cache'
export interface CloudflareKvNamespaceLike {
get(key: string, options: { cacheTtl: number }): Promise<string | null>
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>
get(key: string, options?: { cacheTtl: number }): Promise<string | null>
put(key: string, value: string, options?: { expirationTtl: number }): Promise<void>
delete(key: string): Promise<void>
}
export function createBetterAuthApiKeyStorage(namespace: CloudflareKvNamespaceLike) {
const storageKey = (key: string) => `better-auth:${key}`
return {
get(key: string) {
return namespace.get(storageKey(key))
},
set(key: string, value: string, ttl?: number) {
return namespace.put(
storageKey(key),
value,
ttl === undefined ? undefined : { expirationTtl: Math.max(60, Math.ceil(ttl)) },
)
},
delete(key: string) {
return namespace.delete(storageKey(key))
},
}
}
export function createCloudflareKvBackend(namespace: CloudflareKvNamespaceLike): DistributedCacheBackend {
return {
get(key, cacheTtlSeconds) {
@@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
WEBDAV_API_KEY_RATE_LIMIT_MAX_REQUESTS,
WEBDAV_API_KEY_RATE_LIMIT_WINDOW_MS,
@@ -111,8 +111,21 @@ describe('API keys', () => {
it('defers Better Auth WebDAV key bookkeeping when the native limiter is authoritative', async () => {
const backgroundTasks: Promise<unknown>[] = []
const { app, db, auth } = await createTestApp({}, { [WEBDAV_RATE_LIMITER_BINDING]: {} }, (task) =>
backgroundTasks.push(task),
const stored = new Map<string, string>()
const get = vi.fn(async (key: string) => stored.get(key) ?? null)
const put = vi.fn(async (key: string, value: string) => {
stored.set(key, value)
})
const remove = vi.fn(async (key: string) => {
stored.delete(key)
})
const { app, db, auth } = await createTestApp(
{},
{
[WEBDAV_RATE_LIMITER_BINDING]: {},
CACHE_KV: { get, put, delete: remove },
},
(task) => backgroundTasks.push(task),
)
await authedHeaders(app)
const { userId } = await getUserAndOrg(db)
@@ -121,12 +134,24 @@ describe('API keys', () => {
body: { configId: 'webdav', userId },
})) as { key: string }
await Promise.all(backgroundTasks.splice(0))
stored.clear()
get.mockClear()
put.mockClear()
await expect(
apiKeys.verifyApiKeyForPermission(auth, db, webdav.key, 'webdav', 'read', 'webdav'),
).resolves.toMatchObject({ referenceId: userId })
expect(get).toHaveBeenCalledOnce()
expect(get.mock.calls[0][0]).toMatch(/^better-auth:api-key:/)
expect(put).toHaveBeenCalled()
expect(backgroundTasks.length).toBeGreaterThan(0)
await Promise.all(backgroundTasks)
get.mockClear()
await expect(
apiKeys.verifyApiKeyForPermission(auth, db, webdav.key, 'webdav', 'read', 'webdav'),
).resolves.toMatchObject({ referenceId: userId })
expect(get).toHaveBeenCalledOnce()
})
it('persists the configured defaults for each API key template', async () => {
+10
View File
@@ -37,6 +37,7 @@ import {
parseProviderConfig,
} from '../shared/oauth-providers'
import { generateUserOrgSlug, isPersonalOrgLike } from '../shared/org-slugs'
import { type CloudflareKvNamespaceLike, createBetterAuthApiKeyStorage } from './adapters/cache/cloudflare-kv'
import { createEmailGateway } from './adapters/gateways/email'
import { deleteApiKeysScopedToOrganization } from './adapters/repos/api-key-scopes'
import { createAuditRepo } from './adapters/repos/audit'
@@ -330,6 +331,8 @@ export async function createAuth(
const email = createEmailGateway(systemOptionsRepo)
const providerConfigs = await loadProviderConfigs(rawDb)
const usesNativeWebDavRateLimit = Boolean(authPlatform.getBinding(WEBDAV_RATE_LIMITER_BINDING))
const apiKeyKv = authPlatform.getBinding<CloudflareKvNamespaceLike>('CACHE_KV')
const webDavApiKeyStorage = apiKeyKv ? createBetterAuthApiKeyStorage(apiKeyKv) : undefined
const authOptions = {
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
secret,
@@ -565,6 +568,13 @@ export async function createAuth(
configId: ApiKeyTemplate.WEBDAV,
references: 'user',
enableMetadata: true,
...(webDavApiKeyStorage
? {
storage: 'secondary-storage' as const,
fallbackToDatabase: true,
customStorage: webDavApiKeyStorage,
}
: {}),
// Cloudflare's native limiter remains the authoritative synchronous
// rate limit. Better Auth can therefore move its bookkeeping write
// off the response path without weakening enforcement.
+2 -3
View File
@@ -161,7 +161,7 @@ async function folder(db: TestApp['db'], orgId: string, opts: { id: string; name
}
describe('WebDAV API', () => {
it('uses the native limiter for every request and reuses successful auth briefly', async () => {
it('uses the native limiter and Better Auth verification for every request', async () => {
const limit = vi.fn(async () => ({ success: true }))
const { app, db, auth, deps } = await createTestApp({}, { [WEBDAV_RATE_LIMITER_BINDING]: { limit } })
await authedHeaders(app)
@@ -176,8 +176,7 @@ describe('WebDAV API', () => {
expect(first.status).toBe(207)
expect(second.status).toBe(207)
expect(limit).toHaveBeenCalledTimes(2)
expect(verify).toHaveBeenCalledTimes(1)
expect(second.headers.get('Server-Timing')).toContain('webdav-auth:memory')
expect(verify).toHaveBeenCalledTimes(2)
})
it('rejects native WebDAV rate limits before API-key verification', async () => {
-1
View File
@@ -111,7 +111,6 @@ async function requireWebDavApiKey(c: DavContext): Promise<DavAuth | Response> {
resource: WEBDAV_RESOURCE,
action,
configId: ApiKeyTemplate.WEBDAV,
cacheKey: nativeRateLimiter && credentialKey ? `${credentialKey}:${action}` : undefined,
})
c.get('webDavTrace').push(`auth:${Math.round(performance.now() - startedAt)}`)
if (!result.ok) {
-7
View File
@@ -4,7 +4,6 @@ import type { Database } from '../platform/interface'
import type {
ApiKeyAuth,
ApiKeyGateway,
CacheService,
CloudTrafficReportRepo,
DavLock,
DownloadTaskRecord,
@@ -126,12 +125,6 @@ function makeDeps(
verifyApiKeyForPermission: async () => ({ id: 'k1', configId: 'webdav', referenceId: 'u1', permissions: null }),
...overrides.apiKeys,
} as unknown as ApiKeyGateway,
cache: {
mode: 'off',
getOrLoad: async (_policy, _key, loader) => ({ value: await loader(), tier: 'bypass' }),
replace: async () => {},
invalidate: async () => {},
} as CacheService,
userAdmin: {
isBanned: async () => false,
matchesActiveUsername: async () => true,
-59
View File
@@ -21,7 +21,6 @@ import { assertFolderNotUsedByDownload } from './downloads/download-folders'
import {
type ApiKeyAuth,
ApiKeyRateLimitError,
type CachePolicy,
type DavDeadProperty,
type DavLock,
type DeadPropertyUpdate,
@@ -49,65 +48,7 @@ export type WebDavAuthOutcome =
| { ok: false; reason: 'unauthorized' }
| { ok: false; reason: 'rate_limited'; retryAfterMs?: number; message: string }
type VerifiedWebDavAuth = Extract<WebDavAuthOutcome, { ok: true }>
const WEBDAV_AUTH_CACHE_POLICY: CachePolicy<VerifiedWebDavAuth> = {
namespace: 'webdav-auth',
version: 1,
ttlMs: 1_000,
maxEntries: 256,
distributed: false,
validate(value): value is VerifiedWebDavAuth {
if (typeof value !== 'object' || value === null) return false
const auth = value as Partial<VerifiedWebDavAuth>
return (
auth.ok === true &&
typeof auth.userId === 'string' &&
typeof auth.keyId === 'string' &&
typeof auth.configId === 'string' &&
(auth.permissions === null ||
(typeof auth.permissions === 'object' && auth.permissions !== null && !Array.isArray(auth.permissions)))
)
},
}
class WebDavAuthRejected extends Error {
constructor(readonly outcome: Exclude<WebDavAuthOutcome, { ok: true }>) {
super('WebDAV authentication rejected')
}
}
export async function resolveWebDavAuth(
deps: Pick<Deps, 'apiKeys' | 'cache' | 'userAdmin'>,
params: {
auth: ApiKeyAuth
db: Database
username: string
password: string
resource: string
action: 'read' | 'write'
configId: string
cacheKey?: string
},
): Promise<WebDavAuthOutcome> {
const load = () => verifyWebDavAuth(deps, params)
if (!params.cacheKey) return load()
try {
return (
await deps.cache.getOrLoad(WEBDAV_AUTH_CACHE_POLICY, params.cacheKey, async () => {
const result = await load()
if (!result.ok) throw new WebDavAuthRejected(result)
return result
})
).value
} catch (error) {
if (error instanceof WebDavAuthRejected) return error.outcome
throw error
}
}
async function verifyWebDavAuth(
deps: Pick<Deps, 'apiKeys' | 'userAdmin'>,
params: {
auth: ApiKeyAuth