DOCUMENTATION

Sub-App SSO 接入经验总结(EdgePDF 案例)

登录、权限、测试身份与用量接入

页面更新 2026-10-24

Sub-App SSO 接入经验总结(EdgePDF 案例)

更新:2026-10-24。本文保留历史排错记录,正在逐步更新;新登录接入及 JWT 到期处理以统一登录与静默续期接入指南为准。

记录时间:2026-03-04 适用场景:将现有 Cloudflare Workers + React SPA + Hono 后端 的 Web App 接入 accounts.aryuki.com 统一认证


一、错误经验与报错总结

1. SPA 路由 404(/sso-callback Not Found)

现象:

auth-center 重定向到 https://pdf.andog.eu/sso-callback?token=... 后,页面返回 404

根本原因:

  • /sso-callback 是 React Router 的前端路由,在服务器上不存在对应文件
  • Cloudflare Workers 收到请求后,Hono 没有匹配的后端路由,直接返回 404
  • not_found_handling = "single-page-application" 在有 Worker 脚本时无效,该配置只在纯静态资源部署(无 Worker 脚本)时生效

修复方法:

步骤 1:在 wrangler.toml 的 [assets] 中添加 Fetcher binding:

[assets]
directory = "dist"
not_found_handling = "single-page-application"
binding = "ASSETS"

步骤 2:在 Hono 末尾添加 notFound 降级处理,将所有非 API 路由转发给 Assets:

app.notFound(async (c) => {
  return c.env.ASSETS.fetch(c.req.raw);
});

步骤 3:在 Bindings 类型中加入:

ASSETS: Fetcher;

2. JWT 本地签名验证失败(Invalid token signature)

现象:

POST /api/sso-callback 返回 401:Invalid token signature

根本原因:

  • auth-center 在注册 App 时给出的 App Secret(a9caca6a-...)是 App 在中心的身份标识凭证
  • JWT 的实际签名密钥是 auth-center 服务端的全局内部密钥,不对外暴露
  • 两者不是同一个东西,用 App Secret 做 HS256 本地验签永远失败

教训:

  • App Secret ≠ JWT Signing Key
  • 除非 auth-center 明确提供「JWT 验签公钥/密钥」,否则不要尝试本地验签
  • 正确方式是调用 auth-center 提供的 /api/verify 端点进行权威验证

修复方法:

// ❌ 错误:用 App Secret 做本地 HS256 验签
const cryptoKey = await crypto.subtle.importKey('raw', encoder.encode(c.env.SSO_SECRET), ...);
const valid = await crypto.subtle.verify('HMAC', cryptoKey, sigBin, signingInput);

// ✅ 正确:调用 SSO center 的 verify 端点
const verifyRes = await fetch(
  'https://accounts.aryuki.com/api/verify?app_id=edge-pdf',
  { method: 'GET', headers: { Authorization: `Bearer ${token}` } }
);

3. D1 建表报错(D1_EXEC_ERROR: incomplete input)

现象:

Database error: D1_EXEC_ERROR: Error in line 1: CREATE TABLE IF NOT EXISTS users (: incomplete input: SQLITE_ERROR

根本原因:

  • D1 的 db.exec() 使用精简 SQL 解析器,不支持多行模板字符串
  • 模板字符串中的换行符、首尾空白导致解析失败,把整段 SQL 当成不完整输入

修复方法:

// ❌ 错误:使用 db.exec() + 多行模板字符串
await db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ...
  )
