Skip to content

demo 客户端(SPA 侧) ​

登录流程的另一半在客户端。它很短,但决定了"OP 会收到什么参数"。 界面用 Ant Design v6(Layout / Menu / Card / Button / Table 展示天气 / Descriptions 展示令牌,全局 ConfigProvider 在 main.tsx), 业务逻辑只有下面这些。

UserManager 配置 ​

demos/yagang-client-demo/src/oidc/client.ts:

ts
export const userManager = new UserManager({
  authority: import.meta.env.VITE_OIDC_AUTHORITY,   // https://localhost:7016
  client_id: import.meta.env.VITE_OIDC_CLIENT_ID,   // client-demo
  redirect_uri: import.meta.env.VITE_OIDC_REDIRECT_URI, // http://localhost:5173/signin-callback
  response_type: 'code',
  scope: import.meta.env.VITE_OIDC_SCOPE,           // openid offline_access demo_api
  userStore: new WebStorageStateStore({ store: window.localStorage }),
  automaticSilentRenew: true,                        // token 过期前 60s 用 refresh_token 静默续期
})

oidc-client-ts 负责的事(这些你写代码时都不用自己实现):

  • 生成 state 与 PKCE code_verifier / code_challenge;
  • 把 code_verifier 暂存起来,回调时带上去换 token;
  • 校验回来的 state、把 token 存 localStorage、到点自动刷新(refresh token 旋转)。

发起登录 ​

ts
async function login() {
  await userManager.signinRedirect()   // 顶层导航到 /connect/authorize?...(不是 fetch!)
}

必须用顶层导航,不能用 fetch

授权请求的响应是 302(跳到登录页/同意页/回调地址)。 fetch 遇到跨源/重定向时拿到的是"不透明重定向",JS 读不到 Location,也没法种 cookie—— 所以授权请求只能由浏览器顶层导航发起。 (这也是本项目登录页为什么用表单 POST(服务端 302 回 authorize),而不是 fetch + JS 导航的原因,见 ② 登录。)

回调 ​

tsx
// pages/SignInCallback.tsx(路由 /signin-callback,与客户端注册的 redirect_uri 完全一致)
useEffect(() => {
  void userManager.signinCallback()
    .catch(...)
    .finally(() => navigate('/', { replace: true }))
}, [navigate])

signinCallback() 内部:校验 state → 用 code + code_verifier POST /connect/token → 存 token → 触发 addUserLoaded。

登出 ​

ts
// pages/Home.tsx
await userManager.removeUser()      // ⚠️ 先删本地 token,再跳 OP
userManager.clearStaleState()
const params = new URLSearchParams({
  client_id: CLIENT_ID,
  post_logout_redirect_uri: POST_LOGOUT_URI,
})
window.location.href = `${AUTHORITY}/connect/logout?${params.toString()}`

走的是标准 end-session 端点,而不是自己造一个 /account/logout: OP 会校验 client_id / post_logout_redirect_uri 是否是注册过的,再清 cookie 并跳回来。

别只跳转、忘了删本地 token

如果只做 window.location.href = .../connect/logout,OP 那边的会话确实清了,但客户端 localStorage 里的 user 还在:回到本页 getUser() 仍能读到旧用户 → 界面显示成"已登录 + 旧 token"(假登录态), 直到下一次静默刷新失败才恢复。正确顺序:removeUser()(清 token 并触发 userUnloaded 事件)→ 再跳 end-session。

客户端注册(OP 侧) ​

appsettings.json 的 OAuth:Clients → 启动时写进 oi_app 表:

json
{
  "ClientId": "client-demo",
  "RedirectUri": "http://localhost:5173/signin-callback",
  "PostLogoutRedirectUri": "http://localhost:5173/"
}
属性取值为什么
ClientTypepublicSPA 存不住 secret,靠 PKCE 证明"换 code 的人就是发起登录的人"
ConsentTypeexplicit首次授权必须经过同意页(本项目刻意不让客户端"静默拿到"用户数据)
Requirements.Features.ProofKeyForCodeExchange强制公开客户端必须 PKCE
Permissionsauthorization / end-session / token / revocation 端点 + authorization_code、refresh_token + response_type=code + openid/email/profile/roles/demo_api/offline_access白名单式授权;openid 默认允许,不需要(也没有)Permissions.Scopes.OpenId

为什么 refresh_token 需要 offline_access

OpenIddict 只在令牌请求包含 offline_access scope 时才发 refresh_token (IncludeRefreshToken 判定 HasScope(Scopes.OfflineAccess))。 本项目客户端在 VITE_OIDC_SCOPE 里显式带上它,所以能拿到 refresh token。

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