feat(auth): email login & profile (#1745)

Co-authored-by: Liet Blue <127093491+lietblue@users.noreply.github.com>
This commit is contained in:
RainbowBird
2026-04-28 00:07:38 +08:00
committed by GitHub
co-authored by Liet Blue
parent 0346aa729e
commit 172e4ce59c
50 changed files with 3469 additions and 385 deletions
+5
View File
@@ -29,6 +29,10 @@
- traces / metrics 命名规则,标准 OTel 字段与 `airi.*` 自定义字段边界
- `auth-and-oidc.md`
- 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录
- `email-auth-resend.md`
- Resend 接入、Better Auth 四个邮件 callback、范围 / 决策 / 不做项
- `verifications/email-auth.md`
- 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据
## 快速结论
@@ -50,3 +54,4 @@
- 改扣费、充值、Stripe:先看 `billing-architecture.md`
- 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md`
- 改认证、OIDC、登录流程:先看 `auth-and-oidc.md`
- 改邮件 service / Better Auth 邮件 callback:先看 `email-auth-resend.md`
@@ -0,0 +1,78 @@
# Email auth via Resend (apps/server + apps/ui-server-auth)
Status: in progress
Last updated: 2026-04-27
## Goal
1. 接入 **Resend** 作为 `apps/server` 的统一邮件发送 service。
2. 把 Better Auth 的四个邮件回调接好:
- `emailVerification.sendVerificationEmail`(注册后验证邮箱)
- `emailAndPassword.sendResetPassword`(忘记密码)
- `user.changeEmail.sendChangeEmailVerification`(改邮箱)
- `magicLink.sendMagicLink`passwordless 登录,启用 plugin
3.`apps/ui-server-auth` 加上邮箱注册 / 邮箱密码登录 / 忘记密码 / 重置密码 等界面。
## 用户路径(必须端到端跑通)
只有这两条本期要 ship
1. **注册路径**:用户敲 `/sign-up` → 填邮箱 + 密码 → 提交 → 进 `verify-email` 提示页 → 收邮件点链接 → `verify-email?token=...` 落地页提示成功 → 跳 `/sign-in`
2. **忘记密码路径**:用户在 `/sign-in` 点 "Forgot password" → 进 `/forgot-password` 输邮箱 → 提交 → 提示已发送 → 用户点邮件链接 → `/reset-password?token=...` 输新密码 → 跳 `/sign-in`
3. **常规邮箱登录**`/sign-in` 输邮箱 + 密码 → 走 OIDC `loginPage` 流程把用户登入,返回上游 `/oauth/authorize`
服务端为 magic link / change email 接好回调(避免功能闭包不齐一半),但前端 UI 留待后续。Service 拒绝静默吞错——发送失败要走错误响应让 Better Auth 把错抛回前端。
## 范围明确
In:
- `apps/server/src/services/email.ts`:统一 `EmailService` 接口(`sendVerification` / `sendPasswordReset` / `sendMagicLink` / `sendChangeEmail`),每个方法对应一个 HTML + plaintext 模板。
- `apps/server/src/libs/auth.ts`:装上 4 个 callback;启用 `requireEmailVerification: true`;加载 `magicLink` plugin。
- `apps/server/src/libs/env.ts`:新增 `RESEND_API_KEY`(必填)、`RESEND_FROM_EMAIL`(必填)、`RESEND_FROM_NAME`(可选)、`AUTH_EMAIL_VERIFY_REDIRECT_URL` / `AUTH_PASSWORD_RESET_REDIRECT_URL`(可选,默认根据 `API_SERVER_URL` 推算 ui-server-auth origin)。
- `apps/server/src/app.ts`:把 `EmailService` 通过 `injeca` 装配,注入到 `auth` provider。
- `apps/ui-server-auth/src/pages`:扩 `sign-in.vue`;新增 `sign-up.vue``verify-email.vue``forgot-password.vue``reset-password.vue`
- `apps/ui-server-auth/src/modules/sign-in.ts` 同级补 `email-password.ts` 处理 emailPassword sign-in/up + forgot/reset 的真实调用。
- `packages/i18n`:新增 auth.signUp / verifyEmail / forgotPassword / resetPassword 字段。
Out:
- Magic link 前端 UI`magic-link-sent.vue` / sign-in 上的 "Email me a link" 入口)。
- Change email 前端流程(账号设置页里发起、点击新邮箱链接验证)。
- 自定义 SMTP fallback / 多 provider 抽象。本期只接 Resend,但 service 接口签名留 provider 替换余地。
- 邮箱 / 邮件模板的 i18n(先英文一个版本,后续补)。
## 关键决策
- **Resend SDK**:使用官方 `resend` npm 包。错误处理走 `errorMessageFrom``@moeru/std`);失败时抛 `ApiError(502, 'email/send_failed', ...)` 让 Better Auth 把错传回前端。
- **触发邮件的位置**Better Auth 的 hook 是 server 内部回调,不是 HTTP 路由——跨实例时只有处理该次 sign-in/up 的实例会触发,不会重复。
- **Verify / reset 链接 URL**:链接落地页不放 `apps/server`,而是放 `apps/ui-server-auth``API_SERVER_URL` 是 server 自身(如 `https://airi-api.moeru.ai`),ui-server-auth 通常是另一域(如 `https://auth.airi.moeru.ai`);两者要么同源(dev)要么通过 trustedOrigins 已经互信。链接组装规则:
- Verify email`<UI_BASE>/verify-email?token=<token>`
- Reset password`<UI_BASE>/reset-password?token=<token>`
-`getAuthTrustedOrigins(request)` 第一个匹配的 origin 决定 `<UI_BASE>`,避免硬编码。
- **`requireEmailVerification: true` 开启的副作用**:现存历史用户(尚未验证)将在下次登录被拦截。**社交登录(Google/GitHub)默认 `emailVerified=true`**,不受影响。需要在 sign-up 后端响应中带 `requiresEmailVerification` 标志,前端据此跳到 `verify-email` 提示页。
- **OIDC `loginPage: '/sign-in'` 不变**:sign-in 加表单后仍然走 `oauth/authorize → /sign-in?... → 登录成功 → callbackURL 回 oauth/authorize`,不破坏现有流程。
## 假设 / 待验证
- `resend` SDK ESM-only?需在加包后 `pnpm typecheck` 验证(unverified)。
- `better-auth/plugins/magic-link` 可与 `oauthProvider` 共存(unverified,但插件是独立 endpoint,不冲突)。
- ui-server-auth 在 dev 下走 `http://localhost:5173`,与 `apps/server` 不同源。`server` 已在 `getAuthTrustedOrigins` 把 dev origin 加进来。
## 验证计划
每条用户路径要落一份验证记录到 `docs/ai/context/verifications/email-auth-<path>.md`
1. `email-auth-signup.md`:dev 环境注册一次,列出真实 curl / 浏览器步骤、Resend dashboard 命中、点链接落地页结果。
2. `email-auth-forgot.md`:忘记密码同上。
3. `email-auth-signin-email-password.md`emailPassword sign-in 完整 OIDC 闭环。
未跑过这三条 = 默认 unverified,不能声明完成。
## 不做(明确说"以后"
- 邮件 i18n(仅英文)
- 邮件模板真实视觉设计(先用最小可读模板)
- Resend webhookbounce / complaint 回调)接入
- 邮件审计日志写入 `request_log`
- Magic link 前端 UI 与 change-email 前端 UI
@@ -0,0 +1,79 @@
# Verification: email auth via Resend
Status: **Path 1 verified**, Path 2/3 unverified.
Last attempted: 2026-04-27
Owner: rbxin2003@gmail.com
## What's verified end-to-end
### Path 1 — Sign-up + verify email + sign-in (✅ 2026-04-27)
Tested with a live Resend API key, real Outlook inbox.
| Step | Evidence |
|---|---|
| `POST /api/auth/sign-up/email` (raw fetch) | `200` with `{ token: null, user: { ..., emailVerified: false } }` for `rbxin2003+probe@outlook.com` and `rbxin2003@outlook.com` |
| Resend dispatch | server log `<-- POST /api/auth/sign-up/email``--> POST /api/auth/sign-up/email 200 5s` (Resend API call latency, no errors logged from `services:email`) |
| Inbox delivery | User confirmed receipt at `rbxin2003@outlook.com` with subject "Verify your email", containing link `http://localhost:3000/api/auth/verify-email?token=eyJ...&callbackURL=%2F` |
| Click verify link | `GET /api/auth/verify-email?token=...&callbackURL=/``302` (redirect honored) |
| `emailVerified` flips to `true` | follow-up `POST /api/auth/sign-in/email` for the same user → `200` with `{ redirect: false, token: <session>, user: { ..., emailVerified: true, updatedAt > createdAt } }` |
| UI sign-up form submit | navigated `http://localhost:5174/_ui/server-auth/sign-up`, filled form via chrome-devtools, click `Create account` → server log `POST /api/auth/sign-up/email 200 2s` → browser landed on UI's verify-email page |
Two follow-up issues surfaced and were fixed in the same session:
1. **vue-i18n linked-format crash** — placeholder `you@example.com` parsed as a linked-message reference. Escaped to `you{'@'}example.com` in `packages/i18n/src/locales/en/server/auth.yaml`.
2. **Email link landed on `http://localhost:3000/` (404)** when there was no OIDC context, because Better Auth resolves bare `/` callback against `API_SERVER_URL`. Fixed in `apps/ui-server-auth/src/pages/sign-up.vue` and `sign-in.vue` by passing an absolute UI URL (`${origin}/_ui/server-auth/verify-email?verified=true`) when no OIDC params are present.
3. **API root + 404 friendliness** — added structured JSON for `GET /` and `notFound()` in `apps/server/src/app.ts` so stale email links / scanners hit a clear pointer instead of hono's default `404 Not Found` HTML.
- Verified with `curl http://localhost:3000/``200 {"service":"airi-api",...}` and `curl http://localhost:3000/some/random/path``404 {"error":"NOT_FOUND",...}`.
### Path 2 — Forgot + reset password (✅ 2026-04-27)
Tested with `rbxin2003+reset@outlook.com` (live Resend account). The bare `rbxin2003@outlook.com` is on Resend's suppression list and cannot be used for QA — see `~/.claude/projects/<project>/memory/reference_resend.md`.
| Step | Evidence |
|---|---|
| Sign-up `rbxin2003+reset@outlook.com` | `POST /api/auth/sign-up/email 200 2s`; UI navigated to `/verify-email?email=...` |
| Verify email | clicked link from real Outlook inbox; `GET /api/auth/verify-email?token=...&callbackURL=http://localhost:5173/_ui/server-auth/verify-email?verified=true` → 302 → UI shows "Email verified" |
| `POST /api/auth/request-password-reset` from UI | server log `200 3s`; UI shows "If rbxin2003+reset@outlook.com matches an account, a reset link is on the way" |
| Resend dashboard | `Reset your Project AIRI password` to `rbxin2003+reset@outlook.com``last_event: delivered` |
| Click reset link | `GET /api/auth/reset-password/<token>?callbackURL=http://localhost:5173/_ui/server-auth/reset-password` → 302 → UI form rendered with `?token=<token>` |
| Submit new password | `POST /api/auth/reset-password?token=...` → 200; UI shows "Password updated" |
| Sign in with new password | `POST /api/auth/sign-in/email``200` `{ token: <session>, user: { emailVerified: true, updatedAt: 2026-04-27T06:57:59.387Z } }` |
Two follow-up issues surfaced and were fixed in the same session:
1. **`apps/ui-server-auth` defaulted to production `https://api.airi.build`** because `VITE_SERVER_URL` was unset. Fixed by adding `apps/ui-server-auth/.env.development.local``VITE_SERVER_URL=http://localhost:3000`. Detected via `window.fetch` patching showing prod hostname; saved to `~/.claude/projects/<project>/memory/project_ui_server_auth_dev_env.md`.
2. **Better Auth's `originCheck` rejected `http://localhost:5173/...` callbackURLs** when the request came from a top-level GET (no Origin/Referer that matches dev origins). Fixed by adding `localhost:5173 / 5174 / 4173` to `ALWAYS_TRUSTED_AUTH_ORIGINS` in `apps/server/src/utils/origin.ts`. Prod-safe: those addresses are unreachable in prod, so the static list does not expand attack surface.
### Path 3 — Email + password sign-in via OIDC (partially verified)
`POST /api/auth/sign-in/email` was exercised directly to confirm `emailVerified` flips and a session token is issued, but the full UI-driven OIDC handoff (stage app → `/oauth2/authorize` → ui-server-auth → back to stage app with tokens) has NOT been tested in this session.
## What still needs running
### Path 2 — Forgot + reset password
1. From `/sign-in`, click "Forgot password?" → `/forgot-password`.
2. Submit the registered email. Expect `POST /api/auth/request-password-reset` returns 200, an email arrives ("Reset your Project AIRI password").
3. Click the email link. Expect server validates and 302s to `${UI}/_ui/server-auth/reset-password?token=<token>`.
4. Submit a new password. Expect `POST /api/auth/reset-password?token=...` returns 200; UI shows "Password updated".
5. Sign in with the new password and confirm session is issued.
### Path 3 — OIDC-bridged sign-in
1. Open a stage app (e.g. `apps/stage-web`) → triggers OIDC `/oauth2/authorize` → bounces to `ui-server-auth /sign-in?...`.
2. Submit email + password against the verified user. Expect session cookie set; browser redirects to the OIDC continuation URL; stage app yields `code` → token exchange.
3. Stage app shows a signed-in state.
## Until Path 2 + 3 are ticked
Treat the email-auth feature as **partially shipped**. Sign-up + verify-email is production-quality; password reset and OIDC bridging are code-complete but not load-bearing without an end-to-end run.
## Known gaps deferred to follow-up
- Magic link UI (server-side wired, no front-end entry yet).
- Change-email front-end flow.
- Email i18n (only English).
- Resend bounce / complaint webhook ingestion.
- Email send audit log in `request_log`.
- dev/prod served-from parity (dev runs Vite at `:5174`; prod expects ui-server-auth dist under `apps/server/public/ui-server-auth`).
+1
View File
@@ -53,6 +53,7 @@
"ioredis": "^5.10.1",
"jose": "catalog:",
"pg": "^8.20.0",
"resend": "^6.12.2",
"stripe": "^22.0.2",
"valibot": "catalog:",
"zod": "catalog:"
+35 -2
View File
@@ -51,6 +51,7 @@ import { createFluxMeter } from './services/billing/flux-meter'
import { createCharacterService } from './services/characters'
import { createChatService } from './services/chats'
import { createConfigKVService } from './services/config-kv'
import { createEmailService } from './services/email'
import { createFluxService } from './services/flux'
import { createFluxTransactionService } from './services/flux-transaction'
import { createProviderService } from './services/providers'
@@ -156,6 +157,18 @@ export async function buildApp(deps: AppDeps) {
*/
.on('GET', '/health', c => c.json({ status: 'ok' }))
/**
* Service identity at the API root. Visitors who land here from a stray
* email link, search engine, or copy-pasted URL get a clear pointer to
* the actual product UI instead of the framework's default "404 Not Found".
*/
.on('GET', '/', c => c.json({
service: 'airi-api',
message: 'This is the Project AIRI API server. Visit https://airi.moeru.ai to use the product, or see the docs at https://airi.moeru.ai/docs.',
docs: 'https://airi.moeru.ai/docs',
ui: 'https://airi.moeru.ai',
}))
/**
* Auth routes: sign-in page, token auth helpers, electron callback
* relay, well-known metadata, and better-auth catch-all.
@@ -197,6 +210,17 @@ export async function buildApp(deps: AppDeps) {
*/
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue))
/**
* Catch-all 404 in JSON. Replaces hono's default `text/html` "404 Not
* Found" so unmatched routes (typos, stale email links, scanners) get a
* structured response and a hint at where to go for the real product UI.
*/
.notFound(c => c.json({
error: 'NOT_FOUND',
message: `No route matched ${c.req.method} ${new URL(c.req.url).pathname}. This is the airi-api server; the product UI lives at https://airi.moeru.ai.`,
ui: 'https://airi.moeru.ai',
}, 404))
return { app: builtApp, injectWebSocket }
}
@@ -292,8 +316,17 @@ export async function createApp() {
}),
})
const emailService = injeca.provide('services:email', {
dependsOn: { env: parsedEnv },
build: ({ dependsOn }) => createEmailService({
apiKey: dependsOn.env.RESEND_API_KEY,
fromEmail: dependsOn.env.RESEND_FROM_EMAIL,
fromName: dependsOn.env.RESEND_FROM_NAME,
}),
})
const auth = injeca.provide('services:auth', {
dependsOn: { db, env: parsedEnv, otel },
dependsOn: { db, env: parsedEnv, otel, email: emailService },
build: async ({ dependsOn }) => {
// Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied
await seedTrustedClients(dependsOn.db, dependsOn.env)
@@ -306,7 +339,7 @@ export async function createApp() {
redirectUris: client.redirectUris.join(', '),
}).log('OIDC trusted client ready')
}
return createAuth(dependsOn.db, dependsOn.env, dependsOn.otel?.auth)
return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth)
},
})
+100 -3
View File
@@ -1,3 +1,4 @@
import type { EmailService } from '../services/email'
import type { Database } from './db'
import type { Env } from './env'
import type { AuthMetrics } from './otel'
@@ -8,9 +9,10 @@ import { oauthProvider } from '@better-auth/oauth-provider'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { createAuthMiddleware } from 'better-auth/api'
import { bearer, jwt } from 'better-auth/plugins'
import { bearer, jwt, magicLink } from 'better-auth/plugins'
import { eq } from 'drizzle-orm'
import { ApiError } from '../utils/error'
import { getAuthTrustedOrigins, getTrustedOrigin } from '../utils/origin'
import * as authSchema from '../schemas/accounts'
@@ -30,6 +32,16 @@ interface TrustedClientSeed {
tokenEndpointAuthMethod: 'none' | 'client_secret_post'
requirePKCE: boolean
skipConsent: boolean
/**
* Enables RP-Initiated Logout via `/api/auth/oauth2/end-session`.
*
* NOTICE: also gates whether the issued ID token carries the `sid` claim
* (see oauth-provider/dist/index.mjs L308: `sid: client.enableEndSession ? sessionId : void 0`).
* `sid` is required by the end-session handler, so this flag is the single
* switch that lets a Bearer-only OIDC client log out without depending on
* cross-site session cookies.
*/
enableEndSession: boolean
}
export interface TrustedClientSeedSummary {
@@ -126,6 +138,7 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] {
tokenEndpointAuthMethod: 'none',
requirePKCE: true,
skipConsent: true,
enableEndSession: true,
})
// Electron desktop app — public client (installed app, PKCE only).
@@ -145,6 +158,7 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] {
tokenEndpointAuthMethod: 'none',
requirePKCE: true,
skipConsent: true,
enableEndSession: true,
})
// Capacitor mobile app — public client (no secret, PKCE only).
@@ -163,6 +177,7 @@ function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] {
tokenEndpointAuthMethod: 'none',
requirePKCE: true,
skipConsent: true,
enableEndSession: true,
})
return clients
@@ -273,6 +288,7 @@ export async function seedTrustedClients(db: Database, env: Env): Promise<void>
tokenEndpointAuthMethod: seed.tokenEndpointAuthMethod,
requirePKCE: seed.requirePKCE,
skipConsent: seed.skipConsent,
enableEndSession: seed.enableEndSession,
updatedAt: new Date(),
}
@@ -294,7 +310,28 @@ export async function seedTrustedClients(db: Database, env: Env): Promise<void>
}
}
export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null) {
/**
* Throws when an email-driven Better Auth callback fires without an EmailService.
*
* NOTICE:
* `EmailService` is optional on `createAuth` so contexts that never exercise
* email flows (e.g. `pnpm run auth:generate` schema introspection) can run
* without a Resend key. Each callback that needs the service guards on it via
* `requireEmailService(email)`. The error is surfaced to the HTTP caller so
* the misconfiguration is loud instead of silent.
*/
function requireEmailService(email: EmailService | undefined): EmailService {
if (!email) {
throw new ApiError(
503,
'email/service_not_configured',
'Email service not available in this server context.',
)
}
return email
}
export function createAuth(db: Database, env: Env, email?: EmailService, metrics?: AuthMetrics | null) {
return betterAuth({
secret: env.BETTER_AUTH_SECRET,
@@ -312,8 +349,24 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null)
plugins: [
bearer(),
jwt(),
magicLink({
// NOTICE: better-auth's magic-link callback receives a server-side
// verification URL ({baseURL}/magic-link/verify?token=...&callbackURL=...).
// The user clicks → server validates → 302s to callbackURL with session
// cookie set. UI page only needs to receive the redirect; no token
// handling required there.
async sendMagicLink({ email: address, url }) {
await requireEmailService(email).sendMagicLink({ to: address, url })
},
}),
oauthProvider({
loginPage: '/sign-in',
// Keep loginPage inside the ui-server-auth vue-router base (`/auth/`)
// so the OIDC redirect lands on a URL the SPA router actually owns.
// Without the prefix the address bar stays on bare `/sign-in`, which
// is outside vue-router's history base — SPA-internal `router.push`
// later jumps to `/auth/...`, and a refresh of the bare URL would
// fall through to the global 404.
loginPage: '/auth/sign-in',
consentPage: '/oauth/authorize',
scopes: [...OIDC_SCOPES],
validAudiences: [env.API_SERVER_URL],
@@ -328,6 +381,50 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null)
emailAndPassword: {
enabled: true,
// Block sign-in until the user proves they own the address. Social
// logins (Google/GitHub) bypass this because better-auth seeds
// emailVerified=true for OAuth-issued accounts.
requireEmailVerification: true,
async sendResetPassword({ user, url }) {
await requireEmailService(email).sendPasswordReset({ to: user.email, url })
},
},
emailVerification: {
// Trigger sendVerificationEmail automatically on sign-up so the frontend
// doesn't need to make a follow-up call. requireEmailVerification above
// already enforces this on its own, but sendOnSignUp keeps behavior
// explicit if requireEmailVerification ever gets toggled off.
sendOnSignUp: true,
// NOTICE: Establish a session cookie when the user clicks the
// verification link, so they don't have to re-enter the password they
// just chose. The original tab (still on the verify-email pending page)
// detects the new session via polling and resumes the OIDC handoff.
// Source: node_modules/better-auth/dist/api/routes/email-verification.mjs L268+
autoSignInAfterVerification: true,
async sendVerificationEmail({ user, url }) {
await requireEmailService(email).sendVerification({ to: user.email, url })
},
},
user: {
changeEmail: {
enabled: true,
// NOTICE:
// Better Auth fires sendChangeEmailConfirmation against the *current*
// email address before the change is committed. Send to user.email
// (current) so the owner of the existing account confirms the move;
// sending to newEmail would let an attacker who only controls newEmail
// confirm a takeover.
// Source: node_modules/better-auth/dist/api/routes/update-user.mjs L468-475
async sendChangeEmailConfirmation({ user, newEmail, url }) {
await requireEmailService(email).sendChangeEmailConfirmation({
to: user.email,
newEmail,
url,
})
},
},
},
session: {
+9
View File
@@ -53,6 +53,15 @@ const EnvSchema = object({
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
// Resend transactional email. RESEND_API_KEY required when emailAndPassword
// sign-up / forgot-password / change-email / magic-link is exercised. Service
// boots without it but those flows will throw at send-time.
RESEND_API_KEY: optional(string(), ''),
// From address must be a verified Resend sender (e.g. `noreply@your-domain`).
RESEND_FROM_EMAIL: optional(string(), 'noreply@airi.moeru.ai'),
// Optional friendly name; rendered as `Name <email>` per Resend's RFC 5322 display-name format.
RESEND_FROM_NAME: optional(string(), 'Project AIRI'),
STRIPE_SECRET_KEY: optional(string()),
STRIPE_WEBHOOK_SECRET: optional(string()),
+11 -5
View File
@@ -19,12 +19,18 @@ type AuthInstance = ReturnType<typeof createAuth>
*/
export function sessionMiddleware(auth: AuthInstance, env: Env): MiddlewareHandler<HonoEnv> {
return async (c, next) => {
// NOTICE: auth routes handle session lookup inside better-auth itself.
// Running the global session middleware on `/api/auth/*`, `/sign-in`, and
// the auth discovery endpoints duplicates the same session read and slows
// the OIDC login path (`authorize` → `token` → `get-session`) noticeably.
// NOTICE: auth routes handle session lookup inside better-auth itself,
// and the ui-server-auth SPA bundle (HTML/JS/CSS + SPA routes like
// `/auth/sign-in`, `/auth/verify-email`, …) doesn't need a session
// attached either. Running the global session middleware on `/api/auth/*`,
// `/auth/*`, and the auth discovery endpoints duplicates the same session
// read and slows the OIDC login path (`authorize` → `token` →
// `get-session`) noticeably.
//
// `/auth/` and `/api/auth/` are distinct prefixes — `/api/auth/...`
// starts with `/api` and won't be matched by the `/auth/` startsWith.
if (
c.req.path === '/sign-in'
c.req.path.startsWith('/auth/')
|| c.req.path.startsWith('/api/auth/')
|| c.req.path === '/.well-known/oauth-authorization-server/api/auth'
) {
+91 -8
View File
@@ -6,15 +6,25 @@ import type { HonoEnv } from '../../types/hono'
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider'
import { serveStatic } from '@hono/node-server/serve-static'
import { and, eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { ensureDynamicFirstPartyRedirectUri } from '../../libs/auth'
import { rateLimiter } from '../../middlewares/rate-limit'
import { account, user } from '../../schemas/accounts'
import { createBadRequestError } from '../../utils/error'
import { getServerAuthUiDistDir, renderServerAuthUiHtml, SERVER_AUTH_UI_BASE_PATH } from '../../utils/server-auth-ui'
import { createElectronCallbackRelay } from '../oidc/electron-callback'
import { createOIDCTokenAuthRoute } from '../oidc/token-auth'
const RE_SERVER_AUTH_UI_BASE_PATH = /^\/_ui\/server-auth/
// NOTICE:
// Loose RFC-5322-ish regex used to fail fast on obviously malformed input.
// Authoritative validation happens in better-auth on sign-in/sign-up;
// this is just a pre-flight gate for the email-first identifier step so we
// avoid hitting the DB with garbage.
const EMAIL_SHAPE_RE = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
const RE_SERVER_AUTH_UI_BASE_PATH = /^\/auth/
export interface AuthRoutesDeps {
auth: AuthInstance
@@ -29,7 +39,7 @@ export interface AuthRoutesDeps {
* well-known metadata endpoints.
*
* Mounted at the root level because routes span multiple prefixes
* (`/sign-in`, `/api/auth/*`, `/.well-known/*`).
* (`/auth/*`, `/api/auth/*`, `/.well-known/*`).
*/
export async function createAuthRoutes(deps: AuthRoutesDeps) {
async function handleAuthRequest(request: Request): Promise<Response> {
@@ -47,16 +57,21 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
rewriteRequestPath: (path: string) => path.replace(RE_SERVER_AUTH_UI_BASE_PATH, ''),
}))
/**
* Minimal login page for the OIDC Provider flow.
* When an unauthenticated user hits /api/auth/oauth2/authorize,
* better-auth redirects here. After the user signs in via a social
* provider, the social callback redirects to callbackURL which
* points back to the OIDC authorize endpoint.
* Login page for the OIDC Provider flow, served under the ui-server-auth
* vue-router base (`/auth/sign-in`). When an unauthenticated
* user hits `/api/auth/oauth2/authorize`, better-auth redirects here
* because of `oauthProvider({ loginPage })`. After the user signs in via
* a social provider, the social callback redirects to `callbackURL`,
* which points back to the OIDC authorize endpoint.
*
* If a `provider` query parameter is present (e.g. `?provider=github`),
* skip the picker page and redirect directly to the social provider.
*
* Registered BEFORE the SPA `/auth/*` wildcard fallback so
* the provider shortcut gets a chance to short-circuit. Hono matches
* routes in registration order — specific path before wildcard wins.
*/
.on('GET', '/sign-in', (c) => {
.on('GET', `${SERVER_AUTH_UI_BASE_PATH}/sign-in`, (c) => {
const provider = c.req.query('provider')
// Reconstruct the OIDC authorize URL from query params so the flow
@@ -83,6 +98,27 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
currentUrl: c.req.url,
}))
})
/**
* SPA fallback for the ui-server-auth bundle.
*
* vue-router runs with `createWebHistory('/auth/')`, so any
* client-side route — `/auth/verify-email`,
* `/auth/forgot-password`, `/auth/reset-password`,
* etc. — appears in the URL bar but has no matching file in the dist.
* Without this handler, deep-link hits (verification email links, page
* refresh on a SPA route, copy-pasted URLs) fall through `serveStatic`
* to the global 404 JSON.
*
* Mounted AFTER the static middleware so real assets under
* `/auth/assets/...` still resolve to the file on disk;
* `serveStatic` short-circuits on hits and only calls through on misses.
*/
.on('GET', `${SERVER_AUTH_UI_BASE_PATH}/*`, (c) => {
return c.html(renderServerAuthUiHtml({
apiServerUrl: deps.env.API_SERVER_URL,
currentUrl: c.req.url,
}))
})
/**
* Auth routes are handled by the auth instance directly,
@@ -119,6 +155,53 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
.on('GET', '/api/auth/.well-known/openid-configuration', async (c) => {
return oauthProviderOpenIdConfigMetadata(deps.auth)(c.req.raw)
})
/**
* Email-first identifier check.
*
* Powers the unified sign-in/up UI: the user types an email, the UI calls
* this to decide whether to render a password input (existing user with
* a credential account) or the new-account form (or steer them to a
* social provider when only social accounts exist).
*
* Returns:
* - `exists`: a `user` row matches the email (case-insensitive).
* - `hasPassword`: that user has an account row with `providerId='credential'`,
* i.e. can sign in via email + password (vs. social-only).
*
* Account-enumeration tradeoff: this confirms whether an email is
* registered, mirroring the standard set by Google/Linear/Notion. We
* accept the disclosure since the existing rate limiter applied to
* `/api/auth/*` (`AUTH_RATE_LIMIT_MAX` per IP per window) already throttles
* enumeration attempts.
*/
.on('POST', '/api/auth/check-email', async (c) => {
const body = await c.req.json().catch(() => null) as { email?: unknown } | null
const raw = typeof body?.email === 'string' ? body.email.trim() : ''
const email = raw.toLowerCase()
if (!email || !EMAIL_SHAPE_RE.test(email))
throw createBadRequestError('Invalid email', 'INVALID_EMAIL')
const [matched] = await deps.db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1)
if (!matched)
return c.json({ exists: false, hasPassword: false })
const [credential] = await deps.db
.select({ id: account.id })
.from(account)
.where(and(
eq(account.userId, matched.id),
eq(account.providerId, 'credential'),
))
.limit(1)
return c.json({ exists: true, hasPassword: !!credential })
})
.on(['POST', 'GET'], '/api/auth/*', async (c) => {
return handleAuthRequest(c.req.raw)
})
+5
View File
@@ -5,4 +5,9 @@ import { createDrizzle } from '../libs/db'
import { parseEnv } from '../libs/env'
const env = parseEnv(process.env)
// NOTICE:
// `better-auth generate` only introspects the auth instance's schema — it never
// fires the email callbacks. Pass no EmailService; createAuth's email-aware
// callbacks throw if invoked, but introspection never reaches them.
export default createAuth(createDrizzle(env).db, env)
+276
View File
@@ -0,0 +1,276 @@
import type { Logger } from '@guiiai/logg'
import { useLogger } from '@guiiai/logg'
import { errorMessageFrom } from '@moeru/std'
import { Resend } from 'resend'
import { ApiError } from '../utils/error'
/**
* Outbound email payload accepted by {@link EmailService.send}.
*
* Use when:
* - Building a higher-level transactional template (verification, reset, magic link, change-email).
*
* Expects:
* - Both `html` and `text` set so deliverability scoring stays high (text fallback
* is what spam filters score when HTML is hostile or stripped).
* - `to` is already validated by Better Auth (we trust caller for internal flows).
*/
export interface EmailPayload {
/** Recipient address. Single address — Better Auth callbacks always emit one. */
to: string
/** Subject line. Plain text. */
subject: string
/** HTML body. */
html: string
/** Plain-text body. Required for spam-filter parity and accessibility. */
text: string
}
/**
* Email service abstraction shared by all Better Auth callbacks.
*
* Use when:
* - Wiring `sendVerificationEmail` / `sendResetPassword` / `sendMagicLink` /
* `sendChangeEmailConfirmation` in `createAuth()`.
*
* Expects:
* - Service is constructed once per process by `injeca` and shared across requests.
*
* Returns:
* - A `send` method plus four high-level helpers that own subject/body composition.
*/
export interface EmailService {
send: (payload: EmailPayload) => Promise<void>
sendVerification: (params: { to: string, url: string }) => Promise<void>
sendPasswordReset: (params: { to: string, url: string }) => Promise<void>
sendMagicLink: (params: { to: string, url: string }) => Promise<void>
sendChangeEmailConfirmation: (params: { to: string, newEmail: string, url: string }) => Promise<void>
}
interface EmailConfig {
apiKey: string
fromEmail: string
fromName?: string
}
/**
* Format an RFC 5322 display-name + address pair for the `From` header.
*
* Before:
* - `{ fromEmail: 'noreply@a.io', fromName: 'AIRI' }`
*
* After:
* - `'AIRI <noreply@a.io>'`
*/
function formatFrom(config: EmailConfig): string {
if (config.fromName)
return `${config.fromName} <${config.fromEmail}>`
return config.fromEmail
}
/**
* Construct the email service.
*
* Use when:
* - DI assembly in `apps/server/src/app.ts`.
*
* Expects:
* - `RESEND_API_KEY` is set in env. When empty, `send` throws an `ApiError`
* instead of silently dropping mail Better Auth surfaces it back to the
* caller so frontend can show a clear "email service not configured" error.
*/
export function createEmailService(config: EmailConfig, logger: Logger = useLogger('email')): EmailService {
// NOTICE:
// Construct Resend lazily so the server can boot in environments where the
// RESEND_API_KEY is intentionally empty (e.g. local dev that never exercises
// email flows). Calls to `send` will throw, which Better Auth surfaces.
// Root cause summary: Resend's constructor logs but does not throw on empty
// keys; explicit guard keeps the failure mode visible at the call site.
// Source: node_modules/.pnpm/resend@*/node_modules/resend/dist/index.cjs
// Removal condition: when we make RESEND_API_KEY required at env-parse time.
let client: Resend | null = null
function getClient(): Resend {
if (!client) {
if (!config.apiKey) {
throw new ApiError(
503,
'email/service_not_configured',
'Email service not configured (RESEND_API_KEY is missing).',
)
}
client = new Resend(config.apiKey)
}
return client
}
const from = formatFrom(config)
async function send(payload: EmailPayload): Promise<void> {
try {
const { error } = await getClient().emails.send({
from,
to: [payload.to],
subject: payload.subject,
html: payload.html,
text: payload.text,
})
if (error) {
logger.withFields({ to: payload.to, subject: payload.subject, errorName: error.name }).error(error.message)
throw new ApiError(502, 'email/send_failed', error.message, { providerError: error.name })
}
}
catch (error) {
if (error instanceof ApiError)
throw error
const message = errorMessageFrom(error) ?? 'Unknown email send error'
logger.withFields({ to: payload.to, subject: payload.subject }).error(message)
throw new ApiError(502, 'email/send_failed', message)
}
}
return {
send,
async sendVerification({ to, url }) {
await send({
to,
subject: 'Verify your email for Project AIRI',
html: renderVerificationHtml(url),
text: renderVerificationText(url),
})
},
async sendPasswordReset({ to, url }) {
await send({
to,
subject: 'Reset your Project AIRI password',
html: renderPasswordResetHtml(url),
text: renderPasswordResetText(url),
})
},
async sendMagicLink({ to, url }) {
await send({
to,
subject: 'Your Project AIRI sign-in link',
html: renderMagicLinkHtml(url),
text: renderMagicLinkText(url),
})
},
async sendChangeEmailConfirmation({ to, newEmail, url }) {
await send({
to,
subject: 'Confirm your new email address for Project AIRI',
html: renderChangeEmailHtml(url, newEmail),
text: renderChangeEmailText(url, newEmail),
})
},
}
}
// NOTICE:
// Templates are intentionally minimal inline HTML. Goal here is functional
// delivery + plaintext fallback. Visual design is deferred (see
// docs/ai/context/email-auth-resend.md "不做" section).
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderActionEmailHtml(args: { heading: string, body: string, ctaLabel: string, url: string, footer: string }): string {
const safeUrl = escapeHtml(args.url)
return `<!doctype html>
<html><body style="font-family: -apple-system, Segoe UI, sans-serif; color: #111; max-width: 480px; margin: 24px auto; padding: 0 16px;">
<h2 style="margin: 0 0 16px;">${escapeHtml(args.heading)}</h2>
<p style="margin: 0 0 16px;">${escapeHtml(args.body)}</p>
<p style="margin: 0 0 16px;"><a href="${safeUrl}" style="display: inline-block; padding: 10px 16px; background: #111; color: #fff; border-radius: 6px; text-decoration: none;">${escapeHtml(args.ctaLabel)}</a></p>
<p style="margin: 0 0 16px; font-size: 12px; color: #666;">If the button doesn't work, copy this URL into your browser:<br/><span style="word-break: break-all;">${safeUrl}</span></p>
<p style="margin: 24px 0 0; font-size: 12px; color: #888;">${escapeHtml(args.footer)}</p>
</body></html>`
}
function renderActionEmailText(args: { heading: string, body: string, url: string, footer: string }): string {
return `${args.heading}\n\n${args.body}\n\n${args.url}\n\n${args.footer}\n`
}
function renderVerificationHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Verify your email',
body: 'Welcome to Project AIRI. Click the button below to confirm this is your email address.',
ctaLabel: 'Verify email',
url,
footer: 'If you did not create an account, you can safely ignore this email.',
})
}
function renderVerificationText(url: string): string {
return renderActionEmailText({
heading: 'Verify your email',
body: 'Welcome to Project AIRI. Open this link to confirm your email address:',
url,
footer: 'If you did not create an account, you can safely ignore this email.',
})
}
function renderPasswordResetHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Reset your password',
body: 'We received a request to reset the password for your Project AIRI account.',
ctaLabel: 'Reset password',
url,
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
})
}
function renderPasswordResetText(url: string): string {
return renderActionEmailText({
heading: 'Reset your password',
body: 'Open this link to reset your Project AIRI password:',
url,
footer: 'If you did not request this, you can safely ignore this email — your password will not change.',
})
}
function renderMagicLinkHtml(url: string): string {
return renderActionEmailHtml({
heading: 'Sign in to Project AIRI',
body: 'Click the button below to sign in. This link expires shortly and can be used once.',
ctaLabel: 'Sign in',
url,
footer: 'If you did not request this link, you can safely ignore this email.',
})
}
function renderMagicLinkText(url: string): string {
return renderActionEmailText({
heading: 'Sign in to Project AIRI',
body: 'Open this link to sign in (single-use, expires shortly):',
url,
footer: 'If you did not request this link, you can safely ignore this email.',
})
}
function renderChangeEmailHtml(url: string, newEmail: string): string {
return renderActionEmailHtml({
heading: 'Confirm your new email',
body: `Confirm that ${newEmail} should become your Project AIRI account email.`,
ctaLabel: 'Confirm new email',
url,
footer: 'If you did not request this change, contact support immediately.',
})
}
function renderChangeEmailText(url: string, newEmail: string): string {
return renderActionEmailText({
heading: 'Confirm your new email',
body: `Confirm that ${newEmail} should become your Project AIRI account email by opening this link:`,
url,
footer: 'If you did not request this change, contact support immediately.',
})
}
+2 -2
View File
@@ -3,6 +3,6 @@ import { errorMessageFrom } from '@moeru/std'
/**
* Returns a stable human-readable message for unknown errors.
*/
export function errorMessageFromUnknown(error: unknown): string {
return errorMessageFrom(error) ?? 'Unknown error'
export function errorMessageFromUnknown(error: unknown, unknownMessage?: string): string {
return errorMessageFrom(error) ?? unknownMessage ?? 'Unknown error'
}
+21
View File
@@ -57,6 +57,23 @@ export function resolveTrustedRequestOrigin(request: Request): string | undefine
return undefined
}
// NOTICE:
// Better Auth's callbackURL validation walks `trustedOrigins`. Static entries
// support `*` wildcards via the framework's wildcardMatch (see
// node_modules/better-auth/dist/auth/trusted-origins.mjs). Loopback origins
// across any port are allowed so dev (Vite at :5173/:5174/:4173, electron
// loopback OAuth at :random_port) and prod (where these addresses are
// unreachable) share the same config. The pattern is intentionally broad —
// loopback is unreachable from the public internet, so any origin that
// resolves to localhost is by definition the same machine the user is on.
//
// Removal condition: when dev serves UI from the same origin as the API
// (e.g. via vite proxy or static mount), drop these entries.
const ALWAYS_TRUSTED_AUTH_ORIGINS = [
'http://localhost:*',
'http://127.0.0.1:*',
]
export function getAuthTrustedOrigins(env: Pick<Env, 'API_SERVER_URL'>, request?: Request): string[] {
const origins = new Set<string>()
const apiServerOrigin = getOriginFromUrl(env.API_SERVER_URL)
@@ -64,6 +81,10 @@ export function getAuthTrustedOrigins(env: Pick<Env, 'API_SERVER_URL'>, request?
origins.add(apiServerOrigin)
}
for (const origin of ALWAYS_TRUSTED_AUTH_ORIGINS) {
origins.add(origin)
}
if (request) {
const requestOrigin = resolveTrustedRequestOrigin(request)
if (requestOrigin) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
export const SERVER_AUTH_UI_BASE_PATH = '/_ui/server-auth'
export const SERVER_AUTH_UI_BASE_PATH = '/auth'
const SERVER_AUTH_UI_DIST_DIR = fileURLToPath(new URL('../../public/ui-server-auth', import.meta.url))
const SERVER_AUTH_UI_INDEX_HTML_PATH = fileURLToPath(new URL('../../public/ui-server-auth/index.html', import.meta.url))