@nihplod/sso-sdk 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -25,27 +25,54 @@ __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,
32
+ getLogoutState: () => getLogoutState,
31
33
  getOAuthState: () => getOAuthState,
32
34
  getPkceVerifier: () => getPkceVerifier,
33
35
  getReturnUrl: () => getReturnUrl,
34
36
  getTokenData: () => getTokenData,
35
37
  getTokenStorage: () => getTokenStorage,
38
+ isTrustedReturnUrl: () => isTrustedReturnUrl,
39
+ mapOAuthErrorToSsoCode: () => mapOAuthErrorToSsoCode,
40
+ removeLogoutState: () => removeLogoutState,
36
41
  removeOAuthState: () => removeOAuthState,
37
42
  removePkceVerifier: () => removePkceVerifier,
38
43
  removeReturnUrl: () => removeReturnUrl,
39
44
  removeTokenData: () => removeTokenData,
45
+ saveLogoutState: () => saveLogoutState,
40
46
  saveOAuthState: () => saveOAuthState,
41
47
  savePkceVerifier: () => savePkceVerifier,
42
48
  saveReturnUrl: () => saveReturnUrl,
43
49
  saveTokenData: () => saveTokenData,
44
- setTokenStorage: () => setTokenStorage
50
+ setTokenStorage: () => setTokenStorage,
51
+ timingSafeEqualString: () => timingSafeEqualString
45
52
  });
46
53
  module.exports = __toCommonJS(src_exports);
47
54
 
48
55
  // src/core/errors.ts
