feat(auth): email login & profile (#1745)
Co-authored-by: Liet Blue <127093491+lietblue@users.noreply.github.com>
This commit is contained in:
@@ -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 webhook(bounce / 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`).
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
|
||||
@@ -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'
|
||||
) {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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.',
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
Reference in New Issue
Block a user