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>上的darkclass 被正确添加,但页面样式没有变化
根本原因:
- 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或 JWTsub。所有资产、业务数据、用量记录、审计日志和统计记录都应以 uuid 关联。name、username、fullname、
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,不阻塞主流程:
| 事件名 | 触发时机 | 发送方 |
|---|---|---|
login | SSO 回调成功 | Worker(ctx.waitUntil) |
page_view | 用户进入 Dashboard | 前端 |
page_view + duration_seconds | 用户离开 Dashboard | 前端(unmount) |
pdf_generate | PDF 生成成功 | 前端 |
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 ID | edge-pdf |
| Callback URL | https://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 原生限流。