`);

// ✅ 正确:使用 db.prepare() + 字符串拼接(单行)
await db.prepare(
  'CREATE TABLE IF NOT EXISTS users (' +
  'id INTEGER PRIMARY KEY AUTOINCREMENT,' +
  'uuid TEXT NOT NULL UNIQUE,' +
  'user_id INTEGER,' +
  'name TEXT,' +
  'username TEXT,' +
  'token TEXT,' +
  'first_seen TEXT NOT NULL,' +
  'last_seen TEXT NOT NULL' +
  ')'
).run();

4. SSO verify 端点调用出现 500(Internal Server Error)

现象:

POST /api/sso-callback 500: Internal server error during SSO callback

根本原因:

  • 最初错误使用 const { token } = await c.req.json() 解构,若 body 解析失败会直接抛出异常被 catch 吞掉
  • 调用外部 verify 端点时,若网络超时或 SSO 中心返回非 JSON,也会抛出未处理的异常
  • 错误信息笼统('Internal server error'),无法定位是哪一步失败

修复方法:分离各阶段的 try-catch,并透传真实错误信息:

// 1. 单独解析 body
let token: string;
try {
  const body = await c.req.json<{ token: string }>();
  token = body?.token;
} catch {
  return c.json({ success: false, message: 'Invalid request body' }, 400);
}

// 2. 单独处理 verify 网络调用
try {
  const verifyRes = await fetch('...', { signal: AbortSignal.timeout(6000) });
  if (!verifyRes.ok) {
    let errBody: any = {};
    try { errBody = await verifyRes.json(); } catch {}
    verifyErr = errBody?.error || `HTTP ${verifyRes.status}`;
  }
} catch (fetchErr: any) {
  verifyErr = `Network error: ${fetchErr?.message}`;
}

// 3. 单独处理 D1
try {
  await db.prepare(...).bind(...).run();
} catch (dbErr: any) {
  return c.json({ success: false, message: `Database error: ${dbErr?.message}` }, 500);
}

5. Tailwind v4 暗黑模式失效

现象:

点击切换按钮,<html> 上的 dark class 被正确添加,但页面样式没有变化

根本原因:

  • Tailwind v4 自定义 dark variant 写法 (&:is(.dark *)) 只匹配 .dark 类内部的子元素
  • 给 <html> 加 .dark 后,<html> 自身不匹配,导致 dark:bg-zinc-950 等类失效

修复方法:

/* ❌ 错误:只匹配 .dark 的后代 */
@custom-variant dark (&:is(.dark *));

/* ✅ 正确:匹配 .dark 自身及其所有后代 */
@custom-variant dark (&:where(.dark *, .dark));

二、SSO 接入最佳实践

标准接入流程

1. 前端登录页:
   window.location.href = `https://accounts.aryuki.com/?client_id=${APP_ID}&redirect=${encodeURIComponent(CALLBACK_URL)}`;

2. SSO 中心处理:
   用户完成登录 → 重定向到 CALLBACK_URL?token=<jwt>

3. 前端 /sso-callback 页面:
   - 从 URL params 提取 token
   - POST /api/sso-callback { token }

4. 后端 /api/sso-callback 端点:
   a. 解析 body(独立 try-catch)
   b. Base64 decode JWT payload,检查 exp 过期时间
   c. 调用 GET /api/verify?app_id=... 进行权威验证(带 AbortSignal.timeout)
   d. 从 JWT payload 提取 uuid/sub、user_id、name、username、avatar_url 等
   e. UPSERT 写入本地 D1 users 表,所有业务表都使用 uuid/sub 作为用户归属键
   f. 返回成功,前端存 localStorage,跳转 Dashboard

5. 前端存储:
   localStorage.setItem('auth', 'true');
   localStorage.setItem('sso_token', token);
   localStorage.setItem('user_uuid', uuid);
   localStorage.setItem('user_name', name);

重要:用户对应时必须使用 Auth Center 下发的 uuid 或 JWT sub。所有资产、业务数据、用量记录、审计日志和统计记录都应以 uuid 关联。name、username、fullname、email 只用于显示,不能作为归属键,避免删除账号后,新出现的同名账号错误继承原有记录。

D1 建表规范(避免 exec() 陷阱)

// ✅ 推荐:prepare() + 字符串拼接,首次请求时自动建表
async function ensureTable(db: D1Database) {
  await db.prepare(
    'CREATE TABLE IF NOT EXISTS users (' +
    'id INTEGER PRIMARY KEY AUTOINCREMENT,' +
    'uuid TEXT NOT NULL UNIQUE,' +
    /* ... */
    ')'
  ).run();
}
// 在每个需要该表的 endpoint 开头调用 ensureTable()

Analytics 事件埋点规范

所有 track 调用均为 fire-and-forget,不阻塞主流程:

事件名触发时机发送方
loginSSO 回调成功Worker(ctx.waitUntil)
page_view用户进入 Dashboard前端
page_view + duration_seconds用户离开 Dashboard前端(unmount)
pdf_generatePDF 生成成功前端
r2_upload文件上传 R2 成功前端
// Worker 端(fire-and-forget,不阻塞 Response)
c.executionCtx.waitUntil(trackEvent(uuid, 'login'));

