@nihplod/sso-sdk 1.1.0 → 1.2.0

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;
@@ -173,12 +179,20 @@ declare class SsoClient {
173
179
  *
174
180
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
175
181
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
182
+ *
183
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
184
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
185
+ * 同一次登录只使用其中一个入口。
176
186
  */
177
187
  login(returnUrl?: string): Promise<void>;
178
188
  /**
179
189
  * 构建登录 URL(不跳转,返回 URL 字符串)
180
190
  *
181
191
  * 适用于需要手动处理跳转的场景。
192
+ *
193
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
194
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
195
+ * 同一次登录只使用其中一个入口。
182
196
  */
183
197
  getLoginUrl(returnUrl?: string): Promise<string>;
184
198
  /**
@@ -223,7 +237,7 @@ declare class SsoClient {
223
237
  * 处理 OAuth 回调
224
238
  *
225
239
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
226
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
240
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
227
241
  *
228
242
  * @param callbackUrl - 完整的回调 URL(window.location.href)
229
243
  * @returns TokenData 或 null
@@ -261,9 +275,28 @@ declare class SsoClient {
261
275
  * 登出
262
276
  *
263
277
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
264
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
278
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
279
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
280
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
265
281
  */
266
282
  logout(redirectToSso?: boolean): Promise<void>;
283
+ /**
284
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
285
+ *
286
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
287
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
288
+ * 校验后清除已保存的 logout state(一次性)。
289
+ *
290
+ * @param url - 当前页面完整 URL(window.location.href)
291
+ *
292
+ * @example
293
+ * ```typescript
294
+ * if (sso.validateLogoutState(window.location.href)) {
295
+ * // 来自 SSO 登出的可信回跳
296
+ * }
297
+ * ```
298
+ */
299
+ validateLogoutState(url: string): boolean;
267
300
  /**
268
301
  * 获取 OIDC Discovery 文档
269
302
  *
@@ -332,8 +365,9 @@ declare class SsoError extends Error {
332
365
  /**
333
366
  * OAuth 2.0 协议层错误(RFC 6749 §5.2)
334
367
  *
335
- * 用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
336
- * 与 SDK 语义的 SsoError 区分。
368
+ * 保留用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
369
+ * 与 SDK 语义的 SsoError 区分。当前 SDK 内部不抛出此类型(统一使用 SsoError +
370
+ * mapOAuthErrorToSsoCode 映射),作为公共类型导出供子项目自行解析 OAuth 响应时使用。
337
371
  */
338
372
  declare class OAuthError extends Error {
339
373
  readonly code: string;
@@ -348,6 +382,8 @@ declare class OAuthError extends Error {
348
382
  /**
349
383
  * 校验 returnUrl 是否可信(防开放重定向)。
350
384
  * 仅允许:相对路径(且不以 // 开头)或与 currentOrigin 完全同源的绝对 URL。
385
+ * 拒绝一切含反斜杠 \ 的值:浏览器会把 "/\evil.com" 归一化为 "//evil.com",
386
+ * 形成协议相对 URL 开放重定向。
351
387
  */
352
388
  declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
353
389
  /**
@@ -356,4 +392,4 @@ declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean
356
392
  */
357
393
  declare function timingSafeEqualString(a: string, b: string): boolean;
358
394
 
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 };
395
+ 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;
@@ -173,12 +179,20 @@ declare class SsoClient {
173
179
  *
174
180
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
175
181
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
182
+ *
183
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
184
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
185
+ * 同一次登录只使用其中一个入口。
176
186
  */
177
187
  login(returnUrl?: string): Promise<void>;
178
188
  /**
179
189
  * 构建登录 URL(不跳转,返回 URL 字符串)
180
190
  *
181
191
  * 适用于需要手动处理跳转的场景。
192
+ *
193
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
194
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
195
+ * 同一次登录只使用其中一个入口。
182
196
  */
183
197
  getLoginUrl(returnUrl?: string): Promise<string>;
184
198
  /**
@@ -223,7 +237,7 @@ declare class SsoClient {
223
237
  * 处理 OAuth 回调
224
238
  *
225
239
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
226
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
240
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
227
241
  *
228
242
  * @param callbackUrl - 完整的回调 URL(window.location.href)
229
243
  * @returns TokenData 或 null
@@ -261,9 +275,28 @@ declare class SsoClient {
261
275
  * 登出
262
276
  *
263
277
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
264
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
278
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
279
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
280
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
265
281
  */
266
282
  logout(redirectToSso?: boolean): Promise<void>;
283
+ /**
284
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
285
+ *
286
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
287
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
288
+ * 校验后清除已保存的 logout state(一次性)。
289
+ *
290
+ * @param url - 当前页面完整 URL(window.location.href)
291
+ *
292
+ * @example
293
+ * ```typescript
294
+ * if (sso.validateLogoutState(window.location.href)) {
295
+ * // 来自 SSO 登出的可信回跳
296
+ * }
297
+ * ```
298
+ */
299
+ validateLogoutState(url: string): boolean;
267
300
  /**
268
301
  * 获取 OIDC Discovery 文档
269
302
  *
@@ -332,8 +365,9 @@ declare class SsoError extends Error {
332
365
  /**
333
366
  * OAuth 2.0 协议层错误(RFC 6749 §5.2)
334
367
  *
335
- * 用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
336
- * 与 SDK 语义的 SsoError 区分。
368
+ * 保留用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
369
+ * 与 SDK 语义的 SsoError 区分。当前 SDK 内部不抛出此类型(统一使用 SsoError +
370
+ * mapOAuthErrorToSsoCode 映射),作为公共类型导出供子项目自行解析 OAuth 响应时使用。
337
371
  */
338
372
  declare class OAuthError extends Error {
339
373
  readonly code: string;
@@ -348,6 +382,8 @@ declare class OAuthError extends Error {
348
382
  /**
349
383
  * 校验 returnUrl 是否可信(防开放重定向)。
350
384
  * 仅允许:相对路径(且不以 // 开头)或与 currentOrigin 完全同源的绝对 URL。
385
+ * 拒绝一切含反斜杠 \ 的值:浏览器会把 "/\evil.com" 归一化为 "//evil.com",
386
+ * 形成协议相对 URL 开放重定向。
351
387
  */
352
388
  declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
353
389
  /**
@@ -356,4 +392,4 @@ declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean
356
392
  */
357
393
  declare function timingSafeEqualString(a: string, b: string): boolean;
358
394
 
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 };
395
+ 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.js CHANGED
@@ -29,6 +29,7 @@ __export(src_exports, {
29
29
  generateCodeChallenge: () => generateCodeChallenge,
30
30
  generateCodeVerifier: () => generateCodeVerifier,
31
31
  generateState: () => generateState,
32
+ getLogoutState: () => getLogoutState,
32
33
  getOAuthState: () => getOAuthState,
33
34
  getPkceVerifier: () => getPkceVerifier,
34
35
  getReturnUrl: () => getReturnUrl,
@@ -36,10 +37,12 @@ __export(src_exports, {
36
37
  getTokenStorage: () => getTokenStorage,
37
38
  isTrustedReturnUrl: () => isTrustedReturnUrl,
38
39
  mapOAuthErrorToSsoCode: () => mapOAuthErrorToSsoCode,
40
+ removeLogoutState: () => removeLogoutState,
39
41
  removeOAuthState: () => removeOAuthState,
40
42
  removePkceVerifier: () => removePkceVerifier,
41
43
  removeReturnUrl: () => removeReturnUrl,
42
44
  removeTokenData: () => removeTokenData,
45
+ saveLogoutState: () => saveLogoutState,
43
46
  saveOAuthState: () => saveOAuthState,
44
47
  savePkceVerifier: () => savePkceVerifier,
45
48
  saveReturnUrl: () => saveReturnUrl,
@@ -134,6 +137,7 @@ var TOKEN_KEY = "token";
134
137
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
135
138
  var STATE_KEY = "oauth_state";
136
139
  var RETURN_URL_KEY = "return_url";
140
+ var LOGOUT_STATE_KEY = "logout_state";
137
141
  function buildKey(base, clientId) {
138
142
  return clientId ? `${base}:${clientId}` : base;
139
143
  }
@@ -201,9 +205,9 @@ function createSessionStorageAdapter() {
201
205
  }
202
206
  var _transient = createSessionStorageAdapter();
203
207
  function createSecureStorage(options = {}) {
204
- return options.persist ? localStorageAdapter : memoryStorageAdapter;
208
+ return options.persist ? localStorageAdapter : createSessionStorageAdapter();
205
209
  }
206
- var _storage = memoryStorageAdapter;
210
+ var _storage = createSessionStorageAdapter();
207
211
  function setTokenStorage(storage) {
208
212
  _storage = storage;
209
213
  }
@@ -243,6 +247,15 @@ function getOAuthState(clientId) {
243
247
  function removeOAuthState(clientId) {
244
248
  _transient.remove(buildKey(STATE_KEY, clientId));
245
249
  }
250
+ function saveLogoutState(state, clientId) {
251
+ _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
252
+ }
253
+ function getLogoutState(clientId) {
254
+ return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
255
+ }
256
+ function removeLogoutState(clientId) {
257
+ _transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
258
+ }
246
259
  function saveReturnUrl(url, clientId) {
247
260
  _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
248
261
  }
@@ -257,6 +270,7 @@ function clearAllSsoData(clientId) {
257
270
  removeTokenData(clientId);
258
271
  removeOAuthState(clientId);
259
272
  removeReturnUrl(clientId);
273
+ removeLogoutState(clientId);
260
274
  removePkceVerifier(clientId);
261
275
  removePkceVerifier(`${clientId}_popup_nonce`);
262
276
  return;
@@ -264,6 +278,7 @@ function clearAllSsoData(clientId) {
264
278
  removeTokenData();
265
279
  removeOAuthState();
266
280
  removeReturnUrl();
281
+ removeLogoutState();
267
282
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
268
283
  const stores = [
269
284
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -291,6 +306,7 @@ function clearVerifiersForClients(clientIds) {
291
306
  // src/core/security.ts
292
307
  function isTrustedReturnUrl(url, currentOrigin) {
293
308
  if (!url) return false;
309
+ if (url.includes("\\")) return false;
294
310
  if (url.startsWith("/") && !url.startsWith("//")) return true;
295
311
  try {
296
312
  return new URL(url).origin === currentOrigin;
@@ -496,7 +512,7 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
496
512
  if (typeof payload.exp !== "number") {
497
513
  throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
498
514
  }
499
- if (Date.now() >= payload.exp * 1e3) {
515
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
500
516
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
501
517
  }
502
518
  if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
@@ -589,7 +605,7 @@ var _SsoClient = class _SsoClient {
589
605
  */
590
606
  _saveReturnUrlIfTrusted(returnUrl) {
591
607
  if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
592
- saveReturnUrl(returnUrl);
608
+ saveReturnUrl(returnUrl, this.config.clientId);
593
609
  } else {
594
610
  console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
595
611
  }
@@ -605,6 +621,10 @@ var _SsoClient = class _SsoClient {
605
621
  *
606
622
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
607
623
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
624
+ *
625
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
626
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
627
+ * 同一次登录只使用其中一个入口。
608
628
  */
609
629
  async login(returnUrl) {
610
630
  const verifier = generateCodeVerifier();
@@ -630,6 +650,10 @@ var _SsoClient = class _SsoClient {
630
650
  * 构建登录 URL(不跳转,返回 URL 字符串)
631
651
  *
632
652
  * 适用于需要手动处理跳转的场景。
653
+ *
654
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
655
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
656
+ * 同一次登录只使用其中一个入口。
633
657
  */
634
658
  async getLoginUrl(returnUrl) {
635
659
  const verifier = generateCodeVerifier();
@@ -782,7 +806,7 @@ var _SsoClient = class _SsoClient {
782
806
  * 处理 OAuth 回调
783
807
  *
784
808
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
785
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
809
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
786
810
  *
787
811
  * @param callbackUrl - 完整的回调 URL(window.location.href)
788
812
  * @returns TokenData 或 null
@@ -792,12 +816,16 @@ var _SsoClient = class _SsoClient {
792
816
  const params = url.searchParams;
793
817
  const error = params.get("error");
794
818
  if (error) {
819
+ removeOAuthState(this.config.clientId);
820
+ removePkceVerifier(this.config.clientId);
795
821
  const desc = params.get("error_description") || error;
796
- throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
822
+ throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
797
823
  }
798
824
  const code = params.get("code");
799
825
  const returnedState = params.get("state");
800
826
  if (!code) {
827
+ removeOAuthState(this.config.clientId);
828
+ removePkceVerifier(this.config.clientId);
801
829
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
802
830
  }
803
831
  const savedState = getOAuthState(this.config.clientId);
@@ -849,8 +877,6 @@ var _SsoClient = class _SsoClient {
849
877
  );
850
878
  }
851
879
  const data = await res.json();
852
- removeOAuthState(this.config.clientId);
853
- removePkceVerifier(this.config.clientId);
854
880
  if (data.id_token) {
855
881
  try {
856
882
  await validateIdToken(
@@ -864,6 +890,8 @@ var _SsoClient = class _SsoClient {
864
890
  throw err;
865
891
  }
866
892
  }
893
+ removeOAuthState(this.config.clientId);
894
+ removePkceVerifier(this.config.clientId);
867
895
  const now = Date.now();
868
896
  const tokenData = {
869
897
  access_token: data.access_token,
@@ -1032,7 +1060,9 @@ var _SsoClient = class _SsoClient {
1032
1060
  * 登出
1033
1061
  *
1034
1062
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
1035
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
1063
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
1064
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
1065
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
1036
1066
  */
1037
1067
  async logout(redirectToSso = false) {
1038
1068
  const tokenData = getTokenData(this.config.clientId);
@@ -1075,10 +1105,36 @@ var _SsoClient = class _SsoClient {
1075
1105
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
1076
1106
  }
1077
1107
  const state = generateState();
1108
+ saveLogoutState(state, this.config.clientId);
1078
1109
  logoutUrl.searchParams.set("state", state);
1079
1110
  window.location.href = logoutUrl.toString();
1080
1111
  }
1081
1112
  }
1113
+ /**
1114
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
1115
+ *
1116
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
1117
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
1118
+ * 校验后清除已保存的 logout state(一次性)。
1119
+ *
1120
+ * @param url - 当前页面完整 URL(window.location.href)
1121
+ *
1122
+ * @example
1123
+ * ```typescript
1124
+ * if (sso.validateLogoutState(window.location.href)) {
1125
+ * // 来自 SSO 登出的可信回跳
1126
+ * }
1127
+ * ```
1128
+ */
1129
+ validateLogoutState(url) {
1130
+ const returnedState = new URL(url).searchParams.get("state");
1131
+ if (!returnedState) return false;
1132
+ const savedState = getLogoutState(this.config.clientId);
1133
+ if (!savedState) return false;
1134
+ if (!timingSafeEqualString(savedState, returnedState)) return false;
1135
+ removeLogoutState(this.config.clientId);
1136
+ return true;
1137
+ }
1082
1138
  /**
1083
1139
  * 获取 OIDC Discovery 文档
1084
1140
  *
@@ -1105,6 +1161,7 @@ var SsoClient = _SsoClient;
1105
1161
  generateCodeChallenge,
1106
1162
  generateCodeVerifier,
1107
1163
  generateState,
1164
+ getLogoutState,
1108
1165
  getOAuthState,
1109
1166
  getPkceVerifier,
1110
1167
  getReturnUrl,
@@ -1112,10 +1169,12 @@ var SsoClient = _SsoClient;
1112
1169
  getTokenStorage,
1113
1170
  isTrustedReturnUrl,
1114
1171
  mapOAuthErrorToSsoCode,
1172
+ removeLogoutState,
1115
1173
  removeOAuthState,
1116
1174
  removePkceVerifier,
1117
1175
  removeReturnUrl,
1118
1176
  removeTokenData,
1177
+ saveLogoutState,
1119
1178
  saveOAuthState,
1120
1179
  savePkceVerifier,
1121
1180
  saveReturnUrl,