@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.
@@ -9,6 +9,26 @@ import React, {
9
9
  } from "react";
10
10
 
11
11
  // src/core/errors.ts
12
+ function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
13
+ switch (oauthError) {
14
+ case "invalid_grant":
15
+ return context === "refresh" ? "session_expired" : "authorization_code_expired";
16
+ case "invalid_client":
17
+ return "client_disabled";
18
+ case "access_denied":
19
+ return "user_denied_authorization";
20
+ case "server_error":
21
+ return "sso_server_error";
22
+ case "rate_limited":
23
+ return "rate_limited";
24
+ case "invalid_scope":
25
+ case "invalid_request":
26
+ case "unauthorized_client":
27
+ case "unsupported_grant_type":
28
+ default:
29
+ return "token_request_failed";
30
+ }
31
+ }
12
32
  var SsoError = class extends Error {
13
33
  constructor(code, description, cause) {
14
34
  super(`[SSO SDK] ${code}: ${description}`);
@@ -64,6 +84,7 @@ var TOKEN_KEY = "token";
64
84
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
65
85
  var STATE_KEY = "oauth_state";
66
86
  var RETURN_URL_KEY = "return_url";
87
+ var LOGOUT_STATE_KEY = "logout_state";
67
88
  function buildKey(base, clientId) {
68
89
  return clientId ? `${base}:${clientId}` : base;
69
90
  }
@@ -82,7 +103,35 @@ function createMemoryStorageAdapter() {
82
103
  };
83
104
  }
84
105
  var memoryStorageAdapter = createMemoryStorageAdapter();
85
- var _storage = memoryStorageAdapter;
106
+ function createSessionStorageAdapter() {
107
+ const fallback = /* @__PURE__ */ new Map();
108
+ return {
109
+ get(key) {
110
+ if (typeof sessionStorage !== "undefined") {
111
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
112
+ }
113
+ return fallback.get(key) ?? null;
114
+ },
115
+ set(key, value) {
116
+ if (typeof sessionStorage === "undefined") {
117
+ fallback.set(key, value);
118
+ return;
119
+ }
120
+ try {
121
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
122
+ } catch {
123
+ fallback.set(key, value);
124
+ }
125
+ },
126
+ remove(key) {
127
+ fallback.delete(key);
128
+ if (typeof sessionStorage === "undefined") return;
129
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
130
+ }
131
+ };
132
+ }
133
+ var _transient = createSessionStorageAdapter();
134
+ var _storage = createSessionStorageAdapter();
86
135
  function saveTokenData(data, clientId) {
87
136
  _storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
88
137
  }
@@ -99,55 +148,102 @@ function removeTokenData(clientId) {
99
148
  _storage.remove(buildKey(TOKEN_KEY, clientId));
100
149
  }
101
150
  function savePkceVerifier(clientId, verifier) {
102
- _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
151
+ _transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
103
152
  }
104
153
  function getPkceVerifier(clientId) {
105
- return _storage.get(VERIFIER_KEY_PREFIX + clientId);
154
+ return _transient.get(VERIFIER_KEY_PREFIX + clientId);
106
155
  }
107
156
  function removePkceVerifier(clientId) {
108
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
157
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
109
158
  }
110
159
  function saveOAuthState(state, clientId) {
111
- _storage.set(buildKey(STATE_KEY, clientId), state);
160
+ _transient.set(buildKey(STATE_KEY, clientId), state);
112
161
  }
113
162
  function getOAuthState(clientId) {
114
- return _storage.get(buildKey(STATE_KEY, clientId));
163
+ return _transient.get(buildKey(STATE_KEY, clientId));
115
164
  }
116
165
  function removeOAuthState(clientId) {
117
- _storage.remove(buildKey(STATE_KEY, clientId));
166
+ _transient.remove(buildKey(STATE_KEY, clientId));
167
+ }
168
+ function saveLogoutState(state, clientId) {
169
+ _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
170
+ }
171
+ function getLogoutState(clientId) {
172
+ return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
173
+ }
174
+ function removeLogoutState(clientId) {
175
+ _transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
118
176
  }
119
177
  function saveReturnUrl(url, clientId) {
120
- _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
178
+ _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
121
179
  }
122
180
  function getReturnUrl(clientId) {
123
- return _storage.get(buildKey(RETURN_URL_KEY, clientId));
181
+ return _transient.get(buildKey(RETURN_URL_KEY, clientId));
124
182
  }
125
183
  function removeReturnUrl(clientId) {
126
- _storage.remove(buildKey(RETURN_URL_KEY, clientId));
184
+ _transient.remove(buildKey(RETURN_URL_KEY, clientId));
127
185
  }
128
186
  function clearAllSsoData(clientId) {
129
187
  if (clientId) {
130
188
  removeTokenData(clientId);
131
189
  removeOAuthState(clientId);
132
190
  removeReturnUrl(clientId);
191
+ removeLogoutState(clientId);
133
192
  removePkceVerifier(clientId);
193
+ removePkceVerifier(`${clientId}_popup_nonce`);
134
194
  return;
135
195
  }
136
196
  removeTokenData();
137
197
  removeOAuthState();
138
198
  removeReturnUrl();
139
- if (typeof localStorage !== "undefined") {
140
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
141
- for (let i = 0; i < localStorage.length; i++) {
142
- const key = localStorage.key(i);
143
- if (key?.startsWith(prefix)) {
144
- _storage.remove(key.slice(STORAGE_PREFIX.length));
145
- }
199
+ removeLogoutState();
200
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
201
+ const stores = [
202
+ typeof sessionStorage !== "undefined" ? sessionStorage : null,
203
+ typeof localStorage !== "undefined" ? localStorage : null
204
+ ];
205
+ for (const store of stores) {
206
+ if (!store) continue;
207
+ const keys = [];
208
+ for (let i = 0; i < store.length; i++) {
209
+ const key = store.key(i);
210
+ if (key?.startsWith(prefix)) keys.push(key);
211
+ }
212
+ for (const key of keys) {
213
+ _transient.remove(key.slice(STORAGE_PREFIX.length));
214
+ store.removeItem(key);
146
215
  }
147
216
  }
148
217
  }
149
218
 
150
- // src/core/SsoClient.ts
219
+ // src/core/security.ts
220
+ function isTrustedReturnUrl(url, currentOrigin) {
221
+ if (!url) return false;
222
+ if (url.includes("\\")) return false;
223
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
224
+ try {
225
+ return new URL(url).origin === currentOrigin;
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+ function timingSafeEqualString(a, b) {
231
+ const ba = new TextEncoder().encode(a);
232
+ const bb = new TextEncoder().encode(b);
233
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
234
+ const len = Math.max(ba.length, bb.length);
235
+ let diff = ba.length ^ bb.length;
236
+ for (let i = 0; i < len; i++) {
237
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
238
+ }
239
+ return diff === 0;
240
+ }
241
+
242
+ // src/core/id-token.ts
243
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
244
+ function getCrypto() {
245
+ return _crypto ?? null;
246
+ }
151
247
  function base64UrlDecode(input) {
152
248
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
153
249
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -182,14 +278,32 @@ function normalizeIssuer(url) {
182
278
  return url.replace(/\/+$/, "");
183
279
  }
184
280
  var cachedJwks = null;
281
+ var cachedDiscovery = null;
185
282
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
186
- async function fetchJwks(baseUrl) {
283
+ async function fetchDiscoveryDoc(baseUrl) {
187
284
  const now = Date.now();
188
- if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
285
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
286
+ return cachedDiscovery.doc;
287
+ }
288
+ try {
289
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
290
+ const doc = res.ok ? await res.json() : null;
291
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
292
+ return doc;
293
+ } catch {
294
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
295
+ return null;
296
+ }
297
+ }
298
+ async function fetchJwks(baseUrl, options = {}) {
299
+ const now = Date.now();
300
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
189
301
  return cachedJwks.jwks;
190
302
  }
303
+ const discovery = await fetchDiscoveryDoc(baseUrl);
304
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
191
305
  try {
192
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
306
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
193
307
  if (!res.ok) return null;
194
308
  const jwks = await res.json();
195
309
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -198,24 +312,13 @@ async function fetchJwks(baseUrl) {
198
312
  return null;
199
313
  }
200
314
  }
201
- async function computeAtHash(accessToken) {
202
- const hash = await crypto.subtle.digest(
203
- "SHA-256",
204
- new TextEncoder().encode(accessToken)
205
- );
206
- const bytes = new Uint8Array(hash);
207
- const half = bytes.slice(0, bytes.length / 2);
208
- let binary = "";
209
- for (const b of half) {
210
- binary += String.fromCharCode(b);
211
- }
212
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
213
- }
214
- async function verifyRs256(token, jwk) {
315
+ async function verifyRs256Signature(token, jwk) {
215
316
  try {
216
317
  const [headerB64, payloadB64, signature] = token.split(".");
217
318
  if (!signature || !jwk.n || !jwk.e) return false;
218
- const cryptoKey = await crypto.subtle.importKey(
319
+ const c = getCrypto();
320
+ if (!c) return false;
321
+ const cryptoKey = await c.subtle.importKey(
219
322
  "jwk",
220
323
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
221
324
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -224,7 +327,7 @@ async function verifyRs256(token, jwk) {
224
327
  );
225
328
  const signatureBytes = base64UrlDecode(signature);
226
329
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
227
- return await crypto.subtle.verify(
330
+ return await c.subtle.verify(
228
331
  "RSASSA-PKCS1-v1_5",
229
332
  cryptoKey,
230
333
  signatureBytes,
@@ -234,7 +337,20 @@ async function verifyRs256(token, jwk) {
234
337
  return false;
235
338
  }
236
339
  }
237
- async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
340
+ async function computeAtHash(accessToken) {
341
+ const c = getCrypto();
342
+ if (!c) return "";
343
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
344
+ const bytes = new Uint8Array(hash);
345
+ const half = bytes.slice(0, bytes.length / 2);
346
+ let binary = "";
347
+ for (const b of half) {
348
+ binary += String.fromCharCode(b);
349
+ }
350
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
351
+ }
352
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
353
+ const { rejectHs256WhenRs256Available = true } = options;
238
354
  const header = decodeJwtHeader(idToken);
239
355
  if (!header) {
240
356
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -243,54 +359,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
243
359
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
244
360
  throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
245
361
  }
362
+ const baseUrl = normalizeIssuer(expectedIssuer);
363
+ const discovery = await fetchDiscoveryDoc(baseUrl);
364
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
246
365
  if (alg === "RS256") {
247
- const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
366
+ const jwks = await fetchJwks(baseUrl);
248
367
  if (!jwks) {
249
368
  throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
250
369
  }
251
370
  const kid = typeof header.kid === "string" ? header.kid : void 0;
252
- const key = jwks.keys.find(
371
+ const matchCandidates = (set) => set.keys.filter(
253
372
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
254
373
  );
255
- if (!key) {
374
+ const verifyAny = async (keys) => {
375
+ for (const key of keys) {
376
+ if (await verifyRs256Signature(idToken, key)) return true;
377
+ }
378
+ return false;
379
+ };
380
+ let candidates = matchCandidates(jwks);
381
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
382
+ if (candidates.length === 0 || !validSig) {
383
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
384
+ if (freshJwks) {
385
+ candidates = matchCandidates(freshJwks);
386
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
387
+ }
388
+ }
389
+ if (candidates.length === 0) {
256
390
  throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
257
391
  }
258
- const validSig = await verifyRs256(idToken, key);
259
392
  if (!validSig) {
260
393
  throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
261
394
  }
262
395
  }
263
396
  if (alg === "HS256") {
264
- console.warn(
265
- "[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"
397
+ if (rejectHs256WhenRs256Available) {
398
+ const jwks = await fetchJwks(baseUrl);
399
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
400
+ if (hasRs256) {
401
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
402
+ }
403
+ }
404
+ throw new SsoError(
405
+ "id_token_hs256_unsupported",
406
+ "ID Token \u4F7F\u7528 HS256 \u5BF9\u79F0\u7B7E\u540D\uFF0CSDK \u65E0\u6CD5\u5B89\u5168\u9A8C\u8BC1\u3002\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5728 SSO \u4E3B\u7AD9\u914D\u7F6E RS256 \u5BC6\u94A5\u5BF9\uFF08JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY\uFF09\u540E\u91CD\u65B0\u7B7E\u53D1\u3002"
266
407
  );
267
408
  }
268
409
  const payload = decodeJwtPayload(idToken);
269
410
  if (!payload) {
270
411
  throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
271
412
  }
272
- const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
273
413
  const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
274
- if (tokenIssuer !== normalizedExpectedIssuer) {
414
+ if (tokenIssuer !== normalizedIssuer) {
275
415
  throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
276
416
  }
277
- if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
417
+ const aud = payload.aud;
418
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
419
+ if (!audList.includes(expectedClientId)) {
278
420
  throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
279
421
  }
280
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
422
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
423
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
424
+ }
425
+ if (typeof payload.exp !== "number") {
426
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
427
+ }
428
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
281
429
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
282
430
  }
431
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
432
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
433
+ }
283
434
  if (typeof payload.sub !== "string" || !payload.sub) {
284
435
  throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
285
436
  }
286
437
  if (typeof payload.at_hash === "string" && payload.at_hash) {
287
438
  const actual = await computeAtHash(accessToken);
288
- if (actual !== payload.at_hash) {
439
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
289
440
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
290
441
  }
291
442
  }
292
443
  return { sub: payload.sub };
293
444
  }
445
+
446
+ // src/core/SsoClient.ts
294
447
  var _SsoClient = class _SsoClient {
295
448
  constructor(config) {
296
449
  this._discovery = null;
@@ -359,6 +512,17 @@ var _SsoClient = class _SsoClient {
359
512
  if (d) return d.userinfo_endpoint;
360
513
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
361
514
  }
515
+ /**
516
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
517
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
518
+ */
519
+ _saveReturnUrlIfTrusted(returnUrl) {
520
+ if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
521
+ saveReturnUrl(returnUrl, this.config.clientId);
522
+ } else {
523
+ console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
524
+ }
525
+ }
362
526
  // ============================================
363
527
  // 公共 API
364
528
  // ============================================
@@ -368,7 +532,12 @@ var _SsoClient = class _SsoClient {
368
532
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
369
533
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
370
534
  *
371
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
535
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
536
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
537
+ *
538
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
539
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
540
+ * 同一次登录只使用其中一个入口。
372
541
  */
373
542
  async login(returnUrl) {
374
543
  const verifier = generateCodeVerifier();
@@ -377,7 +546,7 @@ var _SsoClient = class _SsoClient {
377
546
  savePkceVerifier(this.config.clientId, verifier);
378
547
  saveOAuthState(state, this.config.clientId);
379
548
  if (returnUrl) {
380
- saveReturnUrl(returnUrl);
549
+ this._saveReturnUrlIfTrusted(returnUrl);
381
550
  }
382
551
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
383
552
  const params = new URLSearchParams();
@@ -394,6 +563,10 @@ var _SsoClient = class _SsoClient {
394
563
  * 构建登录 URL(不跳转,返回 URL 字符串)
395
564
  *
396
565
  * 适用于需要手动处理跳转的场景。
566
+ *
567
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
568
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
569
+ * 同一次登录只使用其中一个入口。
397
570
  */
398
571
  async getLoginUrl(returnUrl) {
399
572
  const verifier = generateCodeVerifier();
@@ -401,7 +574,7 @@ var _SsoClient = class _SsoClient {
401
574
  const state = generateState();
402
575
  savePkceVerifier(this.config.clientId, verifier);
403
576
  saveOAuthState(state, this.config.clientId);
404
- if (returnUrl) saveReturnUrl(returnUrl);
577
+ if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
405
578
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
406
579
  const params = new URLSearchParams();
407
580
  params.set("response_type", "code");
@@ -413,11 +586,140 @@ var _SsoClient = class _SsoClient {
413
586
  params.set("code_challenge_method", "S256");
414
587
  return `${authorizeEndpoint}?${params.toString()}`;
415
588
  }
589
+ /**
590
+ * 弹窗模式 SSO 登录
591
+ *
592
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
593
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
594
+ *
595
+ * 流程:
596
+ * 1. window.open() 打开授权 URL 到弹窗
597
+ * 2. 用户在弹窗中完成登录
598
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
599
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
600
+ * 5. 弹窗自动关闭
601
+ *
602
+ * @param options - 弹窗配置
603
+ * @param options.returnUrl - 登录成功后的返回地址
604
+ * @param options.width - 弹窗宽度(默认 480)
605
+ * @param options.height - 弹窗高度(默认 640)
606
+ * @returns TokenData
607
+ *
608
+ * @example
609
+ * ```typescript
610
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
611
+ * try {
612
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
613
+ * console.log("登录成功", tokenData);
614
+ * } catch (err) {
615
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
616
+ * // 弹窗被拦截,回退到同页重定向
617
+ * await client.login("/dashboard");
618
+ * }
619
+ * }
620
+ * ```
621
+ */
622
+ async loginPopup(options = {}) {
623
+ const verifier = generateCodeVerifier();
624
+ const challenge = await generateCodeChallenge(verifier);
625
+ const state = generateState();
626
+ const popupNonce = generateState();
627
+ savePkceVerifier(this.config.clientId, verifier);
628
+ saveOAuthState(state, this.config.clientId);
629
+ savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
630
+ if (options.returnUrl) {
631
+ this._saveReturnUrlIfTrusted(options.returnUrl);
632
+ }
633
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
634
+ const params = new URLSearchParams();
635
+ params.set("response_type", "code");
636
+ params.set("client_id", this.config.clientId);
637
+ params.set("redirect_uri", this.config.redirectUri);
638
+ params.set("scope", this.config.scopes || "openid profile");
639
+ params.set("state", state);
640
+ params.set("code_challenge", challenge);
641
+ params.set("code_challenge_method", "S256");
642
+ params.set("popup_nonce", popupNonce);
643
+ const width = options.width || 480;
644
+ const height = options.height || 640;
645
+ const left = Math.max(0, (window.screen.width - width) / 2);
646
+ const top = Math.max(0, (window.screen.height - height) / 2);
647
+ const popupFeatures = [
648
+ `width=${width}`,
649
+ `height=${height}`,
650
+ `left=${Math.round(left)}`,
651
+ `top=${Math.round(top)}`,
652
+ "resizable=yes",
653
+ "scrollbars=yes",
654
+ "status=yes"
655
+ ].join(",");
656
+ const popup = window.open(
657
+ `${authorizeEndpoint}?${params.toString()}`,
658
+ "nihplod_sso_popup",
659
+ popupFeatures
660
+ );
661
+ if (!popup) {
662
+ removePkceVerifier(this.config.clientId);
663
+ removeOAuthState(this.config.clientId);
664
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
665
+ throw new SsoError(
666
+ "popup_blocked",
667
+ "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
668
+ );
669
+ }
670
+ const redirectUriOrigin = new URL(this.config.redirectUri).origin;
671
+ try {
672
+ popup.focus();
673
+ return await new Promise((resolve, reject) => {
674
+ let completed = false;
675
+ const handleMessage = (event) => {
676
+ if (completed) return;
677
+ if (event.origin !== redirectUriOrigin) return;
678
+ if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
679
+ if (!event.data.callbackUrl) return;
680
+ const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
681
+ if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
682
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
683
+ completed = true;
684
+ cleanup();
685
+ if (popup && !popup.closed) {
686
+ popup.close();
687
+ }
688
+ this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
689
+ };
690
+ const pollTimer = setInterval(() => {
691
+ if (popup.closed) {
692
+ if (!completed) {
693
+ completed = true;
694
+ cleanup();
695
+ reject(
696
+ new SsoError(
697
+ "popup_closed",
698
+ "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
699
+ )
700
+ );
701
+ }
702
+ }
703
+ }, 500);
704
+ const cleanup = () => {
705
+ clearInterval(pollTimer);
706
+ window.removeEventListener("message", handleMessage);
707
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
708
+ };
709
+ window.addEventListener("message", handleMessage);
710
+ });
711
+ } catch (err) {
712
+ removePkceVerifier(this.config.clientId);
713
+ removeOAuthState(this.config.clientId);
714
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
715
+ throw err;
716
+ }
717
+ }
416
718
  /**
417
719
  * 处理 OAuth 回调
418
720
  *
419
721
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
420
- * 成功后 token 自动保存到 sessionStorage。
722
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
421
723
  *
422
724
  * @param callbackUrl - 完整的回调 URL(window.location.href)
423
725
  * @returns TokenData 或 null
@@ -427,23 +729,27 @@ var _SsoClient = class _SsoClient {
427
729
  const params = url.searchParams;
428
730
  const error = params.get("error");
429
731
  if (error) {
732
+ removeOAuthState(this.config.clientId);
733
+ removePkceVerifier(this.config.clientId);
430
734
  const desc = params.get("error_description") || error;
431
- throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
735
+ throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
432
736
  }
433
737
  const code = params.get("code");
434
738
  const returnedState = params.get("state");
435
739
  if (!code) {
740
+ removeOAuthState(this.config.clientId);
741
+ removePkceVerifier(this.config.clientId);
436
742
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
437
743
  }
438
744
  const savedState = getOAuthState(this.config.clientId);
439
- if (!savedState || savedState !== returnedState) {
745
+ if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
440
746
  removeOAuthState(this.config.clientId);
747
+ removePkceVerifier(this.config.clientId);
441
748
  throw new SsoError(
442
749
  "state_mismatch",
443
750
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
444
751
  );
445
752
  }
446
- removeOAuthState(this.config.clientId);
447
753
  const verifier = getPkceVerifier(this.config.clientId);
448
754
  if (!verifier) {
449
755
  throw new SsoError(
@@ -451,7 +757,6 @@ var _SsoClient = class _SsoClient {
451
757
  "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
452
758
  );
453
759
  }
454
- removePkceVerifier(this.config.clientId);
455
760
  const tokenEndpoint = await this._getTokenEndpoint();
456
761
  const body = new URLSearchParams();
457
762
  body.set("grant_type", "authorization_code");
@@ -478,8 +783,9 @@ var _SsoClient = class _SsoClient {
478
783
  errData = await res.json();
479
784
  } catch {
480
785
  }
786
+ const serverError = errData.error || "";
481
787
  throw new SsoError(
482
- "token_request_failed",
788
+ mapOAuthErrorToSsoCode(serverError),
483
789
  errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
484
790
  );
485
791
  }
@@ -488,15 +794,17 @@ var _SsoClient = class _SsoClient {
488
794
  try {
489
795
  await validateIdToken(
490
796
  data.id_token,
797
+ data.access_token,
491
798
  this.config.ssoBaseUrl,
492
- this.config.clientId,
493
- data.access_token
799
+ this.config.clientId
494
800
  );
495
801
  } catch (err) {
496
802
  removeTokenData(this.config.clientId);
497
803
  throw err;
498
804
  }
499
805
  }
806
+ removeOAuthState(this.config.clientId);
807
+ removePkceVerifier(this.config.clientId);
500
808
  const now = Date.now();
501
809
  const tokenData = {
502
810
  access_token: data.access_token,
@@ -539,15 +847,24 @@ var _SsoClient = class _SsoClient {
539
847
  if (this.config.clientSecret) {
540
848
  body.set("client_secret", this.config.clientSecret);
541
849
  }
542
- let res;
543
- try {
544
- res = await fetch(tokenEndpoint, {
545
- method: "POST",
546
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
547
- body: body.toString()
548
- });
549
- } catch (err) {
550
- throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
850
+ let res = null;
851
+ let lastErr;
852
+ for (let attempt = 0; attempt < 2; attempt++) {
853
+ try {
854
+ res = await fetch(tokenEndpoint, {
855
+ method: "POST",
856
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
857
+ body: body.toString()
858
+ });
859
+ lastErr = null;
860
+ break;
861
+ } catch (err) {
862
+ lastErr = err;
863
+ if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
864
+ }
865
+ }
866
+ if (lastErr || !res) {
867
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
551
868
  }
552
869
  if (!res.ok) {
553
870
  let errData = {};
@@ -560,17 +877,31 @@ var _SsoClient = class _SsoClient {
560
877
  removeTokenData(this.config.clientId);
561
878
  }
562
879
  throw new SsoError(
563
- errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
880
+ mapOAuthErrorToSsoCode(errorCode, "refresh"),
564
881
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
565
882
  );
566
883
  }
567
884
  const data = await res.json();
885
+ if (data.id_token) {
886
+ try {
887
+ await validateIdToken(
888
+ data.id_token,
889
+ data.access_token,
890
+ this.config.ssoBaseUrl,
891
+ this.config.clientId
892
+ );
893
+ } catch (err) {
894
+ removeTokenData(this.config.clientId);
895
+ throw err;
896
+ }
897
+ }
568
898
  const now = Date.now();
569
899
  const tokenData = {
570
900
  access_token: data.access_token,
571
901
  token_type: data.token_type,
572
902
  expires_in: data.expires_in,
573
- refresh_token: data.refresh_token,
903
+ // RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
904
+ refresh_token: data.refresh_token || current.refresh_token,
574
905
  id_token: data.id_token,
575
906
  issued_at: now,
576
907
  expires_at: now + data.expires_in * 1e3
@@ -642,7 +973,9 @@ var _SsoClient = class _SsoClient {
642
973
  * 登出
643
974
  *
644
975
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
645
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
976
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
977
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
978
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
646
979
  */
647
980
  async logout(redirectToSso = false) {
648
981
  const tokenData = getTokenData(this.config.clientId);
@@ -671,23 +1004,50 @@ var _SsoClient = class _SsoClient {
671
1004
  }
672
1005
  if (redirectToSso) {
673
1006
  const discovery = await this._getDiscovery();
674
- const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
1007
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
675
1008
  const logoutUrl = new URL(endSessionEndpoint);
676
1009
  logoutUrl.searchParams.set("client_id", this.config.clientId);
677
- if (this.config.redirectUri) {
1010
+ const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1011
+ if (postLogoutUri) {
678
1012
  logoutUrl.searchParams.set(
679
1013
  "post_logout_redirect_uri",
680
- this.config.redirectUri
1014
+ postLogoutUri
681
1015
  );
682
1016
  }
683
1017
  if (idTokenHint) {
684
1018
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
685
1019
  }
686
1020
  const state = generateState();
1021
+ saveLogoutState(state, this.config.clientId);
687
1022
  logoutUrl.searchParams.set("state", state);
688
1023
  window.location.href = logoutUrl.toString();
689
1024
  }
690
1025
  }
1026
+ /**
1027
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
1028
+ *
1029
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
1030
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
1031
+ * 校验后清除已保存的 logout state(一次性)。
1032
+ *
1033
+ * @param url - 当前页面完整 URL(window.location.href)
1034
+ *
1035
+ * @example
1036
+ * ```typescript
1037
+ * if (sso.validateLogoutState(window.location.href)) {
1038
+ * // 来自 SSO 登出的可信回跳
1039
+ * }
1040
+ * ```
1041
+ */
1042
+ validateLogoutState(url) {
1043
+ const returnedState = new URL(url).searchParams.get("state");
1044
+ if (!returnedState) return false;
1045
+ const savedState = getLogoutState(this.config.clientId);
1046
+ if (!savedState) return false;
1047
+ if (!timingSafeEqualString(savedState, returnedState)) return false;
1048
+ removeLogoutState(this.config.clientId);
1049
+ return true;
1050
+ }
691
1051
  /**
692
1052
  * 获取 OIDC Discovery 文档
693
1053
  *
@@ -708,6 +1068,7 @@ var SsoClient = _SsoClient;
708
1068
  var SsoContext = createContext(null);
709
1069
  var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
710
1070
  var LOCK_TTL_MS = 5e3;
1071
+ var ownedLocks = /* @__PURE__ */ new Map();
711
1072
  function lockKey(clientId) {
712
1073
  return REFRESH_LOCK_PREFIX + clientId;
713
1074
  }
@@ -722,12 +1083,44 @@ function acquireRefreshLock(clientId) {
722
1083
  return false;
723
1084
  }
724
1085
  }
725
- localStorage.setItem(key, String(now));
1086
+ const token = `${now}:${Math.random().toString(36).slice(2)}`;
1087
+ localStorage.setItem(key, token);
1088
+ if (localStorage.getItem(key) !== token) return false;
1089
+ ownedLocks.set(clientId, token);
726
1090
  return true;
727
1091
  }
728
1092
  function releaseRefreshLock(clientId) {
729
1093
  if (typeof localStorage === "undefined") return;
730
- localStorage.removeItem(lockKey(clientId));
1094
+ const owned = ownedLocks.get(clientId);
1095
+ ownedLocks.delete(clientId);
1096
+ if (owned && localStorage.getItem(lockKey(clientId)) === owned) {
1097
+ localStorage.removeItem(lockKey(clientId));
1098
+ }
1099
+ }
1100
+ async function withRefreshLock(clientId, task) {
1101
+ if (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function") {
1102
+ try {
1103
+ let ran = false;
1104
+ await navigator.locks.request(
1105
+ `nihplod_sso_refresh_${clientId}`,
1106
+ { ifAvailable: true },
1107
+ async (lock) => {
1108
+ if (!lock) return;
1109
+ ran = true;
1110
+ await task();
1111
+ }
1112
+ );
1113
+ return ran;
1114
+ } catch {
1115
+ }
1116
+ }
1117
+ if (!acquireRefreshLock(clientId)) return false;
1118
+ try {
1119
+ await task();
1120
+ return true;
1121
+ } finally {
1122
+ releaseRefreshLock(clientId);
1123
+ }
731
1124
  }
732
1125
  function SsoProvider({
733
1126
  config,
@@ -738,14 +1131,16 @@ function SsoProvider({
738
1131
  const [user, setUser] = useState(null);
739
1132
  const [isLoading, setIsLoading] = useState(true);
740
1133
  const [isAuthenticated, setIsAuthenticated] = useState(false);
741
- const clientRef = useRef(new SsoClient(config));
1134
+ const [error, setError] = useState(null);
1135
+ const [client] = useState(() => new SsoClient(config));
742
1136
  const refreshTimerRef = useRef(null);
743
1137
  const loadedRef = useRef(false);
744
- const client = clientRef.current;
745
1138
  const loadUser = useCallback(async () => {
746
- if (!client.isAuthenticated()) {
1139
+ const tokenData = getTokenData(client.config.clientId);
1140
+ if (!tokenData) {
747
1141
  setUser(null);
748
1142
  setIsAuthenticated(false);
1143
+ setError(null);
749
1144
  setIsLoading(false);
750
1145
  return;
751
1146
  }
@@ -753,9 +1148,13 @@ function SsoProvider({
753
1148
  const u = await client.getUserInfo();
754
1149
  setUser(u);
755
1150
  setIsAuthenticated(true);
756
- } catch {
1151
+ setError(null);
1152
+ } catch (err) {
757
1153
  setUser(null);
758
1154
  setIsAuthenticated(false);
1155
+ setError(
1156
+ err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1157
+ );
759
1158
  removeTokenData(client.config.clientId);
760
1159
  } finally {
761
1160
  setIsLoading(false);
@@ -764,14 +1163,25 @@ function SsoProvider({
764
1163
  useEffect(() => {
765
1164
  if (loadedRef.current) return;
766
1165
  loadedRef.current = true;
767
- if (client.isAuthenticated()) {
768
- loadUser();
769
- } else {
770
- setIsLoading(false);
771
- }
1166
+ Promise.resolve().then(() => {
1167
+ if (getTokenData(client.config.clientId)) {
1168
+ loadUser();
1169
+ } else {
1170
+ setIsLoading(false);
1171
+ }
1172
+ });
772
1173
  }, [client, loadUser]);
773
1174
  useEffect(() => {
774
1175
  let active = true;
1176
+ const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
1177
+ try {
1178
+ const td = await client.refreshToken();
1179
+ onTokenRefreshed?.(td.access_token);
1180
+ loadUser();
1181
+ } catch {
1182
+ setTimeout(() => loadUser(), 500);
1183
+ }
1184
+ });
775
1185
  const scheduleNextRefresh = () => {
776
1186
  if (!active) return;
777
1187
  if (refreshTimerRef.current) {
@@ -782,41 +1192,23 @@ function SsoProvider({
782
1192
  if (!tokenData) return;
783
1193
  const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
784
1194
  if (remainingSec <= 0) {
785
- if (!acquireRefreshLock(client.config.clientId)) return;
786
- client.refreshToken().then((td) => {
787
- onTokenRefreshed?.(td.access_token);
788
- loadUser();
789
- }).catch(() => {
790
- setTimeout(() => loadUser(), 500);
791
- }).finally(() => releaseRefreshLock(client.config.clientId));
1195
+ void attemptRefresh();
792
1196
  return;
793
1197
  }
794
1198
  if (remainingSec <= refreshThreshold) {
795
- if (!acquireRefreshLock(client.config.clientId)) return;
796
- client.refreshToken().then((td) => {
797
- onTokenRefreshed?.(td.access_token);
798
- loadUser();
799
- }).catch(() => {
800
- setTimeout(() => loadUser(), 500);
801
- }).finally(() => releaseRefreshLock(client.config.clientId));
1199
+ void attemptRefresh();
802
1200
  return;
803
1201
  }
804
1202
  const delayMs = (remainingSec - refreshThreshold) * 1e3;
805
1203
  refreshTimerRef.current = setTimeout(() => {
1204
+ if (!active) return;
806
1205
  const td = getTokenData(client.config.clientId);
807
1206
  if (!td) return;
808
1207
  const secLeft = (td.expires_at - Date.now()) / 1e3;
809
1208
  if (secLeft <= refreshThreshold) {
810
- if (!acquireRefreshLock(client.config.clientId)) {
811
- return;
812
- }
813
- client.refreshToken().then((newTd) => {
814
- onTokenRefreshed?.(newTd.access_token);
815
- loadUser();
816
- }).catch(() => {
817
- setTimeout(() => loadUser(), 500);
818
- }).finally(() => releaseRefreshLock(client.config.clientId));
819
- scheduleNextRefresh();
1209
+ void attemptRefresh().then((didRefresh) => {
1210
+ if (didRefresh) scheduleNextRefresh();
1211
+ });
820
1212
  }
821
1213
  }, Math.max(delayMs, 1e3));
822
1214
  };
@@ -844,11 +1236,20 @@ function SsoProvider({
844
1236
  },
845
1237
  [client]
846
1238
  );
1239
+ const loginPopup = useCallback(
1240
+ async (options) => {
1241
+ const tokenData = await client.loginPopup(options);
1242
+ await loadUser();
1243
+ return tokenData;
1244
+ },
1245
+ [client, loadUser]
1246
+ );
847
1247
  const logout = useCallback(
848
1248
  async (redirectToSso = false) => {
849
1249
  await client.logout(redirectToSso);
850
1250
  setUser(null);
851
1251
  setIsAuthenticated(false);
1252
+ setError(null);
852
1253
  },
853
1254
  [client]
854
1255
  );
@@ -862,7 +1263,9 @@ function SsoProvider({
862
1263
  user,
863
1264
  isAuthenticated,
864
1265
  isLoading,
1266
+ error,
865
1267
  login,
1268
+ loginPopup,
866
1269
  logout,
867
1270
  refreshUser,
868
1271
  getAccessToken,
@@ -879,25 +1282,67 @@ function useSso() {
879
1282
  }
880
1283
 
881
1284
  // src/react/RequireAuth.tsx
882
- import React2, { useEffect as useEffect2, useRef as useRef2 } from "react";
1285
+ import React2, { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
883
1286
  function RequireAuth({
884
1287
  children,
885
1288
  fallback,
886
- autoLogin = true
1289
+ autoLogin = true,
1290
+ usePopup = false,
1291
+ onError,
1292
+ renderLoginError
887
1293
  }) {
888
- const { isAuthenticated, isLoading, login } = useSso();
1294
+ const { isAuthenticated, isLoading, login, loginPopup } = useSso();
889
1295
  const loginTriggeredRef = useRef2(false);
1296
+ const [loginError, setLoginError] = useState2(null);
1297
+ const triggerLogin = () => {
1298
+ loginTriggeredRef.current = true;
1299
+ const currentPath = window.location.pathname + window.location.search;
1300
+ const handleFailure = (err) => {
1301
+ loginTriggeredRef.current = false;
1302
+ setLoginError(err);
1303
+ onError?.(err);
1304
+ };
1305
+ if (usePopup) {
1306
+ loginPopup({ returnUrl: currentPath }).catch((err) => {
1307
+ if (err.code === "popup_blocked") {
1308
+ login(currentPath).catch(handleFailure);
1309
+ return;
1310
+ }
1311
+ handleFailure(err);
1312
+ });
1313
+ } else {
1314
+ login(currentPath).catch(handleFailure);
1315
+ }
1316
+ };
1317
+ const retry = () => {
1318
+ setLoginError(null);
1319
+ triggerLogin();
1320
+ };
890
1321
  useEffect2(() => {
891
1322
  if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
892
- loginTriggeredRef.current = true;
893
- const currentPath = window.location.pathname + window.location.search;
894
- login(currentPath);
1323
+ triggerLogin();
895
1324
  }
896
- }, [isLoading, isAuthenticated, autoLogin, login]);
1325
+ }, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
897
1326
  if (isLoading) {
898
1327
  return fallback || React2.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
899
1328
  }
900
1329
  if (!isAuthenticated) {
1330
+ if (loginError) {
1331
+ if (renderLoginError) {
1332
+ return React2.createElement(
1333
+ React2.Fragment,
1334
+ null,
1335
+ renderLoginError(loginError, retry)
1336
+ );
1337
+ }
1338
+ const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
1339
+ return React2.createElement(
1340
+ "div",
1341
+ null,
1342
+ React2.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
1343
+ React2.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
1344
+ );
1345
+ }
901
1346
  if (fallback) return React2.createElement(React2.Fragment, null, fallback);
902
1347
  return React2.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");
903
1348
  }
@@ -917,31 +1362,81 @@ function withAuth(Component) {
917
1362
  }
918
1363
 
919
1364
  // src/react/CallbackPage.tsx
920
- import React3, { useEffect as useEffect3, useState as useState2 } from "react";
921
- function CallbackPage() {
1365
+ import React3, { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
1366
+ function DefaultCallbackError({ error }) {
1367
+ return React3.createElement(
1368
+ "div",
1369
+ {
1370
+ style: {
1371
+ display: "flex",
1372
+ flexDirection: "column",
1373
+ alignItems: "center",
1374
+ justifyContent: "center",
1375
+ minHeight: "100vh",
1376
+ fontFamily: "system-ui, sans-serif"
1377
+ }
1378
+ },
1379
+ React3.createElement(
1380
+ "p",
1381
+ { style: { color: "#dc2626", marginBottom: "1rem" } },
1382
+ error
1383
+ ),
1384
+ React3.createElement(
1385
+ "a",
1386
+ { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1387
+ "\u8FD4\u56DE\u9996\u9875"
1388
+ )
1389
+ );
1390
+ }
1391
+ function CallbackPage({ onSuccess, onError, renderError } = {}) {
922
1392
  const { client, refreshUser } = useSso();
923
- const [error, setError] = useState2(null);
924
- const [processing, setProcessing] = useState2(true);
1393
+ const [error, setError] = useState3(null);
1394
+ const [processing, setProcessing] = useState3(true);
1395
+ const onSuccessRef = useRef3(onSuccess);
1396
+ const onErrorRef = useRef3(onError);
1397
+ useEffect3(() => {
1398
+ onSuccessRef.current = onSuccess;
1399
+ onErrorRef.current = onError;
1400
+ });
925
1401
  useEffect3(() => {
1402
+ if (window.opener && !window.opener.closed) {
1403
+ const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
1404
+ let targetOrigin = window.location.origin;
1405
+ try {
1406
+ targetOrigin = window.opener.location.origin;
1407
+ } catch {
1408
+ }
1409
+ window.opener.postMessage(
1410
+ { type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
1411
+ targetOrigin
1412
+ );
1413
+ Promise.resolve().then(() => setProcessing(false));
1414
+ return;
1415
+ }
926
1416
  let cancelled = false;
927
1417
  async function handleCallback() {
928
1418
  try {
929
- await client.handleCallback(window.location.href);
1419
+ const tokenData = await client.handleCallback(window.location.href);
930
1420
  if (cancelled) return;
931
1421
  await refreshUser();
932
- const returnUrl = getReturnUrl();
933
- removeReturnUrl();
934
- window.location.href = returnUrl || "/";
1422
+ if (cancelled) return;
1423
+ const clientId = client.config.clientId;
1424
+ const returnUrl = getReturnUrl(clientId);
1425
+ removeReturnUrl(clientId);
1426
+ const onSuccessCb = onSuccessRef.current;
1427
+ if (onSuccessCb) {
1428
+ onSuccessCb(tokenData);
1429
+ return;
1430
+ }
1431
+ window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
935
1432
  } catch (err) {
936
1433
  if (cancelled) return;
937
- if (err instanceof SsoError) {
938
- setError(err.description || `SSO \u9519\u8BEF (${err.code})`);
939
- } else if (err instanceof TypeError && err.message.includes("fetch")) {
940
- setError("\u7F51\u7EDC\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5");
941
- } else if (err instanceof Error) {
942
- setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${err.message}`);
1434
+ const errorObj = err instanceof Error ? err : new Error(String(err));
1435
+ onErrorRef.current?.(errorObj);
1436
+ if (errorObj instanceof SsoError) {
1437
+ setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
943
1438
  } else {
944
- setError("\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5");
1439
+ setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
945
1440
  }
946
1441
  setProcessing(false);
947
1442
  }
@@ -952,29 +1447,8 @@ function CallbackPage() {
952
1447
  };
953
1448
  }, [client, refreshUser]);
954
1449
  if (error) {
955
- return React3.createElement(
956
- "div",
957
- {
958
- style: {
959
- display: "flex",
960
- flexDirection: "column",
961
- alignItems: "center",
962
- justifyContent: "center",
963
- minHeight: "100vh",
964
- fontFamily: "system-ui, sans-serif"
965
- }
966
- },
967
- React3.createElement(
968
- "p",
969
- { style: { color: "#dc2626", marginBottom: "1rem" } },
970
- error
971
- ),
972
- React3.createElement(
973
- "a",
974
- { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
975
- "\u8FD4\u56DE\u9996\u9875"
976
- )
977
- );
1450
+ if (renderError) return React3.createElement(React3.Fragment, null, renderError(error));
1451
+ return React3.createElement(DefaultCallbackError, { error });
978
1452
  }
979
1453
  if (processing) {
980
1454
  return React3.createElement(
@@ -995,6 +1469,7 @@ function CallbackPage() {
995
1469
  }
996
1470
  export {
997
1471
  CallbackPage,
1472
+ DefaultCallbackError,
998
1473
  RequireAuth,
999
1474
  SsoProvider,
1000
1475
  useSso,