@nihplod/sso-sdk 1.2.1 → 1.3.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
@@ -1,441 +1,443 @@
1
- # @nihplod/sso-sdk
2
-
3
- NIHPLOD SSO client SDK — OAuth 2.0 Authorization Code + PKCE wrapper for sub-projects.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @nihplod/sso-sdk
9
- ```
10
-
11
- ## Quick Start
12
-
13
- ```typescript
14
- import { SsoClient } from "@nihplod/sso-sdk";
15
-
16
- const sso = new SsoClient({
17
- clientId: "your-client-id",
18
- redirectUri: "https://yourapp.com/callback",
19
- ssoBaseUrl: "https://nihplod.cn",
20
- });
21
-
22
- // 1. Initiate login
23
- await sso.login();
24
-
25
- // 2. Handle callback (inside the callback page)
26
- // Token is persisted to sessionStorage by default, so it survives
27
- // the full-page redirect back from the SSO center.
28
- const token = await sso.handleCallback(window.location.href);
29
-
30
- // 3. Get user info
31
- const user = await sso.getUserInfo();
32
-
33
- // 4. Logout
34
- await sso.logout();
35
- ```
36
-
37
- ---
38
-
39
- ## API Reference
40
-
41
- ### `new SsoClient(config)`
42
-
43
- Create an SSO client instance.
44
-
45
- | Parameter | Type | Required | Description |
46
- |------|------|------|------|
47
- | `clientId` | `string` | ✅ | OAuth Client ID |
48
- | `redirectUri` | `string` | ✅ | Callback URL, must match the one registered |
49
- | `ssoBaseUrl` | `string` | ✅ | SSO provider base URL, e.g. `https://nihplod.cn` |
50
- | `scopes` | `string` | ❌ | Space-separated scopes, default `"openid profile"` |
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
-
53
- ### `sso.login(returnUrl?)`
54
-
55
- Initiate SSO login. Generates PKCE parameters and redirects to the SSO login page.
56
-
57
- | Parameter | Type | Description |
58
- |------|------|------|
59
- | `returnUrl` | `string` | Optional URL to return to after login |
60
-
61
- ### `sso.getLoginUrl(returnUrl?)`
62
-
63
- Build the login URL string without redirecting. Returns `Promise<string>`.
64
-
65
- > ⚠️ Do NOT mix `getLoginUrl()` with `login()` for the same login attempt: both regenerate and overwrite the `state` / PKCE verifier in sessionStorage, so the flow started first will fail with a state mismatch. Use only one entry point per login.
66
-
67
- ### `sso.handleCallback(callbackUrl)`
68
-
69
- Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens.
70
-
71
- | Parameter | Type | Description |
72
- |------|------|------|
73
- | `callbackUrl` | `string` | Full callback URL (`window.location.href`) |
74
-
75
- Returns `Promise<TokenData>`.
76
-
77
- ### `sso.refreshToken()`
78
-
79
- Refresh the access_token using the refresh_token. Uses a mutex to prevent concurrent refresh requests.
80
-
81
- Returns `Promise<TokenData>`.
82
-
83
- ### `sso.getUserInfo()`
84
-
85
- Fetch current user info. Automatically refreshes the access_token if expired.
86
-
87
- Returns `Promise<SsoUser>`.
88
-
89
- ```typescript
90
- interface SsoUser {
91
- sub: string;
92
- nickname?: string;
93
- avatar?: string;
94
- phone?: string; // Masked phone number
95
- membership_level?: string;
96
- total_points?: number;
97
- }
98
- ```
99
-
100
- ### `sso.getAccessToken()`
101
-
102
- Get the current valid access_token. Automatically refreshes if expired.
103
-
104
- Returns `Promise<string | null>`.
105
-
106
- ### `sso.isAuthenticated()`
107
-
108
- Check whether the user is authenticated (local check only, no network request).
109
-
110
- Returns `boolean`.
111
-
112
- ### `sso.logout(redirectToSso?)`
113
-
114
- Clear local token data and attempt to revoke the server-side refresh_token.
115
-
116
- | Parameter | Type | Default | Description |
117
- |------|------|------|------|
118
- | `redirectToSso` | `boolean` | `false` | Whether to redirect to the SSO logout page (OIDC RP-Initiated Logout) |
119
-
120
- When `redirectToSso=true`, the user is redirected to the `end_session_endpoint` from OIDC Discovery (fallback: `/api/oauth/end-session`) with `client_id`, `post_logout_redirect_uri`, `id_token_hint` and `state` parameters. The generated `state` is saved to sessionStorage; the main site clears the session and then returns to the sub-project callback address. Validate the `state` on the return page with `sso.validateLogoutState()` to prevent logout CSRF.
121
-
122
- ### `sso.validateLogoutState(url)`
123
-
124
- Validate the `state` parameter of an RP-Initiated Logout redirect-back (logout CSRF protection). Call this on the page that `post_logout_redirect_uri` points to. Returns `true` only when the URL carries a `state` matching the one saved by `sso.logout(true)`; on success the saved state is cleared (one-time).
125
-
126
- ```typescript
127
- // On the post_logout_redirect_uri page:
128
- if (sso.validateLogoutState(window.location.href)) {
129
- // Trusted redirect-back from the SSO logout page
130
- }
131
- ```
132
-
133
- Returns `boolean`.
134
-
135
- ### `sso.getDiscovery()`
136
-
137
- Fetch the OIDC Discovery document.
138
-
139
- Returns `Promise<OidcDiscovery | null>` — may be `null` when the discovery endpoint is unreachable and no cached document exists.
140
-
141
- ---
142
-
143
- ## TypeScript Types
144
-
145
- ```typescript
146
- // SsoClient config
147
- interface SsoClientConfig {
148
- clientId: string;
149
- redirectUri: string;
150
- ssoBaseUrl: string;
151
- scopes?: string;
152
- clientSecret?: string; // Only for Confidential Clients
153
- }
154
-
155
- // Token data
156
- interface TokenData {
157
- access_token: string;
158
- token_type: string;
159
- expires_in: number;
160
- refresh_token: string;
161
- id_token?: string;
162
- issued_at: number;
163
- expires_at: number;
164
- }
165
-
166
- // User info
167
- interface SsoUser {
168
- sub: string;
169
- nickname?: string;
170
- avatar?: string;
171
- phone?: string;
172
- membership_level?: string;
173
- total_points?: number;
174
- }
175
-
176
- // OIDC Discovery
177
- interface OidcDiscovery {
178
- issuer: string;
179
- authorization_endpoint: string;
180
- token_endpoint: string;
181
- userinfo_endpoint: string;
182
- jwks_uri: string;
183
- introspection_endpoint: string;
184
- revocation_endpoint?: string;
185
- end_session_endpoint?: string;
186
- scopes_supported: string[];
187
- response_types_supported: string[];
188
- grant_types_supported: string[];
189
- code_challenge_methods_supported: string[];
190
- }
191
- ```
192
-
193
- ---
194
-
195
- ## React Bindings
196
-
197
- ### `<SsoProvider>`
198
-
199
- ```tsx
200
- import { SsoProvider } from "@nihplod/sso-sdk/react";
201
-
202
- <SsoProvider
203
- config={{
204
- clientId: "...",
205
- redirectUri: "...",
206
- ssoBaseUrl: "...",
207
- scopes: "openid profile",
208
- }}
209
- refreshThreshold={60} // Auto-refresh 60s before expiry
210
- >
211
- <App />
212
- </SsoProvider>
213
- ```
214
-
215
- ### `useSso()`
216
-
217
- ```tsx
218
- import { useSso } from "@nihplod/sso-sdk/react";
219
-
220
- const {
221
- user, // SsoUser | null
222
- isAuthenticated, // boolean
223
- isLoading, // boolean
224
- error, // SsoError | null — set when loading user info fails (e.g. session revoked)
225
- login, // (returnUrl?: string) => Promise<void>
226
- logout, // (redirectToSso?: boolean) => Promise<void>
227
- refreshUser, // () => Promise<void>
228
- getAccessToken, // () => Promise<string | null>
229
- client, // SsoClient instance
230
- } = useSso();
231
- ```
232
-
233
- Authentication state is three-valued: `isLoading` (initializing/refreshing) → `error` (load failed, e.g. session expired) → `user` (authenticated). Render your UI accordingly.
234
-
235
- ### `<RequireAuth>`
236
-
237
- ```tsx
238
- import { RequireAuth } from "@nihplod/sso-sdk/react";
239
-
240
- <RequireAuth>
241
- <ProtectedContent />
242
- </RequireAuth>
243
- ```
244
-
245
- If starting the login flow fails (e.g. the user closes the popup, `popup_closed`), the component resets its internal trigger flag and shows a retry entry point instead of getting stuck on "please log in". Use `onError` to observe the failure and `renderLoginError(error, retry)` to customize the UI.
246
-
247
- ```tsx
248
- <RequireAuth
249
- autoLogin
250
- usePopup
251
- onError={(err) => console.warn("login failed", err)}
252
- renderLoginError={(err, retry) => (
253
- <button onClick={retry}>登录未完成,点击重试</button>
254
- )}
255
- >
256
- <ProtectedContent />
257
- </RequireAuth>
258
- ```
259
-
260
- ### `withAuth(Component)`
261
-
262
- ```tsx
263
- import { withAuth } from "@nihplod/sso-sdk/react";
264
-
265
- function DashboardPage() { return <div>Dashboard</div>; }
266
- export default withAuth(DashboardPage);
267
- ```
268
-
269
- ### `<CallbackPage>`
270
-
271
- ```tsx
272
- import { CallbackPage } from "@nihplod/sso-sdk/react";
273
-
274
- // Render this component in the callback route
275
- export default function AuthCallback() {
276
- return <CallbackPage />;
277
- }
278
- ```
279
-
280
- By default the component performs a full-page redirect (`window.location.href`) to the saved `returnUrl` (or `/`) after the token exchange — safe because tokens are persisted to sessionStorage by default. For SPAs that prefer router navigation (no page reload, app state preserved), pass `onSuccess` to take over the redirect; `onError` / `renderError` customize failure handling:
281
-
282
- ```tsx
283
- function CallbackRoute() {
284
- const navigate = useNavigate();
285
- return (
286
- <CallbackPage
287
- onSuccess={(tokenData) => navigate("/dashboard", { replace: true })}
288
- onError={(err) => console.warn("sso callback failed", err)}
289
- renderError={(message) => <MyErrorPage message={message} />}
290
- />
291
- );
292
- }
293
- ```
294
-
295
- The default error UI is also exported as `DefaultCallbackError` if you want to reuse it.
296
-
297
- ---
298
-
299
- ## Next.js Bindings
300
-
301
- For Next.js, the recommended approach is **Middleware + Route Handler** BFF pattern. Tokens are stored in `httpOnly` cookies so JavaScript cannot read them, providing the highest security.
302
-
303
- ```typescript
304
- // src/middleware.ts
305
- import { createSsoMiddleware } from "@nihplod/sso-sdk/next";
306
-
307
- export const middleware = createSsoMiddleware({
308
- clientId: "...",
309
- ssoBaseUrl: "https://nihplod.cn",
310
- redirectUri: "https://yourapp.com/api/auth/callback",
311
- scopes: "openid profile",
312
- publicPaths: ["/", "/public", "/api/auth/logout"],
313
- // Confidential Client (BFF) can pass clientSecret
314
- // clientSecret: process.env.SSO_CLIENT_SECRET,
315
- // validateSsoCookie defaults to true: the middleware calls the introspection
316
- // endpoint to verify the SSO session cookie. Set to false only if you accept
317
- // "cookie exists = logged in" semantics (lowest latency, but may pass revoked
318
- // sessions). Either way, the middleware is only a UX gate — always re-verify
319
- // tokens in Route Handlers / Server Components before serving sensitive data.
320
- // insecureLocalDev: false by default; set true ONLY for http://localhost
321
- // development (disables the Secure cookie attribute and strips __Host-/__Secure-
322
- // prefixes, which browsers refuse to write over HTTP). Must be set consistently
323
- // on the middleware, callback and logout handlers. Never enable in production —
324
- // as a safety guard, the option is force-ignored (with a warning) when
325
- // NODE_ENV=production and ssoBaseUrl uses https, keeping cookies secure.
326
- });
327
-
328
- export const config = {
329
- matcher: ["/((?!_next|favicon.ico).*)"],
330
- };
331
- ```
332
-
333
- > **Token expiry behavior (expected):** the middleware does NOT refresh tokens. When the
334
- > `access_token` cookie expires or fails introspection, the middleware clears it and
335
- > redirects to `/api/oauth/authorize`. Because the user still holds an SSO session on the
336
- > main site, the authorize endpoint immediately redirects back with a fresh authorization
337
- > code (silent re-auth), and the callback handler issues new cookies — the user typically
338
- > only sees a quick redirect loop through the SSO center. No client-side action is needed.
339
-
340
- ```typescript
341
- // src/app/api/auth/callback/route.ts
342
- import { createCallbackRouteHandler } from "@nihplod/sso-sdk/next";
343
-
344
- export const GET = createCallbackRouteHandler({
345
- clientId: "...",
346
- ssoBaseUrl: "https://nihplod.cn",
347
- redirectUri: "https://yourapp.com/api/auth/callback",
348
- defaultReturnPath: "/dashboard",
349
- // Same as middleware; Confidential Client can pass clientSecret
350
- });
351
- ```
352
-
353
- ```typescript
354
- // src/app/api/auth/logout/route.ts
355
- import { createLogoutRouteHandler } from "@nihplod/sso-sdk/next";
356
-
357
- const handler = createLogoutRouteHandler({
358
- clientId: "...",
359
- ssoBaseUrl: "https://nihplod.cn",
360
- redirectUri: "https://yourapp.com/api/auth/callback",
361
- postLogoutRedirectUri: "https://yourapp.com/",
362
- redirectToSso: true,
363
- });
364
-
365
- // Prefer POST to trigger logout (prevents logout CSRF via cross-site GET);
366
- // GET is kept for compatibility with plain <a> navigations.
367
- export const GET = handler;
368
- export const POST = handler;
369
- ```
370
-
371
- Trigger the logout endpoint with a POST request (recommended):
372
-
373
- ```tsx
374
- <button onClick={() => fetch("/api/auth/logout", { method: "POST" }).then(() => location.assign("/"))}>
375
- Logout
376
- </button>
377
- ```
378
-
379
- A plain `<a href="/api/auth/logout">` still works via GET, but note it can be triggered cross-site (logout CSRF).
380
-
381
- ### Cookie Configuration
382
-
383
- Default cookie names:
384
-
385
- | Cookie | Default Name | Description |
386
- |--------|--------------|-------------|
387
- | access_token | `__Host-nihplod_sso_at` | Requires Secure + Path=/ + no Domain |
388
- | refresh_token | `__Host-nihplod_sso_rt` | Requires Secure + Path=/ + no Domain |
389
- | state | `__Host-nihplod_sso_state` | Requires Secure + Path=/ + no Domain |
390
- | return_url | `__Host-nihplod_sso_return` | Requires Secure + Path=/ + no Domain |
391
- | verifier | `__Secure-nihplod_sso_verifier` | Requires Secure + no Domain; Path is the callback path, therefore uses `__Secure-` prefix |
392
-
393
- > For local development with `http://localhost`, browsers reject `Secure` cookies — and cookies named with `__Host-`/`__Secure-` prefixes are refused outright when `Secure` is missing (Chrome, Edge and Firefox all enforce this; behavior on `localhost` varies by browser, some treat it as a secure context for `Secure` cookies, none accept prefixed names without `Secure`). The visible symptom: the login callback appears to succeed but the cookies are never written, so the middleware keeps judging you as logged out and redirects to the SSO authorize page in an infinite loop. Fix: set `insecureLocalDev: true` on `createSsoMiddleware`, `createCallbackRouteHandler` and `createLogoutRouteHandler` (it disables `Secure` and strips the prefixes, with a startup warning), or serve local dev over HTTPS. HTTPS is mandatory in production — and as a production guard, all three helpers force-ignore `insecureLocalDev` (keeping `Secure` and the `__Host-`/`__Secure-` prefixes, with a warning) when `NODE_ENV=production` and `ssoBaseUrl` uses HTTPS.
394
-
395
- ---
396
-
397
- ## Security Recommendations and Token Storage
398
-
399
- By default, the SDK stores tokens in **sessionStorage** (tab-scoped persistence). This keeps the login state across page reloads and the full-page redirect that `CallbackPage` performs after the token exchange, while the data is cleared automatically when the tab closes and is never shared with other tabs. In SSR environments or privacy modes where `sessionStorage` is unavailable/unwritable, it falls back to an in-memory map (login state is lost on reload in that case).
400
-
401
- > ⚠️ XSS note: any token readable by JavaScript can be stolen by XSS. sessionStorage narrows the exposure compared with `localStorage` (tab-scoped, auto-cleared), but a `refresh_token` readable by JS is still exfiltratable while the tab is open. If you need stronger guarantees, use the Next.js BFF pattern below (tokens in `httpOnly` cookies) or keep the refresh token inside a Service Worker.
402
-
403
- Transient OAuth data (PKCE `code_verifier`, `state`, `returnUrl`, popup nonce) is stored separately in **sessionStorage**, because it must survive the full-page redirect to the SSO center and back; it is cleared automatically when the tab closes. In SSR environments without `sessionStorage`, it falls back to an in-memory map.
404
-
405
- If the sub-project is a **Next.js BFF / Confidential Client**, you can store tokens in `localStorage` for multi-tab sharing:
406
-
407
- ```typescript
408
- import { setTokenStorage, createSecureStorage } from "@nihplod/sso-sdk";
409
-
410
- setTokenStorage(createSecureStorage({ persist: true }));
411
- ```
412
-
413
- In production, it is more secure to keep the refresh token in a Service Worker or HTTP-only cookie, exposing only the short-lived access token to the frontend.
414
-
415
- ---
416
-
417
- ## Utility Functions
418
-
419
- ```typescript
420
- import {
421
- generateCodeVerifier,
422
- generateCodeChallenge,
423
- generateState,
424
- setTokenStorage,
425
- createSecureStorage,
426
- getTokenData,
427
- saveTokenData,
428
- removeTokenData,
429
- clearAllSsoData,
430
- } from "@nihplod/sso-sdk";
431
-
432
- // PKCE
433
- const verifier = generateCodeVerifier(64);
434
- const challenge = await generateCodeChallenge(verifier);
435
-
436
- // State
437
- const state = generateState();
438
-
439
- // Custom token storage (default is sessionStorage; persist: true → localStorage)
440
- setTokenStorage(createSecureStorage({ persist: false }));
441
- ```
1
+ # @nihplod/sso-sdk
2
+
3
+ NIHPLOD SSO client SDK — OAuth 2.0 Authorization Code + PKCE wrapper for sub-projects.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @nihplod/sso-sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { SsoClient } from "@nihplod/sso-sdk";
15
+
16
+ const sso = new SsoClient({
17
+ clientId: "your-client-id",
18
+ redirectUri: "https://yourapp.com/callback",
19
+ ssoBaseUrl: "https://nihplod.cn",
20
+ });
21
+
22
+ // 1. Initiate login
23
+ await sso.login();
24
+
25
+ // 2. Handle callback (inside the callback page)
26
+ // Token is persisted to sessionStorage by default, so it survives
27
+ // the full-page redirect back from the SSO center.
28
+ const token = await sso.handleCallback(window.location.href);
29
+
30
+ // 3. Get user info
31
+ const user = await sso.getUserInfo();
32
+
33
+ // 4. Logout
34
+ await sso.logout();
35
+ ```
36
+
37
+ ---
38
+
39
+ ## API Reference
40
+
41
+ ### `new SsoClient(config)`
42
+
43
+ Create an SSO client instance.
44
+
45
+ | Parameter | Type | Required | Description |
46
+ |------|------|------|------|
47
+ | `clientId` | `string` | ✅ | OAuth Client ID |
48
+ | `redirectUri` | `string` | ✅ | Callback URL, must match the one registered |
49
+ | `ssoBaseUrl` | `string` | ✅ | SSO provider base URL, e.g. `https://nihplod.cn` |
50
+ | `scopes` | `string` | ❌ | Space-separated scopes, default `"openid profile"` |
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
+
53
+ ### `sso.login(returnUrl?)`
54
+
55
+ Initiate SSO login. Generates PKCE parameters and redirects to the SSO login page.
56
+
57
+ | Parameter | Type | Description |
58
+ |------|------|------|
59
+ | `returnUrl` | `string` | Optional URL to return to after login |
60
+
61
+ ### `sso.getLoginUrl(returnUrl?)`
62
+
63
+ Build the login URL string without redirecting. Returns `Promise<string>`.
64
+
65
+ > ⚠️ Do NOT mix `getLoginUrl()` with `login()` for the same login attempt: both regenerate and overwrite the `state` / PKCE verifier in sessionStorage, so the flow started first will fail with a state mismatch. Use only one entry point per login.
66
+
67
+ ### `sso.handleCallback(callbackUrl)`
68
+
69
+ Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens.
70
+
71
+ | Parameter | Type | Description |
72
+ |------|------|------|
73
+ | `callbackUrl` | `string` | Full callback URL (`window.location.href`) |
74
+
75
+ Returns `Promise<TokenData>`.
76
+
77
+ ### `sso.refreshToken()`
78
+
79
+ Refresh the access_token using the refresh_token. Uses a mutex to prevent concurrent refresh requests.
80
+
81
+ Returns `Promise<TokenData>`.
82
+
83
+ ### `sso.getUserInfo()`
84
+
85
+ Fetch current user info. Automatically refreshes the access_token if expired.
86
+
87
+ Returns `Promise<SsoUser>`.
88
+
89
+ ```typescript
90
+ interface SsoUser {
91
+ sub: string;
92
+ nickname?: string;
93
+ avatar?: string;
94
+ phone?: string; // Masked phone number
95
+ membership_level?: string;
96
+ total_spent?: number; // 累计消费金额(元),来自 membership scope
97
+ total_points?: number;
98
+ }
99
+ ```
100
+
101
+ ### `sso.getAccessToken()`
102
+
103
+ Get the current valid access_token. Automatically refreshes if expired.
104
+
105
+ Returns `Promise<string | null>`.
106
+
107
+ ### `sso.isAuthenticated()`
108
+
109
+ Check whether the user is authenticated (local check only, no network request).
110
+
111
+ Returns `boolean`.
112
+
113
+ ### `sso.logout(redirectToSso?)`
114
+
115
+ Clear local token data and attempt to revoke the server-side refresh_token.
116
+
117
+ | Parameter | Type | Default | Description |
118
+ |------|------|------|------|
119
+ | `redirectToSso` | `boolean` | `false` | Whether to redirect to the SSO logout page (OIDC RP-Initiated Logout) |
120
+
121
+ When `redirectToSso=true`, the user is redirected to the `end_session_endpoint` from OIDC Discovery (fallback: `/api/oauth/end-session`) with `client_id`, `post_logout_redirect_uri`, `id_token_hint` and `state` parameters. The generated `state` is saved to sessionStorage; the main site clears the session and then returns to the sub-project callback address. Validate the `state` on the return page with `sso.validateLogoutState()` to prevent logout CSRF.
122
+
123
+ ### `sso.validateLogoutState(url)`
124
+
125
+ Validate the `state` parameter of an RP-Initiated Logout redirect-back (logout CSRF protection). Call this on the page that `post_logout_redirect_uri` points to. Returns `true` only when the URL carries a `state` matching the one saved by `sso.logout(true)`; on success the saved state is cleared (one-time).
126
+
127
+ ```typescript
128
+ // On the post_logout_redirect_uri page:
129
+ if (sso.validateLogoutState(window.location.href)) {
130
+ // Trusted redirect-back from the SSO logout page
131
+ }
132
+ ```
133
+
134
+ Returns `boolean`.
135
+
136
+ ### `sso.getDiscovery()`
137
+
138
+ Fetch the OIDC Discovery document.
139
+
140
+ Returns `Promise<OidcDiscovery | null>` — may be `null` when the discovery endpoint is unreachable and no cached document exists.
141
+
142
+ ---
143
+
144
+ ## TypeScript Types
145
+
146
+ ```typescript
147
+ // SsoClient config
148
+ interface SsoClientConfig {
149
+ clientId: string;
150
+ redirectUri: string;
151
+ ssoBaseUrl: string;
152
+ scopes?: string;
153
+ clientSecret?: string; // Only for Confidential Clients
154
+ }
155
+
156
+ // Token data
157
+ interface TokenData {
158
+ access_token: string;
159
+ token_type: string;
160
+ expires_in: number;
161
+ refresh_token: string;
162
+ id_token?: string;
163
+ issued_at: number;
164
+ expires_at: number;
165
+ }
166
+
167
+ // User info
168
+ interface SsoUser {
169
+ sub: string;
170
+ nickname?: string;
171
+ avatar?: string;
172
+ phone?: string;
173
+ membership_level?: string;
174
+ total_spent?: number;
175
+ total_points?: number;
176
+ }
177
+
178
+ // OIDC Discovery
179
+ interface OidcDiscovery {
180
+ issuer: string;
181
+ authorization_endpoint: string;
182
+ token_endpoint: string;
183
+ userinfo_endpoint: string;
184
+ jwks_uri: string;
185
+ introspection_endpoint: string;
186
+ revocation_endpoint?: string;
187
+ end_session_endpoint?: string;
188
+ scopes_supported: string[];
189
+ response_types_supported: string[];
190
+ grant_types_supported: string[];
191
+ code_challenge_methods_supported: string[];
192
+ }
193
+ ```
194
+
195
+ ---
196
+
197
+ ## React Bindings
198
+
199
+ ### `<SsoProvider>`
200
+
201
+ ```tsx
202
+ import { SsoProvider } from "@nihplod/sso-sdk/react";
203
+
204
+ <SsoProvider
205
+ config={{
206
+ clientId: "...",
207
+ redirectUri: "...",
208
+ ssoBaseUrl: "...",
209
+ scopes: "openid profile",
210
+ }}
211
+ refreshThreshold={60} // Auto-refresh 60s before expiry
212
+ >
213
+ <App />
214
+ </SsoProvider>
215
+ ```
216
+
217
+ ### `useSso()`
218
+
219
+ ```tsx
220
+ import { useSso } from "@nihplod/sso-sdk/react";
221
+
222
+ const {
223
+ user, // SsoUser | null
224
+ isAuthenticated, // boolean
225
+ isLoading, // boolean
226
+ error, // SsoError | null — set when loading user info fails (e.g. session revoked)
227
+ login, // (returnUrl?: string) => Promise<void>
228
+ logout, // (redirectToSso?: boolean) => Promise<void>
229
+ refreshUser, // () => Promise<void>
230
+ getAccessToken, // () => Promise<string | null>
231
+ client, // SsoClient instance
232
+ } = useSso();
233
+ ```
234
+
235
+ Authentication state is three-valued: `isLoading` (initializing/refreshing) → `error` (load failed, e.g. session expired) → `user` (authenticated). Render your UI accordingly.
236
+
237
+ ### `<RequireAuth>`
238
+
239
+ ```tsx
240
+ import { RequireAuth } from "@nihplod/sso-sdk/react";
241
+
242
+ <RequireAuth>
243
+ <ProtectedContent />
244
+ </RequireAuth>
245
+ ```
246
+
247
+ If starting the login flow fails (e.g. the user closes the popup, `popup_closed`), the component resets its internal trigger flag and shows a retry entry point instead of getting stuck on "please log in". Use `onError` to observe the failure and `renderLoginError(error, retry)` to customize the UI.
248
+
249
+ ```tsx
250
+ <RequireAuth
251
+ autoLogin
252
+ usePopup
253
+ onError={(err) => console.warn("login failed", err)}
254
+ renderLoginError={(err, retry) => (
255
+ <button onClick={retry}>登录未完成,点击重试</button>
256
+ )}
257
+ >
258
+ <ProtectedContent />
259
+ </RequireAuth>
260
+ ```
261
+
262
+ ### `withAuth(Component)`
263
+
264
+ ```tsx
265
+ import { withAuth } from "@nihplod/sso-sdk/react";
266
+
267
+ function DashboardPage() { return <div>Dashboard</div>; }
268
+ export default withAuth(DashboardPage);
269
+ ```
270
+
271
+ ### `<CallbackPage>`
272
+
273
+ ```tsx
274
+ import { CallbackPage } from "@nihplod/sso-sdk/react";
275
+
276
+ // Render this component in the callback route
277
+ export default function AuthCallback() {
278
+ return <CallbackPage />;
279
+ }
280
+ ```
281
+
282
+ By default the component performs a full-page redirect (`window.location.href`) to the saved `returnUrl` (or `/`) after the token exchange — safe because tokens are persisted to sessionStorage by default. For SPAs that prefer router navigation (no page reload, app state preserved), pass `onSuccess` to take over the redirect; `onError` / `renderError` customize failure handling:
283
+
284
+ ```tsx
285
+ function CallbackRoute() {
286
+ const navigate = useNavigate();
287
+ return (
288
+ <CallbackPage
289
+ onSuccess={(tokenData) => navigate("/dashboard", { replace: true })}
290
+ onError={(err) => console.warn("sso callback failed", err)}
291
+ renderError={(message) => <MyErrorPage message={message} />}
292
+ />
293
+ );
294
+ }
295
+ ```
296
+
297
+ The default error UI is also exported as `DefaultCallbackError` if you want to reuse it.
298
+
299
+ ---
300
+
301
+ ## Next.js Bindings
302
+
303
+ For Next.js, the recommended approach is **Middleware + Route Handler** BFF pattern. Tokens are stored in `httpOnly` cookies so JavaScript cannot read them, providing the highest security.
304
+
305
+ ```typescript
306
+ // src/middleware.ts
307
+ import { createSsoMiddleware } from "@nihplod/sso-sdk/next";
308
+
309
+ export const middleware = createSsoMiddleware({
310
+ clientId: "...",
311
+ ssoBaseUrl: "https://nihplod.cn",
312
+ redirectUri: "https://yourapp.com/api/auth/callback",
313
+ scopes: "openid profile",
314
+ publicPaths: ["/", "/public", "/api/auth/logout"],
315
+ // Confidential Client (BFF) can pass clientSecret
316
+ // clientSecret: process.env.SSO_CLIENT_SECRET,
317
+ // validateSsoCookie defaults to true: the middleware calls the introspection
318
+ // endpoint to verify the SSO session cookie. Set to false only if you accept
319
+ // "cookie exists = logged in" semantics (lowest latency, but may pass revoked
320
+ // sessions). Either way, the middleware is only a UX gate — always re-verify
321
+ // tokens in Route Handlers / Server Components before serving sensitive data.
322
+ // insecureLocalDev: false by default; set true ONLY for http://localhost
323
+ // development (disables the Secure cookie attribute and strips __Host-/__Secure-
324
+ // prefixes, which browsers refuse to write over HTTP). Must be set consistently
325
+ // on the middleware, callback and logout handlers. Never enable in production —
326
+ // as a safety guard, the option is force-ignored (with a warning) when
327
+ // NODE_ENV=production and ssoBaseUrl uses https, keeping cookies secure.
328
+ });
329
+
330
+ export const config = {
331
+ matcher: ["/((?!_next|favicon.ico).*)"],
332
+ };
333
+ ```
334
+
335
+ > **Token expiry behavior (expected):** the middleware does NOT refresh tokens. When the
336
+ > `access_token` cookie expires or fails introspection, the middleware clears it and
337
+ > redirects to `/api/oauth/authorize`. Because the user still holds an SSO session on the
338
+ > main site, the authorize endpoint immediately redirects back with a fresh authorization
339
+ > code (silent re-auth), and the callback handler issues new cookies — the user typically
340
+ > only sees a quick redirect loop through the SSO center. No client-side action is needed.
341
+
342
+ ```typescript
343
+ // src/app/api/auth/callback/route.ts
344
+ import { createCallbackRouteHandler } from "@nihplod/sso-sdk/next";
345
+
346
+ export const GET = createCallbackRouteHandler({
347
+ clientId: "...",
348
+ ssoBaseUrl: "https://nihplod.cn",
349
+ redirectUri: "https://yourapp.com/api/auth/callback",
350
+ defaultReturnPath: "/dashboard",
351
+ // Same as middleware; Confidential Client can pass clientSecret
352
+ });
353
+ ```
354
+
355
+ ```typescript
356
+ // src/app/api/auth/logout/route.ts
357
+ import { createLogoutRouteHandler } from "@nihplod/sso-sdk/next";
358
+
359
+ const handler = createLogoutRouteHandler({
360
+ clientId: "...",
361
+ ssoBaseUrl: "https://nihplod.cn",
362
+ redirectUri: "https://yourapp.com/api/auth/callback",
363
+ postLogoutRedirectUri: "https://yourapp.com/",
364
+ redirectToSso: true,
365
+ });
366
+
367
+ // Prefer POST to trigger logout (prevents logout CSRF via cross-site GET);
368
+ // GET is kept for compatibility with plain <a> navigations.
369
+ export const GET = handler;
370
+ export const POST = handler;
371
+ ```
372
+
373
+ Trigger the logout endpoint with a POST request (recommended):
374
+
375
+ ```tsx
376
+ <button onClick={() => fetch("/api/auth/logout", { method: "POST" }).then(() => location.assign("/"))}>
377
+ Logout
378
+ </button>
379
+ ```
380
+
381
+ A plain `<a href="/api/auth/logout">` still works via GET, but note it can be triggered cross-site (logout CSRF).
382
+
383
+ ### Cookie Configuration
384
+
385
+ Default cookie names:
386
+
387
+ | Cookie | Default Name | Description |
388
+ |--------|--------------|-------------|
389
+ | access_token | `__Host-nihplod_sso_at` | Requires Secure + Path=/ + no Domain |
390
+ | refresh_token | `__Host-nihplod_sso_rt` | Requires Secure + Path=/ + no Domain |
391
+ | state | `__Host-nihplod_sso_state` | Requires Secure + Path=/ + no Domain |
392
+ | return_url | `__Host-nihplod_sso_return` | Requires Secure + Path=/ + no Domain |
393
+ | verifier | `__Secure-nihplod_sso_verifier` | Requires Secure + no Domain; Path is the callback path, therefore uses `__Secure-` prefix |
394
+
395
+ > 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.
396
+
397
+ ---
398
+
399
+ ## Security Recommendations and Token Storage
400
+
401
+ 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).
402
+
403
+ > ⚠️ 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
+
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.
406
+
407
+ If the sub-project is a **Next.js BFF / Confidential Client**, you can store tokens in `localStorage` for multi-tab sharing:
408
+
409
+ ```typescript
410
+ import { setTokenStorage, createSecureStorage } from "@nihplod/sso-sdk";
411
+
412
+ setTokenStorage(createSecureStorage({ persist: true }));
413
+ ```
414
+
415
+ In production, it is more secure to keep the refresh token in a Service Worker or HTTP-only cookie, exposing only the short-lived access token to the frontend.
416
+
417
+ ---
418
+
419
+ ## Utility Functions
420
+
421
+ ```typescript
422
+ import {
423
+ generateCodeVerifier,
424
+ generateCodeChallenge,
425
+ generateState,
426
+ setTokenStorage,
427
+ createSecureStorage,
428
+ getTokenData,
429
+ saveTokenData,
430
+ removeTokenData,
431
+ clearAllSsoData,
432
+ } from "@nihplod/sso-sdk";
433
+
434
+ // PKCE
435
+ const verifier = generateCodeVerifier(64);
436
+ const challenge = await generateCodeChallenge(verifier);
437
+
438
+ // State
439
+ const state = generateState();
440
+
441
+ // Custom token storage (default is sessionStorage; persist: true → localStorage)
442
+ setTokenStorage(createSecureStorage({ persist: false }));
443
+ ```