所有路由处理器在 src/routes.ts,装配在 src/index.ts。公网入口 https://authapi.kira.art(内部端口 8095)。
路由概览
| Method | Path | 用途 | CORS |
|---|
| GET | /login?next=&invite= | 校验 next 白名单 → 暂存 next/invite(host cookie)→ signInWithOAuth(google) → 302 Google | 顶层导航,无 |
| GET | /callback?code= | exchangeCodeForSession → onboarding → 封印 refresh 进 .kira.art cookie → 302 next | 顶层导航,无 |
| POST | /refresh | 读 ka_session → Supabase 轮换 → 回写 cookie → 返回 { access_token, ... } | 白名单 + credentials |
| POST | /logout | 用 cookie refresh 换 access → Supabase logout(global) → 清 cookie。幂等 | 白名单 + credentials |
| GET | /health | 健康检查 | — |
| GET | / | 服务标识 | — |
CORS 与 credentials
/login、/callback 是浏览器顶层导航,无需 CORS。/refresh、/logout 是前端跨源 fetch(需带 credentials 以携带 .kira.art cookie),装了 CORS 中间件:
cors({
origin: env.ALLOWED_REDIRECT_ORIGINS, // 白名单 origin(非 *)
credentials: true, // 回显具体 origin,放行 cookie
allowMethods: ["POST", "OPTIONS"],
allowHeaders: ["Content-Type", "traceparent", "tracestate", "baggage"],
})
credentials: true 时 ACAO 必须回显具体 origin(不能是 *),Hono 用数组(白名单)形式自动处理。traceparent / tracestate / baggage 是 W3C trace context —— kira-web 的 fetch instrumentation 会给自家服务注入,必须在 preflight 放行。
GET /login
发起 Google OAuth。
Query 参数
| 参数 | 必填 | 说明 |
|---|
next | 否 | 登录完成后的回跳目标。必须落在 ALLOWED_REDIRECT_ORIGINS 内,否则用 DEFAULT_REDIRECT_ORIGIN 兜底 |
invite | 否 | 邀请人 user id,用于 onboarding referral |
行为
resolveNext(next) 白名单校验。
setCookie(ka_next),有 invite 则 setCookie(ka_invite)(均 host-only + httpOnly + SameSite=Lax,maxAge=600s)。
signInWithOAuth({ provider: "google", options: { redirectTo: ${PUBLIC_BASE_URL}/callback, skipBrowserRedirect: true } })。
302 到 Google 授权页。
错误
| 情况 | 响应 |
|---|
signInWithOAuth 返回 error 或无 url | 502 { message: "oauth_init_failed" }(appLog.error) |
GET /callback
Google 回跳端点。必须在 Supabase Auth → URL Configuration → Redirect URLs 注册(prod https://authapi.kira.art/callback,dev http://localhost:8095/callback)。
Query 参数
| 参数 | 必填 | 说明 |
|---|
code | 是 | Google 授权码,用于 exchangeCodeForSession |
行为
- 无
code → 302 回 DEFAULT_REDIRECT_ORIGIN。
exchangeCodeForSession(code)。失败 → 302 回 ${DEFAULT_REDIRECT_ORIGIN}/?auth_error=1。
- 取回并清掉
ka_next / ka_invite。
runOnboarding(user, invite, accept-language)(referral / language)。
setSessionCookie(refresh_token) —— 封印进 .kira.art ka_session(maxAge=400d)。
302 回 next。referral 成功且有 invite 时,追加 ?referral=success&inviter=<invite>。
access token 不进 URL。前端落地后必须经 POST /refresh 拿 access。
POST /refresh
前端获取 access token 的唯一途径。无请求体(refresh 来自 ka_session cookie)。
请求
POST /refresh
Origin: https://creator.kira.art
Cookie: ka_session=<sealed> # 浏览器在 credentialed fetch 自动带上 .kira.art cookie
成功响应 200
{
"access_token": "<supabase-signed-jwt>",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 1730000000
}
同时 Set-Cookie: ka_session=<新封印>(refresh 已轮换)。
错误状态
/refresh 严格区分「会话失效」与「Supabase 暂时不可达」——核心是 refreshGrant() 的三态返回,绝不能把抖动当登出:
| 情况 | refreshGrant 状态 | HTTP | body | cookie | 日志 |
|---|
无 / 无法解封的 ka_session | — | 401 | { error: "no_session" } | 不动 | debug |
| 新 token 拿到 | ok | 200 | { access_token, ... } | 回写新封印 | debug |
| refresh 真失效(过期/已用/reuse 撤销,Supabase 4xx) | expired | 401 | { error: "refresh_failed" } | 清掉死 cookie | debug(常见会话到期) |
| Supabase 暂时不可达(网络错 / 5xx) | unavailable | 503 | { error: "upstream_unavailable" } | 不清(refresh 还有效) | warn(真异常,上 Dash0) |
unavailable 故意回 503 且不清 cookie,让前端稍后重试、不要登出。此前 bare fetch 无 try/catch:Supabase 不可达会 reject → 冒到 onError 成 500 → 前端当 auth 失败把用户登出 → 一次 Supabase 抖动 = 全员掉线。三态返回就是修这个坑。
POST /logout
幂等登出。无请求体。
行为
- 读
ka_session 的 refresh;有则 refreshGrant() 换 access,再 revokeSession(access, "global")(POST /auth/v1/logout?scope=global,对齐 kira-web signOut 默认 global)。
- 无论吊销成功与否(best-effort,失败仅
warn)→ clearSessionCookie() 清 .kira.art cookie。
响应 200
GET /health · GET /
无认证的标识端点(src/index.ts inline)。
| Path | 响应 |
|---|
GET /health | { "status": "ok", "service": "kira-auth" } |
GET / | { "service": "kira-auth" } |
错误 span 语义(src/index.ts)
@hono/otel 在 handler 抛错时把 SERVER span 标 ERROR(含 4xx HTTPException)。按 OTel HTTP 语义,4xx SERVER 不该是 ERROR。中间件在 @hono/otel 内侧吃掉 <500 的 HTTPException 转 response,不让它回到 @hono/otel 的 catch;5xx + 未捕获错误仍往上传到 onError → captureException(ERROR log + span.recordException)。详见 Deployment 的 Observability 小节。
app.use(async (c, next) => {
try {
await next();
} catch (e) {
if (e instanceof HTTPException && e.status < 500) {
return e.getResponse(); // 4xx:转 response,不标 ERROR span
}
throw e; // 5xx:上传 onError → captureException
}
});
GoTrue 转发(src/lib/gotrue.ts)
/refresh 和 /logout 不走 supabase-js client(refreshSession 会 mutate client 内部状态、并发 race),直接 fetch GoTrue REST:
| 操作 | 调用 | Headers |
|---|
| 刷新 | POST ${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token | apikey: $SUPABASE_ANON_KEY,Authorization: Bearer $SUPABASE_ANON_KEY,body { refresh_token } |
| 吊销 | POST ${SUPABASE_URL}/auth/v1/logout?scope=global | apikey: $SUPABASE_ANON_KEY,Authorization: Bearer <access_token> |