diff --git a/server/adapters/repos/api-key-scopes.ts b/server/adapters/repos/api-key-scopes.ts index 6f2dc72a..42ee3ddd 100644 --- a/server/adapters/repos/api-key-scopes.ts +++ b/server/adapters/repos/api-key-scopes.ts @@ -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 { - 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 { const rows = await db .select({ id: apikey.id, configId: apikey.configId, referenceId: apikey.referenceId, metadata: apikey.metadata }) diff --git a/server/adapters/repos/api-keys-rate-limit.integration.test.ts b/server/adapters/repos/api-keys-rate-limit.integration.test.ts index a7d898fd..4d02e326 100644 --- a/server/adapters/repos/api-keys-rate-limit.integration.test.ts +++ b/server/adapters/repos/api-keys-rate-limit.integration.test.ts @@ -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[] = [] + 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 () => { diff --git a/server/adapters/repos/api-keys.ts b/server/adapters/repos/api-keys.ts index ea610338..24e34b4e 100644 --- a/server/adapters/repos/api-keys.ts +++ b/server/adapters/repos/api-keys.ts @@ -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, -): Promise { - const normalized = await normalizeLegacyApiKey(db, key) - if (!normalized) return null +async function normalizeVerifiedApiKey(key: NonNullable): Promise { + 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, } } diff --git a/server/auth.ts b/server/auth.ts index eda8c22d..16e522c9 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -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) => 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 | 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 diff --git a/server/http/admin-users-ba.integration.test.ts b/server/http/admin-users-ba.integration.test.ts index ef016845..b620ac22 100644 --- a/server/http/admin-users-ba.integration.test.ts +++ b/server/http/admin-users-ba.integration.test.ts @@ -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', { diff --git a/server/http/share-utils.ts b/server/http/share-utils.ts index 18c6f01b..16f79495 100644 --- a/server/http/share-utils.ts +++ b/server/http/share-utils.ts @@ -26,9 +26,6 @@ export function escapeLike(s: string): string { return s.replace(/[\\%_]/g, '\\$&') } -export async function readUserId(c: Context): Promise { - 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): string | null { + return c.get('userId') } diff --git a/server/http/users.integration.test.ts b/server/http/users.integration.test.ts index e081e310..cdbbcd7a 100644 --- a/server/http/users.integration.test.ts +++ b/server/http/users.integration.test.ts @@ -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) diff --git a/server/http/webdav.ts b/server/http/webdav.ts index c97a7449..ce03262a 100644 --- a/server/http/webdav.ts +++ b/server/http/webdav.ts @@ -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 | null } -type VerifiedWebDavAuth = Extract>, { 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 = { - 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 - 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>, { 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 { @@ -135,29 +103,16 @@ async function requireWebDavApiKey(c: DavContext): Promise { } 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> - 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') diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 87339512..0717be39 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -77,12 +77,6 @@ export const authMiddleware = createMiddleware(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(async (c, next) => { }) export const requireAdmin = createMiddleware(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() diff --git a/server/test/setup.ts b/server/test/setup.ts index 7f2bf02c..41cc152d 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -628,6 +628,7 @@ const APP_SCHEMA_SQL = ` export async function createTestApp( envOverrides: Record = {}, bindingOverrides: Record = {}, + backgroundTaskHandler?: (promise: Promise) => 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: (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) diff --git a/server/usecases/webdav.test.ts b/server/usecases/webdav.test.ts index f5e33dc3..09579862 100644 --- a/server/usecases/webdav.test.ts +++ b/server/usecases/webdav.test.ts @@ -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, diff --git a/server/usecases/webdav.ts b/server/usecases/webdav.ts index d9bd66ad..62b4b921 100644 --- a/server/usecases/webdav.ts +++ b/server/usecases/webdav.ts @@ -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 + +const WEBDAV_AUTH_CACHE_POLICY: CachePolicy = { + 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 + 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) { + super('WebDAV authentication rejected') + } +} + export async function resolveWebDavAuth( + deps: Pick, + params: { + auth: ApiKeyAuth + db: Database + username: string + password: string + resource: string + action: 'read' | 'write' + configId: string + cacheKey?: string + }, +): Promise { + 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, params: { auth: ApiKeyAuth diff --git a/spec/users.feature b/spec/users.feature index affb1248..fda07b90 100644 --- a/spec/users.feature +++ b/spec/users.feature @@ -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 diff --git a/workers/bootstrap.ts b/workers/bootstrap.ts index 6fc03ad8..ffa52ba8 100644 --- a/workers/bootstrap.ts +++ b/workers/bootstrap.ts @@ -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)