@nihplod/sso-sdk 1.0.1 → 1.1.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 +19 -3
- package/dist/index.d.mts +99 -12
- package/dist/index.d.ts +99 -12
- package/dist/index.js +413 -75
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +408 -74
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +17 -2
- package/dist/next/index.d.ts +17 -2
- package/dist/next/index.js +276 -45
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +274 -45
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +71 -8
- package/dist/react/index.d.ts +71 -8
- package/dist/react/index.js +476 -111
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +476 -111
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react/index.js
CHANGED
|
@@ -42,6 +42,26 @@ module.exports = __toCommonJS(react_exports);
|
|
|
42
42
|
var import_react = __toESM(require("react"));
|
|
43
43
|
|
|
44
44
|
// src/core/errors.ts
|
|
45
|
+
function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
|
|
46
|
+
switch (oauthError) {
|
|
47
|
+
case "invalid_grant":
|
|
48
|
+
return context === "refresh" ? "session_expired" : "authorization_code_expired";
|
|
49
|
+
case "invalid_client":
|
|
50
|
+
return "client_disabled";
|
|
51
|
+
case "access_denied":
|
|
52
|
+
return "user_denied_authorization";
|
|
53
|
+
case "server_error":
|
|
54
|
+
return "sso_server_error";
|
|
55
|
+
case "rate_limited":
|
|
56
|
+
return "rate_limited";
|
|
57
|
+
case "invalid_scope":
|
|
58
|
+
case "invalid_request":
|
|
59
|
+
case "unauthorized_client":
|
|
60
|
+
case "unsupported_grant_type":
|
|
61
|
+
default:
|
|
62
|
+
return "token_request_failed";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
45
65
|
var SsoError = class extends Error {
|
|
46
66
|
constructor(code, description, cause) {
|
|
47
67
|
super(`[SSO SDK] ${code}: ${description}`);
|
|
@@ -115,6 +135,34 @@ function createMemoryStorageAdapter() {
|
|
|
115
135
|
};
|
|
116
136
|
}
|
|
117
137
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
138
|
+
function createSessionStorageAdapter() {
|
|
139
|
+
const fallback = /* @__PURE__ */ new Map();
|
|
140
|
+
return {
|
|
141
|
+
get(key) {
|
|
142
|
+
if (typeof sessionStorage !== "undefined") {
|
|
143
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
144
|
+
}
|
|
145
|
+
return fallback.get(key) ?? null;
|
|
146
|
+
},
|
|
147
|
+
set(key, value) {
|
|
148
|
+
if (typeof sessionStorage === "undefined") {
|
|
149
|
+
fallback.set(key, value);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
154
|
+
} catch {
|
|
155
|
+
fallback.set(key, value);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
remove(key) {
|
|
159
|
+
fallback.delete(key);
|
|
160
|
+
if (typeof sessionStorage === "undefined") return;
|
|
161
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
var _transient = createSessionStorageAdapter();
|
|
118
166
|
var _storage = memoryStorageAdapter;
|
|
119
167
|
function saveTokenData(data, clientId) {
|
|
120
168
|
_storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
|
|
@@ -132,31 +180,31 @@ function removeTokenData(clientId) {
|
|
|
132
180
|
_storage.remove(buildKey(TOKEN_KEY, clientId));
|
|
133
181
|
}
|
|
134
182
|
function savePkceVerifier(clientId, verifier) {
|
|
135
|
-
|
|
183
|
+
_transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
|
|
136
184
|
}
|
|
137
185
|
function getPkceVerifier(clientId) {
|
|
138
|
-
return
|
|
186
|
+
return _transient.get(VERIFIER_KEY_PREFIX + clientId);
|
|
139
187
|
}
|
|
140
188
|
function removePkceVerifier(clientId) {
|
|
141
|
-
|
|
189
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
142
190
|
}
|
|
143
191
|
function saveOAuthState(state, clientId) {
|
|
144
|
-
|
|
192
|
+
_transient.set(buildKey(STATE_KEY, clientId), state);
|
|
145
193
|
}
|
|
146
194
|
function getOAuthState(clientId) {
|
|
147
|
-
return
|
|
195
|
+
return _transient.get(buildKey(STATE_KEY, clientId));
|
|
148
196
|
}
|
|
149
197
|
function removeOAuthState(clientId) {
|
|
150
|
-
|
|
198
|
+
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
151
199
|
}
|
|
152
200
|
function saveReturnUrl(url, clientId) {
|
|
153
|
-
|
|
201
|
+
_transient.set(buildKey(RETURN_URL_KEY, clientId), url);
|
|
154
202
|
}
|
|
155
203
|
function getReturnUrl(clientId) {
|
|
156
|
-
return
|
|
204
|
+
return _transient.get(buildKey(RETURN_URL_KEY, clientId));
|
|
157
205
|
}
|
|
158
206
|
function removeReturnUrl(clientId) {
|
|
159
|
-
|
|
207
|
+
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
160
208
|
}
|
|
161
209
|
function clearAllSsoData(clientId) {
|
|
162
210
|
if (clientId) {
|
|
@@ -164,23 +212,58 @@ function clearAllSsoData(clientId) {
|
|
|
164
212
|
removeOAuthState(clientId);
|
|
165
213
|
removeReturnUrl(clientId);
|
|
166
214
|
removePkceVerifier(clientId);
|
|
215
|
+
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
167
216
|
return;
|
|
168
217
|
}
|
|
169
218
|
removeTokenData();
|
|
170
219
|
removeOAuthState();
|
|
171
220
|
removeReturnUrl();
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
221
|
+
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
222
|
+
const stores = [
|
|
223
|
+
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
224
|
+
typeof localStorage !== "undefined" ? localStorage : null
|
|
225
|
+
];
|
|
226
|
+
for (const store of stores) {
|
|
227
|
+
if (!store) continue;
|
|
228
|
+
const keys = [];
|
|
229
|
+
for (let i = 0; i < store.length; i++) {
|
|
230
|
+
const key = store.key(i);
|
|
231
|
+
if (key?.startsWith(prefix)) keys.push(key);
|
|
232
|
+
}
|
|
233
|
+
for (const key of keys) {
|
|
234
|
+
_transient.remove(key.slice(STORAGE_PREFIX.length));
|
|
235
|
+
store.removeItem(key);
|
|
179
236
|
}
|
|
180
237
|
}
|
|
181
238
|
}
|
|
182
239
|
|
|
183
|
-
// src/core/
|
|
240
|
+
// src/core/security.ts
|
|
241
|
+
function isTrustedReturnUrl(url, currentOrigin) {
|
|
242
|
+
if (!url) return false;
|
|
243
|
+
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
244
|
+
try {
|
|
245
|
+
return new URL(url).origin === currentOrigin;
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function timingSafeEqualString(a, b) {
|
|
251
|
+
const ba = new TextEncoder().encode(a);
|
|
252
|
+
const bb = new TextEncoder().encode(b);
|
|
253
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
254
|
+
const len = Math.max(ba.length, bb.length);
|
|
255
|
+
let diff = ba.length ^ bb.length;
|
|
256
|
+
for (let i = 0; i < len; i++) {
|
|
257
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
258
|
+
}
|
|
259
|
+
return diff === 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/core/id-token.ts
|
|
263
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
264
|
+
function getCrypto() {
|
|
265
|
+
return _crypto ?? null;
|
|
266
|
+
}
|
|
184
267
|
function base64UrlDecode(input) {
|
|
185
268
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
186
269
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -215,14 +298,32 @@ function normalizeIssuer(url) {
|
|
|
215
298
|
return url.replace(/\/+$/, "");
|
|
216
299
|
}
|
|
217
300
|
var cachedJwks = null;
|
|
301
|
+
var cachedDiscovery = null;
|
|
218
302
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
219
|
-
async function
|
|
303
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
220
304
|
const now = Date.now();
|
|
221
|
-
if (
|
|
305
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
306
|
+
return cachedDiscovery.doc;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
310
|
+
const doc = res.ok ? await res.json() : null;
|
|
311
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
312
|
+
return doc;
|
|
313
|
+
} catch {
|
|
314
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
222
321
|
return cachedJwks.jwks;
|
|
223
322
|
}
|
|
323
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
324
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
224
325
|
try {
|
|
225
|
-
const res = await fetch(
|
|
326
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
226
327
|
if (!res.ok) return null;
|
|
227
328
|
const jwks = await res.json();
|
|
228
329
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -231,24 +332,13 @@ async function fetchJwks(baseUrl) {
|
|
|
231
332
|
return null;
|
|
232
333
|
}
|
|
233
334
|
}
|
|
234
|
-
async function
|
|
235
|
-
const hash = await crypto.subtle.digest(
|
|
236
|
-
"SHA-256",
|
|
237
|
-
new TextEncoder().encode(accessToken)
|
|
238
|
-
);
|
|
239
|
-
const bytes = new Uint8Array(hash);
|
|
240
|
-
const half = bytes.slice(0, bytes.length / 2);
|
|
241
|
-
let binary = "";
|
|
242
|
-
for (const b of half) {
|
|
243
|
-
binary += String.fromCharCode(b);
|
|
244
|
-
}
|
|
245
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
246
|
-
}
|
|
247
|
-
async function verifyRs256(token, jwk) {
|
|
335
|
+
async function verifyRs256Signature(token, jwk) {
|
|
248
336
|
try {
|
|
249
337
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
250
338
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
251
|
-
const
|
|
339
|
+
const c = getCrypto();
|
|
340
|
+
if (!c) return false;
|
|
341
|
+
const cryptoKey = await c.subtle.importKey(
|
|
252
342
|
"jwk",
|
|
253
343
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
254
344
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -257,7 +347,7 @@ async function verifyRs256(token, jwk) {
|
|
|
257
347
|
);
|
|
258
348
|
const signatureBytes = base64UrlDecode(signature);
|
|
259
349
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
260
|
-
return await
|
|
350
|
+
return await c.subtle.verify(
|
|
261
351
|
"RSASSA-PKCS1-v1_5",
|
|
262
352
|
cryptoKey,
|
|
263
353
|
signatureBytes,
|
|
@@ -267,7 +357,20 @@ async function verifyRs256(token, jwk) {
|
|
|
267
357
|
return false;
|
|
268
358
|
}
|
|
269
359
|
}
|
|
270
|
-
async function
|
|
360
|
+
async function computeAtHash(accessToken) {
|
|
361
|
+
const c = getCrypto();
|
|
362
|
+
if (!c) return "";
|
|
363
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
364
|
+
const bytes = new Uint8Array(hash);
|
|
365
|
+
const half = bytes.slice(0, bytes.length / 2);
|
|
366
|
+
let binary = "";
|
|
367
|
+
for (const b of half) {
|
|
368
|
+
binary += String.fromCharCode(b);
|
|
369
|
+
}
|
|
370
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
371
|
+
}
|
|
372
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
373
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
271
374
|
const header = decodeJwtHeader(idToken);
|
|
272
375
|
if (!header) {
|
|
273
376
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -276,54 +379,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
|
|
|
276
379
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
277
380
|
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
278
381
|
}
|
|
382
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
383
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
384
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
279
385
|
if (alg === "RS256") {
|
|
280
|
-
const jwks = await fetchJwks(
|
|
386
|
+
const jwks = await fetchJwks(baseUrl);
|
|
281
387
|
if (!jwks) {
|
|
282
388
|
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
283
389
|
}
|
|
284
390
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
285
|
-
const
|
|
391
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
286
392
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
287
393
|
);
|
|
288
|
-
|
|
394
|
+
const verifyAny = async (keys) => {
|
|
395
|
+
for (const key of keys) {
|
|
396
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
};
|
|
400
|
+
let candidates = matchCandidates(jwks);
|
|
401
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
402
|
+
if (candidates.length === 0 || !validSig) {
|
|
403
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
404
|
+
if (freshJwks) {
|
|
405
|
+
candidates = matchCandidates(freshJwks);
|
|
406
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (candidates.length === 0) {
|
|
289
410
|
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
290
411
|
}
|
|
291
|
-
const validSig = await verifyRs256(idToken, key);
|
|
292
412
|
if (!validSig) {
|
|
293
413
|
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
294
414
|
}
|
|
295
415
|
}
|
|
296
416
|
if (alg === "HS256") {
|
|
297
|
-
|
|
298
|
-
|
|
417
|
+
if (rejectHs256WhenRs256Available) {
|
|
418
|
+
const jwks = await fetchJwks(baseUrl);
|
|
419
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
420
|
+
if (hasRs256) {
|
|
421
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
throw new SsoError(
|
|
425
|
+
"id_token_hs256_unsupported",
|
|
426
|
+
"ID Token \u4F7F\u7528 HS256 \u5BF9\u79F0\u7B7E\u540D\uFF0CSDK \u65E0\u6CD5\u5B89\u5168\u9A8C\u8BC1\u3002\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5728 SSO \u4E3B\u7AD9\u914D\u7F6E RS256 \u5BC6\u94A5\u5BF9\uFF08JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY\uFF09\u540E\u91CD\u65B0\u7B7E\u53D1\u3002"
|
|
299
427
|
);
|
|
300
428
|
}
|
|
301
429
|
const payload = decodeJwtPayload(idToken);
|
|
302
430
|
if (!payload) {
|
|
303
431
|
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
304
432
|
}
|
|
305
|
-
const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
|
|
306
433
|
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
307
|
-
if (tokenIssuer !==
|
|
434
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
308
435
|
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
309
436
|
}
|
|
310
|
-
|
|
437
|
+
const aud = payload.aud;
|
|
438
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
439
|
+
if (!audList.includes(expectedClientId)) {
|
|
311
440
|
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
312
441
|
}
|
|
313
|
-
if (
|
|
442
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
443
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
444
|
+
}
|
|
445
|
+
if (typeof payload.exp !== "number") {
|
|
446
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
447
|
+
}
|
|
448
|
+
if (Date.now() >= payload.exp * 1e3) {
|
|
314
449
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
315
450
|
}
|
|
451
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
452
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
453
|
+
}
|
|
316
454
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
317
455
|
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
318
456
|
}
|
|
319
457
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
320
458
|
const actual = await computeAtHash(accessToken);
|
|
321
|
-
if (actual
|
|
459
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
322
460
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
323
461
|
}
|
|
324
462
|
}
|
|
325
463
|
return { sub: payload.sub };
|
|
326
464
|
}
|
|
465
|
+
|
|
466
|
+
// src/core/SsoClient.ts
|
|
327
467
|
var _SsoClient = class _SsoClient {
|
|
328
468
|
constructor(config) {
|
|
329
469
|
this._discovery = null;
|
|
@@ -392,6 +532,17 @@ var _SsoClient = class _SsoClient {
|
|
|
392
532
|
if (d) return d.userinfo_endpoint;
|
|
393
533
|
return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
|
|
394
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
|
|
537
|
+
* 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
|
|
538
|
+
*/
|
|
539
|
+
_saveReturnUrlIfTrusted(returnUrl) {
|
|
540
|
+
if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
|
|
541
|
+
saveReturnUrl(returnUrl);
|
|
542
|
+
} else {
|
|
543
|
+
console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
395
546
|
// ============================================
|
|
396
547
|
// 公共 API
|
|
397
548
|
// ============================================
|
|
@@ -401,7 +552,8 @@ var _SsoClient = class _SsoClient {
|
|
|
401
552
|
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
402
553
|
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
403
554
|
*
|
|
404
|
-
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage
|
|
555
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
556
|
+
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
405
557
|
*/
|
|
406
558
|
async login(returnUrl) {
|
|
407
559
|
const verifier = generateCodeVerifier();
|
|
@@ -410,7 +562,7 @@ var _SsoClient = class _SsoClient {
|
|
|
410
562
|
savePkceVerifier(this.config.clientId, verifier);
|
|
411
563
|
saveOAuthState(state, this.config.clientId);
|
|
412
564
|
if (returnUrl) {
|
|
413
|
-
|
|
565
|
+
this._saveReturnUrlIfTrusted(returnUrl);
|
|
414
566
|
}
|
|
415
567
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
416
568
|
const params = new URLSearchParams();
|
|
@@ -434,7 +586,7 @@ var _SsoClient = class _SsoClient {
|
|
|
434
586
|
const state = generateState();
|
|
435
587
|
savePkceVerifier(this.config.clientId, verifier);
|
|
436
588
|
saveOAuthState(state, this.config.clientId);
|
|
437
|
-
if (returnUrl)
|
|
589
|
+
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
438
590
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
439
591
|
const params = new URLSearchParams();
|
|
440
592
|
params.set("response_type", "code");
|
|
@@ -446,11 +598,140 @@ var _SsoClient = class _SsoClient {
|
|
|
446
598
|
params.set("code_challenge_method", "S256");
|
|
447
599
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
448
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* 弹窗模式 SSO 登录
|
|
603
|
+
*
|
|
604
|
+
* 打开一个小窗口进行登录,认证完成后窗口自动关闭,
|
|
605
|
+
* 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
|
|
606
|
+
*
|
|
607
|
+
* 流程:
|
|
608
|
+
* 1. window.open() 打开授权 URL 到弹窗
|
|
609
|
+
* 2. 用户在弹窗中完成登录
|
|
610
|
+
* 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
|
|
611
|
+
* 4. 主页面收到消息后调用 handleCallback() 交换 token
|
|
612
|
+
* 5. 弹窗自动关闭
|
|
613
|
+
*
|
|
614
|
+
* @param options - 弹窗配置
|
|
615
|
+
* @param options.returnUrl - 登录成功后的返回地址
|
|
616
|
+
* @param options.width - 弹窗宽度(默认 480)
|
|
617
|
+
* @param options.height - 弹窗高度(默认 640)
|
|
618
|
+
* @returns TokenData
|
|
619
|
+
*
|
|
620
|
+
* @example
|
|
621
|
+
* ```typescript
|
|
622
|
+
* const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
|
|
623
|
+
* try {
|
|
624
|
+
* const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
|
|
625
|
+
* console.log("登录成功", tokenData);
|
|
626
|
+
* } catch (err) {
|
|
627
|
+
* if (err instanceof SsoError && err.code === "popup_blocked") {
|
|
628
|
+
* // 弹窗被拦截,回退到同页重定向
|
|
629
|
+
* await client.login("/dashboard");
|
|
630
|
+
* }
|
|
631
|
+
* }
|
|
632
|
+
* ```
|
|
633
|
+
*/
|
|
634
|
+
async loginPopup(options = {}) {
|
|
635
|
+
const verifier = generateCodeVerifier();
|
|
636
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
637
|
+
const state = generateState();
|
|
638
|
+
const popupNonce = generateState();
|
|
639
|
+
savePkceVerifier(this.config.clientId, verifier);
|
|
640
|
+
saveOAuthState(state, this.config.clientId);
|
|
641
|
+
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
642
|
+
if (options.returnUrl) {
|
|
643
|
+
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
644
|
+
}
|
|
645
|
+
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
646
|
+
const params = new URLSearchParams();
|
|
647
|
+
params.set("response_type", "code");
|
|
648
|
+
params.set("client_id", this.config.clientId);
|
|
649
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
650
|
+
params.set("scope", this.config.scopes || "openid profile");
|
|
651
|
+
params.set("state", state);
|
|
652
|
+
params.set("code_challenge", challenge);
|
|
653
|
+
params.set("code_challenge_method", "S256");
|
|
654
|
+
params.set("popup_nonce", popupNonce);
|
|
655
|
+
const width = options.width || 480;
|
|
656
|
+
const height = options.height || 640;
|
|
657
|
+
const left = Math.max(0, (window.screen.width - width) / 2);
|
|
658
|
+
const top = Math.max(0, (window.screen.height - height) / 2);
|
|
659
|
+
const popupFeatures = [
|
|
660
|
+
`width=${width}`,
|
|
661
|
+
`height=${height}`,
|
|
662
|
+
`left=${Math.round(left)}`,
|
|
663
|
+
`top=${Math.round(top)}`,
|
|
664
|
+
"resizable=yes",
|
|
665
|
+
"scrollbars=yes",
|
|
666
|
+
"status=yes"
|
|
667
|
+
].join(",");
|
|
668
|
+
const popup = window.open(
|
|
669
|
+
`${authorizeEndpoint}?${params.toString()}`,
|
|
670
|
+
"nihplod_sso_popup",
|
|
671
|
+
popupFeatures
|
|
672
|
+
);
|
|
673
|
+
if (!popup) {
|
|
674
|
+
removePkceVerifier(this.config.clientId);
|
|
675
|
+
removeOAuthState(this.config.clientId);
|
|
676
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
677
|
+
throw new SsoError(
|
|
678
|
+
"popup_blocked",
|
|
679
|
+
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
683
|
+
try {
|
|
684
|
+
popup.focus();
|
|
685
|
+
return await new Promise((resolve, reject) => {
|
|
686
|
+
let completed = false;
|
|
687
|
+
const handleMessage = (event) => {
|
|
688
|
+
if (completed) return;
|
|
689
|
+
if (event.origin !== redirectUriOrigin) return;
|
|
690
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
|
|
691
|
+
if (!event.data.callbackUrl) return;
|
|
692
|
+
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
693
|
+
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
694
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
695
|
+
completed = true;
|
|
696
|
+
cleanup();
|
|
697
|
+
if (popup && !popup.closed) {
|
|
698
|
+
popup.close();
|
|
699
|
+
}
|
|
700
|
+
this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
|
|
701
|
+
};
|
|
702
|
+
const pollTimer = setInterval(() => {
|
|
703
|
+
if (popup.closed) {
|
|
704
|
+
if (!completed) {
|
|
705
|
+
completed = true;
|
|
706
|
+
cleanup();
|
|
707
|
+
reject(
|
|
708
|
+
new SsoError(
|
|
709
|
+
"popup_closed",
|
|
710
|
+
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
711
|
+
)
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}, 500);
|
|
716
|
+
const cleanup = () => {
|
|
717
|
+
clearInterval(pollTimer);
|
|
718
|
+
window.removeEventListener("message", handleMessage);
|
|
719
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
720
|
+
};
|
|
721
|
+
window.addEventListener("message", handleMessage);
|
|
722
|
+
});
|
|
723
|
+
} catch (err) {
|
|
724
|
+
removePkceVerifier(this.config.clientId);
|
|
725
|
+
removeOAuthState(this.config.clientId);
|
|
726
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
727
|
+
throw err;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
449
730
|
/**
|
|
450
731
|
* 处理 OAuth 回调
|
|
451
732
|
*
|
|
452
733
|
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
453
|
-
* 成功后 token 自动保存到
|
|
734
|
+
* 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
|
|
454
735
|
*
|
|
455
736
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
456
737
|
* @returns TokenData 或 null
|
|
@@ -469,14 +750,14 @@ var _SsoClient = class _SsoClient {
|
|
|
469
750
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
470
751
|
}
|
|
471
752
|
const savedState = getOAuthState(this.config.clientId);
|
|
472
|
-
if (!savedState || savedState
|
|
753
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
473
754
|
removeOAuthState(this.config.clientId);
|
|
755
|
+
removePkceVerifier(this.config.clientId);
|
|
474
756
|
throw new SsoError(
|
|
475
757
|
"state_mismatch",
|
|
476
758
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
477
759
|
);
|
|
478
760
|
}
|
|
479
|
-
removeOAuthState(this.config.clientId);
|
|
480
761
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
481
762
|
if (!verifier) {
|
|
482
763
|
throw new SsoError(
|
|
@@ -484,7 +765,6 @@ var _SsoClient = class _SsoClient {
|
|
|
484
765
|
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
485
766
|
);
|
|
486
767
|
}
|
|
487
|
-
removePkceVerifier(this.config.clientId);
|
|
488
768
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
489
769
|
const body = new URLSearchParams();
|
|
490
770
|
body.set("grant_type", "authorization_code");
|
|
@@ -511,19 +791,22 @@ var _SsoClient = class _SsoClient {
|
|
|
511
791
|
errData = await res.json();
|
|
512
792
|
} catch {
|
|
513
793
|
}
|
|
794
|
+
const serverError = errData.error || "";
|
|
514
795
|
throw new SsoError(
|
|
515
|
-
|
|
796
|
+
mapOAuthErrorToSsoCode(serverError),
|
|
516
797
|
errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
|
|
517
798
|
);
|
|
518
799
|
}
|
|
519
800
|
const data = await res.json();
|
|
801
|
+
removeOAuthState(this.config.clientId);
|
|
802
|
+
removePkceVerifier(this.config.clientId);
|
|
520
803
|
if (data.id_token) {
|
|
521
804
|
try {
|
|
522
805
|
await validateIdToken(
|
|
523
806
|
data.id_token,
|
|
807
|
+
data.access_token,
|
|
524
808
|
this.config.ssoBaseUrl,
|
|
525
|
-
this.config.clientId
|
|
526
|
-
data.access_token
|
|
809
|
+
this.config.clientId
|
|
527
810
|
);
|
|
528
811
|
} catch (err) {
|
|
529
812
|
removeTokenData(this.config.clientId);
|
|
@@ -572,15 +855,24 @@ var _SsoClient = class _SsoClient {
|
|
|
572
855
|
if (this.config.clientSecret) {
|
|
573
856
|
body.set("client_secret", this.config.clientSecret);
|
|
574
857
|
}
|
|
575
|
-
let res;
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
858
|
+
let res = null;
|
|
859
|
+
let lastErr;
|
|
860
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
861
|
+
try {
|
|
862
|
+
res = await fetch(tokenEndpoint, {
|
|
863
|
+
method: "POST",
|
|
864
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
865
|
+
body: body.toString()
|
|
866
|
+
});
|
|
867
|
+
lastErr = null;
|
|
868
|
+
break;
|
|
869
|
+
} catch (err) {
|
|
870
|
+
lastErr = err;
|
|
871
|
+
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (lastErr || !res) {
|
|
875
|
+
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
584
876
|
}
|
|
585
877
|
if (!res.ok) {
|
|
586
878
|
let errData = {};
|
|
@@ -593,17 +885,31 @@ var _SsoClient = class _SsoClient {
|
|
|
593
885
|
removeTokenData(this.config.clientId);
|
|
594
886
|
}
|
|
595
887
|
throw new SsoError(
|
|
596
|
-
errorCode
|
|
888
|
+
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
597
889
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
598
890
|
);
|
|
599
891
|
}
|
|
600
892
|
const data = await res.json();
|
|
893
|
+
if (data.id_token) {
|
|
894
|
+
try {
|
|
895
|
+
await validateIdToken(
|
|
896
|
+
data.id_token,
|
|
897
|
+
data.access_token,
|
|
898
|
+
this.config.ssoBaseUrl,
|
|
899
|
+
this.config.clientId
|
|
900
|
+
);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
removeTokenData(this.config.clientId);
|
|
903
|
+
throw err;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
601
906
|
const now = Date.now();
|
|
602
907
|
const tokenData = {
|
|
603
908
|
access_token: data.access_token,
|
|
604
909
|
token_type: data.token_type,
|
|
605
910
|
expires_in: data.expires_in,
|
|
606
|
-
|
|
911
|
+
// RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
|
|
912
|
+
refresh_token: data.refresh_token || current.refresh_token,
|
|
607
913
|
id_token: data.id_token,
|
|
608
914
|
issued_at: now,
|
|
609
915
|
expires_at: now + data.expires_in * 1e3
|
|
@@ -704,13 +1010,14 @@ var _SsoClient = class _SsoClient {
|
|
|
704
1010
|
}
|
|
705
1011
|
if (redirectToSso) {
|
|
706
1012
|
const discovery = await this._getDiscovery();
|
|
707
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/
|
|
1013
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
|
|
708
1014
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
709
1015
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
710
|
-
|
|
1016
|
+
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1017
|
+
if (postLogoutUri) {
|
|
711
1018
|
logoutUrl.searchParams.set(
|
|
712
1019
|
"post_logout_redirect_uri",
|
|
713
|
-
|
|
1020
|
+
postLogoutUri
|
|
714
1021
|
);
|
|
715
1022
|
}
|
|
716
1023
|
if (idTokenHint) {
|
|
@@ -741,6 +1048,7 @@ var SsoClient = _SsoClient;
|
|
|
741
1048
|
var SsoContext = (0, import_react.createContext)(null);
|
|
742
1049
|
var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
743
1050
|
var LOCK_TTL_MS = 5e3;
|
|
1051
|
+
var ownedLocks = /* @__PURE__ */ new Map();
|
|
744
1052
|
function lockKey(clientId) {
|
|
745
1053
|
return REFRESH_LOCK_PREFIX + clientId;
|
|
746
1054
|
}
|
|
@@ -755,12 +1063,44 @@ function acquireRefreshLock(clientId) {
|
|
|
755
1063
|
return false;
|
|
756
1064
|
}
|
|
757
1065
|
}
|
|
758
|
-
|
|
1066
|
+
const token = `${now}:${Math.random().toString(36).slice(2)}`;
|
|
1067
|
+
localStorage.setItem(key, token);
|
|
1068
|
+
if (localStorage.getItem(key) !== token) return false;
|
|
1069
|
+
ownedLocks.set(clientId, token);
|
|
759
1070
|
return true;
|
|
760
1071
|
}
|
|
761
1072
|
function releaseRefreshLock(clientId) {
|
|
762
1073
|
if (typeof localStorage === "undefined") return;
|
|
763
|
-
|
|
1074
|
+
const owned = ownedLocks.get(clientId);
|
|
1075
|
+
ownedLocks.delete(clientId);
|
|
1076
|
+
if (owned && localStorage.getItem(lockKey(clientId)) === owned) {
|
|
1077
|
+
localStorage.removeItem(lockKey(clientId));
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async function withRefreshLock(clientId, task) {
|
|
1081
|
+
if (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function") {
|
|
1082
|
+
try {
|
|
1083
|
+
let ran = false;
|
|
1084
|
+
await navigator.locks.request(
|
|
1085
|
+
`nihplod_sso_refresh_${clientId}`,
|
|
1086
|
+
{ ifAvailable: true },
|
|
1087
|
+
async (lock) => {
|
|
1088
|
+
if (!lock) return;
|
|
1089
|
+
ran = true;
|
|
1090
|
+
await task();
|
|
1091
|
+
}
|
|
1092
|
+
);
|
|
1093
|
+
return ran;
|
|
1094
|
+
} catch {
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (!acquireRefreshLock(clientId)) return false;
|
|
1098
|
+
try {
|
|
1099
|
+
await task();
|
|
1100
|
+
return true;
|
|
1101
|
+
} finally {
|
|
1102
|
+
releaseRefreshLock(clientId);
|
|
1103
|
+
}
|
|
764
1104
|
}
|
|
765
1105
|
function SsoProvider({
|
|
766
1106
|
config,
|
|
@@ -771,12 +1111,12 @@ function SsoProvider({
|
|
|
771
1111
|
const [user, setUser] = (0, import_react.useState)(null);
|
|
772
1112
|
const [isLoading, setIsLoading] = (0, import_react.useState)(true);
|
|
773
1113
|
const [isAuthenticated, setIsAuthenticated] = (0, import_react.useState)(false);
|
|
774
|
-
const
|
|
1114
|
+
const [client] = (0, import_react.useState)(() => new SsoClient(config));
|
|
775
1115
|
const refreshTimerRef = (0, import_react.useRef)(null);
|
|
776
1116
|
const loadedRef = (0, import_react.useRef)(false);
|
|
777
|
-
const client = clientRef.current;
|
|
778
1117
|
const loadUser = (0, import_react.useCallback)(async () => {
|
|
779
|
-
|
|
1118
|
+
const tokenData = getTokenData(client.config.clientId);
|
|
1119
|
+
if (!tokenData) {
|
|
780
1120
|
setUser(null);
|
|
781
1121
|
setIsAuthenticated(false);
|
|
782
1122
|
setIsLoading(false);
|
|
@@ -797,14 +1137,25 @@ function SsoProvider({
|
|
|
797
1137
|
(0, import_react.useEffect)(() => {
|
|
798
1138
|
if (loadedRef.current) return;
|
|
799
1139
|
loadedRef.current = true;
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1140
|
+
Promise.resolve().then(() => {
|
|
1141
|
+
if (getTokenData(client.config.clientId)) {
|
|
1142
|
+
loadUser();
|
|
1143
|
+
} else {
|
|
1144
|
+
setIsLoading(false);
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
805
1147
|
}, [client, loadUser]);
|
|
806
1148
|
(0, import_react.useEffect)(() => {
|
|
807
1149
|
let active = true;
|
|
1150
|
+
const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
|
|
1151
|
+
try {
|
|
1152
|
+
const td = await client.refreshToken();
|
|
1153
|
+
onTokenRefreshed?.(td.access_token);
|
|
1154
|
+
loadUser();
|
|
1155
|
+
} catch {
|
|
1156
|
+
setTimeout(() => loadUser(), 500);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
808
1159
|
const scheduleNextRefresh = () => {
|
|
809
1160
|
if (!active) return;
|
|
810
1161
|
if (refreshTimerRef.current) {
|
|
@@ -815,41 +1166,23 @@ function SsoProvider({
|
|
|
815
1166
|
if (!tokenData) return;
|
|
816
1167
|
const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
|
|
817
1168
|
if (remainingSec <= 0) {
|
|
818
|
-
|
|
819
|
-
client.refreshToken().then((td) => {
|
|
820
|
-
onTokenRefreshed?.(td.access_token);
|
|
821
|
-
loadUser();
|
|
822
|
-
}).catch(() => {
|
|
823
|
-
setTimeout(() => loadUser(), 500);
|
|
824
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
1169
|
+
void attemptRefresh();
|
|
825
1170
|
return;
|
|
826
1171
|
}
|
|
827
1172
|
if (remainingSec <= refreshThreshold) {
|
|
828
|
-
|
|
829
|
-
client.refreshToken().then((td) => {
|
|
830
|
-
onTokenRefreshed?.(td.access_token);
|
|
831
|
-
loadUser();
|
|
832
|
-
}).catch(() => {
|
|
833
|
-
setTimeout(() => loadUser(), 500);
|
|
834
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
1173
|
+
void attemptRefresh();
|
|
835
1174
|
return;
|
|
836
1175
|
}
|
|
837
1176
|
const delayMs = (remainingSec - refreshThreshold) * 1e3;
|
|
838
1177
|
refreshTimerRef.current = setTimeout(() => {
|
|
1178
|
+
if (!active) return;
|
|
839
1179
|
const td = getTokenData(client.config.clientId);
|
|
840
1180
|
if (!td) return;
|
|
841
1181
|
const secLeft = (td.expires_at - Date.now()) / 1e3;
|
|
842
1182
|
if (secLeft <= refreshThreshold) {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
}
|
|
846
|
-
client.refreshToken().then((newTd) => {
|
|
847
|
-
onTokenRefreshed?.(newTd.access_token);
|
|
848
|
-
loadUser();
|
|
849
|
-
}).catch(() => {
|
|
850
|
-
setTimeout(() => loadUser(), 500);
|
|
851
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
852
|
-
scheduleNextRefresh();
|
|
1183
|
+
void attemptRefresh().then((didRefresh) => {
|
|
1184
|
+
if (didRefresh) scheduleNextRefresh();
|
|
1185
|
+
});
|
|
853
1186
|
}
|
|
854
1187
|
}, Math.max(delayMs, 1e3));
|
|
855
1188
|
};
|
|
@@ -877,6 +1210,14 @@ function SsoProvider({
|
|
|
877
1210
|
},
|
|
878
1211
|
[client]
|
|
879
1212
|
);
|
|
1213
|
+
const loginPopup = (0, import_react.useCallback)(
|
|
1214
|
+
async (options) => {
|
|
1215
|
+
const tokenData = await client.loginPopup(options);
|
|
1216
|
+
await loadUser();
|
|
1217
|
+
return tokenData;
|
|
1218
|
+
},
|
|
1219
|
+
[client, loadUser]
|
|
1220
|
+
);
|
|
880
1221
|
const logout = (0, import_react.useCallback)(
|
|
881
1222
|
async (redirectToSso = false) => {
|
|
882
1223
|
await client.logout(redirectToSso);
|
|
@@ -896,6 +1237,7 @@ function SsoProvider({
|
|
|
896
1237
|
isAuthenticated,
|
|
897
1238
|
isLoading,
|
|
898
1239
|
login,
|
|
1240
|
+
loginPopup,
|
|
899
1241
|
logout,
|
|
900
1242
|
refreshUser,
|
|
901
1243
|
getAccessToken,
|
|
@@ -916,17 +1258,26 @@ var import_react2 = __toESM(require("react"));
|
|
|
916
1258
|
function RequireAuth({
|
|
917
1259
|
children,
|
|
918
1260
|
fallback,
|
|
919
|
-
autoLogin = true
|
|
1261
|
+
autoLogin = true,
|
|
1262
|
+
usePopup = false
|
|
920
1263
|
}) {
|
|
921
|
-
const { isAuthenticated, isLoading, login } = useSso();
|
|
1264
|
+
const { isAuthenticated, isLoading, login, loginPopup } = useSso();
|
|
922
1265
|
const loginTriggeredRef = (0, import_react2.useRef)(false);
|
|
923
1266
|
(0, import_react2.useEffect)(() => {
|
|
924
1267
|
if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
|
|
925
1268
|
loginTriggeredRef.current = true;
|
|
926
1269
|
const currentPath = window.location.pathname + window.location.search;
|
|
927
|
-
|
|
1270
|
+
if (usePopup) {
|
|
1271
|
+
loginPopup({ returnUrl: currentPath }).catch((err) => {
|
|
1272
|
+
if (err.code === "popup_blocked") {
|
|
1273
|
+
login(currentPath);
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
} else {
|
|
1277
|
+
login(currentPath);
|
|
1278
|
+
}
|
|
928
1279
|
}
|
|
929
|
-
}, [isLoading, isAuthenticated, autoLogin, login]);
|
|
1280
|
+
}, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
|
|
930
1281
|
if (isLoading) {
|
|
931
1282
|
return fallback || import_react2.default.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
|
|
932
1283
|
}
|
|
@@ -956,6 +1307,20 @@ function CallbackPage() {
|
|
|
956
1307
|
const [error, setError] = (0, import_react3.useState)(null);
|
|
957
1308
|
const [processing, setProcessing] = (0, import_react3.useState)(true);
|
|
958
1309
|
(0, import_react3.useEffect)(() => {
|
|
1310
|
+
if (window.opener && !window.opener.closed) {
|
|
1311
|
+
const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
|
|
1312
|
+
let targetOrigin = window.location.origin;
|
|
1313
|
+
try {
|
|
1314
|
+
targetOrigin = window.opener.location.origin;
|
|
1315
|
+
} catch {
|
|
1316
|
+
}
|
|
1317
|
+
window.opener.postMessage(
|
|
1318
|
+
{ type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
|
|
1319
|
+
targetOrigin
|
|
1320
|
+
);
|
|
1321
|
+
Promise.resolve().then(() => setProcessing(false));
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
959
1324
|
let cancelled = false;
|
|
960
1325
|
async function handleCallback() {
|
|
961
1326
|
try {
|
|
@@ -964,7 +1329,7 @@ function CallbackPage() {
|
|
|
964
1329
|
await refreshUser();
|
|
965
1330
|
const returnUrl = getReturnUrl();
|
|
966
1331
|
removeReturnUrl();
|
|
967
|
-
window.location.href = returnUrl
|
|
1332
|
+
window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
|
|
968
1333
|
} catch (err) {
|
|
969
1334
|
if (cancelled) return;
|
|
970
1335
|
if (err instanceof SsoError) {
|