@nihplod/sso-sdk 1.2.0 → 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 +443 -441
- package/dist/index.d.mts +18 -2
- package/dist/index.d.ts +18 -2
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8 -5
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +18 -0
- package/dist/next/index.d.ts +18 -0
- package/dist/next/index.js +52 -26
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +52 -26
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +18 -2
- package/dist/react/index.d.ts +18 -2
- package/dist/react/index.js +8 -5
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +8 -5
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
255
|
-
>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
>
|
|
336
|
-
>
|
|
337
|
-
>
|
|
338
|
-
>
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
|
388
|
-
|
|
389
|
-
|
|
|
390
|
-
|
|
|
391
|
-
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
setTokenStorage
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
+
```
|