@nihplod/sso-sdk 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,8 @@ const sso = new SsoClient({
23
23
  await sso.login();
24
24
 
25
25
  // 2. Handle callback (inside the callback page)
26
+ // Token is persisted to sessionStorage by default, so it survives
27
+ // the full-page redirect back from the SSO center.
26
28
  const token = await sso.handleCallback(window.location.href);
27
29
 
28
30
  // 3. Get user info
@@ -60,6 +62,8 @@ Initiate SSO login. Generates PKCE parameters and redirects to the SSO login pag
60
62
 
61
63
  Build the login URL string without redirecting. Returns `Promise<string>`.
62
64
 
65
+ > ⚠️ Do NOT mix `getLoginUrl()` with `login()` for the same login attempt: both regenerate and overwrite the `state` / PKCE verifier in sessionStorage, so the flow started first will fail with a state mismatch. Use only one entry point per login.
66
+
63
67
  ### `sso.handleCallback(callbackUrl)`
64
68
 
65
69
  Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens.
@@ -113,13 +117,26 @@ Clear local token data and attempt to revoke the server-side refresh_token.
113
117
  |------|------|------|------|
114
118
  | `redirectToSso` | `boolean` | `false` | Whether to redirect to the SSO logout page (OIDC RP-Initiated Logout) |
115
119
 
116
- When `redirectToSso=true`, the user is redirected to `/logout?client_id=...&post_logout_redirect_uri=...`. The main site clears the session and then returns to the sub-project callback address.
120
+ When `redirectToSso=true`, the user is redirected to the `end_session_endpoint` from OIDC Discovery (fallback: `/api/oauth/end-session`) with `client_id`, `post_logout_redirect_uri`, `id_token_hint` and `state` parameters. The generated `state` is saved to sessionStorage; the main site clears the session and then returns to the sub-project callback address. Validate the `state` on the return page with `sso.validateLogoutState()` to prevent logout CSRF.
121
+
122
+ ### `sso.validateLogoutState(url)`
123
+
124
+ Validate the `state` parameter of an RP-Initiated Logout redirect-back (logout CSRF protection). Call this on the page that `post_logout_redirect_uri` points to. Returns `true` only when the URL carries a `state` matching the one saved by `sso.logout(true)`; on success the saved state is cleared (one-time).
125
+
126
+ ```typescript
127
+ // On the post_logout_redirect_uri page:
128
+ if (sso.validateLogoutState(window.location.href)) {
129
+ // Trusted redirect-back from the SSO logout page
130
+ }
131
+ ```
132
+
133
+ Returns `boolean`.
117
134
 
118
135
  ### `sso.getDiscovery()`
119
136
 
120
137
  Fetch the OIDC Discovery document.
121
138
 
122
- Returns `Promise<OidcDiscovery>`.
139
+ Returns `Promise<OidcDiscovery | null>` — may be `null` when the discovery endpoint is unreachable and no cached document exists.
123
140
 
124
141
  ---
125
142
 
@@ -204,6 +221,7 @@ const {
204
221
  user, // SsoUser | null
205
222
  isAuthenticated, // boolean
206
223
  isLoading, // boolean
224
+ error, // SsoError | null — set when loading user info fails (e.g. session revoked)
207
225
  login, // (returnUrl?: string) => Promise<void>
208
226
  logout, // (redirectToSso?: boolean) => Promise<void>
209
227
  refreshUser, // () => Promise<void>
@@ -212,6 +230,8 @@ const {
212
230
  } = useSso();
213
231
  ```
214
232
 
233
+ Authentication state is three-valued: `isLoading` (initializing/refreshing) → `error` (load failed, e.g. session expired) → `user` (authenticated). Render your UI accordingly.
234
+
215
235
  ### `<RequireAuth>`
216
236
 
217
237
  ```tsx
@@ -222,6 +242,21 @@ import { RequireAuth } from "@nihplod/sso-sdk/react";
222
242
  </RequireAuth>
223
243
  ```
224
244
 
245
+ If starting the login flow fails (e.g. the user closes the popup, `popup_closed`), the component resets its internal trigger flag and shows a retry entry point instead of getting stuck on "please log in". Use `onError` to observe the failure and `renderLoginError(error, retry)` to customize the UI.
246
+
247
+ ```tsx
248
+ <RequireAuth
249
+ autoLogin
250
+ usePopup
251
+ onError={(err) => console.warn("login failed", err)}
252
+ renderLoginError={(err, retry) => (
253
+ <button onClick={retry}>登录未完成,点击重试</button>
254
+ )}
255
+ >
256
+ <ProtectedContent />
257
+ </RequireAuth>
258
+ ```
259
+
225
260
  ### `withAuth(Component)`
226
261
 
227
262
  ```tsx
@@ -242,6 +277,23 @@ export default function AuthCallback() {
242
277
  }
243
278
  ```
244
279
 
280
+ By default the component performs a full-page redirect (`window.location.href`) to the saved `returnUrl` (or `/`) after the token exchange — safe because tokens are persisted to sessionStorage by default. For SPAs that prefer router navigation (no page reload, app state preserved), pass `onSuccess` to take over the redirect; `onError` / `renderError` customize failure handling:
281
+
282
+ ```tsx
283
+ function CallbackRoute() {
284
+ const navigate = useNavigate();
285
+ return (
286
+ <CallbackPage
287
+ onSuccess={(tokenData) => navigate("/dashboard", { replace: true })}
288
+ onError={(err) => console.warn("sso callback failed", err)}
289
+ renderError={(message) => <MyErrorPage message={message} />}
290
+ />
291
+ );
292
+ }
293
+ ```
294
+
295
+ The default error UI is also exported as `DefaultCallbackError` if you want to reuse it.
296
+
245
297
  ---
246
298
 
247
299
  ## Next.js Bindings
@@ -257,7 +309,7 @@ export const middleware = createSsoMiddleware({
257
309
  ssoBaseUrl: "https://nihplod.cn",
258
310
  redirectUri: "https://yourapp.com/api/auth/callback",
259
311
  scopes: "openid profile",
260
- publicPaths: ["/", "/public"],
312
+ publicPaths: ["/", "/public", "/api/auth/logout"],
261
313
  // Confidential Client (BFF) can pass clientSecret
262
314
  // clientSecret: process.env.SSO_CLIENT_SECRET,
263
315
  // validateSsoCookie defaults to true: the middleware calls the introspection
@@ -265,6 +317,12 @@ export const middleware = createSsoMiddleware({
265
317
  // "cookie exists = logged in" semantics (lowest latency, but may pass revoked
266
318
  // sessions). Either way, the middleware is only a UX gate — always re-verify
267
319
  // tokens in Route Handlers / Server Components before serving sensitive data.
320
+ // insecureLocalDev: false by default; set true ONLY for http://localhost
321
+ // development (disables the Secure cookie attribute and strips __Host-/__Secure-
322
+ // prefixes, which browsers refuse to write over HTTP). Must be set consistently
323
+ // on the middleware, callback and logout handlers. Never enable in production —
324
+ // as a safety guard, the option is force-ignored (with a warning) when
325
+ // NODE_ENV=production and ssoBaseUrl uses https, keeping cookies secure.
268
326
  });
269
327
 
270
328
  export const config = {
@@ -272,6 +330,13 @@ export const config = {
272
330
  };
273
331
  ```
274
332
 
333
+ > **Token expiry behavior (expected):** the middleware does NOT refresh tokens. When the
334
+ > `access_token` cookie expires or fails introspection, the middleware clears it and
335
+ > redirects to `/api/oauth/authorize`. Because the user still holds an SSO session on the
336
+ > main site, the authorize endpoint immediately redirects back with a fresh authorization
337
+ > code (silent re-auth), and the callback handler issues new cookies — the user typically
338
+ > only sees a quick redirect loop through the SSO center. No client-side action is needed.
339
+
275
340
  ```typescript
276
341
  // src/app/api/auth/callback/route.ts
277
342
  import { createCallbackRouteHandler } from "@nihplod/sso-sdk/next";
@@ -325,13 +390,15 @@ Default cookie names:
325
390
  | return_url | `__Host-nihplod_sso_return` | Requires Secure + Path=/ + no Domain |
326
391
  | verifier | `__Secure-nihplod_sso_verifier` | Requires Secure + no Domain; Path is the callback path, therefore uses `__Secure-` prefix |
327
392
 
328
- > For local development with `http://localhost`, the browser will reject `Secure` cookies. You may disable `secure` locally, but HTTPS is mandatory in production.
393
+ > For local development with `http://localhost`, browsers reject `Secure` cookies — and cookies named with `__Host-`/`__Secure-` prefixes are refused outright when `Secure` is missing (Chrome, Edge and Firefox all enforce this; behavior on `localhost` varies by browser, some treat it as a secure context for `Secure` cookies, none accept prefixed names without `Secure`). The visible symptom: the login callback appears to succeed but the cookies are never written, so the middleware keeps judging you as logged out and redirects to the SSO authorize page in an infinite loop. Fix: set `insecureLocalDev: true` on `createSsoMiddleware`, `createCallbackRouteHandler` and `createLogoutRouteHandler` (it disables `Secure` and strips the prefixes, with a startup warning), or serve local dev over HTTPS. HTTPS is mandatory in production — and as a production guard, all three helpers force-ignore `insecureLocalDev` (keeping `Secure` and the `__Host-`/`__Secure-` prefixes, with a warning) when `NODE_ENV=production` and `ssoBaseUrl` uses HTTPS.
329
394
 
330
395
  ---
331
396
 
332
397
  ## Security Recommendations and Token Storage
333
398
 
334
- By default, the SDK stores tokens in **memory**. Public Clients (SPA / mobile / desktop) should **never** write the `refresh_token` to `localStorage` to prevent XSS from stealing long-lived credentials.
399
+ By default, the SDK stores tokens in **sessionStorage** (tab-scoped persistence). This keeps the login state across page reloads and the full-page redirect that `CallbackPage` performs after the token exchange, while the data is cleared automatically when the tab closes and is never shared with other tabs. In SSR environments or privacy modes where `sessionStorage` is unavailable/unwritable, it falls back to an in-memory map (login state is lost on reload in that case).
400
+
401
+ > ⚠️ XSS note: any token readable by JavaScript can be stolen by XSS. sessionStorage narrows the exposure compared with `localStorage` (tab-scoped, auto-cleared), but a `refresh_token` readable by JS is still exfiltratable while the tab is open. If you need stronger guarantees, use the Next.js BFF pattern below (tokens in `httpOnly` cookies) or keep the refresh token inside a Service Worker.
335
402
 
336
403
  Transient OAuth data (PKCE `code_verifier`, `state`, `returnUrl`, popup nonce) is stored separately in **sessionStorage**, because it must survive the full-page redirect to the SSO center and back; it is cleared automatically when the tab closes. In SSR environments without `sessionStorage`, it falls back to an in-memory map.
337
404
 
@@ -369,6 +436,6 @@ const challenge = await generateCodeChallenge(verifier);
369
436
  // State
370
437
  const state = generateState();
371
438
 
372
- // Custom token storage (default is memory storage)
439
+ // Custom token storage (default is sessionStorage; persist: true → localStorage)
373
440
  setTokenStorage(createSecureStorage({ persist: false }));
374
441
  ```
package/dist/index.d.mts CHANGED
@@ -2,8 +2,10 @@
2
2
  * Token 存储抽象层
3
3
  *
4
4
  * 分两类存储:
5
- * - Token 数据:默认使用内存存储,Public Client 浏览器中 refresh_token 不落盘,
6
- * XSS 无法窃取长期凭证。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
5
+ * - Token 数据:默认使用 sessionStorage(标签页级持久化)。登录回调后 CallbackPage
6
+ * 默认整页跳转,内存存储会丢失登录态,因此默认改为 sessionStorage:
7
+ * 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
8
+ * 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
7
9
  * 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
8
10
  * - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
9
11
  * (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
@@ -34,12 +36,13 @@ interface TokenStorage {
34
36
  /**
35
37
  * 创建存储实现
36
38
  *
37
- * @param options.persist 是否持久化到 localStorage。默认 false(内存存储)。
39
+ * @param options.persist 是否持久化到 localStorage。默认 false(sessionStorage)。
40
+ * - false(默认):sessionStorage,标签页级持久化(刷新/整页跳转后登录态保留,
41
+ * 关闭标签页即清除),多 Tab 间不共享;隐私模式等写入失败时降级为内存。
42
+ * - true:localStorage,多 Tab 共享、关闭浏览器后仍保留。
38
43
  * ⚠️ 安全警告:persist=true 会将 refresh_token 明文写入 localStorage,
39
44
  * 任何 XSS 均可在不被检测的情况下读取。仅在 BFF/Confidential Client
40
45
  * 且 refresh_token 不直接暴露给浏览器的场景下使用。
41
- * - Confidential/BFF 子项目可设为 true。
42
- * - Public Client 在浏览器中应保持 false(默认)。
43
46
  */
44
47
  declare function createSecureStorage(options?: {
45
48
  persist?: boolean;
@@ -61,6 +64,9 @@ declare function removePkceVerifier(clientId: string): void;
61
64
  declare function saveOAuthState(state: string, clientId?: string): void;
62
65
  declare function getOAuthState(clientId?: string): string | null;
63
66
  declare function removeOAuthState(clientId?: string): void;
67
+ declare function saveLogoutState(state: string, clientId?: string): void;
68
+ declare function getLogoutState(clientId?: string): string | null;
69
+ declare function removeLogoutState(clientId?: string): void;
64
70
  declare function saveReturnUrl(url: string, clientId?: string): void;
65
71
  declare function getReturnUrl(clientId?: string): string | null;
66
72
  declare function removeReturnUrl(clientId?: string): void;
@@ -110,7 +116,12 @@ interface SsoUser {
110
116
  sub: string;
111
117
  nickname?: string;
112
118
  avatar?: string;
119
+ /** 手机号(脱敏,兼容保留的非标准 claim,新代码请使用 phone_number) */
113
120
  phone?: string;
121
+ /** 手机号(脱敏,OIDC 标准 claim,与 phone 内容一致) */
122
+ phone_number?: string;
123
+ /** 生日(ISO 8601,需 birthday scope,未设置时为 null) */
124
+ birthday?: string | null;
114
125
  membership_level?: string;
115
126
  total_points?: number;
116
127
  }
@@ -173,12 +184,20 @@ declare class SsoClient {
173
184
  *
174
185
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
175
186
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
187
+ *
188
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
189
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
190
+ * 同一次登录只使用其中一个入口。
176
191
  */
177
192
  login(returnUrl?: string): Promise<void>;
178
193
  /**
179
194
  * 构建登录 URL(不跳转,返回 URL 字符串)
180
195
  *
181
196
  * 适用于需要手动处理跳转的场景。
197
+ *
198
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
199
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
200
+ * 同一次登录只使用其中一个入口。
182
201
  */
183
202
  getLoginUrl(returnUrl?: string): Promise<string>;
184
203
  /**
@@ -223,7 +242,7 @@ declare class SsoClient {
223
242
  * 处理 OAuth 回调
224
243
  *
225
244
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
226
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
245
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
227
246
  *
228
247
  * @param callbackUrl - 完整的回调 URL(window.location.href)
229
248
  * @returns TokenData 或 null
@@ -261,9 +280,28 @@ declare class SsoClient {
261
280
  * 登出
262
281
  *
263
282
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
264
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
283
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
284
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
285
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
265
286
  */
266
287
  logout(redirectToSso?: boolean): Promise<void>;
288
+ /**
289
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
290
+ *
291
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
292
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
293
+ * 校验后清除已保存的 logout state(一次性)。
294
+ *
295
+ * @param url - 当前页面完整 URL(window.location.href)
296
+ *
297
+ * @example
298
+ * ```typescript
299
+ * if (sso.validateLogoutState(window.location.href)) {
300
+ * // 来自 SSO 登出的可信回跳
301
+ * }
302
+ * ```
303
+ */
304
+ validateLogoutState(url: string): boolean;
267
305
  /**
268
306
  * 获取 OIDC Discovery 文档
269
307
  *
@@ -332,8 +370,9 @@ declare class SsoError extends Error {
332
370
  /**
333
371
  * OAuth 2.0 协议层错误(RFC 6749 §5.2)
334
372
  *
335
- * 用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
336
- * 与 SDK 语义的 SsoError 区分。
373
+ * 保留用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
374
+ * 与 SDK 语义的 SsoError 区分。当前 SDK 内部不抛出此类型(统一使用 SsoError +
375
+ * mapOAuthErrorToSsoCode 映射),作为公共类型导出供子项目自行解析 OAuth 响应时使用。
337
376
  */
338
377
  declare class OAuthError extends Error {
339
378
  readonly code: string;
@@ -348,6 +387,8 @@ declare class OAuthError extends Error {
348
387
  /**
349
388
  * 校验 returnUrl 是否可信(防开放重定向)。
350
389
  * 仅允许:相对路径(且不以 // 开头)或与 currentOrigin 完全同源的绝对 URL。
390
+ * 拒绝一切含反斜杠 \ 的值:浏览器会把 "/\evil.com" 归一化为 "//evil.com",
391
+ * 形成协议相对 URL 开放重定向。
351
392
  */
352
393
  declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
353
394
  /**
@@ -356,4 +397,4 @@ declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean
356
397
  */
357
398
  declare function timingSafeEqualString(a: string, b: string): boolean;
358
399
 
359
- export { OAuthError, type OidcDiscovery, SsoClient, type SsoClientConfig, SsoError, type SsoErrorCode, type SsoUser, type TokenData, type TokenResponse, type TokenStorage, clearAllSsoData, clearVerifiersForClients, createSecureStorage, generateCodeChallenge, generateCodeVerifier, generateState, getOAuthState, getPkceVerifier, getReturnUrl, getTokenData, getTokenStorage, isTrustedReturnUrl, mapOAuthErrorToSsoCode, removeOAuthState, removePkceVerifier, removeReturnUrl, removeTokenData, saveOAuthState, savePkceVerifier, saveReturnUrl, saveTokenData, setTokenStorage, timingSafeEqualString };
400
+ export { OAuthError, type OidcDiscovery, SsoClient, type SsoClientConfig, SsoError, type SsoErrorCode, type SsoUser, type TokenData, type TokenResponse, type TokenStorage, clearAllSsoData, clearVerifiersForClients, createSecureStorage, generateCodeChallenge, generateCodeVerifier, generateState, getLogoutState, getOAuthState, getPkceVerifier, getReturnUrl, getTokenData, getTokenStorage, isTrustedReturnUrl, mapOAuthErrorToSsoCode, removeLogoutState, removeOAuthState, removePkceVerifier, removeReturnUrl, removeTokenData, saveLogoutState, saveOAuthState, savePkceVerifier, saveReturnUrl, saveTokenData, setTokenStorage, timingSafeEqualString };
package/dist/index.d.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  * Token 存储抽象层
3
3
  *
4
4
  * 分两类存储:
5
- * - Token 数据:默认使用内存存储,Public Client 浏览器中 refresh_token 不落盘,
6
- * XSS 无法窃取长期凭证。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
5
+ * - Token 数据:默认使用 sessionStorage(标签页级持久化)。登录回调后 CallbackPage
6
+ * 默认整页跳转,内存存储会丢失登录态,因此默认改为 sessionStorage:
7
+ * 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
8
+ * 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
7
9
  * 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
8
10
  * - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
9
11
  * (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
@@ -34,12 +36,13 @@ interface TokenStorage {
34
36
  /**
35
37
  * 创建存储实现
36
38
  *
37
- * @param options.persist 是否持久化到 localStorage。默认 false(内存存储)。
39
+ * @param options.persist 是否持久化到 localStorage。默认 false(sessionStorage)。
40
+ * - false(默认):sessionStorage,标签页级持久化(刷新/整页跳转后登录态保留,
41
+ * 关闭标签页即清除),多 Tab 间不共享;隐私模式等写入失败时降级为内存。
42
+ * - true:localStorage,多 Tab 共享、关闭浏览器后仍保留。
38
43
  * ⚠️ 安全警告:persist=true 会将 refresh_token 明文写入 localStorage,
39
44
  * 任何 XSS 均可在不被检测的情况下读取。仅在 BFF/Confidential Client
40
45
  * 且 refresh_token 不直接暴露给浏览器的场景下使用。
41
- * - Confidential/BFF 子项目可设为 true。
42
- * - Public Client 在浏览器中应保持 false(默认)。
43
46
  */
44
47
  declare function createSecureStorage(options?: {
45
48
  persist?: boolean;
@@ -61,6 +64,9 @@ declare function removePkceVerifier(clientId: string): void;
61
64
  declare function saveOAuthState(state: string, clientId?: string): void;
62
65
  declare function getOAuthState(clientId?: string): string | null;
63
66
  declare function removeOAuthState(clientId?: string): void;
67
+ declare function saveLogoutState(state: string, clientId?: string): void;
68
+ declare function getLogoutState(clientId?: string): string | null;
69
+ declare function removeLogoutState(clientId?: string): void;
64
70
  declare function saveReturnUrl(url: string, clientId?: string): void;
65
71
  declare function getReturnUrl(clientId?: string): string | null;
66
72
  declare function removeReturnUrl(clientId?: string): void;
@@ -110,7 +116,12 @@ interface SsoUser {
110
116
  sub: string;
111
117
  nickname?: string;
112
118
  avatar?: string;
119
+ /** 手机号(脱敏,兼容保留的非标准 claim,新代码请使用 phone_number) */
113
120
  phone?: string;
121
+ /** 手机号(脱敏,OIDC 标准 claim,与 phone 内容一致) */
122
+ phone_number?: string;
123
+ /** 生日(ISO 8601,需 birthday scope,未设置时为 null) */
124
+ birthday?: string | null;
114
125
  membership_level?: string;
115
126
  total_points?: number;
116
127
  }
@@ -173,12 +184,20 @@ declare class SsoClient {
173
184
  *
174
185
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
175
186
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
187
+ *
188
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
189
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
190
+ * 同一次登录只使用其中一个入口。
176
191
  */
177
192
  login(returnUrl?: string): Promise<void>;
178
193
  /**
179
194
  * 构建登录 URL(不跳转,返回 URL 字符串)
180
195
  *
181
196
  * 适用于需要手动处理跳转的场景。
197
+ *
198
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
199
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
200
+ * 同一次登录只使用其中一个入口。
182
201
  */
183
202
  getLoginUrl(returnUrl?: string): Promise<string>;
184
203
  /**
@@ -223,7 +242,7 @@ declare class SsoClient {
223
242
  * 处理 OAuth 回调
224
243
  *
225
244
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
226
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
245
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
227
246
  *
228
247
  * @param callbackUrl - 完整的回调 URL(window.location.href)
229
248
  * @returns TokenData 或 null
@@ -261,9 +280,28 @@ declare class SsoClient {
261
280
  * 登出
262
281
  *
263
282
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
264
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
283
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
284
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
285
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
265
286
  */
266
287
  logout(redirectToSso?: boolean): Promise<void>;
288
+ /**
289
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
290
+ *
291
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
292
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
293
+ * 校验后清除已保存的 logout state(一次性)。
294
+ *
295
+ * @param url - 当前页面完整 URL(window.location.href)
296
+ *
297
+ * @example
298
+ * ```typescript
299
+ * if (sso.validateLogoutState(window.location.href)) {
300
+ * // 来自 SSO 登出的可信回跳
301
+ * }
302
+ * ```
303
+ */
304
+ validateLogoutState(url: string): boolean;
267
305
  /**
268
306
  * 获取 OIDC Discovery 文档
269
307
  *
@@ -332,8 +370,9 @@ declare class SsoError extends Error {
332
370
  /**
333
371
  * OAuth 2.0 协议层错误(RFC 6749 §5.2)
334
372
  *
335
- * 用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
336
- * 与 SDK 语义的 SsoError 区分。
373
+ * 保留用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
374
+ * 与 SDK 语义的 SsoError 区分。当前 SDK 内部不抛出此类型(统一使用 SsoError +
375
+ * mapOAuthErrorToSsoCode 映射),作为公共类型导出供子项目自行解析 OAuth 响应时使用。
337
376
  */
338
377
  declare class OAuthError extends Error {
339
378
  readonly code: string;
@@ -348,6 +387,8 @@ declare class OAuthError extends Error {
348
387
  /**
349
388
  * 校验 returnUrl 是否可信(防开放重定向)。
350
389
  * 仅允许:相对路径(且不以 // 开头)或与 currentOrigin 完全同源的绝对 URL。
390
+ * 拒绝一切含反斜杠 \ 的值:浏览器会把 "/\evil.com" 归一化为 "//evil.com",
391
+ * 形成协议相对 URL 开放重定向。
351
392
  */
352
393
  declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
353
394
  /**
@@ -356,4 +397,4 @@ declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean
356
397
  */
357
398
  declare function timingSafeEqualString(a: string, b: string): boolean;
358
399
 
359
- export { OAuthError, type OidcDiscovery, SsoClient, type SsoClientConfig, SsoError, type SsoErrorCode, type SsoUser, type TokenData, type TokenResponse, type TokenStorage, clearAllSsoData, clearVerifiersForClients, createSecureStorage, generateCodeChallenge, generateCodeVerifier, generateState, getOAuthState, getPkceVerifier, getReturnUrl, getTokenData, getTokenStorage, isTrustedReturnUrl, mapOAuthErrorToSsoCode, removeOAuthState, removePkceVerifier, removeReturnUrl, removeTokenData, saveOAuthState, savePkceVerifier, saveReturnUrl, saveTokenData, setTokenStorage, timingSafeEqualString };
400
+ export { OAuthError, type OidcDiscovery, SsoClient, type SsoClientConfig, SsoError, type SsoErrorCode, type SsoUser, type TokenData, type TokenResponse, type TokenStorage, clearAllSsoData, clearVerifiersForClients, createSecureStorage, generateCodeChallenge, generateCodeVerifier, generateState, getLogoutState, getOAuthState, getPkceVerifier, getReturnUrl, getTokenData, getTokenStorage, isTrustedReturnUrl, mapOAuthErrorToSsoCode, removeLogoutState, removeOAuthState, removePkceVerifier, removeReturnUrl, removeTokenData, saveLogoutState, saveOAuthState, savePkceVerifier, saveReturnUrl, saveTokenData, setTokenStorage, timingSafeEqualString };