mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
perf(auth): use Better Auth hot-path features
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { type ApiKeyScope, ApiKeyTemplate, apiKeyMetadata, parseApiKeyScope } from '@shared/api-key-templates'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { apikey, member } from '../../db/auth-schema'
|
||||
import { type ApiKeyScope, ApiKeyTemplate, parseApiKeyScope } from '@shared/api-key-templates'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { apikey } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import { resolveOrganizationOwnerUserId } from './organization-owner'
|
||||
|
||||
const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD]
|
||||
|
||||
@@ -17,80 +16,6 @@ export function scopeForApiKey(configId: string, metadata: unknown): ApiKeyScope
|
||||
return null
|
||||
}
|
||||
|
||||
export async function normalizeLegacyApiKey(
|
||||
db: Database,
|
||||
key: { id: string; configId: string; referenceId: string; metadata: unknown },
|
||||
): Promise<{ referenceId: string; scope: ApiKeyScope } | null> {
|
||||
const existingScope = scopeForApiKey(key.configId, key.metadata)
|
||||
if (existingScope) return { referenceId: key.referenceId, scope: existingScope }
|
||||
|
||||
if (key.configId === ApiKeyTemplate.WEBDAV) {
|
||||
const scope = { mode: 'user-workspaces' } as const
|
||||
await db
|
||||
.update(apikey)
|
||||
.set({ metadata: JSON.stringify(apiKeyMetadata(scope)), updatedAt: new Date() })
|
||||
.where(and(eq(apikey.id, key.id), eq(apikey.referenceId, key.referenceId)))
|
||||
return { referenceId: key.referenceId, scope }
|
||||
}
|
||||
|
||||
if (!WORKSPACE_TEMPLATES.includes(key.configId as (typeof WORKSPACE_TEMPLATES)[number])) return null
|
||||
|
||||
let ownerUserId: string
|
||||
try {
|
||||
ownerUserId = await resolveOrganizationOwnerUserId(db, key.referenceId)
|
||||
} catch {
|
||||
await db.update(apikey).set({ enabled: false, updatedAt: new Date() }).where(eq(apikey.id, key.id))
|
||||
return null
|
||||
}
|
||||
|
||||
const scope = { mode: 'workspace', orgId: key.referenceId } as const
|
||||
await db
|
||||
.update(apikey)
|
||||
.set({
|
||||
referenceId: ownerUserId,
|
||||
metadata: JSON.stringify(apiKeyMetadata(scope)),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(apikey.id, key.id), eq(apikey.referenceId, key.referenceId)))
|
||||
return { referenceId: ownerUserId, scope }
|
||||
}
|
||||
|
||||
export async function normalizeLegacyApiKeysForUser(db: Database, userId: string): Promise<void> {
|
||||
const webDavKeys = await db
|
||||
.select({ id: apikey.id, metadata: apikey.metadata })
|
||||
.from(apikey)
|
||||
.where(and(eq(apikey.configId, ApiKeyTemplate.WEBDAV), eq(apikey.referenceId, userId)))
|
||||
const legacyWebDavKeyIds = webDavKeys
|
||||
.filter(({ metadata }) => scopeForApiKey(ApiKeyTemplate.WEBDAV, parseMetadata(metadata)) === null)
|
||||
.map(({ id }) => id)
|
||||
if (legacyWebDavKeyIds.length > 0) {
|
||||
await db
|
||||
.update(apikey)
|
||||
.set({
|
||||
metadata: JSON.stringify(apiKeyMetadata({ mode: 'user-workspaces' })),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(inArray(apikey.id, legacyWebDavKeyIds))
|
||||
}
|
||||
|
||||
const ownedOrgs = await db
|
||||
.select({ orgId: member.organizationId })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.role, 'owner')))
|
||||
|
||||
for (const { orgId } of ownedOrgs) {
|
||||
if ((await resolveOrganizationOwnerUserId(db, orgId)) !== userId) continue
|
||||
await db
|
||||
.update(apikey)
|
||||
.set({
|
||||
referenceId: userId,
|
||||
metadata: JSON.stringify(apiKeyMetadata({ mode: 'workspace', orgId })),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(inArray(apikey.configId, WORKSPACE_TEMPLATES), eq(apikey.referenceId, orgId)))
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteApiKeysScopedToOrganization(db: Database, orgId: string): Promise<void> {
|
||||
const rows = await db
|
||||
.select({ id: apikey.id, configId: apikey.configId, referenceId: apikey.referenceId, metadata: apikey.metadata })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WEBDAV_API_KEY_RATE_LIMIT_MAX_REQUESTS,
|
||||
WEBDAV_API_KEY_RATE_LIMIT_WINDOW_MS,
|
||||
WEBDAV_RATE_LIMITER_BINDING,
|
||||
} from '../../../shared/api-key-templates.js'
|
||||
import { authedHeaders, createTestApp } from '../../test/setup.js'
|
||||
import { ApiKeyRateLimitError } from '../../usecases/ports'
|
||||
@@ -108,6 +109,26 @@ describe('API keys', () => {
|
||||
expect(listed.apiKeys.every((key) => key.referenceId === userId)).toBe(true)
|
||||
})
|
||||
|
||||
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),
|
||||
)
|
||||
await authedHeaders(app)
|
||||
const { userId } = await getUserAndOrg(db)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
|
||||
const webdav = (await (auth.api as any).createApiKey({
|
||||
body: { configId: 'webdav', userId },
|
||||
})) as { key: string }
|
||||
await Promise.all(backgroundTasks.splice(0))
|
||||
|
||||
await expect(
|
||||
apiKeys.verifyApiKeyForPermission(auth, db, webdav.key, 'webdav', 'read', 'webdav'),
|
||||
).resolves.toMatchObject({ referenceId: userId })
|
||||
expect(backgroundTasks.length).toBeGreaterThan(0)
|
||||
await Promise.all(backgroundTasks)
|
||||
})
|
||||
|
||||
it('persists the configured defaults for each API key template', async () => {
|
||||
const { app, db, auth } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
@@ -159,7 +180,7 @@ describe('API keys', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a legacy organization-owned key to the current owner', async () => {
|
||||
it('rejects legacy API keys instead of upgrading them during verification', async () => {
|
||||
const { app, db, auth } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const { orgId, userId } = await getUserAndOrg(db)
|
||||
@@ -176,32 +197,18 @@ describe('API keys', () => {
|
||||
`)
|
||||
await db.run(sql`UPDATE apikey SET metadata = '{}' WHERE id = ${webdav.id}`)
|
||||
|
||||
await expect(apiKeys.verifyApiKey(auth, db, remoteDownload.key, 'remote-download')).resolves.toMatchObject({
|
||||
referenceId: userId,
|
||||
scope: { mode: 'workspace', orgId },
|
||||
})
|
||||
await expect(apiKeys.verifyApiKey(auth, db, remoteDownload.key, 'remote-download')).resolves.toBeNull()
|
||||
const row = await getApiKeyRow(db, remoteDownload.id)
|
||||
expect(row.reference_id).toBe(userId)
|
||||
expect(JSON.parse(row.metadata as string)).toEqual({ scope: { mode: 'workspace', orgId } })
|
||||
expect(row.reference_id).toBe(orgId)
|
||||
expect(JSON.parse(row.metadata as string)).toEqual({})
|
||||
|
||||
const listResponse = await app.request('/api/auth/api-key/list', { headers })
|
||||
expect(listResponse.status).toBe(200)
|
||||
const listed = (await listResponse.json()) as {
|
||||
apiKeys: Array<{ id: string; metadata: { scope: { mode: string; orgId?: string } } }>
|
||||
}
|
||||
expect(listed.apiKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: ihost.id,
|
||||
metadata: { scope: { mode: 'workspace', orgId } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: webdav.id,
|
||||
metadata: { scope: { mode: 'user-workspaces' } },
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(await getApiKeyRow(db, ihost.id)).toMatchObject({ reference_id: userId })
|
||||
expect(listed.apiKeys).toEqual([expect.objectContaining({ id: webdav.id, metadata: {} })])
|
||||
expect(await getApiKeyRow(db, ihost.id)).toMatchObject({ reference_id: orgId })
|
||||
})
|
||||
|
||||
it('deletes workspace-scoped keys with their organization but preserves WebDAV keys', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { apikey } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import { type ApiKeyAuth, type ApiKeyGateway, ApiKeyRateLimitError, type VerifiedApiKey } from '../../usecases/ports'
|
||||
import { normalizeLegacyApiKey } from './api-key-scopes'
|
||||
import { scopeForApiKey } from './api-key-scopes'
|
||||
|
||||
type VerifyApiKeyResult = {
|
||||
valid: boolean
|
||||
@@ -25,7 +25,7 @@ export function createApiKeyGateway(): ApiKeyGateway {
|
||||
if (!resolvedConfigId) return null
|
||||
const result = await verify(auth, { configId: resolvedConfigId, key })
|
||||
throwIfRateLimited(result)
|
||||
if (result?.valid && result.key) return normalizeVerifiedApiKey(db, result.key)
|
||||
if (result?.valid && result.key) return normalizeVerifiedApiKey(result.key)
|
||||
return null
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ export function createApiKeyGateway(): ApiKeyGateway {
|
||||
permissions: { [resource]: [action] },
|
||||
})
|
||||
throwIfRateLimited(result)
|
||||
if (result?.valid && result.key) return normalizeVerifiedApiKey(db, result.key)
|
||||
if (result?.valid && result.key) return normalizeVerifiedApiKey(result.key)
|
||||
return null
|
||||
},
|
||||
|
||||
@@ -48,17 +48,14 @@ export function createApiKeyGateway(): ApiKeyGateway {
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeVerifiedApiKey(
|
||||
db: Database,
|
||||
key: NonNullable<VerifyApiKeyResult['key']>,
|
||||
): Promise<VerifiedApiKey | null> {
|
||||
const normalized = await normalizeLegacyApiKey(db, key)
|
||||
if (!normalized) return null
|
||||
async function normalizeVerifiedApiKey(key: NonNullable<VerifyApiKeyResult['key']>): Promise<VerifiedApiKey | null> {
|
||||
const scope = scopeForApiKey(key.configId, key.metadata)
|
||||
if (!scope) return null
|
||||
return {
|
||||
id: key.id,
|
||||
configId: key.configId,
|
||||
referenceId: normalized.referenceId,
|
||||
scope: normalized.scope,
|
||||
referenceId: key.referenceId,
|
||||
scope,
|
||||
permissions: key.permissions,
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -38,7 +38,7 @@ import {
|
||||
} from '../shared/oauth-providers'
|
||||
import { generateUserOrgSlug, isPersonalOrgLike } from '../shared/org-slugs'
|
||||
import { createEmailGateway } from './adapters/gateways/email'
|
||||
import { deleteApiKeysScopedToOrganization, normalizeLegacyApiKeysForUser } from './adapters/repos/api-key-scopes'
|
||||
import { deleteApiKeysScopedToOrganization } from './adapters/repos/api-key-scopes'
|
||||
import { createAuditRepo } from './adapters/repos/audit'
|
||||
import { createInviteRepo } from './adapters/repos/invite'
|
||||
import { createLicenseBindingRepo } from './adapters/repos/license-binding'
|
||||
@@ -311,6 +311,7 @@ export async function createAuth(
|
||||
secret: string,
|
||||
baseURL?: string,
|
||||
trustedOrigins?: string[],
|
||||
backgroundTaskHandler?: (promise: Promise<unknown>) => void,
|
||||
) {
|
||||
const isPlatform = 'db' in initialSource
|
||||
const rawPlatform = isPlatform ? initialSource : null
|
||||
@@ -347,6 +348,7 @@ export async function createAuth(
|
||||
// better-auth silently disables it under NODE_ENV=test, so tests would
|
||||
// never exercise the real CSRF/origin behavior.
|
||||
disableOriginCheck: false,
|
||||
...(backgroundTaskHandler ? { backgroundTasks: { handler: backgroundTaskHandler } } : {}),
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
@@ -382,7 +384,7 @@ export async function createAuth(
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5,
|
||||
maxAge: 60,
|
||||
},
|
||||
},
|
||||
socialProviders: Object.fromEntries(
|
||||
@@ -393,11 +395,6 @@ export async function createAuth(
|
||||
if (ctx.path === '/delete-user') {
|
||||
throw new APIError('FORBIDDEN', { message: 'Self-service account deletion is not available' })
|
||||
}
|
||||
if (ctx.path === '/api-key/list') {
|
||||
const session = await getSessionFromCtx(ctx)
|
||||
if (session?.user.id) await normalizeLegacyApiKeysForUser(db, session.user.id)
|
||||
return
|
||||
}
|
||||
if (ctx.path !== '/api-key/create') return
|
||||
|
||||
const body = ctx.body as Record<string, unknown> | undefined
|
||||
@@ -568,6 +565,10 @@ export async function createAuth(
|
||||
configId: ApiKeyTemplate.WEBDAV,
|
||||
references: 'user',
|
||||
enableMetadata: true,
|
||||
// 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.
|
||||
deferUpdates: usesNativeWebDavRateLimit && backgroundTaskHandler !== undefined,
|
||||
rateLimit: {
|
||||
enabled: !usesNativeWebDavRateLimit,
|
||||
// Filesystem clients such as macOS Finder issue bursts of PROPFIND
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('better-auth admin user endpoints (migration target)', () => {
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('POST /api/auth/admin/ban-user sets banned, and our middleware then rejects the user [spec: users/disable]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
it('POST /api/auth/admin/ban-user sets banned and revokes the Better Auth session [spec: users/disable] [spec: users/disabled-session-rejected]', async () => {
|
||||
const { app, db, auth } = await createTestApp()
|
||||
const headers = await adminCookie(app)
|
||||
const memberHeaders = await authedHeaders(app, 'ban-me@example.com', 'password123456')
|
||||
const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'ban-me@example.com'`)
|
||||
@@ -68,9 +68,17 @@ describe('better-auth admin user endpoints (migration target)', () => {
|
||||
)
|
||||
expect(disableEvt).toEqual([{ user_id: adminId, target_id: userId }])
|
||||
|
||||
// The banned user's existing session is rejected by our auth middleware.
|
||||
const blocked = await app.request('/api/quotas/me', { headers: memberHeaders })
|
||||
expect(blocked.status).toBe(403)
|
||||
// Better Auth's signed cookie cache has a bounded revocation window, so
|
||||
// ordinary routes can still use this one-minute cached session.
|
||||
const cached = await app.request('/api/quotas/me', { headers: memberHeaders })
|
||||
expect(cached.status).toBe(200)
|
||||
|
||||
// Bypassing the cookie cache proves that Better Auth deleted the session.
|
||||
const revoked = await auth.api.getSession({
|
||||
headers: new Headers(memberHeaders),
|
||||
query: { disableCookieCache: true },
|
||||
})
|
||||
expect(revoked).toBeNull()
|
||||
|
||||
// Unban restores access and is audited as user_enable.
|
||||
const unban = await app.request('/api/auth/admin/unban-user', {
|
||||
|
||||
@@ -26,9 +26,6 @@ export function escapeLike(s: string): string {
|
||||
return s.replace(/[\\%_]/g, '\\$&')
|
||||
}
|
||||
|
||||
export async function readUserId(c: Context<Env>): Promise<string | null> {
|
||||
const session = (await c.get('auth').api.getSession({ headers: c.req.raw.headers })) as {
|
||||
user: { id: string }
|
||||
} | null
|
||||
return session?.user?.id ?? null
|
||||
export function readUserId(c: Context<Env>): string | null {
|
||||
return c.get('userId')
|
||||
}
|
||||
|
||||
@@ -82,20 +82,6 @@ describe('User entitlements API (admin)', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('auth middleware rejects a banned user on an existing session [spec: users/disabled-session-rejected]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
|
||||
// Capture a live session, then ban the user. Admin ban/unban is served by
|
||||
// better-auth's /admin/ban-user; here we only assert our middleware enforces it.
|
||||
const userHeaders = await authedHeaders(app, 'banned@example.com')
|
||||
await db.run(sql`UPDATE user SET banned = 1 WHERE email = 'banned@example.com'`)
|
||||
|
||||
const res = await app.request('/api/quotas/me', { headers: userHeaders })
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Account disabled')
|
||||
})
|
||||
|
||||
it('POST /api/users/:id/entitlements grants storage entitlement for a personal org [spec: users/grant-entitlement]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
+10
-55
@@ -26,7 +26,6 @@ import { isDownloadFailureStatus, transferAuditActor, transferFailureReason } fr
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
ApiKeyRateLimitError,
|
||||
type CachePolicy,
|
||||
insufficientCredits,
|
||||
type RecordAuditEventInput,
|
||||
type StorageRecord,
|
||||
@@ -80,41 +79,10 @@ type DavAuth = {
|
||||
permissions: Record<string, string[]> | null
|
||||
}
|
||||
|
||||
type VerifiedWebDavAuth = Extract<Awaited<ReturnType<typeof resolveWebDavAuth>>, { ok: true }>
|
||||
|
||||
interface NativeRateLimiter {
|
||||
limit(options: { key: string }): Promise<{ success: boolean }>
|
||||
}
|
||||
|
||||
// The native limiter still counts every request. This only collapses repeated D1
|
||||
// verification inside a Finder burst; memory-only storage bounds revocation lag
|
||||
// to one second and keeps credential-derived keys out of KV.
|
||||
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<Awaited<ReturnType<typeof resolveWebDavAuth>>, { ok: true }>) {
|
||||
super('WebDAV authentication rejected')
|
||||
}
|
||||
}
|
||||
|
||||
const cloudBaseUrl = (c: DavContext): string => c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
|
||||
|
||||
async function requireWebDavApiKey(c: DavContext): Promise<DavAuth | Response> {
|
||||
@@ -135,29 +103,16 @@ async function requireWebDavApiKey(c: DavContext): Promise<DavAuth | Response> {
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
const loadAuth = async () => {
|
||||
const result = await resolveWebDavAuth(c.get('deps'), {
|
||||
auth: c.get('auth'),
|
||||
db: c.get('platform').db,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
resource: WEBDAV_RESOURCE,
|
||||
action,
|
||||
configId: ApiKeyTemplate.WEBDAV,
|
||||
})
|
||||
if (!result.ok) throw new WebDavAuthRejected(result)
|
||||
return result
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof resolveWebDavAuth>>
|
||||
try {
|
||||
result =
|
||||
nativeRateLimiter && credentialKey
|
||||
? (await c.get('deps').cache.getOrLoad(WEBDAV_AUTH_CACHE_POLICY, `${credentialKey}:${action}`, loadAuth)).value
|
||||
: await loadAuth()
|
||||
} catch (error) {
|
||||
if (!(error instanceof WebDavAuthRejected)) throw error
|
||||
result = error.outcome
|
||||
}
|
||||
const result = await resolveWebDavAuth(c.get('deps'), {
|
||||
auth: c.get('auth'),
|
||||
db: c.get('platform').db,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
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) {
|
||||
if (result.reason === 'rate_limited')
|
||||
|
||||
@@ -77,12 +77,6 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
|
||||
const auth = c.get('auth')
|
||||
const result = (await auth.api.getSession({ headers: c.req.raw.headers })) as SessionWithPlugins | null
|
||||
|
||||
if (result?.user?.id) {
|
||||
if (await c.get('deps').userAdmin.isBanned(result.user.id)) {
|
||||
throw forbidden('Account disabled')
|
||||
}
|
||||
}
|
||||
|
||||
c.set('userId', result?.user?.id ?? null)
|
||||
c.set('userRole', result?.user?.role ?? null)
|
||||
|
||||
@@ -118,12 +112,14 @@ export const requireAuth = createMiddleware<Env>(async (c, next) => {
|
||||
})
|
||||
|
||||
export const requireAdmin = createMiddleware<Env>(async (c, next) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) {
|
||||
const result = (await c.get('auth').api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
query: { disableCookieCache: true },
|
||||
})) as SessionWithPlugins | null
|
||||
if (!result?.user?.id) {
|
||||
throw unauthorized('Unauthorized')
|
||||
}
|
||||
const userRole = c.get('userRole')
|
||||
if (userRole !== 'admin') {
|
||||
if (result.user.role !== 'admin') {
|
||||
throw forbidden('Forbidden')
|
||||
}
|
||||
await next()
|
||||
|
||||
@@ -628,6 +628,7 @@ const APP_SCHEMA_SQL = `
|
||||
export async function createTestApp(
|
||||
envOverrides: Record<string, string> = {},
|
||||
bindingOverrides: Record<string, unknown> = {},
|
||||
backgroundTaskHandler?: (promise: Promise<unknown>) => void,
|
||||
) {
|
||||
// Each test app is a brand-new site; drop origin state cached by a previous
|
||||
// app in the same test file.
|
||||
@@ -643,7 +644,7 @@ export async function createTestApp(
|
||||
getEnv: (key: string) => envOverrides[key],
|
||||
getBinding: <T = unknown>(key: string) => bindingOverrides[key] as T | undefined,
|
||||
}
|
||||
const auth = await createAuth(platform, 'test-secret', 'http://localhost:3000')
|
||||
const auth = await createAuth(platform, 'test-secret', 'http://localhost:3000', undefined, backgroundTaskHandler)
|
||||
const deps = createDeps(platform)
|
||||
const app = createApp(platform, auth, deps)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Database } from '../platform/interface'
|
||||
import type {
|
||||
ApiKeyAuth,
|
||||
ApiKeyGateway,
|
||||
CacheService,
|
||||
CloudTrafficReportRepo,
|
||||
DavLock,
|
||||
DownloadTaskRecord,
|
||||
@@ -125,6 +126,12 @@ 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,6 +21,7 @@ import { assertFolderNotUsedByDownload } from './downloads/download-folders'
|
||||
import {
|
||||
type ApiKeyAuth,
|
||||
ApiKeyRateLimitError,
|
||||
type CachePolicy,
|
||||
type DavDeadProperty,
|
||||
type DavLock,
|
||||
type DeadPropertyUpdate,
|
||||
@@ -48,7 +49,65 @@ 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
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
Feature: User administration
|
||||
Admin user management runs through better-auth's admin plugin
|
||||
(/api/auth/admin/*). ZPan owns the per-user storage quota and the entitlement
|
||||
grants against each user's personal org, and enforces bans in its auth
|
||||
middleware.
|
||||
grants against each user's personal org. Better Auth owns ban enforcement and
|
||||
session revocation.
|
||||
|
||||
@users/admin-only @api
|
||||
Scenario: Non-admins cannot administer users
|
||||
@@ -35,10 +35,10 @@ Feature: User administration
|
||||
Then the API responds 404
|
||||
|
||||
@users/disabled-session-rejected @api
|
||||
Scenario: A disabled user's existing session is rejected
|
||||
Scenario: A disabled user's server session is revoked
|
||||
Given a user disabled mid-session
|
||||
When they make an authenticated request
|
||||
Then the auth middleware rejects it
|
||||
When authentication bypasses the bounded signed-cookie cache
|
||||
Then Better Auth rejects the revoked session
|
||||
|
||||
@users/delete @api
|
||||
Scenario: Admins delete a user
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { waitUntil } from 'cloudflare:workers'
|
||||
import { createCloudflareKvBackend } from '../server/adapters/cache/cloudflare-kv'
|
||||
import { createRuntimeCache, resolveCacheMode } from '../server/adapters/cache/runtime-cache'
|
||||
import { createArchiveJobsGateway } from '../server/adapters/gateways/archive-jobs'
|
||||
@@ -90,7 +91,7 @@ async function appForRequest(
|
||||
const cachedAuth = runtime.authBySlot.get(slot)
|
||||
if (cachedApp && cachedAuth) return cachedApp
|
||||
|
||||
const auth = await createAuth(runtime.platform, env.BETTER_AUTH_SECRET, baseURL, trustedOrigins)
|
||||
const auth = await createAuth(runtime.platform, env.BETTER_AUTH_SECRET, baseURL, trustedOrigins, waitUntil)
|
||||
const app = createApp(runtime.platform, auth, runtime.deps)
|
||||
runtime.authBySlot.set(slot, auth)
|
||||
runtime.appBySlot.set(slot, app)
|
||||
|
||||
Reference in New Issue
Block a user