56
+ function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
57
+ switch (oauthError) {
58
+ case "invalid_grant":
59
+ return context === "refresh" ? "session_expired" : "authorization_code_expired";
60
+ case "invalid_client":
61
+ return "client_disabled";
62
+ case "access_denied":
63
+ return "user_denied_authorization";
64
+ case "server_error":
65
+ return "sso_server_error";
66
+ case "rate_limited":
67
+ return "rate_limited";
68
+ case "invalid_scope":
69
+ case "invalid_request":
70
+ case "unauthorized_client":
71
+ case "unsupported_grant_type":
72
+ default:
73
+ return "token_request_failed";
74
+ }
75
+ }
49
76
  var SsoError = class extends Error {
50
77
  constructor(code, description, cause) {
51
78
  super(`[SSO SDK] ${code}: ${description}`);
@@ -110,6 +137,7 @@ var TOKEN_KEY = "token";
110
137
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
111
138
  var STATE_KEY = "oauth_state";
112
139
  var RETURN_URL_KEY = "return_url";
140
+ var LOGOUT_STATE_KEY = "logout_state";
113
141
  function buildKey(base, clientId) {
114
142
  return clientId ? `${base}:${clientId}` : base;
115
143
  }
@@ -128,7 +156,58 @@ function createMemoryStorageAdapter() {
128
156
  };
129
157
  }
130
158
  var memoryStorageAdapter = createMemoryStorageAdapter();
131
- var _storage = memoryStorageAdapter;
159
+ var localStorageAdapter = {
160
+ get(key) {
161
+ if (typeof localStorage === "undefined") return null;
162
+ return localStorage.getItem(STORAGE_PREFIX + key);
163
+ },
164
+ set(key, value) {
165
+ if (typeof localStorage === "undefined") return;
166
+ try {
167
+ localStorage.setItem(STORAGE_PREFIX + key, value);
168
+ } catch (err) {
169
+ console.warn(
170
+ `[SSO SDK] localStorage \u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.name : String(err)}\uFF09\uFF0C\u6570\u636E\u672A\u6301\u4E45\u5316`
171
+ );
172
+ }
173
+ },
174
+ remove(key) {
175
+ if (typeof localStorage === "undefined") return;
176
+ localStorage.removeItem(STORAGE_PREFIX + key);
177
+ }
178
+ };
179
+ function createSessionStorageAdapter() {
180
+ const fallback = /* @__PURE__ */ new Map();
181
+ return {
182
+ get(key) {
183
+ if (typeof sessionStorage !== "undefined") {
184
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
185
+ }
186
+ return fallback.get(key) ?? null;
187
+ },
188
+ set(key, value) {
189
+ if (typeof sessionStorage === "undefined") {
190
+ fallback.set(key, value);
191
+ return;
192
+ }
193
+ try {
194
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
195
+ } catch {
196
+ fallback.set(key, value);
197
+ }
198
+ },
199
+ remove(key) {
200
+ fallback.delete(key);
201
+ if (typeof sessionStorage === "undefined") return;
202
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
203
+ }
204
+ };
205
+ }
206
+ var _transient = createSessionStorageAdapter();
207
+ function createSecureStorage(options = {}) {
208
+ return options.persist ? localStorageAdapter : createSessionStorageAdapter();
209
+ }
210
+ var _storage = createSessionStorageAdapter();
132
211
  function setTokenStorage(storage) {
133
212
  _storage = storage;
134
213
  }
@@ -151,60 +230,107 @@ function removeTokenData(clientId) {
151
230
  _storage.remove(buildKey(TOKEN_KEY, clientId));
152
231
  }
153
232
  function savePkceVerifier(clientId, verifier) {
154
- _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
233
+ _transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
155
234
  }
156
235
  function getPkceVerifier(clientId) {
157
- return _storage.get(VERIFIER_KEY_PREFIX + clientId);
236
+ return _transient.get(VERIFIER_KEY_PREFIX + clientId);
158
237
  }
159
238
  function removePkceVerifier(clientId) {
160
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
239
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
161
240
  }
162
241
  function saveOAuthState(state, clientId) {
163
- _storage.set(buildKey(STATE_KEY, clientId), state);
242
+ _transient.set(buildKey(STATE_KEY, clientId), state);
164
243
  }
165
244
  function getOAuthState(clientId) {
166
- return _storage.get(buildKey(STATE_KEY, clientId));
245
+ return _transient.get(buildKey(STATE_KEY, clientId));
167
246
  }
168
247
  function removeOAuthState(clientId) {
169
- _storage.remove(buildKey(STATE_KEY, clientId));
248
+ _transient.remove(buildKey(STATE_KEY, clientId));
249
+ }
250
+ function saveLogoutState(state, clientId) {
251
+ _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
252
+ }
253
+ function getLogoutState(clientId) {
254
+ return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
255
+ }
256
+ function removeLogoutState(clientId) {
257
+ _transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
170
258
  }
171
259
  function saveReturnUrl(url, clientId) {
172
- _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
260
+ _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
173
261
  }
174
262
  function getReturnUrl(clientId) {
175
- return _storage.get(buildKey(RETURN_URL_KEY, clientId));
263
+ return _transient.get(buildKey(RETURN_URL_KEY, clientId));
176
264
  }
177
265
  function removeReturnUrl(clientId) {
178
- _storage.remove(buildKey(RETURN_URL_KEY, clientId));
266
+ _transient.remove(buildKey(RETURN_URL_KEY, clientId));
179
267
  }
180
268
  function clearAllSsoData(clientId) {
181
269
  if (clientId) {
182
270
  removeTokenData(clientId);
183
271
  removeOAuthState(clientId);
184
272
  removeReturnUrl(clientId);
273
+ removeLogoutState(clientId);
185
274
  removePkceVerifier(clientId);
275
+ removePkceVerifier(`${clientId}_popup_nonce`);
186
276
  return;
187
277
  }
188
278
  removeTokenData();
189
279
  removeOAuthState();
190
280
  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
- }
281
+ removeLogoutState();
282
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
283
+ const stores = [
284
+ typeof sessionStorage !== "undefined" ? sessionStorage : null,
285
+ typeof localStorage !== "undefined" ? localStorage : null
286
+ ];
287
+ for (const store of stores) {
288
+ if (!store) continue;
289
+ const keys = [];
290
+ for (let i = 0; i < store.length; i++) {
291
+ const key = store.key(i);
292
+ if (key?.startsWith(prefix)) keys.push(key);
293
+ }
294
+ for (const key of keys) {
295
+ _transient.remove(key.slice(STORAGE_PREFIX.length));
296
+ store.removeItem(key);
198
297
  }
199
298
  }
200
299
  }
201
300
  function clearVerifiersForClients(clientIds) {
202
301
  for (const clientId of clientIds) {
203
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
302
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
204
303
  }
205
304
  }
206
305
 
