@nihplod/sso-sdk 1.0.1 → 1.2.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 +92 -9
- package/dist/index.d.mts +136 -13
- package/dist/index.d.ts +136 -13
- package/dist/index.js +475 -78
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +467 -77
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +54 -6
- package/dist/next/index.d.ts +54 -6
- package/dist/next/index.js +355 -85
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +351 -84
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +175 -14
- package/dist/react/index.d.ts +175 -14
- package/dist/react/index.js +627 -151
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +630 -155
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react/index.js
CHANGED
|
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var react_exports = {};
|
|
32
32
|
__export(react_exports, {
|
|
33
33
|
CallbackPage: () => CallbackPage,
|
|
34
|
+
DefaultCallbackError: () => DefaultCallbackError,
|
|
34
35
|
RequireAuth: () => RequireAuth,
|
|
35
36
|
SsoProvider: () => SsoProvider,
|
|
36
37
|
useSso: () => useSso,
|
|
@@ -42,6 +43,26 @@ module.exports = __toCommonJS(react_exports);
|
|
|
42
43
|
var import_react = __toESM(require("react"));
|
|
43
44
|
|
|
44
45
|
// src/core/errors.ts
|
|
46
|
+
function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
|
|
47
|
+
switch (oauthError) {
|
|
48
|
+
case "invalid_grant":
|
|
49
|
+
return context === "refresh" ? "session_expired" : "authorization_code_expired";
|
|
50
|
+
case "invalid_client":
|
|
51
|
+
return "client_disabled";
|
|
52
|
+
case "access_denied":
|
|
53
|
+
return "user_denied_authorization";
|
|
54
|
+
case "server_error":
|
|
55
|
+
return "sso_server_error";
|
|
56
|
+
case "rate_limited":
|
|
57
|
+
return "rate_limited";
|
|
58
|
+
case "invalid_scope":
|
|
59
|
+
case "invalid_request":
|
|
60
|
+
case "unauthorized_client":
|
|
61
|
+
case "unsupported_grant_type":
|
|
62
|
+
default:
|
|
63
|
+
return "token_request_failed";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
45
66
|
var SsoError = class extends Error {
|
|
46
67
|
constructor(code, description, cause) {
|
|
47
68
|
super(`[SSO SDK] ${code}: ${description}`);
|
|
@@ -97,6 +118,7 @@ var TOKEN_KEY = "token";
|
|
|
97
118
|
var VERIFIER_KEY_PREFIX = "pkce_verifier_";
|
|
98
119
|
var STATE_KEY = "oauth_state";
|
|
99
120
|
var RETURN_URL_KEY = "return_url";
|
|
121
|
+
var LOGOUT_STATE_KEY = "logout_state";
|
|
100
122
|
function buildKey(base, clientId) {
|
|
101
123
|
return clientId ? `${base}:${clientId}` : base;
|
|
102
124
|
}
|
|
@@ -115,7 +137,35 @@ function createMemoryStorageAdapter() {
|
|
|
115
137
|
};
|
|
116
138
|
}
|
|
117
139
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
118
|
-
|
|
140
|
+
function createSessionStorageAdapter() {
|
|
141
|
+
const fallback = /* @__PURE__ */ new Map();
|
|
142
|
+
return {
|
|
143
|
+
get(key) {
|
|
144
|
+
if (typeof sessionStorage !== "undefined") {
|
|
145
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
146
|
+
}
|
|
147
|
+
return fallback.get(key) ?? null;
|
|
148
|
+
},
|
|
149
|
+
set(key, value) {
|
|
150
|
+
if (typeof sessionStorage === "undefined") {
|
|
151
|
+
fallback.set(key, value);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
156
|
+
} catch {
|
|
157
|
+
fallback.set(key, value);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
remove(key) {
|
|
161
|
+
fallback.delete(key);
|
|
162
|
+
if (typeof sessionStorage === "undefined") return;
|
|
163
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
var _transient = createSessionStorageAdapter();
|
|
168
|
+
var _storage = createSessionStorageAdapter();
|
|
119
169
|
function saveTokenData(data, clientId) {
|
|
120
170
|
_storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
|
|
121
171
|
}
|
|
@@ -132,55 +182,102 @@ function removeTokenData(clientId) {
|
|
|
132
182
|
_storage.remove(buildKey(TOKEN_KEY, clientId));
|
|
133
183
|
}
|
|
134
184
|
function savePkceVerifier(clientId, verifier) {
|
|
135
|
-
|
|
185
|
+
_transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
|
|
136
186
|
}
|
|
137
187
|
function getPkceVerifier(clientId) {
|
|
138
|
-
return
|
|
188
|
+
return _transient.get(VERIFIER_KEY_PREFIX + clientId);
|
|
139
189
|
}
|
|
140
190
|
function removePkceVerifier(clientId) {
|
|
141
|
-
|
|
191
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
142
192
|
}
|
|
143
193
|
function saveOAuthState(state, clientId) {
|
|
144
|
-
|
|
194
|
+
_transient.set(buildKey(STATE_KEY, clientId), state);
|
|
145
195
|
}
|
|
146
196
|
function getOAuthState(clientId) {
|
|
147
|
-
return
|
|
197
|
+
return _transient.get(buildKey(STATE_KEY, clientId));
|
|
148
198
|
}
|
|
149
199
|
function removeOAuthState(clientId) {
|
|
150
|
-
|
|
200
|
+
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
201
|
+
}
|
|
202
|
+
function saveLogoutState(state, clientId) {
|
|
203
|
+
_transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
|
|
204
|
+
}
|
|
205
|
+
function getLogoutState(clientId) {
|
|
206
|
+
return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
|
|
207
|
+
}
|
|
208
|
+
function removeLogoutState(clientId) {
|
|
209
|
+
_transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
|
|
151
210
|
}
|
|
152
211
|
function saveReturnUrl(url, clientId) {
|
|
153
|
-
|
|
212
|
+
_transient.set(buildKey(RETURN_URL_KEY, clientId), url);
|
|
154
213
|
}
|
|
155
214
|
function getReturnUrl(clientId) {
|
|
156
|
-
return
|
|
215
|
+
return _transient.get(buildKey(RETURN_URL_KEY, clientId));
|
|
157
216
|
}
|
|
158
217
|
function removeReturnUrl(clientId) {
|
|
159
|
-
|
|
218
|
+
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
160
219
|
}
|
|
161
220
|
function clearAllSsoData(clientId) {
|
|
162
221
|
if (clientId) {
|
|
163
222
|
removeTokenData(clientId);
|
|
164
223
|
removeOAuthState(clientId);
|
|
165
224
|
removeReturnUrl(clientId);
|
|
225
|
+
removeLogoutState(clientId);
|
|
166
226
|
removePkceVerifier(clientId);
|
|
227
|
+
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
167
228
|
return;
|
|
168
229
|
}
|
|
169
230
|
removeTokenData();
|
|
170
231
|
removeOAuthState();
|
|
171
232
|
removeReturnUrl();
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
233
|
+
removeLogoutState();
|
|
234
|
+
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
235
|
+
const stores = [
|
|
236
|
+
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
237
|
+
typeof localStorage !== "undefined" ? localStorage : null
|
|
238
|
+
];
|
|
239
|
+
for (const store of stores) {
|
|
240
|
+
if (!store) continue;
|
|
241
|
+
const keys = [];
|
|
242
|
+
for (let i = 0; i < store.length; i++) {
|
|
243
|
+
const key = store.key(i);
|
|
244
|
+
if (key?.startsWith(prefix)) keys.push(key);
|
|
245
|
+
}
|
|
246
|
+
for (const key of keys) {
|
|
247
|
+
_transient.remove(key.slice(STORAGE_PREFIX.length));
|
|
248
|
+
store.removeItem(key);
|
|
179
249
|
}
|
|
180
250
|
}
|
|
181
251
|
}
|
|
182
252
|
|
|
183
|
-
// src/core/
|
|
253
|
+
// src/core/security.ts
|
|
254
|
+
function isTrustedReturnUrl(url, currentOrigin) {
|
|
255
|
+
if (!url) return false;
|
|
256
|
+
if (url.includes("\\")) return false;
|
|
257
|
+
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
258
|
+
try {
|
|
259
|
+
return new URL(url).origin === currentOrigin;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function timingSafeEqualString(a, b) {
|
|
265
|
+
const ba = new TextEncoder().encode(a);
|
|
266
|
+
const bb = new TextEncoder().encode(b);
|
|
267
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
268
|
+
const len = Math.max(ba.length, bb.length);
|
|
269
|
+
let diff = ba.length ^ bb.length;
|
|
270
|
+
for (let i = 0; i < len; i++) {
|
|
271
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
272
|
+
}
|
|
273
|
+
return diff === 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/core/id-token.ts
|
|
277
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
278
|
+
function getCrypto() {
|
|
279
|
+
return _crypto ?? null;
|
|
280
|
+
}
|
|
184
281
|
function base64UrlDecode(input) {
|
|
185
282
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
186
283
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -215,14 +312,32 @@ function normalizeIssuer(url) {
|
|
|
215
312
|
return url.replace(/\/+$/, "");
|
|
216
313
|
}
|
|
217
314
|
var cachedJwks = null;
|
|
315
|
+
var cachedDiscovery = null;
|
|
218
316
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
219
|
-
async function
|
|
317
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
220
318
|
const now = Date.now();
|
|
221
|
-
if (
|
|
319
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
320
|
+
return cachedDiscovery.doc;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
324
|
+
const doc = res.ok ? await res.json() : null;
|
|
325
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
326
|
+
return doc;
|
|
327
|
+
} catch {
|
|
328
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
222
335
|
return cachedJwks.jwks;
|
|
223
336
|
}
|
|
337
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
338
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
224
339
|
try {
|
|
225
|
-
const res = await fetch(
|
|
340
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
226
341
|
if (!res.ok) return null;
|
|
227
342
|
const jwks = await res.json();
|
|
228
343
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -231,24 +346,13 @@ async function fetchJwks(baseUrl) {
|
|
|
231
346
|
return null;
|
|
232
347
|
}
|
|
233
348
|
}
|
|
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) {
|
|
349
|
+
async function verifyRs256Signature(token, jwk) {
|
|
248
350
|
try {
|
|
249
351
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
250
352
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
251
|
-
const
|
|
353
|
+
const c = getCrypto();
|
|
354
|
+
if (!c) return false;
|
|
355
|
+
const cryptoKey = await c.subtle.importKey(
|
|
252
356
|
"jwk",
|
|
253
357
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
254
358
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -257,7 +361,7 @@ async function verifyRs256(token, jwk) {
|
|
|
257
361
|
);
|
|
258
362
|
const signatureBytes = base64UrlDecode(signature);
|
|
259
363
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
260
|
-
return await
|
|
364
|
+
return await c.subtle.verify(
|
|
261
365
|
"RSASSA-PKCS1-v1_5",
|
|
262
366
|
cryptoKey,
|
|
263
367
|
signatureBytes,
|
|
@@ -267,7 +371,20 @@ async function verifyRs256(token, jwk) {
|
|
|
267
371
|
return false;
|
|
268
372
|
}
|
|
269
373
|
}
|
|
270
|
-
async function
|
|
374
|
+
async function computeAtHash(accessToken) {
|
|
375
|
+
const c = getCrypto();
|
|
376
|
+
if (!c) return "";
|
|
377
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
378
|
+
const bytes = new Uint8Array(hash);
|
|
379
|
+
const half = bytes.slice(0, bytes.length / 2);
|
|
380
|
+
let binary = "";
|
|
381
|
+
for (const b of half) {
|
|
382
|
+
binary += String.fromCharCode(b);
|
|
383
|
+
}
|
|
384
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
385
|
+
}
|
|
386
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
387
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
271
388
|
const header = decodeJwtHeader(idToken);
|
|
272
389
|
if (!header) {
|
|
273
390
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -276,54 +393,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
|
|
|
276
393
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
277
394
|
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
278
395
|
}
|
|
396
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
397
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
398
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
279
399
|
if (alg === "RS256") {
|
|
280
|
-
const jwks = await fetchJwks(
|
|
400
|
+
const jwks = await fetchJwks(baseUrl);
|
|
281
401
|
if (!jwks) {
|
|
282
402
|
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
283
403
|
}
|
|
284
404
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
285
|
-
const
|
|
405
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
286
406
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
287
407
|
);
|
|
288
|
-
|
|
408
|
+
const verifyAny = async (keys) => {
|
|
409
|
+
for (const key of keys) {
|
|
410
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
};
|
|
414
|
+
let candidates = matchCandidates(jwks);
|
|
415
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
416
|
+
if (candidates.length === 0 || !validSig) {
|
|
417
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
418
|
+
if (freshJwks) {
|
|
419
|
+
candidates = matchCandidates(freshJwks);
|
|
420
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (candidates.length === 0) {
|
|
289
424
|
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
290
425
|
}
|
|
291
|
-
const validSig = await verifyRs256(idToken, key);
|
|
292
426
|
if (!validSig) {
|
|
293
427
|
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
294
428
|
}
|
|
295
429
|
}
|
|
296
430
|
if (alg === "HS256") {
|
|
297
|
-
|
|
298
|
-
|
|
431
|
+
if (rejectHs256WhenRs256Available) {
|
|
432
|
+
const jwks = await fetchJwks(baseUrl);
|
|
433
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
434
|
+
if (hasRs256) {
|
|
435
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
throw new SsoError(
|
|
439
|
+
"id_token_hs256_unsupported",
|
|
440
|
+
"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
441
|
);
|
|
300
442
|
}
|
|
301
443
|
const payload = decodeJwtPayload(idToken);
|
|
302
444
|
if (!payload) {
|
|
303
445
|
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
304
446
|
}
|
|
305
|
-
const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
|
|
306
447
|
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
307
|
-
if (tokenIssuer !==
|
|
448
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
308
449
|
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
309
450
|
}
|
|
310
|
-
|
|
451
|
+
const aud = payload.aud;
|
|
452
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
453
|
+
if (!audList.includes(expectedClientId)) {
|
|
311
454
|
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
312
455
|
}
|
|
313
|
-
if (
|
|
456
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
457
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
458
|
+
}
|
|
459
|
+
if (typeof payload.exp !== "number") {
|
|
460
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
461
|
+
}
|
|
462
|
+
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
314
463
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
315
464
|
}
|
|
465
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
466
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
467
|
+
}
|
|
316
468
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
317
469
|
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
318
470
|
}
|
|
319
471
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
320
472
|
const actual = await computeAtHash(accessToken);
|
|
321
|
-
if (actual
|
|
473
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
322
474
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
323
475
|
}
|
|
324
476
|
}
|
|
325
477
|
return { sub: payload.sub };
|
|
326
478
|
}
|
|
479
|
+
|
|
480
|
+
// src/core/SsoClient.ts
|
|
327
481
|
var _SsoClient = class _SsoClient {
|
|
328
482
|
constructor(config) {
|
|
329
483
|
this._discovery = null;
|
|
@@ -392,6 +546,17 @@ var _SsoClient = class _SsoClient {
|
|
|
392
546
|
if (d) return d.userinfo_endpoint;
|
|
393
547
|
return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
|
|
394
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
|
|
551
|
+
* 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
|
|
552
|
+
*/
|
|
553
|
+
_saveReturnUrlIfTrusted(returnUrl) {
|
|
554
|
+
if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
|
|
555
|
+
saveReturnUrl(returnUrl, this.config.clientId);
|
|
556
|
+
} else {
|
|
557
|
+
console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
395
560
|
// ============================================
|
|
396
561
|
// 公共 API
|
|
397
562
|
// ============================================
|
|
@@ -401,7 +566,12 @@ var _SsoClient = class _SsoClient {
|
|
|
401
566
|
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
402
567
|
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
403
568
|
*
|
|
404
|
-
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage
|
|
569
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
570
|
+
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
571
|
+
*
|
|
572
|
+
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
573
|
+
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
574
|
+
* 同一次登录只使用其中一个入口。
|
|
405
575
|
*/
|
|
406
576
|
async login(returnUrl) {
|
|
407
577
|
const verifier = generateCodeVerifier();
|
|
@@ -410,7 +580,7 @@ var _SsoClient = class _SsoClient {
|
|
|
410
580
|
savePkceVerifier(this.config.clientId, verifier);
|
|
411
581
|
saveOAuthState(state, this.config.clientId);
|
|
412
582
|
if (returnUrl) {
|
|
413
|
-
|
|
583
|
+
this._saveReturnUrlIfTrusted(returnUrl);
|
|
414
584
|
}
|
|
415
585
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
416
586
|
const params = new URLSearchParams();
|
|
@@ -427,6 +597,10 @@ var _SsoClient = class _SsoClient {
|
|
|
427
597
|
* 构建登录 URL(不跳转,返回 URL 字符串)
|
|
428
598
|
*
|
|
429
599
|
* 适用于需要手动处理跳转的场景。
|
|
600
|
+
*
|
|
601
|
+
* ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
602
|
+
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
603
|
+
* 同一次登录只使用其中一个入口。
|
|
430
604
|
*/
|
|
431
605
|
async getLoginUrl(returnUrl) {
|
|
432
606
|
const verifier = generateCodeVerifier();
|
|
@@ -434,7 +608,7 @@ var _SsoClient = class _SsoClient {
|
|
|
434
608
|
const state = generateState();
|
|
435
609
|
savePkceVerifier(this.config.clientId, verifier);
|
|
436
610
|
saveOAuthState(state, this.config.clientId);
|
|
437
|
-
if (returnUrl)
|
|
611
|
+
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
438
612
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
439
613
|
const params = new URLSearchParams();
|
|
440
614
|
params.set("response_type", "code");
|
|
@@ -446,11 +620,140 @@ var _SsoClient = class _SsoClient {
|
|
|
446
620
|
params.set("code_challenge_method", "S256");
|
|
447
621
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
448
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* 弹窗模式 SSO 登录
|
|
625
|
+
*
|
|
626
|
+
* 打开一个小窗口进行登录,认证完成后窗口自动关闭,
|
|
627
|
+
* 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
|
|
628
|
+
*
|
|
629
|
+
* 流程:
|
|
630
|
+
* 1. window.open() 打开授权 URL 到弹窗
|
|
631
|
+
* 2. 用户在弹窗中完成登录
|
|
632
|
+
* 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
|
|
633
|
+
* 4. 主页面收到消息后调用 handleCallback() 交换 token
|
|
634
|
+
* 5. 弹窗自动关闭
|
|
635
|
+
*
|
|
636
|
+
* @param options - 弹窗配置
|
|
637
|
+
* @param options.returnUrl - 登录成功后的返回地址
|
|
638
|
+
* @param options.width - 弹窗宽度(默认 480)
|
|
639
|
+
* @param options.height - 弹窗高度(默认 640)
|
|
640
|
+
* @returns TokenData
|
|
641
|
+
*
|
|
642
|
+
* @example
|
|
643
|
+
* ```typescript
|
|
644
|
+
* const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
|
|
645
|
+
* try {
|
|
646
|
+
* const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
|
|
647
|
+
* console.log("登录成功", tokenData);
|
|
648
|
+
* } catch (err) {
|
|
649
|
+
* if (err instanceof SsoError && err.code === "popup_blocked") {
|
|
650
|
+
* // 弹窗被拦截,回退到同页重定向
|
|
651
|
+
* await client.login("/dashboard");
|
|
652
|
+
* }
|
|
653
|
+
* }
|
|
654
|
+
* ```
|
|
655
|
+
*/
|
|
656
|
+
async loginPopup(options = {}) {
|
|
657
|
+
const verifier = generateCodeVerifier();
|
|
658
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
659
|
+
const state = generateState();
|
|
660
|
+
const popupNonce = generateState();
|
|
661
|
+
savePkceVerifier(this.config.clientId, verifier);
|
|
662
|
+
saveOAuthState(state, this.config.clientId);
|
|
663
|
+
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
664
|
+
if (options.returnUrl) {
|
|
665
|
+
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
666
|
+
}
|
|
667
|
+
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
668
|
+
const params = new URLSearchParams();
|
|
669
|
+
params.set("response_type", "code");
|
|
670
|
+
params.set("client_id", this.config.clientId);
|
|
671
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
672
|
+
params.set("scope", this.config.scopes || "openid profile");
|
|
673
|
+
params.set("state", state);
|
|
674
|
+
params.set("code_challenge", challenge);
|
|
675
|
+
params.set("code_challenge_method", "S256");
|
|
676
|
+
params.set("popup_nonce", popupNonce);
|
|
677
|
+
const width = options.width || 480;
|
|
678
|
+
const height = options.height || 640;
|
|
679
|
+
const left = Math.max(0, (window.screen.width - width) / 2);
|
|
680
|
+
const top = Math.max(0, (window.screen.height - height) / 2);
|
|
681
|
+
const popupFeatures = [
|
|
682
|
+
`width=${width}`,
|
|
683
|
+
`height=${height}`,
|
|
684
|
+
`left=${Math.round(left)}`,
|
|
685
|
+
`top=${Math.round(top)}`,
|
|
686
|
+
"resizable=yes",
|
|
687
|
+
"scrollbars=yes",
|
|
688
|
+
"status=yes"
|
|
689
|
+
].join(",");
|
|
690
|
+
const popup = window.open(
|
|
691
|
+
`${authorizeEndpoint}?${params.toString()}`,
|
|
692
|
+
"nihplod_sso_popup",
|
|
693
|
+
popupFeatures
|
|
694
|
+
);
|
|
695
|
+
if (!popup) {
|
|
696
|
+
removePkceVerifier(this.config.clientId);
|
|
697
|
+
removeOAuthState(this.config.clientId);
|
|
698
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
699
|
+
throw new SsoError(
|
|
700
|
+
"popup_blocked",
|
|
701
|
+
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
705
|
+
try {
|
|
706
|
+
popup.focus();
|
|
707
|
+
return await new Promise((resolve, reject) => {
|
|
708
|
+
let completed = false;
|
|
709
|
+
const handleMessage = (event) => {
|
|
710
|
+
if (completed) return;
|
|
711
|
+
if (event.origin !== redirectUriOrigin) return;
|
|
712
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
|
|
713
|
+
if (!event.data.callbackUrl) return;
|
|
714
|
+
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
715
|
+
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
716
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
717
|
+
completed = true;
|
|
718
|
+
cleanup();
|
|
719
|
+
if (popup && !popup.closed) {
|
|
720
|
+
popup.close();
|
|
721
|
+
}
|
|
722
|
+
this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
|
|
723
|
+
};
|
|
724
|
+
const pollTimer = setInterval(() => {
|
|
725
|
+
if (popup.closed) {
|
|
726
|
+
if (!completed) {
|
|
727
|
+
completed = true;
|
|
728
|
+
cleanup();
|
|
729
|
+
reject(
|
|
730
|
+
new SsoError(
|
|
731
|
+
"popup_closed",
|
|
732
|
+
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
733
|
+
)
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}, 500);
|
|
738
|
+
const cleanup = () => {
|
|
739
|
+
clearInterval(pollTimer);
|
|
740
|
+
window.removeEventListener("message", handleMessage);
|
|
741
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
742
|
+
};
|
|
743
|
+
window.addEventListener("message", handleMessage);
|
|
744
|
+
});
|
|
745
|
+
} catch (err) {
|
|
746
|
+
removePkceVerifier(this.config.clientId);
|
|
747
|
+
removeOAuthState(this.config.clientId);
|
|
748
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
749
|
+
throw err;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
449
752
|
/**
|
|
450
753
|
* 处理 OAuth 回调
|
|
451
754
|
*
|
|
452
755
|
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
453
|
-
* 成功后 token 自动保存到 sessionStorage
|
|
756
|
+
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
454
757
|
*
|
|
455
758
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
456
759
|
* @returns TokenData 或 null
|
|
@@ -460,23 +763,27 @@ var _SsoClient = class _SsoClient {
|
|
|
460
763
|
const params = url.searchParams;
|
|
461
764
|
const error = params.get("error");
|
|
462
765
|
if (error) {
|
|
766
|
+
removeOAuthState(this.config.clientId);
|
|
767
|
+
removePkceVerifier(this.config.clientId);
|
|
463
768
|
const desc = params.get("error_description") || error;
|
|
464
|
-
throw new SsoError(
|
|
769
|
+
throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
|
|
465
770
|
}
|
|
466
771
|
const code = params.get("code");
|
|
467
772
|
const returnedState = params.get("state");
|
|
468
773
|
if (!code) {
|
|
774
|
+
removeOAuthState(this.config.clientId);
|
|
775
|
+
removePkceVerifier(this.config.clientId);
|
|
469
776
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
470
777
|
}
|
|
471
778
|
const savedState = getOAuthState(this.config.clientId);
|
|
472
|
-
if (!savedState || savedState
|
|
779
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
473
780
|
removeOAuthState(this.config.clientId);
|
|
781
|
+
removePkceVerifier(this.config.clientId);
|
|
474
782
|
throw new SsoError(
|
|
475
783
|
"state_mismatch",
|
|
476
784
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
477
785
|
);
|
|
478
786
|
}
|
|
479
|
-
removeOAuthState(this.config.clientId);
|
|
480
787
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
481
788
|
if (!verifier) {
|
|
482
789
|
throw new SsoError(
|
|
@@ -484,7 +791,6 @@ var _SsoClient = class _SsoClient {
|
|
|
484
791
|
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
485
792
|
);
|
|
486
793
|
}
|
|
487
|
-
removePkceVerifier(this.config.clientId);
|
|
488
794
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
489
795
|
const body = new URLSearchParams();
|
|
490
796
|
body.set("grant_type", "authorization_code");
|
|
@@ -511,8 +817,9 @@ var _SsoClient = class _SsoClient {
|
|
|
511
817
|
errData = await res.json();
|
|
512
818
|
} catch {
|
|
513
819
|
}
|
|
820
|
+
const serverError = errData.error || "";
|
|
514
821
|
throw new SsoError(
|
|
515
|
-
|
|
822
|
+
mapOAuthErrorToSsoCode(serverError),
|
|
516
823
|
errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
|
|
517
824
|
);
|
|
518
825
|
}
|
|
@@ -521,15 +828,17 @@ var _SsoClient = class _SsoClient {
|
|
|
521
828
|
try {
|
|
522
829
|
await validateIdToken(
|
|
523
830
|
data.id_token,
|
|
831
|
+
data.access_token,
|
|
524
832
|
this.config.ssoBaseUrl,
|
|
525
|
-
this.config.clientId
|
|
526
|
-
data.access_token
|
|
833
|
+
this.config.clientId
|
|
527
834
|
);
|
|
528
835
|
} catch (err) {
|
|
529
836
|
removeTokenData(this.config.clientId);
|
|
530
837
|
throw err;
|
|
531
838
|
}
|
|
532
839
|
}
|
|
840
|
+
removeOAuthState(this.config.clientId);
|
|
841
|
+
removePkceVerifier(this.config.clientId);
|
|
533
842
|
const now = Date.now();
|
|
534
843
|
const tokenData = {
|
|
535
844
|
access_token: data.access_token,
|
|
@@ -572,15 +881,24 @@ var _SsoClient = class _SsoClient {
|
|
|
572
881
|
if (this.config.clientSecret) {
|
|
573
882
|
body.set("client_secret", this.config.clientSecret);
|
|
574
883
|
}
|
|
575
|
-
let res;
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
884
|
+
let res = null;
|
|
885
|
+
let lastErr;
|
|
886
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
887
|
+
try {
|
|
888
|
+
res = await fetch(tokenEndpoint, {
|
|
889
|
+
method: "POST",
|
|
890
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
891
|
+
body: body.toString()
|
|
892
|
+
});
|
|
893
|
+
lastErr = null;
|
|
894
|
+
break;
|
|
895
|
+
} catch (err) {
|
|
896
|
+
lastErr = err;
|
|
897
|
+
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (lastErr || !res) {
|
|
901
|
+
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
584
902
|
}
|
|
585
903
|
if (!res.ok) {
|
|
586
904
|
let errData = {};
|
|
@@ -593,17 +911,31 @@ var _SsoClient = class _SsoClient {
|
|
|
593
911
|
removeTokenData(this.config.clientId);
|
|
594
912
|
}
|
|
595
913
|
throw new SsoError(
|
|
596
|
-
errorCode
|
|
914
|
+
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
597
915
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
598
916
|
);
|
|
599
917
|
}
|
|
600
918
|
const data = await res.json();
|
|
919
|
+
if (data.id_token) {
|
|
920
|
+
try {
|
|
921
|
+
await validateIdToken(
|
|
922
|
+
data.id_token,
|
|
923
|
+
data.access_token,
|
|
924
|
+
this.config.ssoBaseUrl,
|
|
925
|
+
this.config.clientId
|
|
926
|
+
);
|
|
927
|
+
} catch (err) {
|
|
928
|
+
removeTokenData(this.config.clientId);
|
|
929
|
+
throw err;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
601
932
|
const now = Date.now();
|
|
602
933
|
const tokenData = {
|
|
603
934
|
access_token: data.access_token,
|
|
604
935
|
token_type: data.token_type,
|
|
605
936
|
expires_in: data.expires_in,
|
|
606
|
-
|
|
937
|
+
// RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
|
|
938
|
+
refresh_token: data.refresh_token || current.refresh_token,
|
|
607
939
|
id_token: data.id_token,
|
|
608
940
|
issued_at: now,
|
|
609
941
|
expires_at: now + data.expires_in * 1e3
|
|
@@ -675,7 +1007,9 @@ var _SsoClient = class _SsoClient {
|
|
|
675
1007
|
* 登出
|
|
676
1008
|
*
|
|
677
1009
|
* 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
|
|
678
|
-
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false
|
|
1010
|
+
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
|
|
1011
|
+
* 为 true 时携带 state 参数(已保存到 sessionStorage),
|
|
1012
|
+
* 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
|
|
679
1013
|
*/
|
|
680
1014
|
async logout(redirectToSso = false) {
|
|
681
1015
|
const tokenData = getTokenData(this.config.clientId);
|
|
@@ -704,23 +1038,50 @@ var _SsoClient = class _SsoClient {
|
|
|
704
1038
|
}
|
|
705
1039
|
if (redirectToSso) {
|
|
706
1040
|
const discovery = await this._getDiscovery();
|
|
707
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/
|
|
1041
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
|
|
708
1042
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
709
1043
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
710
|
-
|
|
1044
|
+
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1045
|
+
if (postLogoutUri) {
|
|
711
1046
|
logoutUrl.searchParams.set(
|
|
712
1047
|
"post_logout_redirect_uri",
|
|
713
|
-
|
|
1048
|
+
postLogoutUri
|
|
714
1049
|
);
|
|
715
1050
|
}
|
|
716
1051
|
if (idTokenHint) {
|
|
717
1052
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
718
1053
|
}
|
|
719
1054
|
const state = generateState();
|
|
1055
|
+
saveLogoutState(state, this.config.clientId);
|
|
720
1056
|
logoutUrl.searchParams.set("state", state);
|
|
721
1057
|
window.location.href = logoutUrl.toString();
|
|
722
1058
|
}
|
|
723
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
|
|
1062
|
+
*
|
|
1063
|
+
* 在 post_logout_redirect_uri 指向的页面加载时调用;
|
|
1064
|
+
* 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
|
|
1065
|
+
* 校验后清除已保存的 logout state(一次性)。
|
|
1066
|
+
*
|
|
1067
|
+
* @param url - 当前页面完整 URL(window.location.href)
|
|
1068
|
+
*
|
|
1069
|
+
* @example
|
|
1070
|
+
* ```typescript
|
|
1071
|
+
* if (sso.validateLogoutState(window.location.href)) {
|
|
1072
|
+
* // 来自 SSO 登出的可信回跳
|
|
1073
|
+
* }
|
|
1074
|
+
* ```
|
|
1075
|
+
*/
|
|
1076
|
+
validateLogoutState(url) {
|
|
1077
|
+
const returnedState = new URL(url).searchParams.get("state");
|
|
1078
|
+
if (!returnedState) return false;
|
|
1079
|
+
const savedState = getLogoutState(this.config.clientId);
|
|
1080
|
+
if (!savedState) return false;
|
|
1081
|
+
if (!timingSafeEqualString(savedState, returnedState)) return false;
|
|
1082
|
+
removeLogoutState(this.config.clientId);
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
724
1085
|
/**
|
|
725
1086
|
* 获取 OIDC Discovery 文档
|
|
726
1087
|
*
|
|
@@ -741,6 +1102,7 @@ var SsoClient = _SsoClient;
|
|
|
741
1102
|
var SsoContext = (0, import_react.createContext)(null);
|
|
742
1103
|
var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
743
1104
|
var LOCK_TTL_MS = 5e3;
|
|
1105
|
+
var ownedLocks = /* @__PURE__ */ new Map();
|
|
744
1106
|
function lockKey(clientId) {
|
|
745
1107
|
return REFRESH_LOCK_PREFIX + clientId;
|
|
746
1108
|
}
|
|
@@ -755,12 +1117,44 @@ function acquireRefreshLock(clientId) {
|
|
|
755
1117
|
return false;
|
|
756
1118
|
}
|
|
757
1119
|
}
|
|
758
|
-
|
|
1120
|
+
const token = `${now}:${Math.random().toString(36).slice(2)}`;
|
|
1121
|
+
localStorage.setItem(key, token);
|
|
1122
|
+
if (localStorage.getItem(key) !== token) return false;
|
|
1123
|
+
ownedLocks.set(clientId, token);
|
|
759
1124
|
return true;
|
|
760
1125
|
}
|
|
761
1126
|
function releaseRefreshLock(clientId) {
|
|
762
1127
|
if (typeof localStorage === "undefined") return;
|
|
763
|
-
|
|
1128
|
+
const owned = ownedLocks.get(clientId);
|
|
1129
|
+
ownedLocks.delete(clientId);
|
|
1130
|
+
if (owned && localStorage.getItem(lockKey(clientId)) === owned) {
|
|
1131
|
+
localStorage.removeItem(lockKey(clientId));
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
async function withRefreshLock(clientId, task) {
|
|
1135
|
+
if (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function") {
|
|
1136
|
+
try {
|
|
1137
|
+
let ran = false;
|
|
1138
|
+
await navigator.locks.request(
|
|
1139
|
+
`nihplod_sso_refresh_${clientId}`,
|
|
1140
|
+
{ ifAvailable: true },
|
|
1141
|
+
async (lock) => {
|
|
1142
|
+
if (!lock) return;
|
|
1143
|
+
ran = true;
|
|
1144
|
+
await task();
|
|
1145
|
+
}
|
|
1146
|
+
);
|
|
1147
|
+
return ran;
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (!acquireRefreshLock(clientId)) return false;
|
|
1152
|
+
try {
|
|
1153
|
+
await task();
|
|
1154
|
+
return true;
|
|
1155
|
+
} finally {
|
|
1156
|
+
releaseRefreshLock(clientId);
|
|
1157
|
+
}
|
|
764
1158
|
}
|
|
765
1159
|
function SsoProvider({
|
|
766
1160
|
config,
|
|
@@ -771,14 +1165,16 @@ function SsoProvider({
|
|
|
771
1165
|
const [user, setUser] = (0, import_react.useState)(null);
|
|
772
1166
|
const [isLoading, setIsLoading] = (0, import_react.useState)(true);
|
|
773
1167
|
const [isAuthenticated, setIsAuthenticated] = (0, import_react.useState)(false);
|
|
774
|
-
const
|
|
1168
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
1169
|
+
const [client] = (0, import_react.useState)(() => new SsoClient(config));
|
|
775
1170
|
const refreshTimerRef = (0, import_react.useRef)(null);
|
|
776
1171
|
const loadedRef = (0, import_react.useRef)(false);
|
|
777
|
-
const client = clientRef.current;
|
|
778
1172
|
const loadUser = (0, import_react.useCallback)(async () => {
|
|
779
|
-
|
|
1173
|
+
const tokenData = getTokenData(client.config.clientId);
|
|
1174
|
+
if (!tokenData) {
|
|
780
1175
|
setUser(null);
|
|
781
1176
|
setIsAuthenticated(false);
|
|
1177
|
+
setError(null);
|
|
782
1178
|
setIsLoading(false);
|
|
783
1179
|
return;
|
|
784
1180
|
}
|
|
@@ -786,9 +1182,13 @@ function SsoProvider({
|
|
|
786
1182
|
const u = await client.getUserInfo();
|
|
787
1183
|
setUser(u);
|
|
788
1184
|
setIsAuthenticated(true);
|
|
789
|
-
|
|
1185
|
+
setError(null);
|
|
1186
|
+
} catch (err) {
|
|
790
1187
|
setUser(null);
|
|
791
1188
|
setIsAuthenticated(false);
|
|
1189
|
+
setError(
|
|
1190
|
+
err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
|
|
1191
|
+
);
|
|
792
1192
|
removeTokenData(client.config.clientId);
|
|
793
1193
|
} finally {
|
|
794
1194
|
setIsLoading(false);
|
|
@@ -797,14 +1197,25 @@ function SsoProvider({
|
|
|
797
1197
|
(0, import_react.useEffect)(() => {
|
|
798
1198
|
if (loadedRef.current) return;
|
|
799
1199
|
loadedRef.current = true;
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1200
|
+
Promise.resolve().then(() => {
|
|
1201
|
+
if (getTokenData(client.config.clientId)) {
|
|
1202
|
+
loadUser();
|
|
1203
|
+
} else {
|
|
1204
|
+
setIsLoading(false);
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
805
1207
|
}, [client, loadUser]);
|
|
806
1208
|
(0, import_react.useEffect)(() => {
|
|
807
1209
|
let active = true;
|
|
1210
|
+
const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
|
|
1211
|
+
try {
|
|
1212
|
+
const td = await client.refreshToken();
|
|
1213
|
+
onTokenRefreshed?.(td.access_token);
|
|
1214
|
+
loadUser();
|
|
1215
|
+
} catch {
|
|
1216
|
+
setTimeout(() => loadUser(), 500);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
808
1219
|
const scheduleNextRefresh = () => {
|
|
809
1220
|
if (!active) return;
|
|
810
1221
|
if (refreshTimerRef.current) {
|
|
@@ -815,41 +1226,23 @@ function SsoProvider({
|
|
|
815
1226
|
if (!tokenData) return;
|
|
816
1227
|
const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
|
|
817
1228
|
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));
|
|
1229
|
+
void attemptRefresh();
|
|
825
1230
|
return;
|
|
826
1231
|
}
|
|
827
1232
|
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));
|
|
1233
|
+
void attemptRefresh();
|
|
835
1234
|
return;
|
|
836
1235
|
}
|
|
837
1236
|
const delayMs = (remainingSec - refreshThreshold) * 1e3;
|
|
838
1237
|
refreshTimerRef.current = setTimeout(() => {
|
|
1238
|
+
if (!active) return;
|
|
839
1239
|
const td = getTokenData(client.config.clientId);
|
|
840
1240
|
if (!td) return;
|
|
841
1241
|
const secLeft = (td.expires_at - Date.now()) / 1e3;
|
|
842
1242
|
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();
|
|
1243
|
+
void attemptRefresh().then((didRefresh) => {
|
|
1244
|
+
if (didRefresh) scheduleNextRefresh();
|
|
1245
|
+
});
|
|
853
1246
|
}
|
|
854
1247
|
}, Math.max(delayMs, 1e3));
|
|
855
1248
|
};
|
|
@@ -877,11 +1270,20 @@ function SsoProvider({
|
|
|
877
1270
|
},
|
|
878
1271
|
[client]
|
|
879
1272
|
);
|
|
1273
|
+
const loginPopup = (0, import_react.useCallback)(
|
|
1274
|
+
async (options) => {
|
|
1275
|
+
const tokenData = await client.loginPopup(options);
|
|
1276
|
+
await loadUser();
|
|
1277
|
+
return tokenData;
|
|
1278
|
+
},
|
|
1279
|
+
[client, loadUser]
|
|
1280
|
+
);
|
|
880
1281
|
const logout = (0, import_react.useCallback)(
|
|
881
1282
|
async (redirectToSso = false) => {
|
|
882
1283
|
await client.logout(redirectToSso);
|
|
883
1284
|
setUser(null);
|
|
884
1285
|
setIsAuthenticated(false);
|
|
1286
|
+
setError(null);
|
|
885
1287
|
},
|
|
886
1288
|
[client]
|
|
887
1289
|
);
|
|
@@ -895,7 +1297,9 @@ function SsoProvider({
|
|
|
895
1297
|
user,
|
|
896
1298
|
isAuthenticated,
|
|
897
1299
|
isLoading,
|
|
1300
|
+
error,
|
|
898
1301
|
login,
|
|
1302
|
+
loginPopup,
|
|
899
1303
|
logout,
|
|
900
1304
|
refreshUser,
|
|
901
1305
|
getAccessToken,
|
|
@@ -916,21 +1320,63 @@ var import_react2 = __toESM(require("react"));
|
|
|
916
1320
|
function RequireAuth({
|
|
917
1321
|
children,
|
|
918
1322
|
fallback,
|
|
919
|
-
autoLogin = true
|
|
1323
|
+
autoLogin = true,
|
|
1324
|
+
usePopup = false,
|
|
1325
|
+
onError,
|
|
1326
|
+
renderLoginError
|
|
920
1327
|
}) {
|
|
921
|
-
const { isAuthenticated, isLoading, login } = useSso();
|
|
1328
|
+
const { isAuthenticated, isLoading, login, loginPopup } = useSso();
|
|
922
1329
|
const loginTriggeredRef = (0, import_react2.useRef)(false);
|
|
1330
|
+
const [loginError, setLoginError] = (0, import_react2.useState)(null);
|
|
1331
|
+
const triggerLogin = () => {
|
|
1332
|
+
loginTriggeredRef.current = true;
|
|
1333
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
1334
|
+
const handleFailure = (err) => {
|
|
1335
|
+
loginTriggeredRef.current = false;
|
|
1336
|
+
setLoginError(err);
|
|
1337
|
+
onError?.(err);
|
|
1338
|
+
};
|
|
1339
|
+
if (usePopup) {
|
|
1340
|
+
loginPopup({ returnUrl: currentPath }).catch((err) => {
|
|
1341
|
+
if (err.code === "popup_blocked") {
|
|
1342
|
+
login(currentPath).catch(handleFailure);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
handleFailure(err);
|
|
1346
|
+
});
|
|
1347
|
+
} else {
|
|
1348
|
+
login(currentPath).catch(handleFailure);
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
const retry = () => {
|
|
1352
|
+
setLoginError(null);
|
|
1353
|
+
triggerLogin();
|
|
1354
|
+
};
|
|
923
1355
|
(0, import_react2.useEffect)(() => {
|
|
924
1356
|
if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
|
|
925
|
-
|
|
926
|
-
const currentPath = window.location.pathname + window.location.search;
|
|
927
|
-
login(currentPath);
|
|
1357
|
+
triggerLogin();
|
|
928
1358
|
}
|
|
929
|
-
}, [isLoading, isAuthenticated, autoLogin, login]);
|
|
1359
|
+
}, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
|
|
930
1360
|
if (isLoading) {
|
|
931
1361
|
return fallback || import_react2.default.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
|
|
932
1362
|
}
|
|
933
1363
|
if (!isAuthenticated) {
|
|
1364
|
+
if (loginError) {
|
|
1365
|
+
if (renderLoginError) {
|
|
1366
|
+
return import_react2.default.createElement(
|
|
1367
|
+
import_react2.default.Fragment,
|
|
1368
|
+
null,
|
|
1369
|
+
renderLoginError(loginError, retry)
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
|
|
1373
|
+
return import_react2.default.createElement(
|
|
1374
|
+
"div",
|
|
1375
|
+
null,
|
|
1376
|
+
import_react2.default.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
|
|
1377
|
+
import_react2.default.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
934
1380
|
if (fallback) return import_react2.default.createElement(import_react2.default.Fragment, null, fallback);
|
|
935
1381
|
return import_react2.default.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");
|
|
936
1382
|
}
|
|
@@ -951,30 +1397,80 @@ function withAuth(Component) {
|
|
|
951
1397
|
|
|
952
1398
|
// src/react/CallbackPage.tsx
|
|
953
1399
|
var import_react3 = __toESM(require("react"));
|
|
954
|
-
function
|
|
1400
|
+
function DefaultCallbackError({ error }) {
|
|
1401
|
+
return import_react3.default.createElement(
|
|
1402
|
+
"div",
|
|
1403
|
+
{
|
|
1404
|
+
style: {
|
|
1405
|
+
display: "flex",
|
|
1406
|
+
flexDirection: "column",
|
|
1407
|
+
alignItems: "center",
|
|
1408
|
+
justifyContent: "center",
|
|
1409
|
+
minHeight: "100vh",
|
|
1410
|
+
fontFamily: "system-ui, sans-serif"
|
|
1411
|
+
}
|
|
1412
|
+
},
|
|
1413
|
+
import_react3.default.createElement(
|
|
1414
|
+
"p",
|
|
1415
|
+
{ style: { color: "#dc2626", marginBottom: "1rem" } },
|
|
1416
|
+
error
|
|
1417
|
+
),
|
|
1418
|
+
import_react3.default.createElement(
|
|
1419
|
+
"a",
|
|
1420
|
+
{ href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
|
|
1421
|
+
"\u8FD4\u56DE\u9996\u9875"
|
|
1422
|
+
)
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
955
1426
|
const { client, refreshUser } = useSso();
|
|
956
1427
|
const [error, setError] = (0, import_react3.useState)(null);
|
|
957
1428
|
const [processing, setProcessing] = (0, import_react3.useState)(true);
|
|
1429
|
+
const onSuccessRef = (0, import_react3.useRef)(onSuccess);
|
|
1430
|
+
const onErrorRef = (0, import_react3.useRef)(onError);
|
|
1431
|
+
(0, import_react3.useEffect)(() => {
|
|
1432
|
+
onSuccessRef.current = onSuccess;
|
|
1433
|
+
onErrorRef.current = onError;
|
|
1434
|
+
});
|
|
958
1435
|
(0, import_react3.useEffect)(() => {
|
|
1436
|
+
if (window.opener && !window.opener.closed) {
|
|
1437
|
+
const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
|
|
1438
|
+
let targetOrigin = window.location.origin;
|
|
1439
|
+
try {
|
|
1440
|
+
targetOrigin = window.opener.location.origin;
|
|
1441
|
+
} catch {
|
|
1442
|
+
}
|
|
1443
|
+
window.opener.postMessage(
|
|
1444
|
+
{ type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
|
|
1445
|
+
targetOrigin
|
|
1446
|
+
);
|
|
1447
|
+
Promise.resolve().then(() => setProcessing(false));
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
959
1450
|
let cancelled = false;
|
|
960
1451
|
async function handleCallback() {
|
|
961
1452
|
try {
|
|
962
|
-
await client.handleCallback(window.location.href);
|
|
1453
|
+
const tokenData = await client.handleCallback(window.location.href);
|
|
963
1454
|
if (cancelled) return;
|
|
964
1455
|
await refreshUser();
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1456
|
+
if (cancelled) return;
|
|
1457
|
+
const clientId = client.config.clientId;
|
|
1458
|
+
const returnUrl = getReturnUrl(clientId);
|
|
1459
|
+
removeReturnUrl(clientId);
|
|
1460
|
+
const onSuccessCb = onSuccessRef.current;
|
|
1461
|
+
if (onSuccessCb) {
|
|
1462
|
+
onSuccessCb(tokenData);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
|
|
968
1466
|
} catch (err) {
|
|
969
1467
|
if (cancelled) return;
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
setError(
|
|
974
|
-
} else if (err instanceof Error) {
|
|
975
|
-
setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${err.message}`);
|
|
1468
|
+
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
1469
|
+
onErrorRef.current?.(errorObj);
|
|
1470
|
+
if (errorObj instanceof SsoError) {
|
|
1471
|
+
setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
|
|
976
1472
|
} else {
|
|
977
|
-
setError(
|
|
1473
|
+
setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
|
|
978
1474
|
}
|
|
979
1475
|
setProcessing(false);
|
|
980
1476
|
}
|
|
@@ -985,29 +1481,8 @@ function CallbackPage() {
|
|
|
985
1481
|
};
|
|
986
1482
|
}, [client, refreshUser]);
|
|
987
1483
|
if (error) {
|
|
988
|
-
return import_react3.default.createElement(
|
|
989
|
-
|
|
990
|
-
{
|
|
991
|
-
style: {
|
|
992
|
-
display: "flex",
|
|
993
|
-
flexDirection: "column",
|
|
994
|
-
alignItems: "center",
|
|
995
|
-
justifyContent: "center",
|
|
996
|
-
minHeight: "100vh",
|
|
997
|
-
fontFamily: "system-ui, sans-serif"
|
|
998
|
-
}
|
|
999
|
-
},
|
|
1000
|
-
import_react3.default.createElement(
|
|
1001
|
-
"p",
|
|
1002
|
-
{ style: { color: "#dc2626", marginBottom: "1rem" } },
|
|
1003
|
-
error
|
|
1004
|
-
),
|
|
1005
|
-
import_react3.default.createElement(
|
|
1006
|
-
"a",
|
|
1007
|
-
{ href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
|
|
1008
|
-
"\u8FD4\u56DE\u9996\u9875"
|
|
1009
|
-
)
|
|
1010
|
-
);
|
|
1484
|
+
if (renderError) return import_react3.default.createElement(import_react3.default.Fragment, null, renderError(error));
|
|
1485
|
+
return import_react3.default.createElement(DefaultCallbackError, { error });
|
|
1011
1486
|
}
|
|
1012
1487
|
if (processing) {
|
|
1013
1488
|
return import_react3.default.createElement(
|
|
@@ -1029,6 +1504,7 @@ function CallbackPage() {
|
|
|
1029
1504
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1030
1505
|
0 && (module.exports = {
|
|
1031
1506
|
CallbackPage,
|
|
1507
|
+
DefaultCallbackError,
|
|
1032
1508
|
RequireAuth,
|
|
1033
1509
|
SsoProvider,
|
|
1034
1510
|
useSso,
|