@nihplod/sso-sdk 1.0.1 → 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.mjs CHANGED
@@ -1,4 +1,24 @@
1
1
  // src/core/errors.ts
2
+ function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
3
+ switch (oauthError) {
4
+ case "invalid_grant":
5
+ return context === "refresh" ? "session_expired" : "authorization_code_expired";
6
+ case "invalid_client":
7
+ return "client_disabled";
8
+ case "access_denied":
9
+ return "user_denied_authorization";
10
+ case "server_error":
11
+ return "sso_server_error";
12
+ case "rate_limited":
13
+ return "rate_limited";
14
+ case "invalid_scope":
15
+ case "invalid_request":
16
+ case "unauthorized_client":
17
+ case "unsupported_grant_type":
18
+ default:
19
+ return "token_request_failed";
20
+ }
21
+ }
2
22
  var SsoError = class extends Error {
3
23
  constructor(code, description, cause) {
4
24
  super(`[SSO SDK] ${code}: ${description}`);
@@ -81,6 +101,57 @@ function createMemoryStorageAdapter() {
81
101
  };
82
102
  }
83
103
  var memoryStorageAdapter = createMemoryStorageAdapter();
104
+ var localStorageAdapter = {
105
+ get(key) {
106
+ if (typeof localStorage === "undefined") return null;
107
+ return localStorage.getItem(STORAGE_PREFIX + key);
108
+ },
109
+ set(key, value) {
110
+ if (typeof localStorage === "undefined") return;
111
+ try {
112
+ localStorage.setItem(STORAGE_PREFIX + key, value);
113
+ } catch (err) {
114
+ console.warn(
115
+ `[SSO SDK] localStorage \u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.name : String(err)}\uFF09\uFF0C\u6570\u636E\u672A\u6301\u4E45\u5316`
116
+ );
117
+ }
118
+ },
119
+ remove(key) {
120
+ if (typeof localStorage === "undefined") return;
121
+ localStorage.removeItem(STORAGE_PREFIX + key);
122
+ }
123
+ };
124
+ function createSessionStorageAdapter() {
125
+ const fallback = /* @__PURE__ */ new Map();
126
+ return {
127
+ get(key) {
128
+ if (typeof sessionStorage !== "undefined") {
129
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
130
+ }
131
+ return fallback.get(key) ?? null;
132
+ },
133
+ set(key, value) {
134
+ if (typeof sessionStorage === "undefined") {
135
+ fallback.set(key, value);
136
+ return;
137
+ }
138
+ try {
139
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
140
+ } catch {
141
+ fallback.set(key, value);
142
+ }
143
+ },
144
+ remove(key) {
145
+ fallback.delete(key);
146
+ if (typeof sessionStorage === "undefined") return;
147
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
148
+ }
149
+ };
150
+ }
151
+ var _transient = createSessionStorageAdapter();
152
+ function createSecureStorage(options = {}) {
153
+ return options.persist ? localStorageAdapter : memoryStorageAdapter;
154
+ }
84
155
  var _storage = memoryStorageAdapter;
85
156
  function setTokenStorage(storage) {
86
157
  _storage = storage;
@@ -104,31 +175,31 @@ function removeTokenData(clientId) {
104
175
  _storage.remove(buildKey(TOKEN_KEY, clientId));
105
176
  }
106
177
  function savePkceVerifier(clientId, verifier) {
107
- _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
178
+ _transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
108
179
  }
109
180
  function getPkceVerifier(clientId) {
110
- return _storage.get(VERIFIER_KEY_PREFIX + clientId);
181
+ return _transient.get(VERIFIER_KEY_PREFIX + clientId);
111
182
  }
112
183
  function removePkceVerifier(clientId) {
113
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
184
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
114
185
  }
115
186
  function saveOAuthState(state, clientId) {
116
- _storage.set(buildKey(STATE_KEY, clientId), state);
187
+ _transient.set(buildKey(STATE_KEY, clientId), state);
117
188
  }
118
189
  function getOAuthState(clientId) {
119
- return _storage.get(buildKey(STATE_KEY, clientId));
190
+ return _transient.get(buildKey(STATE_KEY, clientId));
120
191
  }
121
192
  function removeOAuthState(clientId) {
122
- _storage.remove(buildKey(STATE_KEY, clientId));
193
+ _transient.remove(buildKey(STATE_KEY, clientId));
123
194
  }
124
195
  function saveReturnUrl(url, clientId) {
125
- _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
196
+ _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
126
197
  }
127
198
  function getReturnUrl(clientId) {
128
- return _storage.get(buildKey(RETURN_URL_KEY, clientId));
199
+ return _transient.get(buildKey(RETURN_URL_KEY, clientId));
129
200
  }
130
201
  function removeReturnUrl(clientId) {
131
- _storage.remove(buildKey(RETURN_URL_KEY, clientId));
202
+ _transient.remove(buildKey(RETURN_URL_KEY, clientId));
132
203
  }
133
204
  function clearAllSsoData(clientId) {
134
205
  if (clientId) {
@@ -136,28 +207,63 @@ function clearAllSsoData(clientId) {
136
207
  removeOAuthState(clientId);
137
208
  removeReturnUrl(clientId);
138
209
  removePkceVerifier(clientId);
210
+ removePkceVerifier(`${clientId}_popup_nonce`);
139
211
  return;
140
212
  }
141
213
  removeTokenData();
142
214
  removeOAuthState();
143
215
  removeReturnUrl();
144
- if (typeof localStorage !== "undefined") {
145
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
146
- for (let i = 0; i < localStorage.length; i++) {
147
- const key = localStorage.key(i);
148
- if (key?.startsWith(prefix)) {
149
- _storage.remove(key.slice(STORAGE_PREFIX.length));
150
- }
216
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
217
+ const stores = [
218
+ typeof sessionStorage !== "undefined" ? sessionStorage : null,
219
+ typeof localStorage !== "undefined" ? localStorage : null
220
+ ];
221
+ for (const store of stores) {
222
+ if (!store) continue;
223
+ const keys = [];
224
+ for (let i = 0; i < store.length; i++) {
225
+ const key = store.key(i);
226
+ if (key?.startsWith(prefix)) keys.push(key);
227
+ }
228
+ for (const key of keys) {
229
+ _transient.remove(key.slice(STORAGE_PREFIX.length));
230
+ store.removeItem(key);
151
231
  }
152
232
  }
153
233
  }
154
234
  function clearVerifiersForClients(clientIds) {
155
235
  for (const clientId of clientIds) {
156
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
236
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
157
237
  }
158
238
  }
159
239
 
160
- // src/core/SsoClient.ts
240
+ // src/core/security.ts
241
+ function isTrustedReturnUrl(url, currentOrigin) {
242
+ if (!url) return false;
243
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
244
+ try {
245
+ return new URL(url).origin === currentOrigin;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+ function timingSafeEqualString(a, b) {
251
+ const ba = new TextEncoder().encode(a);
252
+ const bb = new TextEncoder().encode(b);
253
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
254
+ const len = Math.max(ba.length, bb.length);
255
+ let diff = ba.length ^ bb.length;
256
+ for (let i = 0; i < len; i++) {
257
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
258
+ }
259
+ return diff === 0;
260
+ }
261
+
262
+ // src/core/id-token.ts
263
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
264
+ function getCrypto() {
265
+ return _crypto ?? null;
266
+ }
161
267
  function base64UrlDecode(input) {
162
268
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
163
269
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -192,14 +298,32 @@ function normalizeIssuer(url) {
192
298
  return url.replace(/\/+$/, "");
193
299
  }
194
300
  var cachedJwks = null;
301
+ var cachedDiscovery = null;
195
302
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
196
- async function fetchJwks(baseUrl) {
303
+ async function fetchDiscoveryDoc(baseUrl) {
197
304
  const now = Date.now();
198
- if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
305
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
306
+ return cachedDiscovery.doc;
307
+ }
308
+ try {
309
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
310
+ const doc = res.ok ? await res.json() : null;
311
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
312
+ return doc;
313
+ } catch {
314
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
315
+ return null;
316
+ }
317
+ }
318
+ async function fetchJwks(baseUrl, options = {}) {
319
+ const now = Date.now();
320
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
199
321
  return cachedJwks.jwks;
200
322
  }
323
+ const discovery = await fetchDiscoveryDoc(baseUrl);
324
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
201
325
  try {
202
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
326
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
203
327
  if (!res.ok) return null;
204
328
  const jwks = await res.json();
205
329
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -208,24 +332,13 @@ async function fetchJwks(baseUrl) {
208
332
  return null;
209
333
  }
210
334
  }
211
- async function computeAtHash(accessToken) {
212
- const hash = await crypto.subtle.digest(
213
- "SHA-256",
214
- new TextEncoder().encode(accessToken)
215
- );
216
- const bytes = new Uint8Array(hash);
217
- const half = bytes.slice(0, bytes.length / 2);
218
- let binary = "";
219
- for (const b of half) {
220
- binary += String.fromCharCode(b);
221
- }
222
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
223
- }
224
- async function verifyRs256(token, jwk) {
335
+ async function verifyRs256Signature(token, jwk) {
225
336
  try {
226
337
  const [headerB64, payloadB64, signature] = token.split(".");
227
338
  if (!signature || !jwk.n || !jwk.e) return false;
228
- const cryptoKey = await crypto.subtle.importKey(
339
+ const c = getCrypto();
340
+ if (!c) return false;
341
+ const cryptoKey = await c.subtle.importKey(
229
342
  "jwk",
230
343
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
231
344
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -234,7 +347,7 @@ async function verifyRs256(token, jwk) {
234
347
  );
235
348
  const signatureBytes = base64UrlDecode(signature);
236
349
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
237
- return await crypto.subtle.verify(
350
+ return await c.subtle.verify(
238
351
  "RSASSA-PKCS1-v1_5",
239
352
  cryptoKey,
240
353
  signatureBytes,
@@ -244,7 +357,20 @@ async function verifyRs256(token, jwk) {
244
357
  return false;
245
358
  }
246
359
  }
247
- async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
360
+ async function computeAtHash(accessToken) {
361
+ const c = getCrypto();
362
+ if (!c) return "";
363
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
364
+ const bytes = new Uint8Array(hash);
365
+ const half = bytes.slice(0, bytes.length / 2);
366
+ let binary = "";
367
+ for (const b of half) {
368
+ binary += String.fromCharCode(b);
369
+ }
370
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
371
+ }
372
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
373
+ const { rejectHs256WhenRs256Available = true } = options;
248
374
  const header = decodeJwtHeader(idToken);
249
375
  if (!header) {
250
376
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -253,54 +379,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
253
379
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
254
380
  throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
255
381
  }
382
+ const baseUrl = normalizeIssuer(expectedIssuer);
383
+ const discovery = await fetchDiscoveryDoc(baseUrl);
384
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
256
385
  if (alg === "RS256") {
257
- const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
386
+ const jwks = await fetchJwks(baseUrl);
258
387
  if (!jwks) {
259
388
  throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
260
389
  }
261
390
  const kid = typeof header.kid === "string" ? header.kid : void 0;
262
- const key = jwks.keys.find(
391
+ const matchCandidates = (set) => set.keys.filter(
263
392
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
264
393
  );
265
- if (!key) {
394
+ const verifyAny = async (keys) => {
395
+ for (const key of keys) {
396
+ if (await verifyRs256Signature(idToken, key)) return true;
397
+ }
398
+ return false;
399
+ };
400
+ let candidates = matchCandidates(jwks);
401
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
402
+ if (candidates.length === 0 || !validSig) {
403
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
404
+ if (freshJwks) {
405
+ candidates = matchCandidates(freshJwks);
406
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
407
+ }
408
+ }
409
+ if (candidates.length === 0) {
266
410
  throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
267
411
  }
268
- const validSig = await verifyRs256(idToken, key);
269
412
  if (!validSig) {
270
413
  throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
271
414
  }
272
415
  }
273
416
  if (alg === "HS256") {
274
- console.warn(
275
- "[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"
417
+ if (rejectHs256WhenRs256Available) {
418
+ const jwks = await fetchJwks(baseUrl);
419
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
420
+ if (hasRs256) {
421
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
422
+ }
423
+ }
424
+ throw new SsoError(
425
+ "id_token_hs256_unsupported",
426
+ "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"
276
427
  );
277
428
  }
278
429
  const payload = decodeJwtPayload(idToken);
279
430
  if (!payload) {
280
431
  throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
281
432
  }
282
- const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
283
433
  const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
284
- if (tokenIssuer !== normalizedExpectedIssuer) {
434
+ if (tokenIssuer !== normalizedIssuer) {
285
435
  throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
286
436
  }
287
- if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
437
+ const aud = payload.aud;
438
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
439
+ if (!audList.includes(expectedClientId)) {
288
440
  throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
289
441
  }
290
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
442
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
443
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
444
+ }
445
+ if (typeof payload.exp !== "number") {
446
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
447
+ }
448
+ if (Date.now() >= payload.exp * 1e3) {
291
449
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
292
450
  }
451
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
452
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
453
+ }
293
454
  if (typeof payload.sub !== "string" || !payload.sub) {
294
455
  throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
295
456
  }
296
457
  if (typeof payload.at_hash === "string" && payload.at_hash) {
297
458
  const actual = await computeAtHash(accessToken);
298
- if (actual !== payload.at_hash) {
459
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
299
460
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
300
461
  }
301
462
  }
302
463
  return { sub: payload.sub };
303
464
  }
465
+
466
+ // src/core/SsoClient.ts
304
467
  var _SsoClient = class _SsoClient {
305
468
  constructor(config) {
306
469
  this._discovery = null;
@@ -369,6 +532,17 @@ var _SsoClient = class _SsoClient {
369
532
  if (d) return d.userinfo_endpoint;
370
533
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
371
534
  }
535
+ /**
536
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
537
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
538
+ */
539
+ _saveReturnUrlIfTrusted(returnUrl) {
540
+ if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
541
+ saveReturnUrl(returnUrl);
542
+ } else {
543
+ console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
544
+ }
545
+ }
372
546
  // ============================================
373
547
  // 公共 API
374
548
  // ============================================
@@ -378,7 +552,8 @@ var _SsoClient = class _SsoClient {
378
552
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
379
553
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
380
554
  *
381
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
555
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
556
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
382
557
  */
383
558
  async login(returnUrl) {
384
559
  const verifier = generateCodeVerifier();
@@ -387,7 +562,7 @@ var _SsoClient = class _SsoClient {
387
562
  savePkceVerifier(this.config.clientId, verifier);
388
563
  saveOAuthState(state, this.config.clientId);
389
564
  if (returnUrl) {
390
- saveReturnUrl(returnUrl);
565
+ this._saveReturnUrlIfTrusted(returnUrl);
391
566
  }
392
567
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
393
568
  const params = new URLSearchParams();
@@ -411,7 +586,7 @@ var _SsoClient = class _SsoClient {
411
586
  const state = generateState();
412
587
  savePkceVerifier(this.config.clientId, verifier);
413
588
  saveOAuthState(state, this.config.clientId);
414
- if (returnUrl) saveReturnUrl(returnUrl);
589
+ if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
415
590
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
416
591
  const params = new URLSearchParams();
417
592
  params.set("response_type", "code");
@@ -423,11 +598,140 @@ var _SsoClient = class _SsoClient {
423
598
  params.set("code_challenge_method", "S256");
424
599
  return `${authorizeEndpoint}?${params.toString()}`;
425
600
  }
601
+ /**
602
+ * 弹窗模式 SSO 登录
603
+ *
604
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
605
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
606
+ *
607
+ * 流程:
608
+ * 1. window.open() 打开授权 URL 到弹窗
609
+ * 2. 用户在弹窗中完成登录
610
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
611
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
612
+ * 5. 弹窗自动关闭
613
+ *
614
+ * @param options - 弹窗配置
615
+ * @param options.returnUrl - 登录成功后的返回地址
616
+ * @param options.width - 弹窗宽度(默认 480)
617
+ * @param options.height - 弹窗高度(默认 640)
618
+ * @returns TokenData
619
+ *
620
+ * @example
621
+ * ```typescript
622
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
623
+ * try {
624
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
625
+ * console.log("登录成功", tokenData);
626
+ * } catch (err) {
627
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
628
+ * // 弹窗被拦截,回退到同页重定向
629
+ * await client.login("/dashboard");
630
+ * }
631
+ * }
632
+ * ```
633
+ */
634
+ async loginPopup(options = {}) {
635
+ const verifier = generateCodeVerifier();
636
+ const challenge = await generateCodeChallenge(verifier);
637
+ const state = generateState();
638
+ const popupNonce = generateState();
639
+ savePkceVerifier(this.config.clientId, verifier);
640
+ saveOAuthState(state, this.config.clientId);
641
+ savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
642
+ if (options.returnUrl) {
643
+ this._saveReturnUrlIfTrusted(options.returnUrl);
644
+ }
645
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
646
+ const params = new URLSearchParams();
647
+ params.set("response_type", "code");
648
+ params.set("client_id", this.config.clientId);
649
+ params.set("redirect_uri", this.config.redirectUri);
650
+ params.set("scope", this.config.scopes || "openid profile");
651
+ params.set("state", state);
652
+ params.set("code_challenge", challenge);
653
+ params.set("code_challenge_method", "S256");
654
+ params.set("popup_nonce", popupNonce);
655
+ const width = options.width || 480;
656
+ const height = options.height || 640;
657
+ const left = Math.max(0, (window.screen.width - width) / 2);
658
+ const top = Math.max(0, (window.screen.height - height) / 2);
659
+ const popupFeatures = [
660
+ `width=${width}`,
661
+ `height=${height}`,
662
+ `left=${Math.round(left)}`,
663
+ `top=${Math.round(top)}`,
664
+ "resizable=yes",
665
+ "scrollbars=yes",
666
+ "status=yes"
667
+ ].join(",");
668
+ const popup = window.open(
669
+ `${authorizeEndpoint}?${params.toString()}`,
670
+ "nihplod_sso_popup",
671
+ popupFeatures
672
+ );
673
+ if (!popup) {
674
+ removePkceVerifier(this.config.clientId);
675
+ removeOAuthState(this.config.clientId);
676
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
677
+ throw new SsoError(
678
+ "popup_blocked",
679
+ "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
680
+ );
681
+ }
682
+ const redirectUriOrigin = new URL(this.config.redirectUri).origin;
683
+ try {
684
+ popup.focus();
685
+ return await new Promise((resolve, reject) => {
686
+ let completed = false;
687
+ const handleMessage = (event) => {
688
+ if (completed) return;
689
+ if (event.origin !== redirectUriOrigin) return;
690
+ if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
691
+ if (!event.data.callbackUrl) return;
692
+ const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
693
+ if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
694
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
695
+ completed = true;
696
+ cleanup();
697
+ if (popup && !popup.closed) {
698
+ popup.close();
699
+ }
700
+ this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
701
+ };
702
+ const pollTimer = setInterval(() => {
703
+ if (popup.closed) {
704
+ if (!completed) {
705
+ completed = true;
706
+ cleanup();
707
+ reject(
708
+ new SsoError(
709
+ "popup_closed",
710
+ "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
711
+ )
712
+ );
713
+ }
714
+ }
715
+ }, 500);
716
+ const cleanup = () => {
717
+ clearInterval(pollTimer);
718
+ window.removeEventListener("message", handleMessage);
719
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
720
+ };
721
+ window.addEventListener("message", handleMessage);
722
+ });
723
+ } catch (err) {
724
+ removePkceVerifier(this.config.clientId);
725
+ removeOAuthState(this.config.clientId);
726
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
727
+ throw err;
728
+ }
729
+ }
426
730
  /**
427
731
  * 处理 OAuth 回调
428
732
  *
429
733
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
430
- * 成功后 token 自动保存到 sessionStorage。
734
+ * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
431
735
  *
432
736
  * @param callbackUrl - 完整的回调 URL(window.location.href)
433
737
  * @returns TokenData 或 null
@@ -446,14 +750,14 @@ var _SsoClient = class _SsoClient {
446
750
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
447
751
  }
448
752
  const savedState = getOAuthState(this.config.clientId);
449
- if (!savedState || savedState !== returnedState) {
753
+ if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
450
754
  removeOAuthState(this.config.clientId);
755
+ removePkceVerifier(this.config.clientId);
451
756
  throw new SsoError(
452
757
  "state_mismatch",
453
758
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
454
759
  );
455
760
  }
456
- removeOAuthState(this.config.clientId);
457
761
  const verifier = getPkceVerifier(this.config.clientId);
458
762
  if (!verifier) {
459
763
  throw new SsoError(
@@ -461,7 +765,6 @@ var _SsoClient = class _SsoClient {
461
765
  "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
462
766
  );
463
767
  }
464
- removePkceVerifier(this.config.clientId);
465
768
  const tokenEndpoint = await this._getTokenEndpoint();
466
769
  const body = new URLSearchParams();
467
770
  body.set("grant_type", "authorization_code");
@@ -488,19 +791,22 @@ var _SsoClient = class _SsoClient {
488
791
  errData = await res.json();
489
792
  } catch {
490
793
  }
794
+ const serverError = errData.error || "";
491
795
  throw new SsoError(
492
- "token_request_failed",
796
+ mapOAuthErrorToSsoCode(serverError),
493
797
  errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
494
798
  );
495
799
  }
496
800
  const data = await res.json();
801
+ removeOAuthState(this.config.clientId);
802
+ removePkceVerifier(this.config.clientId);
497
803
  if (data.id_token) {
498
804
  try {
499
805
  await validateIdToken(
500
806
  data.id_token,
807
+ data.access_token,
501
808
  this.config.ssoBaseUrl,
502
- this.config.clientId,
503
- data.access_token
809
+ this.config.clientId
504
810
  );
505
811
  } catch (err) {
506
812
  removeTokenData(this.config.clientId);
@@ -549,15 +855,24 @@ var _SsoClient = class _SsoClient {
549
855
  if (this.config.clientSecret) {
550
856
  body.set("client_secret", this.config.clientSecret);
551
857
  }
552
- let res;
553
- try {
554
- res = await fetch(tokenEndpoint, {
555
- method: "POST",
556
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
557
- body: body.toString()
558
- });
559
- } catch (err) {
560
- throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
858
+ let res = null;
859
+ let lastErr;
860
+ for (let attempt = 0; attempt < 2; attempt++) {
861
+ try {
862
+ res = await fetch(tokenEndpoint, {
863
+ method: "POST",
864
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
865
+ body: body.toString()
866
+ });
867
+ lastErr = null;
868
+ break;
869
+ } catch (err) {
870
+ lastErr = err;
871
+ if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
872
+ }
873
+ }
874
+ if (lastErr || !res) {
875
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
561
876
  }
562
877
  if (!res.ok) {
563
878
  let errData = {};
@@ -570,17 +885,31 @@ var _SsoClient = class _SsoClient {
570
885
  removeTokenData(this.config.clientId);
571
886
  }
572
887
  throw new SsoError(
573
- errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
888
+ mapOAuthErrorToSsoCode(errorCode, "refresh"),
574
889
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
575
890
  );
576
891
  }
577
892
  const data = await res.json();
893
+ if (data.id_token) {
894
+ try {
895
+ await validateIdToken(
896
+ data.id_token,
897
+ data.access_token,
898
+ this.config.ssoBaseUrl,
899
+ this.config.clientId
900
+ );
901
+ } catch (err) {
902
+ removeTokenData(this.config.clientId);
903
+ throw err;
904
+ }
905
+ }
578
906
  const now = Date.now();
579
907
  const tokenData = {
580
908
  access_token: data.access_token,
581
909
  token_type: data.token_type,
582
910
  expires_in: data.expires_in,
583
- refresh_token: data.refresh_token,
911
+ // RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
912
+ refresh_token: data.refresh_token || current.refresh_token,
584
913
  id_token: data.id_token,
585
914
  issued_at: now,
586
915
  expires_at: now + data.expires_in * 1e3
@@ -681,13 +1010,14 @@ var _SsoClient = class _SsoClient {
681
1010
  }
682
1011
  if (redirectToSso) {
683
1012
  const discovery = await this._getDiscovery();
684
- const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
1013
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
685
1014
  const logoutUrl = new URL(endSessionEndpoint);
686
1015
  logoutUrl.searchParams.set("client_id", this.config.clientId);
687
- if (this.config.redirectUri) {
1016
+ const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1017
+ if (postLogoutUri) {
688
1018
  logoutUrl.searchParams.set(
689
1019
  "post_logout_redirect_uri",
690
- this.config.redirectUri
1020
+ postLogoutUri
691
1021
  );
692
1022
  }
693
1023
  if (idTokenHint) {
@@ -719,6 +1049,7 @@ export {
719
1049
  SsoError,
720
1050
  clearAllSsoData,
721
1051
  clearVerifiersForClients,
1052
+ createSecureStorage,
722
1053
  generateCodeChallenge,
723
1054
  generateCodeVerifier,
724
1055
  generateState,
@@ -727,6 +1058,8 @@ export {
727
1058
  getReturnUrl,
728
1059
  getTokenData,
729
1060
  getTokenStorage,
1061
+ isTrustedReturnUrl,
1062
+ mapOAuthErrorToSsoCode,
730
1063
  removeOAuthState,
731
1064
  removePkceVerifier,
732
1065
  removeReturnUrl,
@@ -735,6 +1068,7 @@ export {
735
1068
  savePkceVerifier,
736
1069
  saveReturnUrl,
737
1070
  saveTokenData,
738
- setTokenStorage
1071
+ setTokenStorage,
1072
+ timingSafeEqualString
739
1073
  };
740
1074
  //# sourceMappingURL=index.mjs.map