|
| 1 | +/** |
| 2 | + * better-auth config factory for agent products (#188 Phase 1). Every product |
| 3 | + * hand-rolls the same ~70–150 line setup — drizzle adapter over the standard |
| 4 | + * users/sessions/accounts/verifications tables, email+password with Resend |
| 5 | + * reset/verification mail, env-gated GitHub/Google social providers, session |
| 6 | + * cookie cache, and a per-app cookie prefix — and tax additionally |
| 7 | + * re-implemented better-auth's cookie signing for its Tangle SSO callback. |
| 8 | + * `createAppAuth` owns that mechanism once and returns the configured |
| 9 | + * better-auth instance plus the request guards products actually use. |
| 10 | + * |
| 11 | + * Domain stays a parameter: the drizzle db + schema, email client, provider |
| 12 | + * credentials, and SSO store/client all come from the product. No product |
| 13 | + * import, no engine re-implementation — the SSO path composes the existing |
| 14 | + * `platform/sso` cookie minter (`createBetterAuthSessionCookieMinter`), which |
| 15 | + * is byte-compatible with better-auth's own `makeSignature` contract. |
| 16 | + * |
| 17 | + * better-auth is an OPTIONAL peer: only this subpath imports it, so it is |
| 18 | + * deliberately NOT re-exported from the root barrel. |
| 19 | + */ |
| 20 | + |
| 21 | +import { betterAuth, type Auth, type BetterAuthOptions, type Session, type User } from 'better-auth' |
| 22 | +import { drizzleAdapter } from 'better-auth/adapters/drizzle' |
| 23 | +import { createAuthGuard, type AuthGuard } from '../platform/guards' |
| 24 | +import { |
| 25 | + createBetterAuthSessionCookieMinter, |
| 26 | + createTangleSsoHandlers, |
| 27 | + type TangleSsoAccountStore, |
| 28 | + type TangleSsoAuthClient, |
| 29 | + type TangleSsoHandlers, |
| 30 | +} from '../platform/sso' |
| 31 | + |
| 32 | +const DEFAULT_STATE_COOKIE = 'tangle_sso_state' |
| 33 | +const DEFAULT_SESSION_COOKIE_CACHE_SECONDS = 5 * 60 |
| 34 | + |
| 35 | +/** Structural slice of a Resend-style client — no `resend` import. */ |
| 36 | +export interface AppAuthEmailClient { |
| 37 | + emails: { |
| 38 | + send(message: { |
| 39 | + from: string |
| 40 | + to: string |
| 41 | + subject: string |
| 42 | + html: string |
| 43 | + text?: string |
| 44 | + }): Promise<unknown> |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +export interface AppAuthEmailConfig { |
| 49 | + /** A Resend-style client, or a lazy getter returning null when the API key |
| 50 | + * is absent (the products' dev default — mail is skipped with a warning, |
| 51 | + * sign-up itself must not crash). */ |
| 52 | + resend: AppAuthEmailClient | (() => AppAuthEmailClient | null) |
| 53 | + /** RFC 5322 From, e.g. `'Legal Agent <noreply@legal.tangle.tools>'`. */ |
| 54 | + from: string |
| 55 | + /** Send a verification email on sign-up and auto-sign-in after verifying |
| 56 | + * (the tax/gtm behavior). Default false (the legal behavior). */ |
| 57 | + verifyOnSignUp?: boolean |
| 58 | + /** Receives the "email client unavailable" warning. Default console.warn. */ |
| 59 | + warn?: (message: string) => void |
| 60 | +} |
| 61 | + |
| 62 | +/** Env-shaped: pass the env vars straight through; the provider is registered |
| 63 | + * only when BOTH values are non-empty, so unset env disables it. */ |
| 64 | +export interface AppAuthSocialProviderConfig { |
| 65 | + clientId?: string |
| 66 | + clientSecret?: string |
| 67 | +} |
| 68 | + |
| 69 | +export interface AppAuthSocialConfig { |
| 70 | + github?: AppAuthSocialProviderConfig |
| 71 | + google?: AppAuthSocialProviderConfig |
| 72 | +} |
| 73 | + |
| 74 | +/** Cross-site Tangle SSO wiring. The factory supplies the better-auth side — |
| 75 | + * `setSessionCookie` via `createBetterAuthSessionCookieMinter(auth)` (signed |
| 76 | + * `__Secure-`/prefixed cookie that `auth.api.getSession` accepts) — so the |
| 77 | + * product no longer touches `better-auth/crypto` itself. */ |
| 78 | +export interface AppAuthSsoConfig { |
| 79 | + /** Platform wire client (authorizeUrl + exchange). */ |
| 80 | + client: TangleSsoAuthClient |
| 81 | + /** Product persistence: user upsert, session row, platform link. */ |
| 82 | + store: TangleSsoAccountStore |
| 83 | + /** Absolute callback URL registered with the platform. */ |
| 84 | + callbackUrl: string |
| 85 | + /** Default 'tangle_sso_state'. */ |
| 86 | + stateCookieName?: string |
| 87 | + /** HMAC secret for the CSRF state cookie. Default: the auth `secret`. */ |
| 88 | + stateSecret?: string |
| 89 | + /** Default: `baseURL` is https. Must match better-auth's own |
| 90 | + * secure-cookie decision or the cookie name lookup diverges. */ |
| 91 | + secureCookies?: boolean |
| 92 | + sessionTtlSeconds?: number |
| 93 | + stateTtlSeconds?: number |
| 94 | + /** Post-login redirect fallback. Default '/app'. */ |
| 95 | + defaultRedirectPath?: string |
| 96 | + /** Default: the top-level `loginPath`. */ |
| 97 | + loginPath?: string |
| 98 | + /** Failure log hook (e.g. console.error). Default no-op. */ |
| 99 | + log?: (message: string, error?: unknown) => void |
| 100 | +} |
| 101 | + |
| 102 | +export interface AppAuthSchema { |
| 103 | + users: unknown |
| 104 | + sessions: unknown |
| 105 | + accounts: unknown |
| 106 | + verifications: unknown |
| 107 | +} |
| 108 | + |
| 109 | +export interface AppAuthConfig { |
| 110 | + /** Product name — used in email subjects and as the cookie-prefix default. */ |
| 111 | + appName: string |
| 112 | + /** Absolute origin better-auth serves from (`BETTER_AUTH_URL`). */ |
| 113 | + baseURL: string |
| 114 | + /** better-auth HMAC secret. Optional only because better-auth falls back to |
| 115 | + * the BETTER_AUTH_SECRET env var; `sso` needs it explicitly (or |
| 116 | + * `sso.stateSecret`). */ |
| 117 | + secret?: string |
| 118 | + trustedOrigins?: string[] |
| 119 | + /** |
| 120 | + * Session-cookie prefix. Default: slugified `appName`. A per-app prefix is |
| 121 | + * load-bearing, not cosmetic: the platform (id.tangle.tools) mints its own |
| 122 | + * better-auth cookie `Domain=.tangle.tools`-wide under the DEFAULT name, and |
| 123 | + * the platform's (older) cookie wins the Cookie-header order — an app on the |
| 124 | + * default prefix reads the platform's token, fails its own signature check, |
| 125 | + * and every fresh login lands back on /login. |
| 126 | + */ |
| 127 | + cookiePrefix?: string |
| 128 | + /** Drizzle database instance; wired through better-auth's drizzle adapter |
| 129 | + * together with `schema`. */ |
| 130 | + db?: unknown |
| 131 | + /** The product's users/sessions/accounts/verifications tables (mapped to |
| 132 | + * better-auth's user/session/account/verification models). */ |
| 133 | + schema?: AppAuthSchema |
| 134 | + /** Drizzle dialect. Default 'sqlite' (D1). */ |
| 135 | + provider?: 'sqlite' | 'pg' | 'mysql' |
| 136 | + /** Escape hatch: a pre-built better-auth database adapter (e.g. |
| 137 | + * `memoryAdapter` in tests). Wins over `db`/`schema`. */ |
| 138 | + database?: BetterAuthOptions['database'] |
| 139 | + /** Email+password sign-in. Default true (all current products enable it). */ |
| 140 | + emailAndPassword?: boolean |
| 141 | + /** Reset/verification mail. Omit to disable password reset entirely. */ |
| 142 | + email?: AppAuthEmailConfig |
| 143 | + social?: AppAuthSocialConfig |
| 144 | + /** Session cookie-cache TTL in seconds; `false` disables the cache. |
| 145 | + * Default 300. */ |
| 146 | + sessionCookieCacheSeconds?: number | false |
| 147 | + /** Tangle cross-site SSO (start/callback handlers). */ |
| 148 | + sso?: AppAuthSsoConfig |
| 149 | + /** Where guards redirect unauthenticated page requests. Default '/login'. */ |
| 150 | + loginPath?: string |
| 151 | + /** Merged over the factory's `advanced` block (cookiePrefix stays unless |
| 152 | + * overridden here). */ |
| 153 | + advanced?: BetterAuthOptions['advanced'] |
| 154 | +} |
| 155 | + |
| 156 | +/** The configured better-auth instance, typed at better-auth's base surface |
| 157 | + * (`handler`, `api`, `$context`, `$Infer`). */ |
| 158 | +export type AppAuthInstance = Auth |
| 159 | + |
| 160 | +/** What `getSession`/guards resolve: better-auth's base session + user rows. */ |
| 161 | +export interface AppAuthSession { |
| 162 | + session: Session |
| 163 | + user: User |
| 164 | +} |
| 165 | + |
| 166 | +export interface AppAuth extends AuthGuard<AppAuthSession> { |
| 167 | + auth: AppAuthInstance |
| 168 | + /** `auth.api.getSession` over a `Request` — the seam the guards consume. */ |
| 169 | + getSession(request: Request): Promise<AppAuthSession | null> |
| 170 | + /** Tangle SSO start/callback handlers; null unless `sso` was configured. */ |
| 171 | + sso: TangleSsoHandlers | null |
| 172 | +} |
| 173 | + |
| 174 | +/** Lowercased, non-alphanumerics collapsed to '-': `'Legal Agent'` → `'legal-agent'`. */ |
| 175 | +function slugify(name: string): string { |
| 176 | + return name |
| 177 | + .toLowerCase() |
| 178 | + .replace(/[^a-z0-9]+/g, '-') |
| 179 | + .replace(/^-+|-+$/g, '') |
| 180 | +} |
| 181 | + |
| 182 | +function resolveEmailClient(email: AppAuthEmailConfig): AppAuthEmailClient | null { |
| 183 | + return typeof email.resend === 'function' ? email.resend() : email.resend |
| 184 | +} |
| 185 | + |
| 186 | +/** Resend reports failures in the resolved value, not by rejecting — surface |
| 187 | + * them loud so better-auth's flow (and the caller's logs) see the failure. */ |
| 188 | +async function sendEmail( |
| 189 | + client: AppAuthEmailClient, |
| 190 | + message: { from: string; to: string; subject: string; html: string; text: string }, |
| 191 | +): Promise<void> { |
| 192 | + const result = (await client.emails.send(message)) as { error?: { message?: string } | null } | null | undefined |
| 193 | + if (result && typeof result === 'object' && result.error) { |
| 194 | + throw new Error(`[app-auth] email send failed: ${result.error.message ?? 'unknown error'}`) |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +function resolveDatabase(config: AppAuthConfig): NonNullable<BetterAuthOptions['database']> { |
| 199 | + if (config.database) return config.database |
| 200 | + if (config.db && config.schema) { |
| 201 | + return drizzleAdapter(config.db as Record<string, unknown>, { |
| 202 | + provider: config.provider ?? 'sqlite', |
| 203 | + schema: { |
| 204 | + user: config.schema.users, |
| 205 | + session: config.schema.sessions, |
| 206 | + account: config.schema.accounts, |
| 207 | + verification: config.schema.verifications, |
| 208 | + } as Record<string, unknown>, |
| 209 | + }) |
| 210 | + } |
| 211 | + throw new Error( |
| 212 | + 'createAppAuth requires a database: pass `db` + `schema` (drizzle) or `database` (a better-auth adapter)', |
| 213 | + ) |
| 214 | +} |
| 215 | + |
| 216 | +function resolveSocialProviders(social: AppAuthSocialConfig | undefined): BetterAuthOptions['socialProviders'] { |
| 217 | + const providers: NonNullable<BetterAuthOptions['socialProviders']> = {} |
| 218 | + if (social?.github?.clientId && social.github.clientSecret) { |
| 219 | + providers.github = { clientId: social.github.clientId, clientSecret: social.github.clientSecret } |
| 220 | + } |
| 221 | + if (social?.google?.clientId && social.google.clientSecret) { |
| 222 | + providers.google = { clientId: social.google.clientId, clientSecret: social.google.clientSecret } |
| 223 | + } |
| 224 | + return Object.keys(providers).length > 0 ? providers : undefined |
| 225 | +} |
| 226 | + |
| 227 | +function emailAndPasswordOptions(config: AppAuthConfig): BetterAuthOptions['emailAndPassword'] { |
| 228 | + const email = config.email |
| 229 | + if (!email) return { enabled: true } |
| 230 | + const warn = email.warn ?? ((message: string) => console.warn(message)) |
| 231 | + return { |
| 232 | + enabled: true, |
| 233 | + sendResetPassword: async ({ user, url }) => { |
| 234 | + const client = resolveEmailClient(email) |
| 235 | + if (!client) { |
| 236 | + warn('[app-auth] email client unavailable — password reset email not sent') |
| 237 | + return |
| 238 | + } |
| 239 | + await sendEmail(client, { |
| 240 | + from: email.from, |
| 241 | + to: user.email, |
| 242 | + subject: 'Reset your password', |
| 243 | + html: `<p>Click the link below to reset your password:</p><p><a href="${url}">${url}</a></p><p>This link expires in 1 hour.</p>`, |
| 244 | + text: `Reset your password:\n\n${url}\n\nThis link expires in 1 hour.`, |
| 245 | + }) |
| 246 | + }, |
| 247 | + } |
| 248 | +} |
| 249 | + |
| 250 | +function emailVerificationOptions(config: AppAuthConfig): BetterAuthOptions['emailVerification'] { |
| 251 | + const email = config.email |
| 252 | + if (!email?.verifyOnSignUp) return undefined |
| 253 | + const warn = email.warn ?? ((message: string) => console.warn(message)) |
| 254 | + return { |
| 255 | + sendOnSignUp: true, |
| 256 | + autoSignInAfterVerification: true, |
| 257 | + sendVerificationEmail: async ({ user, url }) => { |
| 258 | + const client = resolveEmailClient(email) |
| 259 | + if (!client) { |
| 260 | + warn('[app-auth] email client unavailable — verification email not sent') |
| 261 | + return |
| 262 | + } |
| 263 | + await sendEmail(client, { |
| 264 | + from: email.from, |
| 265 | + to: user.email, |
| 266 | + subject: `Verify your ${config.appName} email`, |
| 267 | + html: `<p>Verify your email to finish accessing ${config.appName}:</p><p><a href="${url}">${url}</a></p><p>This link expires in 1 hour.</p>`, |
| 268 | + text: `Verify your email to finish accessing ${config.appName}:\n\n${url}\n\nThis link expires in 1 hour.`, |
| 269 | + }) |
| 270 | + }, |
| 271 | + } |
| 272 | +} |
| 273 | + |
| 274 | +/** |
| 275 | + * Build the product's better-auth instance plus the request-boundary helpers: |
| 276 | + * `getSession` (Request → session|null), the `createAuthGuard` quartet |
| 277 | + * (`requireUser` 302, `requireApiUser` JSON 401, `requireSession`, |
| 278 | + * `getOptionalSession`), and — when `sso` is configured — the Tangle SSO |
| 279 | + * start/callback handlers with the session cookie minted through better-auth's |
| 280 | + * own name/attributes/signing (no `better-auth/crypto` in product code). |
| 281 | + */ |
| 282 | +export function createAppAuth(config: AppAuthConfig): AppAuth { |
| 283 | + if (!config.appName) throw new Error('createAppAuth: appName is required') |
| 284 | + if (!config.baseURL) throw new Error('createAppAuth: baseURL is required') |
| 285 | + |
| 286 | + const cookiePrefix = config.cookiePrefix ?? slugify(config.appName) |
| 287 | + if (!cookiePrefix) throw new Error('createAppAuth: cookiePrefix (or a slugifiable appName) is required') |
| 288 | + |
| 289 | + const socialProviders = resolveSocialProviders(config.social) |
| 290 | + const options: BetterAuthOptions = { |
| 291 | + appName: config.appName, |
| 292 | + baseURL: config.baseURL, |
| 293 | + ...(config.secret ? { secret: config.secret } : {}), |
| 294 | + ...(config.trustedOrigins ? { trustedOrigins: config.trustedOrigins } : {}), |
| 295 | + database: resolveDatabase(config), |
| 296 | + ...(config.emailAndPassword === false ? {} : { emailAndPassword: emailAndPasswordOptions(config) }), |
| 297 | + ...(config.email?.verifyOnSignUp ? { emailVerification: emailVerificationOptions(config) } : {}), |
| 298 | + ...(socialProviders ? { socialProviders } : {}), |
| 299 | + ...(config.sessionCookieCacheSeconds === false |
| 300 | + ? {} |
| 301 | + : { |
| 302 | + session: { |
| 303 | + cookieCache: { |
| 304 | + enabled: true, |
| 305 | + maxAge: config.sessionCookieCacheSeconds ?? DEFAULT_SESSION_COOKIE_CACHE_SECONDS, |
| 306 | + }, |
| 307 | + }, |
| 308 | + }), |
| 309 | + advanced: { cookiePrefix, ...config.advanced }, |
| 310 | + } |
| 311 | + |
| 312 | + const auth: AppAuthInstance = betterAuth(options) |
| 313 | + |
| 314 | + const getSession = async (request: Request): Promise<AppAuthSession | null> => { |
| 315 | + const session = await auth.api.getSession({ headers: request.headers }) |
| 316 | + return (session as AppAuthSession | null) ?? null |
| 317 | + } |
| 318 | + |
| 319 | + const loginPath = config.loginPath ?? '/login' |
| 320 | + const guard = createAuthGuard<AppAuthSession>({ getSession, loginPath }) |
| 321 | + |
| 322 | + let sso: TangleSsoHandlers | null = null |
| 323 | + if (config.sso) { |
| 324 | + const stateSecret = config.sso.stateSecret ?? config.secret |
| 325 | + if (!stateSecret) { |
| 326 | + throw new Error( |
| 327 | + 'createAppAuth: sso requires `secret` (or sso.stateSecret) — the signed-state CSRF cookie needs an HMAC secret', |
| 328 | + ) |
| 329 | + } |
| 330 | + sso = createTangleSsoHandlers({ |
| 331 | + auth: config.sso.client, |
| 332 | + store: config.sso.store, |
| 333 | + stateSecret, |
| 334 | + callbackUrl: config.sso.callbackUrl, |
| 335 | + stateCookieName: config.sso.stateCookieName ?? DEFAULT_STATE_COOKIE, |
| 336 | + setSessionCookie: createBetterAuthSessionCookieMinter(auth), |
| 337 | + secureCookies: config.sso.secureCookies ?? config.baseURL.startsWith('https:'), |
| 338 | + ...(config.sso.sessionTtlSeconds !== undefined ? { sessionTtlSeconds: config.sso.sessionTtlSeconds } : {}), |
| 339 | + ...(config.sso.stateTtlSeconds !== undefined ? { stateTtlSeconds: config.sso.stateTtlSeconds } : {}), |
| 340 | + ...(config.sso.defaultRedirectPath ? { defaultRedirectPath: config.sso.defaultRedirectPath } : {}), |
| 341 | + loginPath: config.sso.loginPath ?? loginPath, |
| 342 | + ...(config.sso.log ? { log: config.sso.log } : {}), |
| 343 | + }) |
| 344 | + } |
| 345 | + |
| 346 | + return { auth, getSession, ...guard, sso } |
| 347 | +} |
0 commit comments