// 前端端
function track(eventType: string, durationSeconds?: number) {
  const uuid = localStorage.getItem('user_uuid') || '';
  if (!uuid) return; // 未登录时自动跳过
  fetch('/api/track', { method: 'POST', ... }).catch(() => {});
}

Cloudflare Workers + Hono + React SPA 路由配置

# wrangler.toml
[assets]
directory = "dist"
not_found_handling = "single-page-application"
binding = "ASSETS"   # 必须暴露为 Fetcher binding
// worker.ts 末尾必须添加 SPA fallback
app.notFound(async (c) => {
  return c.env.ASSETS.fetch(c.req.raw);
});

三、接入本 App 所需的 auth-center 配置

字段值
App ID / Client IDedge-pdf
Callback URLhttps://pdf.andog.eu/sso-callback
App Secret<请在 Worker secret 中配置,不要公开>

注意:App Secret 不应放在公开文档或前端,也不应提交到 wrangler.toml 的 [vars]。请保存在 Worker secret 中;实际 JWT 验证依赖 auth-center 的 /api/verify 端点。如历史示例中的密钥仍有效,请轮换。

2026-05-30 admin 判断补充

  • JWT payload 已包含 email、email_verified、role。
  • JWT payload 在用户设置头像时也会包含完整 avatar_url,子应用可直接渲染。
  • 旧子应用中 name === "admin" 的管理员判断仍保持兼容。
  • 新子应用必须优先使用 role === "admin" 判断管理员,使用 role === "user" 判断普通用户。
  • Auth Center 注册时会拒绝包含任意大小写 admin 的 username 和 fullname,避免误导旧规则。

2026-10-24:多层代理下的 SSO 会话耗时

**适用前提:**先按普通方式使用业务 Worker 的 D1 限流,并完成 ESA、代理 Worker 等回源配置。只有在这类多层链路下,真实登录持续出现慢速,而且分段计时确认瓶颈是登录前的 D1 限流调用时,才考虑将这一入口改为 Cloudflare Workers 原生 Rate Limiting binding。不要因为接入代理就直接替换所有 D1 限流。

Point 的一次 .cn 登录请求耗时 19.52 秒;业务 Worker 的 Server-Timing 显示 rate-limit=18525 ms、auth-verify=97 ms、db-session=694 ms、total=19316 ms。这证明等待主要发生在 D1 限流调用,不能据此断定是 ESA 缓存、Accounts 验证或某一种 D1 底层故障。该限流表当时只有 12 行且有主键索引。普通公开 API 请求约 0.6–0.9 秒也不能代替真实登录的分段测量。

排查时给业务 Worker 的登录响应添加不含令牌的 Server-Timing,分别测限流、Auth Center /api/verify、本地会话写入,并与浏览器 Network 的总耗时比较。不要在日志、截图中泄露 callback JWT、Cookie、Set-Cookie 或密钥。若 rate-limit 才是长尾,再只替换该入口:Point 以 AUTH_SESSION_RATE_LIMIT(10 次/60 秒)调用原生 limit();其余 AI、导入等严格限流继续使用 D1。登录成功仍需写入 D1 会话,原生限流不会消除这些业务读写。切换后用真实登录再次核对耗时和 429 行为。

**计费与语义:**原 D1 限流每次尝试至少执行一次 rate_limits UPSERT,插入或更新行、索引可能增加计费写入;现在该入口的限流检查对 D1 是零读写,其他登录数据库操作不变。具体节省量以原查询返回的 meta.rows_read、meta.rows_written 和 Cloudflare D1 用量页为准,不要把一次请求直接等同于固定数量的计费行。原生限流按 Cloudflare 节点近似计数、跨节点不共享,也只有 10 秒或 60 秒窗口;它不等价于原先 D1 的全局 30 次/10 分钟严格窗口。若需要严格的跨地区总量,保留 D1 或设计 Durable Object。共享 IP 作为 key 也可能误伤同一出口的用户,应按实际身份与威胁模型选择 key。参见 D1 计费与 Workers 原生限流。