@nihplod/sso-sdk 1.3.0 → 1.4.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
@@ -50,29 +50,47 @@ Create an SSO client instance.
50
50
  | `scopes` | `string` | ❌ | Space-separated scopes, default `"openid profile"` |
51
51
  | `clientSecret` | `string` | ❌ | **Only for Confidential Clients**. Do NOT pass this in browser SPA (Public Client) to avoid leaking secrets. BFF / Next.js Route Handlers may pass it. |
52
52
 
53
- ### `sso.login(returnUrl?)`
53
+ ### `sso.login(returnUrl?, options?)`
54
54
 
55
55
  Initiate SSO login. Generates PKCE parameters and redirects to the SSO login page.
56
56
 
57
57
  | Parameter | Type | Description |
58
58
  |------|------|------|
59
59
  | `returnUrl` | `string` | Optional URL to return to after login |
60
+ | `options.prompt` | `"none" \| "login" \| "consent"` | Optional OIDC `prompt` parameter, passed through to the authorize URL |
60
61
 
61
- ### `sso.getLoginUrl(returnUrl?)`
62
+ ### `sso.getLoginUrl(returnUrl?, options?)`
62
63
 
63
- Build the login URL string without redirecting. Returns `Promise<string>`.
64
+ Build the login URL string without redirecting. Returns `Promise<string>`. Accepts the same `options.prompt` as `login()`.
64
65
 
65
66
  > ⚠️ 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
67
 
68
+ #### Silent session probe (`prompt: "none"`)
69
+
70
+ Passing `prompt: "none"` starts a **silent probe**: if the user still has a session on the SSO center, the authorize endpoint redirects straight back with a code and login completes without UI; if not, the IdP redirects back with `error=login_required` (or `consent_required` / `interaction_required`). When you start the probe via `login()` / `getLoginUrl()`, the SDK records a probe marker alongside `state`; `handleCallback()` then recognizes the matching error callback, cleans up the transient data and returns `null` instead of throwing — treat `null` as "no SSO session". The default `<CallbackPage>` redirects back to your `returnUrl` with `sso_probe=no_session` appended.
71
+
72
+ ```typescript
73
+ // Triggered by an explicit user action (e.g. clicking "继续 NIHPLOD 账户"):
74
+ await sso.login("/dashboard", { prompt: "none" });
75
+
76
+ // On the page returnUrl points to:
77
+ const params = new URLSearchParams(location.search);
78
+ if (params.get("sso_probe") === "no_session") {
79
+ // No SSO session — fall back to the normal interactive login button
80
+ }
81
+ ```
82
+
83
+ > ⚠️ Do NOT auto-probe on page load: it round-trips every visitor through the SSO center and adds latency for users without a session. Always trigger it from a deliberate user action (click).
84
+
67
85
  ### `sso.handleCallback(callbackUrl)`
68
86
 
69
- Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens.
87
+ Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens. When an `id_token` is present, the SDK verifies its signature, issuer, audience, expiry and `at_hash`, and — when the login was initiated via `login()` / `getLoginUrl()` / `loginPopup()` — also validates the OIDC `nonce` claim against the value generated at login time (constant-time comparison, fail-closed, protecting against ID Token replay).
70
88
 
71
89
  | Parameter | Type | Description |
72
90
  |------|------|------|
73
91
  | `callbackUrl` | `string` | Full callback URL (`window.location.href`) |
74
92
 
75
- Returns `Promise<TokenData>`.
93
+ Returns `Promise<TokenData | null>` — `null` only when a silent probe (`prompt: "none"`) finds no SSO session (see above).
76
94
 
77
95
  ### `sso.refreshToken()`
78
96
 
@@ -361,15 +379,39 @@ const handler = createLogoutRouteHandler({
361
379
  ssoBaseUrl: "https://nihplod.cn",
362
380
  redirectUri: "https://yourapp.com/api/auth/callback",
363
381
  postLogoutRedirectUri: "https://yourapp.com/",
364
- redirectToSso: true,
382
+ // Logout scope: "local" (default) only signs out of THIS site (revoke
383
+ // refresh_token + clear cookies, redirect to the site home page);
384
+ // "global" additionally redirects to the SSO end-session endpoint to
385
+ // sign out of all NIHPLOD platforms (RP-Initiated Logout).
386
+ defaultScope: "local",
365
387
  });
366
388
 
