@nihplod/sso-sdk 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -9
- package/dist/index.d.mts +27 -3
- package/dist/index.d.ts +27 -3
- package/dist/index.js +169 -38
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +169 -38
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +50 -0
- package/dist/next/index.d.ts +50 -0
- package/dist/next/index.js +304 -95
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +304 -95
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +25 -1
- package/dist/react/index.d.ts +25 -1
- package/dist/react/index.js +329 -71
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +329 -71
- package/dist/react/index.mjs.map +1 -1
- package/package.json +9 -3
package/dist/react/index.js
CHANGED
|
@@ -135,6 +135,9 @@ function createMemoryStorageAdapter() {
|
|
|
135
135
|
},
|
|
136
136
|
remove(key) {
|
|
137
137
|
store.delete(key);
|
|
138
|
+
},
|
|
139
|
+
keys() {
|
|
140
|
+
return [...store.keys()];
|
|
138
141
|
}
|
|
139
142
|
};
|
|
140
143
|
}
|
|
@@ -143,26 +146,44 @@ function createSessionStorageAdapter() {
|
|
|
143
146
|
const fallback = /* @__PURE__ */ new Map();
|
|
144
147
|
return {
|
|
145
148
|
get(key) {
|
|
146
|
-
|
|
147
|
-
|
|
149
|
+
try {
|
|
150
|
+
if (typeof sessionStorage !== "undefined") {
|
|
151
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
148
154
|
}
|
|
149
155
|
return fallback.get(key) ?? null;
|
|
150
156
|
},
|
|
151
157
|
set(key, value) {
|
|
152
|
-
if (typeof sessionStorage === "undefined") {
|
|
153
|
-
fallback.set(key, value);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
158
|
try {
|
|
157
|
-
|
|
159
|
+
if (typeof sessionStorage !== "undefined") {
|
|
160
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
158
163
|
} catch {
|
|
159
|
-
fallback.set(key, value);
|
|
160
164
|
}
|
|
165
|
+
fallback.set(key, value);
|
|
161
166
|
},
|
|
162
167
|
remove(key) {
|
|
163
168
|
fallback.delete(key);
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
try {
|
|
170
|
+
if (typeof sessionStorage === "undefined") return;
|
|
171
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
172
|
+
} catch {
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
keys() {
|
|
176
|
+
const result = new Set(fallback.keys());
|
|
177
|
+
try {
|
|
178
|
+
if (typeof sessionStorage !== "undefined") {
|
|
179
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
180
|
+
const key = sessionStorage.key(i);
|
|
181
|
+
if (key?.startsWith(STORAGE_PREFIX)) result.add(key.slice(STORAGE_PREFIX.length));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
return [...result];
|
|
166
187
|
}
|
|
167
188
|
};
|
|
168
189
|
}
|
|
@@ -255,32 +276,58 @@ function clearAllSsoData(clientId) {
|
|
|
255
276
|
removeReturnUrl();
|
|
256
277
|
removeLogoutState();
|
|
257
278
|
removeSilentProbe();
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
typeof sessionStorage !== "undefined"
|
|
261
|
-
|
|
262
|
-
|
|
279
|
+
const stores = [];
|
|
280
|
+
try {
|
|
281
|
+
if (typeof sessionStorage !== "undefined") stores.push(sessionStorage);
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
if (typeof localStorage !== "undefined") stores.push(localStorage);
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
const abstractKeys = /* @__PURE__ */ new Set();
|
|
263
289
|
for (const store of stores) {
|
|
264
|
-
if (!store) continue;
|
|
265
290
|
const keys = [];
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
291
|
+
try {
|
|
292
|
+
for (let i = 0; i < store.length; i++) {
|
|
293
|
+
const key = store.key(i);
|
|
294
|
+
if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
continue;
|
|
269
298
|
}
|
|
270
299
|
for (const key of keys) {
|
|
271
|
-
|
|
272
|
-
|
|
300
|
+
abstractKeys.add(key.slice(STORAGE_PREFIX.length));
|
|
301
|
+
try {
|
|
302
|
+
store.removeItem(key);
|
|
303
|
+
} catch {
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (const store of [_transient, _storage]) {
|
|
308
|
+
try {
|
|
309
|
+
for (const key of store.keys?.() ?? []) abstractKeys.add(key);
|
|
310
|
+
} catch {
|
|
273
311
|
}
|
|
274
312
|
}
|
|
313
|
+
for (const key of abstractKeys) {
|
|
314
|
+
_transient.remove(key);
|
|
315
|
+
_storage.remove(key);
|
|
316
|
+
}
|
|
275
317
|
}
|
|
276
318
|
|
|
277
319
|
// src/core/security.ts
|
|
320
|
+
var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
|
|
278
321
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
279
322
|
if (!url) return false;
|
|
280
|
-
if (
|
|
281
|
-
if (url.startsWith("
|
|
323
|
+
if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
|
|
324
|
+
if (url.startsWith("//")) return false;
|
|
325
|
+
if (url.startsWith("/")) return true;
|
|
282
326
|
try {
|
|
283
|
-
|
|
327
|
+
const parsed = new URL(url);
|
|
328
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
329
|
+
if (parsed.username || parsed.password) return false;
|
|
330
|
+
return parsed.origin === currentOrigin;
|
|
284
331
|
} catch {
|
|
285
332
|
return false;
|
|
286
333
|
}
|
|
@@ -514,6 +561,34 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
514
561
|
}
|
|
515
562
|
|
|
516
563
|
// src/core/SsoClient.ts
|
|
564
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
565
|
+
var REVOKE_TIMEOUT_MS = 3e3;
|
|
566
|
+
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
567
|
+
const controller = new AbortController();
|
|
568
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
569
|
+
try {
|
|
570
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
571
|
+
} finally {
|
|
572
|
+
clearTimeout(timer);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function assertValidTokenResponse(data, requireRefreshToken) {
|
|
576
|
+
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
577
|
+
throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
|
|
578
|
+
}
|
|
579
|
+
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
|
|
580
|
+
throw new SsoError(
|
|
581
|
+
"token_request_failed",
|
|
582
|
+
`Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
|
|
586
|
+
throw new SsoError(
|
|
587
|
+
"token_request_failed",
|
|
588
|
+
"Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
517
592
|
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
518
593
|
"login_required",
|
|
519
594
|
"consent_required",
|
|
@@ -532,6 +607,19 @@ var _SsoClient = class _SsoClient {
|
|
|
532
607
|
this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
|
|
533
608
|
}
|
|
534
609
|
// ============================================
|
|
610
|
+
// 调试日志
|
|
611
|
+
// ============================================
|
|
612
|
+
/**
|
|
613
|
+
* 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
|
|
614
|
+
* 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
|
|
615
|
+
* 不会输出 token/授权码等敏感值。
|
|
616
|
+
*/
|
|
617
|
+
debugLog(...args) {
|
|
618
|
+
if (this.config.debug) {
|
|
619
|
+
console.warn("[SSO SDK]", ...args);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// ============================================
|
|
535
623
|
// 内部方法
|
|
536
624
|
// ============================================
|
|
537
625
|
/**
|
|
@@ -911,7 +999,7 @@ var _SsoClient = class _SsoClient {
|
|
|
911
999
|
}
|
|
912
1000
|
let res;
|
|
913
1001
|
try {
|
|
914
|
-
res = await
|
|
1002
|
+
res = await fetchWithTimeout(tokenEndpoint, {
|
|
915
1003
|
method: "POST",
|
|
916
1004
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
917
1005
|
body: body.toString()
|
|
@@ -932,6 +1020,15 @@ var _SsoClient = class _SsoClient {
|
|
|
932
1020
|
);
|
|
933
1021
|
}
|
|
934
1022
|
const data = await res.json();
|
|
1023
|
+
assertValidTokenResponse(data, true);
|
|
1024
|
+
const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
|
|
1025
|
+
if (requestedScopes.includes("openid") && !data.id_token) {
|
|
1026
|
+
removeTokenData(this.config.clientId);
|
|
1027
|
+
throw new SsoError(
|
|
1028
|
+
"id_token_invalid",
|
|
1029
|
+
"Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
935
1032
|
if (data.id_token) {
|
|
936
1033
|
try {
|
|
937
1034
|
await validateIdToken(
|
|
@@ -961,6 +1058,7 @@ var _SsoClient = class _SsoClient {
|
|
|
961
1058
|
expires_at: now + data.expires_in * 1e3
|
|
962
1059
|
};
|
|
963
1060
|
saveTokenData(tokenData, this.config.clientId);
|
|
1061
|
+
this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
|
|
964
1062
|
return tokenData;
|
|
965
1063
|
}
|
|
966
1064
|
/**
|
|
@@ -996,7 +1094,7 @@ var _SsoClient = class _SsoClient {
|
|
|
996
1094
|
let lastErr;
|
|
997
1095
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
998
1096
|
try {
|
|
999
|
-
res = await
|
|
1097
|
+
res = await fetchWithTimeout(tokenEndpoint, {
|
|
1000
1098
|
method: "POST",
|
|
1001
1099
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1002
1100
|
body: body.toString()
|
|
@@ -1018,15 +1116,20 @@ var _SsoClient = class _SsoClient {
|
|
|
1018
1116
|
} catch {
|
|
1019
1117
|
}
|
|
1020
1118
|
const errorCode = errData.error || "";
|
|
1021
|
-
if (errorCode === "invalid_grant"
|
|
1119
|
+
if (errorCode === "invalid_grant") {
|
|
1022
1120
|
removeTokenData(this.config.clientId);
|
|
1023
1121
|
}
|
|
1122
|
+
this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
|
|
1123
|
+
if (!errorCode && res.status === 401) {
|
|
1124
|
+
throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
|
|
1125
|
+
}
|
|
1024
1126
|
throw new SsoError(
|
|
1025
1127
|
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
1026
1128
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
1027
1129
|
);
|
|
1028
1130
|
}
|
|
1029
1131
|
const data = await res.json();
|
|
1132
|
+
assertValidTokenResponse(data, false);
|
|
1030
1133
|
if (data.id_token) {
|
|
1031
1134
|
try {
|
|
1032
1135
|
await validateIdToken(
|
|
@@ -1070,7 +1173,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1070
1173
|
const userinfoEndpoint = await this._getUserinfoEndpoint();
|
|
1071
1174
|
let res;
|
|
1072
1175
|
try {
|
|
1073
|
-
res = await
|
|
1176
|
+
res = await fetchWithTimeout(userinfoEndpoint, {
|
|
1074
1177
|
headers: {
|
|
1075
1178
|
Authorization: `Bearer ${tokenData.access_token}`
|
|
1076
1179
|
}
|
|
@@ -1127,6 +1230,10 @@ var _SsoClient = class _SsoClient {
|
|
|
1127
1230
|
const refreshToken = tokenData?.refresh_token;
|
|
1128
1231
|
const idTokenHint = tokenData?.id_token;
|
|
1129
1232
|
clearAllSsoData(this.config.clientId);
|
|
1233
|
+
this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
|
|
1234
|
+
clientId: this.config.clientId,
|
|
1235
|
+
redirectToSso
|
|
1236
|
+
});
|
|
1130
1237
|
if (refreshToken && this.config.clientId) {
|
|
1131
1238
|
try {
|
|
1132
1239
|
const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
|
|
@@ -1139,11 +1246,15 @@ var _SsoClient = class _SsoClient {
|
|
|
1139
1246
|
if (this.config.clientSecret) {
|
|
1140
1247
|
revokeBody.set("client_secret", this.config.clientSecret);
|
|
1141
1248
|
}
|
|
1142
|
-
await
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1249
|
+
await fetchWithTimeout(
|
|
1250
|
+
revokeUrl,
|
|
1251
|
+
{
|
|
1252
|
+
method: "POST",
|
|
1253
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1254
|
+
body: revokeBody.toString()
|
|
1255
|
+
},
|
|
1256
|
+
REVOKE_TIMEOUT_MS
|
|
1257
|
+
);
|
|
1147
1258
|
} catch {
|
|
1148
1259
|
}
|
|
1149
1260
|
}
|
|
@@ -1213,6 +1324,17 @@ var SsoClient = _SsoClient;
|
|
|
1213
1324
|
var SsoContext = (0, import_react.createContext)(null);
|
|
1214
1325
|
var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
1215
1326
|
var LOCK_TTL_MS = 5e3;
|
|
1327
|
+
var CHANNEL_PREFIX = "nihplod_sso_events:";
|
|
1328
|
+
var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1329
|
+
function broadcastSsoEvent(clientId, type) {
|
|
1330
|
+
if (typeof BroadcastChannel === "undefined") return;
|
|
1331
|
+
try {
|
|
1332
|
+
const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
|
|
1333
|
+
channel.postMessage({ type, sourceTabId: TAB_ID });
|
|
1334
|
+
channel.close();
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1216
1338
|
var ownedLocks = /* @__PURE__ */ new Map();
|
|
1217
1339
|
function lockKey(clientId) {
|
|
1218
1340
|
return REFRESH_LOCK_PREFIX + clientId;
|
|
@@ -1271,21 +1393,37 @@ function SsoProvider({
|
|
|
1271
1393
|
config,
|
|
1272
1394
|
children,
|
|
1273
1395
|
refreshThreshold = 60,
|
|
1274
|
-
onTokenRefreshed
|
|
1396
|
+
onTokenRefreshed,
|
|
1397
|
+
onSessionExpired
|
|
1275
1398
|
}) {
|
|
1276
1399
|
const [user, setUser] = (0, import_react.useState)(null);
|
|
1277
1400
|
const [isLoading, setIsLoading] = (0, import_react.useState)(true);
|
|
1278
1401
|
const [isAuthenticated, setIsAuthenticated] = (0, import_react.useState)(false);
|
|
1279
1402
|
const [error, setError] = (0, import_react.useState)(null);
|
|
1403
|
+
const [sessionExpired, setSessionExpired] = (0, import_react.useState)(false);
|
|
1280
1404
|
const [client] = (0, import_react.useState)(() => new SsoClient(config));
|
|
1281
1405
|
const refreshTimerRef = (0, import_react.useRef)(null);
|
|
1282
1406
|
const loadedRef = (0, import_react.useRef)(false);
|
|
1407
|
+
const onSessionExpiredRef = (0, import_react.useRef)(onSessionExpired);
|
|
1408
|
+
(0, import_react.useEffect)(() => {
|
|
1409
|
+
onSessionExpiredRef.current = onSessionExpired;
|
|
1410
|
+
});
|
|
1411
|
+
const sessionExpiredNotifiedRef = (0, import_react.useRef)(false);
|
|
1412
|
+
const refreshFailureCountRef = (0, import_react.useRef)(0);
|
|
1413
|
+
const MAX_REFRESH_FAILURES = 3;
|
|
1414
|
+
const markSessionExpired = (0, import_react.useCallback)((error2) => {
|
|
1415
|
+
setError(error2);
|
|
1416
|
+
setSessionExpired(true);
|
|
1417
|
+
if (!sessionExpiredNotifiedRef.current) {
|
|
1418
|
+
sessionExpiredNotifiedRef.current = true;
|
|
1419
|
+
onSessionExpiredRef.current?.(error2);
|
|
1420
|
+
}
|
|
1421
|
+
}, []);
|
|
1283
1422
|
const loadUser = (0, import_react.useCallback)(async () => {
|
|
1284
1423
|
const tokenData = getTokenData(client.config.clientId);
|
|
1285
1424
|
if (!tokenData) {
|
|
1286
1425
|
setUser(null);
|
|
1287
1426
|
setIsAuthenticated(false);
|
|
1288
|
-
setError(null);
|
|
1289
1427
|
setIsLoading(false);
|
|
1290
1428
|
return;
|
|
1291
1429
|
}
|
|
@@ -1294,19 +1432,22 @@ function SsoProvider({
|
|
|
1294
1432
|
setUser(u);
|
|
1295
1433
|
setIsAuthenticated(true);
|
|
1296
1434
|
setError(null);
|
|
1435
|
+
setSessionExpired(false);
|
|
1436
|
+
sessionExpiredNotifiedRef.current = false;
|
|
1437
|
+
refreshFailureCountRef.current = 0;
|
|
1297
1438
|
} catch (err) {
|
|
1298
1439
|
setUser(null);
|
|
1299
1440
|
setIsAuthenticated(false);
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
)
|
|
1303
|
-
if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
|
|
1441
|
+
const errorObj = err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err));
|
|
1442
|
+
setError(errorObj);
|
|
1443
|
+
if (errorObj.code === "not_authenticated" || errorObj.code === "session_expired") {
|
|
1304
1444
|
removeTokenData(client.config.clientId);
|
|
1445
|
+
markSessionExpired(errorObj);
|
|
1305
1446
|
}
|
|
1306
1447
|
} finally {
|
|
1307
1448
|
setIsLoading(false);
|
|
1308
1449
|
}
|
|
1309
|
-
}, [client]);
|
|
1450
|
+
}, [client, markSessionExpired]);
|
|
1310
1451
|
(0, import_react.useEffect)(() => {
|
|
1311
1452
|
if (loadedRef.current) return;
|
|
1312
1453
|
loadedRef.current = true;
|
|
@@ -1323,13 +1464,28 @@ function SsoProvider({
|
|
|
1323
1464
|
const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
|
|
1324
1465
|
try {
|
|
1325
1466
|
const td = await client.refreshToken();
|
|
1467
|
+
refreshFailureCountRef.current = 0;
|
|
1326
1468
|
onTokenRefreshed?.(td.access_token);
|
|
1469
|
+
broadcastSsoEvent(client.config.clientId, "token");
|
|
1327
1470
|
loadUser();
|
|
1328
|
-
} catch {
|
|
1471
|
+
} catch (err) {
|
|
1472
|
+
if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
|
|
1473
|
+
removeTokenData(client.config.clientId);
|
|
1474
|
+
markSessionExpired(err);
|
|
1475
|
+
} else {
|
|
1476
|
+
refreshFailureCountRef.current += 1;
|
|
1477
|
+
if (refreshFailureCountRef.current >= MAX_REFRESH_FAILURES) {
|
|
1478
|
+
markSessionExpired(
|
|
1479
|
+
err instanceof SsoError ? err : new SsoError("session_expired", "\u591A\u6B21\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55")
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1329
1483
|
setTimeout(() => loadUser(), 500);
|
|
1330
1484
|
}
|
|
1485
|
+
}).finally(() => {
|
|
1486
|
+
if (active) scheduleNextRefresh(5e3);
|
|
1331
1487
|
});
|
|
1332
|
-
const scheduleNextRefresh = () => {
|
|
1488
|
+
const scheduleNextRefresh = (minDelayMs = 0) => {
|
|
1333
1489
|
if (!active) return;
|
|
1334
1490
|
if (refreshTimerRef.current) {
|
|
1335
1491
|
clearTimeout(refreshTimerRef.current);
|
|
@@ -1338,26 +1494,27 @@ function SsoProvider({
|
|
|
1338
1494
|
const tokenData = getTokenData(client.config.clientId);
|
|
1339
1495
|
if (!tokenData) return;
|
|
1340
1496
|
const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
|
|
1341
|
-
if (remainingSec <= 0) {
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1497
|
+
if (remainingSec <= 0 || remainingSec <= refreshThreshold) {
|
|
1498
|
+
refreshTimerRef.current = setTimeout(
|
|
1499
|
+
() => {
|
|
1500
|
+
if (active) void attemptRefresh();
|
|
1501
|
+
},
|
|
1502
|
+
Math.max(minDelayMs, 1e3)
|
|
1503
|
+
);
|
|
1347
1504
|
return;
|
|
1348
1505
|
}
|
|
1349
|
-
const delayMs = (remainingSec - refreshThreshold) * 1e3;
|
|
1506
|
+
const delayMs = Math.max((remainingSec - refreshThreshold) * 1e3, minDelayMs, 1e3);
|
|
1350
1507
|
refreshTimerRef.current = setTimeout(() => {
|
|
1351
1508
|
if (!active) return;
|
|
1352
1509
|
const td = getTokenData(client.config.clientId);
|
|
1353
1510
|
if (!td) return;
|
|
1354
1511
|
const secLeft = (td.expires_at - Date.now()) / 1e3;
|
|
1355
1512
|
if (secLeft <= refreshThreshold) {
|
|
1356
|
-
void attemptRefresh()
|
|
1357
|
-
|
|
1358
|
-
|
|
1513
|
+
void attemptRefresh();
|
|
1514
|
+
} else {
|
|
1515
|
+
scheduleNextRefresh();
|
|
1359
1516
|
}
|
|
1360
|
-
},
|
|
1517
|
+
}, delayMs);
|
|
1361
1518
|
};
|
|
1362
1519
|
scheduleNextRefresh();
|
|
1363
1520
|
return () => {
|
|
@@ -1377,6 +1534,26 @@ function SsoProvider({
|
|
|
1377
1534
|
window.addEventListener("storage", handleStorageChange);
|
|
1378
1535
|
return () => window.removeEventListener("storage", handleStorageChange);
|
|
1379
1536
|
}, [loadUser]);
|
|
1537
|
+
(0, import_react.useEffect)(() => {
|
|
1538
|
+
if (typeof BroadcastChannel === "undefined") return;
|
|
1539
|
+
const channel = new BroadcastChannel(CHANNEL_PREFIX + client.config.clientId);
|
|
1540
|
+
channel.onmessage = (event) => {
|
|
1541
|
+
const data = event.data;
|
|
1542
|
+
if (!data || data.sourceTabId === TAB_ID) return;
|
|
1543
|
+
if (data.type === "logout") {
|
|
1544
|
+
removeTokenData(client.config.clientId);
|
|
1545
|
+
setUser(null);
|
|
1546
|
+
setIsAuthenticated(false);
|
|
1547
|
+
setError(null);
|
|
1548
|
+
setSessionExpired(false);
|
|
1549
|
+
sessionExpiredNotifiedRef.current = false;
|
|
1550
|
+
refreshFailureCountRef.current = 0;
|
|
1551
|
+
} else if (data.type === "token") {
|
|
1552
|
+
loadUser();
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
return () => channel.close();
|
|
1556
|
+
}, [client, loadUser]);
|
|
1380
1557
|
const login = (0, import_react.useCallback)(
|
|
1381
1558
|
async (returnUrl) => {
|
|
1382
1559
|
await client.login(returnUrl);
|
|
@@ -1393,10 +1570,17 @@ function SsoProvider({
|
|
|
1393
1570
|
);
|
|
1394
1571
|
const logout = (0, import_react.useCallback)(
|
|
1395
1572
|
async (redirectToSso = false) => {
|
|
1396
|
-
await client.logout(redirectToSso);
|
|
1397
1573
|
setUser(null);
|
|
1398
1574
|
setIsAuthenticated(false);
|
|
1399
1575
|
setError(null);
|
|
1576
|
+
setSessionExpired(false);
|
|
1577
|
+
sessionExpiredNotifiedRef.current = false;
|
|
1578
|
+
refreshFailureCountRef.current = 0;
|
|
1579
|
+
broadcastSsoEvent(client.config.clientId, "logout");
|
|
1580
|
+
try {
|
|
1581
|
+
await client.logout(redirectToSso);
|
|
1582
|
+
} catch {
|
|
1583
|
+
}
|
|
1400
1584
|
},
|
|
1401
1585
|
[client]
|
|
1402
1586
|
);
|
|
@@ -1416,6 +1600,7 @@ function SsoProvider({
|
|
|
1416
1600
|
logout,
|
|
1417
1601
|
refreshUser,
|
|
1418
1602
|
getAccessToken,
|
|
1603
|
+
sessionExpired,
|
|
1419
1604
|
client
|
|
1420
1605
|
};
|
|
1421
1606
|
return import_react.default.createElement(SsoContext.Provider, { value }, children);
|
|
@@ -1510,6 +1695,36 @@ function withAuth(Component) {
|
|
|
1510
1695
|
|
|
1511
1696
|
// src/react/CallbackPage.tsx
|
|
1512
1697
|
var import_react3 = __toESM(require("react"));
|
|
1698
|
+
var CALLBACK_ERROR_HINTS = {
|
|
1699
|
+
state_mismatch: "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1700
|
+
pkce_required: "\u767B\u5F55\u4F1A\u8BDD\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1701
|
+
user_denied_authorization: "\u4F60\u5DF2\u53D6\u6D88\u767B\u5F55",
|
|
1702
|
+
session_expired: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1703
|
+
authorization_code_expired: "\u767B\u5F55\u4FE1\u606F\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1704
|
+
authorization_code_used: "\u767B\u5F55\u4FE1\u606F\u5DF2\u88AB\u4F7F\u7528\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1705
|
+
token_request_failed: "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1706
|
+
userinfo_failed: "\u767B\u5F55\u4FE1\u606F\u83B7\u53D6\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
|
|
1707
|
+
not_authenticated: "\u8BF7\u5148\u767B\u5F55\u540E\u518D\u8BBF\u95EE",
|
|
1708
|
+
network_error: "\u7F51\u7EDC\u5F02\u5E38\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5",
|
|
1709
|
+
rate_limited: "\u64CD\u4F5C\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
1710
|
+
popup_blocked: "\u767B\u5F55\u7A97\u53E3\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5",
|
|
1711
|
+
popup_closed: "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1712
|
+
client_disabled: "\u8BE5\u5E94\u7528\u5DF2\u505C\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
|
|
1713
|
+
account_disabled: "\u8D26\u53F7\u6682\u65F6\u65E0\u6CD5\u4F7F\u7528\uFF0C\u8BF7\u8054\u7CFB\u5BA2\u670D",
|
|
1714
|
+
sso_server_error: "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
1715
|
+
invalid_config: "\u5E94\u7528\u914D\u7F6E\u6709\u8BEF\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
|
|
1716
|
+
no_refresh_token: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
|
|
1717
|
+
};
|
|
1718
|
+
function getCallbackErrorMessage(err) {
|
|
1719
|
+
if (err instanceof SsoError) {
|
|
1720
|
+
const hint = CALLBACK_ERROR_HINTS[err.code];
|
|
1721
|
+
if (hint) return hint;
|
|
1722
|
+
if (err.code.startsWith("id_token")) return "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
|
|
1723
|
+
if (/[\u4e00-\u9fff]/.test(err.description)) return err.description;
|
|
1724
|
+
return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
|
|
1725
|
+
}
|
|
1726
|
+
return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5";
|
|
1727
|
+
}
|
|
1513
1728
|
function DefaultCallbackError({ error }) {
|
|
1514
1729
|
return import_react3.default.createElement(
|
|
1515
1730
|
"div",
|
|
@@ -1520,18 +1735,54 @@ function DefaultCallbackError({ error }) {
|
|
|
1520
1735
|
alignItems: "center",
|
|
1521
1736
|
justifyContent: "center",
|
|
1522
1737
|
minHeight: "100vh",
|
|
1523
|
-
fontFamily: "system-ui, sans-serif"
|
|
1738
|
+
fontFamily: "system-ui, sans-serif",
|
|
1739
|
+
padding: "2rem",
|
|
1740
|
+
textAlign: "center"
|
|
1524
1741
|
}
|
|
1525
1742
|
},
|
|
1526
1743
|
import_react3.default.createElement(
|
|
1527
1744
|
"p",
|
|
1528
|
-
{ style: { color: "#dc2626", marginBottom: "
|
|
1745
|
+
{ style: { color: "#dc2626", marginBottom: "1.5rem" } },
|
|
1529
1746
|
error
|
|
1530
1747
|
),
|
|
1531
1748
|
import_react3.default.createElement(
|
|
1532
|
-
"
|
|
1533
|
-
{
|
|
1534
|
-
|
|
1749
|
+
"div",
|
|
1750
|
+
{ style: { display: "flex", gap: "0.75rem" } },
|
|
1751
|
+
import_react3.default.createElement(
|
|
1752
|
+
"a",
|
|
1753
|
+
{
|
|
1754
|
+
href: "/",
|
|
1755
|
+
style: {
|
|
1756
|
+
display: "inline-block",
|
|
1757
|
+
padding: "0.6rem 1.4rem",
|
|
1758
|
+
background: "#2c2c2c",
|
|
1759
|
+
color: "#fff",
|
|
1760
|
+
fontSize: "0.8125rem",
|
|
1761
|
+
textDecoration: "none"
|
|
1762
|
+
}
|
|
1763
|
+
},
|
|
1764
|
+
"\u91CD\u65B0\u767B\u5F55"
|
|
1765
|
+
),
|
|
1766
|
+
import_react3.default.createElement(
|
|
1767
|
+
"button",
|
|
1768
|
+
{
|
|
1769
|
+
type: "button",
|
|
1770
|
+
onClick: () => {
|
|
1771
|
+
if (window.history.length > 1) window.history.back();
|
|
1772
|
+
else window.location.href = "/";
|
|
1773
|
+
},
|
|
1774
|
+
style: {
|
|
1775
|
+
display: "inline-block",
|
|
1776
|
+
padding: "0.6rem 1.4rem",
|
|
1777
|
+
border: "1px solid rgba(44, 44, 44, 0.25)",
|
|
1778
|
+
background: "transparent",
|
|
1779
|
+
color: "#2c2c2c",
|
|
1780
|
+
fontSize: "0.8125rem",
|
|
1781
|
+
cursor: "pointer"
|
|
1782
|
+
}
|
|
1783
|
+
},
|
|
1784
|
+
"\u8FD4\u56DE\u4E0A\u4E00\u9875"
|
|
1785
|
+
)
|
|
1535
1786
|
)
|
|
1536
1787
|
);
|
|
1537
1788
|
}
|
|
@@ -1546,9 +1797,13 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1546
1797
|
onSuccessRef.current = onSuccess;
|
|
1547
1798
|
onErrorRef.current = onError;
|
|
1548
1799
|
});
|
|
1800
|
+
const callbackPromiseRef = (0, import_react3.useRef)(null);
|
|
1801
|
+
const postProcessedRef = (0, import_react3.useRef)(false);
|
|
1802
|
+
const errorHandledRef = (0, import_react3.useRef)(false);
|
|
1549
1803
|
(0, import_react3.useEffect)(() => {
|
|
1550
|
-
|
|
1551
|
-
|
|
1804
|
+
const popupNonce = new URL(window.location.href).searchParams.get("popup_nonce");
|
|
1805
|
+
if (window.opener && !window.opener.closed && popupNonce) {
|
|
1806
|
+
const nonce = popupNonce;
|
|
1552
1807
|
let targetOrigin = window.location.origin;
|
|
1553
1808
|
try {
|
|
1554
1809
|
targetOrigin = window.opener.location.origin;
|
|
@@ -1557,7 +1812,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1557
1812
|
const message = {
|
|
1558
1813
|
type: "nihplod_sso_popup_callback",
|
|
1559
1814
|
callbackUrl: window.location.href,
|
|
1560
|
-
nonce
|
|
1815
|
+
nonce
|
|
1561
1816
|
};
|
|
1562
1817
|
let acked = false;
|
|
1563
1818
|
const send = () => {
|
|
@@ -1572,9 +1827,10 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1572
1827
|
setAwaitingManualClose(true);
|
|
1573
1828
|
}, 1e4);
|
|
1574
1829
|
const handleAck = (event) => {
|
|
1830
|
+
if (event.source !== window.opener) return;
|
|
1575
1831
|
if (event.origin !== targetOrigin) return;
|
|
1576
1832
|
if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
|
|
1577
|
-
if (
|
|
1833
|
+
if (event.data.nonce !== nonce) return;
|
|
1578
1834
|
acked = true;
|
|
1579
1835
|
clearInterval(resendTimer);
|
|
1580
1836
|
clearTimeout(ackTimeout);
|
|
@@ -1590,8 +1846,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1590
1846
|
let cancelled = false;
|
|
1591
1847
|
async function handleCallback() {
|
|
1592
1848
|
try {
|
|
1593
|
-
|
|
1594
|
-
|
|
1849
|
+
if (!callbackPromiseRef.current) {
|
|
1850
|
+
callbackPromiseRef.current = client.handleCallback(window.location.href);
|
|
1851
|
+
}
|
|
1852
|
+
const tokenData = await callbackPromiseRef.current;
|
|
1853
|
+
if (cancelled || postProcessedRef.current) return;
|
|
1854
|
+
postProcessedRef.current = true;
|
|
1595
1855
|
if (tokenData === null) {
|
|
1596
1856
|
const clientId2 = client.config.clientId;
|
|
1597
1857
|
const probeReturnUrl = getReturnUrl(clientId2);
|
|
@@ -1612,14 +1872,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1612
1872
|
}
|
|
1613
1873
|
window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
|
|
1614
1874
|
} catch (err) {
|
|
1615
|
-
if (cancelled) return;
|
|
1875
|
+
if (cancelled || errorHandledRef.current) return;
|
|
1876
|
+
errorHandledRef.current = true;
|
|
1877
|
+
callbackPromiseRef.current = null;
|
|
1616
1878
|
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
1617
1879
|
onErrorRef.current?.(errorObj);
|
|
1618
|
-
|
|
1619
|
-
setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
|
|
1620
|
-
} else {
|
|
1621
|
-
setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
|
|
1622
|
-
}
|
|
1880
|
+
setError(getCallbackErrorMessage(errorObj));
|
|
1623
1881
|
setProcessing(false);
|
|
1624
1882
|
}
|
|
1625
1883
|
}
|