GitHub

07 — 登出与单点登出(RP-Initiated Logout)

返回 README

本节是跨服务契约:下游(kungal / moyu / wiki 等 RP)必须按此实现登出,否则会出现「登出后再点登录/注册,直接静默登回刚才的账号」的回归。

背景与症状

集中式 SSO 下,oauth.kungal.com 是 OP(身份提供方),各站是 RP(接入方)。用户反馈:在 wiki / 补丁站登出后,点「登录」或「注册」会直接以刚才的账号登入,没有任何提示。

根因:RP 登出 ≠ OP 登出

OP 的「登录态」由两样东西决定,都在 oauth.kungal.com 这个 origin 上

  1. OP 前端 localStorage 里持久化的 user(驱动 isLoggedIn);
  2. refresh_token httpOnly cookie + DB sessions 行。

RP 登出只清掉了那个站自己的状态。而:

  • localStorage 严格按 origin 隔离 —— 任何 RP 都无法清除 oauth.kungal.comlocalStorage,OP 前端的 user 始终还在 → isLoggedIn 恒为真。
  • 跨站(如补丁站 touchgal.moeoauth.kungal.com)的 SameSite=Lax refresh cookie 连后台 fetch 都带不过去 → cookie / 会话也清不掉。

于是再点登录跳到 /oauth/authorize 时,OP 前端判定「已登录」+ 客户端 auto_consent=true静默发码 → 登回原账号。

关键结论:要清掉 OP 会话,浏览器必须顶层导航到 oauth.kungal.com —— 只有真正访问到该 origin,才能同时清掉它的 cookie/会话 localStorage。后台 fetch 两样都做不到。这正是 OpenID Connect RP-Initiated Logout 解决的问题。

方案:RP 登出时顶层跳转到 OP 登出入口(单点登出)

对同主人的一方生态,「登出 = 退出整个鲲 SSO」是最自然的语义。流程:

用户在 RP 点登出
  → RP 清本地会话
  → 浏览器顶层跳转 {OAUTH_API_BASE}/oauth/logout?client_id=<id>&redirect=<回到RP的地址>
       → 后端 302 跳到 OP 前端登出页 /auth/logout(对称于 /oauth/authorize 跳同意页)
       → OP 前端页清 refresh cookie + DB session + OP localStorage 的 user
       → 校验 redirect 在白名单 → 跳回 RP
  → 用户回到 RP(此时 OP 会话已清;再点登录会要求重新登录)

OP 端点契约

1. 登出入口(顶层导航,对称于 /oauth/authorize)

GET {OAUTH_API_BASE}/oauth/logout?client_id=<client_id>&redirect=<post_logout_url>
# 生产: https://oauth.kungal.com/api/v1/oauth/logout?client_id=...&redirect=...
  • RP 必须用 window.location.href(浏览器顶层导航)跳过来,不能用 fetch/XHR —— fetch 清不掉 OP 的 localStorage/cookie(见上文根因)。
  • 这是后端入口,会 302 跳到 OP 前端登出页 /auth/logout(与 /oauth/authorize 跳到前端同意页同一模式)。前端页执行:清 OP 会话(POST /api/v1/auth/logout 删 session + 清 refresh cookie)+ 清 OP 前端 localStorageuseraccess_token cookie → 校验 redirectwindow.location 跳回。
  • RP 复用访问 /oauth/authorize 时用的同一个 OAUTH_API_BASE(含 /api/v1),无需额外配置 OP 前端域名;dev(前后端不同源)下也能正确解析(后端用 cfg.Server.FrontendURL 找前端页)。
参数 必填 说明
client_id 发起登出的 RP 客户端 ID(用于校验 redirect 白名单)
redirect 登出后回跳地址(post-logout redirect URI)。缺省 / 校验不过 → 跳 OP 首页

2. 回跳白名单校验(OP 登出页内部用)

GET /api/v1/oauth/post-logout-redirect?client_id=<id>&redirect=<url>

无需鉴权。校验 redirectorigin(scheme+host)是否匹配该 client 注册的任一 redirect_uri 的 origin,匹配则回显,否则回空。

成功响应

{ "code": 0, "data": { "url": "https://www.moyu.moe/?logged_out=1" } }

data.url 为空字符串表示 redirect 不在白名单(防 open-redirect),调用方应回退到安全默认地址。

白名单口径:复用 client 已注册的 redirect_uris 的 origin(一方生态下,能作回调目标的 origin 即可信作登出回跳目标)。如需更严格,后续可加独立的 post_logout_redirect_uris 注册项。

3. prompt=login(强制重新登录,可选的「仅登出本站」语义)

GET /oauth/authorize 新增可选查询参数 prompt

行为
login 即使 OP 仍有会话,也强制显示登录界面(不走 auto-consent 静默放行)
(空) 默认行为(有会话 + auto_consent → 静默发码)

适用「只想登出本站、但本站再登要重新确认」的 RP:登出时做全局单点登出,而是在下次发起 authorize 时带上 prompt=login。与方案 1(单点登出)二选一即可。

下游(RP)接入步骤

单点登出(推荐)——把登出按钮改成顶层跳转:

// RP 登出处理
const logout = async () => {
  // 1. 清本站自己的会话(清 token / 本地 store / 调用本站后端登出)
  await clearLocalSession()
  // 2. 顶层跳转到 OP 登出入口,登出后回到本站。
  //    OAUTH_API_BASE = 你访问 /oauth/authorize 用的同一个 base(含 /api/v1)。
  const back = encodeURIComponent(window.location.origin + '/')
  window.location.href = `${OAUTH_API_BASE}/oauth/logout?client_id=${MY_CLIENT_ID}&redirect=${back}`
}

注册要求:回跳地址(redirect)的 origin 必须与该 client 注册的某个 redirect_uri 同源。例如 redirect_uri = https://www.moyu.moe/auth/callback,则 redirect = https://www.moyu.moe/... 合法。

注意:不要只在前端「清得更干净」就完事 —— localStorage 跨 origin 清不掉 OP 的,必须顶层跳转到 OP 登出入口。

与既有端点的关系

端点 作用 与本节关系
POST /api/v1/auth/logout 删当前 session + 清 refresh cookie(需 Bearer) OP 登出页内部调用它清后端会话
POST /api/v1/oauth/revoke RFC 7009 吊销指定 token S2S 吊销;不清浏览器 OP 前端状态,不能替代登出入口
GET /api/v1/oauth/logout(后端入口) 本节新增:302 跳到 OP 前端登出页 RP 登出的正确入口(顶层导航)
GET /auth/logout(OP 前端页) 本节新增:清 OP 会话 + 校验回跳 由上面的入口 302 进来;RP 一般不直接跳这个

安全

  • Open-redirect 防护redirect 必须通过白名单校验(origin 匹配注册的 redirect_uri),否则回退安全默认地址。
  • 登出入口对未登录访问也安全(无会话可清时直接按白名单回跳 / 跳首页)。
  • 顶层导航不依赖跨站 cookie,补丁站(跨主域)同样可靠。

源:kun-galgame-infra/docs/integration/oauth/07-logout.md