367
389
  // Prefer POST to trigger logout (prevents logout CSRF via cross-site GET);
368
- // GET is kept for compatibility with plain <a> navigations.
390
+ // a GET without a valid logout state does NOT log out — it returns a
391
+ // confirmation page (HTML) whose form POSTs to the same endpoint.
369
392
  export const GET = handler;
370
393
  export const POST = handler;
371
394
  ```
372
395
 
396
+ **Logout scopes (default changed):** the GET confirmation page now includes a "同时退出所有 NIHPLOD 平台" checkbox; when checked, the form POSTs `global=1` and the handler performs a global logout (end-session redirect) regardless of `defaultScope`. A POST without a `global` field uses `defaultScope`. If you trigger logout from your own fetch call, add `global=1` to the form body to opt into a global logout.
397
+
398
+ > **Migration:** `redirectToSso` is deprecated and logs a one-time warning. `redirectToSso: true` → `defaultScope: "global"`; `redirectToSso: false` → `defaultScope: "local"`. Note the default changed from "redirect to SSO" to **local logout** — pass `defaultScope: "global"` to keep the old behavior.
399
+
400
+ ```typescript
401
+ // src/app/api/auth/backchannel-logout/route.ts
402
+ import { createBackchannelLogoutRouteHandler } from "@nihplod/sso-sdk/next";
403
+
404
+ export const POST = createBackchannelLogoutRouteHandler({
405
+ clientId: "...",
406
+ ssoBaseUrl: "https://nihplod.cn",
407
+ onLogout: async ({ sub, sid }) => {
408
+ // Clear your own server-side session for this user/session
409
+ },
410
+ });
411
+ ```
412
+
413
+ The backchannel handler receives `logout_token` pushes from the SSO center (OIDC Back-Channel Logout) when the user signs out globally or revokes consent, verifies the token (RS256 signature via JWKS, issuer/audience/expiry, `events` claim, `jti` replay protection), clears the SSO cookies, and calls your `onLogout` hook. If the hook throws, it responds 500 so the IdP retries delivery. **Remember to register this route's public URL as `backchannelLogoutUri` in the SSO admin console for your client.**
414
+
373
415
  Trigger the logout endpoint with a POST request (recommended):
374
416
 
375
417
  ```tsx
@@ -378,7 +420,7 @@ Trigger the logout endpoint with a POST request (recommended):
378
420
  </button>
379
421
  ```
380
422
 
381
- A plain `<a href="/api/auth/logout">` still works via GET, but note it can be triggered cross-site (logout CSRF).
423
+ A plain `<a href="/api/auth/logout">` navigation (GET) renders the built-in confirmation page instead of logging out directly, so cross-site image/prefetch requests can no longer trigger a logout (logout CSRF protection). Only a POST — or a GET carrying a valid RP-Initiated Logout `state` — performs the logout.
382
424
 
383
425
  ### Cookie Configuration
384
426
 
@@ -389,6 +431,7 @@ Default cookie names:
389
431
  | access_token | `__Host-nihplod_sso_at` | Requires Secure + Path=/ + no Domain |
390
432
  | refresh_token | `__Host-nihplod_sso_rt` | Requires Secure + Path=/ + no Domain |
391
433
  | state | `__Host-nihplod_sso_state` | Requires Secure + Path=/ + no Domain |
434
+ | nonce | `__Host-nihplod_sso_nonce` | OIDC nonce for ID Token replay protection (validated fail-closed in the callback); Requires Secure + Path=/ + no Domain |
392
435
  | return_url | `__Host-nihplod_sso_return` | Requires Secure + Path=/ + no Domain |
393
436
  | verifier | `__Secure-nihplod_sso_verifier` | Requires Secure + no Domain; Path is the callback path, therefore uses `__Secure-` prefix |
394
437
 
@@ -402,7 +445,7 @@ By default, the SDK stores tokens in **sessionStorage** (tab-scoped persistence)
402
445
 
403
446
  > ⚠️ 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.
404
447
 
405
- 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.
448
+ Transient OAuth data (PKCE `code_verifier`, `state`, OIDC `nonce`, `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.
406
449
 
407
450
  If the sub-project is a **Next.js BFF / Confidential Client**, you can store tokens in `localStorage` for multi-tab sharing:
408
451
 
package/dist/index.d.mts CHANGED
@@ -7,7 +7,7 @@
7
7
  * 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
