Skip to content

⑤ 登出 end-session ​

客户端发起 ​

ts
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 只负责"渲染确认页" ​

csharp
[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 登出确认页 ​

tsx
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 才真的登出 ​

csharp
[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 = "/" });
}

两件事:

  1. SignInManager.SignOutAsync() → 删掉 Identity 应用 cookie(清掉"人"这一层)。 注释特别提到它也会清外部登录相关 cookie(例如外部的 Identity.External cookie)。
  2. SignOut(OpenIddict scheme) → 让 OpenIddict 把浏览器重定向到客户端注册过的 post_logout_redirect_uri (没给就用 RedirectUri = "/")。

登出 ≠ 撤销 token

SignOutAsync 清的是 OP 侧的会话 cookie。已经发出去的 access token 在过期前依然有效(JWT 是无状态的)。 如果业务需要"立刻作废",要另外撤销 refresh token / 用引用型 token / 维护黑名单。

实测链路 ​

http
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 侧会直接免登录。

OpenIddict + ASP.NET Core Identity 授权服务器,React 登录页,逐步拆解登录链路