import { agentAuth } from "@better-auth/agent-auth"; import { apiKey } from "@better-auth/api-key"; import { type BetterAuthPlugin, betterAuth } from "better-auth"; import { admin, bearer, genericOAuth } from "better-auth/plugins"; import { Kysely } from "kysely"; import { D1Dialect } from "kysely-d1"; import { type D1, queryDb } from "./db"; import { sendVerificationEmail } from "./emailService"; import { getPool } from "./pgDb"; import type { Env } from "./types"; // AMA can only be unlinked once the user has no AMA-backed resources left: // latest worker agents that actually have a backing AMA agent, or machines. // Leaders, builtin agents, old snapshots, and pre-AMA rows still missing an // ama_agent_id are AK-only records and must not block disconnect. export async function hasAmaResources(db: D1, ownerId: string): Promise { const agentRes = await queryDb( db, "SELECT 1 FROM agents WHERE owner_id = $1 AND (builtin = 0) AND kind = 'worker' AND version = 'latest' AND ama_agent_id IS NOT NULL LIMIT 1", [ownerId], ); if (agentRes.rows.length > 0) return true; const machineRes = await queryDb(db, "SELECT 1 FROM machines WHERE owner_id = $1 LIMIT 1", [ownerId]); return machineRes.rows.length > 0; } // Registers AMA as a generic OIDC provider so each AK user can link their own // AMA account. Only added when AMA OIDC is configured; standalone AK skips it. function amaProviderPlugins(env: Env): BetterAuthPlugin[] { const issuer = env.AMA_OIDC_ISSUER; if (!issuer || !env.AMA_OIDC_CLIENT_ID || !env.AMA_OIDC_CLIENT_SECRET) return []; const resource = amaOidcResource(env); return [ genericOAuth({ config: [ { providerId: "ama", discoveryUrl: oidcDiscoveryUrl(issuer), clientId: env.AMA_OIDC_CLIENT_ID, clientSecret: env.AMA_OIDC_CLIENT_SECRET, authentication: "basic", scopes: amaOidcScopes(env), pkce: true, ...(resource ? { authorizationUrlParams: { resource }, tokenUrlParams: { resource } } : {}), }, ], }), ]; } export function oidcDiscoveryUrl(issuer: string): string { return `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`; } function amaOidcScopes(env: Env): string[] { return ( env.AMA_OIDC_SCOPES?.trim() .split(/[\s,]+/) .filter(Boolean) ?? ["openid", "profile", "email", "offline_access"] ); } export function amaOidcResource(env: Pick): string | null { const origin = env.AMA_ORIGIN?.trim().replace(/\/+$/, ""); return origin || null; } export function createAuth(env: Env) { const dbBinding = env?.DB as D1 | undefined; // Always prefer PostgreSQL when DATABASE_URL is configured — even in dev // mode where miniflare provides a real D1 (SQLite) binding via env.DB. const hasPostgres = !!process.env.DATABASE_URL; const useSqliteD1 = !hasPostgres && !!dbBinding && typeof dbBinding.prepare === "function" && typeof (dbBinding as { transaction?: unknown }).transaction !== "function"; // Better Auth owns the user/session/account/verification/apikey/agent tables // and talks to the database directly rather than through env.DB. It gets the // same pg.Pool the rest of the server uses, so auth writes and application // writes share one connection pool and one transaction manager. return betterAuth({ database: useSqliteD1 ? { db: new Kysely({ dialect: new D1Dialect({ database: dbBinding as D1Database }), }), type: "sqlite" as const, } : getPool(), basePath: "/api/auth", baseURL: { allowedHosts: authAllowedHosts(env), fallback: `https://${env.ALLOWED_HOSTS.split(",")[0]}`, protocol: "auto", }, trustedOrigins: [ "http://localhost:6265", "http://127.0.0.1:6265", "http://localhost:5173", "http://localhost:8787", "http://127.0.0.1:8787", ], secret: env.AUTH_SECRET, // The AK user links their AMA account (a separate FlareAuth identity) whose // email need not match their AK login email, so account linking must allow // different emails — otherwise BetterAuth rejects the link with // "email_doesn't_match". Linking is user-initiated and authenticated, and the // linked token is only used for that user's own AMA calls. account: { accountLinking: { enabled: true, trustedProviders: ["ama"], allowDifferentEmails: true, }, }, emailAndPassword: { enabled: true, // VTIT fork: bỏ hẳn xác thực email. Hàm gửi thư của bản gốc dùng binding // EMAIL của Cloudflare — binding này không tồn tại ngoài Workers nên user // không bao giờ nhận được thư và bị kẹt ở màn hình chờ xác thực. // Đăng nhập đúng email/mật khẩu là vào thẳng hệ thống. requireEmailVerification: false, customSyntheticUser: ({ coreFields, additionalFields, id }) => ({ ...coreFields, role: "user", banned: false, banReason: null, banExpires: null, ...additionalFields, id, }), }, // VTIT fork: không tự gửi mail xác thực khi đăng nhập/đăng ký nữa. // Link /auth/verify cũ vẫn hoạt động cho tài khoản đã nhận mail trước đây. emailVerification: { autoSignInAfterVerification: true, sendOnSignIn: false, sendVerificationEmail: async ({ user, url }, request) => { await sendVerificationEmail(env, user.email, verificationPageUrl(env, url, request)); }, }, socialProviders: { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, scope: ["user", "admin:gpg_key"], }, }, plugins: [ bearer(), // Admin plugin enables /api/auth/admin/* endpoints (list-users, ban-user, set-role, etc.) // First admin must be set manually via D1 console: // UPDATE user SET role = 'admin' WHERE email = '...'; admin(), apiKey([ { configId: "default", defaultPrefix: "ak_", enableMetadata: true, rateLimit: { enabled: false }, }, { configId: "maintainer", defaultPrefix: "ak_maint_", enableMetadata: true, rateLimit: { enabled: true, maxRequests: 60, timeWindow: 60_000 }, permissions: { defaultPermissions: { maintainerSession: ["create"] }, }, }, ]), agentAuth({ allowedKeyAlgorithms: ["Ed25519"], agentSessionTTL: 86400, getAgentIdentity: async (agentId: string) => { if (env?.DB && typeof env.DB.prepare === "function") { const row = await env.DB.prepare("SELECT public_key, fingerprint FROM agents WHERE id = ?") .bind(agentId) .first<{ public_key: string; fingerprint: string }>(); if (!row) return null; return { publicKeyBase64: row.public_key, fingerprint: row.fingerprint }; } const res = await queryDb<{ public_key: string; fingerprint: string }>( env?.DB, "SELECT public_key, fingerprint FROM agents WHERE id = $1", [agentId], ); const row = res.rows[0]; if (!row) return null; return { publicKeyBase64: row.public_key, fingerprint: row.fingerprint }; }, } as any), ...amaProviderPlugins(env), ], }); } function verificationPageUrl(env: Pick, rawUrl: string, request?: Request): string { const origin = requestOrigin(request) ?? `https://${env.ALLOWED_HOSTS?.split?.(",")?.[0] ?? "localhost"}`; const baseUrl = origin.startsWith("http://") || origin.startsWith("https://") ? origin : `https://${origin}`; const pathWithAuth = rawUrl.startsWith("/verify-email") ? rawUrl.replace("/verify-email", "/auth/verify") : rawUrl; const parsed = new URL(pathWithAuth, baseUrl); return parsed.toString(); } function requestOrigin(request?: Request): string | null { if (!request) return null; const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); if (!host) return null; const proto = request.headers.get("x-forwarded-proto") ?? "https"; return `${proto}://${host}`; } export function authAllowedHosts(env: Pick): string[] { const custom = env.ALLOWED_HOSTS.split(",") .map((h) => h.trim()) .filter(Boolean); return Array.from(new Set([...custom, "localhost", "127.0.0.1"])); }