@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.mjs
CHANGED
|
@@ -101,6 +101,9 @@ function createMemoryStorageAdapter() {
|
|
|
101
101
|
},
|
|
102
102
|
remove(key) {
|
|
103
103
|
store.delete(key);
|
|
104
|
+
},
|
|
105
|
+
keys() {
|
|
106
|
+
return [...store.keys()];
|
|
104
107
|
}
|
|
105
108
|
};
|
|
106
109
|
}
|
|
@@ -109,26 +112,44 @@ function createSessionStorageAdapter() {
|
|
|
109
112
|
const fallback = /* @__PURE__ */ new Map();
|
|
110
113
|
return {
|
|
111
114
|
get(key) {
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
try {
|
|
116
|
+
if (typeof sessionStorage !== "undefined") {
|
|
117
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
114
120
|
}
|
|
115
121
|
return fallback.get(key) ?? null;
|
|
116
122
|
},
|
|
117
123
|
set(key, value) {
|
|
118
|
-
if (typeof sessionStorage === "undefined") {
|
|
119
|
-
fallback.set(key, value);
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
124
|
try {
|
|
123
|
-
|
|
125
|
+
if (typeof sessionStorage !== "undefined") {
|
|
126
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
124
129
|
} catch {
|
|
125
|
-
fallback.set(key, value);
|
|
126
130
|
}
|
|
131
|
+
fallback.set(key, value);
|
|
127
132
|
},
|
|
128
133
|
remove(key) {
|
|
129
134
|
fallback.delete(key);
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
try {
|
|
136
|
+
if (typeof sessionStorage === "undefined") return;
|
|
137
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
138
|
+
} catch {
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
keys() {
|
|
142
|
+
const result = new Set(fallback.keys());
|
|
143
|
+
try {
|
|
144
|
+
if (typeof sessionStorage !== "undefined") {
|
|
145
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
146
|
+
const key = sessionStorage.key(i);
|
|
147
|
+
if (key?.startsWith(STORAGE_PREFIX)) result.add(key.slice(STORAGE_PREFIX.length));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
}
|
|
152
|
+
return [...result];
|
|
132
153
|
}
|
|
133
154
|
};
|
|
134
155
|
}
|
|
@@ -221,32 +242,58 @@ function clearAllSsoData(clientId) {
|
|
|
221
242
|
removeReturnUrl();
|
|
222
243
|
removeLogoutState();
|
|
223
244
|
removeSilentProbe();
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
typeof sessionStorage !== "undefined"
|
|
227
|
-
|
|
228
|
-
|
|
245
|
+
const stores = [];
|
|
246
|
+
try {
|
|
247
|
+
if (typeof sessionStorage !== "undefined") stores.push(sessionStorage);
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
if (typeof localStorage !== "undefined") stores.push(localStorage);
|
|
252
|
+
} catch {
|
|
253
|
+
}
|
|
254
|
+
const abstractKeys = /* @__PURE__ */ new Set();
|
|
229
255
|
for (const store of stores) {
|
|
230
|
-
if (!store) continue;
|
|
231
256
|
const keys = [];
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
257
|
+
try {
|
|
258
|
+
for (let i = 0; i < store.length; i++) {
|
|
259
|
+
const key = store.key(i);
|
|
260
|
+
if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
continue;
|
|
235
264
|
}
|
|
236
265
|
for (const key of keys) {
|
|
237
|
-
|
|
238
|
-
|
|
266
|
+
abstractKeys.add(key.slice(STORAGE_PREFIX.length));
|
|
267
|
+
try {
|
|
268
|
+
store.removeItem(key);
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const store of [_transient, _storage]) {
|
|
274
|
+
try {
|
|
275
|
+
for (const key of store.keys?.() ?? []) abstractKeys.add(key);
|
|
276
|
+
} catch {
|
|
239
277
|
}
|
|
240
278
|
}
|
|
279
|
+
for (const key of abstractKeys) {
|
|
280
|
+
_transient.remove(key);
|
|
281
|
+
_storage.remove(key);
|
|
282
|
+
}
|
|
241
283
|
}
|
|
242
284
|
|
|
243
285
|
// src/core/security.ts
|
|
286
|
+
var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
|
|
244
287
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
245
288
|
if (!url) return false;
|
|
246
|
-
if (
|
|
247
|
-
if (url.startsWith("
|
|
289
|
+
if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
|
|
290
|
+
if (url.startsWith("//")) return false;
|
|
291
|
+
if (url.startsWith("/")) return true;
|
|
248
292
|
try {
|
|
249
|
-
|
|
293
|
+
const parsed = new URL(url);
|
|
294
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
295
|
+
if (parsed.username || parsed.password) return false;
|
|
296
|
+
return parsed.origin === currentOrigin;
|
|
250
297
|
} catch {
|
|
251
298
|
return false;
|
|
252
299
|
}
|
|
@@ -480,6 +527,34 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
480
527
|
}
|
|
481
528
|
|
|
482
529
|
// src/core/SsoClient.ts
|
|
530
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
531
|
+
var REVOKE_TIMEOUT_MS = 3e3;
|
|
532
|
+
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
533
|
+
const controller = new AbortController();
|
|
534
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
535
|
+
try {
|
|
536
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
537
|
+
} finally {
|
|
538
|
+
clearTimeout(timer);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function assertValidTokenResponse(data, requireRefreshToken) {
|
|
542
|
+
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
543
|
+
throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
|
|
544
|
+
}
|
|
545
|
+
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
|
|
546
|
+
throw new SsoError(
|
|
547
|
+
"token_request_failed",
|
|
548
|
+
`Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
|
|
552
|
+
throw new SsoError(
|
|
553
|
+
"token_request_failed",
|
|
554
|
+
"Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
483
558
|
var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
|
|
484
559
|
"login_required",
|
|
485
560
|
"consent_required",
|
|
@@ -498,6 +573,19 @@ var _SsoClient = class _SsoClient {
|
|
|
498
573
|
this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
|
|
499
574
|
}
|
|
500
575
|
// ============================================
|
|
576
|
+
// 调试日志
|
|
577
|
+
// ============================================
|
|
578
|
+
/**
|
|
579
|
+
* 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
|
|
580
|
+
* 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
|
|
581
|
+
* 不会输出 token/授权码等敏感值。
|
|
582
|
+
*/
|
|
583
|
+
debugLog(...args) {
|
|
584
|
+
if (this.config.debug) {
|
|
585
|
+
console.warn("[SSO SDK]", ...args);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// ============================================
|
|
501
589
|
// 内部方法
|
|
502
590
|
// ============================================
|
|
503
591
|
/**
|
|
@@ -877,7 +965,7 @@ var _SsoClient = class _SsoClient {
|
|
|
877
965
|
}
|
|
878
966
|
let res;
|
|
879
967
|
try {
|
|
880
|
-
res = await
|
|
968
|
+
res = await fetchWithTimeout(tokenEndpoint, {
|
|
881
969
|
method: "POST",
|
|
882
970
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
883
971
|
body: body.toString()
|
|
@@ -898,6 +986,15 @@ var _SsoClient = class _SsoClient {
|
|
|
898
986
|
);
|
|
899
987
|
}
|
|
900
988
|
const data = await res.json();
|
|
989
|
+
assertValidTokenResponse(data, true);
|
|
990
|
+
const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
|
|
991
|
+
if (requestedScopes.includes("openid") && !data.id_token) {
|
|
992
|
+
removeTokenData(this.config.clientId);
|
|
993
|
+
throw new SsoError(
|
|
994
|
+
"id_token_invalid",
|
|
995
|
+
"Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
|
|
996
|
+
);
|
|
997
|
+
}
|
|
901
998
|
if (data.id_token) {
|
|
902
999
|
try {
|
|
903
1000
|
await validateIdToken(
|
|
@@ -927,6 +1024,7 @@ var _SsoClient = class _SsoClient {
|
|
|
927
1024
|
expires_at: now + data.expires_in * 1e3
|
|
928
1025
|
};
|
|
929
1026
|
saveTokenData(tokenData, this.config.clientId);
|
|
1027
|
+
this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
|
|
930
1028
|
return tokenData;
|
|
931
1029
|
}
|
|
932
1030
|
/**
|
|
@@ -962,7 +1060,7 @@ var _SsoClient = class _SsoClient {
|
|
|
962
1060
|
let lastErr;
|
|
963
1061
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
964
1062
|
try {
|
|
965
|
-
res = await
|
|
1063
|
+
res = await fetchWithTimeout(tokenEndpoint, {
|
|
966
1064
|
method: "POST",
|
|
967
1065
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
968
1066
|
body: body.toString()
|
|
@@ -984,15 +1082,20 @@ var _SsoClient = class _SsoClient {
|
|
|
984
1082
|
} catch {
|
|
985
1083
|
}
|
|
986
1084
|
const errorCode = errData.error || "";
|
|
987
|
-
if (errorCode === "invalid_grant"
|
|
1085
|
+
if (errorCode === "invalid_grant") {
|
|
988
1086
|
removeTokenData(this.config.clientId);
|
|
989
1087
|
}
|
|
1088
|
+
this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
|
|
1089
|
+
if (!errorCode && res.status === 401) {
|
|
1090
|
+
throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
|
|
1091
|
+
}
|
|
990
1092
|
throw new SsoError(
|
|
991
1093
|
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
992
1094
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
993
1095
|
);
|
|
994
1096
|
}
|
|
995
1097
|
const data = await res.json();
|
|
1098
|
+
assertValidTokenResponse(data, false);
|
|
996
1099
|
if (data.id_token) {
|
|
997
1100
|
try {
|
|
998
1101
|
await validateIdToken(
|
|
@@ -1036,7 +1139,7 @@ var _SsoClient = class _SsoClient {
|
|
|
1036
1139
|
const userinfoEndpoint = await this._getUserinfoEndpoint();
|
|
1037
1140
|
let res;
|
|
1038
1141
|
try {
|
|
1039
|
-
res = await
|
|
1142
|
+
res = await fetchWithTimeout(userinfoEndpoint, {
|
|
1040
1143
|
headers: {
|
|
1041
1144
|
Authorization: `Bearer ${tokenData.access_token}`
|
|
1042
1145
|
}
|
|
@@ -1093,6 +1196,10 @@ var _SsoClient = class _SsoClient {
|
|
|
1093
1196
|
const refreshToken = tokenData?.refresh_token;
|
|
1094
1197
|
const idTokenHint = tokenData?.id_token;
|
|
1095
1198
|
clearAllSsoData(this.config.clientId);
|
|
1199
|
+
this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
|
|
1200
|
+
clientId: this.config.clientId,
|
|
1201
|
+
redirectToSso
|
|
1202
|
+
});
|
|
1096
1203
|
if (refreshToken && this.config.clientId) {
|
|
1097
1204
|
try {
|
|
1098
1205
|
const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
|
|
@@ -1105,11 +1212,15 @@ var _SsoClient = class _SsoClient {
|
|
|
1105
1212
|
if (this.config.clientSecret) {
|
|
1106
1213
|
revokeBody.set("client_secret", this.config.clientSecret);
|
|
1107
1214
|
}
|
|
1108
|
-
await
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1215
|
+
await fetchWithTimeout(
|
|
1216
|
+
revokeUrl,
|
|
1217
|
+
{
|
|
1218
|
+
method: "POST",
|
|
1219
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1220
|
+
body: revokeBody.toString()
|
|
1221
|
+
},
|
|
1222
|
+
REVOKE_TIMEOUT_MS
|
|
1223
|
+
);
|
|
1113
1224
|
} catch {
|
|
1114
1225
|
}
|
|
1115
1226
|
}
|
|
@@ -1179,6 +1290,17 @@ var SsoClient = _SsoClient;
|
|
|
1179
1290
|
var SsoContext = createContext(null);
|
|
1180
1291
|
var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
1181
1292
|
var LOCK_TTL_MS = 5e3;
|
|
1293
|
+
var CHANNEL_PREFIX = "nihplod_sso_events:";
|
|
1294
|
+
var TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1295
|
+
function broadcastSsoEvent(clientId, type) {
|
|
1296
|
+
if (typeof BroadcastChannel === "undefined") return;
|
|
1297
|
+
try {
|
|
1298
|
+
const channel = new BroadcastChannel(CHANNEL_PREFIX + clientId);
|
|
1299
|
+
channel.postMessage({ type, sourceTabId: TAB_ID });
|
|
1300
|
+
channel.close();
|
|
1301
|
+
} catch {
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1182
1304
|
var ownedLocks = /* @__PURE__ */ new Map();
|
|
1183
1305
|
function lockKey(clientId) {
|
|
1184
1306
|
return REFRESH_LOCK_PREFIX + clientId;
|
|
@@ -1237,21 +1359,37 @@ function SsoProvider({
|
|
|
1237
1359
|
config,
|
|
1238
1360
|
children,
|
|
1239
1361
|
refreshThreshold = 60,
|
|
1240
|
-
onTokenRefreshed
|
|
1362
|
+
onTokenRefreshed,
|
|
1363
|
+
onSessionExpired
|
|
1241
1364
|
}) {
|
|
1242
1365
|
const [user, setUser] = useState(null);
|
|
1243
1366
|
const [isLoading, setIsLoading] = useState(true);
|
|
1244
1367
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
1245
1368
|
const [error, setError] = useState(null);
|
|
1369
|
+
const [sessionExpired, setSessionExpired] = useState(false);
|
|
1246
1370
|
const [client] = useState(() => new SsoClient(config));
|
|
1247
1371
|
const refreshTimerRef = useRef(null);
|
|
1248
1372
|
const loadedRef = useRef(false);
|
|
1373
|
+
const onSessionExpiredRef = useRef(onSessionExpired);
|
|
1374
|
+
useEffect(() => {
|
|
1375
|
+
onSessionExpiredRef.current = onSessionExpired;
|
|
1376
|
+
});
|
|
1377
|
+
const sessionExpiredNotifiedRef = useRef(false);
|
|
1378
|
+
const refreshFailureCountRef = useRef(0);
|
|
1379
|
+
const MAX_REFRESH_FAILURES = 3;
|
|
1380
|
+
const markSessionExpired = useCallback((error2) => {
|
|
1381
|
+
setError(error2);
|
|
1382
|
+
setSessionExpired(true);
|
|
1383
|
+
if (!sessionExpiredNotifiedRef.current) {
|
|
1384
|
+
sessionExpiredNotifiedRef.current = true;
|
|
1385
|
+
onSessionExpiredRef.current?.(error2);
|
|
1386
|
+
}
|
|
1387
|
+
}, []);
|
|
1249
1388
|
const loadUser = useCallback(async () => {
|
|
1250
1389
|
const tokenData = getTokenData(client.config.clientId);
|
|
1251
1390
|
if (!tokenData) {
|
|
1252
1391
|
setUser(null);
|
|
1253
1392
|
setIsAuthenticated(false);
|
|
1254
|
-
setError(null);
|
|
1255
1393
|
setIsLoading(false);
|
|
1256
1394
|
return;
|
|
1257
1395
|
}
|
|
@@ -1260,19 +1398,22 @@ function SsoProvider({
|
|
|
1260
1398
|
setUser(u);
|
|
1261
1399
|
setIsAuthenticated(true);
|
|
1262
1400
|
setError(null);
|
|
1401
|
+
setSessionExpired(false);
|
|
1402
|
+
sessionExpiredNotifiedRef.current = false;
|
|
1403
|
+
refreshFailureCountRef.current = 0;
|
|
1263
1404
|
} catch (err) {
|
|
1264
1405
|
setUser(null);
|
|
1265
1406
|
setIsAuthenticated(false);
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
)
|
|
1269
|
-
if (err instanceof SsoError && (err.code === "not_authenticated" || err.code === "session_expired")) {
|
|
1407
|
+
const errorObj = err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err));
|
|
1408
|
+
setError(errorObj);
|
|
1409
|
+
if (errorObj.code === "not_authenticated" || errorObj.code === "session_expired") {
|
|
1270
1410
|
removeTokenData(client.config.clientId);
|
|
1411
|
+
markSessionExpired(errorObj);
|
|
1271
1412
|
}
|
|
1272
1413
|
} finally {
|
|
1273
1414
|
setIsLoading(false);
|
|
1274
1415
|
}
|
|
1275
|
-
}, [client]);
|
|
1416
|
+
}, [client, markSessionExpired]);
|
|
1276
1417
|
useEffect(() => {
|
|
1277
1418
|
if (loadedRef.current) return;
|
|
1278
1419
|
loadedRef.current = true;
|
|
@@ -1289,13 +1430,28 @@ function SsoProvider({
|
|
|
1289
1430
|
const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
|
|
1290
1431
|
try {
|
|
1291
1432
|
const td = await client.refreshToken();
|
|
1433
|
+
refreshFailureCountRef.current = 0;
|
|
1292
1434
|
onTokenRefreshed?.(td.access_token);
|
|
1435
|
+
broadcastSsoEvent(client.config.clientId, "token");
|
|
1293
1436
|
loadUser();
|
|
1294
|
-
} catch {
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
if (err instanceof SsoError && (err.code === "session_expired" || err.code === "no_refresh_token" || err.code === "not_authenticated")) {
|
|
1439
|
+
removeTokenData(client.config.clientId);
|
|
1440
|
+
markSessionExpired(err);
|
|
1441
|
+
} else {
|
|
1442
|
+
refreshFailureCountRef.current += 1;
|
|
1443
|
+
if (refreshFailureCountRef.current >= MAX_REFRESH_FAILURES) {
|
|
1444
|
+
markSessionExpired(
|
|
1445
|
+
err instanceof SsoError ? err : new SsoError("session_expired", "\u591A\u6B21\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55")
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1295
1449
|
setTimeout(() => loadUser(), 500);
|
|
1296
1450
|
}
|
|
1451
|
+
}).finally(() => {
|
|
1452
|
+
if (active) scheduleNextRefresh(5e3);
|
|
1297
1453
|
});
|
|
1298
|
-
const scheduleNextRefresh = () => {
|
|
1454
|
+
const scheduleNextRefresh = (minDelayMs = 0) => {
|
|
1299
1455
|
if (!active) return;
|
|
1300
1456
|
if (refreshTimerRef.current) {
|
|
1301
1457
|
clearTimeout(refreshTimerRef.current);
|
|
@@ -1304,26 +1460,27 @@ function SsoProvider({
|
|
|
1304
1460
|
const tokenData = getTokenData(client.config.clientId);
|
|
1305
1461
|
if (!tokenData) return;
|
|
1306
1462
|
const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
|
|
1307
|
-
if (remainingSec <= 0) {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1463
|
+
if (remainingSec <= 0 || remainingSec <= refreshThreshold) {
|
|
1464
|
+
refreshTimerRef.current = setTimeout(
|
|
1465
|
+
() => {
|
|
1466
|
+
if (active) void attemptRefresh();
|
|
1467
|
+
},
|
|
1468
|
+
Math.max(minDelayMs, 1e3)
|
|
1469
|
+
);
|
|
1313
1470
|
return;
|
|
1314
1471
|
}
|
|
1315
|
-
const delayMs = (remainingSec - refreshThreshold) * 1e3;
|
|
1472
|
+
const delayMs = Math.max((remainingSec - refreshThreshold) * 1e3, minDelayMs, 1e3);
|
|
1316
1473
|
refreshTimerRef.current = setTimeout(() => {
|
|
1317
1474
|
if (!active) return;
|
|
1318
1475
|
const td = getTokenData(client.config.clientId);
|
|
1319
1476
|
if (!td) return;
|
|
1320
1477
|
const secLeft = (td.expires_at - Date.now()) / 1e3;
|
|
1321
1478
|
if (secLeft <= refreshThreshold) {
|
|
1322
|
-
void attemptRefresh()
|
|
1323
|
-
|
|
1324
|
-
|
|
1479
|
+
void attemptRefresh();
|
|
1480
|
+
} else {
|
|
1481
|
+
scheduleNextRefresh();
|
|
1325
1482
|
}
|
|
1326
|
-
},
|
|
1483
|
+
}, delayMs);
|
|
1327
1484
|
};
|
|
1328
1485
|
scheduleNextRefresh();
|
|
1329
1486
|
return () => {
|
|
@@ -1343,6 +1500,26 @@ function SsoProvider({
|
|
|
1343
1500
|
window.addEventListener("storage", handleStorageChange);
|
|
1344
1501
|
return () => window.removeEventListener("storage", handleStorageChange);
|
|
1345
1502
|
}, [loadUser]);
|
|
1503
|
+
useEffect(() => {
|
|
1504
|
+
if (typeof BroadcastChannel === "undefined") return;
|
|
1505
|
+
const channel = new BroadcastChannel(CHANNEL_PREFIX + client.config.clientId);
|
|
1506
|
+
channel.onmessage = (event) => {
|
|
1507
|
+
const data = event.data;
|
|
1508
|
+
if (!data || data.sourceTabId === TAB_ID) return;
|
|
1509
|
+
if (data.type === "logout") {
|
|
1510
|
+
removeTokenData(client.config.clientId);
|
|
1511
|
+
setUser(null);
|
|
1512
|
+
setIsAuthenticated(false);
|
|
1513
|
+
setError(null);
|
|
1514
|
+
setSessionExpired(false);
|
|
1515
|
+
sessionExpiredNotifiedRef.current = false;
|
|
1516
|
+
refreshFailureCountRef.current = 0;
|
|
1517
|
+
} else if (data.type === "token") {
|
|
1518
|
+
loadUser();
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
return () => channel.close();
|
|
1522
|
+
}, [client, loadUser]);
|
|
1346
1523
|
const login = useCallback(
|
|
1347
1524
|
async (returnUrl) => {
|
|
1348
1525
|
await client.login(returnUrl);
|
|
@@ -1359,10 +1536,17 @@ function SsoProvider({
|
|
|
1359
1536
|
);
|
|
1360
1537
|
const logout = useCallback(
|
|
1361
1538
|
async (redirectToSso = false) => {
|
|
1362
|
-
await client.logout(redirectToSso);
|
|
1363
1539
|
setUser(null);
|
|
1364
1540
|
setIsAuthenticated(false);
|
|
1365
1541
|
setError(null);
|
|
1542
|
+
setSessionExpired(false);
|
|
1543
|
+
sessionExpiredNotifiedRef.current = false;
|
|
1544
|
+
refreshFailureCountRef.current = 0;
|
|
1545
|
+
broadcastSsoEvent(client.config.clientId, "logout");
|
|
1546
|
+
try {
|
|
1547
|
+
await client.logout(redirectToSso);
|
|
1548
|
+
} catch {
|
|
1549
|
+
}
|
|
1366
1550
|
},
|
|
1367
1551
|
[client]
|
|
1368
1552
|
);
|
|
@@ -1382,6 +1566,7 @@ function SsoProvider({
|
|
|
1382
1566
|
logout,
|
|
1383
1567
|
refreshUser,
|
|
1384
1568
|
getAccessToken,
|
|
1569
|
+
sessionExpired,
|
|
1385
1570
|
client
|
|
1386
1571
|
};
|
|
1387
1572
|
return React.createElement(SsoContext.Provider, { value }, children);
|
|
@@ -1476,6 +1661,36 @@ function withAuth(Component) {
|
|
|
1476
1661
|
|
|
1477
1662
|
// src/react/CallbackPage.tsx
|
|
1478
1663
|
import React3, { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
|
|
1664
|
+
var CALLBACK_ERROR_HINTS = {
|
|
1665
|
+
state_mismatch: "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1666
|
+
pkce_required: "\u767B\u5F55\u4F1A\u8BDD\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1667
|
+
user_denied_authorization: "\u4F60\u5DF2\u53D6\u6D88\u767B\u5F55",
|
|
1668
|
+
session_expired: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1669
|
+
authorization_code_expired: "\u767B\u5F55\u4FE1\u606F\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1670
|
+
authorization_code_used: "\u767B\u5F55\u4FE1\u606F\u5DF2\u88AB\u4F7F\u7528\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1671
|
+
token_request_failed: "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1672
|
+
userinfo_failed: "\u767B\u5F55\u4FE1\u606F\u83B7\u53D6\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
|
|
1673
|
+
not_authenticated: "\u8BF7\u5148\u767B\u5F55\u540E\u518D\u8BBF\u95EE",
|
|
1674
|
+
network_error: "\u7F51\u7EDC\u5F02\u5E38\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5",
|
|
1675
|
+
rate_limited: "\u64CD\u4F5C\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
1676
|
+
popup_blocked: "\u767B\u5F55\u7A97\u53E3\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5",
|
|
1677
|
+
popup_closed: "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55",
|
|
1678
|
+
client_disabled: "\u8BE5\u5E94\u7528\u5DF2\u505C\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
|
|
1679
|
+
account_disabled: "\u8D26\u53F7\u6682\u65F6\u65E0\u6CD5\u4F7F\u7528\uFF0C\u8BF7\u8054\u7CFB\u5BA2\u670D",
|
|
1680
|
+
sso_server_error: "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
1681
|
+
invalid_config: "\u5E94\u7528\u914D\u7F6E\u6709\u8BEF\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458",
|
|
1682
|
+
no_refresh_token: "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
|
|
1683
|
+
};
|
|
1684
|
+
function getCallbackErrorMessage(err) {
|
|
1685
|
+
if (err instanceof SsoError) {
|
|
1686
|
+
const hint = CALLBACK_ERROR_HINTS[err.code];
|
|
1687
|
+
if (hint) return hint;
|
|
1688
|
+
if (err.code.startsWith("id_token")) return "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
|
|
1689
|
+
if (/[\u4e00-\u9fff]/.test(err.description)) return err.description;
|
|
1690
|
+
return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
|
|
1691
|
+
}
|
|
1692
|
+
return "\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5";
|
|
1693
|
+
}
|
|
1479
1694
|
function DefaultCallbackError({ error }) {
|
|
1480
1695
|
return React3.createElement(
|
|
1481
1696
|
"div",
|
|
@@ -1486,18 +1701,54 @@ function DefaultCallbackError({ error }) {
|
|
|
1486
1701
|
alignItems: "center",
|
|
1487
1702
|
justifyContent: "center",
|
|
1488
1703
|
minHeight: "100vh",
|
|
1489
|
-
fontFamily: "system-ui, sans-serif"
|
|
1704
|
+
fontFamily: "system-ui, sans-serif",
|
|
1705
|
+
padding: "2rem",
|
|
1706
|
+
textAlign: "center"
|
|
1490
1707
|
}
|
|
1491
1708
|
},
|
|
1492
1709
|
React3.createElement(
|
|
1493
1710
|
"p",
|
|
1494
|
-
{ style: { color: "#dc2626", marginBottom: "
|
|
1711
|
+
{ style: { color: "#dc2626", marginBottom: "1.5rem" } },
|
|
1495
1712
|
error
|
|
1496
1713
|
),
|
|
1497
1714
|
React3.createElement(
|
|
1498
|
-
"
|
|
1499
|
-
{
|
|
1500
|
-
|
|
1715
|
+
"div",
|
|
1716
|
+
{ style: { display: "flex", gap: "0.75rem" } },
|
|
1717
|
+
React3.createElement(
|
|
1718
|
+
"a",
|
|
1719
|
+
{
|
|
1720
|
+
href: "/",
|
|
1721
|
+
style: {
|
|
1722
|
+
display: "inline-block",
|
|
1723
|
+
padding: "0.6rem 1.4rem",
|
|
1724
|
+
background: "#2c2c2c",
|
|
1725
|
+
color: "#fff",
|
|
1726
|
+
fontSize: "0.8125rem",
|
|
1727
|
+
textDecoration: "none"
|
|
1728
|
+
}
|
|
1729
|
+
},
|
|
1730
|
+
"\u91CD\u65B0\u767B\u5F55"
|
|
1731
|
+
),
|
|
1732
|
+
React3.createElement(
|
|
1733
|
+
"button",
|
|
1734
|
+
{
|
|
1735
|
+
type: "button",
|
|
1736
|
+
onClick: () => {
|
|
1737
|
+
if (window.history.length > 1) window.history.back();
|
|
1738
|
+
else window.location.href = "/";
|
|
1739
|
+
},
|
|
1740
|
+
style: {
|
|
1741
|
+
display: "inline-block",
|
|
1742
|
+
padding: "0.6rem 1.4rem",
|
|
1743
|
+
border: "1px solid rgba(44, 44, 44, 0.25)",
|
|
1744
|
+
background: "transparent",
|
|
1745
|
+
color: "#2c2c2c",
|
|
1746
|
+
fontSize: "0.8125rem",
|
|
1747
|
+
cursor: "pointer"
|
|
1748
|
+
}
|
|
1749
|
+
},
|
|
1750
|
+
"\u8FD4\u56DE\u4E0A\u4E00\u9875"
|
|
1751
|
+
)
|
|
1501
1752
|
)
|
|
1502
1753
|
);
|
|
1503
1754
|
}
|
|
@@ -1512,9 +1763,13 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1512
1763
|
onSuccessRef.current = onSuccess;
|
|
1513
1764
|
onErrorRef.current = onError;
|
|
1514
1765
|
});
|
|
1766
|
+
const callbackPromiseRef = useRef3(null);
|
|
1767
|
+
const postProcessedRef = useRef3(false);
|
|
1768
|
+
const errorHandledRef = useRef3(false);
|
|
1515
1769
|
useEffect3(() => {
|
|
1516
|
-
|
|
1517
|
-
|
|
1770
|
+
const popupNonce = new URL(window.location.href).searchParams.get("popup_nonce");
|
|
1771
|
+
if (window.opener && !window.opener.closed && popupNonce) {
|
|
1772
|
+
const nonce = popupNonce;
|
|
1518
1773
|
let targetOrigin = window.location.origin;
|
|
1519
1774
|
try {
|
|
1520
1775
|
targetOrigin = window.opener.location.origin;
|
|
@@ -1523,7 +1778,7 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1523
1778
|
const message = {
|
|
1524
1779
|
type: "nihplod_sso_popup_callback",
|
|
1525
1780
|
callbackUrl: window.location.href,
|
|
1526
|
-
nonce
|
|
1781
|
+
nonce
|
|
1527
1782
|
};
|
|
1528
1783
|
let acked = false;
|
|
1529
1784
|
const send = () => {
|
|
@@ -1538,9 +1793,10 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1538
1793
|
setAwaitingManualClose(true);
|
|
1539
1794
|
}, 1e4);
|
|
1540
1795
|
const handleAck = (event) => {
|
|
1796
|
+
if (event.source !== window.opener) return;
|
|
1541
1797
|
if (event.origin !== targetOrigin) return;
|
|
1542
1798
|
if (!event.data || event.data.type !== "nihplod_sso_popup_ack") return;
|
|
1543
|
-
if (
|
|
1799
|
+
if (event.data.nonce !== nonce) return;
|
|
1544
1800
|
acked = true;
|
|
1545
1801
|
clearInterval(resendTimer);
|
|
1546
1802
|
clearTimeout(ackTimeout);
|
|
@@ -1556,8 +1812,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1556
1812
|
let cancelled = false;
|
|
1557
1813
|
async function handleCallback() {
|
|
1558
1814
|
try {
|
|
1559
|
-
|
|
1560
|
-
|
|
1815
|
+
if (!callbackPromiseRef.current) {
|
|
1816
|
+
callbackPromiseRef.current = client.handleCallback(window.location.href);
|
|
1817
|
+
}
|
|
1818
|
+
const tokenData = await callbackPromiseRef.current;
|
|
1819
|
+
if (cancelled || postProcessedRef.current) return;
|
|
1820
|
+
postProcessedRef.current = true;
|
|
1561
1821
|
if (tokenData === null) {
|
|
1562
1822
|
const clientId2 = client.config.clientId;
|
|
1563
1823
|
const probeReturnUrl = getReturnUrl(clientId2);
|
|
@@ -1578,14 +1838,12 @@ function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
|
1578
1838
|
}
|
|
1579
1839
|
window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
|
|
1580
1840
|
} catch (err) {
|
|
1581
|
-
if (cancelled) return;
|
|
1841
|
+
if (cancelled || errorHandledRef.current) return;
|
|
1842
|
+
errorHandledRef.current = true;
|
|
1843
|
+
callbackPromiseRef.current = null;
|
|
1582
1844
|
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
1583
1845
|
onErrorRef.current?.(errorObj);
|
|
1584
|
-
|
|
1585
|
-
setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
|
|
1586
|
-
} else {
|
|
1587
|
-
setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
|
|
1588
|
-
}
|
|
1846
|
+
setError(getCallbackErrorMessage(errorObj));
|
|
1589
1847
|
setProcessing(false);
|
|
1590
1848
|
}
|
|
1591
1849
|
}
|