@nihplod/sso-sdk 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ __export(src_exports, {
25
25
  SsoError: () => SsoError,
26
26
  clearAllSsoData: () => clearAllSsoData,
27
27
  clearVerifiersForClients: () => clearVerifiersForClients,
28
+ createSecureStorage: () => createSecureStorage,
28
29
  generateCodeChallenge: () => generateCodeChallenge,
29
30
  generateCodeVerifier: () => generateCodeVerifier,
30
31
  generateState: () => generateState,
@@ -33,6 +34,8 @@ __export(src_exports, {
33
34
  getReturnUrl: () => getReturnUrl,
34
35
  getTokenData: () => getTokenData,
35
36
  getTokenStorage: () => getTokenStorage,
37
+ isTrustedReturnUrl: () => isTrustedReturnUrl,
38
+ mapOAuthErrorToSsoCode: () => mapOAuthErrorToSsoCode,
36
39
  removeOAuthState: () => removeOAuthState,
37
40
  removePkceVerifier: () => removePkceVerifier,
38
41
  removeReturnUrl: () => removeReturnUrl,
@@ -41,11 +44,32 @@ __export(src_exports, {
41
44
  savePkceVerifier: () => savePkceVerifier,
42
45
  saveReturnUrl: () => saveReturnUrl,
43
46
  saveTokenData: () => saveTokenData,
44
- setTokenStorage: () => setTokenStorage
47
+ setTokenStorage: () => setTokenStorage,
48
+ timingSafeEqualString: () => timingSafeEqualString
45
49
  });
46
50
  module.exports = __toCommonJS(src_exports);
47
51
 
48
52
  // src/core/errors.ts
53
+ function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
54
+ switch (oauthError) {
55
+ case "invalid_grant":
56
+ return context === "refresh" ? "session_expired" : "authorization_code_expired";
57
+ case "invalid_client":
58
+ return "client_disabled";
59
+ case "access_denied":
60
+ return "user_denied_authorization";
61
+ case "server_error":
62
+ return "sso_server_error";
63
+ case "rate_limited":
64
+ return "rate_limited";
65
+ case "invalid_scope":
66
+ case "invalid_request":
67
+ case "unauthorized_client":
68
+ case "unsupported_grant_type":
69
+ default:
70
+ return "token_request_failed";
71
+ }
72
+ }
49
73
  var SsoError = class extends Error {
50
74
  constructor(code, description, cause) {
51
75
  super(`[SSO SDK] ${code}: ${description}`);
@@ -128,6 +152,57 @@ function createMemoryStorageAdapter() {
128
152
  };
129
153
  }
130
154
  var memoryStorageAdapter = createMemoryStorageAdapter();
155
+ var localStorageAdapter = {
156
+ get(key) {
157
+ if (typeof localStorage === "undefined") return null;
158
+ return localStorage.getItem(STORAGE_PREFIX + key);
159
+ },
160
+ set(key, value) {
161
+ if (typeof localStorage === "undefined") return;
162
+ try {
163
+ localStorage.setItem(STORAGE_PREFIX + key, value);
164
+ } catch (err) {
165
+ console.warn(
166
+ `[SSO SDK] localStorage \u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.name : String(err)}\uFF09\uFF0C\u6570\u636E\u672A\u6301\u4E45\u5316`
167
+ );
168
+ }
169
+ },
170
+ remove(key) {
171
+ if (typeof localStorage === "undefined") return;
172
+ localStorage.removeItem(STORAGE_PREFIX + key);
173
+ }
174
+ };
175
+ function createSessionStorageAdapter() {
176
+ const fallback = /* @__PURE__ */ new Map();
177
+ return {
178
+ get(key) {
179
+ if (typeof sessionStorage !== "undefined") {
180
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
181
+ }
182
+ return fallback.get(key) ?? null;
183
+ },
184
+ set(key, value) {
185
+ if (typeof sessionStorage === "undefined") {
186
+ fallback.set(key, value);
187
+ return;
188
+ }
189
+ try {
190
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
191
+ } catch {
192
+ fallback.set(key, value);
193
+ }
194
+ },
195
+ remove(key) {
196
+ fallback.delete(key);
197
+ if (typeof sessionStorage === "undefined") return;
198
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
199
+ }
200
+ };
201
+ }
202
+ var _transient = createSessionStorageAdapter();
203
+ function createSecureStorage(options = {}) {
204
+ return options.persist ? localStorageAdapter : memoryStorageAdapter;
205
+ }
131
206
  var _storage = memoryStorageAdapter;
132
207
  function setTokenStorage(storage) {
133
208
  _storage = storage;
@@ -151,31 +226,31 @@ function removeTokenData(clientId) {
151
226
  _storage.remove(buildKey(TOKEN_KEY, clientId));
152
227
  }
153
228
  function savePkceVerifier(clientId, verifier) {
154
- _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
229
+ _transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
155
230
  }
156
231
  function getPkceVerifier(clientId) {
157
- return _storage.get(VERIFIER_KEY_PREFIX + clientId);
232
+ return _transient.get(VERIFIER_KEY_PREFIX + clientId);
158
233
  }
159
234
  function removePkceVerifier(clientId) {
160
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
235
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
161
236
  }
162
237
  function saveOAuthState(state, clientId) {
163
- _storage.set(buildKey(STATE_KEY, clientId), state);
238
+ _transient.set(buildKey(STATE_KEY, clientId), state);
164
239
  }
165
240
  function getOAuthState(clientId) {
166
- return _storage.get(buildKey(STATE_KEY, clientId));
241
+ return _transient.get(buildKey(STATE_KEY, clientId));
167
242
  }
168
243
  function removeOAuthState(clientId) {
169
- _storage.remove(buildKey(STATE_KEY, clientId));
244
+ _transient.remove(buildKey(STATE_KEY, clientId));
170
245
  }
171
246
  function saveReturnUrl(url, clientId) {
172
- _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
247
+ _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
173
248
  }
174
249
  function getReturnUrl(clientId) {
175
- return _storage.get(buildKey(RETURN_URL_KEY, clientId));
250
+ return _transient.get(buildKey(RETURN_URL_KEY, clientId));
176
251
  }
177
252
  function removeReturnUrl(clientId) {
178
- _storage.remove(buildKey(RETURN_URL_KEY, clientId));
253
+ _transient.remove(buildKey(RETURN_URL_KEY, clientId));
179
254
  }
180
255
  function clearAllSsoData(clientId) {
181
256
  if (clientId) {
@@ -183,28 +258,63 @@ function clearAllSsoData(clientId) {
183
258
  removeOAuthState(clientId);
184
259
  removeReturnUrl(clientId);
185
260
  removePkceVerifier(clientId);
261
+ removePkceVerifier(`${clientId}_popup_nonce`);
186
262
  return;
187
263
  }
188
264
  removeTokenData();
189
265
  removeOAuthState();
190
266
  removeReturnUrl();
191
- if (typeof localStorage !== "undefined") {
192
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
193
- for (let i = 0; i < localStorage.length; i++) {
194
- const key = localStorage.key(i);
195
- if (key?.startsWith(prefix)) {
196
- _storage.remove(key.slice(STORAGE_PREFIX.length));
197
- }
267
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
268
+ const stores = [
269
+ typeof sessionStorage !== "undefined" ? sessionStorage : null,
270
+ typeof localStorage !== "undefined" ? localStorage : null
271
+ ];
272
+ for (const store of stores) {
273
+ if (!store) continue;
274
+ const keys = [];
275
+ for (let i = 0; i < store.length; i++) {
276
+ const key = store.key(i);
277
+ if (key?.startsWith(prefix)) keys.push(key);
278
+ }
279
+ for (const key of keys) {
280
+ _transient.remove(key.slice(STORAGE_PREFIX.length));
281
+ store.removeItem(key);
198
282
  }
199
283
  }
200
284
  }
201
285
  function clearVerifiersForClients(clientIds) {
202
286
  for (const clientId of clientIds) {
203
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
287
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
204
288
  }
205
289
  }
206
290
 
207
- // src/core/SsoClient.ts
291
+ // src/core/security.ts
292
+ function isTrustedReturnUrl(url, currentOrigin) {
293
+ if (!url) return false;
294
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
295
+ try {
296
+ return new URL(url).origin === currentOrigin;
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+ function timingSafeEqualString(a, b) {
302
+ const ba = new TextEncoder().encode(a);
303
+ const bb = new TextEncoder().encode(b);
304
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
305
+ const len = Math.max(ba.length, bb.length);
306
+ let diff = ba.length ^ bb.length;
307
+ for (let i = 0; i < len; i++) {
308
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
309
+ }
310
+ return diff === 0;
311
+ }
312
+
313
+ // src/core/id-token.ts
314
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
315
+ function getCrypto() {
316
+ return _crypto ?? null;
317
+ }
208
318
  function base64UrlDecode(input) {
209
319
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
210
320
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -239,14 +349,32 @@ function normalizeIssuer(url) {
239
349
  return url.replace(/\/+$/, "");
240
350
  }
241
351
  var cachedJwks = null;
352
+ var cachedDiscovery = null;
242
353
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
243
- async function fetchJwks(baseUrl) {
354
+ async function fetchDiscoveryDoc(baseUrl) {
244
355
  const now = Date.now();
245
- if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
356
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
357
+ return cachedDiscovery.doc;
358
+ }
359
+ try {
360
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
361
+ const doc = res.ok ? await res.json() : null;
362
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
363
+ return doc;
364
+ } catch {
365
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
366
+ return null;
367
+ }
368
+ }
369
+ async function fetchJwks(baseUrl, options = {}) {
370
+ const now = Date.now();
371
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
246
372
  return cachedJwks.jwks;
247
373
  }
374
+ const discovery = await fetchDiscoveryDoc(baseUrl);
375
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
248
376
  try {
249
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
377
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
250
378
  if (!res.ok) return null;
251
379
  const jwks = await res.json();
252
380
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -255,24 +383,13 @@ async function fetchJwks(baseUrl) {
255
383
  return null;
256
384
  }
257
385
  }
258
- async function computeAtHash(accessToken) {
259
- const hash = await crypto.subtle.digest(
260
- "SHA-256",
261
- new TextEncoder().encode(accessToken)
262
- );
263
- const bytes = new Uint8Array(hash);
264
- const half = bytes.slice(0, bytes.length / 2);
265
- let binary = "";
266
- for (const b of half) {
267
- binary += String.fromCharCode(b);
268
- }
269
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
270
- }
271
- async function verifyRs256(token, jwk) {
386
+ async function verifyRs256Signature(token, jwk) {
272
387
  try {
273
388
  const [headerB64, payloadB64, signature] = token.split(".");
274
389
  if (!signature || !jwk.n || !jwk.e) return false;
275
- const cryptoKey = await crypto.subtle.importKey(
390
+ const c = getCrypto();
391
+ if (!c) return false;
392
+ const cryptoKey = await c.subtle.importKey(
276
393
  "jwk",
277
394
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
278
395
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -281,7 +398,7 @@ async function verifyRs256(token, jwk) {
281
398
  );
282
399
  const signatureBytes = base64UrlDecode(signature);
283
400
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
284
- return await crypto.subtle.verify(
401
+ return await c.subtle.verify(
285
402
  "RSASSA-PKCS1-v1_5",
286
403
  cryptoKey,
287
404
  signatureBytes,
@@ -291,7 +408,20 @@ async function verifyRs256(token, jwk) {
291
408
  return false;
292
409
  }
293
410
  }
294
- async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
411
+ async function computeAtHash(accessToken) {
412
+ const c = getCrypto();
413
+ if (!c) return "";
414
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
415
+ const bytes = new Uint8Array(hash);
416
+ const half = bytes.slice(0, bytes.length / 2);
417
+ let binary = "";
418
+ for (const b of half) {
419
+ binary += String.fromCharCode(b);
420
+ }
421
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
422
+ }
423
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
424
+ const { rejectHs256WhenRs256Available = true } = options;
295
425
  const header = decodeJwtHeader(idToken);
296
426
  if (!header) {
297
427
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -300,54 +430,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
300
430
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
301
431
  throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
302
432
  }
433
+ const baseUrl = normalizeIssuer(expectedIssuer);
434
+ const discovery = await fetchDiscoveryDoc(baseUrl);
435
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
303
436
  if (alg === "RS256") {
304
- const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
437
+ const jwks = await fetchJwks(baseUrl);
305
438
  if (!jwks) {
306
439
  throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
307
440
  }
308
441
  const kid = typeof header.kid === "string" ? header.kid : void 0;
309
- const key = jwks.keys.find(
442
+ const matchCandidates = (set) => set.keys.filter(
310
443
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
311
444
  );
312
- if (!key) {
445
+ const verifyAny = async (keys) => {
446
+ for (const key of keys) {
447
+ if (await verifyRs256Signature(idToken, key)) return true;
448
+ }
449
+ return false;
450
+ };
451
+ let candidates = matchCandidates(jwks);
452
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
453
+ if (candidates.length === 0 || !validSig) {
454
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
455
+ if (freshJwks) {
456
+ candidates = matchCandidates(freshJwks);
457
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
458
+ }
459
+ }
460
+ if (candidates.length === 0) {
313
461
  throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
314
462
  }
315
- const validSig = await verifyRs256(idToken, key);
316
463
  if (!validSig) {
317
464
  throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
318
465
  }
319
466
  }
320
467
  if (alg === "HS256") {
321
- console.warn(
322
- "[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"
468
+ if (rejectHs256WhenRs256Available) {
469
+ const jwks = await fetchJwks(baseUrl);
470
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
471
+ if (hasRs256) {
472
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
473
+ }
474
+ }
475
+ throw new SsoError(
476
+ "id_token_hs256_unsupported",
477
+ "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"
323
478
  );
324
479
  }
325
480
  const payload = decodeJwtPayload(idToken);
326
481
  if (!payload) {
327
482
  throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
328
483
  }
329
- const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
330
484
  const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
331
- if (tokenIssuer !== normalizedExpectedIssuer) {
485
+ if (tokenIssuer !== normalizedIssuer) {
332
486
  throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
333
487
  }
334
- if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
488
+ const aud = payload.aud;
489
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
490
+ if (!audList.includes(expectedClientId)) {
335
491
  throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
336
492
  }
337
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
493
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
494
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
495
+ }
496
+ if (typeof payload.exp !== "number") {
497
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
498
+ }
499
+ if (Date.now() >= payload.exp * 1e3) {
338
500
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
339
501
  }
502
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
503
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
504
+ }
340
505
  if (typeof payload.sub !== "string" || !payload.sub) {
341
506
  throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
342
507
  }
343
508
  if (typeof payload.at_hash === "string" && payload.at_hash) {
344
509
  const actual = await computeAtHash(accessToken);
345
- if (actual !== payload.at_hash) {
510
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
346
511
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
347
512
  }
348
513
  }
349
514
  return { sub: payload.sub };
350
515
  }
516
+
517
+ // src/core/SsoClient.ts
351
518
  var _SsoClient = class _SsoClient {
352
519
  constructor(config) {
353
520
  this._discovery = null;
@@ -416,6 +583,17 @@ var _SsoClient = class _SsoClient {
416
583
  if (d) return d.userinfo_endpoint;
417
584
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
418
585
  }
586
+ /**
587
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
588
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
589
+ */
590
+ _saveReturnUrlIfTrusted(returnUrl) {
591
+ if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
592
+ saveReturnUrl(returnUrl);
593
+ } else {
594
+ console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
595
+ }
596
+ }
419
597
  // ============================================
420
598
  // 公共 API
421
599
  // ============================================
@@ -425,7 +603,8 @@ var _SsoClient = class _SsoClient {
425
603
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
426
604
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
427
605
  *
428
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
606
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
607
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
429
608
  */
430
609
  async login(returnUrl) {
431
610
  const verifier = generateCodeVerifier();
@@ -434,7 +613,7 @@ var _SsoClient = class _SsoClient {
434
613
  savePkceVerifier(this.config.clientId, verifier);
435
614
  saveOAuthState(state, this.config.clientId);
436
615
  if (returnUrl) {
437
- saveReturnUrl(returnUrl);
616
+ this._saveReturnUrlIfTrusted(returnUrl);
438
617
  }
439
618
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
440
619
  const params = new URLSearchParams();
@@ -458,7 +637,7 @@ var _SsoClient = class _SsoClient {
458
637
  const state = generateState();
459
638
  savePkceVerifier(this.config.clientId, verifier);
460
639
  saveOAuthState(state, this.config.clientId);
461
- if (returnUrl) saveReturnUrl(returnUrl);
640
+ if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
462
641
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
463
642
  const params = new URLSearchParams();
464
643
  params.set("response_type", "code");
@@ -470,11 +649,140 @@ var _SsoClient = class _SsoClient {
470
649
  params.set("code_challenge_method", "S256");
471
650
  return `${authorizeEndpoint}?${params.toString()}`;
472
651
  }
652
+ /**
653
+ * 弹窗模式 SSO 登录
654
+ *
655
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
656
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
657
+ *
658
+ * 流程:
659
+ * 1. window.open() 打开授权 URL 到弹窗
660
+ * 2. 用户在弹窗中完成登录
661
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
662
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
663
+ * 5. 弹窗自动关闭
664
+ *
665
+ * @param options - 弹窗配置
666
+ * @param options.returnUrl - 登录成功后的返回地址
667
+ * @param options.width - 弹窗宽度(默认 480)
668
+ * @param options.height - 弹窗高度(默认 640)
669
+ * @returns TokenData
670
+ *
671
+ * @example
672
+ * ```typescript
673
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
674
+ * try {
675
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
676
+ * console.log("登录成功", tokenData);
677
+ * } catch (err) {
678
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
679
+ * // 弹窗被拦截,回退到同页重定向
680
+ * await client.login("/dashboard");
681
+ * }
682
+ * }
683
+ * ```
684
+ */
685
+ async loginPopup(options = {}) {
686
+ const verifier = generateCodeVerifier();
687
+ const challenge = await generateCodeChallenge(verifier);
688
+ const state = generateState();
689
+ const popupNonce = generateState();
690
+ savePkceVerifier(this.config.clientId, verifier);
691
+ saveOAuthState(state, this.config.clientId);
692
+ savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
693
+ if (options.returnUrl) {
694
+ this._saveReturnUrlIfTrusted(options.returnUrl);
695
+ }
696
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
697
+ const params = new URLSearchParams();
698
+ params.set("response_type", "code");
699
+ params.set("client_id", this.config.clientId);
700
+ params.set("redirect_uri", this.config.redirectUri);
701
+ params.set("scope", this.config.scopes || "openid profile");
702
+ params.set("state", state);
703
+ params.set("code_challenge", challenge);
704
+ params.set("code_challenge_method", "S256");
705
+ params.set("popup_nonce", popupNonce);
706
+ const width = options.width || 480;
707
+ const height = options.height || 640;
708
+ const left = Math.max(0, (window.screen.width - width) / 2);
709
+ const top = Math.max(0, (window.screen.height - height) / 2);
710
+ const popupFeatures = [
711
+ `width=${width}`,
712
+ `height=${height}`,
713
+ `left=${Math.round(left)}`,
714
+ `top=${Math.round(top)}`,
715
+ "resizable=yes",
716
+ "scrollbars=yes",
717
+ "status=yes"
718
+ ].join(",");
719
+ const popup = window.open(
720
+ `${authorizeEndpoint}?${params.toString()}`,
721
+ "nihplod_sso_popup",
722
+ popupFeatures
723
+ );
724
+ if (!popup) {
725
+ removePkceVerifier(this.config.clientId);
726
+ removeOAuthState(this.config.clientId);
727
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
728
+ throw new SsoError(
729
+ "popup_blocked",
730
+ "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
731
+ );
732
+ }
733
+ const redirectUriOrigin = new URL(this.config.redirectUri).origin;
734
+ try {
735
+ popup.focus();
736
+ return await new Promise((resolve, reject) => {
737
+ let completed = false;
738
+ const handleMessage = (event) => {
739
+ if (completed) return;
740
+ if (event.origin !== redirectUriOrigin) return;
741
+ if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
742
+ if (!event.data.callbackUrl) return;
743
+ const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
744
+ if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
745
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
746
+ completed = true;
747
+ cleanup();
748
+ if (popup && !popup.closed) {
749
+ popup.close();
750
+ }
751
+ this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
752
+ };
753
+ const pollTimer = setInterval(() => {
754
+ if (popup.closed) {
755
+ if (!completed) {
756
+ completed = true;
757
+ cleanup();
758
+ reject(
759
+ new SsoError(
760
+ "popup_closed",
761
+ "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
762
+ )
763
+ );
764
+ }
765
+ }
766
+ }, 500);
767
+ const cleanup = () => {
768
+ clearInterval(pollTimer);
769
+ window.removeEventListener("message", handleMessage);
770
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
771
+ };
772
+ window.addEventListener("message", handleMessage);
773
+ });
774
+ } catch (err) {
775
+ removePkceVerifier(this.config.clientId);
776
+ removeOAuthState(this.config.clientId);
777
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
778
+ throw err;
779
+ }
780
+ }
473
781
  /**
474
782
  * 处理 OAuth 回调
475
783
  *
476
784
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
477
- * 成功后 token 自动保存到 sessionStorage。
785
+ * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
478
786
  *
479
787
  * @param callbackUrl - 完整的回调 URL(window.location.href)
480
788
  * @returns TokenData 或 null
@@ -493,14 +801,14 @@ var _SsoClient = class _SsoClient {
493
801
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
494
802
  }
495
803
  const savedState = getOAuthState(this.config.clientId);
496
- if (!savedState || savedState !== returnedState) {
804
+ if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
497
805
  removeOAuthState(this.config.clientId);
806
+ removePkceVerifier(this.config.clientId);
498
807
  throw new SsoError(
499
808
  "state_mismatch",
500
809
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
501
810
  );
502
811
  }
503
- removeOAuthState(this.config.clientId);
504
812
  const verifier = getPkceVerifier(this.config.clientId);
505
813
  if (!verifier) {
506
814
  throw new SsoError(
@@ -508,7 +816,6 @@ var _SsoClient = class _SsoClient {
508
816
  "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
509
817
  );
510
818
  }
511
- removePkceVerifier(this.config.clientId);
512
819
  const tokenEndpoint = await this._getTokenEndpoint();
513
820
  const body = new URLSearchParams();
514
821
  body.set("grant_type", "authorization_code");
@@ -535,19 +842,22 @@ var _SsoClient = class _SsoClient {
535
842
  errData = await res.json();
536
843
  } catch {
537
844
  }
845
+ const serverError = errData.error || "";
538
846
  throw new SsoError(
539
- "token_request_failed",
847
+ mapOAuthErrorToSsoCode(serverError),
540
848
  errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
541
849
  );
542
850
  }
543
851
  const data = await res.json();
852
+ removeOAuthState(this.config.clientId);
853
+ removePkceVerifier(this.config.clientId);
544
854
  if (data.id_token) {
545
855
  try {
546
856
  await validateIdToken(
547
857
  data.id_token,
858
+ data.access_token,
548
859
  this.config.ssoBaseUrl,
549
- this.config.clientId,
550
- data.access_token
860
+ this.config.clientId
551
861
  );
552
862
  } catch (err) {
553
863
  removeTokenData(this.config.clientId);
@@ -596,15 +906,24 @@ var _SsoClient = class _SsoClient {
596
906
  if (this.config.clientSecret) {
597
907
  body.set("client_secret", this.config.clientSecret);
598
908
  }
599
- let res;
600
- try {
601
- res = await fetch(tokenEndpoint, {
602
- method: "POST",
603
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
604
- body: body.toString()
605
- });
606
- } catch (err) {
607
- throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
909
+ let res = null;
910
+ let lastErr;
911
+ for (let attempt = 0; attempt < 2; attempt++) {
912
+ try {
913
+ res = await fetch(tokenEndpoint, {
914
+ method: "POST",
915
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
916
+ body: body.toString()
917
+ });
918
+ lastErr = null;
919
+ break;
920
+ } catch (err) {
921
+ lastErr = err;
922
+ if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
923
+ }
924
+ }
925
+ if (lastErr || !res) {
926
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
608
927
  }
609
928
  if (!res.ok) {
610
929
  let errData = {};
@@ -617,17 +936,31 @@ var _SsoClient = class _SsoClient {
617
936
  removeTokenData(this.config.clientId);
618
937
  }
619
938
  throw new SsoError(
620
- errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
939
+ mapOAuthErrorToSsoCode(errorCode, "refresh"),
621
940
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
622
941
  );
623
942
  }
624
943
  const data = await res.json();
944
+ if (data.id_token) {
945
+ try {
946
+ await validateIdToken(
947
+ data.id_token,
948
+ data.access_token,
949
+ this.config.ssoBaseUrl,
950
+ this.config.clientId
951
+ );
952
+ } catch (err) {
953
+ removeTokenData(this.config.clientId);
954
+ throw err;
955
+ }
956
+ }
625
957
  const now = Date.now();
626
958
  const tokenData = {
627
959
  access_token: data.access_token,
628
960
  token_type: data.token_type,
629
961
  expires_in: data.expires_in,
630
- refresh_token: data.refresh_token,
962
+ // RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
963
+ refresh_token: data.refresh_token || current.refresh_token,
631
964
  id_token: data.id_token,
632
965
  issued_at: now,
633
966
  expires_at: now + data.expires_in * 1e3
@@ -728,13 +1061,14 @@ var _SsoClient = class _SsoClient {
728
1061
  }
729
1062
  if (redirectToSso) {
730
1063
  const discovery = await this._getDiscovery();
731
- const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
1064
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
732
1065
  const logoutUrl = new URL(endSessionEndpoint);
733
1066
  logoutUrl.searchParams.set("client_id", this.config.clientId);
734
- if (this.config.redirectUri) {
1067
+ const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1068
+ if (postLogoutUri) {
735
1069
  logoutUrl.searchParams.set(
736
1070
  "post_logout_redirect_uri",
737
- this.config.redirectUri
1071
+ postLogoutUri
738
1072
  );
739
1073
  }
740
1074
  if (idTokenHint) {
@@ -767,6 +1101,7 @@ var SsoClient = _SsoClient;
767
1101
  SsoError,
768
1102
  clearAllSsoData,
769
1103
  clearVerifiersForClients,
1104
+ createSecureStorage,
770
1105
  generateCodeChallenge,
771
1106
  generateCodeVerifier,
772
1107
  generateState,
@@ -775,6 +1110,8 @@ var SsoClient = _SsoClient;
775
1110
  getReturnUrl,
776
1111
  getTokenData,
777
1112
  getTokenStorage,
1113
+ isTrustedReturnUrl,
1114
+ mapOAuthErrorToSsoCode,
778
1115
  removeOAuthState,
779
1116
  removePkceVerifier,
780
1117
  removeReturnUrl,
@@ -783,6 +1120,7 @@ var SsoClient = _SsoClient;
783
1120
  savePkceVerifier,
784
1121
  saveReturnUrl,
785
1122
  saveTokenData,
786
- setTokenStorage
1123
+ setTokenStorage,
1124
+ timingSafeEqualString
787
1125
  });
788
1126
  //# sourceMappingURL=index.js.map