8
8
  * 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
9
9
  * 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
10
- * - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
10
+ * - 临时数据(PKCE verifier / state / OIDC nonce / returnUrl / popup nonce):必须跨整页重定向存活
11
11
  * (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
12
12
  * SSR 等无 sessionStorage 环境自动降级为内存 Map。
13
13
  *
@@ -134,6 +134,21 @@ interface SsoUser {
134
134
  total_spent?: number;
135
135
  total_points?: number;
136
136
  }
137
+ /** login() / getLoginUrl() 选项 */
138
+ interface LoginOptions {
139
+ /**
140
+ * OIDC prompt 参数:
141
+ * - "none":静默探测 SSO 会话。用户在 IdP 有会话时直接回跳完成登录;
142
+ * 无会话时 IdP 回跳 error=login_required(或 consent_required /
143
+ * interaction_required),handleCallback() 识别后返回 null 而不抛错
144
+ * (CallbackPage 默认跳回 returnUrl 并附 sso_probe=no_session)。
145
+ * ⚠️ 不要在页面加载时自动探测(会打扰无会话用户并消耗请求),
146
+ * 应由用户点击等明确动作触发。
147
+ * - "login":强制重新认证;
148
+ * - "consent":强制重新授权确认。
149
+ */
150
+ prompt?: "none" | "login" | "consent";
151
+ }
137
152
  /** Token 响应 */
138
153
  interface TokenResponse {
139
154
  access_token: string;
@@ -195,12 +210,15 @@ declare class SsoClient {
195
210
  *
196
211
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
197
212
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
213
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
214
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
215
+ * handleCallback() 返回 null 而不抛错。
198
216
  *
199
217
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
200
218
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
201
219
  * 同一次登录只使用其中一个入口。
202
220
  */
203
- login(returnUrl?: string): Promise<void>;
221
+ login(returnUrl?: string, options?: LoginOptions): Promise<void>;
204
222
  /**
205
223
  * 构建登录 URL(不跳转,返回 URL 字符串)
206
224
  *
@@ -210,7 +228,7 @@ declare class SsoClient {
210
228
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
211
229
  * 同一次登录只使用其中一个入口。
212
230
  */
213
- getLoginUrl(returnUrl?: string): Promise<string>;
231
+ getLoginUrl(returnUrl?: string, options?: LoginOptions): Promise<string>;
214
232
  /**
215
233
  * 弹窗模式 SSO 登录
216
234
  *
@@ -256,9 +274,11 @@ declare class SsoClient {
256
274
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
257
275
  *
258
276
  * @param callbackUrl - 完整的回调 URL(window.location.href)
259
- * @returns TokenData 或 null
277
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
278
+ * (IdP 回跳 login_required / consent_required / interaction_required
279
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
260
280
  */
261
- handleCallback(callbackUrl: string): Promise<TokenData>;
281
+ handleCallback(callbackUrl: string): Promise<TokenData | null>;
262
282
  /**
263
283
  * 刷新 Access Token
264
284
  *
@@ -359,7 +379,7 @@ declare function generateState(): string;
359
379
  * SSO SDK 错误类型
360
380
  */
361
381
  /** SSO 错误码 */
362
- 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";
382
+ 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" | "id_token_nonce_mismatch" | "logout_token_invalid" | "logout_token_unsupported_alg" | "logout_token_invalid_signature" | "logout_token_issuer_mismatch" | "logout_token_audience_mismatch" | "logout_token_expired" | "logout_token_replay";
363
383
  /**
364
384
  * 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode
365
385
  *
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
8
8
  * 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
9
9
  * 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
10
- * - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
10
+ * - 临时数据(PKCE verifier / state / OIDC nonce / returnUrl / popup nonce):必须跨整页重定向存活
11
11
  * (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
12
12
  * SSR 等无 sessionStorage 环境自动降级为内存 Map。
13
13
  *
@@ -134,6 +134,21 @@ interface SsoUser {
134
134
  total_spent?: number;
135
135
  total_points?: number;
136
136
  }
137
+ /** login() / getLoginUrl() 选项 */
138
+ interface LoginOptions {
139
+ /**
140
+ * OIDC prompt 参数:
141
+ * - "none":静默探测 SSO 会话。用户在 IdP 有会话时直接回跳完成登录;
142
+ * 无会话时 IdP 回跳 error=login_required(或 consent_required /
143
+ * interaction_required),handleCallback() 识别后返回 null 而不抛错
144
+ * (CallbackPage 默认跳回 returnUrl 并附 sso_probe=no_session)。
145
+ * ⚠️ 不要在页面加载时自动探测(会打扰无会话用户并消耗请求),
146
+ * 应由用户点击等明确动作触发。
147
+ * - "login":强制重新认证;
148
+ * - "consent":强制重新授权确认。
149
+ */
150
+ prompt?: "none" | "login" | "consent";
151
+ }
137
152
  /** Token 响应 */
138
153
  interface TokenResponse {
139
154
  access_token: string;
@@ -195,12 +210,15 @@ declare class SsoClient {
195
210
  *
196
211
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
197
212
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
213
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
214
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
215
+ * handleCallback() 返回 null 而不抛错。
198
216
  *
199
217
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
200
218
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
201
219
  * 同一次登录只使用其中一个入口。
202
220
  */
203
- login(returnUrl?: string): Promise<void>;
221
+ login(returnUrl?: string, options?: LoginOptions): Promise<void>;
204
222
  /**
205
223
  * 构建登录 URL(不跳转,返回 URL 字符串)
206
224
  *
@@ -210,7 +228,7 @@ declare class SsoClient {
210
228
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
211
229
  * 同一次登录只使用其中一个入口。
212
230
  */
213
- getLoginUrl(returnUrl?: string): Promise<string>;
231
+ getLoginUrl(returnUrl?: string, options?: LoginOptions): Promise<string>;
214
232
  /**
215
233
  * 弹窗模式 SSO 登录
216
234
  *
@@ -256,9 +274,11 @@ declare class SsoClient {
256
274
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
257
275
  *
258
276
  * @param callbackUrl - 完整的回调 URL(window.location.href)
259
- * @returns TokenData 或 null
277
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
278
+ * (IdP 回跳 login_required / consent_required / interaction_required
279
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
260
280
  */
261
- handleCallback(callbackUrl: string): Promise<TokenData>;
281
+ handleCallback(callbackUrl: string): Promise<TokenData | null>;
262
282
  /**
263
283
  * 刷新 Access Token
264
284
  *
@@ -359,7 +379,7 @@ declare function generateState(): string;
359
379
  * SSO SDK 错误类型
360
380
  */
361
381
  /** SSO 错误码 */
362
- 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";
382
+ 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" | "id_token_nonce_mismatch" | "logout_token_invalid" | "logout_token_unsupported_alg" | "logout_token_invalid_signature" | "logout_token_issuer_mismatch" | "logout_token_audience_mismatch" | "logout_token_expired" | "logout_token_replay";
363
383
  /**
364
384
  * 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode
365
385
  *
package/dist/index.js CHANGED
@@ -136,8 +136,10 @@ var STORAGE_PREFIX = "nihplod_sso_";
136
136
  var TOKEN_KEY = "token";
137
137
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
138
138
  var STATE_KEY = "oauth_state";
139
+ var NONCE_KEY = "oidc_nonce";
139
140
  var RETURN_URL_KEY = "return_url";
140
141
  var LOGOUT_STATE_KEY = "logout_state";
142
+ var SILENT_PROBE_KEY = "silent_probe";
141
143
  function buildKey(base, clientId) {
142
144
  return clientId ? `${base}:${clientId}` : base;
143
145
  }
@@ -247,6 +249,15 @@ function getOAuthState(clientId) {
247
249
  function removeOAuthState(clientId) {
248
250
  _transient.remove(buildKey(STATE_KEY, clientId));
249
251
  }
252
+ function saveOAuthNonce(nonce, clientId) {
253
+ _transient.set(buildKey(NONCE_KEY, clientId), nonce);
254
+ }
255
+ function getOAuthNonce(clientId) {
256
+ return _transient.get(buildKey(NONCE_KEY, clientId));
257
+ }
258
+ function removeOAuthNonce(clientId) {
259
+ _transient.remove(buildKey(NONCE_KEY, clientId));
260
+ }
250
261
  function saveLogoutState(state, clientId) {
251
262
  _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
252
263
  }
@@ -265,20 +276,33 @@ function getReturnUrl(clientId) {
265
276
  function removeReturnUrl(clientId) {
266
277
  _transient.remove(buildKey(RETURN_URL_KEY, clientId));
267
278
  }
279
+ function saveSilentProbe(state, clientId) {
280
+ _transient.set(buildKey(SILENT_PROBE_KEY, clientId), state);
281
+ }
282
+ function getSilentProbe(clientId) {
283
+ return _transient.get(buildKey(SILENT_PROBE_KEY, clientId));
284
+ }
285
+ function removeSilentProbe(clientId) {
286
+ _transient.remove(buildKey(SILENT_PROBE_KEY, clientId));
287
+ }
268
288
  function clearAllSsoData(clientId) {
269
289
  if (clientId) {
270
290
  removeTokenData(clientId);
271
291
  removeOAuthState(clientId);
292
+ removeOAuthNonce(clientId);
272
293
  removeReturnUrl(clientId);
273
294
  removeLogoutState(clientId);
295
+ removeSilentProbe(clientId);
274
296
  removePkceVerifier(clientId);
275
297
  removePkceVerifier(`${clientId}_popup_nonce`);
276
298
  return;
277
299
  }
278
300
  removeTokenData();
279
301
  removeOAuthState();
302
+ removeOAuthNonce();
280
303
  removeReturnUrl();
281
304
  removeLogoutState();
305
+ removeSilentProbe();
282
306
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
283
307
  const stores = [
284
308
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -367,18 +391,21 @@ function normalizeIssuer(url) {
367
391
  var cachedJwks = null;
368
392
  var cachedDiscovery = null;
369
393
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
394
+ var FETCH_TIMEOUT_MS = 1e4;
370
395
  async function fetchDiscoveryDoc(baseUrl) {
371
396
  const now = Date.now();
372
397
  if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
373
398
  return cachedDiscovery.doc;
374
399
  }
375
400
  try {
376
- const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
377
- const doc = res.ok ? await res.json() : null;
401
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
402
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
403
+ });
404
+ if (!res.ok) return null;
405
+ const doc = await res.json();
378
406
  cachedDiscovery = { baseUrl, doc, fetchedAt: now };
379
407
  return doc;
380
408
  } catch {
381
- cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
382
409
  return null;
383
410
  }
384
411
  }
@@ -390,7 +417,10 @@ async function fetchJwks(baseUrl, options = {}) {
390
417
  const discovery = await fetchDiscoveryDoc(baseUrl);
391
418
  const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
392
419
  try {
393
- const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
420
+ const res = await fetch(jwksUri, {
421
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
422
+ ...options.forceRefresh ? { cache: "no-cache" } : {}
423
+ });
394
424
  if (!res.ok) return null;
395
425
  const jwks = await res.json();
396
426
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -437,7 +467,7 @@ async function computeAtHash(accessToken) {
437
467
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
438
468
  }
439
469
  async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
440
- const { rejectHs256WhenRs256Available = true } = options;
470
+ const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
441
471
  const header = decodeJwtHeader(idToken);
442
472
  if (!header) {
443
473
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -527,10 +557,21 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
527
557
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
528
558
  }
529
559
  }
560
+ if (expectedNonce !== void 0) {
561
+ const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
562
+ if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
563
+ throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
564
+ }
565
+ }
530
566
  return { sub: payload.sub };
531
567
  }
532
568
 
533
569
  // src/core/SsoClient.ts
570
+ var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
571
+ "login_required",
572
+ "consent_required",
573
+ "interaction_required"
574
+ ]);
534
575
  var _SsoClient = class _SsoClient {
535
576
  constructor(config) {
536
577
  this._discovery = null;
@@ -624,17 +665,27 @@ var _SsoClient = class _SsoClient {
624
665
  *
625
666
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
626
667
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
668
+ * @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
669
+ * 额外在存储中记录探测标记(该次请求的 state),回调无会话时
670
+ * handleCallback() 返回 null 而不抛错。
627
671
  *
628
672
  * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
629
673
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
630
674
  * 同一次登录只使用其中一个入口。
631
675
  */
632
- async login(returnUrl) {
676
+ async login(returnUrl, options = {}) {
633
677
  const verifier = generateCodeVerifier();
634
678
  const challenge = await generateCodeChallenge(verifier);
635
679
  const state = generateState();
680
+ const nonce = generateState();
636
681
  savePkceVerifier(this.config.clientId, verifier);
637
682
  saveOAuthState(state, this.config.clientId);
683
+ saveOAuthNonce(nonce, this.config.clientId);
684
+ if (options.prompt === "none") {
685
+ saveSilentProbe(state, this.config.clientId);
686
+ } else {
687
+ removeSilentProbe(this.config.clientId);
688
+ }
638
689
  if (returnUrl) {
639
690
  this._saveReturnUrlIfTrusted(returnUrl);
640
691
  }
@@ -645,8 +696,12 @@ var _SsoClient = class _SsoClient {
645
696
  params.set("redirect_uri", this.config.redirectUri);
646
697
  params.set("scope", this.config.scopes || "openid profile");
647
698
  params.set("state", state);
699
+ params.set("nonce", nonce);
648
700
  params.set("code_challenge", challenge);
649
701
  params.set("code_challenge_method", "S256");
702
+ if (options.prompt) {
703
+ params.set("prompt", options.prompt);
704
+ }
650
705
  window.location.href = `${authorizeEndpoint}?${params.toString()}`;
651
706
  }
652
707
  /**
@@ -658,12 +713,19 @@ var _SsoClient = class _SsoClient {
658
713
  * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
659
714
  * 同一次登录只使用其中一个入口。
660
715
  */
661
- async getLoginUrl(returnUrl) {
716
+ async getLoginUrl(returnUrl, options = {}) {
662
717
  const verifier = generateCodeVerifier();
663
718
  const challenge = await generateCodeChallenge(verifier);
664
719
  const state = generateState();
720
+ const nonce = generateState();
665
721
  savePkceVerifier(this.config.clientId, verifier);
666
722
  saveOAuthState(state, this.config.clientId);
723
+ saveOAuthNonce(nonce, this.config.clientId);
724
+ if (options.prompt === "none") {
725
+ saveSilentProbe(state, this.config.clientId);
726
+ } else {
727
+ removeSilentProbe(this.config.clientId);
728
+ }
667
729
  if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
668
730
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
669
731
  const params = new URLSearchParams();
@@ -672,8 +734,12 @@ var _SsoClient = class _SsoClient {
672
734
  params.set("redirect_uri", this.config.redirectUri);
673
735
  params.set("scope", this.config.scopes || "openid profile");
674
736
  params.set("state", state);
737
+ params.set("nonce", nonce);
675
738
  params.set("code_challenge", challenge);
676
739
  params.set("code_challenge_method", "S256");
740
+ if (options.prompt) {
741
+ params.set("prompt", options.prompt);
742
+ }
677
743
  return `${authorizeEndpoint}?${params.toString()}`;
678
744
  }
679
745
  /**
@@ -713,9 +779,11 @@ var _SsoClient = class _SsoClient {
713
779
  const verifier = generateCodeVerifier();
714
780
  const challenge = await generateCodeChallenge(verifier);
715
781
  const state = generateState();
782
+ const nonce = generateState();
716
783
  const popupNonce = generateState();
717
784
  savePkceVerifier(this.config.clientId, verifier);
718
785
  saveOAuthState(state, this.config.clientId);
786
+ saveOAuthNonce(nonce, this.config.clientId);
719
787
  savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
720
788
  if (options.returnUrl) {
721
789
  this._saveReturnUrlIfTrusted(options.returnUrl);
@@ -727,6 +795,7 @@ var _SsoClient = class _SsoClient {
727
795
  params.set("redirect_uri", this.config.redirectUri);
728
796
  params.set("scope", this.config.scopes || "openid profile");
729
797
  params.set("state", state);
798
+ params.set("nonce", nonce);
730
799
  params.set("code_challenge", challenge);
731
800
  params.set("code_challenge_method", "S256");
732
801
  params.set("popup_nonce", popupNonce);
@@ -751,6 +820,7 @@ var _SsoClient = class _SsoClient {
751
820
  if (!popup) {
752
821
  removePkceVerifier(this.config.clientId);
753
822
  removeOAuthState(this.config.clientId);
823
+ removeOAuthNonce(this.config.clientId);
754
824
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
755
825
  throw new SsoError(
756
826
  "popup_blocked",
@@ -770,12 +840,27 @@ var _SsoClient = class _SsoClient {
770
840
  const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
771
841
  if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
772
842
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
843
+ try {
844
+ event.source?.postMessage(
845
+ { type: "nihplod_sso_popup_ack", nonce: savedNonce },
846
+ event.origin
847
+ );
848
+ } catch {
849
+ }
773
850
  completed = true;
774
851
  cleanup();
775
852
  if (popup && !popup.closed) {
776
853
  popup.close();
777
854
  }
778
- this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
855
+ this.handleCallback(event.data.callbackUrl).then((tokenData) => {
856
+ if (tokenData === null) {
857
+ reject(
858
+ new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
859
+ );
860
+ return;
861
+ }
862
+ resolve(tokenData);
863
+ }).catch(reject);
779
864
  };
780
865
  const pollTimer = setInterval(() => {
781
866
  if (popup.closed) {
@@ -801,6 +886,7 @@ var _SsoClient = class _SsoClient {
801
886
  } catch (err) {
802
887
  removePkceVerifier(this.config.clientId);
803
888
  removeOAuthState(this.config.clientId);
889
+ removeOAuthNonce(this.config.clientId);
804
890
  removePkceVerifier(`${this.config.clientId}_popup_nonce`);
805
891
  throw err;
806
892
  }
@@ -812,15 +898,29 @@ var _SsoClient = class _SsoClient {
812
898
  * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
813
899
  *
814
900
  * @param callbackUrl - 完整的回调 URL(window.location.href)
815
- * @returns TokenData 或 null
901
+ * @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
902
+ * (IdP 回跳 login_required / consent_required / interaction_required
903
+ * 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
816
904
  */
817
905
  async handleCallback(callbackUrl) {
818
906
  const url = new URL(callbackUrl);
819
907
  const params = url.searchParams;
820
908
  const error = params.get("error");
821
909
  if (error) {
910
+ const returnedErrorState = params.get("state");
911
+ const probeState = getSilentProbe(this.config.clientId);
912
+ const savedStateForProbe = getOAuthState(this.config.clientId);
913
+ if (SILENT_PROBE_ERRORS.has(error) && probeState && savedStateForProbe && timingSafeEqualString(savedStateForProbe, returnedErrorState ?? "") && timingSafeEqualString(probeState, returnedErrorState ?? "")) {
914
+ removeOAuthState(this.config.clientId);
915
+ removeOAuthNonce(this.config.clientId);
916
+ removePkceVerifier(this.config.clientId);
917
+ removeSilentProbe(this.config.clientId);
918
+ return null;
919
+ }
822
920
  removeOAuthState(this.config.clientId);
921
+ removeOAuthNonce(this.config.clientId);
823
922
  removePkceVerifier(this.config.clientId);
923
+ removeSilentProbe(this.config.clientId);
824
924
  const desc = params.get("error_description") || error;
825
925
  throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
826
926
  }
@@ -828,18 +928,23 @@ var _SsoClient = class _SsoClient {
828
928
  const returnedState = params.get("state");
829
929
  if (!code) {
830
930
  removeOAuthState(this.config.clientId);
931
+ removeOAuthNonce(this.config.clientId);
831
932
  removePkceVerifier(this.config.clientId);
933
+ removeSilentProbe(this.config.clientId);
832
934
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
833
935
  }
834
936
  const savedState = getOAuthState(this.config.clientId);
835
937
  if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
836
938
  removeOAuthState(this.config.clientId);
939
+ removeOAuthNonce(this.config.clientId);
837
940
  removePkceVerifier(this.config.clientId);
941
+ removeSilentProbe(this.config.clientId);
838
942
  throw new SsoError(
839
943
  "state_mismatch",
840
944
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
841
945
  );
842
946
  }
947
+ const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
843
948
  const verifier = getPkceVerifier(this.config.clientId);
844
949
  if (!verifier) {
845
950
  throw new SsoError(
@@ -886,7 +991,8 @@ var _SsoClient = class _SsoClient {
886
991
  data.id_token,
887
992
  data.access_token,
888
993
  this.config.ssoBaseUrl,
889
- this.config.clientId
994
+ this.config.clientId,
995
+ { expectedNonce }
890
996
  );
891
997
  } catch (err) {
892
998
  removeTokenData(this.config.clientId);
@@ -894,7 +1000,9 @@ var _SsoClient = class _SsoClient {
894
1000
  }
895
1001
  }
896
1002
  removeOAuthState(this.config.clientId);
1003
+ removeOAuthNonce(this.config.clientId);
897
1004
  removePkceVerifier(this.config.clientId);
1005
+ removeSilentProbe(this.config.clientId);
898
1006
  const now = Date.now();
899
1007
  const tokenData = {
900
1008
  access_token: data.access_token,