@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.
@@ -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
- var _storage = memoryStorageAdapter;
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
- _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
185
+ _transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
136
186
  }
137
187
  function getPkceVerifier(clientId) {
138
- return _storage.get(VERIFIER_KEY_PREFIX + clientId);
188
+ return _transient.get(VERIFIER_KEY_PREFIX + clientId);
139
189
  }
140
190
  function removePkceVerifier(clientId) {
141
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
191
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
142
192
  }
143
193
  function saveOAuthState(state, clientId) {
144
- _storage.set(buildKey(STATE_KEY, clientId), state);
194
+ _transient.set(buildKey(STATE_KEY, clientId), state);
145
195
  }
146
196
  function getOAuthState(clientId) {
147
- return _storage.get(buildKey(STATE_KEY, clientId));
197
+ return _transient.get(buildKey(STATE_KEY, clientId));
148
198
  }
149
199
  function removeOAuthState(clientId) {
150
- _storage.remove(buildKey(STATE_KEY, clientId));
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
- _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
212
+ _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
154
213
  }
155
214
  function getReturnUrl(clientId) {
156
- return _storage.get(buildKey(RETURN_URL_KEY, clientId));
215
+ return _transient.get(buildKey(RETURN_URL_KEY, clientId));
157
216
  }
158
217
  function removeReturnUrl(clientId) {
159
- _storage.remove(buildKey(RETURN_URL_KEY, clientId));
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
- if (typeof localStorage !== "undefined") {
173
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
174
- for (let i = 0; i < localStorage.length; i++) {
175
- const key = localStorage.key(i);
176
- if (key?.startsWith(prefix)) {
177
- _storage.remove(key.slice(STORAGE_PREFIX.length));
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/SsoClient.ts
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 fetchJwks(baseUrl) {
317
+ async function fetchDiscoveryDoc(baseUrl) {
220
318
  const now = Date.now();
221
- if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
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(`${baseUrl}/api/oauth/jwks`);
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 computeAtHash(accessToken) {
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 cryptoKey = await crypto.subtle.importKey(
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 crypto.subtle.verify(
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 validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
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(normalizeIssuer(expectedIssuer));
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 key = jwks.keys.find(
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
- if (!key) {
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
- console.warn(
298
- "[SSO SDK] ID Token \u4F7F\u7528 HS256 \u7B7E\u540D\uFF0CPublic Client \u65E0\u6CD5\u5B89\u5168\u9A8C\u8BC1\u7B7E\u540D\u3002\u5EFA\u8BAE\u4E3B\u7AD9\u914D\u7F6E JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY \u542F\u7528 RS256\u3002"
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 !== normalizedExpectedIssuer) {
448
+ if (tokenIssuer !== normalizedIssuer) {
308
449
  throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
309
450
  }
310
- if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
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 (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
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 !== payload.at_hash) {
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
- saveReturnUrl(returnUrl);
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) saveReturnUrl(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("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
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 !== returnedState) {
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
- "token_request_failed",
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
- try {
577
- res = await fetch(tokenEndpoint, {
578
- method: "POST",
579
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
580
- body: body.toString()
581
- });
582
- } catch (err) {
583
- throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
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 === "invalid_grant" ? "session_expired" : "token_request_failed",
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
- refresh_token: data.refresh_token,
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}/logout`;
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
- if (this.config.redirectUri) {
1044
+ const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1045
+ if (postLogoutUri) {
711
1046
  logoutUrl.searchParams.set(
712
1047
  "post_logout_redirect_uri",
713
- this.config.redirectUri
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
- localStorage.setItem(key, String(now));
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
- localStorage.removeItem(lockKey(clientId));
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 clientRef = (0, import_react.useRef)(new SsoClient(config));
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
- if (!client.isAuthenticated()) {
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
- } catch {
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
- if (client.isAuthenticated()) {
801
- loadUser();
802
- } else {
803
- setIsLoading(false);
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
- if (!acquireRefreshLock(client.config.clientId)) return;
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
- if (!acquireRefreshLock(client.config.clientId)) return;
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
- if (!acquireRefreshLock(client.config.clientId)) {
844
- return;
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
- loginTriggeredRef.current = true;
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 CallbackPage() {
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
- const returnUrl = getReturnUrl();
966
- removeReturnUrl();
967
- window.location.href = returnUrl || "/";
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
- if (err instanceof SsoError) {
971
- setError(err.description || `SSO \u9519\u8BEF (${err.code})`);
972
- } else if (err instanceof TypeError && err.message.includes("fetch")) {
973
- setError("\u7F51\u7EDC\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5");
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("\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5");
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
- "div",
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,