/** * Auth middleware for admin routes * * Checks if the user is authenticated and has appropriate permissions. * Supports two auth modes: * - Passkey (default): Session-based auth with passkey login * - External providers: JWT-based auth (Cloudflare Access, etc.) * * This middleware runs AFTER the setup middleware - so if we get here, * we know setup is complete and users exist. */ import type { User, RoleLevel } from "@premium-cms/auth"; import { builtinRoleForLevel, grantsAllowRoute, toApiRelativePath, } from "@premium-cms/auth"; import { createKyselyAdapter } from "@premium-cms/auth/adapters/kysely"; import { defineMiddleware } from "astro:middleware"; import { ulid } from "ulidx"; // Import auth provider via virtual module (statically bundled) // This avoids dynamic import issues in Cloudflare Workers import { authenticate as virtualAuthenticate } from "virtual:emdash/auth"; // @ts-ignore - virtual module import virtualConfig from "virtual:emdash/config"; import { checkPublicCsrf } from "../../api/csrf.js"; import { apiError } from "../../api/error.js"; import { getConfiguredOrigin, getPublicOrigin } from "../../api/public-url.js"; import { OptionsRepository } from "../../database/repositories/options.js"; /** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */ const MW_CACHE_HEADERS = { "Cache-Control": "private, no-store", } as const; import { resolveApiToken, resolveOAuthToken } from "../../api/handlers/api-tokens.js"; import { attachRequestAuthz, type RequestAuthz } from "../../auth/authz.js"; import { getAuthMode, type ExternalAuthMode } from "../../auth/mode.js"; import type { ExternalAuthConfig } from "../../auth/types.js"; import { resolveSessionUser } from "../session-user.js"; import type { EmDashHandlers } from "../types.js"; import { buildEmDashCsp, getConfiguredStorageEndpoint } from "./csp.js"; const TRAILING_SLASH = /\/$/; /** * Where to send an unauthenticated admin request. Behind a custom-domain * proxy the request URL is the instance's default host, so the canonical * origin (`emdash:site_url`, the Site URL setting) wins; the request origin * is only the fallback before setup has recorded one. */ async function loginOrigin( url: URL, emdash: { config?: unknown; db?: unknown } | undefined, ): Promise { const configured = getConfiguredOrigin( emdash?.config as Parameters[0], ); if (configured) return configured; const db = emdash?.db as ConstructorParameters[0] | undefined; if (db) { try { const stored = await new OptionsRepository(db).get("emdash:site_url"); if (stored) return stored.replace(TRAILING_SLASH, ""); } catch { // options unavailable (pre-setup) — fall through } } return url.origin; } declare global { namespace App { interface Locals { user?: User; /** Token scopes when authenticated via API token or OAuth token. Undefined for session auth. */ /** Set when the request authenticated with a Bearer token (API token or OAuth). */ tokenAuth?: boolean; /** Set when the Bearer token's row opts into CORS (middleware/cors.ts reflects the Origin). */ tokenCors?: boolean; /** * Policy slugs carried by a policy-based API token. Null for a legacy * scoped token or an OAuth token; undefined for session auth. */ tokenPolicies?: string[] | null; /** Resolved role and grants for the authenticated principal. */ authz?: RequestAuthz; emdash?: EmDashHandlers; } interface SessionData { user: { id: string }; hasSeenWelcome: boolean; } } } // Role level constants (matching @premium-cms/auth) const ROLE_ADMIN = 50; const MCP_ENDPOINT_PATH = "/_emdash/api/mcp"; const COMMENT_SUBMISSION_PATH = /^\/_emdash\/api\/comments\/[^/]+\/[^/]+\/?$/; function isUnsafeMethod(method: string): boolean { return method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; } /** * Routes every signed-in principal may call regardless of policy: identity, * sign-out and managing one's own credentials. They are not an authorization * surface, and a role that could not reach them would lock its holders out * of the admin shell itself. */ const SELF_SERVICE_PREFIXES = [ "/_emdash/api/auth/me", "/_emdash/api/auth/logout", "/_emdash/api/auth/passkey", "/_emdash/api/auth/mode", "/_emdash/api/manifest", ]; function isSelfServiceRoute(pathname: string): boolean { return SELF_SERVICE_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + "/")); } /** * The route gate: refuse a request whose path is outside the principal's * route grants. Applies to sessions and tokens alike once grants have been * resolved; the per-permission checks inside handlers remain the second gate. */ function enforceRouteGrant(locals: App.Locals, pathname: string, method: string): Response | null { const authz = locals.authz; if (!authz) return null; // MCP authenticates here but authorizes per tool. if (pathname === MCP_ENDPOINT_PATH) return null; if (isSelfServiceRoute(pathname)) return null; if (grantsAllowRoute(authz.grants, method, pathname)) return null; return apiError( "ROUTE_NOT_GRANTED", `Your role does not grant ${method} ${toApiRelativePath(pathname)}`, 403, ); } /** Resolve grants for a user and put both on locals. */ async function attachAuthz( locals: App.Locals, emdash: EmDashHandlers, user: User, tokenPolicies: readonly string[] | null, ): Promise { const authz = await attachRequestAuthz(emdash.db, user, tokenPolicies); locals.user = authz.user; locals.authz = authz; return authz; } function csrfRejectedResponse(): Response { return apiError("CSRF_REJECTED", "Missing required header", 403); } function mcpUnauthorizedResponse( url: URL, config?: Parameters[1], ): Response { const origin = getPublicOrigin(url, config); const response = apiError("NOT_AUTHENTICATED", "Not authenticated", 401); // Preserve the OAuth discovery header so MCP clients can find the auth server. response.headers.set( "WWW-Authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, ); return response; } /** * API routes that skip auth — each handles its own access control. * * Prefix entries match any path starting with that prefix. * Exact entries (no trailing slash or wildcard) match that path only. */ const PUBLIC_API_PREFIXES = [ "/_emdash/api/setup", "/_emdash/api/auth/login", "/_emdash/api/auth/register", "/_emdash/api/auth/dev-bypass", "/_emdash/api/auth/signup/", "/_emdash/api/auth/magic-link/", "/_emdash/api/auth/invite/", "/_emdash/api/auth/oauth/", "/_emdash/api/oauth/device/token", "/_emdash/api/oauth/device/code", "/_emdash/api/oauth/token", "/_emdash/api/oauth/register", "/_emdash/api/comments/", "/_emdash/api/media/file/", "/_emdash/.well-known/", ]; const PUBLIC_API_EXACT = new Set([ "/_emdash/api/auth/passkey/options", "/_emdash/api/auth/passkey/verify", "/_emdash/api/auth/mode", "/_emdash/api/oauth/token", // Public site search — read-only. The query layer hardcodes status='published' // so unauthenticated callers only see published content. Admin endpoints // (/enable, /rebuild, /stats) remain private because they're not in this set. "/_emdash/api/search", "/_emdash/api/search/suggest", ]); // Build merged public routes at module load from auth provider descriptors. // Routes ending with "/" are treated as prefixes; all others are exact matches. const { exact: _providerExactRoutes, prefixes: _providerPrefixRoutes } = (() => { const exact = new Set(); const prefixes: string[] = []; if (!virtualConfig?.authProviders) return { exact, prefixes }; for (const route of virtualConfig.authProviders.flatMap((p) => p.publicRoutes ?? [])) { if (route.endsWith("/")) { prefixes.push(route); } else { exact.add(route); } } return { exact, prefixes }; })(); /** * OAuth protocol endpoints that are CSRF-exempt by design. * * These are RFC-defined endpoints (RFC 6749 §3.2, RFC 7591 §3, RFC 8628 §3.1/§3.4) * specified to be called cross-origin by external clients (MCP clients, CLIs, * native apps). They authenticate each request on its own merits: * * - /oauth/token: requires PKCE code_verifier, device_code, or refresh_token * - /oauth/register: RFC 7591 dynamic client registration — anonymous by design * - /oauth/device/code: RFC 8628 device flow initiation — anonymous by design * - /oauth/device/token: requires device_code the client already holds * * None of these rely on ambient cookie credentials, so browser-based CSRF * attacks have nothing to exploit. The endpoints themselves advertise * `Access-Control-Allow-Origin: *`. Note: /oauth/device/authorize (the user * consent step) is NOT in this list — it is session-authenticated. */ const CSRF_EXEMPT_PUBLIC_ROUTES = new Set([ "/_emdash/api/oauth/token", "/_emdash/api/oauth/register", "/_emdash/api/oauth/device/code", "/_emdash/api/oauth/device/token", ]); function isPublicEmDashRoute(pathname: string): boolean { if (PUBLIC_API_EXACT.has(pathname)) return true; if (PUBLIC_API_PREFIXES.some((p) => pathname.startsWith(p))) return true; if (_providerExactRoutes.has(pathname)) return true; if (_providerPrefixRoutes.some((p) => pathname.startsWith(p))) return true; if (import.meta.env.DEV && pathname === "/_emdash/api/typegen") return true; return false; } function isCsrfExemptPublicRoute(pathname: string): boolean { return CSRF_EXEMPT_PUBLIC_ROUTES.has(pathname); } export const onRequest = defineMiddleware(async (context, next) => { const { url } = context; // Only check auth on admin routes and API routes const isAdminRoute = url.pathname.startsWith("/_emdash/admin"); const isSetupRoute = url.pathname.startsWith("/_emdash/admin/setup"); const isApiRoute = url.pathname.startsWith("/_emdash/api"); const isPublicApiRoute = isPublicEmDashRoute(url.pathname); const isPublicRoute = !isAdminRoute && !isApiRoute; // Public API routes skip auth but still need CSRF protection on state-changing methods. // We check Origin header against the request host (same approach as Astro's checkOrigin). // This prevents cross-origin form submissions and fetch requests from malicious sites. if (isPublicApiRoute) { const method = context.request.method.toUpperCase(); if ( isUnsafeMethod(method) && !isCsrfExemptPublicRoute(url.pathname) // OAuth protocol endpoints — cross-origin by design ) { const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config); const csrfError = checkPublicCsrf(context.request, url, publicOrigin); if (csrfError) return csrfError; } if (method === "POST" && COMMENT_SUBMISSION_PATH.test(url.pathname)) { return handlePublicRouteAuth(context, next); } return next(); } // Plugin routes: soft auth (resolve user if credentials present, but never block). // The catch-all handler decides per-route whether auth is required (public vs private). // Public plugin routes that accept POST are vulnerable to cross-origin form submissions, // so we apply the same Origin-based CSRF check as other public routes. const isPluginRoute = url.pathname.startsWith("/_emdash/api/plugins/"); if (isPluginRoute) { const method = context.request.method.toUpperCase(); if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") { const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config); const csrfError = checkPublicCsrf(context.request, url, publicOrigin); if (csrfError) return csrfError; } return handlePluginRouteAuth(context, next); } // Setup routes: skip auth but still enforce CSRF on state-changing methods if (isSetupRoute) { const method = context.request.method.toUpperCase(); if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") { const csrfHeader = context.request.headers.get("X-EmDash-Request"); if (csrfHeader !== "1") { return apiError("CSRF_REJECTED", "Missing required header", 403); } } return next(); } // For public routes: soft auth check (set locals.user if session exists, but never block) if (isPublicRoute) { return handlePublicRouteAuth(context, next); } // --- Everything below is /_emdash (admin + API) --- // Try Bearer token auth first (API tokens and OAuth tokens). // If successful, skip CSRF (tokens aren't ambient credentials like cookies). const bearerResult = await handleBearerAuth(context); if (bearerResult === "invalid") { const response = apiError("INVALID_TOKEN", "Invalid or expired token", 401); // Add WWW-Authenticate header on MCP endpoint 401s to trigger OAuth discovery if (url.pathname === "/_emdash/api/mcp") { const origin = getPublicOrigin(url, context.locals.emdash?.config); response.headers.set( "WWW-Authenticate", `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, ); } return response; } const isTokenAuth = bearerResult === "authenticated"; // MCP discovery/tooling is bearer-only. Session/external auth should never // be consulted for this endpoint, and unauthenticated requests must return // the OAuth discovery-style 401 response. const method = context.request.method.toUpperCase(); const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH; if (isMcpEndpoint && !isTokenAuth) { return mcpUnauthorizedResponse(url, context.locals.emdash?.config); } // CSRF protection: require X-EmDash-Request header on state-changing requests. // Skip for token-authenticated requests (tokens aren't ambient credentials). // Browsers block cross-origin custom headers, so this prevents CSRF without tokens. // OAuth authorize consent is exempt: it's a standard HTML form POST that can't // include custom headers. The consent flow is protected by session + single-use codes. const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize"); if ( isApiRoute && !isTokenAuth && !isOAuthConsent && isUnsafeMethod(method) && !isPublicApiRoute ) { const csrfHeader = context.request.headers.get("X-EmDash-Request"); if (csrfHeader !== "1") { return csrfRejectedResponse(); } } // If already authenticated via Bearer token, enforce scope then skip session/external auth if (isTokenAuth) { // Policy tokens are gated by their route grants. Legacy scoped tokens // (and OAuth tokens) keep the prefix/method scope table as well. const routeError = enforceRouteGrant(context.locals, url.pathname, method); if (routeError) return routeError; const response = await next(); if (!import.meta.env.DEV) { response.headers.set( "Content-Security-Policy", buildEmDashCsp( context.locals.emdash?.config.experimental?.registry, getConfiguredStorageEndpoint( context.locals.emdash?.config.storage, context.locals.emdash?.storage, ), ), ); } return response; } const response = await handleEmDashAuth(context, next); // Set strict CSP on all /_emdash responses (prod only) if (!import.meta.env.DEV) { response.headers.set( "Content-Security-Policy", buildEmDashCsp( context.locals.emdash?.config.experimental?.registry, getConfiguredStorageEndpoint( context.locals.emdash?.config.storage, context.locals.emdash?.storage, ), ), ); } return response; }); /** * Auth handling for /_emdash routes. Returns a Response from either * an auth error/redirect or the downstream route handler. */ async function handleEmDashAuth( context: Parameters[0]>[0], next: Parameters[0]>[1], ): Promise { const { url, locals } = context; const { emdash } = locals; const isPublicAdminRoute = url.pathname.startsWith("/_emdash/admin/login") || url.pathname.startsWith("/_emdash/admin/invite/accept"); const isApiRoute = url.pathname.startsWith("/_emdash/api"); if (!emdash?.db) { // No database - let the admin handle this error return next(); } // Determine auth mode from config const authMode = getAuthMode(emdash.config); if (authMode.type === "external") { // In dev mode, fall back to passkey auth since external JWT won't be present if (import.meta.env.DEV) { if (isPublicAdminRoute) { return next(); } return handlePasskeyAuth(context, next, isApiRoute); } // External auth provider (Cloudflare Access, etc.) return handleExternalAuth(context, next, authMode, isApiRoute); } // Passkey authentication (default) if (isPublicAdminRoute) { return next(); } return handlePasskeyAuth(context, next, isApiRoute); } /** * Plugin-route auth. Resolves the user in three steps, stopping at the first that * applies: * * 1. Bearer token (all modes). A valid token authenticates; an invalid/expired one * returns 401 (we never silently downgrade a bad token to anonymous). * 2. External provider — only for a *private* route in production external-auth * mode. Here `handleExternalAuth` is the sole authority: the provider (e.g. * Cloudflare Access) is re-verified on every request, so it hard-blocks with * 401 on failure. It does persist an EmDash session (so public pages can * identify the user), but on these routes that session is deliberately NOT * consulted as a fallback — the provider check is authoritative every time. * 3. Session — everything else (non-external mode, DEV, and all public routes). * This is soft: it sets `locals.user` if a session exists but never blocks. * * Public routes are always allowed through. The catch-all handler still enforces * the `plugins:manage` permission and CSRF for private invocations. */ async function handlePluginRouteAuth( context: Parameters[0]>[0], next: Parameters[0]>[1], ): Promise { const { locals, url } = context; const { emdash } = locals; try { // Try Bearer token auth first (API tokens and OAuth tokens) const bearerResult = await handleBearerAuth(context); if (bearerResult === "authenticated") { // User and grants are set on locals by handleBearerAuth. // Private plugin routes sit inside the route grants like any other. if (!isPublicPluginApiRoute(url.pathname, emdash)) { const routeError = enforceRouteGrant( locals, url.pathname, context.request.method.toUpperCase(), ); if (routeError) return routeError; } return next(); } if (bearerResult === "invalid") { // A token was presented but is invalid/expired — return 401 so the // caller knows their token is bad (don't silently downgrade to no-auth). return apiError("INVALID_TOKEN", "Invalid or expired token", 401); } // "none" — no token presented, try external/session auth below. } catch (error) { console.error("Plugin route bearer auth error:", error); } const authMode = getAuthMode(emdash?.config); if ( authMode.type === "external" && !import.meta.env.DEV && !isPublicPluginApiRoute(url.pathname, emdash) ) { return handleExternalAuth(context, next, authMode, true); } try { // Try session auth (sets locals.user if session exists) const { session } = context; const sessionUser = await resolveSessionUser(session); if (sessionUser?.id && emdash?.db) { const adapter = createKyselyAdapter(emdash.db); const user = await adapter.getUserById(sessionUser.id); if (user && !user.disabled) { await attachAuthz(locals, emdash, user, null); } } } catch (error) { // Log but don't block — public routes should still work without session console.error("Plugin route session auth error:", error); } if (locals.authz && !isPublicPluginApiRoute(url.pathname, emdash)) { const routeError = enforceRouteGrant( locals, url.pathname, context.request.method.toUpperCase(), ); if (routeError) return routeError; } return next(); } function isPublicPluginApiRoute(pathname: string, emdash: EmDashHandlers | undefined): boolean { const prefix = "/_emdash/api/plugins/"; const route = pathname.slice(prefix.length); const slashIndex = route.indexOf("/"); if (slashIndex <= 0 || !emdash?.getPluginRouteMeta) return false; return ( emdash.getPluginRouteMeta(route.slice(0, slashIndex), route.slice(slashIndex))?.public === true ); } /** * Soft auth check for public routes with edit mode cookie. * Checks the session and sets locals.user if valid, but never blocks the request. */ async function handlePublicRouteAuth( context: Parameters[0]>[0], next: Parameters[0]>[1], ): Promise { const { locals, session } = context; const { emdash } = locals; try { const sessionUser = await resolveSessionUser(session); if (sessionUser?.id && emdash?.db) { const adapter = createKyselyAdapter(emdash.db); const user = await adapter.getUserById(sessionUser.id); if (user && !user.disabled) { await attachAuthz(locals, emdash, user, null); } } } catch { // Silently continue — public page should render normally } return next(); } /** * Handle external auth provider authentication (Cloudflare Access, etc.) */ async function handleExternalAuth( context: Parameters[0]>[0], next: Parameters[0]>[1], authMode: ExternalAuthMode, isApiRoute: boolean, ): Promise { const { locals, request, url } = context; const { emdash } = locals; try { // Use the authenticate function from the virtual module // (statically imported at build time to work with Cloudflare Workers) if (typeof virtualAuthenticate !== "function") { throw new Error( `Auth provider ${authMode.entrypoint} does not export an authenticate function`, ); } // Authenticate via the provider const authResult = await virtualAuthenticate(request, authMode.config); // Get external auth config for auto-provision settings // eslint-disable-next-line typescript/no-unsafe-type-assertion -- narrowing AuthModeConfig to ExternalAuthConfig after provider check const externalConfig = authMode.config as ExternalAuthConfig; // Find or create user const adapter = createKyselyAdapter(emdash.db); let user = await adapter.getUserByEmail(authResult.email); if (!user) { // User doesn't exist if (externalConfig.autoProvision === false) { return new Response("User not authorized", { status: 403, headers: { "Content-Type": "text/plain", ...MW_CACHE_HEADERS }, }); } // Check if this is the first user (they become admin) const userCount = await emdash.db .selectFrom("users") .select(emdash.db.fn.count("id").as("count")) .executeTakeFirst(); const isFirstUser = Number(userCount?.count ?? 0) === 0; const role = isFirstUser ? ROLE_ADMIN : authResult.role; // Create user const now = new Date().toISOString(); const newUser = { id: ulid(), email: authResult.email, name: authResult.name, role, role_id: `role:${builtinRoleForLevel(role).slug}`, email_verified: 1, created_at: now, updated_at: now, }; await emdash.db.insertInto("users").values(newUser).execute(); user = await adapter.getUserByEmail(authResult.email); console.log( `[external-auth] Provisioned user: ${authResult.email} (role: ${role}, first: ${isFirstUser})`, ); } else { // User exists - check if we need to sync anything const updates: Record = {}; let newName: string | undefined; let newRole: RoleLevel | undefined; // Sync name from provider if provider provides one and local differs if (authResult.name && user.name !== authResult.name) { newName = authResult.name; updates.name = newName; } // Sync role if enabled if (externalConfig.syncRoles && user.role !== authResult.role) { newRole = authResult.role; updates.role = newRole; } if (Object.keys(updates).length > 0) { updates.updated_at = new Date().toISOString(); await emdash.db.updateTable("users").set(updates).where("id", "=", user.id).execute(); user = { ...user, ...(newName ? { name: newName } : {}), ...(newRole ? { role: newRole } : {}), }; console.log( `[external-auth] Updated user ${authResult.email}:`, Object.keys(updates).filter((k) => k !== "updated_at"), ); } } if (!user) { // This shouldn't happen, but handle it gracefully return new Response("Failed to provision user", { status: 500, headers: { "Content-Type": "text/plain", ...MW_CACHE_HEADERS }, }); } // Check if user is disabled locally if (user.disabled) { return new Response("Account disabled", { status: 403, headers: { "Content-Type": "text/plain", ...MW_CACHE_HEADERS }, }); } // Set user in locals, with the grants their role resolves to await attachAuthz(locals, emdash, user, null); // Persist to session so public pages can identify the user // (external auth headers are only verified on /_emdash routes) const { session } = context; session?.set("user", { id: user.id }); if (isApiRoute) { const routeError = enforceRouteGrant(locals, url.pathname, request.method.toUpperCase()); if (routeError) return routeError; } return next(); } catch (error) { console.error("[external-auth] Auth error:", error); return new Response("Authentication failed", { status: 401, headers: { "Content-Type": "text/plain", ...MW_CACHE_HEADERS }, }); } } /** * Try to authenticate via Bearer token (API token or OAuth token). * * Returns: * - "authenticated" if token is valid and user is resolved * - "invalid" if a token was provided but is invalid/expired * - "none" if no Bearer token was provided */ async function handleBearerAuth( context: Parameters[0]>[0], ): Promise<"authenticated" | "invalid" | "none"> { const authHeader = context.request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) return "none"; const token = authHeader.slice(7); if (!token) return "none"; const { locals } = context; const { emdash } = locals; if (!emdash?.db) return "none"; // Resolve token based on prefix let resolved: { userId: string; policies: string[] | null; cors?: boolean } | null = null; if (token.startsWith("ec_pat_")) { resolved = await resolveApiToken(emdash.db, token); } else if (token.startsWith("ec_oat_")) { resolved = await resolveOAuthToken(emdash.db, token); } else { // Unknown token format return "invalid"; } if (!resolved) return "invalid"; // Look up the user const adapter = createKyselyAdapter(emdash.db); const user = await adapter.getUserById(resolved.userId); if (!user || user.disabled) return "invalid"; // Resolve the owner's grants and clamp the token inside them: a token can // never do more than the user who minted it, however it was scoped. const authz = await attachAuthz(locals, emdash, user, resolved.policies); locals.tokenPolicies = resolved.policies; locals.tokenAuth = true; locals.tokenCors = resolved.cors === true; return "authenticated"; } /** * Handle passkey (session-based) authentication */ async function handlePasskeyAuth( context: Parameters[0]>[0], next: Parameters[0]>[1], isApiRoute: boolean, ): Promise { const { url, locals, session } = context; const { emdash } = locals; try { // Check session for user (session.get returns a Promise) const sessionUser = await resolveSessionUser(session); if (!sessionUser?.id) { if (isApiRoute) { return apiError("NOT_AUTHENTICATED", "Not authenticated", 401); } const loginUrl = new URL("/_emdash/admin/login", await loginOrigin(url, emdash)); loginUrl.searchParams.set("redirect", url.pathname); return context.redirect(loginUrl.toString()); } // Get full user from database const adapter = createKyselyAdapter(emdash.db); const user = await adapter.getUserById(sessionUser.id); if (!user) { // User no longer exists - clear session session?.destroy(); if (isApiRoute) { return apiError("NOT_FOUND", "User not found", 401); } const loginUrl = new URL("/_emdash/admin/login", await loginOrigin(url, emdash)); return context.redirect(loginUrl.toString()); } // Check if user is disabled if (user.disabled) { session?.destroy(); if (isApiRoute) { return apiError("ACCOUNT_DISABLED", "Account disabled", 403); } const loginUrl = new URL("/_emdash/admin/login", await loginOrigin(url, emdash)); loginUrl.searchParams.set("error", "account_disabled"); return context.redirect(loginUrl.toString()); } // Set user in locals for use by routes, with the grants their role resolves to await attachAuthz(locals, emdash, user, null); if (isApiRoute) { const routeError = enforceRouteGrant( locals, url.pathname, context.request.method.toUpperCase(), ); if (routeError) return routeError; } } catch (error) { console.error("Auth middleware error:", error); // On error, redirect to login return context.redirect("/_emdash/admin/login"); } return next(); } // ============================================================================= // Token scope enforcement // =============================================================================