@nihplod/sso-sdk 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -5
- package/dist/index.d.mts +25 -5
- package/dist/index.d.ts +25 -5
- package/dist/index.js +64 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +64 -4
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +73 -3
- package/dist/next/index.d.ts +73 -3
- package/dist/next/index.js +231 -12
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +230 -12
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +25 -5
- package/dist/react/index.d.ts +25 -5
- package/dist/react/index.js +72 -4
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +72 -4
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,20 +50,38 @@ 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
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).
|
|
@@ -72,7 +90,7 @@ Handle the OAuth callback. Parses `code` and `state` from the URL, validates sta
|
|
|
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
|
|
|
@@ -339,6 +357,15 @@ export const config = {
|
|
|
339
357
|
> code (silent re-auth), and the callback handler issues new cookies — the user typically
|
|
340
358
|
> only sees a quick redirect loop through the SSO center. No client-side action is needed.
|
|
341
359
|
|
|
360
|
+
> **Introspection outage behavior (since 1.4.1):** introspection requests time out after
|
|
361
|
+
> 5s. When introspection is *unreachable* (network error, timeout, or 5xx — as opposed to
|
|
362
|
+
> a confirmed-invalid token), the middleware fails open for requests that already carry an
|
|
363
|
+
> SSO `access_token` cookie: it lets the request through (with one `console.warn`) instead
|
|
364
|
+
> of redirecting to an SSO center that is likely down. This is safe because the middleware
|
|
365
|
+
> is only a UX gate — Route Handlers / Server Components must still re-verify the token.
|
|
366
|
+
> Confirmed-invalid tokens (401/403 or `active:false`) still trigger the redirect, and
|
|
367
|
+
> requests without any SSO cookie still redirect as before.
|
|
368
|
+
|
|
342
369
|
```typescript
|
|
343
370
|
// src/app/api/auth/callback/route.ts
|
|
344
371
|
import { createCallbackRouteHandler } from "@nihplod/sso-sdk/next";
|
|
@@ -361,7 +388,11 @@ const handler = createLogoutRouteHandler({
|
|
|
361
388
|
ssoBaseUrl: "https://nihplod.cn",
|
|
362
389
|
redirectUri: "https://yourapp.com/api/auth/callback",
|
|
363
390
|
postLogoutRedirectUri: "https://yourapp.com/",
|
|
364
|
-
|
|
391
|
+
// Logout scope: "local" (default) only signs out of THIS site (revoke
|
|
392
|
+
// refresh_token + clear cookies, redirect to the site home page);
|
|
393
|
+
// "global" additionally redirects to the SSO end-session endpoint to
|
|
394
|
+
// sign out of all NIHPLOD platforms (RP-Initiated Logout).
|
|
395
|
+
defaultScope: "local",
|
|
365
396
|
});
|
|
366
397
|
|
|
367
398
|
// Prefer POST to trigger logout (prevents logout CSRF via cross-site GET);
|
|
@@ -371,6 +402,25 @@ export const GET = handler;
|
|
|
371
402
|
export const POST = handler;
|
|
372
403
|
```
|
|
373
404
|
|
|
405
|
+
**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.
|
|
406
|
+
|
|
407
|
+
> **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.
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
// src/app/api/auth/backchannel-logout/route.ts
|
|
411
|
+
import { createBackchannelLogoutRouteHandler } from "@nihplod/sso-sdk/next";
|
|
412
|
+
|
|
413
|
+
export const POST = createBackchannelLogoutRouteHandler({
|
|
414
|
+
clientId: "...",
|
|
415
|
+
ssoBaseUrl: "https://nihplod.cn",
|
|
416
|
+
onLogout: async ({ sub, sid }) => {
|
|
417
|
+
// Clear your own server-side session for this user/session
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
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.**
|
|
423
|
+
|
|
374
424
|
Trigger the logout endpoint with a POST request (recommended):
|
|
375
425
|
|
|
376
426
|
```tsx
|
package/dist/index.d.mts
CHANGED
|
@@ -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
|
|
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" | "id_token_nonce_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
|
@@ -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
|
|
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" | "id_token_nonce_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
|
@@ -139,6 +139,7 @@ var STATE_KEY = "oauth_state";
|
|
|
139
139
|
var NONCE_KEY = "oidc_nonce";
|
|
140
140
|
var RETURN_URL_KEY = "return_url";
|
|
141
141
|
var LOGOUT_STATE_KEY = "logout_state";
|
|
142
|
+
var SILENT_PROBE_KEY = "silent_probe";
|
|
142
143
|
function buildKey(base, clientId) {
|
|
143
144
|
return clientId ? `${base}:${clientId}` : base;
|
|
144
145
|
}
|
|
@@ -275,6 +276,15 @@ function getReturnUrl(clientId) {
|
|
|
275
276
|
function removeReturnUrl(clientId) {
|
|
276
277
|
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
277
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
|
+
}
|
|
278
288
|
function clearAllSsoData(clientId) {
|
|
279
289
|
if (clientId) {
|
|
280
290
|
removeTokenData(clientId);
|
|
@@ -282,6 +292,7 @@ function clearAllSsoData(clientId) {
|
|
|
282
292
|
removeOAuthNonce(clientId);
|
|
283
293
|
removeReturnUrl(clientId);
|
|
284
294
|
removeLogoutState(clientId);
|
|
295
|
+
removeSilentProbe(clientId);
|
|
285
296
|
removePkceVerifier(clientId);
|
|
286
297
|
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
287
298
|
return;
|
|
@@ -291,6 +302,7 @@ function clearAllSsoData(clientId) {
|
|
|
291
302
|
removeOAuthNonce();
|
|
292
303
|
removeReturnUrl();
|
|
293
304
|
removeLogoutState();
|
|
305
|
+
removeSilentProbe();
|
|
294
306
|
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
295
307
|
const stores = [
|
|
296
308
|
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
@@ -555,6 +567,11 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
555
567
|
}
|
|
556
568
|
|
|
557
569
|
// src/core/SsoClient.ts
|
|
570
|
+
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
571
|
+
"login_required",
|
|
572
|
+
"consent_required",
|
|
573
|
+
"interaction_required"
|
|
574
|
+
]);
|
|
558
575
|
var _SsoClient = class _SsoClient {
|
|
559
576
|
constructor(config) {
|
|
560
577
|
this._discovery = null;
|
|
@@ -648,12 +665,15 @@ var _SsoClient = class _SsoClient {
|
|
|
648
665
|
*
|
|
649
666
|
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
650
667
|
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
668
|
+
* @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
|
|
669
|
+
* 额外在存储中记录探测标记(该次请求的 state),回调无会话时
|
|
670
|
+
* handleCallback() 返回 null 而不抛错。
|
|
651
671
|
*
|
|
652
672
|
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
653
673
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
654
674
|
* 同一次登录只使用其中一个入口。
|
|
655
675
|
*/
|
|
656
|
-
async login(returnUrl) {
|
|
676
|
+
async login(returnUrl, options = {}) {
|
|
657
677
|
const verifier = generateCodeVerifier();
|
|
658
678
|
const challenge = await generateCodeChallenge(verifier);
|
|
659
679
|
const state = generateState();
|
|
@@ -661,6 +681,11 @@ var _SsoClient = class _SsoClient {
|
|
|
661
681
|
savePkceVerifier(this.config.clientId, verifier);
|
|
662
682
|
saveOAuthState(state, this.config.clientId);
|
|
663
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
|
+
}
|
|
664
689
|
if (returnUrl) {
|
|
665
690
|
this._saveReturnUrlIfTrusted(returnUrl);
|
|
666
691
|
}
|
|
@@ -674,6 +699,9 @@ var _SsoClient = class _SsoClient {
|
|
|
674
699
|
params.set("nonce", nonce);
|
|
675
700
|
params.set("code_challenge", challenge);
|
|
676
701
|
params.set("code_challenge_method", "S256");
|
|
702
|
+
if (options.prompt) {
|
|
703
|
+
params.set("prompt", options.prompt);
|
|
704
|
+
}
|
|
677
705
|
window.location.href = `${authorizeEndpoint}?${params.toString()}`;
|
|
678
706
|
}
|
|
679
707
|
/**
|
|
@@ -685,7 +713,7 @@ var _SsoClient = class _SsoClient {
|
|
|
685
713
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
686
714
|
* 同一次登录只使用其中一个入口。
|
|
687
715
|
*/
|
|
688
|
-
async getLoginUrl(returnUrl) {
|
|
716
|
+
async getLoginUrl(returnUrl, options = {}) {
|
|
689
717
|
const verifier = generateCodeVerifier();
|
|
690
718
|
const challenge = await generateCodeChallenge(verifier);
|
|
691
719
|
const state = generateState();
|
|
@@ -693,6 +721,11 @@ var _SsoClient = class _SsoClient {
|
|
|
693
721
|
savePkceVerifier(this.config.clientId, verifier);
|
|
694
722
|
saveOAuthState(state, this.config.clientId);
|
|
695
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
|
+
}
|
|
696
729
|
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
697
730
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
698
731
|
const params = new URLSearchParams();
|
|
@@ -704,6 +737,9 @@ var _SsoClient = class _SsoClient {
|
|
|
704
737
|
params.set("nonce", nonce);
|
|
705
738
|
params.set("code_challenge", challenge);
|
|
706
739
|
params.set("code_challenge_method", "S256");
|
|
740
|
+
if (options.prompt) {
|
|
741
|
+
params.set("prompt", options.prompt);
|
|
742
|
+
}
|
|
707
743
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
708
744
|
}
|
|
709
745
|
/**
|
|
@@ -816,7 +852,15 @@ var _SsoClient = class _SsoClient {
|
|
|
816
852
|
if (popup && !popup.closed) {
|
|
817
853
|
popup.close();
|
|
818
854
|
}
|
|
819
|
-
this.handleCallback(event.data.callbackUrl).then(
|
|
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);
|
|
820
864
|
};
|
|
821
865
|
const pollTimer = setInterval(() => {
|
|
822
866
|
if (popup.closed) {
|
|
@@ -854,16 +898,29 @@ var _SsoClient = class _SsoClient {
|
|
|
854
898
|
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
855
899
|
*
|
|
856
900
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
857
|
-
* @returns TokenData
|
|
901
|
+
* @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
|
|
902
|
+
* (IdP 回跳 login_required / consent_required / interaction_required
|
|
903
|
+
* 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
|
|
858
904
|
*/
|
|
859
905
|
async handleCallback(callbackUrl) {
|
|
860
906
|
const url = new URL(callbackUrl);
|
|
861
907
|
const params = url.searchParams;
|
|
862
908
|
const error = params.get("error");
|
|
863
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
|
+
}
|
|
864
920
|
removeOAuthState(this.config.clientId);
|
|
865
921
|
removeOAuthNonce(this.config.clientId);
|
|
866
922
|
removePkceVerifier(this.config.clientId);
|
|
923
|
+
removeSilentProbe(this.config.clientId);
|
|
867
924
|
const desc = params.get("error_description") || error;
|
|
868
925
|
throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
|
|
869
926
|
}
|
|
@@ -873,6 +930,7 @@ var _SsoClient = class _SsoClient {
|
|
|
873
930
|
removeOAuthState(this.config.clientId);
|
|
874
931
|
removeOAuthNonce(this.config.clientId);
|
|
875
932
|
removePkceVerifier(this.config.clientId);
|
|
933
|
+
removeSilentProbe(this.config.clientId);
|
|
876
934
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
877
935
|
}
|
|
878
936
|
const savedState = getOAuthState(this.config.clientId);
|
|
@@ -880,6 +938,7 @@ var _SsoClient = class _SsoClient {
|
|
|
880
938
|
removeOAuthState(this.config.clientId);
|
|
881
939
|
removeOAuthNonce(this.config.clientId);
|
|
882
940
|
removePkceVerifier(this.config.clientId);
|
|
941
|
+
removeSilentProbe(this.config.clientId);
|
|
883
942
|
throw new SsoError(
|
|
884
943
|
"state_mismatch",
|
|
885
944
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
@@ -943,6 +1002,7 @@ var _SsoClient = class _SsoClient {
|
|
|
943
1002
|
removeOAuthState(this.config.clientId);
|
|
944
1003
|
removeOAuthNonce(this.config.clientId);
|
|
945
1004
|
removePkceVerifier(this.config.clientId);
|
|
1005
|
+
removeSilentProbe(this.config.clientId);
|
|
946
1006
|
const now = Date.now();
|
|
947
1007
|
const tokenData = {
|
|
948
1008
|
access_token: data.access_token,
|