207
- // src/core/SsoClient.ts
306
+ // src/core/security.ts
307
+ function isTrustedReturnUrl(url, currentOrigin) {
308
+ if (!url) return false;
309
+ if (url.includes("\\")) return false;
310
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
311
+ try {
312
+ return new URL(url).origin === currentOrigin;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+ function timingSafeEqualString(a, b) {
318
+ const ba = new TextEncoder().encode(a);
319
+ const bb = new TextEncoder().encode(b);
320
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
321
+ const len = Math.max(ba.length, bb.length);
322
+ let diff = ba.length ^ bb.length;
323
+ for (let i = 0; i < len; i++) {
324
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
325
+ }
326
+ return diff === 0;
327
+ }
328
+
329
+ // src/core/id-token.ts
330
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
331
+ function getCrypto() {
332
+ return _crypto ?? null;
333
+ }
208
334
  function base64UrlDecode(input) {
209
335
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
210
336
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -239,14 +365,32 @@ function normalizeIssuer(url) {
239
365
  return url.replace(/\/+$/, "");
240
366
  }
241
367
  var cachedJwks = null;
368
+ var cachedDiscovery = null;
242
369
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
243
- async function fetchJwks(baseUrl) {
370
+ async function fetchDiscoveryDoc(baseUrl) {
371
+ const now = Date.now();
372
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
373
+ return cachedDiscovery.doc;
374
+ }
375
+ try {
376
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
377
+ const doc = res.ok ? await res.json() : null;
378
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
379
+ return doc;
380
+ } catch {
381
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
382
+ return null;
383
+ }
384
+ }
385
+ async function fetchJwks(baseUrl, options = {}) {
244
386
  const now = Date.now();
245
- if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
387
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
246
388
  return cachedJwks.jwks;
247
389
  }
390
+ const discovery = await fetchDiscoveryDoc(baseUrl);
391
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
248
392
  try {
249
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
393
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
250
394
  if (!res.ok) return null;
251
395
  const jwks = await res.json();
252
396
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -255,24 +399,13 @@ async function fetchJwks(baseUrl) {
255
399
  return null;
256
400
  }
257
401
  }
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) {
402
+ async function verifyRs256Signature(token, jwk) {
272
403
  try {
273
404
  const [headerB64, payloadB64, signature] = token.split(".");
274
405
  if (!signature || !jwk.n || !jwk.e) return false;
275
- const cryptoKey = await crypto.subtle.importKey(
406
+ const c = getCrypto();
407
+ if (!c) return false;
408
+ const cryptoKey = await c.subtle.importKey(
276
409
  "jwk",
277
410
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
278
411
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -281,7 +414,7 @@ async function verifyRs256(token, jwk) {
281
414
  );
282
415
  const signatureBytes = base64UrlDecode(signature);
283
416
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
284
- return await crypto.subtle.verify(
417
+ return await c.subtle.verify(
285
418
  "RSASSA-PKCS1-v1_5",
286
419
  cryptoKey,
287
420
  signatureBytes,
@@ -291,7 +424,20 @@ async function verifyRs256(token, jwk) {
291
424
  return false;
292
425
  }
293
426
  }
294
- async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
427
+ async function computeAtHash(accessToken) {
428
+ const c = getCrypto();
429
+ if (!c) return "";
430
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
431
+ const bytes = new Uint8Array(hash);
432
+ const half = bytes.slice(0, bytes.length / 2);
433
+ let binary = "";
434
+ for (const b of half) {
435
+ binary += String.fromCharCode(b);
436
+ }
437
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
438
+ }
439
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
440
+ const { rejectHs256WhenRs256Available = true } = options;
295
441
  const header = decodeJwtHeader(idToken);
296
442
  if (!header) {
297
443
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -300,54 +446,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
300
446
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
301
447
  throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
302
448
  }
449
+ const baseUrl = normalizeIssuer(expectedIssuer);
450
+ const discovery = await fetchDiscoveryDoc(baseUrl);
451
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
303
452
  if (alg === "RS256") {
304
- const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
453
+ const jwks = await fetchJwks(baseUrl);
305
454
  if (!jwks) {
306
455
  throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
307
456
  }
308
457
  const kid = typeof header.kid === "string" ? header.kid : void 0;
309
- const key = jwks.keys.find(
458
+ const matchCandidates = (set) => set.keys.filter(
310
459
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
311
460
  );
312
- if (!key) {
461
+ const verifyAny = async (keys) => {
462
+ for (const key of keys) {
463
+ if (await verifyRs256Signature(idToken, key)) return true;
464
+ }
465
+ return false;
466
+ };
467
+ let candidates = matchCandidates(jwks);
468
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
469
+ if (candidates.length === 0 || !validSig) {
470
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
471
+ if (freshJwks) {
472
+ candidates = matchCandidates(freshJwks);
473
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
474
+ }
475
+ }
476
+ if (candidates.length === 0) {
313
477
  throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
314
478
  }
315
- const validSig = await verifyRs256(idToken, key);
316
479
  if (!validSig) {
317
480
  throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
318
481
  }
319
482
  }
320
483
  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"
484
+ if (rejectHs256WhenRs256Available) {
485
+ const jwks = await fetchJwks(baseUrl);
486
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
487
+ if (hasRs256) {
488
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
489
+ }
490
+ }
491
+ throw new SsoError(
492
+ "id_token_hs256_unsupported",
493
+ "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
494
  );
324
495
  }
325
496
  const payload = decodeJwtPayload(idToken);
326
497
  if (!payload) {
327
498
  throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
328
499
  }
329
- const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
330
500
  const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
331
- if (tokenIssuer !== normalizedExpectedIssuer) {
501
+ if (tokenIssuer !== normalizedIssuer) {
332
502
  throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
333
503
  }
334
- if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
504
+ const aud = payload.aud;
505
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
506
+ if (!audList.includes(expectedClientId)) {
335
507
  throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
336
508
  }
337
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
509
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
510
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
511
+ }
512
+ if (typeof payload.exp !== "number") {
513
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
514
+ }
515
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
338
516
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
339
517
  }
518
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
519
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
520
+ }
340
521
  if (typeof payload.sub !== "string" || !payload.sub) {
341
522
  throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
342
523
  }
343
524
  if (typeof payload.at_hash === "string" && payload.at_hash) {
344
525
  const actual = await computeAtHash(accessToken);
345
- if (actual !== payload.at_hash) {
526
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
346
527
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
347
528
  }
348
529
  }
349
530
  return { sub: payload.sub };
350
531
  }
532
+
533
+ // src/core/SsoClient.ts
351
534
  var _SsoClient = class _SsoClient {
352
535
  constructor(config) {
353
536
  this._discovery = null;
@@ -416,6 +599,17 @@ var _SsoClient = class _SsoClient {
416
599
  if (d) return d.userinfo_endpoint;
417
600
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
418
601
  }
602
+ /**
603
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
604
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
605
+ */
606
+ _saveReturnUrlIfTrusted(returnUrl) {
607
+ if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
608
+ saveReturnUrl(returnUrl, this.config.clientId);
609
+ } else {
610
+ console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
611
+ }
612
+ }
419
613
  // ============================================
420
614
  // 公共 API
421
615
  // ============================================
@@ -425,7 +619,12 @@ var _SsoClient = class _SsoClient {
425
619
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
426
620
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
427
621
  *
428
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
622
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
623
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
624
+ *
625
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
626
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
627
+ * 同一次登录只使用其中一个入口。
429
628
  */
430
629
  async login(returnUrl) {
431
630
  const verifier = generateCodeVerifier();
@@ -434,7 +633,7 @@ var _SsoClient = class _SsoClient {
434
633
  savePkceVerifier(this.config.clientId, verifier);
435
634
  saveOAuthState(state, this.config.clientId);
436
635
  if (returnUrl) {
437
- saveReturnUrl(returnUrl);
636
+ this._saveReturnUrlIfTrusted(returnUrl);
438
637
  }
439
638
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
440
639
  const params = new URLSearchParams();
@@ -451,6 +650,10 @@ var _SsoClient = class _SsoClient {
451
650
  * 构建登录 URL(不跳转,返回 URL 字符串)
452
651
  *
453
652
  * 适用于需要手动处理跳转的场景。
653
+ *
654
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
655
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
656
+ * 同一次登录只使用其中一个入口。
454
657
  */
455
658
  async getLoginUrl(returnUrl) {
456
659
  const verifier = generateCodeVerifier();
@@ -458,7 +661,7 @@ var _SsoClient = class _SsoClient {
458
661
  const state = generateState();
459
662
  savePkceVerifier(this.config.clientId, verifier);
460
663
  saveOAuthState(state, this.config.clientId);
461
- if (returnUrl) saveReturnUrl(returnUrl);
664
+ if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
462
665
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
463
666
  const params = new URLSearchParams();
464
667
  params.set("response_type", "code");
@@ -470,11 +673,140 @@ var _SsoClient = class _SsoClient {
470
673
  params.set("code_challenge_method", "S256");
471
674
  return `${authorizeEndpoint}?${params.toString()}`;
472
675
  }
676
+ /**
677
+ * 弹窗模式 SSO 登录
678
+ *
679
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
680
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
681
+ *
682
+ * 流程:
683
+ * 1. window.open() 打开授权 URL 到弹窗
684
+ * 2. 用户在弹窗中完成登录
685
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
686
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
687
+ * 5. 弹窗自动关闭
688
+ *
689
+ * @param options - 弹窗配置
690
+ * @param options.returnUrl - 登录成功后的返回地址
691
+ * @param options.width - 弹窗宽度(默认 480)
692
+ * @param options.height - 弹窗高度(默认 640)
693
+ * @returns TokenData
694
+ *
695
+ * @example
696
+ * ```typescript
697
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
698
+ * try {
699
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
700
+ * console.log("登录成功", tokenData);
701
+ * } catch (err) {
702
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
703
+ * // 弹窗被拦截,回退到同页重定向
704
+ * await client.login("/dashboard");
705
+ * }
706
+ * }
707
+ * ```
708
+ */
709
+ async loginPopup(options = {}) {
710
+ const verifier = generateCodeVerifier();
711
+ const challenge = await generateCodeChallenge(verifier);
712
+ const state = generateState();
713
+ const popupNonce = generateState();
714
+ savePkceVerifier(this.config.clientId, verifier);
715
+ saveOAuthState(state, this.config.clientId);
716
+ savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
717
+ if (options.returnUrl) {
718
+ this._saveReturnUrlIfTrusted(options.returnUrl);
719
+ }
720
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
721
+ const params = new URLSearchParams();
722
+ params.set("response_type", "code");
723
+ params.set("client_id", this.config.clientId);
724
+ params.set("redirect_uri", this.config.redirectUri);
725
+ params.set("scope", this.config.scopes || "openid profile");
726
+ params.set("state", state);
727
+ params.set("code_challenge", challenge);
728
+ params.set("code_challenge_method", "S256");
729
+ params.set("popup_nonce", popupNonce);
730
+ const width = options.width || 480;
731
+ const height = options.height || 640;
732
+ const left = Math.max(0, (window.screen.width - width) / 2);
733
+ const top = Math.max(0, (window.screen.height - height) / 2);
734
+ const popupFeatures = [
735
+ `width=${width}`,
736
+ `height=${height}`,
737
+ `left=${Math.round(left)}`,
738
+ `top=${Math.round(top)}`,
739
+ "resizable=yes",
740
+ "scrollbars=yes",
741
+ "status=yes"
742
+ ].join(",");
743
+ const popup = window.open(
744
+ `${authorizeEndpoint}?${params.toString()}`,
745
+ "nihplod_sso_popup",
746
+ popupFeatures
747
+ );
748
+ if (!popup) {
749
+ removePkceVerifier(this.config.clientId);
750
+ removeOAuthState(this.config.clientId);
751
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
752
+ throw new SsoError(
753
+ "popup_blocked",
754
+ "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
755
+ );
756
+ }
757
+ const redirectUriOrigin = new URL(this.config.redirectUri).origin;
758
+ try {
759
+ popup.focus();
760
+ return await new Promise((resolve, reject) => {
761
+ let completed = false;
762
+ const handleMessage = (event) => {
763
+ if (completed) return;
764
+ if (event.origin !== redirectUriOrigin) return;
765
+ if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
766
+ if (!event.data.callbackUrl) return;
767
+ const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
768
+ if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
769
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
770
+ completed = true;
771
+ cleanup();
772
+ if (popup && !popup.closed) {
773
+ popup.close();
774
+ }
775
+ this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
776
+ };
777
+ const pollTimer = setInterval(() => {
778
+ if (popup.closed) {
779
+ if (!completed) {
780
+ completed = true;
781
+ cleanup();
782
+ reject(
783
+ new SsoError(
784
+ "popup_closed",
785
+ "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
786
+ )
787
+ );
788
+ }
789
+ }
790
+ }, 500);
791
+ const cleanup = () => {
792
+ clearInterval(pollTimer);
793
+ window.removeEventListener("message", handleMessage);
794
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
795
+ };
796
+ window.addEventListener("message", handleMessage);
797
+ });
798
+ } catch (err) {
799
+ removePkceVerifier(this.config.clientId);
800
+ removeOAuthState(this.config.clientId);
801
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
802
+ throw err;
803
+ }
804
+ }
473
805
  /**
474
806
  * 处理 OAuth 回调
475
807
  *
476
808
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
477
- * 成功后 token 自动保存到 sessionStorage。
809
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
478
810
  *
479
811
  * @param callbackUrl - 完整的回调 URL(window.location.href)
480
812
  * @returns TokenData 或 null
@@ -484,23 +816,27 @@ var _SsoClient = class _SsoClient {
484
816
  const params = url.searchParams;
485
817
  const error = params.get("error");
486
818
  if (error) {
819
+ removeOAuthState(this.config.clientId);
820
+ removePkceVerifier(this.config.clientId);
487
821
  const desc = params.get("error_description") || error;
488
- throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
822
+ throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
489
823
  }
490
824
  const code = params.get("code");
491
825
  const returnedState = params.get("state");
492
826
  if (!code) {
827
+ removeOAuthState(this.config.clientId);
828
+ removePkceVerifier(this.config.clientId);
493
829
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
494
830
  }
495
831
  const savedState = getOAuthState(this.config.clientId);
496
- if (!savedState || savedState !== returnedState) {
832
+ if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
497
833
  removeOAuthState(this.config.clientId);
834
+ removePkceVerifier(this.config.clientId);
498
835
  throw new SsoError(
499
836
  "state_mismatch",
500
837
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
501
838
  );
502
839
  }
503
- removeOAuthState(this.config.clientId);
504
840
  const verifier = getPkceVerifier(this.config.clientId);
505
841
  if (!verifier) {
506
842
  throw new SsoError(
@@ -508,7 +844,6 @@ var _SsoClient = class _SsoClient {
508
844
  "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
509
845
  );
510
846
  }
511
- removePkceVerifier(this.config.clientId);
512
847
  const tokenEndpoint = await this._getTokenEndpoint();
513
848
  const body = new URLSearchParams();
514
849
  body.set("grant_type", "authorization_code");
@@ -535,8 +870,9 @@ var _SsoClient = class _SsoClient {
535
870
  errData = await res.json();
536
871
  } catch {
537
872
  }
873
+ const serverError = errData.error || "";
538
874
  throw new SsoError(
539
- "token_request_failed",
875
+ mapOAuthErrorToSsoCode(serverError),
540
876
  errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
541
877
  );
542
878
  }
@@ -545,15 +881,17 @@ var _SsoClient = class _SsoClient {
545
881
  try {
546
882
  await validateIdToken(
547
883
  data.id_token,
884
+ data.access_token,
548
885
  this.config.ssoBaseUrl,
549
- this.config.clientId,
550
- data.access_token
886
+ this.config.clientId
551
887
  );
552
888
  } catch (err) {
553
889
  removeTokenData(this.config.clientId);
554
890
  throw err;
555
891
  }
556
892
  }
893
+ removeOAuthState(this.config.clientId);
894
+ removePkceVerifier(this.config.clientId);
557
895
  const now = Date.now();
558
896
  const tokenData = {
559
897
  access_token: data.access_token,
@@ -596,15 +934,24 @@ var _SsoClient = class _SsoClient {
596
934
  if (this.config.clientSecret) {
597
935
  body.set("client_secret", this.config.clientSecret);
598
936
  }
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);
937
+ let res = null;
938
+ let lastErr;
939
+ for (let attempt = 0; attempt < 2; attempt++) {
940
+ try {
941
+ res = await fetch(tokenEndpoint, {
942
+ method: "POST",
943
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
944
+ body: body.toString()
945
+ });
946
+ lastErr = null;
947
+ break;
948
+ } catch (err) {
949
+ lastErr = err;
950
+ if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
951
+ }
952
+ }
953
+ if (lastErr || !res) {
954
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
608
955
  }
609
956
  if (!res.ok) {
610
957
  let errData = {};
@@ -617,17 +964,31 @@ var _SsoClient = class _SsoClient {
617
964
  removeTokenData(this.config.clientId);
618
965
  }
619
966
  throw new SsoError(
620
- errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
967
+ mapOAuthErrorToSsoCode(errorCode, "refresh"),
621
968
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
622
969
  );
623
970
  }
624
971
  const data = await res.json();
972
+ if (data.id_token) {
973
+ try {
974
+ await validateIdToken(
975
+ data.id_token,
976
+ data.access_token,
977
+ this.config.ssoBaseUrl,
978
+ this.config.clientId
979
+ );
980
+ } catch (err) {
981
+ removeTokenData(this.config.clientId);
982
+ throw err;
983
+ }
984
+ }
625
985
  const now = Date.now();
626
986
  const tokenData = {
627
987
  access_token: data.access_token,
628
988
  token_type: data.token_type,
629
989
  expires_in: data.expires_in,
630
- refresh_token: data.refresh_token,
990
+ // RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
991
+ refresh_token: data.refresh_token || current.refresh_token,
631
992
  id_token: data.id_token,
632
993
  issued_at: now,
633
994
  expires_at: now + data.expires_in * 1e3
@@ -699,7 +1060,9 @@ var _SsoClient = class _SsoClient {
699
1060
  * 登出
700
1061
  *
701
1062
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
702
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
1063
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
1064
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
1065
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
703
1066
  */
704
1067
  async logout(redirectToSso = false) {
705
1068
  const tokenData = getTokenData(this.config.clientId);
@@ -728,23 +1091,50 @@ var _SsoClient = class _SsoClient {
728
1091
  }
729
1092
  if (redirectToSso) {
730
1093
  const discovery = await this._getDiscovery();
731
- const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
1094
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
732
1095
  const logoutUrl = new URL(endSessionEndpoint);
733
1096
  logoutUrl.searchParams.set("client_id", this.config.clientId);
734
- if (this.config.redirectUri) {
1097
+ const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1098
+ if (postLogoutUri) {
735
1099
  logoutUrl.searchParams.set(
736
1100
  "post_logout_redirect_uri",
737
- this.config.redirectUri
1101
+ postLogoutUri
738
1102
  );
739
1103
  }
740
1104
  if (idTokenHint) {
741
1105
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
742
1106
  }
743
1107
  const state = generateState();
1108
+ saveLogoutState(state, this.config.clientId);
744
1109
  logoutUrl.searchParams.set("state", state);
745
1110
  window.location.href = logoutUrl.toString();
746
1111
  }
747
1112
  }
1113
+ /**
1114
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
1115
+ *
1116
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
1117
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
1118
+ * 校验后清除已保存的 logout state(一次性)。
1119
+ *
1120
+ * @param url - 当前页面完整 URL(window.location.href)
1121
+ *
1122
+ * @example
1123
+ * ```typescript
1124
+ * if (sso.validateLogoutState(window.location.href)) {
1125
+ * // 来自 SSO 登出的可信回跳
1126
+ * }
1127
+ * ```
1128
+ */
1129
+ validateLogoutState(url) {
1130
+ const returnedState = new URL(url).searchParams.get("state");
1131
+ if (!returnedState) return false;
1132
+ const savedState = getLogoutState(this.config.clientId);
1133
+ if (!savedState) return false;
1134
+ if (!timingSafeEqualString(savedState, returnedState)) return false;
1135
+ removeLogoutState(this.config.clientId);
1136
+ return true;
1137
+ }
748
1138
  /**
749
1139
  * 获取 OIDC Discovery 文档
750
1140
  *
@@ -767,22 +1157,29 @@ var SsoClient = _SsoClient;
767
1157
  SsoError,
768
1158
  clearAllSsoData,
769
1159
  clearVerifiersForClients,
1160
+ createSecureStorage,
770
1161
  generateCodeChallenge,
771
1162
  generateCodeVerifier,
772
1163
  generateState,
1164
+ getLogoutState,
773
1165
  getOAuthState,
774
1166
  getPkceVerifier,
775
1167
  getReturnUrl,
776
1168
  getTokenData,
777
1169
  getTokenStorage,
1170
+ isTrustedReturnUrl,
1171
+ mapOAuthErrorToSsoCode,
1172
+ removeLogoutState,
778
1173
  removeOAuthState,
779
1174
  removePkceVerifier,
780
1175
  removeReturnUrl,
781
1176
  removeTokenData,
1177
+ saveLogoutState,
782
1178
  saveOAuthState,
783
1179
  savePkceVerifier,
784
1180
  saveReturnUrl,
785
1181
  saveTokenData,
786
- setTokenStorage
1182
+ setTokenStorage,
1183
+ timingSafeEqualString
787
1184
  });
788
1185
  //# sourceMappingURL=index.js.map