@nihplod/sso-sdk 1.3.0 → 1.4.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 +52 -9
- package/dist/index.d.mts +26 -6
- package/dist/index.d.ts +26 -6
- package/dist/index.js +118 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +118 -10
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +81 -4
- package/dist/next/index.d.ts +81 -4
- package/dist/next/index.js +287 -23
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +285 -23
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +26 -6
- package/dist/react/index.d.ts +26 -6
- package/dist/react/index.js +176 -16
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +176 -16
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,29 +50,47 @@ Create an SSO client instance.
|
|
|
50
50
|
| `scopes` | `string` | ❌ | Space-separated scopes, default `"openid profile"` |
|
|
51
51
|
| `clientSecret` | `string` | ❌ | **Only for Confidential Clients**. Do NOT pass this in browser SPA (Public Client) to avoid leaking secrets. BFF / Next.js Route Handlers may pass it. |
|
|
52
52
|
|
|
53
|
-
### `sso.login(returnUrl?)`
|
|
53
|
+
### `sso.login(returnUrl?, options?)`
|
|
54
54
|
|
|
55
55
|
Initiate SSO login. Generates PKCE parameters and redirects to the SSO login page.
|
|
56
56
|
|
|
57
57
|
| Parameter | Type | Description |
|
|
58
58
|
|------|------|------|
|
|
59
59
|
| `returnUrl` | `string` | Optional URL to return to after login |
|
|
60
|
+
| `options.prompt` | `"none" \| "login" \| "consent"` | Optional OIDC `prompt` parameter, passed through to the authorize URL |
|
|
60
61
|
|
|
61
|
-
### `sso.getLoginUrl(returnUrl?)`
|
|
62
|
+
### `sso.getLoginUrl(returnUrl?, options?)`
|
|
62
63
|
|
|
63
|
-
Build the login URL string without redirecting. Returns `Promise<string>`.
|
|
64
|
+
Build the login URL string without redirecting. Returns `Promise<string>`. Accepts the same `options.prompt` as `login()`.
|
|
64
65
|
|
|
65
66
|
> ⚠️ Do NOT mix `getLoginUrl()` with `login()` for the same login attempt: both regenerate and overwrite the `state` / PKCE verifier in sessionStorage, so the flow started first will fail with a state mismatch. Use only one entry point per login.
|
|
66
67
|
|
|
68
|
+
#### Silent session probe (`prompt: "none"`)
|
|
69
|
+
|
|
70
|
+
Passing `prompt: "none"` starts a **silent probe**: if the user still has a session on the SSO center, the authorize endpoint redirects straight back with a code and login completes without UI; if not, the IdP redirects back with `error=login_required` (or `consent_required` / `interaction_required`). When you start the probe via `login()` / `getLoginUrl()`, the SDK records a probe marker alongside `state`; `handleCallback()` then recognizes the matching error callback, cleans up the transient data and returns `null` instead of throwing — treat `null` as "no SSO session". The default `<CallbackPage>` redirects back to your `returnUrl` with `sso_probe=no_session` appended.
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
// Triggered by an explicit user action (e.g. clicking "继续 NIHPLOD 账户"):
|
|
74
|
+
await sso.login("/dashboard", { prompt: "none" });
|
|
75
|
+
|
|
76
|
+
// On the page returnUrl points to:
|
|
77
|
+
const params = new URLSearchParams(location.search);
|
|
78
|
+
if (params.get("sso_probe") === "no_session") {
|
|
79
|
+
// No SSO session — fall back to the normal interactive login button
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
> ⚠️ Do NOT auto-probe on page load: it round-trips every visitor through the SSO center and adds latency for users without a session. Always trigger it from a deliberate user action (click).
|
|
84
|
+
|
|
67
85
|
### `sso.handleCallback(callbackUrl)`
|
|
68
86
|
|
|
69
|
-
Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens.
|
|
87
|
+
Handle the OAuth callback. Parses `code` and `state` from the URL, validates state, and exchanges the code for tokens. When an `id_token` is present, the SDK verifies its signature, issuer, audience, expiry and `at_hash`, and — when the login was initiated via `login()` / `getLoginUrl()` / `loginPopup()` — also validates the OIDC `nonce` claim against the value generated at login time (constant-time comparison, fail-closed, protecting against ID Token replay).
|
|
70
88
|
|
|
71
89
|
| Parameter | Type | Description |
|
|
72
90
|
|------|------|------|
|
|
73
91
|
| `callbackUrl` | `string` | Full callback URL (`window.location.href`) |
|
|
74
92
|
|
|
75
|
-
Returns `Promise<TokenData
|
|
93
|
+
Returns `Promise<TokenData | null>` — `null` only when a silent probe (`prompt: "none"`) finds no SSO session (see above).
|
|
76
94
|
|
|
77
95
|
### `sso.refreshToken()`
|
|
78
96
|
|
|
@@ -361,15 +379,39 @@ const handler = createLogoutRouteHandler({
|
|
|
361
379
|
ssoBaseUrl: "https://nihplod.cn",
|
|
362
380
|
redirectUri: "https://yourapp.com/api/auth/callback",
|
|
363
381
|
postLogoutRedirectUri: "https://yourapp.com/",
|
|
364
|
-
|
|
382
|
+
// Logout scope: "local" (default) only signs out of THIS site (revoke
|
|
383
|
+
// refresh_token + clear cookies, redirect to the site home page);
|
|
384
|
+
// "global" additionally redirects to the SSO end-session endpoint to
|
|
385
|
+
// sign out of all NIHPLOD platforms (RP-Initiated Logout).
|
|
386
|
+
defaultScope: "local",
|
|
365
387
|
});
|
|
366
388
|
|
|
367
389
|
// Prefer POST to trigger logout (prevents logout CSRF via cross-site GET);
|
|
368
|
-
// GET
|
|
390
|
+
// a GET without a valid logout state does NOT log out — it returns a
|
|
391
|
+
// confirmation page (HTML) whose form POSTs to the same endpoint.
|
|
369
392
|
export const GET = handler;
|
|
370
393
|
export const POST = handler;
|
|
371
394
|
```
|
|
372
395
|
|
|
396
|
+
**Logout scopes (default changed):** the GET confirmation page now includes a "同时退出所有 NIHPLOD 平台" checkbox; when checked, the form POSTs `global=1` and the handler performs a global logout (end-session redirect) regardless of `defaultScope`. A POST without a `global` field uses `defaultScope`. If you trigger logout from your own fetch call, add `global=1` to the form body to opt into a global logout.
|
|
397
|
+
|
|
398
|
+
> **Migration:** `redirectToSso` is deprecated and logs a one-time warning. `redirectToSso: true` → `defaultScope: "global"`; `redirectToSso: false` → `defaultScope: "local"`. Note the default changed from "redirect to SSO" to **local logout** — pass `defaultScope: "global"` to keep the old behavior.
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
// src/app/api/auth/backchannel-logout/route.ts
|
|
402
|
+
import { createBackchannelLogoutRouteHandler } from "@nihplod/sso-sdk/next";
|
|
403
|
+
|
|
404
|
+
export const POST = createBackchannelLogoutRouteHandler({
|
|
405
|
+
clientId: "...",
|
|
406
|
+
ssoBaseUrl: "https://nihplod.cn",
|
|
407
|
+
onLogout: async ({ sub, sid }) => {
|
|
408
|
+
// Clear your own server-side session for this user/session
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
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.**
|
|
414
|
+
|
|
373
415
|
Trigger the logout endpoint with a POST request (recommended):
|
|
374
416
|
|
|
375
417
|
```tsx
|
|
@@ -378,7 +420,7 @@ Trigger the logout endpoint with a POST request (recommended):
|
|
|
378
420
|
</button>
|
|
379
421
|
```
|
|
380
422
|
|
|
381
|
-
A plain `<a href="/api/auth/logout">`
|
|
423
|
+
A plain `<a href="/api/auth/logout">` navigation (GET) renders the built-in confirmation page instead of logging out directly, so cross-site image/prefetch requests can no longer trigger a logout (logout CSRF protection). Only a POST — or a GET carrying a valid RP-Initiated Logout `state` — performs the logout.
|
|
382
424
|
|
|
383
425
|
### Cookie Configuration
|
|
384
426
|
|
|
@@ -389,6 +431,7 @@ Default cookie names:
|
|
|
389
431
|
| access_token | `__Host-nihplod_sso_at` | Requires Secure + Path=/ + no Domain |
|
|
390
432
|
| refresh_token | `__Host-nihplod_sso_rt` | Requires Secure + Path=/ + no Domain |
|
|
391
433
|
| state | `__Host-nihplod_sso_state` | Requires Secure + Path=/ + no Domain |
|
|
434
|
+
| nonce | `__Host-nihplod_sso_nonce` | OIDC nonce for ID Token replay protection (validated fail-closed in the callback); Requires Secure + Path=/ + no Domain |
|
|
392
435
|
| return_url | `__Host-nihplod_sso_return` | Requires Secure + Path=/ + no Domain |
|
|
393
436
|
| verifier | `__Secure-nihplod_sso_verifier` | Requires Secure + no Domain; Path is the callback path, therefore uses `__Secure-` prefix |
|
|
394
437
|
|
|
@@ -402,7 +445,7 @@ By default, the SDK stores tokens in **sessionStorage** (tab-scoped persistence)
|
|
|
402
445
|
|
|
403
446
|
> ⚠️ 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
447
|
|
|
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.
|
|
448
|
+
Transient OAuth data (PKCE `code_verifier`, `state`, OIDC `nonce`, `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
449
|
|
|
407
450
|
If the sub-project is a **Next.js BFF / Confidential Client**, you can store tokens in `localStorage` for multi-tab sharing:
|
|
408
451
|
|
package/dist/index.d.mts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
|
|
8
8
|
* 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
|
|
9
9
|
* 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
|
|
10
|
-
* - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
|
|
10
|
+
* - 临时数据(PKCE verifier / state / OIDC nonce / returnUrl / popup nonce):必须跨整页重定向存活
|
|
11
11
|
* (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
|
|
12
12
|
* SSR 等无 sessionStorage 环境自动降级为内存 Map。
|
|
13
13
|
*
|
|
@@ -134,6 +134,21 @@ interface SsoUser {
|
|
|
134
134
|
total_spent?: number;
|
|
135
135
|
total_points?: number;
|
|
136
136
|
}
|
|
137
|
+
/** login() / getLoginUrl() 选项 */
|
|
138
|
+
interface LoginOptions {
|
|
139
|
+
/**
|
|
140
|
+
* OIDC prompt 参数:
|
|
141
|
+
* - "none":静默探测 SSO 会话。用户在 IdP 有会话时直接回跳完成登录;
|
|
142
|
+
* 无会话时 IdP 回跳 error=login_required(或 consent_required /
|
|
143
|
+
* interaction_required),handleCallback() 识别后返回 null 而不抛错
|
|
144
|
+
* (CallbackPage 默认跳回 returnUrl 并附 sso_probe=no_session)。
|
|
145
|
+
* ⚠️ 不要在页面加载时自动探测(会打扰无会话用户并消耗请求),
|
|
146
|
+
* 应由用户点击等明确动作触发。
|
|
147
|
+
* - "login":强制重新认证;
|
|
148
|
+
* - "consent":强制重新授权确认。
|
|
149
|
+
*/
|
|
150
|
+
prompt?: "none" | "login" | "consent";
|
|
151
|
+
}
|
|
137
152
|
/** Token 响应 */
|
|
138
153
|
interface TokenResponse {
|
|
139
154
|
access_token: string;
|
|
@@ -195,12 +210,15 @@ declare class SsoClient {
|
|
|
195
210
|
*
|
|
196
211
|
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
197
212
|
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
213
|
+
* @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
|
|
214
|
+
* 额外在存储中记录探测标记(该次请求的 state),回调无会话时
|
|
215
|
+
* handleCallback() 返回 null 而不抛错。
|
|
198
216
|
*
|
|
199
217
|
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
200
218
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
201
219
|
* 同一次登录只使用其中一个入口。
|
|
202
220
|
*/
|
|
203
|
-
login(returnUrl?: string): Promise<void>;
|
|
221
|
+
login(returnUrl?: string, options?: LoginOptions): Promise<void>;
|
|
204
222
|
/**
|
|
205
223
|
* 构建登录 URL(不跳转,返回 URL 字符串)
|
|
206
224
|
*
|
|
@@ -210,7 +228,7 @@ declare class SsoClient {
|
|
|
210
228
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
211
229
|
* 同一次登录只使用其中一个入口。
|
|
212
230
|
*/
|
|
213
|
-
getLoginUrl(returnUrl?: string): Promise<string>;
|
|
231
|
+
getLoginUrl(returnUrl?: string, options?: LoginOptions): Promise<string>;
|
|
214
232
|
/**
|
|
215
233
|
* 弹窗模式 SSO 登录
|
|
216
234
|
*
|
|
@@ -256,9 +274,11 @@ declare class SsoClient {
|
|
|
256
274
|
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
257
275
|
*
|
|
258
276
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
259
|
-
* @returns TokenData
|
|
277
|
+
* @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
|
|
278
|
+
* (IdP 回跳 login_required / consent_required / interaction_required
|
|
279
|
+
* 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
|
|
260
280
|
*/
|
|
261
|
-
handleCallback(callbackUrl: string): Promise<TokenData>;
|
|
281
|
+
handleCallback(callbackUrl: string): Promise<TokenData | null>;
|
|
262
282
|
/**
|
|
263
283
|
* 刷新 Access Token
|
|
264
284
|
*
|
|
@@ -359,7 +379,7 @@ declare function generateState(): string;
|
|
|
359
379
|
* SSO SDK 错误类型
|
|
360
380
|
*/
|
|
361
381
|
/** SSO 错误码 */
|
|
362
|
-
type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "authorization_code_expired" | "authorization_code_used" | "client_disabled" | "user_denied_authorization" | "account_disabled" | "sso_server_error" | "rate_limited" | "network_error" | "popup_blocked" | "popup_closed" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_hs256_unsupported" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch";
|
|
382
|
+
type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "authorization_code_expired" | "authorization_code_used" | "client_disabled" | "user_denied_authorization" | "account_disabled" | "sso_server_error" | "rate_limited" | "network_error" | "popup_blocked" | "popup_closed" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_hs256_unsupported" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch" | "id_token_nonce_mismatch" | "logout_token_invalid" | "logout_token_unsupported_alg" | "logout_token_invalid_signature" | "logout_token_issuer_mismatch" | "logout_token_audience_mismatch" | "logout_token_expired" | "logout_token_replay";
|
|
363
383
|
/**
|
|
364
384
|
* 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode
|
|
365
385
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 整页跳转与刷新后登录态保留,关闭标签页自动清除;SSR / 隐私模式写入失败时
|
|
8
8
|
* 降级为内存 Map。对需要多 Tab 共享 token 或 BFF/Confidential Client 场景,
|
|
9
9
|
* 可通过 setTokenStorage() 注入 localStorage 实现(如 createSecureStorage({ persist: true }))。
|
|
10
|
-
* - 临时数据(PKCE verifier / state / returnUrl / popup nonce):必须跨整页重定向存活
|
|
10
|
+
* - 临时数据(PKCE verifier / state / OIDC nonce / returnUrl / popup nonce):必须跨整页重定向存活
|
|
11
11
|
* (login() 会 302 跳转到 SSO 中心再回来),因此默认写入 sessionStorage;
|
|
12
12
|
* SSR 等无 sessionStorage 环境自动降级为内存 Map。
|
|
13
13
|
*
|
|
@@ -134,6 +134,21 @@ interface SsoUser {
|
|
|
134
134
|
total_spent?: number;
|
|
135
135
|
total_points?: number;
|
|
136
136
|
}
|
|
137
|
+
/** login() / getLoginUrl() 选项 */
|
|
138
|
+
interface LoginOptions {
|
|
139
|
+
/**
|
|
140
|
+
* OIDC prompt 参数:
|
|
141
|
+
* - "none":静默探测 SSO 会话。用户在 IdP 有会话时直接回跳完成登录;
|
|
142
|
+
* 无会话时 IdP 回跳 error=login_required(或 consent_required /
|
|
143
|
+
* interaction_required),handleCallback() 识别后返回 null 而不抛错
|
|
144
|
+
* (CallbackPage 默认跳回 returnUrl 并附 sso_probe=no_session)。
|
|
145
|
+
* ⚠️ 不要在页面加载时自动探测(会打扰无会话用户并消耗请求),
|
|
146
|
+
* 应由用户点击等明确动作触发。
|
|
147
|
+
* - "login":强制重新认证;
|
|
148
|
+
* - "consent":强制重新授权确认。
|
|
149
|
+
*/
|
|
150
|
+
prompt?: "none" | "login" | "consent";
|
|
151
|
+
}
|
|
137
152
|
/** Token 响应 */
|
|
138
153
|
interface TokenResponse {
|
|
139
154
|
access_token: string;
|
|
@@ -195,12 +210,15 @@ declare class SsoClient {
|
|
|
195
210
|
*
|
|
196
211
|
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
197
212
|
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
213
|
+
* @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
|
|
214
|
+
* 额外在存储中记录探测标记(该次请求的 state),回调无会话时
|
|
215
|
+
* handleCallback() 返回 null 而不抛错。
|
|
198
216
|
*
|
|
199
217
|
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
200
218
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
201
219
|
* 同一次登录只使用其中一个入口。
|
|
202
220
|
*/
|
|
203
|
-
login(returnUrl?: string): Promise<void>;
|
|
221
|
+
login(returnUrl?: string, options?: LoginOptions): Promise<void>;
|
|
204
222
|
/**
|
|
205
223
|
* 构建登录 URL(不跳转,返回 URL 字符串)
|
|
206
224
|
*
|
|
@@ -210,7 +228,7 @@ declare class SsoClient {
|
|
|
210
228
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
211
229
|
* 同一次登录只使用其中一个入口。
|
|
212
230
|
*/
|
|
213
|
-
getLoginUrl(returnUrl?: string): Promise<string>;
|
|
231
|
+
getLoginUrl(returnUrl?: string, options?: LoginOptions): Promise<string>;
|
|
214
232
|
/**
|
|
215
233
|
* 弹窗模式 SSO 登录
|
|
216
234
|
*
|
|
@@ -256,9 +274,11 @@ declare class SsoClient {
|
|
|
256
274
|
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
257
275
|
*
|
|
258
276
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
259
|
-
* @returns TokenData
|
|
277
|
+
* @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
|
|
278
|
+
* (IdP 回跳 login_required / consent_required / interaction_required
|
|
279
|
+
* 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
|
|
260
280
|
*/
|
|
261
|
-
handleCallback(callbackUrl: string): Promise<TokenData>;
|
|
281
|
+
handleCallback(callbackUrl: string): Promise<TokenData | null>;
|
|
262
282
|
/**
|
|
263
283
|
* 刷新 Access Token
|
|
264
284
|
*
|
|
@@ -359,7 +379,7 @@ declare function generateState(): string;
|
|
|
359
379
|
* SSO SDK 错误类型
|
|
360
380
|
*/
|
|
361
381
|
/** SSO 错误码 */
|
|
362
|
-
type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "authorization_code_expired" | "authorization_code_used" | "client_disabled" | "user_denied_authorization" | "account_disabled" | "sso_server_error" | "rate_limited" | "network_error" | "popup_blocked" | "popup_closed" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_hs256_unsupported" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch";
|
|
382
|
+
type SsoErrorCode = "invalid_config" | "state_mismatch" | "pkce_required" | "token_request_failed" | "session_expired" | "no_refresh_token" | "userinfo_failed" | "not_authenticated" | "authorization_code_expired" | "authorization_code_used" | "client_disabled" | "user_denied_authorization" | "account_disabled" | "sso_server_error" | "rate_limited" | "network_error" | "popup_blocked" | "popup_closed" | "id_token_invalid" | "id_token_unsupported_alg" | "id_token_hs256_unsupported" | "id_token_missing_secret" | "id_token_invalid_signature" | "id_token_issuer_mismatch" | "id_token_audience_mismatch" | "id_token_expired" | "id_token_missing_sub" | "id_token_at_hash_mismatch" | "id_token_nonce_mismatch" | "logout_token_invalid" | "logout_token_unsupported_alg" | "logout_token_invalid_signature" | "logout_token_issuer_mismatch" | "logout_token_audience_mismatch" | "logout_token_expired" | "logout_token_replay";
|
|
363
383
|
/**
|
|
364
384
|
* 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode
|
|
365
385
|
*
|
package/dist/index.js
CHANGED
|
@@ -136,8 +136,10 @@ var STORAGE_PREFIX = "nihplod_sso_";
|
|
|
136
136
|
var TOKEN_KEY = "token";
|
|
137
137
|
var VERIFIER_KEY_PREFIX = "pkce_verifier_";
|
|
138
138
|
var STATE_KEY = "oauth_state";
|
|
139
|
+
var NONCE_KEY = "oidc_nonce";
|
|
139
140
|
var RETURN_URL_KEY = "return_url";
|
|
140
141
|
var LOGOUT_STATE_KEY = "logout_state";
|
|
142
|
+
var SILENT_PROBE_KEY = "silent_probe";
|
|
141
143
|
function buildKey(base, clientId) {
|
|
142
144
|
return clientId ? `${base}:${clientId}` : base;
|
|
143
145
|
}
|
|
@@ -247,6 +249,15 @@ function getOAuthState(clientId) {
|
|
|
247
249
|
function removeOAuthState(clientId) {
|
|
248
250
|
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
249
251
|
}
|
|
252
|
+
function saveOAuthNonce(nonce, clientId) {
|
|
253
|
+
_transient.set(buildKey(NONCE_KEY, clientId), nonce);
|
|
254
|
+
}
|
|
255
|
+
function getOAuthNonce(clientId) {
|
|
256
|
+
return _transient.get(buildKey(NONCE_KEY, clientId));
|
|
257
|
+
}
|
|
258
|
+
function removeOAuthNonce(clientId) {
|
|
259
|
+
_transient.remove(buildKey(NONCE_KEY, clientId));
|
|
260
|
+
}
|
|
250
261
|
function saveLogoutState(state, clientId) {
|
|
251
262
|
_transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
|
|
252
263
|
}
|
|
@@ -265,20 +276,33 @@ function getReturnUrl(clientId) {
|
|
|
265
276
|
function removeReturnUrl(clientId) {
|
|
266
277
|
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
267
278
|
}
|
|
279
|
+
function saveSilentProbe(state, clientId) {
|
|
280
|
+
_transient.set(buildKey(SILENT_PROBE_KEY, clientId), state);
|
|
281
|
+
}
|
|
282
|
+
function getSilentProbe(clientId) {
|
|
283
|
+
return _transient.get(buildKey(SILENT_PROBE_KEY, clientId));
|
|
284
|
+
}
|
|
285
|
+
function removeSilentProbe(clientId) {
|
|
286
|
+
_transient.remove(buildKey(SILENT_PROBE_KEY, clientId));
|
|
287
|
+
}
|
|
268
288
|
function clearAllSsoData(clientId) {
|
|
269
289
|
if (clientId) {
|
|
270
290
|
removeTokenData(clientId);
|
|
271
291
|
removeOAuthState(clientId);
|
|
292
|
+
removeOAuthNonce(clientId);
|
|
272
293
|
removeReturnUrl(clientId);
|
|
273
294
|
removeLogoutState(clientId);
|
|
295
|
+
removeSilentProbe(clientId);
|
|
274
296
|
removePkceVerifier(clientId);
|
|
275
297
|
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
276
298
|
return;
|
|
277
299
|
}
|
|
278
300
|
removeTokenData();
|
|
279
301
|
removeOAuthState();
|
|
302
|
+
removeOAuthNonce();
|
|
280
303
|
removeReturnUrl();
|
|
281
304
|
removeLogoutState();
|
|
305
|
+
removeSilentProbe();
|
|
282
306
|
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
283
307
|
const stores = [
|
|
284
308
|
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
@@ -367,18 +391,21 @@ function normalizeIssuer(url) {
|
|
|
367
391
|
var cachedJwks = null;
|
|
368
392
|
var cachedDiscovery = null;
|
|
369
393
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
394
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
370
395
|
async function fetchDiscoveryDoc(baseUrl) {
|
|
371
396
|
const now = Date.now();
|
|
372
397
|
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
373
398
|
return cachedDiscovery.doc;
|
|
374
399
|
}
|
|
375
400
|
try {
|
|
376
|
-
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration
|
|
377
|
-
|
|
401
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
|
|
402
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
403
|
+
});
|
|
404
|
+
if (!res.ok) return null;
|
|
405
|
+
const doc = await res.json();
|
|
378
406
|
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
379
407
|
return doc;
|
|
380
408
|
} catch {
|
|
381
|
-
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
382
409
|
return null;
|
|
383
410
|
}
|
|
384
411
|
}
|
|
@@ -390,7 +417,10 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
390
417
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
391
418
|
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
392
419
|
try {
|
|
393
|
-
const res = await fetch(jwksUri,
|
|
420
|
+
const res = await fetch(jwksUri, {
|
|
421
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
422
|
+
...options.forceRefresh ? { cache: "no-cache" } : {}
|
|
423
|
+
});
|
|
394
424
|
if (!res.ok) return null;
|
|
395
425
|
const jwks = await res.json();
|
|
396
426
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -437,7 +467,7 @@ async function computeAtHash(accessToken) {
|
|
|
437
467
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
438
468
|
}
|
|
439
469
|
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
440
|
-
const { rejectHs256WhenRs256Available = true } = options;
|
|
470
|
+
const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
|
|
441
471
|
const header = decodeJwtHeader(idToken);
|
|
442
472
|
if (!header) {
|
|
443
473
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -527,10 +557,21 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
527
557
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
528
558
|
}
|
|
529
559
|
}
|
|
560
|
+
if (expectedNonce !== void 0) {
|
|
561
|
+
const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
|
|
562
|
+
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
563
|
+
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
564
|
+
}
|
|
565
|
+
}
|
|
530
566
|
return { sub: payload.sub };
|
|
531
567
|
}
|
|
532
568
|
|
|
533
569
|
// src/core/SsoClient.ts
|
|
570
|
+
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
571
|
+
"login_required",
|
|
572
|
+
"consent_required",
|
|
573
|
+
"interaction_required"
|
|
574
|
+
]);
|
|
534
575
|
var _SsoClient = class _SsoClient {
|
|
535
576
|
constructor(config) {
|
|
536
577
|
this._discovery = null;
|
|
@@ -624,17 +665,27 @@ var _SsoClient = class _SsoClient {
|
|
|
624
665
|
*
|
|
625
666
|
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
626
667
|
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
668
|
+
* @param options.prompt - OIDC prompt 参数。传 "none" 为静默探测:
|
|
669
|
+
* 额外在存储中记录探测标记(该次请求的 state),回调无会话时
|
|
670
|
+
* handleCallback() 返回 null 而不抛错。
|
|
627
671
|
*
|
|
628
672
|
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
629
673
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
630
674
|
* 同一次登录只使用其中一个入口。
|
|
631
675
|
*/
|
|
632
|
-
async login(returnUrl) {
|
|
676
|
+
async login(returnUrl, options = {}) {
|
|
633
677
|
const verifier = generateCodeVerifier();
|
|
634
678
|
const challenge = await generateCodeChallenge(verifier);
|
|
635
679
|
const state = generateState();
|
|
680
|
+
const nonce = generateState();
|
|
636
681
|
savePkceVerifier(this.config.clientId, verifier);
|
|
637
682
|
saveOAuthState(state, this.config.clientId);
|
|
683
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
684
|
+
if (options.prompt === "none") {
|
|
685
|
+
saveSilentProbe(state, this.config.clientId);
|
|
686
|
+
} else {
|
|
687
|
+
removeSilentProbe(this.config.clientId);
|
|
688
|
+
}
|
|
638
689
|
if (returnUrl) {
|
|
639
690
|
this._saveReturnUrlIfTrusted(returnUrl);
|
|
640
691
|
}
|
|
@@ -645,8 +696,12 @@ var _SsoClient = class _SsoClient {
|
|
|
645
696
|
params.set("redirect_uri", this.config.redirectUri);
|
|
646
697
|
params.set("scope", this.config.scopes || "openid profile");
|
|
647
698
|
params.set("state", state);
|
|
699
|
+
params.set("nonce", nonce);
|
|
648
700
|
params.set("code_challenge", challenge);
|
|
649
701
|
params.set("code_challenge_method", "S256");
|
|
702
|
+
if (options.prompt) {
|
|
703
|
+
params.set("prompt", options.prompt);
|
|
704
|
+
}
|
|
650
705
|
window.location.href = `${authorizeEndpoint}?${params.toString()}`;
|
|
651
706
|
}
|
|
652
707
|
/**
|
|
@@ -658,12 +713,19 @@ var _SsoClient = class _SsoClient {
|
|
|
658
713
|
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
659
714
|
* 同一次登录只使用其中一个入口。
|
|
660
715
|
*/
|
|
661
|
-
async getLoginUrl(returnUrl) {
|
|
716
|
+
async getLoginUrl(returnUrl, options = {}) {
|
|
662
717
|
const verifier = generateCodeVerifier();
|
|
663
718
|
const challenge = await generateCodeChallenge(verifier);
|
|
664
719
|
const state = generateState();
|
|
720
|
+
const nonce = generateState();
|
|
665
721
|
savePkceVerifier(this.config.clientId, verifier);
|
|
666
722
|
saveOAuthState(state, this.config.clientId);
|
|
723
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
724
|
+
if (options.prompt === "none") {
|
|
725
|
+
saveSilentProbe(state, this.config.clientId);
|
|
726
|
+
} else {
|
|
727
|
+
removeSilentProbe(this.config.clientId);
|
|
728
|
+
}
|
|
667
729
|
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
668
730
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
669
731
|
const params = new URLSearchParams();
|
|
@@ -672,8 +734,12 @@ var _SsoClient = class _SsoClient {
|
|
|
672
734
|
params.set("redirect_uri", this.config.redirectUri);
|
|
673
735
|
params.set("scope", this.config.scopes || "openid profile");
|
|
674
736
|
params.set("state", state);
|
|
737
|
+
params.set("nonce", nonce);
|
|
675
738
|
params.set("code_challenge", challenge);
|
|
676
739
|
params.set("code_challenge_method", "S256");
|
|
740
|
+
if (options.prompt) {
|
|
741
|
+
params.set("prompt", options.prompt);
|
|
742
|
+
}
|
|
677
743
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
678
744
|
}
|
|
679
745
|
/**
|
|
@@ -713,9 +779,11 @@ var _SsoClient = class _SsoClient {
|
|
|
713
779
|
const verifier = generateCodeVerifier();
|
|
714
780
|
const challenge = await generateCodeChallenge(verifier);
|
|
715
781
|
const state = generateState();
|
|
782
|
+
const nonce = generateState();
|
|
716
783
|
const popupNonce = generateState();
|
|
717
784
|
savePkceVerifier(this.config.clientId, verifier);
|
|
718
785
|
saveOAuthState(state, this.config.clientId);
|
|
786
|
+
saveOAuthNonce(nonce, this.config.clientId);
|
|
719
787
|
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
720
788
|
if (options.returnUrl) {
|
|
721
789
|
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
@@ -727,6 +795,7 @@ var _SsoClient = class _SsoClient {
|
|
|
727
795
|
params.set("redirect_uri", this.config.redirectUri);
|
|
728
796
|
params.set("scope", this.config.scopes || "openid profile");
|
|
729
797
|
params.set("state", state);
|
|
798
|
+
params.set("nonce", nonce);
|
|
730
799
|
params.set("code_challenge", challenge);
|
|
731
800
|
params.set("code_challenge_method", "S256");
|
|
732
801
|
params.set("popup_nonce", popupNonce);
|
|
@@ -751,6 +820,7 @@ var _SsoClient = class _SsoClient {
|
|
|
751
820
|
if (!popup) {
|
|
752
821
|
removePkceVerifier(this.config.clientId);
|
|
753
822
|
removeOAuthState(this.config.clientId);
|
|
823
|
+
removeOAuthNonce(this.config.clientId);
|
|
754
824
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
755
825
|
throw new SsoError(
|
|
756
826
|
"popup_blocked",
|
|
@@ -770,12 +840,27 @@ var _SsoClient = class _SsoClient {
|
|
|
770
840
|
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
771
841
|
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
772
842
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
843
|
+
try {
|
|
844
|
+
event.source?.postMessage(
|
|
845
|
+
{ type: "nihplod_sso_popup_ack", nonce: savedNonce },
|
|
846
|
+
event.origin
|
|
847
|
+
);
|
|
848
|
+
} catch {
|
|
849
|
+
}
|
|
773
850
|
completed = true;
|
|
774
851
|
cleanup();
|
|
775
852
|
if (popup && !popup.closed) {
|
|
776
853
|
popup.close();
|
|
777
854
|
}
|
|
778
|
-
this.handleCallback(event.data.callbackUrl).then(
|
|
855
|
+
this.handleCallback(event.data.callbackUrl).then((tokenData) => {
|
|
856
|
+
if (tokenData === null) {
|
|
857
|
+
reject(
|
|
858
|
+
new SsoError("not_authenticated", "SSO \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF08\u9759\u9ED8\u63A2\u6D4B\u65E0\u4F1A\u8BDD\uFF09")
|
|
859
|
+
);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
resolve(tokenData);
|
|
863
|
+
}).catch(reject);
|
|
779
864
|
};
|
|
780
865
|
const pollTimer = setInterval(() => {
|
|
781
866
|
if (popup.closed) {
|
|
@@ -801,6 +886,7 @@ var _SsoClient = class _SsoClient {
|
|
|
801
886
|
} catch (err) {
|
|
802
887
|
removePkceVerifier(this.config.clientId);
|
|
803
888
|
removeOAuthState(this.config.clientId);
|
|
889
|
+
removeOAuthNonce(this.config.clientId);
|
|
804
890
|
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
805
891
|
throw err;
|
|
806
892
|
}
|
|
@@ -812,15 +898,29 @@ var _SsoClient = class _SsoClient {
|
|
|
812
898
|
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
813
899
|
*
|
|
814
900
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
815
|
-
* @returns TokenData
|
|
901
|
+
* @returns TokenData;prompt=none 静默探测且无 SSO 会话时返回 null
|
|
902
|
+
* (IdP 回跳 login_required / consent_required / interaction_required
|
|
903
|
+
* 且回调 state 与探测标记匹配),此时临时数据已清理,调用方按"未登录"处理即可
|
|
816
904
|
*/
|
|
817
905
|
async handleCallback(callbackUrl) {
|
|
818
906
|
const url = new URL(callbackUrl);
|
|
819
907
|
const params = url.searchParams;
|
|
820
908
|
const error = params.get("error");
|
|
821
909
|
if (error) {
|
|
910
|
+
const returnedErrorState = params.get("state");
|
|
911
|
+
const probeState = getSilentProbe(this.config.clientId);
|
|
912
|
+
const savedStateForProbe = getOAuthState(this.config.clientId);
|
|
913
|
+
if (SILENT_PROBE_ERRORS.has(error) && probeState && savedStateForProbe && timingSafeEqualString(savedStateForProbe, returnedErrorState ?? "") && timingSafeEqualString(probeState, returnedErrorState ?? "")) {
|
|
914
|
+
removeOAuthState(this.config.clientId);
|
|
915
|
+
removeOAuthNonce(this.config.clientId);
|
|
916
|
+
removePkceVerifier(this.config.clientId);
|
|
917
|
+
removeSilentProbe(this.config.clientId);
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
822
920
|
removeOAuthState(this.config.clientId);
|
|
921
|
+
removeOAuthNonce(this.config.clientId);
|
|
823
922
|
removePkceVerifier(this.config.clientId);
|
|
923
|
+
removeSilentProbe(this.config.clientId);
|
|
824
924
|
const desc = params.get("error_description") || error;
|
|
825
925
|
throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
|
|
826
926
|
}
|
|
@@ -828,18 +928,23 @@ var _SsoClient = class _SsoClient {
|
|
|
828
928
|
const returnedState = params.get("state");
|
|
829
929
|
if (!code) {
|
|
830
930
|
removeOAuthState(this.config.clientId);
|
|
931
|
+
removeOAuthNonce(this.config.clientId);
|
|
831
932
|
removePkceVerifier(this.config.clientId);
|
|
933
|
+
removeSilentProbe(this.config.clientId);
|
|
832
934
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
833
935
|
}
|
|
834
936
|
const savedState = getOAuthState(this.config.clientId);
|
|
835
937
|
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
836
938
|
removeOAuthState(this.config.clientId);
|
|
939
|
+
removeOAuthNonce(this.config.clientId);
|
|
837
940
|
removePkceVerifier(this.config.clientId);
|
|
941
|
+
removeSilentProbe(this.config.clientId);
|
|
838
942
|
throw new SsoError(
|
|
839
943
|
"state_mismatch",
|
|
840
944
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
841
945
|
);
|
|
842
946
|
}
|
|
947
|
+
const expectedNonce = getOAuthNonce(this.config.clientId) ?? void 0;
|
|
843
948
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
844
949
|
if (!verifier) {
|
|
845
950
|
throw new SsoError(
|
|
@@ -886,7 +991,8 @@ var _SsoClient = class _SsoClient {
|
|
|
886
991
|
data.id_token,
|
|
887
992
|
data.access_token,
|
|
888
993
|
this.config.ssoBaseUrl,
|
|
889
|
-
this.config.clientId
|
|
994
|
+
this.config.clientId,
|
|
995
|
+
{ expectedNonce }
|
|
890
996
|
);
|
|
891
997
|
} catch (err) {
|
|
892
998
|
removeTokenData(this.config.clientId);
|
|
@@ -894,7 +1000,9 @@ var _SsoClient = class _SsoClient {
|
|
|
894
1000
|
}
|
|
895
1001
|
}
|
|
896
1002
|
removeOAuthState(this.config.clientId);
|
|
1003
|
+
removeOAuthNonce(this.config.clientId);
|
|
897
1004
|
removePkceVerifier(this.config.clientId);
|
|
1005
|
+
removeSilentProbe(this.config.clientId);
|
|
898
1006
|
const now = Date.now();
|
|
899
1007
|
const tokenData = {
|
|
900
1008
|
access_token: data.access_token,
|