Skip to main content
状态:服务代码已实现并按 Fly 部署配置就绪(authapi.kira.art,Fly app kira-auth,端口 8095)。把 kira-web 从 @supabase/* client 切换到「薄 auth client(只跟 authapi 说话)」是独立的、进行中的工作——本文按代码事实描述服务契约,不代表平台级 cutover 已完成。

概述

kira-auth 是 Kira 的中心登录服务,把 Google OAuth + onboarding 从各前端剥出来统一在一处做。设计是 B1 纯代理 + 跨子域 httpOnly cookie:
  • 前端不再持 @supabase/* client:点登录就跳 authapi,登完 authapi 把 refresh token 封印进 .kira.art httpOnly cookie,前端落地后调 POST /refresh 拿 access token。
  • 前端只揣 access token(内存/localStorage),refresh token 永远在 httpOnly cookie 里,前端 JS 碰不到。
  • 完全无状态、零 DB:无进程内存、无数据库。会话权威是 Supabase;refresh token 封印在客户端 cookie,每请求自带 → 任意机器可处理 → 多机安全、可水平扩。
  • authapi 只中继不自签:access token 仍是 Supabase ES256 签的 JWT,后端 kira-be / kira-agent / kira-cdn 本地验 Supabase JWKS、Centrifugo issuer 钉 raw supabase.co —— 全零改。(自签会破坏 JWKS 链,那是 B2 方案,等于换 IdP,未采纳。)

环境与域

一份部署 = authapi.kira.art(COOKIE_DOMAIN=.kira.art),同时服务多个前端:
环境域名会话载体
prodkira.art.kira.art httpOnly cookie 共享
creatorcreator.kira.art同上(跨子域无感:fetch 自带 .kira.art cookie,首访零跳转零闪)
testpoisson.art走同一个 authapi.kira.art;跨注册域 → SameSite=Lax cookie 跨站不带 → 需重登(测试环境可接受)

会话流程

1. GET /login?next=&invite=

任意 *.kira.art 域点登录跳来。resolveNext() 校验 next 必须落在 ALLOWED_REDIRECT_ORIGINS 白名单内(防开放重定向),否则用兜底默认 DEFAULT_REDIRECT_ORIGIN(白名单第一项)。next/invite 暂存进 authapi 自身 host cookie(ka_next / ka_invite,host-only + httpOnly + SameSite=Lax,maxAge=600s),撑过 Google 往返。然后 signInWithOAuth({ provider: "google", redirectTo: ${PUBLIC_BASE_URL}/callback, skipBrowserRedirect: true }),302 到 Google。
正常情况下用户带着 ka_session cookie 时,前端 load 直接调 /refresh 就拿到 access,根本不会走 /login;/login 只在确实无会话时命中。

2. GET /callback?code=

Google 回跳。exchangeCodeForSession(code) 换会话 → 取回并清掉 ka_next / ka_invite → 跑 runOnboarding(user, invite, accept-language)seal(refresh_token) 封印进 .kira.art httpOnly cookie(ka_session,maxAge=400d)→ 302next
  • access token 不进 URL:避开 OAuth implicit/fragment 投递(RFC 9700 已弃用)。前端落地后经 /refresh 取 access。
  • referral 成功时给 next URL 拼上 ?referral=success&inviter=<invite>
  • code 交换失败 → 302${DEFAULT_REDIRECT_ORIGIN}/?auth_error=1

3. POST /refresh

前端拿 access 的唯一途径(首次落地 + 后续过期都走这一条)。读 ka_session(封印的 refresh)→ refreshGrant() 经 Supabase GoTrue 轮换 → 回写新封印 cookie → 返回 { access_token, token_type, expires_in, expires_at }。跨子域无感:creator.kira.art 的 credentialed fetch 自动带上 .kira.art cookie。 authapi 是唯一刷新权威:前端不自己持 refresh、不自己刷 → 单一刷新者,天然避开 Supabase refresh 轮换的 reuse-detection 整会话吊销坑(并发刷新另靠 Supabase 10s reuse 窗兜底)。

4. POST /logout

用 cookie 的 refresh 换一把 access 去吊销(scope=global,对齐 kira-web 现状 signOut 默认 global),再清 .kira.art cookie。best-effort 幂等:吊销失败(含网络)也照清 cookie。
Cookie作用域属性用途
ka_session.kira.art(prod)httpOnly, Secure(prod), SameSite=Lax, maxAge=400d跨子域会话载体,内含封印后的 refresh token。authapi 是唯一读写者
ka_nextauthapi host-onlyhttpOnly, SameSite=Lax, maxAge=600s/login 暂存回跳目标,/callback 取回即删
ka_inviteauthapi host-onlyhttpOnly, SameSite=Lax, maxAge=600s/login 暂存邀请码,/callback 取回即删
  • .kira.art 父域共享:kira.art + creator.kira.artauthapi.kira.art 同 site,/refresh 的 credentialed fetch 会带上 ka_session → 跨子域无感。本地 localhost:3000 ↔ :8095 也是同 site。
  • SUPABASE_* SSR cookie 被强制 host-only:@supabase/ssr client(仅用于 OAuth 两段)写的 PKCE verifier / sb-<ref>-auth-token 会被 lib/supabase.ts 剥掉 domain,只落 authapi 自己的 host,绝不漏到 .kira.art 父域(否则会复活已废弃的”共享 Supabase 会话 cookie”模型)。
  • poisson.art 跨注册域:走同一个 authapi,但 SameSite=Lax cookie 在 poisson 的跨站 /refresh fetch 不会带 → 静默刷新失败、需重新登录。要 poisson 也无感,需把会话 cookie 改 SameSite=None; Secure(见 lib/session.ts 注释,待决策)。

Token 封印(lib/seal.ts)

refresh token 写进 cookie 前用对称加密封印。httpOnly + Secure 已挡 XSS 和网络嗅探,AES-GCM 多一层是针对「带 cookies 权限的恶意浏览器扩展能读 httpOnly cookie」的纵深防御。
算法AES-256-GCM(内置 WebCrypto)
密钥派生SHA-256(AUTH_REFRESH_SECRET) → 256-bit key(所有机器同值 → 任意机器可解,无需共享存储)
输出格式b64url(iv[12] ‖ ciphertext+tag)
解封失败任何篡改/格式错/密钥不符 → null,调用方按「无有效会话」处理

Onboarding(src/onboarding.ts)

/callback 成功换 code 后调用,用 service-role client(绕 RLS),忠实搬自原 kira-web callback 服务端逻辑。失败不挡登录(catch + log,不抛)。
  • language 初始化:user_profiles.language 为空 → 用 Accept-Language 首选项经 pickLocale() 初始化,不在支持列表则兜底 en-US。支持的 locale:en-US, zh-CN, zh-TW, de-DE, ja-JP, fr-FR, es-ES, it-IT, pt-PT, ms-MY, ko-KR, ru-RU
  • referral claim:invited_by === null 且带合法 invite(非空、非自己)→ 调 claim_referral(p_inviter_id, p_invitee_id) RPC。invitee 永远取服务端验证过的 user.id(exchange 出来的),不信客户端裸传(修了旧 RPC 信客户端 invitee 的洞)。奖励逻辑在 RPC 内,不动。
  • 自荐兜底:invited_by === null 且无合法 invite → 标记 invited_by = user.id(标记为「不合格」)。

后端零改

access token 仍是 Supabase ES256 签的 JWT。下游服务不感知 authapi 的存在:
  • kira-be / kira-agent / kira-cdn 验签照旧(本地校验 Supabase JWKS)。
  • Centrifugo issuer 钉 raw supabase.co
authapi 只中继不自签 —— 这是 B1 方案保持后端零改的核心。

技术栈

  • 运行时:Bun(WebCrypto、fetchBun.serve)
  • 框架:Hono ^4.12.2
  • Auth:@supabase/ssr(OAuth 两段:signInWithOAuth + exchangeCodeForSession,托管 PKCE verifier cookie)+ @supabase/supabase-js(service-role 做 onboarding)
  • GoTrue 直连:/refresh + /logout 不走 supabase-js client,直接 fetch GoTrue REST(无 client 状态、无 race)
  • 观测:OpenTelemetry → kira-otel-collector.internal:4318 → Dash0(traces/metrics/logs);版本钉死 stable 2.7.1 / exp 0.218.0

项目结构

  • src/
    • bootstrap.ts 入口:先 startTelemetry()import index(让 OTel 先 patch globals)
    • index.ts @hono/otel + 4xx-swallow 中间件 + CORS + health + onError + Bun.serve
    • env.ts fail-fast env(无 zod,缺必填直接抛)
    • routes.ts /login /callback /refresh /logout
    • onboarding.ts referral / language 初始化(service-role)
    • telemetry/
      • index.ts OTel bootstrap(traces+metrics+logs → collector;dev no-op)+ installFetchTracing
      • logger.ts appLog(4 级 + per-call report opt;stdout + Dash0)
      • errors.ts captureException(ERROR log + span.recordException)
    • lib/
      • supabase.ts 每请求 SSR client(cookie 强制 host-only)+ service-role client
      • seal.ts AES-256-GCM 封印 refresh token(WebCrypto,无状态)
      • session.ts ka_session 读/写/清
      • gotrue.ts 直连 Supabase GoTrue:refreshGrant / revokeSession
      • redirect.ts next 白名单校验