@nihplod/sso-sdk 1.4.1 → 1.6.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 +101 -9
- package/dist/index.d.mts +52 -3
- package/dist/index.d.ts +52 -3
- package/dist/index.js +246 -42
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +246 -42
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +88 -2
- package/dist/next/index.d.ts +88 -2
- package/dist/next/index.js +380 -109
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +380 -109
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +50 -1
- package/dist/react/index.d.ts +50 -1
- package/dist/react/index.js +414 -75
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +414 -75
- 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 fail-closed both ways: a stored nonce requires a matching token nonce, and (since 1.6.0) a token carrying a `nonce` claim while no expected nonce is stored locally (e.g. the nonce cookie/sessionStorage entry was evicted) is rejected as well.
|
|
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
|
|
@@ -364,7 +379,15 @@ export const config = {
|
|
|
364
379
|
> of redirecting to an SSO center that is likely down. This is safe because the middleware
|
|
365
380
|
> is only a UX gate — Route Handlers / Server Components must still re-verify the token.
|
|
366
381
|
> Confirmed-invalid tokens (401/403 or `active:false`) still trigger the redirect, and
|
|
367
|
-
> requests without any SSO cookie still redirect as before.
|
|
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).
|
|
368
391
|
|
|
369
392
|
```typescript
|
|
370
393
|
// src/app/api/auth/callback/route.ts
|
|
@@ -375,6 +398,12 @@ export const GET = createCallbackRouteHandler({
|
|
|
375
398
|
ssoBaseUrl: "https://nihplod.cn",
|
|
376
399
|
redirectUri: "https://yourapp.com/api/auth/callback",
|
|
377
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",
|
|
378
407
|
// Same as middleware; Confidential Client can pass clientSecret
|
|
379
408
|
});
|
|
380
409
|
```
|
|
@@ -421,6 +450,8 @@ export const POST = createBackchannelLogoutRouteHandler({
|
|
|
421
450
|
|
|
422
451
|
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
452
|
|
|
453
|
+
> **jti replay storage (since 1.6.0):** the default replay cache is an in-memory per-instance Map. Multi-instance / serverless deployments MUST inject shared storage via the `jtiStore` option (e.g. Redis: `addIfAbsent` = `SET key 1 NX EX ttl`, `remove` = `DEL key` — `remove` lets the SDK release the jti so the IdP can redeliver after an `onLogout` failure; without it, redeliveries are rejected as replays).
|
|
454
|
+
|
|
424
455
|
Trigger the logout endpoint with a POST request (recommended):
|
|
425
456
|
|
|
426
457
|
```tsx
|
|
@@ -440,16 +471,50 @@ Default cookie names:
|
|
|
440
471
|
| access_token | `__Host-nihplod_sso_at` | Requires Secure + Path=/ + no Domain |
|
|
441
472
|
| refresh_token | `__Host-nihplod_sso_rt` | Requires Secure + Path=/ + no Domain |
|
|
442
473
|
| state | `__Host-nihplod_sso_state` | Requires Secure + Path=/ + no Domain |
|
|
443
|
-
| nonce | `__Host-nihplod_sso_nonce` | OIDC nonce for ID Token replay protection
|
|
474
|
+
| nonce | `__Host-nihplod_sso_nonce` | OIDC nonce for ID Token replay protection. Validated fail-closed when the nonce cookie is present (cookie TTL 10 minutes); since 1.6.0, an id_token that carries a `nonce` while the cookie has expired is also rejected (restart the login flow). Requires Secure + Path=/ + no Domain |
|
|
475
|
+
| id_token | `__Host-nihplod_sso_id` | ID token cookie used as `id_token_hint` for RP-Initiated Logout. Requires Secure + Path=/ + no Domain |
|
|
476
|
+
| 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 |
|
|
444
477
|
| return_url | `__Host-nihplod_sso_return` | Requires Secure + Path=/ + no Domain |
|
|
445
478
|
| verifier | `__Secure-nihplod_sso_verifier` | Requires Secure + no Domain; Path is the callback path, therefore uses `__Secure-` prefix |
|
|
446
479
|
|
|
480
|
+
> 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.
|
|
481
|
+
|
|
447
482
|
> 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.
|
|
448
483
|
|
|
449
484
|
---
|
|
450
485
|
|
|
486
|
+
## Changelog
|
|
487
|
+
|
|
488
|
+
### 1.6.0
|
|
489
|
+
|
|
490
|
+
Security and correctness hardening. Migration notes:
|
|
491
|
+
|
|
492
|
+
- **ID Token must carry `iat`.** `validateIdToken` now rejects tokens without an `iat` claim (`id_token_invalid`). Any spec-compliant IdP already includes it.
|
|
493
|
+
- **Nonce check fails closed.** An `id_token` that carries a `nonce` claim while no expected nonce is stored locally (e.g. the nonce cookie/sessionStorage entry was evicted) is now rejected with `id_token_nonce_mismatch` (OIDC Core §12.2), instead of silently skipping the check. Refresh-issued ID tokens must not carry `nonce` per spec, so the refresh flow is unaffected. If login fails with this error after upgrading, restart the login flow — do not downgrade the check.
|
|
494
|
+
- **New `jtiStore` option** for `createBackchannelLogoutRouteHandler` / `verifyLogoutToken(Detailed)`. The default jti replay cache is an in-memory per-instance Map; multi-instance / serverless deployments MUST inject shared storage (e.g. Redis `SET NX EX` for `addIfAbsent`, `DEL` for `remove` — `remove` lets the SDK release the jti so the IdP can redeliver after your `onLogout` hook fails). Replay keys are now prefixed with the token issuer to prevent cross-issuer jti collisions.
|
|
495
|
+
- **New `allowCrossOriginDiscoveryEndpoints` option** on `SsoClientConfig` (default `false`). Discovery documents whose `authorization_endpoint` is not same-origin with `ssoBaseUrl`, or whose `token_endpoint` / `userinfo_endpoint` is not same-origin with `ssoBaseUrl` (or `serverBaseUrl` when configured), are rejected with a warning and the SDK falls back to the default endpoints. Set this flag only if your SSO center genuinely hosts endpoints on another origin.
|
|
496
|
+
- **Multi-tab token sync**: after a silent refresh, the new token data is broadcast to other tabs via `BroadcastChannel` and persisted directly (light shape check), so other tabs no longer attempt their own refresh with an already-rotated refresh_token. Older SDK tabs (no payload) fall back to the previous `loadUser()`-only behavior.
|
|
497
|
+
- `handleCallback` now clears the one-time transient data (state/nonce/PKCE verifier/silent-probe marker) when the token endpoint returns an error (e.g. `invalid_grant`) or when the required `id_token` is missing — the authorization code is already consumed and retrying cannot succeed. Network failures and JWKS validation failures still retain the data for retry.
|
|
498
|
+
- `getAccessToken()` / `getUserInfo()` refresh when the token has less than 10s of validity left (expiry skew), instead of returning a token that may expire in flight. `isAuthenticated()` intentionally keeps the exact check (it's a synchronous UI hint).
|
|
499
|
+
- Next.js callback: the token exchange request now has a 10s timeout (previously unbounded) and still gets one retry before the 502 error page. RP-Initiated Logout state comparison is now constant-time. The middleware never asset-bypasses paths under `/api/` (e.g. `/api/app.js` no longer skips authentication; list public API paths in `publicPaths` explicitly).
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
451
503
|
## Security Recommendations and Token Storage
|
|
452
504
|
|
|
505
|
+
> ⚠️ **The Next.js middleware is only a UX gate.** It decides whether to redirect
|
|
506
|
+
> unauthenticated visitors to the SSO login page; it is NOT a security boundary.
|
|
507
|
+
> Any route serving sensitive data MUST re-verify the token in the Route Handler /
|
|
508
|
+
> Server Component itself (e.g. with `@nihplod/sso-verify`, or the `getSessionUser`
|
|
509
|
+
> pattern in `examples/nextjs-app`). This applies regardless of
|
|
510
|
+
> `failClosedOnIntrospectionError` — that option only changes how the middleware
|
|
511
|
+
> behaves when the SSO introspection endpoint is unreachable.
|
|
512
|
+
|
|
513
|
+
> ℹ️ HS256 ID Tokens are **always rejected** (symmetric secrets cannot be safely
|
|
514
|
+
> distributed to public clients). The `rejectHs256WhenRs256Available` option of
|
|
515
|
+
> `validateIdToken` is deprecated and has no effect — it only chooses which error
|
|
516
|
+
> code is thrown. The SSO center must be configured with an RS256 key pair.
|
|
517
|
+
|
|
453
518
|
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).
|
|
454
519
|
|
|
455
520
|
> ⚠️ 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.
|
|
@@ -468,6 +533,33 @@ In production, it is more secure to keep the refresh token in a Service Worker o
|
|
|
468
533
|
|
|
469
534
|
---
|
|
470
535
|
|
|
536
|
+
## Error Codes
|
|
537
|
+
|
|
538
|
+
All SDK failures are thrown as `SsoError` (`error.code` / `error.description`; `error.message` is `[SSO SDK] <code>: <description>`).
|
|
539
|
+
|
|
540
|
+
| Code | Meaning | Typical handling |
|
|
541
|
+
| --- | --- | --- |
|
|
542
|
+
| `invalid_config` | Missing/invalid config (clientId, redirectUri, ssoBaseUrl), or middleware/callback config mismatch | Fix configuration; check `insecureLocalDev` and cookie names are identical everywhere |
|
|
543
|
+
| `state_mismatch` | OAuth `state` mismatch (CSRF protection) | Restart login; never ignore |
|
|
544
|
+
| `pkce_required` | PKCE verifier missing (expired tab / different tab) | Restart login |
|
|
545
|
+
| `authorization_code_expired` / `authorization_code_used` | Code expired or already redeemed | Restart login |
|
|
546
|
+
| `user_denied_authorization` | User cancelled on the SSO page | Show a neutral "login cancelled" message |
|
|
547
|
+
| `client_disabled` | Client disabled in admin console | Contact administrator |
|
|
548
|
+
| `account_disabled` | User account disabled | Show reason, contact support |
|
|
549
|
+
| `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`) |
|
|
550
|
+
| `network_error` | Network failure or request timeout (10s; revoke 3s) | Show retry; keep local tokens for retryable cases |
|
|
551
|
+
| `rate_limited` | Server rate limit | Back off and retry later |
|
|
552
|
+
| `sso_server_error` | SSO server error | Retry later |
|
|
553
|
+
| `popup_blocked` | `window.open` blocked | Fall back to full-page `login()` |
|
|
554
|
+
| `popup_closed` | User closed the login popup | Offer retry |
|
|
555
|
+
| `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 |
|
|
556
|
+
| `logout_token_*` | Back-channel logout token validation failed | Return 400/500 so the IdP retries; check clock skew/JWKS |
|
|
557
|
+
| `not_authenticated` | No local token when calling user APIs | Trigger login |
|
|
558
|
+
|
|
559
|
+
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.
|
|
560
|
+
|
|
561
|
+
---
|
|
562
|
+
|
|
471
563
|
## Utility Functions
|
|
472
564
|
|
|
473
565
|
```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
|
* 创建存储实现
|
|
@@ -106,10 +112,26 @@ interface SsoClientConfig {
|
|
|
106
112
|
redirectUri: string;
|
|
107
113
|
/** SSO 中心地址,如 "https://nihplod.cn" */
|
|
108
114
|
ssoBaseUrl: string;
|
|
115
|
+
/**
|
|
116
|
+
* 允许 Discovery 文档声明跨源端点(默认 false)。
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ 安全警告:Discovery 文档若被篡改(如中间人/反向代理劫持),攻击者可把
|
|
119
|
+
* token_endpoint 指向自己的服务器窃取 authorization code / refresh_token。
|
|
120
|
+
* SDK 默认校验 authorization_endpoint 必须与 ssoBaseUrl 同源、
|
|
121
|
+
* token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl(或 serverBaseUrl)同源,
|
|
122
|
+
* 不匹配时告警并回退到默认端点。仅当 SSO 中心确实把端点托管在其他域名时
|
|
123
|
+
* 才设置此开关,并自行确保 Discovery 通道可信。
|
|
124
|
+
*/
|
|
125
|
+
allowCrossOriginDiscoveryEndpoints?: boolean;
|
|
109
126
|
/** 请求的 scope(空格分隔),如 "openid profile phone" */
|
|
110
127
|
scopes?: string;
|
|
111
128
|
/** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
|
|
112
129
|
postLogoutRedirectUri?: string;
|
|
130
|
+
/**
|
|
131
|
+
* 调试日志开关(默认 false)。开启后输出登录/回调/刷新/登出等关键流程日志,
|
|
132
|
+
* 便于接入方定位问题(生产默认关闭,避免日志噪音)。
|
|
133
|
+
*/
|
|
134
|
+
debug?: boolean;
|
|
113
135
|
/**
|
|
114
136
|
* 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。
|
|
115
137
|
* 仅用于 discovery / token / userinfo / revocation 等服务器间请求;
|
|
@@ -154,6 +176,11 @@ interface TokenResponse {
|
|
|
154
176
|
access_token: string;
|
|
155
177
|
token_type: string;
|
|
156
178
|
expires_in: number;
|
|
179
|
+
/**
|
|
180
|
+
* authorization_code 交换主站必然返回(缺失时 handleCallback 抛
|
|
181
|
+
* token_request_failed);refresh 响应按 RFC 6749 §6 可省略,
|
|
182
|
+
* 此时 SDK 沿用旧 refresh_token。
|
|
183
|
+
*/
|
|
157
184
|
refresh_token: string;
|
|
158
185
|
id_token?: string;
|
|
159
186
|
}
|
|
@@ -179,11 +206,28 @@ declare class SsoClient {
|
|
|
179
206
|
private _discovery;
|
|
180
207
|
private _discoveryFetchedAt;
|
|
181
208
|
private _refreshLock;
|
|
209
|
+
/** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
|
|
210
|
+
private _discoveryOriginWarned;
|
|
182
211
|
/** Discovery 文档缓存 TTL(5 分钟) */
|
|
183
212
|
private static readonly DISCOVERY_TTL_MS;
|
|
184
213
|
/** Discovery fetch 超时(10 秒) */
|
|
185
214
|
private static readonly DISCOVERY_TIMEOUT_MS;
|
|
186
215
|
constructor(config: SsoClientConfig);
|
|
216
|
+
/**
|
|
217
|
+
* 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
|
|
218
|
+
* 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
|
|
219
|
+
* 不会输出 token/授权码等敏感值。
|
|
220
|
+
*/
|
|
221
|
+
debugLog(...args: unknown[]): void;
|
|
222
|
+
/**
|
|
223
|
+
* 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
|
|
224
|
+
* - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
|
|
225
|
+
* - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
|
|
226
|
+
* 或(配置了 serverBaseUrl 时)与内网地址同源。
|
|
227
|
+
* 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
|
|
228
|
+
* 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
|
|
229
|
+
*/
|
|
230
|
+
private _sanitizeDiscovery;
|
|
187
231
|
/**
|
|
188
232
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
189
233
|
*
|
|
@@ -305,6 +349,9 @@ declare class SsoClient {
|
|
|
305
349
|
* 检查是否已认证(不发起网络请求)
|
|
306
350
|
*
|
|
307
351
|
* 仅检查本地是否存在未过期的 access_token。
|
|
352
|
+
* 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
|
|
353
|
+
* 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
|
|
354
|
+
* getAccessToken/getUserInfo 已带提前量。
|
|
308
355
|
*/
|
|
309
356
|
isAuthenticated(): boolean;
|
|
310
357
|
/**
|
|
@@ -417,9 +464,11 @@ declare class OAuthError extends Error {
|
|
|
417
464
|
*/
|
|
418
465
|
/**
|
|
419
466
|
* 校验 returnUrl 是否可信(防开放重定向)。
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
467
|
+
* 仅允许:
|
|
468
|
+
* - 站内相对路径(以单个 "/" 开头且不以 "//" 开头)
|
|
469
|
+
* - 与 currentOrigin 完全同源的 http(s) 绝对 URL
|
|
470
|
+
* 拒绝一切含反斜杠/控制字符的值(浏览器会将 "/\evil.com" 解析为跨站 URL),
|
|
471
|
+
* 拒绝 userinfo 与危险 scheme。
|
|
423
472
|
*/
|
|
424
473
|
declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
|
|
425
474
|
/**
|
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
|
* 创建存储实现
|
|
@@ -106,10 +112,26 @@ interface SsoClientConfig {
|
|
|
106
112
|
redirectUri: string;
|
|
107
113
|
/** SSO 中心地址,如 "https://nihplod.cn" */
|
|
108
114
|
ssoBaseUrl: string;
|
|
115
|
+
/**
|
|
116
|
+
* 允许 Discovery 文档声明跨源端点(默认 false)。
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ 安全警告:Discovery 文档若被篡改(如中间人/反向代理劫持),攻击者可把
|
|
119
|
+
* token_endpoint 指向自己的服务器窃取 authorization code / refresh_token。
|
|
120
|
+
* SDK 默认校验 authorization_endpoint 必须与 ssoBaseUrl 同源、
|
|
121
|
+
* token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl(或 serverBaseUrl)同源,
|
|
122
|
+
* 不匹配时告警并回退到默认端点。仅当 SSO 中心确实把端点托管在其他域名时
|
|
123
|
+
* 才设置此开关,并自行确保 Discovery 通道可信。
|
|
124
|
+
*/
|
|
125
|
+
allowCrossOriginDiscoveryEndpoints?: boolean;
|
|
109
126
|
/** 请求的 scope(空格分隔),如 "openid profile phone" */
|
|
110
127
|
scopes?: string;
|
|
111
128
|
/** RP-Initiated Logout 返回地址(可选)。不传时回退到 redirectUri */
|
|
112
129
|
postLogoutRedirectUri?: string;
|
|
130
|
+
/**
|
|
131
|
+
* 调试日志开关(默认 false)。开启后输出登录/回调/刷新/登出等关键流程日志,
|
|
132
|
+
* 便于接入方定位问题(生产默认关闭,避免日志噪音)。
|
|
133
|
+
*/
|
|
134
|
+
debug?: boolean;
|
|
113
135
|
/**
|
|
114
136
|
* 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。
|
|
115
137
|
* 仅用于 discovery / token / userinfo / revocation 等服务器间请求;
|
|
@@ -154,6 +176,11 @@ interface TokenResponse {
|
|
|
154
176
|
access_token: string;
|
|
155
177
|
token_type: string;
|
|
156
178
|
expires_in: number;
|
|
179
|
+
/**
|
|
180
|
+
* authorization_code 交换主站必然返回(缺失时 handleCallback 抛
|
|
181
|
+
* token_request_failed);refresh 响应按 RFC 6749 §6 可省略,
|
|
182
|
+
* 此时 SDK 沿用旧 refresh_token。
|
|
183
|
+
*/
|
|
157
184
|
refresh_token: string;
|
|
158
185
|
id_token?: string;
|
|
159
186
|
}
|
|
@@ -179,11 +206,28 @@ declare class SsoClient {
|
|
|
179
206
|
private _discovery;
|
|
180
207
|
private _discoveryFetchedAt;
|
|
181
208
|
private _refreshLock;
|
|
209
|
+
/** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
|
|
210
|
+
private _discoveryOriginWarned;
|
|
182
211
|
/** Discovery 文档缓存 TTL(5 分钟) */
|
|
183
212
|
private static readonly DISCOVERY_TTL_MS;
|
|
184
213
|
/** Discovery fetch 超时(10 秒) */
|
|
185
214
|
private static readonly DISCOVERY_TIMEOUT_MS;
|
|
186
215
|
constructor(config: SsoClientConfig);
|
|
216
|
+
/**
|
|
217
|
+
* 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
|
|
218
|
+
* 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
|
|
219
|
+
* 不会输出 token/授权码等敏感值。
|
|
220
|
+
*/
|
|
221
|
+
debugLog(...args: unknown[]): void;
|
|
222
|
+
/**
|
|
223
|
+
* 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
|
|
224
|
+
* - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
|
|
225
|
+
* - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
|
|
226
|
+
* 或(配置了 serverBaseUrl 时)与内网地址同源。
|
|
227
|
+
* 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
|
|
228
|
+
* 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
|
|
229
|
+
*/
|
|
230
|
+
private _sanitizeDiscovery;
|
|
187
231
|
/**
|
|
188
232
|
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
189
233
|
*
|
|
@@ -305,6 +349,9 @@ declare class SsoClient {
|
|
|
305
349
|
* 检查是否已认证(不发起网络请求)
|
|
306
350
|
*
|
|
307
351
|
* 仅检查本地是否存在未过期的 access_token。
|
|
352
|
+
* 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
|
|
353
|
+
* 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
|
|
354
|
+
* getAccessToken/getUserInfo 已带提前量。
|
|
308
355
|
*/
|
|
309
356
|
isAuthenticated(): boolean;
|
|
310
357
|
/**
|
|
@@ -417,9 +464,11 @@ declare class OAuthError extends Error {
|
|
|
417
464
|
*/
|
|
418
465
|
/**
|
|
419
466
|
* 校验 returnUrl 是否可信(防开放重定向)。
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
467
|
+
* 仅允许:
|
|
468
|
+
* - 站内相对路径(以单个 "/" 开头且不以 "//" 开头)
|
|
469
|
+
* - 与 currentOrigin 完全同源的 http(s) 绝对 URL
|
|
470
|
+
* 拒绝一切含反斜杠/控制字符的值(浏览器会将 "/\evil.com" 解析为跨站 URL),
|
|
471
|
+
* 拒绝 userinfo 与危险 scheme。
|
|
423
472
|
*/
|
|
424
473
|
declare function isTrustedReturnUrl(url: string, currentOrigin: string): boolean;
|
|
425
474
|
/**
|