@nihplod/sso-sdk 1.0.1 → 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,9 +309,20 @@ 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,
315
+ // validateSsoCookie defaults to true: the middleware calls the introspection
316
+ // endpoint to verify the SSO session cookie. Set to false only if you accept
317
+ // "cookie exists = logged in" semantics (lowest latency, but may pass revoked
318
+ // sessions). Either way, the middleware is only a UX gate — always re-verify
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.
263
326
  });
264
327
 
265
328
  export const config = {
@@ -267,6 +330,13 @@ export const config = {
267
330
  };
268
331
  ```
269
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
+
270
340
  ```typescript
271
341
  // src/app/api/auth/callback/route.ts
272
342
  import { createCallbackRouteHandler } from "@nihplod/sso-sdk/next";
@@ -284,21 +354,30 @@ export const GET = createCallbackRouteHandler({
284
354
  // src/app/api/auth/logout/route.ts
285
355
  import { createLogoutRouteHandler } from "@nihplod/sso-sdk/next";
286
356
 
287
- export const GET = createLogoutRouteHandler({
357
+ const handler = createLogoutRouteHandler({
288
358
  clientId: "...",
289
359
  ssoBaseUrl: "https://nihplod.cn",
290
360
  redirectUri: "https://yourapp.com/api/auth/callback",
291
361
  postLogoutRedirectUri: "https://yourapp.com/",
292
362
  redirectToSso: true,
293
363
  });
364
+
365
+ // Prefer POST to trigger logout (prevents logout CSRF via cross-site GET);
366
+ // GET is kept for compatibility with plain <a> navigations.
367
+ export const GET = handler;
368
+ export const POST = handler;
294
369
  ```
295
370
 
296
- Use a standard `<a>` tag to trigger the logout endpoint:
371
+ Trigger the logout endpoint with a POST request (recommended):
297
372
 
298
373
  ```tsx
299
- <a href="/api/auth/logout">Logout</a>
374
+ <button onClick={() => fetch("/api/auth/logout", { method: "POST" }).then(() => location.assign("/"))}>
375
+ Logout
376
+ </button>
300
377
  ```
301
378
 
379
+ A plain `<a href="/api/auth/logout">` still works via GET, but note it can be triggered cross-site (logout CSRF).
380
+
302
381
  ### Cookie Configuration
303
382
 
304
383
  Default cookie names:
@@ -311,13 +390,17 @@ Default cookie names:
311
390
  | return_url | `__Host-nihplod_sso_return` | Requires Secure + Path=/ + no Domain |
312
391
  | verifier | `__Secure-nihplod_sso_verifier` | Requires Secure + no Domain; Path is the callback path, therefore uses `__Secure-` prefix |
313
392
 
314
- > 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.
315
394
 
316
395
  ---
317
396
 
318
397
  ## Security Recommendations and Token Storage
319
398
 
320
- 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.
402
+
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.
321
404
 
322
405
  If the sub-project is a **Next.js BFF / Confidential Client**, you can store tokens in `localStorage` for multi-tab sharing:
323
406
 
@@ -353,6 +436,6 @@ const challenge = await generateCodeChallenge(verifier);
353
436
  // State
354
437
  const state = generateState();
355
438
 
356
- // Custom token storage (default is memory storage)
439
+ // Custom token storage (default is sessionStorage; persist: true → localStorage)
357
440
  setTokenStorage(createSecureStorage({ persist: false }));
358
441
  ```
package/dist/index.d.mts CHANGED
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * Token 存储抽象层
3
3
  *
4
- * 默认使用 localStorage,以支持多 Tab 间自动同步 token 并避免并发刷新。
5
- * 对 XSS 敏感的子项目可通过 setTokenStorage() 注入更安全的自定义实现
6
- *(如内存存储、Service Worker 封装、或带加密的 storage)。
4
+ * 分两类存储:
5
+ * - Token 数据:默认使用 sessionStorage(标签页级持久化)。登录回调后 CallbackPage
6
+ * 默认整页跳转,内存存储会丢失登录态,因此默认改为 sessionStorage:
7
+ * 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
8
+ * 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
9
+ * 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
10
+ * - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
11
+ * (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
12
+ * SSR 等无 sessionStorage 环境自动降级为内存 Map。
7
13
  *
8
14
  * 多 client 隔离:
9
15
  * - token / state / return_url 均支持按 clientId 隔离 key
@@ -27,6 +33,20 @@ interface TokenStorage {
27
33
  set(key: string, value: string): void;
28
34
  remove(key: string): void;
29
35
  }
36
+ /**
37
+ * 创建存储实现
38
+ *
39
+ * @param options.persist 是否持久化到 localStorage。默认 false(sessionStorage)。
40
+ * - false(默认):sessionStorage,标签页级持久化(刷新/整页跳转后登录态保留,
41
+ * 关闭标签页即清除),多 Tab 间不共享;隐私模式等写入失败时降级为内存。
42
+ * - true:localStorage,多 Tab 共享、关闭浏览器后仍保留。
43
+ * ⚠️ 安全警告:persist=true 会将 refresh_token 明文写入 localStorage,
44
+ * 任何 XSS 均可在不被检测的情况下读取。仅在 BFF/Confidential Client
45
+ * 且 refresh_token 不直接暴露给浏览器的场景下使用。
46
+ */
47
+ declare function createSecureStorage(options?: {
48
+ persist?: boolean;
49
+ }): TokenStorage;
30
50
  /**
31
51
  * 设置自定义存储实现
32
52
  */
@@ -44,6 +64,9 @@ declare function removePkceVerifier(clientId: string): void;
44
64
  declare function saveOAuthState(state: string, clientId?: string): void;
45
65
  declare function getOAuthState(clientId?: string): string | null;
46
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;
47
70
  declare function saveReturnUrl(url: string, clientId?: string): void;
48
71
  declare function getReturnUrl(clientId?: string): string | null;
49
72
  declare function removeReturnUrl(clientId?: string): void;
@@ -85,6 +108,8 @@ interface SsoClientConfig {
85
108
  ssoBaseUrl: string;
86
109
  /** 请求的 scope(空格分隔),如 "openid profile phone" */
87
110
  scopes?: string;
111
+ /** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
112
+ postLogoutRedirectUri?: string;
88
113
  }
89
114
  /** 用户信息 */
90
115
  interface SsoUser {
@@ -141,26 +166,78 @@ declare class SsoClient {
141
166
  private _getTokenEndpoint;
142
167
  /** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
143
168
  private _getUserinfoEndpoint;
169
+ /**
170
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
171
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
172
+ */
173
+ private _saveReturnUrlIfTrusted;
144
174
  /**
145
175
  * 发起 SSO 登录
146
176
  *
147
177
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
148
178
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
149
179
  *
150
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
180
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
181
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
182
+ *
183
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
184
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
185
+ * 同一次登录只使用其中一个入口。
151
186
  */
152
187
  login(returnUrl?: string): Promise<void>;
153
188
  /**
154
189
  * 构建登录 URL(不跳转,返回 URL 字符串)
155
190
  *
156
191
  * 适用于需要手动处理跳转的场景。
192
+ *
193
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
194
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
195
+ * 同一次登录只使用其中一个入口。
157
196
  */
158
197
  getLoginUrl(returnUrl?: string): Promise<string>;
198
+ /**
199
+ * 弹窗模式 SSO 登录
200
+ *
201
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
202
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
203
+ *
204
+ * 流程:
205
+ * 1. window.open() 打开授权 URL 到弹窗
206
+ * 2. 用户在弹窗中完成登录
207
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
208
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
209
+ * 5. 弹窗自动关闭
210
+ *
211
+ * @param options - 弹窗配置
212
+ * @param options.returnUrl - 登录成功后的返回地址
213
+ * @param options.width - 弹窗宽度(默认 480)
214
+ * @param options.height - 弹窗高度(默认 640)
215
+ * @returns TokenData
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
220
+ * try {
221
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
222
+ * console.log("登录成功", tokenData);
223
+ * } catch (err) {
224
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
225
+ * // 弹窗被拦截,回退到同页重定向
226
+ * await client.login("/dashboard");
227
+ * }
228
+ * }
229
+ * ```
230
+ */
231
+ loginPopup(options?: {
232
+ returnUrl?: string;
233
+ width?: number;
234
+ height?: number;
235
+ }): Promise<TokenData>;
159
236
  /**
160
237
  * 处理 OAuth 回调
161
238
  *
162
239
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
163
- * 成功后 token 自动保存到 sessionStorage。
240
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
164
241
  *
165
242
  * @param callbackUrl - 完整的回调 URL(window.location.href)
166
243
  * @returns TokenData 或 null
@@ -198,9 +275,28 @@ declare class SsoClient {
198
275
  * 登出
199
276
  *
200
277
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
201
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
278
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
279
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
280
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
202
281
  */
203
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;
204
300
  /**
205
301
  * 获取 OIDC Discovery 文档
206
302
  *
@@ -247,9 +343,16 @@ declare function generateState(): string;
247
343
  * SSO SDK 错误类型
248
344
  */
249
345
  /** SSO 错误码 */
250
- type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "sso_server_error" | "network_error" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch";
251
- /** OAuth 2.0 标准错误码 */
252
- type OAuthErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "invalid_scope" | "access_denied" | "server_error" | "rate_limited";
346
+ type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "authorization_code_expired" | "authorization_code_used" | "client_disabled" | "user_denied_authorization" | "account_disabled" | "sso_server_error" | "rate_limited" | "network_error" | "popup_blocked" | "popup_closed" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_hs256_unsupported" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch";
347
+ /**
348
+ * 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode
349
+ *
350
+ * @param oauthError 服务端返回的 error 字段
351
+ * @param context 调用上下文:"token_exchange"(授权码换 token)或 "refresh"(刷新)。
352
+ * invalid_grant 在换 token 场景多为授权码过期/已用,在刷新场景多为会话失效,
353
+ * 需按上下文细化映射。
354
+ */
355
+ declare function mapOAuthErrorToSsoCode(oauthError: string, context?: "token_exchange" | "refresh"): SsoErrorCode;
253
356
  /**
254
357
  * SSO SDK 自定义错误
255
358
  */
@@ -260,13 +363,33 @@ declare class SsoError extends Error {
260
363
  constructor(code: SsoErrorCode, description: string, cause?: unknown);
261
364
  }
262
365
  /**
263
- * OAuth 2.0 服务端返回的错误
366
+ * OAuth 2.0 协议层错误(RFC 6749 §5.2)
367
+ *
368
+ * 保留用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
369
+ * 与 SDK 语义的 SsoError 区分。当前 SDK 内部不抛出此类型(统一使用 SsoError +
370
+ * mapOAuthErrorToSsoCode 映射),作为公共类型导出供子项目自行解析 OAuth 响应时使用。
264
371
  */
265
372
  declare class OAuthError extends Error {
266
- readonly code: OAuthErrorCode;
373
+ readonly code: string;
267
374
  readonly description: string;
268
375
  readonly uri?: string;
269
- constructor(code: OAuthErrorCode, description: string, uri?: string);
376
+ constructor(code: string, description: string, uri?: string);
270
377
  }
271
378
 
272
- export { OAuthError, type OAuthErrorCode, type OidcDiscovery, SsoClient, type SsoClientConfig, SsoError, type SsoErrorCode, type SsoUser, type TokenData, type TokenResponse, type TokenStorage, clearAllSsoData, clearVerifiersForClients, generateCodeChallenge, generateCodeVerifier, generateState, getOAuthState, getPkceVerifier, getReturnUrl, getTokenData, getTokenStorage, removeOAuthState, removePkceVerifier, removeReturnUrl, removeTokenData, saveOAuthState, savePkceVerifier, saveReturnUrl, saveTokenData, setTokenStorage };
379
+ /**
380
+ * 安全相关小工具:returnUrl 开放重定向校验、常量时间字符串比较
381
+ */
382
+ /**
383
+ * 校验 returnUrl 是否可信(防开放重定向)。
384
+ * 仅允许:相对路径(且不以 // 开头)或与 currentOrigin 完全同源的绝对 URL。
385
+ * 拒绝一切含反斜杠 \ 的值:浏览器会把 "/\evil.com" 归一化为 "//evil.com",
386
+ * 形成协议相对 URL 开放重定向。
387
+ */
388
+ declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
389
+ /**
390
+ * 常量时间字符串比较(避免 state / at_hash / nonce 等机密值的时序侧信道)。
391
+ * 长度不同也执行完整循环,不提前返回。
392
+ */
393
+ declare function timingSafeEqualString(a: string, b: string): boolean;
394
+
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
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * Token 存储抽象层
3
3
  *
4
- * 默认使用 localStorage,以支持多 Tab 间自动同步 token 并避免并发刷新。
5
- * 对 XSS 敏感的子项目可通过 setTokenStorage() 注入更安全的自定义实现
6
- *(如内存存储、Service Worker 封装、或带加密的 storage)。
4
+ * 分两类存储:
5
+ * - Token 数据:默认使用 sessionStorage(标签页级持久化)。登录回调后 CallbackPage
6
+ * 默认整页跳转,内存存储会丢失登录态,因此默认改为 sessionStorage:
7
+ * 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
8
+ * 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
9
+ * 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
10
+ * - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
11
+ * (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
12
+ * SSR 等无 sessionStorage 环境自动降级为内存 Map。
7
13
  *
8
14
  * 多 client 隔离:
9
15
  * - token / state / return_url 均支持按 clientId 隔离 key
@@ -27,6 +33,20 @@ interface TokenStorage {
27
33
  set(key: string, value: string): void;
28
34
  remove(key: string): void;
29
35
  }
36
+ /**
37
+ * 创建存储实现
38
+ *
39
+ * @param options.persist 是否持久化到 localStorage。默认 false(sessionStorage)。
40
+ * - false(默认):sessionStorage,标签页级持久化(刷新/整页跳转后登录态保留,
41
+ * 关闭标签页即清除),多 Tab 间不共享;隐私模式等写入失败时降级为内存。
42
+ * - true:localStorage,多 Tab 共享、关闭浏览器后仍保留。
43
+ * ⚠️ 安全警告:persist=true 会将 refresh_token 明文写入 localStorage,
44
+ * 任何 XSS 均可在不被检测的情况下读取。仅在 BFF/Confidential Client
45
+ * 且 refresh_token 不直接暴露给浏览器的场景下使用。
46
+ */
47
+ declare function createSecureStorage(options?: {
48
+ persist?: boolean;
49
+ }): TokenStorage;
30
50
  /**
31
51
  * 设置自定义存储实现
32
52
  */
@@ -44,6 +64,9 @@ declare function removePkceVerifier(clientId: string): void;
44
64
  declare function saveOAuthState(state: string, clientId?: string): void;
45
65
  declare function getOAuthState(clientId?: string): string | null;
46
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;
47
70
  declare function saveReturnUrl(url: string, clientId?: string): void;
48
71
  declare function getReturnUrl(clientId?: string): string | null;
49
72
  declare function removeReturnUrl(clientId?: string): void;
@@ -85,6 +108,8 @@ interface SsoClientConfig {
85
108
  ssoBaseUrl: string;
86
109
  /** 请求的 scope(空格分隔),如 "openid profile phone" */
87
110
  scopes?: string;
111
+ /** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
112
+ postLogoutRedirectUri?: string;
88
113
  }
89
114
  /** 用户信息 */
90
115
  interface SsoUser {
@@ -141,26 +166,78 @@ declare class SsoClient {
141
166
  private _getTokenEndpoint;
142
167
  /** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
143
168
  private _getUserinfoEndpoint;
169
+ /**
170
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
171
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
172
+ */
173
+ private _saveReturnUrlIfTrusted;
144
174
  /**
145
175
  * 发起 SSO 登录
146
176
  *
147
177
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
148
178
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
149
179
  *
150
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
180
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
181
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
182
+ *
183
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
184
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
185
+ * 同一次登录只使用其中一个入口。
151
186
  */
152
187
  login(returnUrl?: string): Promise<void>;
153
188
  /**
154
189
  * 构建登录 URL(不跳转,返回 URL 字符串)
155
190
  *
156
191
  * 适用于需要手动处理跳转的场景。
192
+ *
193
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
194
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
195
+ * 同一次登录只使用其中一个入口。
157
196
  */
158
197
  getLoginUrl(returnUrl?: string): Promise<string>;
198
+ /**
199
+ * 弹窗模式 SSO 登录
200
+ *
201
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
202
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
203
+ *
204
+ * 流程:
205
+ * 1. window.open() 打开授权 URL 到弹窗
206
+ * 2. 用户在弹窗中完成登录
207
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
208
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
209
+ * 5. 弹窗自动关闭
210
+ *
211
+ * @param options - 弹窗配置
212
+ * @param options.returnUrl - 登录成功后的返回地址
213
+ * @param options.width - 弹窗宽度(默认 480)
214
+ * @param options.height - 弹窗高度(默认 640)
215
+ * @returns TokenData
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
220
+ * try {
221
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
222
+ * console.log("登录成功", tokenData);
223
+ * } catch (err) {
224
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
225
+ * // 弹窗被拦截,回退到同页重定向
226
+ * await client.login("/dashboard");
227
+ * }
228
+ * }
229
+ * ```
230
+ */
231
+ loginPopup(options?: {
232
+ returnUrl?: string;
233
+ width?: number;
234
+ height?: number;
235
+ }): Promise<TokenData>;
159
236
  /**
160
237
  * 处理 OAuth 回调
161
238
  *
162
239
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
163
- * 成功后 token 自动保存到 sessionStorage。
240
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
164
241
  *
165
242
  * @param callbackUrl - 完整的回调 URL(window.location.href)
166
243
  * @returns TokenData 或 null
@@ -198,9 +275,28 @@ declare class SsoClient {
198
275
  * 登出
199
276
  *
200
277
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
201
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
278
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
279
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
280
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
202
281
  */
203
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;
204
300
  /**
205
301
  * 获取 OIDC Discovery 文档
206
302
  *
@@ -247,9 +343,16 @@ declare function generateState(): string;
247
343
  * SSO SDK 错误类型
248
344
  */
249
345
  /** SSO 错误码 */
250
- type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "sso_server_error" | "network_error" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch";
251
- /** OAuth 2.0 标准错误码 */
252
- type OAuthErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "invalid_scope" | "access_denied" | "server_error" | "rate_limited";
346
+ type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "authorization_code_expired" | "authorization_code_used" | "client_disabled" | "user_denied_authorization" | "account_disabled" | "sso_server_error" | "rate_limited" | "network_error" | "popup_blocked" | "popup_closed" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_hs256_unsupported" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch";
347
+ /**
348
+ * 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode
349
+ *
350
+ * @param oauthError 服务端返回的 error 字段
351
+ * @param context 调用上下文:"token_exchange"(授权码换 token)或 "refresh"(刷新)。
352
+ * invalid_grant 在换 token 场景多为授权码过期/已用,在刷新场景多为会话失效,
353
+ * 需按上下文细化映射。
354
+ */
355
+ declare function mapOAuthErrorToSsoCode(oauthError: string, context?: "token_exchange" | "refresh"): SsoErrorCode;
253
356
  /**
254
357
  * SSO SDK 自定义错误
255
358
  */
@@ -260,13 +363,33 @@ declare class SsoError extends Error {
260
363
  constructor(code: SsoErrorCode, description: string, cause?: unknown);
261
364
  }
262
365
  /**
263
- * OAuth 2.0 服务端返回的错误
366
+ * OAuth 2.0 协议层错误(RFC 6749 §5.2)
367
+ *
368
+ * 保留用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),
369
+ * 与 SDK 语义的 SsoError 区分。当前 SDK 内部不抛出此类型(统一使用 SsoError +
370
+ * mapOAuthErrorToSsoCode 映射),作为公共类型导出供子项目自行解析 OAuth 响应时使用。
264
371
  */
265
372
  declare class OAuthError extends Error {
266
- readonly code: OAuthErrorCode;
373
+ readonly code: string;
267
374
  readonly description: string;
268
375
  readonly uri?: string;
269
- constructor(code: OAuthErrorCode, description: string, uri?: string);
376
+ constructor(code: string, description: string, uri?: string);
270
377
  }
271
378
 
272
- export { OAuthError, type OAuthErrorCode, type OidcDiscovery, SsoClient, type SsoClientConfig, SsoError, type SsoErrorCode, type SsoUser, type TokenData, type TokenResponse, type TokenStorage, clearAllSsoData, clearVerifiersForClients, generateCodeChallenge, generateCodeVerifier, generateState, getOAuthState, getPkceVerifier, getReturnUrl, getTokenData, getTokenStorage, removeOAuthState, removePkceVerifier, removeReturnUrl, removeTokenData, saveOAuthState, savePkceVerifier, saveReturnUrl, saveTokenData, setTokenStorage };
379
+ /**
380
+ * 安全相关小工具:returnUrl 开放重定向校验、常量时间字符串比较
381
+ */
382
+ /**
383
+ * 校验 returnUrl 是否可信(防开放重定向)。
384
+ * 仅允许:相对路径(且不以 // 开头)或与 currentOrigin 完全同源的绝对 URL。
385
+ * 拒绝一切含反斜杠 \ 的值:浏览器会把 "/\evil.com" 归一化为 "//evil.com",
386
+ * 形成协议相对 URL 开放重定向。
387
+ */
388
+ declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
389
+ /**
390
+ * 常量时间字符串比较(避免 state / at_hash / nonce 等机密值的时序侧信道)。
391
+ * 长度不同也执行完整循环,不提前返回。
392
+ */
393
+ declare function timingSafeEqualString(a: string, b: string): boolean;
394
+
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 };