@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.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}`);
@@ -63,6 +83,7 @@ var TOKEN_KEY = "token";
63
83
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
64
84
  var STATE_KEY = "oauth_state";
65
85
  var RETURN_URL_KEY = "return_url";
86
+ var LOGOUT_STATE_KEY = "logout_state";
66
87
  function buildKey(base, clientId) {
67
88
  return clientId ? `${base}:${clientId}` : base;
68
89
  }
@@ -81,7 +102,58 @@ function createMemoryStorageAdapter() {
81
102
  };
82
103
  }
83
104
  var memoryStorageAdapter = createMemoryStorageAdapter();
84
- var _storage = memoryStorageAdapter;
105
+ var localStorageAdapter = {
106
+ get(key) {
107
+ if (typeof localStorage === "undefined") return null;
108
+ return localStorage.getItem(STORAGE_PREFIX + key);
109
+ },
110
+ set(key, value) {
111
+ if (typeof localStorage === "undefined") return;
112
+ try {
113
+ localStorage.setItem(STORAGE_PREFIX + key, value);
114
+ } catch (err) {
115
+ console.warn(
116
+ `[SSO SDK] localStorage \u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.name : String(err)}\uFF09\uFF0C\u6570\u636E\u672A\u6301\u4E45\u5316`
117
+ );
118
+ }
119
+ },
120
+ remove(key) {
121
+ if (typeof localStorage === "undefined") return;
122
+ localStorage.removeItem(STORAGE_PREFIX + key);
123
+ }
124
+ };
125
+ function createSessionStorageAdapter() {
126
+ const fallback = /* @__PURE__ */ new Map();
127
+ return {
128
+ get(key) {
129
+ if (typeof sessionStorage !== "undefined") {
130
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
131
+ }
132
+ return fallback.get(key) ?? null;
133
+ },
134
+ set(key, value) {
135
+ if (typeof sessionStorage === "undefined") {
136
+ fallback.set(key, value);
137
+ return;
138
+ }
139
+ try {
140
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
141
+ } catch {
142
+ fallback.set(key, value);
143
+ }
144
+ },
145
+ remove(key) {
146
+ fallback.delete(key);
147
+ if (typeof sessionStorage === "undefined") return;
148
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
149
+ }
150
+ };
151
+ }
152
+ var _transient = createSessionStorageAdapter();
153
+ function createSecureStorage(options = {}) {
154
+ return options.persist ? localStorageAdapter : createSessionStorageAdapter();
155
+ }
156
+ var _storage = createSessionStorageAdapter();
85
157
  function setTokenStorage(storage) {
86
158
  _storage = storage;
87
159
  }
@@ -104,60 +176,107 @@ function removeTokenData(clientId) {
104
176
  _storage.remove(buildKey(TOKEN_KEY, clientId));
105
177
  }
106
178
  function savePkceVerifier(clientId, verifier) {
107
- _storage.set(VERIFIER_KEY_PREFIX + clientId, verifier);
179
+ _transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
108
180
  }
109
181
  function getPkceVerifier(clientId) {
110
- return _storage.get(VERIFIER_KEY_PREFIX + clientId);
182
+ return _transient.get(VERIFIER_KEY_PREFIX + clientId);
111
183
  }
112
184
  function removePkceVerifier(clientId) {
113
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
185
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
114
186
  }
115
187
  function saveOAuthState(state, clientId) {
116
- _storage.set(buildKey(STATE_KEY, clientId), state);
188
+ _transient.set(buildKey(STATE_KEY, clientId), state);
117
189
  }
118
190
  function getOAuthState(clientId) {
119
- return _storage.get(buildKey(STATE_KEY, clientId));
191
+ return _transient.get(buildKey(STATE_KEY, clientId));
120
192
  }
121
193
  function removeOAuthState(clientId) {
122
- _storage.remove(buildKey(STATE_KEY, clientId));
194
+ _transient.remove(buildKey(STATE_KEY, clientId));
195
+ }
196
+ function saveLogoutState(state, clientId) {
197
+ _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
198
+ }
199
+ function getLogoutState(clientId) {
200
+ return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
201
+ }
202
+ function removeLogoutState(clientId) {
203
+ _transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
123
204
  }
124
205
  function saveReturnUrl(url, clientId) {
125
- _storage.set(buildKey(RETURN_URL_KEY, clientId), url);
206
+ _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
126
207
  }
127
208
  function getReturnUrl(clientId) {
128
- return _storage.get(buildKey(RETURN_URL_KEY, clientId));
209
+ return _transient.get(buildKey(RETURN_URL_KEY, clientId));
129
210
  }
130
211
  function removeReturnUrl(clientId) {
131
- _storage.remove(buildKey(RETURN_URL_KEY, clientId));
212
+ _transient.remove(buildKey(RETURN_URL_KEY, clientId));
132
213
  }
133
214
  function clearAllSsoData(clientId) {
134
215
  if (clientId) {
135
216
  removeTokenData(clientId);
136
217
  removeOAuthState(clientId);
137
218
  removeReturnUrl(clientId);
219
+ removeLogoutState(clientId);
138
220
  removePkceVerifier(clientId);
221
+ removePkceVerifier(`${clientId}_popup_nonce`);
139
222
  return;
140
223
  }
141
224
  removeTokenData();
142
225
  removeOAuthState();
143
226
  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
- }
227
+ removeLogoutState();
228
+ const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
229
+ const stores = [
230
+ typeof sessionStorage !== "undefined" ? sessionStorage : null,
231
+ typeof localStorage !== "undefined" ? localStorage : null
232
+ ];
233
+ for (const store of stores) {
234
+ if (!store) continue;
235
+ const keys = [];
236
+ for (let i = 0; i < store.length; i++) {
237
+ const key = store.key(i);
238
+ if (key?.startsWith(prefix)) keys.push(key);
239
+ }
240
+ for (const key of keys) {
241
+ _transient.remove(key.slice(STORAGE_PREFIX.length));
242
+ store.removeItem(key);
151
243
  }
152
244
  }
153
245
  }
154
246
  function clearVerifiersForClients(clientIds) {
155
247
  for (const clientId of clientIds) {
156
- _storage.remove(VERIFIER_KEY_PREFIX + clientId);
248
+ _transient.remove(VERIFIER_KEY_PREFIX + clientId);
157
249
  }
158
250
  }
159
251
 
160
- // src/core/SsoClient.ts
252
+ // src/core/security.ts
253
+ function isTrustedReturnUrl(url, currentOrigin) {
254
+ if (!url) return false;
255
+ if (url.includes("\\")) return false;
256
+ if (url.startsWith("/") && !url.startsWith("//")) return true;
257
+ try {
258
+ return new URL(url).origin === currentOrigin;
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+ function timingSafeEqualString(a, b) {
264
+ const ba = new TextEncoder().encode(a);
265
+ const bb = new TextEncoder().encode(b);
266
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
267
+ const len = Math.max(ba.length, bb.length);
268
+ let diff = ba.length ^ bb.length;
269
+ for (let i = 0; i < len; i++) {
270
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
271
+ }
272
+ return diff === 0;
273
+ }
274
+
275
+ // src/core/id-token.ts
276
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
277
+ function getCrypto() {
278
+ return _crypto ?? null;
279
+ }
161
280
  function base64UrlDecode(input) {
162
281
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
163
282
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -192,14 +311,32 @@ function normalizeIssuer(url) {
192
311
  return url.replace(/\/+$/, "");
193
312
  }
194
313
  var cachedJwks = null;
314
+ var cachedDiscovery = null;
195
315
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
196
- async function fetchJwks(baseUrl) {
316
+ async function fetchDiscoveryDoc(baseUrl) {
317
+ const now = Date.now();
318
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
319
+ return cachedDiscovery.doc;
320
+ }
321
+ try {
322
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
323
+ const doc = res.ok ? await res.json() : null;
324
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
325
+ return doc;
326
+ } catch {
327
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
328
+ return null;
329
+ }
330
+ }
331
+ async function fetchJwks(baseUrl, options = {}) {
197
332
  const now = Date.now();
198
- if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
333
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
199
334
  return cachedJwks.jwks;
200
335
  }
336
+ const discovery = await fetchDiscoveryDoc(baseUrl);
337
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
201
338
  try {
202
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
339
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
203
340
  if (!res.ok) return null;
204
341
  const jwks = await res.json();
205
342
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -208,24 +345,13 @@ async function fetchJwks(baseUrl) {
208
345
  return null;
209
346
  }
210
347
  }
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) {
348
+ async function verifyRs256Signature(token, jwk) {
225
349
  try {
226
350
  const [headerB64, payloadB64, signature] = token.split(".");
227
351
  if (!signature || !jwk.n || !jwk.e) return false;
228
- const cryptoKey = await crypto.subtle.importKey(
352
+ const c = getCrypto();
353
+ if (!c) return false;
354
+ const cryptoKey = await c.subtle.importKey(
229
355
  "jwk",
230
356
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
231
357
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -234,7 +360,7 @@ async function verifyRs256(token, jwk) {
234
360
  );
235
361
  const signatureBytes = base64UrlDecode(signature);
236
362
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
237
- return await crypto.subtle.verify(
363
+ return await c.subtle.verify(
238
364
  "RSASSA-PKCS1-v1_5",
239
365
  cryptoKey,
240
366
  signatureBytes,
@@ -244,7 +370,20 @@ async function verifyRs256(token, jwk) {
244
370
  return false;
245
371
  }
246
372
  }
247
- async function validateIdToken(idToken, expectedIssuer, expectedClientId, accessToken) {
373
+ async function computeAtHash(accessToken) {
374
+ const c = getCrypto();
375
+ if (!c) return "";
376
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
377
+ const bytes = new Uint8Array(hash);
378
+ const half = bytes.slice(0, bytes.length / 2);
379
+ let binary = "";
380
+ for (const b of half) {
381
+ binary += String.fromCharCode(b);
382
+ }
383
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
384
+ }
385
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
386
+ const { rejectHs256WhenRs256Available = true } = options;
248
387
  const header = decodeJwtHeader(idToken);
249
388
  if (!header) {
250
389
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -253,54 +392,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
253
392
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
254
393
  throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
255
394
  }
395
+ const baseUrl = normalizeIssuer(expectedIssuer);
396
+ const discovery = await fetchDiscoveryDoc(baseUrl);
397
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
256
398
  if (alg === "RS256") {
257
- const jwks = await fetchJwks(normalizeIssuer(expectedIssuer));
399
+ const jwks = await fetchJwks(baseUrl);
258
400
  if (!jwks) {
259
401
  throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
260
402
  }
261
403
  const kid = typeof header.kid === "string" ? header.kid : void 0;
262
- const key = jwks.keys.find(
404
+ const matchCandidates = (set) => set.keys.filter(
263
405
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
264
406
  );
265
- if (!key) {
407
+ const verifyAny = async (keys) => {
408
+ for (const key of keys) {
409
+ if (await verifyRs256Signature(idToken, key)) return true;
410
+ }
411
+ return false;
412
+ };
413
+ let candidates = matchCandidates(jwks);
414
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
415
+ if (candidates.length === 0 || !validSig) {
416
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
417
+ if (freshJwks) {
418
+ candidates = matchCandidates(freshJwks);
419
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
420
+ }
421
+ }
422
+ if (candidates.length === 0) {
266
423
  throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
267
424
  }
268
- const validSig = await verifyRs256(idToken, key);
269
425
  if (!validSig) {
270
426
  throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
271
427
  }
272
428
  }
273
429
  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"
430
+ if (rejectHs256WhenRs256Available) {
431
+ const jwks = await fetchJwks(baseUrl);
432
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
433
+ if (hasRs256) {
434
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
435
+ }
436
+ }
437
+ throw new SsoError(
438
+ "id_token_hs256_unsupported",
439
+ "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
440
  );
277
441
  }
278
442
  const payload = decodeJwtPayload(idToken);
279
443
  if (!payload) {
280
444
  throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
281
445
  }
282
- const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
283
446
  const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
284
- if (tokenIssuer !== normalizedExpectedIssuer) {
447
+ if (tokenIssuer !== normalizedIssuer) {
285
448
  throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
286
449
  }
287
- if (payload.aud !== expectedClientId && !payload.aud?.includes(expectedClientId)) {
450
+ const aud = payload.aud;
451
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
452
+ if (!audList.includes(expectedClientId)) {
288
453
  throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
289
454
  }
290
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
455
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
456
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
457
+ }
458
+ if (typeof payload.exp !== "number") {
459
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
460
+ }
461
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
291
462
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
292
463
  }
464
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
465
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
466
+ }
293
467
  if (typeof payload.sub !== "string" || !payload.sub) {
294
468
  throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
295
469
  }
296
470
  if (typeof payload.at_hash === "string" && payload.at_hash) {
297
471
  const actual = await computeAtHash(accessToken);
298
- if (actual !== payload.at_hash) {
472
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
299
473
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
300
474
  }
301
475
  }
302
476
  return { sub: payload.sub };
303
477
  }
478
+
479
+ // src/core/SsoClient.ts
304
480
  var _SsoClient = class _SsoClient {
305
481
  constructor(config) {
306
482
  this._discovery = null;
@@ -369,6 +545,17 @@ var _SsoClient = class _SsoClient {
369
545
  if (d) return d.userinfo_endpoint;
370
546
  return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
371
547
  }
548
+ /**
549
+ * 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
550
+ * 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
551
+ */
552
+ _saveReturnUrlIfTrusted(returnUrl) {
553
+ if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
554
+ saveReturnUrl(returnUrl, this.config.clientId);
555
+ } else {
556
+ console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
557
+ }
558
+ }
372
559
  // ============================================
373
560
  // 公共 API
374
561
  // ============================================
@@ -378,7 +565,12 @@ var _SsoClient = class _SsoClient {
378
565
  * 生成 PKCE code_verifier/code_challenge 和 state 参数,
379
566
  * 构建 authorize URL,通过 302 跳转到 SSO 登录页。
380
567
  *
381
- * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
568
+ * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
569
+ * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
570
+ *
571
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
572
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
573
+ * 同一次登录只使用其中一个入口。
382
574
  */
383
575
  async login(returnUrl) {
384
576
  const verifier = generateCodeVerifier();
@@ -387,7 +579,7 @@ var _SsoClient = class _SsoClient {
387
579
  savePkceVerifier(this.config.clientId, verifier);
388
580
  saveOAuthState(state, this.config.clientId);
389
581
  if (returnUrl) {
390
- saveReturnUrl(returnUrl);
582
+ this._saveReturnUrlIfTrusted(returnUrl);
391
583
  }
392
584
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
393
585
  const params = new URLSearchParams();
@@ -404,6 +596,10 @@ var _SsoClient = class _SsoClient {
404
596
  * 构建登录 URL(不跳转,返回 URL 字符串)
405
597
  *
406
598
  * 适用于需要手动处理跳转的场景。
599
+ *
600
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
601
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
602
+ * 同一次登录只使用其中一个入口。
407
603
  */
408
604
  async getLoginUrl(returnUrl) {
409
605
  const verifier = generateCodeVerifier();
@@ -411,7 +607,7 @@ var _SsoClient = class _SsoClient {
411
607
  const state = generateState();
412
608
  savePkceVerifier(this.config.clientId, verifier);
413
609
  saveOAuthState(state, this.config.clientId);
414
- if (returnUrl) saveReturnUrl(returnUrl);
610
+ if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
415
611
  const authorizeEndpoint = await this._getAuthorizeEndpoint();
416
612
  const params = new URLSearchParams();
417
613
  params.set("response_type", "code");
@@ -423,11 +619,140 @@ var _SsoClient = class _SsoClient {
423
619
  params.set("code_challenge_method", "S256");
424
620
  return `${authorizeEndpoint}?${params.toString()}`;
425
621
  }
622
+ /**
623
+ * 弹窗模式 SSO 登录
624
+ *
625
+ * 打开一个小窗口进行登录,认证完成后窗口自动关闭,
626
+ * 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
627
+ *
628
+ * 流程:
629
+ * 1. window.open() 打开授权 URL 到弹窗
630
+ * 2. 用户在弹窗中完成登录
631
+ * 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
632
+ * 4. 主页面收到消息后调用 handleCallback() 交换 token
633
+ * 5. 弹窗自动关闭
634
+ *
635
+ * @param options - 弹窗配置
636
+ * @param options.returnUrl - 登录成功后的返回地址
637
+ * @param options.width - 弹窗宽度(默认 480)
638
+ * @param options.height - 弹窗高度(默认 640)
639
+ * @returns TokenData
640
+ *
641
+ * @example
642
+ * ```typescript
643
+ * const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
644
+ * try {
645
+ * const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
646
+ * console.log("登录成功", tokenData);
647
+ * } catch (err) {
648
+ * if (err instanceof SsoError && err.code === "popup_blocked") {
649
+ * // 弹窗被拦截,回退到同页重定向
650
+ * await client.login("/dashboard");
651
+ * }
652
+ * }
653
+ * ```
654
+ */
655
+ async loginPopup(options = {}) {
656
+ const verifier = generateCodeVerifier();
657
+ const challenge = await generateCodeChallenge(verifier);
658
+ const state = generateState();
659
+ const popupNonce = generateState();
660
+ savePkceVerifier(this.config.clientId, verifier);
661
+ saveOAuthState(state, this.config.clientId);
662
+ savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
663
+ if (options.returnUrl) {
664
+ this._saveReturnUrlIfTrusted(options.returnUrl);
665
+ }
666
+ const authorizeEndpoint = await this._getAuthorizeEndpoint();
667
+ const params = new URLSearchParams();
668
+ params.set("response_type", "code");
669
+ params.set("client_id", this.config.clientId);
670
+ params.set("redirect_uri", this.config.redirectUri);
671
+ params.set("scope", this.config.scopes || "openid profile");
672
+ params.set("state", state);
673
+ params.set("code_challenge", challenge);
674
+ params.set("code_challenge_method", "S256");
675
+ params.set("popup_nonce", popupNonce);
676
+ const width = options.width || 480;
677
+ const height = options.height || 640;
678
+ const left = Math.max(0, (window.screen.width - width) / 2);
679
+ const top = Math.max(0, (window.screen.height - height) / 2);
680
+ const popupFeatures = [
681
+ `width=${width}`,
682
+ `height=${height}`,
683
+ `left=${Math.round(left)}`,
684
+ `top=${Math.round(top)}`,
685
+ "resizable=yes",
686
+ "scrollbars=yes",
687
+ "status=yes"
688
+ ].join(",");
689
+ const popup = window.open(
690
+ `${authorizeEndpoint}?${params.toString()}`,
691
+ "nihplod_sso_popup",
692
+ popupFeatures
693
+ );
694
+ if (!popup) {
695
+ removePkceVerifier(this.config.clientId);
696
+ removeOAuthState(this.config.clientId);
697
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
698
+ throw new SsoError(
699
+ "popup_blocked",
700
+ "\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
701
+ );
702
+ }
703
+ const redirectUriOrigin = new URL(this.config.redirectUri).origin;
704
+ try {
705
+ popup.focus();
706
+ return await new Promise((resolve, reject) => {
707
+ let completed = false;
708
+ const handleMessage = (event) => {
709
+ if (completed) return;
710
+ if (event.origin !== redirectUriOrigin) return;
711
+ if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
712
+ if (!event.data.callbackUrl) return;
713
+ const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
714
+ if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
715
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
716
+ completed = true;
717
+ cleanup();
718
+ if (popup && !popup.closed) {
719
+ popup.close();
720
+ }
721
+ this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
722
+ };
723
+ const pollTimer = setInterval(() => {
724
+ if (popup.closed) {
725
+ if (!completed) {
726
+ completed = true;
727
+ cleanup();
728
+ reject(
729
+ new SsoError(
730
+ "popup_closed",
731
+ "\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
732
+ )
733
+ );
734
+ }
735
+ }
736
+ }, 500);
737
+ const cleanup = () => {
738
+ clearInterval(pollTimer);
739
+ window.removeEventListener("message", handleMessage);
740
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
741
+ };
742
+ window.addEventListener("message", handleMessage);
743
+ });
744
+ } catch (err) {
745
+ removePkceVerifier(this.config.clientId);
746
+ removeOAuthState(this.config.clientId);
747
+ removePkceVerifier(`${this.config.clientId}_popup_nonce`);
748
+ throw err;
749
+ }
750
+ }
426
751
  /**
427
752
  * 处理 OAuth 回调
428
753
  *
429
754
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
430
- * 成功后 token 自动保存到 sessionStorage。
755
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
431
756
  *
432
757
  * @param callbackUrl - 完整的回调 URL(window.location.href)
433
758
  * @returns TokenData 或 null
@@ -437,23 +762,27 @@ var _SsoClient = class _SsoClient {
437
762
  const params = url.searchParams;
438
763
  const error = params.get("error");
439
764
  if (error) {
765
+ removeOAuthState(this.config.clientId);
766
+ removePkceVerifier(this.config.clientId);
440
767
  const desc = params.get("error_description") || error;
441
- throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
768
+ throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
442
769
  }
443
770
  const code = params.get("code");
444
771
  const returnedState = params.get("state");
445
772
  if (!code) {
773
+ removeOAuthState(this.config.clientId);
774
+ removePkceVerifier(this.config.clientId);
446
775
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
447
776
  }
448
777
  const savedState = getOAuthState(this.config.clientId);
449
- if (!savedState || savedState !== returnedState) {
778
+ if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
450
779
  removeOAuthState(this.config.clientId);
780
+ removePkceVerifier(this.config.clientId);
451
781
  throw new SsoError(
452
782
  "state_mismatch",
453
783
  "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
454
784
  );
455
785
  }
456
- removeOAuthState(this.config.clientId);
457
786
  const verifier = getPkceVerifier(this.config.clientId);
458
787
  if (!verifier) {
459
788
  throw new SsoError(
@@ -461,7 +790,6 @@ var _SsoClient = class _SsoClient {
461
790
  "code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
462
791
  );
463
792
  }
464
- removePkceVerifier(this.config.clientId);
465
793
  const tokenEndpoint = await this._getTokenEndpoint();
466
794
  const body = new URLSearchParams();
467
795
  body.set("grant_type", "authorization_code");
@@ -488,8 +816,9 @@ var _SsoClient = class _SsoClient {
488
816
  errData = await res.json();
489
817
  } catch {
490
818
  }
819
+ const serverError = errData.error || "";
491
820
  throw new SsoError(
492
- "token_request_failed",
821
+ mapOAuthErrorToSsoCode(serverError),
493
822
  errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
494
823
  );
495
824
  }
@@ -498,15 +827,17 @@ var _SsoClient = class _SsoClient {
498
827
  try {
499
828
  await validateIdToken(
500
829
  data.id_token,
830
+ data.access_token,
501
831
  this.config.ssoBaseUrl,
502
- this.config.clientId,
503
- data.access_token
832
+ this.config.clientId
504
833
  );
505
834
  } catch (err) {
506
835
  removeTokenData(this.config.clientId);
507
836
  throw err;
508
837
  }
509
838
  }
839
+ removeOAuthState(this.config.clientId);
840
+ removePkceVerifier(this.config.clientId);
510
841
  const now = Date.now();
511
842
  const tokenData = {
512
843
  access_token: data.access_token,
@@ -549,15 +880,24 @@ var _SsoClient = class _SsoClient {
549
880
  if (this.config.clientSecret) {
550
881
  body.set("client_secret", this.config.clientSecret);
551
882
  }
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);
883
+ let res = null;
884
+ let lastErr;
885
+ for (let attempt = 0; attempt < 2; attempt++) {
886
+ try {
887
+ res = await fetch(tokenEndpoint, {
888
+ method: "POST",
889
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
890
+ body: body.toString()
891
+ });
892
+ lastErr = null;
893
+ break;
894
+ } catch (err) {
895
+ lastErr = err;
896
+ if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
897
+ }
898
+ }
899
+ if (lastErr || !res) {
900
+ throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
561
901
  }
562
902
  if (!res.ok) {
563
903
  let errData = {};
@@ -570,17 +910,31 @@ var _SsoClient = class _SsoClient {
570
910
  removeTokenData(this.config.clientId);
571
911
  }
572
912
  throw new SsoError(
573
- errorCode === "invalid_grant" ? "session_expired" : "token_request_failed",
913
+ mapOAuthErrorToSsoCode(errorCode, "refresh"),
574
914
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
575
915
  );
576
916
  }
577
917
  const data = await res.json();
918
+ if (data.id_token) {
919
+ try {
920
+ await validateIdToken(
921
+ data.id_token,
922
+ data.access_token,
923
+ this.config.ssoBaseUrl,
924
+ this.config.clientId
925
+ );
926
+ } catch (err) {
927
+ removeTokenData(this.config.clientId);
928
+ throw err;
929
+ }
930
+ }
578
931
  const now = Date.now();
579
932
  const tokenData = {
580
933
  access_token: data.access_token,
581
934
  token_type: data.token_type,
582
935
  expires_in: data.expires_in,
583
- refresh_token: data.refresh_token,
936
+ // RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
937
+ refresh_token: data.refresh_token || current.refresh_token,
584
938
  id_token: data.id_token,
585
939
  issued_at: now,
586
940
  expires_at: now + data.expires_in * 1e3
@@ -652,7 +1006,9 @@ var _SsoClient = class _SsoClient {
652
1006
  * 登出
653
1007
  *
654
1008
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
655
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
1009
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
1010
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
1011
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
656
1012
  */
657
1013
  async logout(redirectToSso = false) {
658
1014
  const tokenData = getTokenData(this.config.clientId);
@@ -681,23 +1037,50 @@ var _SsoClient = class _SsoClient {
681
1037
  }
682
1038
  if (redirectToSso) {
683
1039
  const discovery = await this._getDiscovery();
684
- const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/logout`;
1040
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
685
1041
  const logoutUrl = new URL(endSessionEndpoint);
