[{"data":1,"prerenderedAt":55},["ShallowReactive",2],{"doc:\u002Fcontracts\u002Foauth\u002F09-account-switching":3},{"title":4,"route":5,"toc":6,"segments":50,"source":54},"09 — 账号切换（多账号 \u002F Account Switching）","\u002Fcontracts\u002Foauth\u002F09-account-switching",[7,11,14,17,21,24,27,30,33,36,39,42,45,48],{"id":8,"text":9,"depth":10},"1-模型-先理解这三句","1. 模型（先理解这三句）",2,{"id":12,"text":13,"depth":10},"2-关键基元-oidc-标准参数-op-已-将支持","2. 关键基元（OIDC 标准参数，OP 已\u002F将支持）",{"id":15,"text":16,"depth":10},"3-下游接入","3. 下游接入",{"id":18,"text":19,"depth":20},"3-1-触发账号选择器-切换-添加账号","3.1 触发账号选择器（切换 \u002F 添加账号）",3,{"id":22,"text":23,"depth":20},"3-2-全局活跃账号-对齐-本系统决策-global-per-browser","3.2 「全局活跃账号」对齐（本系统决策：global per browser）",{"id":25,"text":26,"depth":20},"3-3-会话袋-api-仅同站-app-如账号中心-oauth-kungal-com-profile","3.3 会话袋 API（**仅同站 app**，如账号中心 `oauth.kungal.com\u002Fprofile`）",{"id":28,"text":29,"depth":20},"3-4-登出语义-本系统决策-基于撤销-revocation-based","3.4 登出语义（本系统决策：基于撤销 \u002F revocation-based）",{"id":31,"text":32,"depth":20},"3-5-管理员-step-up-本系统决策-切入管理员账号需重新认证","3.5 管理员 step-up（本系统决策：切入管理员账号需重新认证）",{"id":34,"text":35,"depth":20},"3-6-账号选择器-ux-推荐下游统一实现","3.6 账号选择器 UX（推荐下游**统一**实现）",{"id":37,"text":38,"depth":10},"4-安全要求-下游-必须-依据-rfc-9700-rfc-6819","4. 安全要求（下游 **必须**，依据 RFC 9700 \u002F RFC 6819）",{"id":40,"text":41,"depth":10},"5-错误码-实现已落地-详见-04-tokens-and-errors-md-认证段","5. 错误码（实现已落地；详见 [04-tokens-and-errors.md](.\u002F04-tokens-and-errors.md) 认证段）",{"id":43,"text":44,"depth":10},"6-下游耦合点-重命名-重构时务必同步本节","6. 下游耦合点（重命名\u002F重构时务必同步本节）",{"id":46,"text":47,"depth":10},"7-实施阶段-与-docs-auth-02-对齐","7. 实施阶段（与 `docs\u002Fauth\u002F02` 对齐）",{"id":49,"text":49,"depth":10},"变更摘要",[51],{"type":52,"html":53},"html","\u003Ch1 id=\"09-账号切换-多账号-account-switching\" tabindex=\"-1\">09 — 账号切换（多账号 \u002F Account Switching）\u003C\u002Fh1>\n\u003Cblockquote>\n\u003Cp>✅ \u003Cstrong>状态：后端 + OP 账号选择器已实现，契约稳定，下游可接入\u003C\u002Fstrong>。已上线：会话袋数据层、\n\u003Ccode>\u002Foauth\u002Fauthorize\u003C\u002Fcode> 的 \u003Ccode>prompt=select_account|none\u003C\u002Fcode> + \u003Ccode>login_hint\u003C\u002Fcode>、\u003Ccode>\u002Fauth\u002Fsessions\u003C\u002Fcode>\n（+ \u003Ccode>\u002Fswitch\u003C\u002Fcode>、\u003Ccode>\u002Flogout\u003C\u002Fcode>、\u003Ccode>\u002Flogout-all\u003C\u002Fcode>）、管理员 step-up；OP 账号中心（apps\u002Fweb）与\nwiki 的切换器已接入。\u003Cstrong>待接入\u003C\u002Fstrong>：forum \u002F moyu 的切换器 UI（照 §3.6 实现即可）+\n\u003Ccode>prompt=none\u003C\u002Fcode> 焦点对齐（§3.2，后续阶段）。本文是下游（kungal \u002F moyu \u002F wiki）接入\n「多账号 + 切换」的\u003Cstrong>跨服务契约\u003C\u002Fstrong>；IdP 内部实现（数据模型、决策依据）见 infra 仓\n\u003Ccode>docs\u002Fauth\u002F02-account-switching-design.md\u003C\u002Fcode>。\n⚠️ 生产首次部署需对 \u003Ccode>kun_galgame_infra\u003C\u002Fcode> 跑 \u003Ccode>go run .\u002Fcmd\u002Fmigrate\u003C\u002Fcode>（加 \u003Ccode>sessions.browser_id\u003C\u002Fcode>\n等列；部署不自动跑迁移）。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>让用户像 Gmail\u002F微软那样\u003Cstrong>同时登录多个账号并一键切换\u003C\u002Fstrong>。本文档读者 = 下游前端\u002F后端开发；\n讲「你要怎么调、能依赖什么保证」，不讲 IdP 内部表结构。\u003C\u002Fp>\n\u003Chr>\n\u003Ch2 id=\"1-模型-先理解这三句\" tabindex=\"-1\">1. 模型（先理解这三句）\u003C\u002Fh2>\n\u003Col>\n\u003Cli>\u003Cstrong>IdP 持有「会话袋」\u003C\u002Fstrong>：一个浏览器里登录的 N 个账号，全部由 OP（\u003Ccode>oauth.kungal.com\u003C\u002Fcode>）服务端持有，靠一个 httpOnly 的浏览器锚点 cookie 串起来。下游\u003Cstrong>不持有\u003C\u002Fstrong>多账号的 refresh token。\u003C\u002Fli>\n\u003Cli>\u003Cstrong>每个下游 app 同一时刻只持有「当前账号」的令牌\u003C\u002Fstrong>。\u003C\u002Fli>\n\u003Cli>\u003Cstrong>「切换」= 下游重新走一次到 OP 的授权码重定向\u003C\u002Fstrong>（\u003Ccode>prompt=select_account\u003C\u002Fcode>）。因为 OP 的浏览器 cookie 已经认识袋子里所有账号，所以切换\u003Cstrong>不需要重新输入密码\u003C\u002Fstrong>（管理员账号除外，见 §6）。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cblockquote>\n\u003Cp>这是 \u003Ccode>accounts.google.com\u003C\u002Fcode> 的模型：会话归 OP，各产品跳过去选账号。因为我们的站点\n\u003Cstrong>跨顶级域\u003C\u002Fstrong>（kungal.com ↔ moyu.moe），切换\u003Cstrong>必须走重定向\u003C\u002Fstrong>，绝不能跨域共享 cookie。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2 id=\"2-关键基元-oidc-标准参数-op-已-将支持\" tabindex=\"-1\">2. 关键基元（OIDC 标准参数，OP 已\u002F将支持）\u003C\u002Fh2>\n\u003Cdiv class=\"kun-table-wrap\">\u003Ctable>\u003Cthead>\n\u003Ctr>\n\u003Cth>参数\u003C\u002Fth>\n\u003Cth>作用\u003C\u002Fth>\n\u003Cth>备注\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>prompt=select_account\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>让 OP 渲染\u003Cstrong>账号选择器\u003C\u002Fstrong>（袋子里 ≥1 个账号时）\u003C\u002Ftd>\n\u003Ctd>新增\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>prompt=none\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>静默\u003C\u002Fstrong>查询「当前是谁 \u002F 我的会话还在吗」，OP 不渲染任何 UI；不能静默完成时回错误\u003C\u002Ftd>\n\u003Ctd>新增\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>prompt=login\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>强制重新认证（管理员 step-up \u002F 登出后防静默登回）\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>已存在\u003C\u002Fstrong>，见 \u003Ca href=\"\u002Fcontracts\u002Foauth\u002F07-logout\">07-logout.md\u003C\u002Fa>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>错误 \u003Ccode>account_selection_required\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>prompt=none\u003C\u002Fcode> 时用户登录了多个账号但没选 → 回退到选择器\u003C\u002Ftd>\n\u003Ctd>新增\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>错误 \u003Ccode>login_required\u003C\u002Fcode> \u002F \u003Ccode>consent_required\u003C\u002Fcode> \u002F \u003Ccode>interaction_required\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>prompt=none\u003C\u002Fcode> 无法静默完成\u003C\u002Ftd>\n\u003Ctd>部分已存在\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\u003C\u002Fdiv>\u003Cp>\u003Ccode>state\u003C\u002Fcode> + PKCE 在每次重定向都\u003Cstrong>必须\u003C\u002Fstrong>带（见 §7）。\u003C\u002Fp>\n\u003Ch2 id=\"3-下游接入\" tabindex=\"-1\">3. 下游接入\u003C\u002Fh2>\n\u003Ch3 id=\"3-1-触发账号选择器-切换-添加账号\" tabindex=\"-1\">3.1 触发账号选择器（切换 \u002F 添加账号）\u003C\u002Fh3>\n\u003Cp>顶层跳转到 OP 授权端点，带上 \u003Ccode>prompt=select_account\u003C\u002Fcode>：\u003C\u002Fp>\n\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\">\u003Ccode class=\"language-text\">\u003Cspan class=\"line\">\u003Cspan>GET https:\u002F\u002Foauth.kungal.com\u002Fapi\u002Fv1\u002Foauth\u002Fauthorize\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    ?client_id=&#x3C;your_client_id>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    &#x26;redirect_uri=&#x3C;your_registered_callback>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    &#x26;response_type=code\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    &#x26;code_challenge=&#x3C;pkce>&#x26;code_challenge_method=S256\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    &#x26;state=&#x3C;one-time, user-agent-bound>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>    &#x26;prompt=select_account\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cul>\n\u003Cli>OP 读浏览器锚点 cookie → 列出袋子里的账号（头像\u002F昵称\u002F邮箱）+「使用其他账号登录」。\u003C\u002Fli>\n\u003Cli>用户选账号 B → OP 把 B 设为活跃 → \u003Cstrong>若 B 是管理员，先强制重登（§6）\u003C\u002Fstrong> → 下发授权码。\u003C\u002Fli>\n\u003Cli>你的回调用授权码换\u003Cstrong>B 的新令牌\u003C\u002Fstrong>，替换当前令牌。除 step-up 外\u003Cstrong>无需输入凭据\u003C\u002Fstrong>。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cblockquote>\n\u003Cp>想直接跳到某个已知账号（跳过选择器），可带 \u003Ccode>login_hint=&lt;email&gt;\u003C\u002Fcode>；OP 在无歧义时可不渲染 UI。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch3 id=\"3-2-全局活跃账号-对齐-本系统决策-global-per-browser\" tabindex=\"-1\">3.2 「全局活跃账号」对齐（本系统决策：global per browser）\u003C\u002Fh3>\n\u003Cp>活跃账号的\u003Cstrong>唯一真源是 OP\u003C\u002Fstrong>。下游靠\u003Cstrong>向 OP 静默询问\u003C\u002Fstrong>来对齐：\u003C\u002Fp>\n\u003Cul>\n\u003Cli>在\u003Cstrong>页面加载 \u002F 标签页获得焦点 \u002F 路由切换\u003C\u002Fstrong>时，下游做一次 \u003Ccode>prompt=none\u003C\u002Fcode> 静默检查。\u003C\u002Fli>\n\u003Cli>若 OP 的活跃账号 ≠ 本 app 当前账号 → 静默重新授权为活跃账号 → 替换令牌。\u003C\u002Fli>\n\u003Cli>\u003Cstrong>诚实的限制（跨顶级域固有）\u003C\u002Fstrong>：后台标签页\u003Cstrong>不会瞬时\u003C\u002Fstrong>切换，它会在\u003Cstrong>重新获得焦点\u002F刷新\u003C\u002Fstrong>时对齐。这是跨 TLD 能做到的最好「全局」。\n\u003Cul>\n\u003Cli>\u003Cstrong>同 TLD 例外\u003C\u002Fstrong>：\u003Ccode>oauth.kungal.com\u003C\u002Fcode> 与 \u003Ccode>www.kungal.com\u003C\u002Fcode> 同属 \u003Ccode>kungal.com\u003C\u002Fcode>，可让锚点 cookie \u003Ccode>Domain=.kungal.com\u003C\u002Fcode> → kungal.com 家族内\u003Cstrong>瞬时全局\u003C\u002Fstrong>；只有 \u003Ccode>moyu.moe\u003C\u002Fcode> 走「焦点对齐」。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 id=\"3-3-会话袋-api-仅同站-app-如账号中心-oauth-kungal-com-profile\" tabindex=\"-1\">3.3 会话袋 API（\u003Cstrong>仅同站 app\u003C\u002Fstrong>，如账号中心 \u003Ccode>oauth.kungal.com\u002Fprofile\u003C\u002Fcode>）\u003C\u002Fh3>\n\u003Cblockquote>\n\u003Cp>这些 JSON 端点读 OP 域上的 Lax 锚点 cookie，因此\u003Cstrong>只对与 OP 同站（\u003Ccode>kungal.com\u003C\u002Fcode> 家族：\n\u003Ccode>oauth.kungal.com\u003C\u002Fcode> 自身、\u003Ccode>wiki.kungal.com\u003C\u002Fcode>、\u003Ccode>www.kungal.com\u003C\u002Fcode>）的前端可用\u003C\u002Fstrong>——同站下游若要\n跨子域调用，还需 OP 为该 origin 放行 \u003Cstrong>CORS（\u003Ccode>credentials\u003C\u002Fcode>）\u003C\u002Fstrong>。跨 TLD 的 \u003Ccode>moyu.moe\u003C\u002Fcode>\n\u003Cstrong>用不了\u003C\u002Fstrong>（\u003Ccode>SameSite\u003C\u002Fcode> cookie 不跨站 \u003Ccode>fetch\u003C\u002Fcode> 发送）→ 一律走 §3.1 重定向 + §3.6 本地缓存。\u003C\u002Fp>\n\u003Cp>⚠️ \u003Cstrong>坑（同站接入必看）\u003C\u002Fstrong>：\u003Ccode>switch\u003C\u002Fcode> \u002F \u003Ccode>logout\u003C\u002Fcode> 会返回\u003Cstrong>业务性 401\u003C\u002Fstrong>（\u003Ccode>10016\u003C\u002Fcode> step-up、\n\u003Ccode>10005\u003C\u002Fcode> 不在袋中、\u003Ccode>10001\u003C\u002Fcode> 非成员）。别让前端「遇 401 就刷新令牌 \u002F 跳登录」的\u003Cstrong>全局拦截器\u003C\u002Fstrong>\n吞掉它们——这几个调用要\u003Cstrong>绕过全局 401 处理\u003C\u002Fstrong>、自己读响应 \u003Ccode>code\u003C\u002Fcode> 分支（否则 step-up 会被\n误判成会话失效而把用户登出）。参考实现：apps\u002Fweb \u003Ccode>useAccountSwitch.ts\u003C\u002Fcode> 用裸 \u003Ccode>$fetch\u003C\u002Fcode>\n而非全局封装。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cdiv class=\"kun-table-wrap\">\u003Ctable>\u003Cthead>\n\u003Ctr>\n\u003Cth>方法\u003C\u002Fth>\n\u003Cth>路径\u003C\u002Fth>\n\u003Cth>作用\u003C\u002Fth>\n\u003Cth>响应（\u003Ccode>data\u003C\u002Fcode>）\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>GET\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>\u002Fapi\u002Fv1\u002Fauth\u002Fsessions\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>列出本浏览器袋子里的账号\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>{ items: [{ sub, name, email, avatar, avatar_image_hash?, roles, active, last_used_at }] }\u003C\u002Fcode>（\u003Ccode>roles\u003C\u002Fcode> 供选择器显示角色徽标 + 管理员「需重新登录」提示）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>POST\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>\u002Fapi\u002Fv1\u002Fauth\u002Fsessions\u002Fswitch\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>把某账号设为活跃（同站内即时全局）\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>{ user, access_token }\u003C\u002Fcode>（即登录响应体；并轮换 \u003Ccode>refresh_token\u003C\u002Fcode> cookie。前端可直接更新缓存 + 用新 access_token）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>POST\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>\u002Fapi\u002Fv1\u002Fauth\u002Fsessions\u002Flogout\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>登出此账号\u003C\u002Fstrong>（撤销该会话）\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>null\u003C\u002Fcode>（成功；目标不在袋中 → 404 \u002F 10005）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>POST\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>\u002Fapi\u002Fv1\u002Fauth\u002Fsessions\u002Flogout-all\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>登出全部\u003C\u002Fstrong>（撤销袋内所有会话 + 清锚点 cookie）\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>null\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\u003C\u002Fdiv>\u003Cp>请求体：\u003Ccode>switch\u003C\u002Fcode> \u002F \u003Ccode>logout\u003C\u002Fcode> 带 \u003Ccode>{ sub }\u003C\u002Fcode>（账号的 user uuid）。鉴权 = 用户 JWT（\u003Cstrong>Bearer\u003C\u002Fstrong>，\u003Ccode>Authorization\u003C\u002Fcode> header）——非 cookie 鉴权，天然免 CSRF，无需额外 CSRF 令牌。\u003Cstrong>调用者（Bearer 身份）必须是该浏览器袋子的成员\u003C\u002Fstrong>，否则 401（防 confused-deputy：仅凭锚点 cookie 不足以操作袋子）。\u003C\u002Fp>\n\u003Ch3 id=\"3-4-登出语义-本系统决策-基于撤销-revocation-based\" tabindex=\"-1\">3.4 登出语义（本系统决策：基于撤销 \u002F revocation-based）\u003C\u002Fh3>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>关键：单次「退出登录」只应登出当前账号，不要清空整个袋子。\u003C\u002Fstrong> OIDC 规范本身倾向「按\n会话登出」（用 \u003Ccode>sid\u003C\u002Fcode> 指明登出\u003Cstrong>哪一个\u003C\u002Fstrong>会话），\u003Cstrong>没有\u003C\u002Fstrong>规定「登出 = 登出所有账号」；Google\n网页版的「登出即登出全部」是其产品取舍（且广受诟病，移动端就是逐个登出）。多账号最佳实践\n= 同时提供**「退出当前账号」+「退出全部账号」**两个动作。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cul>\n\u003Cli>\u003Cstrong>退出当前账号\u003C\u002Fstrong>（\u003Ccode>POST \u002Fauth\u002Fsessions\u002Flogout {sub}\u003C\u002Fcode>）：OP 硬删除该账号的会话（删了就刷不动 = 撤销），从袋子移除；\u003Cstrong>其他账号不受影响\u003C\u002Fstrong>。若删的是当前活跃账号，OP 顺带清 \u003Ccode>refresh_token\u003C\u002Fcode> cookie。推荐下游 UX：删除后\u003Cstrong>落到袋内剩余的某个账号\u003C\u002Fstrong>（仍保持登录其它账号，类 Gmail 移动端），袋空了才回登录页——\u003Cstrong>不要\u003C\u002Fstrong>一登出就把人踢回登录页\u002F清掉所有账号。参考实现（apps\u002Fweb 账号中心切换器）：先 \u003Ccode>switch\u003C\u002Fcode> 到剩余的某个非管理员账号（这样调用者仍是袋成员 → 过 §3.3 的 confused-deputy 校验）再 \u003Ccode>logout\u003C\u002Fcode> 旧账号。\u003C\u002Fli>\n\u003Cli>\u003Cstrong>退出全部账号\u003C\u002Fstrong>（\u003Ccode>POST \u002Fauth\u002Fsessions\u002Flogout-all\u003C\u002Fcode>）：撤销袋内全部会话 + 清锚点 cookie。\u003Cstrong>仅在用户显式选择\u003C\u002Fstrong>「退出全部」时调用。\u003C\u002Fli>\n\u003Cli>\u003Cstrong>传播机制 = 撤销 + 短 access token TTL\u003C\u002Fstrong>（不用 back-channel \u002F iframe）：access token 寿命 \u003Cstrong>~10–15 分钟\u003C\u002Fstrong>；任意 app 刷新时若会话已撤销则刷新失败 → 一个 TTL 内登出。想更快就调短 TTL，这是唯一旋钮。\n\u003Cul>\n\u003Cli>这样选是因为：下游是 SPA，\u003Cstrong>没有服务端 RP 会话\u003C\u002Fstrong>可供 back-channel logout 关联 \u003Ccode>sid\u003C\u002Fcode>；front-channel iframe 又依赖跨站 cookie（脆弱）。撤销式最简单、坑最少、可日后再叠加即时传播而无需重构。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003C\u002Fli>\n\u003Cli>\u003Cstrong>与 RP 发起登出（\u003Ca href=\"\u002Fcontracts\u002Foauth\u002F07-logout\">07-logout.md\u003C\u002Fa>）的区别（别混用）\u003C\u002Fstrong>：上面的 \u003Ccode>logout\u003C\u002Fcode> \u002F \u003Ccode>logout-all\u003C\u002Fcode> 是\u003Cstrong>同站、OP 侧的会话袋管理\u003C\u002Fstrong>（账号中心用）。\u003Cstrong>跨 TLD 下游\u003C\u002Fstrong>要登出自己这个 app 的会话，走 RP 发起登出（顶层跳 OP 登出入口，见 07）；要切到别的账号走 §3.1 重定向 \u002F §3.6 切换器。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 id=\"3-5-管理员-step-up-本系统决策-切入管理员账号需重新认证\" tabindex=\"-1\">3.5 管理员 step-up（本系统决策：切入管理员账号需重新认证）\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>当选中的账号持 \u003Ccode>admin\u003C\u002Fcode> \u002F \u003Ccode>ren\u003C\u002Fcode> 角色，切换会被拒（\u003Ccode>\u002Fauth\u002Fsessions\u002Fswitch\u003C\u002Fcode> 返回 \u003Ccode>10016\u003C\u002Fcode>；\n重定向流则由 OP 强制 \u003Ccode>prompt=login\u003C\u002Fcode>）→ 必须重新认证后才激活并下发令牌。\u003C\u002Fli>\n\u003Cli>OP 记录 \u003Ccode>auth_time\u003C\u002Fcode>（已落库，为 \u003Ccode>max_age\u003C\u002Fcode> \u002F 管理端点的「最近认证时间」校验\u003Cstrong>预留\u003C\u002Fstrong>；当前\n仅切入 admin\u002Fren 强制重登已生效，过旧 auth_time 的细粒度校验留待后续）。\u003C\u002Fli>\n\u003Cli>下游无需特殊处理——照常走 §3.1 重定向即可，OP 自动插入重登；走 JSON \u003Ccode>switch\u003C\u002Fcode> 的同站前端\n收到 \u003Ccode>10016\u003C\u002Fcode> 时跳 \u003Ccode>\u002Foauth\u002Fauthorize?prompt=login&amp;login_hint=&lt;sub&gt;\u003C\u002Fcode> 重认证（见 §3.6）。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 id=\"3-6-账号选择器-ux-推荐下游统一实现\" tabindex=\"-1\">3.6 账号选择器 UX（推荐下游\u003Cstrong>统一\u003C\u002Fstrong>实现）\u003C\u002Fh3>\n\u003Cp>理想交互：点头像 → 弹出菜单 → 「切换账号」项（桌面 hover \u002F 手机点击）→ 二级菜单列出可切换账号 + 「添加新账号」。本设计\u003Cstrong>支持\u003C\u002Fstrong>它，机制如下，各站 UI 应统一这么做：\u003C\u002Fp>\n\u003Cp>\u003Cstrong>二级菜单的账号列表 = 本 app 的「已知账号」本地缓存（localStorage）\u003C\u002Fstrong>，不依赖跨站读 OP 袋子：\u003C\u002Fp>\n\u003Cul>\n\u003Cli>缓存项 = \u003Ccode>{ sub, name, avatar, avatar_image_hash?, email, roles? }\u003C\u002Fcode>（够渲染头像 \u002F 邮箱 \u002F 角色徽标），\u003Cstrong>不含任何令牌\u003C\u002Fstrong>。每次本 app 成功登录\u002F切换到某账号后，从 \u003Ccode>GET \u002Fauth\u002Fme\u003C\u002Fcode> 取身份写入缓存。\u003C\u002Fli>\n\u003Cli>二级菜单直接渲染这个本地列表——\u003Cstrong>跨 TLD 也能显示\u003C\u002Fstrong>（因为不需要 OP 的 cookie）。\u003C\u002Fli>\n\u003Cli>点某账号 → \u003Cstrong>顶层跳转\u003C\u002Fstrong> \u003Ccode>…\u002Foauth\u002Fauthorize?prompt=select_account&amp;login_hint=&lt;sub|email&gt;&amp;state=…&amp;code_challenge=…\u003C\u002Fcode>。顶层导航会带上 OP 的 Lax 锚点 cookie，OP 凭袋子静默切到该账号 → 回调换令牌。若 OP 袋子里已无该账号（在别处登出了）→ 优雅回退到登录。\u003C\u002Fli>\n\u003Cli>「添加新账号」→ 顶层跳转 \u003Ccode>…\u002Foauth\u002Fauthorize?prompt=login&amp;…\u003C\u002Fcode>（或选择器里的「使用其他账号」）。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>\u003Cstrong>同站 app（kungal.com 家族）可选增强\u003C\u002Fstrong>：直接 \u003Ccode>GET \u002Fauth\u002Fsessions\u003C\u002Fcode>（同站 cookie 会发送）实时拉取\u003Cstrong>准确\u003C\u002Fstrong>的袋子列表来覆盖本地缓存——列表永远最新。\u003C\u002Fp>\n\u003Cp>\u003Cstrong>诚实的限制（跨 TLD，如 moyu）\u003C\u002Fstrong>：本地缓存可能滞后真实袋子——在 forum 新加的账号 C，moyu 的二级菜单要等你在 moyu 上经 OP 切过一次才出现；在别处登出的账号点切换会回退到登录。OP 袋子始终是真源，切换永远以它为准，「添加新账号」永远可用 → 体验\u003Cstrong>可用且一致\u003C\u002Fstrong>，只是跨 TLD 的列表是「尽力而准」。\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>用各站自己的菜单\u002F下拉组件实现嵌套（桌面 hover、移动端点击展开）。纯前端，不影响以上数据契约。\u003Cstrong>参考实现（可直接照搬到 forum\u002Fmoyu）\u003C\u002Fstrong>：apps\u002Fwiki 的 \u003Ccode>components\u002Fauth\u002FAccountMenu.vue\u003C\u002Fcode>（嵌套菜单 + 角色徽标 + 「切换需重新登录」提示）+ \u003Ccode>composables\u002FuseKnownAccounts.ts\u003C\u002Fcode>（localStorage 缓存，SSR 安全）+ \u003Ccode>composables\u002FuseOAuthLogin.ts\u003C\u002Fcode>（带 \u003Ccode>prompt\u003C\u002Fcode>\u002F\u003Ccode>login_hint\u003C\u002Fcode> 的授权重定向）。\u003Cstrong>后端要求\u003C\u002Fstrong>：\u003Ccode>\u002Foauth\u002Fauthorize\u003C\u002Fcode> 接受 \u003Ccode>login_hint\u003C\u002Fcode>、\u003Ccode>GET \u002Fauth\u002Fme\u003C\u002Fcode> 返回身份（均已有）。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2 id=\"4-安全要求-下游-必须-依据-rfc-9700-rfc-6819\" tabindex=\"-1\">4. 安全要求（下游 \u003Cstrong>必须\u003C\u002Fstrong>，依据 RFC 9700 \u002F RFC 6819）\u003C\u002Fh2>\n\u003Cul>\n\u003Cli>[ ] 每次切换\u002F添加\u002F对齐重定向都带\u003Cstrong>一次性、绑定 user-agent 的 \u003Ccode>state\u003C\u002Fcode>\u003C\u002Fstrong> + \u003Cstrong>PKCE\u003C\u002Fstrong>（SPA 是 public client）。防止伪造回调把受害者\u003Cstrong>静默切到攻击者账号\u003C\u002Fstrong>。\u003C\u002Fli>\n\u003Cli>[ ] 回调\u002F返回 URL 必须在 OP 白名单内（复用 \u003Ca href=\"\u002Fcontracts\u002Foauth\u002F07-logout\">07-logout.md\u003C\u002Fa> 的 \u003Ccode>post-logout-redirect\u003C\u002Fcode> 白名单机制）；\u003Cstrong>不得\u003C\u002Fstrong>跳转到任意 query 参数 URL（开放重定向会泄露授权码\u002F令牌）。\u003C\u002Fli>\n\u003Cli>[ ] \u003Cstrong>当前账号永远从令牌（\u003Ccode>sub\u003C\u002Fcode>）推导，绝不信任前端传的「账号 id」\u003C\u002Fstrong>；每个写操作归属于令牌的 \u003Ccode>sub\u003C\u002Fcode>。\u003C\u002Fli>\n\u003Cli>[ ] access token 应 \u003Ccode>aud\u003C\u002Fcode> 限定到本 app 的资源服务器（moyu 的令牌不能拿去打 kungal 的 API）。\u003C\u002Fli>\n\u003Cli>[ ] UI 上\u003Cstrong>醒目展示当前账号\u003C\u002Fstrong>（头像+昵称在 header），防止后台标签页 stale 导致「以为是 A 实际是 B」。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 id=\"5-错误码-实现已落地-详见-04-tokens-and-errors-md-认证段\" tabindex=\"-1\">5. 错误码（实现已落地；详见 \u003Ca href=\"\u002Fcontracts\u002Foauth\u002F04-tokens-and-errors\">04-tokens-and-errors.md\u003C\u002Fa> 认证段）\u003C\u002Fh2>\n\u003Cblockquote>\n\u003Cp>后端错误统一走 \u003Ccode>{ code, message }\u003C\u002Fcode>（HTTP status + 业务 code 同时返回，下游\u003Cstrong>两者都要看\u003C\u002Fstrong>）。\n切换\u002F登出的业务错误复用认证段（\u003Ccode>10xxx\u003C\u002Fcode>），不另开 \u003Ccode>18xxx\u003C\u002Fcode> 段。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cdiv class=\"kun-table-wrap\">\u003Ctable>\u003Cthead>\n\u003Ctr>\n\u003Cth>场景\u003C\u002Fth>\n\u003Cth>HTTP\u003C\u002Fth>\n\u003Cth>code\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>需要选择账号（\u003Ccode>prompt=none\u003C\u002Fcode> 多账号未选，前端 OP 页判定）\u003C\u002Ftd>\n\u003Ctd>重定向回下游 \u003Ccode>error=account_selection_required\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>—\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>切换\u002F登出目标不在袋中（不存在\u002F已撤销）\u003C\u002Ftd>\n\u003Ctd>404\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>10005\u003C\u002Fcode>（ErrAuthUserNotFound）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>切换目标需 step-up（管理员\u002Fren，未重认证）\u003C\u002Ftd>\n\u003Ctd>401\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>10016\u003C\u002Fcode>（ErrAuthStepUpRequired）→ 前端跳 \u003Ccode>prompt=login\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>调用者非该袋成员（confused-deputy 防护）\u003C\u002Ftd>\n\u003Ctd>401\u003C\u002Ftd>\n\u003Ctd>\u003Ccode>10001\u003C\u002Fcode>（ErrAuthUnauthorized）\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\u003C\u002Fdiv>\u003Ch2 id=\"6-下游耦合点-重命名-重构时务必同步本节\" tabindex=\"-1\">6. 下游耦合点（重命名\u002F重构时务必同步本节）\u003C\u002Fh2>\n\u003Cp>下游会硬编码以下事实：\u003C\u002Fp>\n\u003Col>\n\u003Cli>OP 授权端点接受 \u003Ccode>prompt=select_account\u003C\u002Fcode> \u002F \u003Ccode>prompt=none\u003C\u002Fcode> \u002F \u003Ccode>prompt=login\u003C\u002Fcode>。\u003C\u002Fli>\n\u003Cli>「全局活跃账号」语义 = 焦点对齐（非后台标签页即时），下游 UI 须照此设计预期。\u003C\u002Fli>\n\u003Cli>登出传播 = 撤销 + 短 TTL（\u003Cstrong>不是\u003C\u002Fstrong>即时跨 app 登出）。\u003C\u002Fli>\n\u003Cli>同站才有会话袋 JSON API；跨 TLD 只有重定向流。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2 id=\"7-实施阶段-与-docs-auth-02-对齐\" tabindex=\"-1\">7. 实施阶段（与 \u003Ccode>docs\u002Fauth\u002F02\u003C\u002Fcode> 对齐）\u003C\u002Fh2>\n\u003Col>\n\u003Cli>✅ OP：会话袋 + \u003Ccode>prompt\u003C\u002Fcode> 处理 + 选择器页 + \u003Ccode>\u002Fauth\u002Fsessions\u003C\u002Fcode> API + 管理员 step-up（DB 迁移 \u003Ccode>kun_galgame_infra\u003C\u002Fcode>：\u003Ccode>sessions\u003C\u002Fcode> 加 \u003Ccode>browser_id\u003C\u002Fcode> 等）。单账号行为不变。\u003C\u002Fli>\n\u003Cli>✅ 账号中心（apps\u002Fweb，同 OP 家族）站内切换器（in-place switch + 角色徽标）。\u003C\u002Fli>\n\u003Cli>🚧 逐站接入 forum \u002F moyu \u002F wiki 切换器 UI（\u003Cstrong>wiki ✅ 已接入\u003C\u002Fstrong>；forum \u002F moyu 待做）+ \u003Ccode>prompt=none\u003C\u002Fcode> 焦点对齐（待做）。\u003C\u002Fli>\n\u003Cli>⏳ 调短 access TTL + 开审计日志。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Chr>\n\u003Ch2 id=\"变更摘要\" tabindex=\"-1\">变更摘要\u003C\u002Fh2>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>2026-06-25（登出语义）\u003C\u002Fstrong>：澄清 §3.4——单次「退出登录」只登出\u003Cstrong>当前账号\u003C\u002Fstrong>（非清空整个袋子；这非 RFC 强制、Google 网页版「登出全部」是其取舍），多账号应提供「退出当前账号」+「退出全部账号」两个动作；补充与 RP 发起登出（07）的区别。apps\u002Fweb 账号中心切换器据此实现（退出当前 = 先切到剩余账号再 \u003Ccode>logout\u003C\u002Fcode>，退出全部 = \u003Ccode>logout-all\u003C\u002Fcode>）。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>2026-06-24（实现）\u003C\u002Fstrong>：后端 + OP 账号选择器落地——会话袋（\u003Ccode>sessions.browser_id\u002Fauth_time\u002Flast_used_at\u003C\u002Fcode>）、\u003Ccode>prompt=select_account|none\u003C\u002Fcode> + \u003Ccode>login_hint\u003C\u002Fcode>、\u003Ccode>\u002Fauth\u002Fsessions\u003C\u002Fcode>（+ switch\u002Flogout\u002Flogout-all，Bearer + confused-deputy 防护）、管理员 step-up（\u003Ccode>10016\u003C\u002Fcode>）；apps\u002Fweb 站内切换器 + wiki 切换器（本地缓存 + 重定向）；\u003Ccode>SessionBrief\u003C\u002Fcode> 带 \u003Ccode>roles\u003C\u002Fcode>（角色徽标 + 「切换需重新登录」提示）。错误码复用 \u003Ccode>10xxx\u003C\u002Fcode>（非 \u003Ccode>18xxx\u003C\u002Fcode>）。forum \u002F moyu 切换器待接入。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cblockquote>\n\u003Cp>\u003Cstrong>2026-06-24（设计）\u003C\u002Fstrong>：新增本文。多账号切换契约：会话袋在 OP；切换走 \u003Ccode>prompt=select_account\u003C\u002Fcode> 重定向；全局活跃 = 焦点对齐（同 \u003Ccode>.kungal.com\u003C\u002Fcode> 可瞬时、moyu 跨 TLD 焦点对齐）；登出 = 撤销 + 短 TTL；管理员切入需 \u003Ccode>prompt=login\u003C\u002Fcode> 重登。安全沿用 07 的白名单 + 全程 \u003Ccode>state\u003C\u002Fcode>+PKCE。\u003C\u002Fp>\n\u003C\u002Fblockquote>\n","kun-galgame-infra\u002Fdocs\u002Fintegration\u002Foauth\u002F09-account-switching.md",1783514297224]