⑤ 登出 end-session
客户端发起
const params = new URLSearchParams({
client_id: CLIENT_ID, // client-demo
post_logout_redirect_uri: POST_LOGOUT_URI, // http://localhost:5173/
})
window.location.href = `${AUTHORITY}/connect/logout?${params.toString()}`/connect/logout = OpenIddict 的 end-session 端点(SetEndSessionEndpointUris("connect/logout"))。
OP 端:GET 只负责"渲染确认页"
[HttpGet("~/connect/logout")]
public IActionResult Logout() => Redirect(QueryHelpers.AddQueryString(
oauthOptions.Value.LogoutPageUrl, Request.Query));Request.Query(IQueryCollection)本身就是 IEnumerable<KeyValuePair<string, StringValues>>, 直接命中 QueryHelpers.AddQueryString(string, IEnumerable<KeyValuePair<string, StringValues>>) 重载: 无需 Select 投影,而且多值参数会展开成 ?state=a&state=b(投影 + ToString() 会把多值拼成一个值)。
为什么 GET 只跳确认页
GET /connect/logout 302 到 React 登出确认页,post_logout_redirect_uri 等参数原样带过去; 用户点"确认登出"后表单 POST 回 POST /connect/logout,才真正清会话。
为什么 GET 不直接登出?因为登出必须是一个显式的用户动作(防 CSRF:否则 <img src="/connect/logout"> 就能把用户登出)。 所以 GET 只展示确认页,真正的动作在 POST。
React 登出确认页
const params: Record<string, string> = {}
new URLSearchParams(window.location.search).forEach((value, key) => { params[key] = value })
<form method="post" action={`${AUTHORITY}/connect/logout`}>
{Object.entries(params).map(([key, value]) => <input key={key} type="hidden" name={key} value={value} />)}
<input type="hidden" name="__RequestVerificationToken" value={token} />
<button type="submit" name="Confirm" value="Yes" disabled={!token}>确认登出</button>
</form>同同意页:防伪 token 来自 GET /Account/Antiforgery,表单普通 POST(让浏览器自己跟着 302 走)。
OP 端:POST 才真的登出
[ActionName(nameof(Logout)), HttpPost("~/connect/logout"), ValidateAntiForgeryToken]
public async Task<IActionResult> LogoutPost()
{
// Ask ASP.NET Core Identity to delete the local and external cookies created
// when the user agent is redirected from the external identity provider
// after a successful authentication flow (e.g Google or Facebook).
await signInManager.SignOutAsync();
return SignOut(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties { RedirectUri = "/" });
}两件事:
SignInManager.SignOutAsync()→ 删掉 Identity 应用 cookie(清掉"人"这一层)。 注释特别提到它也会清外部登录相关 cookie(例如外部的Identity.Externalcookie)。SignOut(OpenIddict scheme)→ 让 OpenIddict 把浏览器重定向到客户端注册过的post_logout_redirect_uri(没给就用RedirectUri = "/")。
登出 ≠ 撤销 token
SignOutAsync 清的是 OP 侧的会话 cookie。已经发出去的 access token 在过期前依然有效(JWT 是无状态的)。 如果业务需要"立刻作废",要另外撤销 refresh token / 用引用型 token / 维护黑名单。
实测链路
GET /connect/logout?client_id=client-demo&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173%2F
→ 302 /Account/Logout?client_id=client-demo&post_logout_redirect_uri=...
POST /connect/logout(表单:上面两个参数 + __RequestVerificationToken)
→ 302 http://localhost:5173/post_logout_redirect_uri 必须是该客户端注册过的地址,否则 OpenIddict 会拒绝(这是 end-session 的 ID2xxx 校验之一)。
登出之后
- 再点"登录" → ① 里
AuthenticateAsync()失败 → 回到 React 登录页,要求重新输密码。 - 客户端的本地 token 是
oidc-client-ts自己清的(userManager.removeUser()/signoutRedirect回调), 与 OP 会话无关——这是"OP 会话"和"客户端会话"分离的体现,也是 SSO 的原理:同一浏览器再访问其它客户端时,OP 侧会直接免登录。