686
1042
  logoutUrl.searchParams.set("client_id", this.config.clientId);
687
- if (this.config.redirectUri) {
1043
+ const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
1044
+ if (postLogoutUri) {
688
1045
  logoutUrl.searchParams.set(
689
1046
  "post_logout_redirect_uri",
690
- this.config.redirectUri
1047
+ postLogoutUri
691
1048
  );
692
1049
  }
693
1050
  if (idTokenHint) {
694
1051
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
695
1052
  }
696
1053
  const state = generateState();
1054
+ saveLogoutState(state, this.config.clientId);
697
1055
  logoutUrl.searchParams.set("state", state);
698
1056
  window.location.href = logoutUrl.toString();
699
1057
  }
700
1058
  }
1059
+ /**
1060
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
1061
+ *
1062
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
1063
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
1064
+ * 校验后清除已保存的 logout state(一次性)。
1065
+ *
1066
+ * @param url - 当前页面完整 URL(window.location.href)
1067
+ *
1068
+ * @example
1069
+ * ```typescript
1070
+ * if (sso.validateLogoutState(window.location.href)) {
1071
+ * // 来自 SSO 登出的可信回跳
1072
+ * }
1073
+ * ```
1074
+ */
1075
+ validateLogoutState(url) {
1076
+ const returnedState = new URL(url).searchParams.get("state");
1077
+ if (!returnedState) return false;
1078
+ const savedState = getLogoutState(this.config.clientId);
1079
+ if (!savedState) return false;
1080
+ if (!timingSafeEqualString(savedState, returnedState)) return false;
1081
+ removeLogoutState(this.config.clientId);
1082
+ return true;
1083
+ }
701
1084
  /**
702
1085
  * 获取 OIDC Discovery 文档
703
1086
  *
@@ -719,22 +1102,29 @@ export {
719
1102
  SsoError,
720
1103
  clearAllSsoData,
721
1104
  clearVerifiersForClients,
1105
+ createSecureStorage,
722
1106
  generateCodeChallenge,
723
1107
  generateCodeVerifier,
724
1108
  generateState,
1109
+ getLogoutState,
725
1110
  getOAuthState,
726
1111
  getPkceVerifier,
727
1112
  getReturnUrl,
728
1113
  getTokenData,
729
1114
  getTokenStorage,
1115
+ isTrustedReturnUrl,
1116
+ mapOAuthErrorToSsoCode,
1117
+ removeLogoutState,
730
1118
  removeOAuthState,
731
1119
  removePkceVerifier,
732
1120
  removeReturnUrl,
733
1121
  removeTokenData,
1122
+ saveLogoutState,
734
1123
  saveOAuthState,
735
1124
  savePkceVerifier,
736
1125
  saveReturnUrl,
737
1126
  saveTokenData,
738
- setTokenStorage
1127
+ setTokenStorage,
1128
+ timingSafeEqualString
739
1129
  };
740
1130
  //# sourceMappingURL=index.mjs.map