demo 客户端(SPA 侧)
登录流程的另一半在客户端。它很短,但决定了"OP 会收到什么参数"。 界面用 Ant Design v6(Layout / Menu / Card / Button / Table 展示天气 / Descriptions 展示令牌,全局 ConfigProvider 在 main.tsx), 业务逻辑只有下面这些。
UserManager 配置
demos/yagang-client-demo/src/oidc/client.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与 PKCEcode_verifier/code_challenge; - 把
code_verifier暂存起来,回调时带上去换 token; - 校验回来的
state、把 token 存 localStorage、到点自动刷新(refresh token 旋转)。
发起登录
async function login() {
await userManager.signinRedirect() // 顶层导航到 /connect/authorize?...(不是 fetch!)
}必须用顶层导航,不能用 fetch
授权请求的响应是 302(跳到登录页/同意页/回调地址)。 fetch 遇到跨源/重定向时拿到的是"不透明重定向",JS 读不到 Location,也没法种 cookie—— 所以授权请求只能由浏览器顶层导航发起。 (这也是本项目登录页为什么用表单 POST(服务端 302 回 authorize),而不是 fetch + JS 导航的原因,见 ② 登录。)
回调
// 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。
登出
// 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 表:
{
"ClientId": "client-demo",
"RedirectUri": "http://localhost:5173/signin-callback",
"PostLogoutRedirectUri": "http://localhost:5173/"
}| 属性 | 取值 | 为什么 |
|---|---|---|
ClientType | public | SPA 存不住 secret,靠 PKCE 证明"换 code 的人就是发起登录的人" |
ConsentType | explicit | 首次授权必须经过同意页(本项目刻意不让客户端"静默拿到"用户数据) |
Requirements.Features.ProofKeyForCodeExchange | 强制 | 公开客户端必须 PKCE |
| Permissions | authorization / 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。