@nihplod/sso-sdk 1.4.0 → 1.5.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 +90 -8
- package/dist/index.d.mts +27 -3
- package/dist/index.d.ts +27 -3
- package/dist/index.js +169 -38
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +169 -38
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +50 -0
- package/dist/next/index.d.ts +50 -0
- package/dist/next/index.js +320 -103
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +320 -103
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +25 -1
- package/dist/react/index.d.ts +25 -1
- package/dist/react/index.js +329 -71
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +329 -71
- package/dist/react/index.mjs.map +1 -1
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ if (params.get("sso_probe") === "no_session") {
|
|
|
84
84
|
|
|
85
85
|
### `sso.handleCallback(callbackUrl)`
|
|
86
86
|
|
|
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
|
|
87
|
+
Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens. When the requested scope includes `openid`, the token response **must** contain an `id_token` (fail-closed: a missing `id_token` is rejected instead of silently skipping validation). The SDK verifies the ID Token's 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, protecting against ID Token replay; the check is skipped if no nonce is stored, e.g. when the Next.js nonce cookie has expired).
|
|
88
88
|
|
|
89
89
|
| Parameter | Type | Description |
|
|
90
90
|
|------|------|------|
|
|
@@ -225,8 +225,11 @@ import { SsoProvider } from "@nihplod/sso-sdk/react";
|
|
|
225
225
|
redirectUri: "...",
|
|
226
226
|
ssoBaseUrl: "...",
|
|
227
227
|
scopes: "openid profile",
|
|
228
|
+
debug: false, // Optional: verbose SDK logs ([SSO SDK] prefix)
|
|
228
229
|
}}
|
|
229
|
-
refreshThreshold={60}
|
|
230
|
+
refreshThreshold={60} // Auto-refresh 60s before expiry
|
|
231
|
+
onTokenRefreshed={(token) => { /* sync token elsewhere */ }}
|
|
232
|
+
onSessionExpired={(err) => { /* show "session expired, please sign in again" */ }}
|
|
230
233
|
>
|
|
231
234
|
<App />
|
|
232
235
|
</SsoProvider>
|
|
@@ -242,7 +245,9 @@ const {
|
|
|
242
245
|
isAuthenticated, // boolean
|
|
243
246
|
isLoading, // boolean
|
|
244
247
|
error, // SsoError | null — set when loading user info fails (e.g. session revoked)
|
|
248
|
+
sessionExpired, // boolean — refresh_token revoked/expired; survives "no local token" reloads
|
|
245
249
|
login, // (returnUrl?: string) => Promise<void>
|
|
250
|
+
loginPopup, // (options?: { returnUrl?; width?; height? }) => Promise<TokenData>
|
|
246
251
|
logout, // (redirectToSso?: boolean) => Promise<void>
|
|
247
252
|
refreshUser, // () => Promise<void>
|
|
248
253
|
getAccessToken, // () => Promise<string | null>
|
|
@@ -252,6 +257,12 @@ const {
|
|
|
252
257
|
|
|
253
258
|
Authentication state is three-valued: `isLoading` (initializing/refreshing) → `error` (load failed, e.g. session expired) → `user` (authenticated). Render your UI accordingly.
|
|
254
259
|
|
|
260
|
+
`sessionExpired` turns `true` when refresh fails because the token was revoked/expired; unlike `error` it is **not** cleared when the local token has already been removed, so you can reliably show a "登录已过期,请重新登录" banner. It resets on successful login or explicit `logout()`.
|
|
261
|
+
|
|
262
|
+
`loginPopup` keeps the current page state (no full-page redirect) and returns the exchanged token data; the callback tab notifies the opener via `postMessage` with a one-time nonce. If the browser blocks the popup, catch the `popup_blocked` error and fall back to `login()`.
|
|
263
|
+
|
|
264
|
+
Cross-tab sync: `logout()` and silent token rotation broadcast over `BroadcastChannel`, so other tabs update immediately even though the default token storage is `sessionStorage` (a raw `storage` event does not fire for `sessionStorage`; the listener only helps when you opt into `createSecureStorage({ persist: true })`).
|
|
265
|
+
|
|
255
266
|
### `<RequireAuth>`
|
|
256
267
|
|
|
257
268
|
```tsx
|
|
@@ -332,11 +343,15 @@ export const middleware = createSsoMiddleware({
|
|
|
332
343
|
publicPaths: ["/", "/public", "/api/auth/logout"],
|
|
333
344
|
// Confidential Client (BFF) can pass clientSecret
|
|
334
345
|
// clientSecret: process.env.SSO_CLIENT_SECRET,
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
346
|
+
// ⚠️ The middleware is only a UX gate — always re-verify tokens in
|
|
347
|
+
// Route Handlers / Server Components (e.g. with @nihplod/sso-verify)
|
|
348
|
+
// before serving sensitive data.
|
|
349
|
+
// failClosedOnIntrospectionError: false by default. When introspection is
|
|
350
|
+
// *unreachable* (network error / timeout / 5xx — as opposed to a
|
|
351
|
+
// confirmed-invalid token), requests carrying an SSO access_token cookie are
|
|
352
|
+
// let through (fail-open), because redirecting to an SSO center that is down
|
|
353
|
+
// helps no one. Set to true to return 502 instead (fail-closed) if your app
|
|
354
|
+
// prefers rejecting over passing during an SSO outage.
|
|
340
355
|
// insecureLocalDev: false by default; set true ONLY for http://localhost
|
|
341
356
|
// development (disables the Secure cookie attribute and strips __Host-/__Secure-
|
|
342
357
|
// prefixes, which browsers refuse to write over HTTP). Must be set consistently
|
|
@@ -357,6 +372,23 @@ export const config = {
|
|
|
357
372
|
> code (silent re-auth), and the callback handler issues new cookies — the user typically
|
|
358
373
|
> only sees a quick redirect loop through the SSO center. No client-side action is needed.
|
|
359
374
|
|
|
375
|
+
> **Introspection outage behavior (since 1.4.1):** introspection requests time out after
|
|
376
|
+
> 5s. When introspection is *unreachable* (network error, timeout, or 5xx — as opposed to
|
|
377
|
+
> a confirmed-invalid token), the middleware fails open for requests that already carry an
|
|
378
|
+
> SSO `access_token` cookie: it lets the request through (with one `console.warn`) instead
|
|
379
|
+
> of redirecting to an SSO center that is likely down. This is safe because the middleware
|
|
380
|
+
> is only a UX gate — Route Handlers / Server Components must still re-verify the token.
|
|
381
|
+
> Confirmed-invalid tokens (401/403 or `active:false`) still trigger the redirect, and
|
|
382
|
+
> requests without any SSO cookie still redirect as before. Set
|
|
383
|
+
> `failClosedOnIntrospectionError: true` to return **502** instead of failing open.
|
|
384
|
+
>
|
|
385
|
+
> **Removed fast path:** the former "main-site session cookie" (`__Host-user_token` /
|
|
386
|
+
> `ssoCookieName` / `validateSsoCookie`) shortcut has been removed — it was dead code
|
|
387
|
+
> (`__Host-user_token` holds the main site's *internal* token, which introspection
|
|
388
|
+
> always rejects, and `__Host-` cookies are never sent to subdomains), yet cost every
|
|
389
|
+
> request a wasted introspection round-trip. Both options are now deprecated no-ops
|
|
390
|
+
> (kept for config compatibility; setting either logs a warning).
|
|
391
|
+
|
|
360
392
|
```typescript
|
|
361
393
|
// src/app/api/auth/callback/route.ts
|
|
362
394
|
import { createCallbackRouteHandler } from "@nihplod/sso-sdk/next";
|
|
@@ -366,6 +398,12 @@ export const GET = createCallbackRouteHandler({
|
|
|
366
398
|
ssoBaseUrl: "https://nihplod.cn",
|
|
367
399
|
redirectUri: "https://yourapp.com/api/auth/callback",
|
|
368
400
|
defaultReturnPath: "/dashboard",
|
|
401
|
+
// Optional but recommended: keep in sync with createSsoMiddleware's `scopes`.
|
|
402
|
+
// Only when explicitly set AND it includes `openid` does the callback require an
|
|
403
|
+
// id_token (fail-closed). If omitted, legacy-compatible behavior applies: the ID
|
|
404
|
+
// token is validated when present but its absence is not rejected, so upgrading
|
|
405
|
+
// apps that didn't configure scopes keep working.
|
|
406
|
+
scopes: "openid profile",
|
|
369
407
|
// Same as middleware; Confidential Client can pass clientSecret
|
|
370
408
|
});
|
|
371
409
|
```
|
|
@@ -431,16 +469,33 @@ Default cookie names:
|
|
|
431
469
|
| access_token | `__Host-nihplod_sso_at` | Requires Secure + Path=/ + no Domain |
|
|
432
470
|
| refresh_token | `__Host-nihplod_sso_rt` | Requires Secure + Path=/ + no Domain |
|
|
433
471
|
| state | `__Host-nihplod_sso_state` | Requires Secure + Path=/ + no Domain |
|
|
434
|
-
| nonce | `__Host-nihplod_sso_nonce` | OIDC nonce for ID Token replay protection (
|
|
472
|
+
| nonce | `__Host-nihplod_sso_nonce` | OIDC nonce for ID Token replay protection. Validated when the nonce cookie is present (cookie TTL 10 minutes); if the cookie has expired, the nonce check is skipped. Requires Secure + Path=/ + no Domain |
|
|
473
|
+
| id_token | `__Host-nihplod_sso_id` | ID token cookie used as `id_token_hint` for RP-Initiated Logout. Requires Secure + Path=/ + no Domain |
|
|
474
|
+
| logout_state | `__Host-nihplod_sso_logout_state` | One-time logout state for CSRF protection on the RP-Initiated Logout callback. Requires Secure + Path=/ + no Domain |
|
|
435
475
|
| return_url | `__Host-nihplod_sso_return` | Requires Secure + Path=/ + no Domain |
|
|
436
476
|
| verifier | `__Secure-nihplod_sso_verifier` | Requires Secure + no Domain; Path is the callback path, therefore uses `__Secure-` prefix |
|
|
437
477
|
|
|
478
|
+
> In the Next.js BFF flow, the transient cookies above (`state`, `nonce`, `return_url`, `verifier`) are written with the per-login `state` as a name suffix (e.g. `__Host-nihplod_sso_state_<state>`) and looked up by the `state` returned from the IdP. This keeps concurrent tabs from overwriting each other's login attempt. The legacy unsuffixed names are still accepted as a fallback during rolling upgrades.
|
|
479
|
+
|
|
438
480
|
> 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.
|
|
439
481
|
|
|
440
482
|
---
|
|
441
483
|
|
|
442
484
|
## Security Recommendations and Token Storage
|
|
443
485
|
|
|
486
|
+
> ⚠️ **The Next.js middleware is only a UX gate.** It decides whether to redirect
|
|
487
|
+
> unauthenticated visitors to the SSO login page; it is NOT a security boundary.
|
|
488
|
+
> Any route serving sensitive data MUST re-verify the token in the Route Handler /
|
|
489
|
+
> Server Component itself (e.g. with `@nihplod/sso-verify`, or the `getSessionUser`
|
|
490
|
+
> pattern in `examples/nextjs-app`). This applies regardless of
|
|
491
|
+
> `failClosedOnIntrospectionError` — that option only changes how the middleware
|
|
492
|
+
> behaves when the SSO introspection endpoint is unreachable.
|
|
493
|
+
|
|
494
|
+
> ℹ️ HS256 ID Tokens are **always rejected** (symmetric secrets cannot be safely
|
|
495
|
+
> distributed to public clients). The `rejectHs256WhenRs256Available` option of
|
|
496
|
+
> `validateIdToken` is deprecated and has no effect — it only chooses which error
|
|
497
|
+
> code is thrown. The SSO center must be configured with an RS256 key pair.
|
|
498
|
+
|
|
444
499
|
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).
|
|
445
500
|
|
|
446
501
|
> ⚠️ 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.
|
|
@@ -459,6 +514,33 @@ In production, it is more secure to keep the refresh token in a Service Worker o
|
|
|
459
514
|
|
|
460
515
|
---
|
|
461
516
|
|
|
517
|
+
## Error Codes
|
|
518
|
+
|
|
519
|
+
All SDK failures are thrown as `SsoError` (`error.code` / `error.description`; `error.message` is `[SSO SDK] <code>: <description>`).
|
|
520
|
+
|
|
521
|
+
| Code | Meaning | Typical handling |
|
|
522
|
+
| --- | --- | --- |
|
|
523
|
+
| `invalid_config` | Missing/invalid config (clientId, redirectUri, ssoBaseUrl), or middleware/callback config mismatch | Fix configuration; check `insecureLocalDev` and cookie names are identical everywhere |
|
|
524
|
+
| `state_mismatch` | OAuth `state` mismatch (CSRF protection) | Restart login; never ignore |
|
|
525
|
+
| `pkce_required` | PKCE verifier missing (expired tab / different tab) | Restart login |
|
|
526
|
+
| `authorization_code_expired` / `authorization_code_used` | Code expired or already redeemed | Restart login |
|
|
527
|
+
| `user_denied_authorization` | User cancelled on the SSO page | Show a neutral "login cancelled" message |
|
|
528
|
+
| `client_disabled` | Client disabled in admin console | Contact administrator |
|
|
529
|
+
| `account_disabled` | User account disabled | Show reason, contact support |
|
|
530
|
+
| `session_expired` / `no_refresh_token` | Refresh token revoked/expired | Clear local state and ask the user to sign in again (`sessionExpired` is set on `SsoProvider`) |
|
|
531
|
+
| `network_error` | Network failure or request timeout (10s; revoke 3s) | Show retry; keep local tokens for retryable cases |
|
|
532
|
+
| `rate_limited` | Server rate limit | Back off and retry later |
|
|
533
|
+
| `sso_server_error` | SSO server error | Retry later |
|
|
534
|
+
| `popup_blocked` | `window.open` blocked | Fall back to full-page `login()` |
|
|
535
|
+
| `popup_closed` | User closed the login popup | Offer retry |
|
|
536
|
+
| `id_token_*` (`id_token_invalid`, `..._signature`, `..._nonce_mismatch`, `..._at_hash_mismatch`, …) | ID token missing or failed validation (fail-closed) | Restart login; if persistent, check `scopes` parity between middleware and callback |
|
|
537
|
+
| `logout_token_*` | Back-channel logout token validation failed | Return 400/500 so the IdP retries; check clock skew/JWKS |
|
|
538
|
+
| `not_authenticated` | No local token when calling user APIs | Trigger login |
|
|
539
|
+
|
|
540
|
+
Next.js callback errors are rendered as a branded HTML page for browser navigations (with a "重新登录" action). Programmatic callers can force JSON with `?format=json` or an `Accept` header that excludes `text/html`. Customize the page with the `renderErrorPage` option; enable `debug: true` for server-side error logs.
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
462
544
|
## Utility Functions
|
|
463
545
|
|
|
464
546
|
```typescript
|
package/dist/index.d.mts
CHANGED
|
@@ -32,6 +32,12 @@ interface TokenStorage {
|
|
|
32
32
|
get(key: string): string | null;
|
|
33
33
|
set(key: string, value: string): void;
|
|
34
34
|
remove(key: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* 可选:枚举当前存储中的 key(不含 SDK 内部前缀)。
|
|
37
|
+
* 供 clearAllSsoData() 清理 client 级残留(`token:<clientId>` 等)。
|
|
38
|
+
* 自定义存储若不实现,clearAllSsoData() 仅能按已知 key 清理。
|
|
39
|
+
*/
|
|
40
|
+
keys?(): string[];
|
|
35
41
|
}
|
|
36
42
|
/**
|
|
37
43
|
* 创建存储实现
|
|
@@ -110,6 +116,11 @@ interface SsoClientConfig {
|
|
|
110
116
|
scopes?: string;
|
|
111
117
|
/** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
|
|
112
118
|
postLogoutRedirectUri?: string;
|
|
119
|
+
/**
|
|
120
|
+
* 调试日志开关(默认 false)。开启后输出登录/回调/刷新/登出等关键流程日志,
|
|
121
|
+
* 便于接入方定位问题(生产默认关闭,避免日志噪音)。
|
|
122
|
+
*/
|
|
123
|
+
debug?: boolean;
|
|
113
124
|
/**
|
|
114
125
|
* 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。
|
|
115
126
|
* 仅用于 discovery / token / userinfo / revocation 等服务器间请求;
|
|
@@ -154,6 +165,11 @@ interface TokenResponse {
|
|
|
154
165
|
access_token: string;
|
|
155
166
|
token_type: string;
|
|
156
167
|
expires_in: number;
|
|
168
|
+
/**
|
|
169
|
+
* authorization_code 交换主站必然返回(缺失时 handleCallback 抛
|
|
170
|
+
* token_request_failed);refresh 响应按 RFC 6749 §6 可省略,
|
|
171
|
+
* 此时 SDK 沿用旧 refresh_token。
|
|
172
|
+
*/
|
|
157
173
|
refresh_token: string;
|
|
158
174
|
id_token?: string;
|
|
159
175
|
}
|
|
@@ -184,6 +200,12 @@ declare class SsoClient {
|
|
|
184
200
|
/** Discovery fetch 超时(10 秒) */
|
|
185
201
|
private static readonly DISCOVERY_TIMEOUT_MS;
|
|
186
202
|
constructor(config: SsoClientConfig);
|
|
203
|
+
/**
|
|
204
|
+
* 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
|
|
205
|
+
* 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
|
|
206
|
+
* 不会输出 token/授权码等敏感值。
|
|
207
|
+
*/
|
|
208
|
+
debugLog(...args: unknown[]): void;
|
|
187
209
|
/**
|
|
188
210
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
189
211
|
*
|
|
@@ -417,9 +439,11 @@ declare class OAuthError extends Error {
|
|
|
417
439
|
*/
|
|
418
440
|
/**
|
|
419
441
|
* 校验 returnUrl 是否可信(防开放重定向)。
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
442
|
+
* 仅允许:
|
|
443
|
+
* - 站内相对路径(以单个 "/" 开头且不以 "//" 开头)
|
|
444
|
+
* - 与 currentOrigin 完全同源的 http(s) 绝对 URL
|
|
445
|
+
* 拒绝一切含反斜杠/控制字符的值(浏览器会将 "/\evil.com" 解析为跨站 URL),
|
|
446
|
+
* 拒绝 userinfo 与危险 scheme。
|
|
423
447
|
*/
|
|
424
448
|
declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
|
|
425
449
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ interface TokenStorage {
|
|
|
32
32
|
get(key: string): string | null;
|
|
33
33
|
set(key: string, value: string): void;
|
|
34
34
|
remove(key: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* 可选:枚举当前存储中的 key(不含 SDK 内部前缀)。
|
|
37
|
+
* 供 clearAllSsoData() 清理 client 级残留(`token:<clientId>` 等)。
|
|
38
|
+
* 自定义存储若不实现,clearAllSsoData() 仅能按已知 key 清理。
|
|
39
|
+
*/
|
|
40
|
+
keys?(): string[];
|
|
35
41
|
}
|
|
36
42
|
/**
|
|
37
43
|
* 创建存储实现
|
|
@@ -110,6 +116,11 @@ interface SsoClientConfig {
|
|
|
110
116
|
scopes?: string;
|
|
111
117
|
/** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
|
|
112
118
|
postLogoutRedirectUri?: string;
|
|
119
|
+
/**
|
|
120
|
+
* 调试日志开关(默认 false)。开启后输出登录/回调/刷新/登出等关键流程日志,
|
|
121
|
+
* 便于接入方定位问题(生产默认关闭,避免日志噪音)。
|
|
122
|
+
*/
|
|
123
|
+
debug?: boolean;
|
|
113
124
|
/**
|
|
114
125
|
* 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。
|
|
115
126
|
* 仅用于 discovery / token / userinfo / revocation 等服务器间请求;
|
|
@@ -154,6 +165,11 @@ interface TokenResponse {
|
|
|
154
165
|
access_token: string;
|
|
155
166
|
token_type: string;
|
|
156
167
|
expires_in: number;
|
|
168
|
+
/**
|
|
169
|
+
* authorization_code 交换主站必然返回(缺失时 handleCallback 抛
|
|
170
|
+
* token_request_failed);refresh 响应按 RFC 6749 §6 可省略,
|
|
171
|
+
* 此时 SDK 沿用旧 refresh_token。
|
|
172
|
+
*/
|
|
157
173
|
refresh_token: string;
|
|
158
174
|
id_token?: string;
|
|
159
175
|
}
|
|
@@ -184,6 +200,12 @@ declare class SsoClient {
|
|
|
184
200
|
/** Discovery fetch 超时(10 秒) */
|
|
185
201
|
private static readonly DISCOVERY_TIMEOUT_MS;
|
|
186
202
|
constructor(config: SsoClientConfig);
|
|
203
|
+
/**
|
|
204
|
+
* 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
|
|
205
|
+
* 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
|
|
206
|
+
* 不会输出 token/授权码等敏感值。
|
|
207
|
+
*/
|
|
208
|
+
debugLog(...args: unknown[]): void;
|
|
187
209
|
/**
|
|
188
210
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
189
211
|
*
|
|
@@ -417,9 +439,11 @@ declare class OAuthError extends Error {
|
|
|
417
439
|
*/
|
|
418
440
|
/**
|
|
419
441
|
* 校验 returnUrl 是否可信(防开放重定向)。
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
442
|
+
* 仅允许:
|
|
443
|
+
* - 站内相对路径(以单个 "/" 开头且不以 "//" 开头)
|
|
444
|
+
* - 与 currentOrigin 完全同源的 http(s) 绝对 URL
|
|
445
|
+
* 拒绝一切含反斜杠/控制字符的值(浏览器会将 "/\evil.com" 解析为跨站 URL),
|
|
446
|
+
* 拒绝 userinfo 与危险 scheme。
|
|
423
447
|
*/
|
|
424
448
|
declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
|
|
425
449
|
/**
|
package/dist/index.js
CHANGED
|
@@ -154,18 +154,25 @@ function createMemoryStorageAdapter() {
|
|
|
154
154
|
},
|
|
155
155
|
remove(key) {
|
|
156
156
|
store.delete(key);
|
|
157
|
+
},
|
|
158
|
+
keys() {
|
|
159
|
+
return [...store.keys()];
|
|
157
160
|
}
|
|
158
161
|
};
|
|
159
162
|
}
|
|
160
163
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
161
164
|
var localStorageAdapter = {
|
|
162
165
|
get(key) {
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
try {
|
|
167
|
+
if (typeof localStorage === "undefined") return null;
|
|
168
|
+
return localStorage.getItem(STORAGE_PREFIX + key);
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
165
172
|
},
|
|
166
173
|
set(key, value) {
|
|
167
|
-
if (typeof localStorage === "undefined") return;
|
|
168
174
|
try {
|
|
175
|
+
if (typeof localStorage === "undefined") return;
|
|
169
176
|
localStorage.setItem(STORAGE_PREFIX + key, value);
|
|
170
177
|
} catch (err) {
|
|
171
178
|
console.warn(
|
|
@@ -174,34 +181,68 @@ var localStorageAdapter = {
|
|
|
174
181
|
}
|
|
175
182
|
},
|
|
176
183
|
remove(key) {
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
try {
|
|
185
|
+
if (typeof localStorage === "undefined") return;
|
|
186
|
+
localStorage.removeItem(STORAGE_PREFIX + key);
|
|
187
|
+
} catch {
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
keys() {
|
|
191
|
+
try {
|
|
192
|
+
if (typeof localStorage === "undefined") return [];
|
|
193
|
+
const result = [];
|
|
194
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
195
|
+
const key = localStorage.key(i);
|
|
196
|
+
if (key?.startsWith(STORAGE_PREFIX)) result.push(key.slice(STORAGE_PREFIX.length));
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
179
202
|
}
|
|
180
203
|
};
|
|
181
204
|
function createSessionStorageAdapter() {
|
|
182
205
|
const fallback = /* @__PURE__ */ new Map();
|
|
183
206
|
return {
|
|
184
207
|
get(key) {
|
|
185
|
-
|
|
186
|
-
|
|
208
|
+
try {
|
|
209
|
+
if (typeof sessionStorage !== "undefined") {
|
|
210
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
187
213
|
}
|
|
188
214
|
return fallback.get(key) ?? null;
|
|
189
215
|
},
|
|
190
216
|
set(key, value) {
|
|
191
|
-
if (typeof sessionStorage === "undefined") {
|
|
192
|
-
fallback.set(key, value);
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
217
|
try {
|
|
196
|
-
|
|
218
|
+
if (typeof sessionStorage !== "undefined") {
|
|
219
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
197
222
|
} catch {
|
|
198
|
-
fallback.set(key, value);
|
|
199
223
|
}
|
|
224
|
+
fallback.set(key, value);
|
|
200
225
|
},
|
|
201
226
|
remove(key) {
|
|
202
227
|
fallback.delete(key);
|
|
203
|
-
|
|
204
|
-
|
|
228
|
+
try {
|
|
229
|
+
if (typeof sessionStorage === "undefined") return;
|
|
230
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
keys() {
|
|
235
|
+
const result = new Set(fallback.keys());
|
|
236
|
+
try {
|
|
237
|
+
if (typeof sessionStorage !== "undefined") {
|
|
238
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
239
|
+
const key = sessionStorage.key(i);
|
|
240
|
+
if (key?.startsWith(STORAGE_PREFIX)) result.add(key.slice(STORAGE_PREFIX.length));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
}
|
|
245
|
+
return [...result];
|
|
205
246
|
}
|
|
206
247
|
};
|
|
207
248
|
}
|
|
@@ -303,23 +344,44 @@ function clearAllSsoData(clientId) {
|
|
|
303
344
|
removeReturnUrl();
|
|
304
345
|
removeLogoutState();
|
|
305
346
|
removeSilentProbe();
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
typeof sessionStorage !== "undefined"
|
|
309
|
-
|
|
310
|
-
|
|
347
|
+
const stores = [];
|
|
348
|
+
try {
|
|
349
|
+
if (typeof sessionStorage !== "undefined") stores.push(sessionStorage);
|
|
350
|
+
} catch {
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
if (typeof localStorage !== "undefined") stores.push(localStorage);
|
|
354
|
+
} catch {
|
|
355
|
+
}
|
|
356
|
+
const abstractKeys = /* @__PURE__ */ new Set();
|
|
311
357
|
for (const store of stores) {
|
|
312
|
-
if (!store) continue;
|
|
313
358
|
const keys = [];
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
359
|
+
try {
|
|
360
|
+
for (let i = 0; i < store.length; i++) {
|
|
361
|
+
const key = store.key(i);
|
|
362
|
+
if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
continue;
|
|
317
366
|
}
|
|
318
367
|
for (const key of keys) {
|
|
319
|
-
|
|
320
|
-
|
|
368
|
+
abstractKeys.add(key.slice(STORAGE_PREFIX.length));
|
|
369
|
+
try {
|
|
370
|
+
store.removeItem(key);
|
|
371
|
+
} catch {
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const store of [_transient, _storage]) {
|
|
376
|
+
try {
|
|
377
|
+
for (const key of store.keys?.() ?? []) abstractKeys.add(key);
|
|
378
|
+
} catch {
|
|
321
379
|
}
|
|
322
380
|
}
|
|
381
|
+
for (const key of abstractKeys) {
|
|
382
|
+
_transient.remove(key);
|
|
383
|
+
_storage.remove(key);
|
|
384
|
+
}
|
|
323
385
|
}
|
|
324
386
|
function clearVerifiersForClients(clientIds) {
|
|
325
387
|
for (const clientId of clientIds) {
|
|
@@ -328,12 +390,17 @@ function clearVerifiersForClients(clientIds) {
|
|
|
328
390
|
}
|
|
329
391
|
|
|
330
392
|
// src/core/security.ts
|
|
393
|
+
var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
|
|
331
394
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
332
395
|
if (!url) return false;
|
|
333
|
-
if (
|
|
334
|
-
if (url.startsWith("
|
|
396
|
+
if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
|
|
397
|
+
if (url.startsWith("//")) return false;
|
|
398
|
+
if (url.startsWith("/")) return true;
|
|
335
399
|
try {
|
|
336
|
-
|
|
400
|
+
const parsed = new URL(url);
|
|
401
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
402
|
+
if (parsed.username || parsed.password) return false;
|
|
403
|
+
return parsed.origin === currentOrigin;
|
|
337
404
|
} catch {
|
|
338
405
|
return false;
|
|
339
406
|
}
|
|
@@ -567,6 +634,34 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
567
634
|
}
|
|
568
635
|
|
|
569
636
|
// src/core/SsoClient.ts
|
|
637
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
638
|
+
var REVOKE_TIMEOUT_MS = 3e3;
|
|
639
|
+
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
640
|
+
const controller = new AbortController();
|
|
641
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
642
|
+
try {
|
|
643
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
644
|
+
} finally {
|
|
645
|
+
clearTimeout(timer);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function assertValidTokenResponse(data, requireRefreshToken) {
|
|
649
|
+
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
650
|
+
throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
|
|
651
|
+
}
|
|
652
|
+
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
|
|
653
|
+
throw new SsoError(
|
|
654
|
+
"token_request_failed",
|
|
655
|
+
`Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
|
|
659
|
+
throw new SsoError(
|
|
660
|
+
"token_request_failed",
|
|
661
|
+
"Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
570
665
|
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
571
666
|
"login_required",
|
|
572
667
|
"consent_required",
|
|
@@ -585,6 +680,19 @@ var _SsoClient = class _SsoClient {
|
|
|
585
680
|
this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
|
|
586
681
|
}
|
|
587
682
|
// ============================================
|
|
683
|
+
// 调试日志
|
|
684
|
+
// ============================================
|
|
685
|
+
/**
|
|
686
|
+
* 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
|
|
687
|
+
* 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
|
|
688
|
+
* 不会输出 token/授权码等敏感值。
|
|
689
|
+
*/
|
|
690
|
+
debugLog(...args) {
|
|
691
|
+
if (this.config.debug) {
|
|
692
|
+
console.warn("[SSO SDK]", ...args);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// ============================================
|
|
588
696
|
// 内部方法
|
|
589
697
|
// ============================================
|
|
590
698
|
/**
|
|
@@ -964,7 +1072,7 @@ var _SsoClient = class _SsoClient {
|
|
|
964
1072
|
}
|
|
965
1073
|
let res;
|
|
966
1074
|
try {
|
|
967
|
-
res = await
|
|
1075
|
+
res = await fetchWithTimeout(tokenEndpoint, {
|
|
968
1076
|
method: "POST",
|
|
969
1077
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
970
1078
|
body: body.toString()
|
|
@@ -985,6 +1093,15 @@ var _SsoClient = class _SsoClient {
|
|
|
985
1093
|
);
|
|
986
1094
|
}
|
|
987
1095
|
const data = await res.json();
|
|
1096
|
+
assertValidTokenResponse(data, true);
|
|
1097
|
+
const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
|
|
1098
|
+
if (requestedScopes.includes("openid") && !data.id_token) {
|
|
1099
|
+
removeTokenData(this.config.clientId);
|
|
1100
|
+
throw new SsoError(
|
|
1101
|
+
"id_token_invalid",
|
|
1102
|
+
"Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
988
1105
|
if (data.id_token) {
|
|
989
1106
|
try {
|
|
990
1107
|
await validateIdToken(
|
|
@@ -1014,6 +1131,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1014
1131
|
expires_at: now + data.expires_in * 1e3
|
|
1015
1132
|
};
|
|
1016
1133
|
saveTokenData(tokenData, this.config.clientId);
|
|
1134
|
+
this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
|
|
1017
1135
|
return tokenData;
|
|
1018
1136
|
}
|
|
1019
1137
|
/**
|
|
@@ -1049,7 +1167,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1049
1167
|
let lastErr;
|
|
1050
1168
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1051
1169
|
try {
|
|
1052
|
-
res = await
|
|
1170
|
+
res = await fetchWithTimeout(tokenEndpoint, {
|
|
1053
1171
|
method: "POST",
|
|
1054
1172
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1055
1173
|
body: body.toString()
|
|
@@ -1071,15 +1189,20 @@ var _SsoClient = class _SsoClient {
|
|
|
1071
1189
|
} catch {
|
|
1072
1190
|
}
|
|
1073
1191
|
const errorCode = errData.error || "";
|
|
1074
|
-
if (errorCode === "invalid_grant"
|
|
1192
|
+
if (errorCode === "invalid_grant") {
|
|
1075
1193
|
removeTokenData(this.config.clientId);
|
|
1076
1194
|
}
|
|
1195
|
+
this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
|
|
1196
|
+
if (!errorCode && res.status === 401) {
|
|
1197
|
+
throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
|
|
1198
|
+
}
|
|
1077
1199
|
throw new SsoError(
|
|
1078
1200
|
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
1079
1201
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
1080
1202
|
);
|
|
1081
1203
|
}
|
|
1082
1204
|
const data = await res.json();
|
|
1205
|
+
assertValidTokenResponse(data, false);
|
|
1083
1206
|
if (data.id_token) {
|
|
1084
1207
|
try {
|
|
1085
1208
|
await validateIdToken(
|
|
@@ -1123,7 +1246,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1123
1246
|
const userinfoEndpoint = await this._getUserinfoEndpoint();
|
|
1124
1247
|
let res;
|
|
1125
1248
|
try {
|
|
1126
|
-
res = await
|
|
1249
|
+
res = await fetchWithTimeout(userinfoEndpoint, {
|
|
1127
1250
|
headers: {
|
|
1128
1251
|
Authorization: `Bearer ${tokenData.access_token}`
|
|
1129
1252
|
}
|
|
@@ -1180,6 +1303,10 @@ var _SsoClient = class _SsoClient {
|
|
|
1180
1303
|
const refreshToken = tokenData?.refresh_token;
|
|
1181
1304
|
const idTokenHint = tokenData?.id_token;
|
|
1182
1305
|
clearAllSsoData(this.config.clientId);
|
|
1306
|
+
this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
|
|
1307
|
+
clientId: this.config.clientId,
|
|
1308
|
+
redirectToSso
|
|
1309
|
+
});
|
|
1183
1310
|
if (refreshToken && this.config.clientId) {
|
|
1184
1311
|
try {
|
|
1185
1312
|
const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
|
|
@@ -1192,11 +1319,15 @@ var _SsoClient = class _SsoClient {
|
|
|
1192
1319
|
if (this.config.clientSecret) {
|
|
1193
1320
|
revokeBody.set("client_secret", this.config.clientSecret);
|
|
1194
1321
|
}
|
|
1195
|
-
await
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1322
|
+
await fetchWithTimeout(
|
|
1323
|
+
revokeUrl,
|
|
1324
|
+
{
|
|
1325
|
+
method: "POST",
|
|
1326
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1327
|
+
body: revokeBody.toString()
|
|
1328
|
+
},
|
|
1329
|
+
REVOKE_TIMEOUT_MS
|
|
1330
|
+
);
|
|
1200
1331
|
} catch {
|
|
1201
1332
|
}
|
|
1202
1333
|
}
|