@nihplod/sso-sdk 1.0.0 → 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 +374 -360
- 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 +3 -2
package/dist/react/index.mjs
CHANGED
|
@@ -9,6 +9,26 @@ import React, {
|
|
|
9
9
|
} from "react";
|
|
10
10
|
|
|
11
11
|
// src/core/errors.ts
|
|
12
|
+
function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
|
|
13
|
+
switch (oauthError) {
|
|
14
|
+
case "invalid_grant":
|
|
15
|
+
return context === "refresh" ? "session_expired" : "authorization_code_expired";
|
|
16
|
+
case "invalid_client":
|
|
17
|
+
return "client_disabled";
|
|
18
|
+
case "access_denied":
|
|
19
|
+
return "user_denied_authorization";
|
|
20
|
+
case "server_error":
|
|
21
|
+
return "sso_server_error";
|
|
22
|
+
case "rate_limited":
|
|
23
|
+
return "rate_limited";
|
|
24
|
+
case "invalid_scope":
|
|
25
|
+
case "invalid_request":
|
|
26
|
+
case "unauthorized_client":
|
|
27
|
+
case "unsupported_grant_type":
|
|
28
|
+
default:
|
|
29
|
+
return "token_request_failed";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
12
32
|
var SsoError = class extends Error {
|
|
13
33
|
constructor(code, description, cause) {
|
|
14
34
|
super(`[SSO SDK] ${code}: ${description}`);
|
|
@@ -82,6 +102,34 @@ function createMemoryStorageAdapter() {
|
|
|
82
102
|
};
|
|
83
103
|
}
|
|
84
104
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
105
|
+
function createSessionStorageAdapter() {
|
|
106
|
+
const fallback = /* @__PURE__ */ new Map();
|
|
107
|
+
return {
|
|
108
|
+
get(key) {
|
|
109
|
+
if (typeof sessionStorage !== "undefined") {
|
|
110
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
111
|
+
}
|
|
112
|
+
return fallback.get(key) ?? null;
|
|
113
|
+
},
|
|
114
|
+
set(key, value) {
|
|
115
|
+
if (typeof sessionStorage === "undefined") {
|
|
116
|
+
fallback.set(key, value);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
121
|
+
} catch {
|
|
122
|
+
fallback.set(key, value);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
remove(key) {
|
|
126
|
+
fallback.delete(key);
|
|
127
|
+
if (typeof sessionStorage === "undefined") return;
|
|
128
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
var _transient = createSessionStorageAdapter();
|
|
85
133
|
var _storage = memoryStorageAdapter;
|
|
86
134
|
function saveTokenData(data, clientId) {
|
|
87
135
|
_storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
|
|
@@ -99,31 +147,31 @@ function removeTokenData(clientId) {
|
|
|
99
147
|
_storage.remove(buildKey(TOKEN_KEY, clientId));
|
|
100
148
|
}
|
|
101
149
|
function savePkceVerifier(clientId, verifier) {
|
|
102
|
-
|
|
150
|
+
_transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
|
|
103
151
|
}
|
|
104
152
|
function getPkceVerifier(clientId) {
|
|
105
|
-
return
|
|
153
|
+
return _transient.get(VERIFIER_KEY_PREFIX + clientId);
|
|
106
154
|
}
|
|
107
155
|
function removePkceVerifier(clientId) {
|
|
108
|
-
|
|
156
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
109
157
|
}
|
|
110
158
|
function saveOAuthState(state, clientId) {
|
|
111
|
-
|
|
159
|
+
_transient.set(buildKey(STATE_KEY, clientId), state);
|
|
112
160
|
}
|
|
113
161
|
function getOAuthState(clientId) {
|
|
114
|
-
return
|
|
162
|
+
return _transient.get(buildKey(STATE_KEY, clientId));
|
|
115
163
|
}
|
|
116
164
|
function removeOAuthState(clientId) {
|
|
117
|
-
|
|
165
|
+
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
118
166
|
}
|
|
119
167
|
function saveReturnUrl(url, clientId) {
|
|
120
|
-
|
|
168
|
+
_transient.set(buildKey(RETURN_URL_KEY, clientId), url);
|
|
121
169
|
}
|
|
122
170
|
function getReturnUrl(clientId) {
|
|
123
|
-
return
|
|
171
|
+
return _transient.get(buildKey(RETURN_URL_KEY, clientId));
|
|
124
172
|
}
|
|
125
173
|
function removeReturnUrl(clientId) {
|
|
126
|
-
|
|
174
|
+
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
127
175
|
}
|
|
128
176
|
function clearAllSsoData(clientId) {
|
|
129
177
|
if (clientId) {
|
|
@@ -131,23 +179,58 @@ function clearAllSsoData(clientId) {
|
|
|
131
179
|
removeOAuthState(clientId);
|
|
132
180
|
removeReturnUrl(clientId);
|
|
133
181
|
removePkceVerifier(clientId);
|
|
182
|
+
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
134
183
|
return;
|
|
135
184
|
}
|
|
136
185
|
removeTokenData();
|
|
137
186
|
removeOAuthState();
|
|
138
187
|
removeReturnUrl();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
188
|
+
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
189
|
+
const stores = [
|
|
190
|
+
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
191
|
+
typeof localStorage !== "undefined" ? localStorage : null
|
|
192
|
+
];
|
|
193
|
+
for (const store of stores) {
|
|
194
|
+
if (!store) continue;
|
|
195
|
+
const keys = [];
|
|
196
|
+
for (let i = 0; i < store.length; i++) {
|
|
197
|
+
const key = store.key(i);
|
|
198
|
+
if (key?.startsWith(prefix)) keys.push(key);
|
|
199
|
+
}
|
|
200
|
+
for (const key of keys) {
|
|
201
|
+
_transient.remove(key.slice(STORAGE_PREFIX.length));
|
|
202
|
+
store.removeItem(key);
|
|
146
203
|
}
|
|
147
204
|
}
|
|
148
205
|
}
|
|
149
206
|
|
|
150
|
-
// src/core/
|
|
207
|
+
// src/core/security.ts
|
|
208
|
+
function isTrustedReturnUrl(url, currentOrigin) {
|
|
209
|
+
if (!url) return false;
|
|
210
|
+
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
211
|
+
try {
|
|
212
|
+
return new URL(url).origin === currentOrigin;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function timingSafeEqualString(a, b) {
|
|
218
|
+
const ba = new TextEncoder().encode(a);
|
|
219
|
+
const bb = new TextEncoder().encode(b);
|
|
220
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
221
|
+
const len = Math.max(ba.length, bb.length);
|
|
222
|
+
let diff = ba.length ^ bb.length;
|
|
223
|
+
for (let i = 0; i < len; i++) {
|
|
224
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
225
|
+
}
|
|
226
|
+
return diff === 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/core/id-token.ts
|
|
230
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
231
|
+
function getCrypto() {
|
|
232
|
+
return _crypto ?? null;
|
|
233
|
+
}
|
|
151
234
|
function base64UrlDecode(input) {
|
|
152
235
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
153
236
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -182,14 +265,32 @@ function normalizeIssuer(url) {
|
|
|
182
265
|
return url.replace(/\/+$/, "");
|
|
183
266
|
}
|
|
184
267
|
var cachedJwks = null;
|
|
268
|
+
var cachedDiscovery = null;
|
|
185
269
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
186
|
-
async function
|
|
270
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
187
271
|
const now = Date.now();
|
|
188
|
-
if (
|
|
272
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
273
|
+
return cachedDiscovery.doc;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
277
|
+
const doc = res.ok ? await res.json() : null;
|
|
278
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
279
|
+
return doc;
|
|
280
|
+
} catch {
|
|
281
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
189
288
|
return cachedJwks.jwks;
|
|
190
289
|
}
|
|
290
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
291
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
191
292
|
try {
|
|
192
|
-
const res = await fetch(
|
|
293
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
193
294
|
if (!res.ok) return null;
|
|
194
295
|
const jwks = await res.json();
|
|
195
296
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -198,24 +299,13 @@ async function fetchJwks(baseUrl) {
|
|
|
198
299
|
return null;
|
|
199
300
|
}
|
|
200
301
|
}
|
|
201
|
-
async function
|
|
202
|
-
const hash = await crypto.subtle.digest(
|
|
203
|
-
"SHA-256",
|
|
204
|
-
new TextEncoder().encode(accessToken)
|
|
205
|
-
);
|
|
206
|
-
const bytes = new Uint8Array(hash);
|
|
207
|
-
const half = bytes.slice(0, bytes.length / 2);
|
|
208
|
-
let binary = "";
|
|
209
|
-
for (const b of half) {
|
|
210
|
-
binary += String.fromCharCode(b);
|
|
211
|
-
}
|
|
212
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
213
|
-
}
|
|
214
|
-
async function verifyRs256(token, jwk) {
|
|
302
|
+
async function verifyRs256Signature(token, jwk) {
|
|
215
303
|
try {
|
|
216
304
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
217
305
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
218
|
-
const
|
|
306
|
+
const c = getCrypto();
|
|
307
|
+
if (!c) return false;
|
|
308
|
+
const cryptoKey = await c.subtle.importKey(
|
|
219
309
|
"jwk",
|
|
220
310
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
221
311
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -224,7 +314,7 @@ async function verifyRs256(token, jwk) {
|
|
|
224
314
|
);
|
|
225
315
|
const signatureBytes = base64UrlDecode(signature);
|
|
226
316
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
227
|
-
return await
|
|
317
|
+
return await c.subtle.verify(
|
|
228
318
|
"RSASSA-PKCS1-v1_5",
|
|
229
319
|
cryptoKey,
|
|
230
320
|
signatureBytes,
|
|
@@ -234,7 +324,20 @@ async function verifyRs256(token, jwk) {
|
|
|
234
324
|
return false;
|
|
235
325
|
}
|
|
236
326
|
}
|
|
237
|
-
async function
|
|
327
|
+
async function computeAtHash(accessToken) {
|
|
328
|
+
const c = getCrypto();
|
|
329
|
+
if (!c) return "";
|
|
330
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
331
|
+
const bytes = new Uint8Array(hash);
|
|
332
|
+
const half = bytes.slice(0, bytes.length / 2);
|
|
333
|
+
let binary = "";
|
|
334
|
+
for (const b of half) {
|
|
335
|
+
binary += String.fromCharCode(b);
|
|
336
|
+
}
|
|
337
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
338
|
+
}
|
|
339
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
340
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
238
341
|
const header = decodeJwtHeader(idToken);
|
|
239
342
|
if (!header) {
|
|
240
343
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -243,54 +346,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
|
|
|
243
346
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
244
347
|
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
245
348
|
}
|
|
349
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
350
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
351
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
246
352
|
if (alg === "RS256") {
|
|
247
|
-
const jwks = await fetchJwks(
|
|
353
|
+
const jwks = await fetchJwks(baseUrl);
|
|
248
354
|
if (!jwks) {
|
|
249
355
|
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
250
356
|
}
|
|
251
357
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
252
|
-
const
|
|
358
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
253
359
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
254
360
|
);
|
|
255
|
-
|
|
361
|
+
const verifyAny = async (keys) => {
|
|
362
|
+
for (const key of keys) {
|
|
363
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
};
|
|
367
|
+
let candidates = matchCandidates(jwks);
|
|
368
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
369
|
+
if (candidates.length === 0 || !validSig) {
|
|
370
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
371
|
+
if (freshJwks) {
|
|
372
|
+
candidates = matchCandidates(freshJwks);
|
|
373
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (candidates.length === 0) {
|
|
256
377
|
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
257
378
|
}
|
|
258
|
-
const validSig = await verifyRs256(idToken, key);
|
|
259
379
|
if (!validSig) {
|
|
260
380
|
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
261
381
|
}
|
|
262
382
|
}
|
|
263
383
|
if (alg === "HS256") {
|
|
264
|
-
|
|
265
|
-
|
|
384
|
+
if (rejectHs256WhenRs256Available) {
|
|
385
|
+
const jwks = await fetchJwks(baseUrl);
|
|
386
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
387
|
+
if (hasRs256) {
|
|
388
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
throw new SsoError(
|
|
392
|
+
"id_token_hs256_unsupported",
|
|
393
|
+
"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"
|
|
266
394
|
);
|
|
267
395
|
}
|
|
268
396
|
const payload = decodeJwtPayload(idToken);
|
|
269
397
|
if (!payload) {
|
|
270
398
|
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
271
399
|
}
|
|
272
|
-
const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
|
|
273
400
|
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
274
|
-
if (tokenIssuer !==
|
|
401
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
275
402
|
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
276
403
|
}
|
|
277
|
-
|
|
404
|
+
const aud = payload.aud;
|
|
405
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
406
|
+
if (!audList.includes(expectedClientId)) {
|
|
278
407
|
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
279
408
|
}
|
|
280
|
-
if (
|
|
409
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
410
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
411
|
+
}
|
|
412
|
+
if (typeof payload.exp !== "number") {
|
|
413
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
414
|
+
}
|
|
415
|
+
if (Date.now() >= payload.exp * 1e3) {
|
|
281
416
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
282
417
|
}
|
|
418
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
419
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
420
|
+
}
|
|
283
421
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
284
422
|
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
285
423
|
}
|
|
286
424
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
287
425
|
const actual = await computeAtHash(accessToken);
|
|
288
|
-
if (actual
|
|
426
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
289
427
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
290
428
|
}
|
|
291
429
|
}
|
|
292
430
|
return { sub: payload.sub };
|
|
293
431
|
}
|
|
432
|
+
|
|
433
|
+
// src/core/SsoClient.ts
|
|
294
434
|
var _SsoClient = class _SsoClient {
|
|
295
435
|
constructor(config) {
|
|
296
436
|
this._discovery = null;
|
|
@@ -359,6 +499,17 @@ var _SsoClient = class _SsoClient {
|
|
|
359
499
|
if (d) return d.userinfo_endpoint;
|
|
360
500
|
return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
|
|
361
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
|
|
504
|
+
* 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
|
|
505
|
+
*/
|
|
506
|
+
_saveReturnUrlIfTrusted(returnUrl) {
|
|
507
|
+
if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
|
|
508
|
+
saveReturnUrl(returnUrl);
|
|
509
|
+
} else {
|
|
510
|
+
console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
362
513
|
// ============================================
|
|
363
514
|
// 公共 API
|
|
364
515
|
// ============================================
|
|
@@ -368,7 +519,8 @@ var _SsoClient = class _SsoClient {
|
|
|
368
519
|
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
369
520
|
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
370
521
|
*
|
|
371
|
-
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage
|
|
522
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
523
|
+
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
372
524
|
*/
|
|
373
525
|
async login(returnUrl) {
|
|
374
526
|
const verifier = generateCodeVerifier();
|
|
@@ -377,7 +529,7 @@ var _SsoClient = class _SsoClient {
|
|
|
377
529
|
savePkceVerifier(this.config.clientId, verifier);
|
|
378
530
|
saveOAuthState(state, this.config.clientId);
|
|
379
531
|
if (returnUrl) {
|
|
380
|
-
|
|
532
|
+
this._saveReturnUrlIfTrusted(returnUrl);
|
|
381
533
|
}
|
|
382
534
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
383
535
|
const params = new URLSearchParams();
|
|
@@ -401,7 +553,7 @@ var _SsoClient = class _SsoClient {
|
|
|
401
553
|
const state = generateState();
|
|
402
554
|
savePkceVerifier(this.config.clientId, verifier);
|
|
403
555
|
saveOAuthState(state, this.config.clientId);
|
|
404
|
-
if (returnUrl)
|
|
556
|
+
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
405
557
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
406
558
|
const params = new URLSearchParams();
|
|
407
559
|
params.set("response_type", "code");
|
|
@@ -413,11 +565,140 @@ var _SsoClient = class _SsoClient {
|
|
|
413
565
|
params.set("code_challenge_method", "S256");
|
|
414
566
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
415
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* 弹窗模式 SSO 登录
|
|
570
|
+
*
|
|
571
|
+
* 打开一个小窗口进行登录,认证完成后窗口自动关闭,
|
|
572
|
+
* 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
|
|
573
|
+
*
|
|
574
|
+
* 流程:
|
|
575
|
+
* 1. window.open() 打开授权 URL 到弹窗
|
|
576
|
+
* 2. 用户在弹窗中完成登录
|
|
577
|
+
* 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
|
|
578
|
+
* 4. 主页面收到消息后调用 handleCallback() 交换 token
|
|
579
|
+
* 5. 弹窗自动关闭
|
|
580
|
+
*
|
|
581
|
+
* @param options - 弹窗配置
|
|
582
|
+
* @param options.returnUrl - 登录成功后的返回地址
|
|
583
|
+
* @param options.width - 弹窗宽度(默认 480)
|
|
584
|
+
* @param options.height - 弹窗高度(默认 640)
|
|
585
|
+
* @returns TokenData
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* ```typescript
|
|
589
|
+
* const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
|
|
590
|
+
* try {
|
|
591
|
+
* const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
|
|
592
|
+
* console.log("登录成功", tokenData);
|
|
593
|
+
* } catch (err) {
|
|
594
|
+
* if (err instanceof SsoError && err.code === "popup_blocked") {
|
|
595
|
+
* // 弹窗被拦截,回退到同页重定向
|
|
596
|
+
* await client.login("/dashboard");
|
|
597
|
+
* }
|
|
598
|
+
* }
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
async loginPopup(options = {}) {
|
|
602
|
+
const verifier = generateCodeVerifier();
|
|
603
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
604
|
+
const state = generateState();
|
|
605
|
+
const popupNonce = generateState();
|
|
606
|
+
savePkceVerifier(this.config.clientId, verifier);
|
|
607
|
+
saveOAuthState(state, this.config.clientId);
|
|
608
|
+
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
609
|
+
if (options.returnUrl) {
|
|
610
|
+
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
611
|
+
}
|
|
612
|
+
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
613
|
+
const params = new URLSearchParams();
|
|
614
|
+
params.set("response_type", "code");
|
|
615
|
+
params.set("client_id", this.config.clientId);
|
|
616
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
617
|
+
params.set("scope", this.config.scopes || "openid profile");
|
|
618
|
+
params.set("state", state);
|
|
619
|
+
params.set("code_challenge", challenge);
|
|
620
|
+
params.set("code_challenge_method", "S256");
|
|
621
|
+
params.set("popup_nonce", popupNonce);
|
|
622
|
+
const width = options.width || 480;
|
|
623
|
+
const height = options.height || 640;
|
|
624
|
+
const left = Math.max(0, (window.screen.width - width) / 2);
|
|
625
|
+
const top = Math.max(0, (window.screen.height - height) / 2);
|
|
626
|
+
const popupFeatures = [
|
|
627
|
+
`width=${width}`,
|
|
628
|
+
`height=${height}`,
|
|
629
|
+
`left=${Math.round(left)}`,
|
|
630
|
+
`top=${Math.round(top)}`,
|
|
631
|
+
"resizable=yes",
|
|
632
|
+
"scrollbars=yes",
|
|
633
|
+
"status=yes"
|
|
634
|
+
].join(",");
|
|
635
|
+
const popup = window.open(
|
|
636
|
+
`${authorizeEndpoint}?${params.toString()}`,
|
|
637
|
+
"nihplod_sso_popup",
|
|
638
|
+
popupFeatures
|
|
639
|
+
);
|
|
640
|
+
if (!popup) {
|
|
641
|
+
removePkceVerifier(this.config.clientId);
|
|
642
|
+
removeOAuthState(this.config.clientId);
|
|
643
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
644
|
+
throw new SsoError(
|
|
645
|
+
"popup_blocked",
|
|
646
|
+
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
650
|
+
try {
|
|
651
|
+
popup.focus();
|
|
652
|
+
return await new Promise((resolve, reject) => {
|
|
653
|
+
let completed = false;
|
|
654
|
+
const handleMessage = (event) => {
|
|
655
|
+
if (completed) return;
|
|
656
|
+
if (event.origin !== redirectUriOrigin) return;
|
|
657
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
|
|
658
|
+
if (!event.data.callbackUrl) return;
|
|
659
|
+
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
660
|
+
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
661
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
662
|
+
completed = true;
|
|
663
|
+
cleanup();
|
|
664
|
+
if (popup && !popup.closed) {
|
|
665
|
+
popup.close();
|
|
666
|
+
}
|
|
667
|
+
this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
|
|
668
|
+
};
|
|
669
|
+
const pollTimer = setInterval(() => {
|
|
670
|
+
if (popup.closed) {
|
|
671
|
+
if (!completed) {
|
|
672
|
+
completed = true;
|
|
673
|
+
cleanup();
|
|
674
|
+
reject(
|
|
675
|
+
new SsoError(
|
|
676
|
+
"popup_closed",
|
|
677
|
+
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
678
|
+
)
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}, 500);
|
|
683
|
+
const cleanup = () => {
|
|
684
|
+
clearInterval(pollTimer);
|
|
685
|
+
window.removeEventListener("message", handleMessage);
|
|
686
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
687
|
+
};
|
|
688
|
+
window.addEventListener("message", handleMessage);
|
|
689
|
+
});
|
|
690
|
+
} catch (err) {
|
|
691
|
+
removePkceVerifier(this.config.clientId);
|
|
692
|
+
removeOAuthState(this.config.clientId);
|
|
693
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
694
|
+
throw err;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
416
697
|
/**
|
|
417
698
|
* 处理 OAuth 回调
|
|
418
699
|
*
|
|
419
700
|
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
420
|
-
* 成功后 token 自动保存到
|
|
701
|
+
* 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
|
|
421
702
|
*
|
|
422
703
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
423
704
|
* @returns TokenData 或 null
|
|
@@ -436,14 +717,14 @@ var _SsoClient = class _SsoClient {
|
|
|
436
717
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
437
718
|
}
|
|
438
719
|
const savedState = getOAuthState(this.config.clientId);
|
|
439
|
-
if (!savedState || savedState
|
|
720
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
440
721
|
removeOAuthState(this.config.clientId);
|
|
722
|
+
removePkceVerifier(this.config.clientId);
|
|
441
723
|
throw new SsoError(
|
|
442
724
|
"state_mismatch",
|
|
443
725
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
444
726
|
);
|
|
445
727
|
}
|
|
446
|
-
removeOAuthState(this.config.clientId);
|
|
447
728
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
448
729
|
if (!verifier) {
|
|
449
730
|
throw new SsoError(
|
|
@@ -451,7 +732,6 @@ var _SsoClient = class _SsoClient {
|
|
|
451
732
|
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
452
733
|
);
|
|
453
734
|
}
|
|
454
|
-
removePkceVerifier(this.config.clientId);
|
|
455
735
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
456
736
|
const body = new URLSearchParams();
|
|
457
737
|
body.set("grant_type", "authorization_code");
|
|
@@ -478,19 +758,22 @@ var _SsoClient = class _SsoClient {
|
|
|
478
758
|
errData = await res.json();
|
|
479
759
|
} catch {
|
|
480
760
|
}
|
|
761
|
+
const serverError = errData.error || "";
|
|
481
762
|
throw new SsoError(
|
|
482
|
-
|
|
763
|
+
mapOAuthErrorToSsoCode(serverError),
|
|
483
764
|
errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
|
|
484
765
|
);
|
|
485
766
|
}
|
|
486
767
|
const data = await res.json();
|
|
768
|
+
removeOAuthState(this.config.clientId);
|
|
769
|
+
removePkceVerifier(this.config.clientId);
|
|
487
770
|
if (data.id_token) {
|
|
488
771
|
try {
|
|
489
772
|
await validateIdToken(
|
|
490
773
|
data.id_token,
|
|
774
|
+
data.access_token,
|
|
491
775
|
this.config.ssoBaseUrl,
|
|
492
|
-
this.config.clientId
|
|
493
|
-
data.access_token
|
|
776
|
+
this.config.clientId
|
|
494
777
|
);
|
|
495
778
|
} catch (err) {
|
|
496
779
|
removeTokenData(this.config.clientId);
|
|
@@ -539,15 +822,24 @@ var _SsoClient = class _SsoClient {
|
|
|
539
822
|
if (this.config.clientSecret) {
|
|
540
823
|
body.set("client_secret", this.config.clientSecret);
|
|
541
824
|
}
|
|
542
|
-
let res;
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
825
|
+
let res = null;
|
|
826
|
+
let lastErr;
|
|
827
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
828
|
+
try {
|
|
829
|
+
res = await fetch(tokenEndpoint, {
|
|
830
|
+
method: "POST",
|
|
831
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
832
|
+
body: body.toString()
|
|
833
|
+
});
|
|
834
|
+
lastErr = null;
|
|
835
|
+
break;
|
|
836
|
+
} catch (err) {
|
|
837
|
+
lastErr = err;
|
|
838
|
+
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (lastErr || !res) {
|
|
842
|
+
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
551
843
|
}
|
|
552
844
|
if (!res.ok) {
|
|
553
845
|
let errData = {};
|
|
@@ -560,17 +852,31 @@ var _SsoClient = class _SsoClient {
|
|
|
560
852
|
removeTokenData(this.config.clientId);
|
|
561
853
|
}
|
|
562
854
|
throw new SsoError(
|
|
563
|
-
errorCode
|
|
855
|
+
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
564
856
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
565
857
|
);
|
|
566
858
|
}
|
|
567
859
|
const data = await res.json();
|
|
860
|
+
if (data.id_token) {
|
|
861
|
+
try {
|
|
862
|
+
await validateIdToken(
|
|
863
|
+
data.id_token,
|
|
864
|
+
data.access_token,
|
|
865
|
+
this.config.ssoBaseUrl,
|
|
866
|
+
this.config.clientId
|
|
867
|
+
);
|
|
868
|
+
} catch (err) {
|
|
869
|
+
removeTokenData(this.config.clientId);
|
|
870
|
+
throw err;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
568
873
|
const now = Date.now();
|
|
569
874
|
const tokenData = {
|
|
570
875
|
access_token: data.access_token,
|
|
571
876
|
token_type: data.token_type,
|
|
572
877
|
expires_in: data.expires_in,
|
|
573
|
-
|
|
878
|
+
// RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
|
|
879
|
+
refresh_token: data.refresh_token || current.refresh_token,
|
|
574
880
|
id_token: data.id_token,
|
|
575
881
|
issued_at: now,
|
|
576
882
|
expires_at: now + data.expires_in * 1e3
|
|
@@ -671,13 +977,14 @@ var _SsoClient = class _SsoClient {
|
|
|
671
977
|
}
|
|
672
978
|
if (redirectToSso) {
|
|
673
979
|
const discovery = await this._getDiscovery();
|
|
674
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/
|
|
980
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
|
|
675
981
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
676
982
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
677
|
-
|
|
983
|
+
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
984
|
+
if (postLogoutUri) {
|
|
678
985
|
logoutUrl.searchParams.set(
|
|
679
986
|
"post_logout_redirect_uri",
|
|
680
|
-
|
|
987
|
+
postLogoutUri
|
|
681
988
|
);
|
|
682
989
|
}
|
|
683
990
|
if (idTokenHint) {
|
|
@@ -708,6 +1015,7 @@ var SsoClient = _SsoClient;
|
|
|
708
1015
|
var SsoContext = createContext(null);
|
|
709
1016
|
var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
710
1017
|
var LOCK_TTL_MS = 5e3;
|
|
1018
|
+
var ownedLocks = /* @__PURE__ */ new Map();
|
|
711
1019
|
function lockKey(clientId) {
|
|
712
1020
|
return REFRESH_LOCK_PREFIX + clientId;
|
|
713
1021
|
}
|
|
@@ -722,12 +1030,44 @@ function acquireRefreshLock(clientId) {
|
|
|
722
1030
|
return false;
|
|
723
1031
|
}
|
|
724
1032
|
}
|
|
725
|
-
|
|
1033
|
+
const token = `${now}:${Math.random().toString(36).slice(2)}`;
|
|
1034
|
+
localStorage.setItem(key, token);
|
|
1035
|
+
if (localStorage.getItem(key) !== token) return false;
|
|
1036
|
+
ownedLocks.set(clientId, token);
|
|
726
1037
|
return true;
|
|
727
1038
|
}
|
|
728
1039
|
function releaseRefreshLock(clientId) {
|
|
729
1040
|
if (typeof localStorage === "undefined") return;
|
|
730
|
-
|
|
1041
|
+
const owned = ownedLocks.get(clientId);
|
|
1042
|
+
ownedLocks.delete(clientId);
|
|
1043
|
+
if (owned && localStorage.getItem(lockKey(clientId)) === owned) {
|
|
1044
|
+
localStorage.removeItem(lockKey(clientId));
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
async function withRefreshLock(clientId, task) {
|
|
1048
|
+
if (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function") {
|
|
1049
|
+
try {
|
|
1050
|
+
let ran = false;
|
|
1051
|
+
await navigator.locks.request(
|
|
1052
|
+
`nihplod_sso_refresh_${clientId}`,
|
|
1053
|
+
{ ifAvailable: true },
|
|
1054
|
+
async (lock) => {
|
|
1055
|
+
if (!lock) return;
|
|
1056
|
+
ran = true;
|
|
1057
|
+
await task();
|
|
1058
|
+
}
|
|
1059
|
+
);
|
|
1060
|
+
return ran;
|
|
1061
|
+
} catch {
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (!acquireRefreshLock(clientId)) return false;
|
|
1065
|
+
try {
|
|
1066
|
+
await task();
|
|
1067
|
+
return true;
|
|
1068
|
+
} finally {
|
|
1069
|
+
releaseRefreshLock(clientId);
|
|
1070
|
+
}
|
|
731
1071
|
}
|
|
732
1072
|
function SsoProvider({
|
|
733
1073
|
config,
|
|
@@ -738,12 +1078,12 @@ function SsoProvider({
|
|
|
738
1078
|
const [user, setUser] = useState(null);
|
|
739
1079
|
const [isLoading, setIsLoading] = useState(true);
|
|
740
1080
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
741
|
-
const
|
|
1081
|
+
const [client] = useState(() => new SsoClient(config));
|
|
742
1082
|
const refreshTimerRef = useRef(null);
|
|
743
1083
|
const loadedRef = useRef(false);
|
|
744
|
-
const client = clientRef.current;
|
|
745
1084
|
const loadUser = useCallback(async () => {
|
|
746
|
-
|
|
1085
|
+
const tokenData = getTokenData(client.config.clientId);
|
|
1086
|
+
if (!tokenData) {
|
|
747
1087
|
setUser(null);
|
|
748
1088
|
setIsAuthenticated(false);
|
|
749
1089
|
setIsLoading(false);
|
|
@@ -764,14 +1104,25 @@ function SsoProvider({
|
|
|
764
1104
|
useEffect(() => {
|
|
765
1105
|
if (loadedRef.current) return;
|
|
766
1106
|
loadedRef.current = true;
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1107
|
+
Promise.resolve().then(() => {
|
|
1108
|
+
if (getTokenData(client.config.clientId)) {
|
|
1109
|
+
loadUser();
|
|
1110
|
+
} else {
|
|
1111
|
+
setIsLoading(false);
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
772
1114
|
}, [client, loadUser]);
|
|
773
1115
|
useEffect(() => {
|
|
774
1116
|
let active = true;
|
|
1117
|
+
const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
|
|
1118
|
+
try {
|
|
1119
|
+
const td = await client.refreshToken();
|
|
1120
|
+
onTokenRefreshed?.(td.access_token);
|
|
1121
|
+
loadUser();
|
|
1122
|
+
} catch {
|
|
1123
|
+
setTimeout(() => loadUser(), 500);
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
775
1126
|
const scheduleNextRefresh = () => {
|
|
776
1127
|
if (!active) return;
|
|
777
1128
|
if (refreshTimerRef.current) {
|
|
@@ -782,41 +1133,23 @@ function SsoProvider({
|
|
|
782
1133
|
if (!tokenData) return;
|
|
783
1134
|
const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
|
|
784
1135
|
if (remainingSec <= 0) {
|
|
785
|
-
|
|
786
|
-
client.refreshToken().then((td) => {
|
|
787
|
-
onTokenRefreshed?.(td.access_token);
|
|
788
|
-
loadUser();
|
|
789
|
-
}).catch(() => {
|
|
790
|
-
setTimeout(() => loadUser(), 500);
|
|
791
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
1136
|
+
void attemptRefresh();
|
|
792
1137
|
return;
|
|
793
1138
|
}
|
|
794
1139
|
if (remainingSec <= refreshThreshold) {
|
|
795
|
-
|
|
796
|
-
client.refreshToken().then((td) => {
|
|
797
|
-
onTokenRefreshed?.(td.access_token);
|
|
798
|
-
loadUser();
|
|
799
|
-
}).catch(() => {
|
|
800
|
-
setTimeout(() => loadUser(), 500);
|
|
801
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
1140
|
+
void attemptRefresh();
|
|
802
1141
|
return;
|
|
803
1142
|
}
|
|
804
1143
|
const delayMs = (remainingSec - refreshThreshold) * 1e3;
|
|
805
1144
|
refreshTimerRef.current = setTimeout(() => {
|
|
1145
|
+
if (!active) return;
|
|
806
1146
|
const td = getTokenData(client.config.clientId);
|
|
807
1147
|
if (!td) return;
|
|
808
1148
|
const secLeft = (td.expires_at - Date.now()) / 1e3;
|
|
809
1149
|
if (secLeft <= refreshThreshold) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
}
|
|
813
|
-
client.refreshToken().then((newTd) => {
|
|
814
|
-
onTokenRefreshed?.(newTd.access_token);
|
|
815
|
-
loadUser();
|
|
816
|
-
}).catch(() => {
|
|
817
|
-
setTimeout(() => loadUser(), 500);
|
|
818
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
819
|
-
scheduleNextRefresh();
|
|
1150
|
+
void attemptRefresh().then((didRefresh) => {
|
|
1151
|
+
if (didRefresh) scheduleNextRefresh();
|
|
1152
|
+
});
|
|
820
1153
|
}
|
|
821
1154
|
}, Math.max(delayMs, 1e3));
|
|
822
1155
|
};
|
|
@@ -844,6 +1177,14 @@ function SsoProvider({
|
|
|
844
1177
|
},
|
|
845
1178
|
[client]
|
|
846
1179
|
);
|
|
1180
|
+
const loginPopup = useCallback(
|
|
1181
|
+
async (options) => {
|
|
1182
|
+
const tokenData = await client.loginPopup(options);
|
|
1183
|
+
await loadUser();
|
|
1184
|
+
return tokenData;
|
|
1185
|
+
},
|
|
1186
|
+
[client, loadUser]
|
|
1187
|
+
);
|
|
847
1188
|
const logout = useCallback(
|
|
848
1189
|
async (redirectToSso = false) => {
|
|
849
1190
|
await client.logout(redirectToSso);
|
|
@@ -863,6 +1204,7 @@ function SsoProvider({
|
|
|
863
1204
|
isAuthenticated,
|
|
864
1205
|
isLoading,
|
|
865
1206
|
login,
|
|
1207
|
+
loginPopup,
|
|
866
1208
|
logout,
|
|
867
1209
|
refreshUser,
|
|
868
1210
|
getAccessToken,
|
|
@@ -883,17 +1225,26 @@ import React2, { useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
|
883
1225
|
function RequireAuth({
|
|
884
1226
|
children,
|
|
885
1227
|
fallback,
|
|
886
|
-
autoLogin = true
|
|
1228
|
+
autoLogin = true,
|
|
1229
|
+
usePopup = false
|
|
887
1230
|
}) {
|
|
888
|
-
const { isAuthenticated, isLoading, login } = useSso();
|
|
1231
|
+
const { isAuthenticated, isLoading, login, loginPopup } = useSso();
|
|
889
1232
|
const loginTriggeredRef = useRef2(false);
|
|
890
1233
|
useEffect2(() => {
|
|
891
1234
|
if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
|
|
892
1235
|
loginTriggeredRef.current = true;
|
|
893
1236
|
const currentPath = window.location.pathname + window.location.search;
|
|
894
|
-
|
|
1237
|
+
if (usePopup) {
|
|
1238
|
+
loginPopup({ returnUrl: currentPath }).catch((err) => {
|
|
1239
|
+
if (err.code === "popup_blocked") {
|
|
1240
|
+
login(currentPath);
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
} else {
|
|
1244
|
+
login(currentPath);
|
|
1245
|
+
}
|
|
895
1246
|
}
|
|
896
|
-
}, [isLoading, isAuthenticated, autoLogin, login]);
|
|
1247
|
+
}, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
|
|
897
1248
|
if (isLoading) {
|
|
898
1249
|
return fallback || React2.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
|
|
899
1250
|
}
|
|
@@ -923,6 +1274,20 @@ function CallbackPage() {
|
|
|
923
1274
|
const [error, setError] = useState2(null);
|
|
924
1275
|
const [processing, setProcessing] = useState2(true);
|
|
925
1276
|
useEffect3(() => {
|
|
1277
|
+
if (window.opener && !window.opener.closed) {
|
|
1278
|
+
const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
|
|
1279
|
+
let targetOrigin = window.location.origin;
|
|
1280
|
+
try {
|
|
1281
|
+
targetOrigin = window.opener.location.origin;
|
|
1282
|
+
} catch {
|
|
1283
|
+
}
|
|
1284
|
+
window.opener.postMessage(
|
|
1285
|
+
{ type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
|
|
1286
|
+
targetOrigin
|
|
1287
|
+
);
|
|
1288
|
+
Promise.resolve().then(() => setProcessing(false));
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
926
1291
|
let cancelled = false;
|
|
927
1292
|
async function handleCallback() {
|
|
928
1293
|
try {
|
|
@@ -931,7 +1296,7 @@ function CallbackPage() {
|
|
|
931
1296
|
await refreshUser();
|
|
932
1297
|
const returnUrl = getReturnUrl();
|
|
933
1298
|
removeReturnUrl();
|
|
934
|
-
window.location.href = returnUrl
|
|
1299
|
+
window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
|
|
935
1300
|
} catch (err) {
|
|
936
1301
|
if (cancelled) return;
|
|
937
1302
|
if (err instanceof SsoError) {
|