mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
perf(auth): use Better Auth API key storage
This commit is contained in:
+22
-2
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user