@nihplod/sso-sdk 1.4.1 → 1.6.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
@@ -154,18 +154,25 @@ function createMemoryStorageAdapter() {
154
154
  },
155
155
  remove(key) {
156
156
  store.delete(key);
157
+ },
158
+ keys() {
159
+ return [...store.keys()];
157
160
  }
158
161
  };
159
162
  }
160
163
  var memoryStorageAdapter = createMemoryStorageAdapter();
161
164
  var localStorageAdapter = {
162
165
  get(key) {
163
- if (typeof localStorage === "undefined") return null;
164
- return localStorage.getItem(STORAGE_PREFIX + key);
166
+ try {
167
+ if (typeof localStorage === "undefined") return null;
168
+ return localStorage.getItem(STORAGE_PREFIX + key);
169
+ } catch {
170
+ return null;
171
+ }
165
172
  },
166
173
  set(key, value) {
167
- if (typeof localStorage === "undefined") return;
168
174
  try {
175
+ if (typeof localStorage === "undefined") return;
169
176
  localStorage.setItem(STORAGE_PREFIX + key, value);
170
177
  } catch (err) {
171
178
  console.warn(
@@ -174,34 +181,68 @@ var localStorageAdapter = {
174
181
  }
175
182
  },
176
183
  remove(key) {
177
- if (typeof localStorage === "undefined") return;
178
- localStorage.removeItem(STORAGE_PREFIX + key);
184
+ try {
185
+ if (typeof localStorage === "undefined") return;
186
+ localStorage.removeItem(STORAGE_PREFIX + key);
187
+ } catch {
188
+ }
189
+ },
190
+ keys() {
191
+ try {
192
+ if (typeof localStorage === "undefined") return [];
193
+ const result = [];
194
+ for (let i = 0; i < localStorage.length; i++) {
195
+ const key = localStorage.key(i);
196
+ if (key?.startsWith(STORAGE_PREFIX)) result.push(key.slice(STORAGE_PREFIX.length));
197
+ }
198
+ return result;
199
+ } catch {
200
+ return [];
201
+ }
179
202
  }
180
203
  };
181
204
  function createSessionStorageAdapter() {
182
205
  const fallback = /* @__PURE__ */ new Map();
183
206
  return {
184
207
  get(key) {
185
- if (typeof sessionStorage !== "undefined") {
186
- return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
208
+ try {
209
+ if (typeof sessionStorage !== "undefined") {
210
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
211
+ }
212
+ } catch {
187
213
  }
188
214
  return fallback.get(key) ?? null;
189
215
  },
190
216
  set(key, value) {
191
- if (typeof sessionStorage === "undefined") {
192
- fallback.set(key, value);
193
- return;
194
- }
195
217
  try {
196
- sessionStorage.setItem(STORAGE_PREFIX + key, value);
218
+ if (typeof sessionStorage !== "undefined") {
219
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
220
+ return;
221
+ }
197
222
  } catch {
198
- fallback.set(key, value);
199
223
  }
224
+ fallback.set(key, value);
200
225
  },
201
226
  remove(key) {
202
227
  fallback.delete(key);
203
- if (typeof sessionStorage === "undefined") return;
204
- sessionStorage.removeItem(STORAGE_PREFIX + key);
228
+ try {
229
+ if (typeof sessionStorage === "undefined") return;
230
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
231
+ } catch {
232
+ }
233
+ },
234
+ keys() {
235
+ const result = new Set(fallback.keys());
236
+ try {
237
+ if (typeof sessionStorage !== "undefined") {
238
+ for (let i = 0; i < sessionStorage.length; i++) {
239
+ const key = sessionStorage.key(i);
240
+ if (key?.startsWith(STORAGE_PREFIX)) result.add(key.slice(STORAGE_PREFIX.length));
241
+ }
242
+ }
243
+ } catch {
244
+ }
245
+ return [...result];
205
246
  }
206
247
  };
207
248
  }
@@ -303,23 +344,44 @@ function clearAllSsoData(clientId) {
303
344
  removeReturnUrl();
304
345
  removeLogoutState();
305
346
  removeSilentProbe();
306
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
307
- const stores = [
308
- typeof sessionStorage !== "undefined" ? sessionStorage : null,
309
- typeof localStorage !== "undefined" ? localStorage : null
310
- ];
347
+ const stores = [];
348
+ try {
349
+ if (typeof sessionStorage !== "undefined") stores.push(sessionStorage);
350
+ } catch {
351
+ }
352
+ try {
353
+ if (typeof localStorage !== "undefined") stores.push(localStorage);
354
+ } catch {
355
+ }
356
+ const abstractKeys = /* @__PURE__ */ new Set();
311
357
  for (const store of stores) {
312
- if (!store) continue;
313
358
  const keys = [];
314
- for (let i = 0; i < store.length; i++) {
315
- const key = store.key(i);
316
- if (key?.startsWith(prefix)) keys.push(key);
359
+ try {
360
+ for (let i = 0; i < store.length; i++) {
361
+ const key = store.key(i);
362
+ if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
363
+ }
364
+ } catch {
365
+ continue;
317
366
  }
318
367
  for (const key of keys) {
319
- _transient.remove(key.slice(STORAGE_PREFIX.length));
320
- store.removeItem(key);
368
+ abstractKeys.add(key.slice(STORAGE_PREFIX.length));
369
+ try {
370
+ store.removeItem(key);
371
+ } catch {
372
+ }
321
373
  }
322
374
  }
375
+ for (const store of [_transient, _storage]) {
376
+ try {
377
+ for (const key of store.keys?.() ?? []) abstractKeys.add(key);
378
+ } catch {
379
+ }
380
+ }
381
+ for (const key of abstractKeys) {
382
+ _transient.remove(key);
383
+ _storage.remove(key);
384
+ }
323
385
  }
324
386
  function clearVerifiersForClients(clientIds) {
325
387
  for (const clientId of clientIds) {
@@ -328,12 +390,17 @@ function clearVerifiersForClients(clientIds) {
328
390
  }
329
391
 
330
392
  // src/core/security.ts
393
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
331
394
  function isTrustedReturnUrl(url, currentOrigin) {
332
395
  if (!url) return false;
333
- if (url.includes("\\")) return false;
334
- if (url.startsWith("/") && !url.startsWith("//")) return true;
396
+ if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
397
+ if (url.startsWith("//")) return false;
398
+ if (url.startsWith("/")) return true;
335
399
  try {
336
- return new URL(url).origin === currentOrigin;
400
+ const parsed = new URL(url);
401
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
402
+ if (parsed.username || parsed.password) return false;
403
+ return parsed.origin === currentOrigin;
337
404
  } catch {
338
405
  return false;
339
406
  }
@@ -545,7 +612,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
545
612
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
546
613
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
547
614
  }
548
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
615
+ if (typeof payload.iat !== "number") {
616
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
617
+ }
618
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
549
619
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
550
620
  }
551
621
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -562,11 +632,47 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
562
632
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
563
633
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
564
634
  }
635
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
636
+ throw new SsoError(
637
+ "id_token_nonce_mismatch",
638
+ "ID Token \u643A\u5E26 nonce \u4F46\u672C\u5730\u65E0\u671F\u671B\u503C\uFF08\u53EF\u80FD\u975E\u672C\u6B21\u767B\u5F55\u4F1A\u8BDD\u7B7E\u53D1\uFF09"
639
+ );
565
640
  }
566
641
  return { sub: payload.sub };
567
642
  }
568
643
 
644
+ // src/core/http.ts
645
+ var REQUEST_TIMEOUT_MS = 1e4;
646
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
647
+ const controller = new AbortController();
648
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
649
+ try {
650
+ return await fetch(input, { ...init, signal: controller.signal });
651
+ } finally {
652
+ clearTimeout(timer);
653
+ }
654
+ }
655
+
569
656
  // src/core/SsoClient.ts
657
+ var REVOKE_TIMEOUT_MS = 3e3;
658
+ var EXPIRY_SKEW_MS = 1e4;
659
+ function assertValidTokenResponse(data, requireRefreshToken) {
660
+ if (typeof data.access_token !== "string" || !data.access_token) {
661
+ throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
662
+ }
663
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
664
+ throw new SsoError(
665
+ "token_request_failed",
666
+ `Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
667
+ );
668
+ }
669
+ if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
670
+ throw new SsoError(
671
+ "token_request_failed",
672
+ "Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
673
+ );
674
+ }
675
+ }
570
676
  var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
571
677
  "login_required",
572
678
  "consent_required",
@@ -577,6 +683,8 @@ var _SsoClient = class _SsoClient {
577
683
  this._discovery = null;
578
684
  this._discoveryFetchedAt = 0;
579
685
  this._refreshLock = null;
686
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
687
+ this._discoveryOriginWarned = false;
580
688
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
581
689
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
582
690
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -585,8 +693,67 @@ var _SsoClient = class _SsoClient {
585
693
  this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
586
694
  }
587
695
  // ============================================
696
+ // 调试日志
697
+ // ============================================
698
+ /**
699
+ * 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
700
+ * 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
701
+ * 不会输出 token/授权码等敏感值。
702
+ */
703
+ debugLog(...args) {
704
+ if (this.config.debug) {
705
+ console.warn("[SSO SDK]", ...args);
706
+ }
707
+ }
708
+ // ============================================
588
709
  // 内部方法
589
710
  // ============================================
711
+ /**
712
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
713
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
714
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
715
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
716
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
717
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
718
+ */
719
+ _sanitizeDiscovery(d) {
720
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
721
+ const allowedOrigins = /* @__PURE__ */ new Set();
722
+ try {
723
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
724
+ allowedOrigins.add(new URL(this._serverBase).origin);
725
+ } catch {
726
+ return null;
727
+ }
728
+ const sameOrigin = (endpoint) => {
729
+ try {
730
+ return allowedOrigins.has(new URL(endpoint).origin);
731
+ } catch {
732
+ return false;
733
+ }
734
+ };
735
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
736
+ let authorizationOk = false;
737
+ try {
738
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
739
+ } catch {
740
+ authorizationOk = false;
741
+ }
742
+ const bad = [];
743
+ if (!authorizationOk) bad.push("authorization_endpoint");
744
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
745
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
746
+ if (bad.length > 0) {
747
+ if (!this._discoveryOriginWarned) {
748
+ this._discoveryOriginWarned = true;
749
+ console.warn(
750
+ `[SSO SDK] OIDC Discovery \u7AEF\u70B9 origin \u4E0E ssoBaseUrl/serverBaseUrl \u4E0D\u5339\u914D\uFF08${bad.join(", ")}\uFF09\uFF0C\u5DF2\u56DE\u9000\u5230\u9ED8\u8BA4\u7AEF\u70B9\u3002\u82E5 SSO \u4E2D\u5FC3\u786E\u5B9E\u6258\u7BA1\u8DE8\u6E90\u7AEF\u70B9\uFF0C\u8BF7\u8BBE\u7F6E allowCrossOriginDiscoveryEndpoints: true`
751
+ );
752
+ }
753
+ return null;
754
+ }
755
+ return d;
756
+ }
590
757
  /**
591
758
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
592
759
  *
@@ -613,7 +780,10 @@ var _SsoClient = class _SsoClient {
613
780
  );
614
781
  return null;
615
782
  }
616
- this._discovery = await res.json();
783
+ const doc = await res.json();
784
+ const sanitized = this._sanitizeDiscovery(doc);
785
+ if (!sanitized) return null;
786
+ this._discovery = sanitized;
617
787
  this._discoveryFetchedAt = now;
618
788
  return this._discovery;
619
789
  } catch (err) {
@@ -964,7 +1134,7 @@ var _SsoClient = class _SsoClient {
964
1134
  }
965
1135
  let res;
966
1136
  try {
967
- res = await fetch(tokenEndpoint, {
1137
+ res = await fetchWithTimeout(tokenEndpoint, {
968
1138
  method: "POST",
969
1139
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
970
1140
  body: body.toString()
@@ -973,6 +1143,10 @@ var _SsoClient = class _SsoClient {
973
1143
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
974
1144
  }
975
1145
  if (!res.ok) {
1146
+ removeOAuthState(this.config.clientId);
1147
+ removeOAuthNonce(this.config.clientId);
1148
+ removePkceVerifier(this.config.clientId);
1149
+ removeSilentProbe(this.config.clientId);
976
1150
  let errData = {};
977
1151
  try {
978
1152
  errData = await res.json();
@@ -985,6 +1159,19 @@ var _SsoClient = class _SsoClient {
985
1159
  );
986
1160
  }
987
1161
  const data = await res.json();
1162
+ assertValidTokenResponse(data, true);
1163
+ const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1164
+ if (requestedScopes.includes("openid") && !data.id_token) {
1165
+ removeOAuthState(this.config.clientId);
1166
+ removeOAuthNonce(this.config.clientId);
1167
+ removePkceVerifier(this.config.clientId);
1168
+ removeSilentProbe(this.config.clientId);
1169
+ removeTokenData(this.config.clientId);
1170
+ throw new SsoError(
1171
+ "id_token_invalid",
1172
+ "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
1173
+ );
1174
+ }
988
1175
  if (data.id_token) {
989
1176
  try {
990
1177
  await validateIdToken(
@@ -1014,6 +1201,7 @@ var _SsoClient = class _SsoClient {
1014
1201
  expires_at: now + data.expires_in * 1e3
1015
1202
  };
1016
1203
  saveTokenData(tokenData, this.config.clientId);
1204
+ this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
1017
1205
  return tokenData;
1018
1206
  }
1019
1207
  /**
@@ -1049,7 +1237,7 @@ var _SsoClient = class _SsoClient {
1049
1237
  let lastErr;
1050
1238
  for (let attempt = 0; attempt < 2; attempt++) {
1051
1239
  try {
1052
- res = await fetch(tokenEndpoint, {
1240
+ res = await fetchWithTimeout(tokenEndpoint, {
1053
1241
  method: "POST",
1054
1242
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1055
1243
  body: body.toString()
@@ -1071,15 +1259,20 @@ var _SsoClient = class _SsoClient {
1071
1259
  } catch {
1072
1260
  }
1073
1261
  const errorCode = errData.error || "";
1074
- if (errorCode === "invalid_grant" || res.status === 401) {
1262
+ if (errorCode === "invalid_grant") {
1075
1263
  removeTokenData(this.config.clientId);
1076
1264
  }
1265
+ this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
1266
+ if (!errorCode && res.status === 401) {
1267
+ throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
1268
+ }
1077
1269
  throw new SsoError(
1078
1270
  mapOAuthErrorToSsoCode(errorCode, "refresh"),
1079
1271
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
1080
1272
  );
1081
1273
  }
1082
1274
  const data = await res.json();
1275
+ assertValidTokenResponse(data, false);
1083
1276
  if (data.id_token) {
1084
1277
  try {
1085
1278
  await validateIdToken(
@@ -1117,13 +1310,13 @@ var _SsoClient = class _SsoClient {
1117
1310
  if (!tokenData) {
1118
1311
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1119
1312
  }
1120
- if (Date.now() >= tokenData.expires_at) {
1313
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1121
1314
  tokenData = await this.refreshToken();
1122
1315
  }
1123
1316
  const userinfoEndpoint = await this._getUserinfoEndpoint();
1124
1317
  let res;
1125
1318
  try {
1126
- res = await fetch(userinfoEndpoint, {
1319
+ res = await fetchWithTimeout(userinfoEndpoint, {
1127
1320
  headers: {
1128
1321
  Authorization: `Bearer ${tokenData.access_token}`
1129
1322
  }
@@ -1152,7 +1345,7 @@ var _SsoClient = class _SsoClient {
1152
1345
  async getAccessToken() {
1153
1346
  let tokenData = getTokenData(this.config.clientId);
1154
1347
  if (!tokenData) return null;
1155
- if (Date.now() >= tokenData.expires_at) {
1348
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1156
1349
  tokenData = await this.refreshToken();
1157
1350
  }
1158
1351
  return tokenData.access_token;
@@ -1161,6 +1354,9 @@ var _SsoClient = class _SsoClient {
1161
1354
  * 检查是否已认证(不发起网络请求)
1162
1355
  *
1163
1356
  * 仅检查本地是否存在未过期的 access_token。
1357
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1358
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1359
+ * getAccessToken/getUserInfo 已带提前量。
1164
1360
  */
1165
1361
  isAuthenticated() {
1166
1362
  const tokenData = getTokenData(this.config.clientId);
@@ -1180,6 +1376,10 @@ var _SsoClient = class _SsoClient {
1180
1376
  const refreshToken = tokenData?.refresh_token;
1181
1377
  const idTokenHint = tokenData?.id_token;
1182
1378
  clearAllSsoData(this.config.clientId);
1379
+ this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
1380
+ clientId: this.config.clientId,
1381
+ redirectToSso
1382
+ });
1183
1383
  if (refreshToken && this.config.clientId) {
1184
1384
  try {
1185
1385
  const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
@@ -1192,11 +1392,15 @@ var _SsoClient = class _SsoClient {
1192
1392
  if (this.config.clientSecret) {
1193
1393
  revokeBody.set("client_secret", this.config.clientSecret);
1194
1394
  }
1195
- await fetch(revokeUrl, {
1196
- method: "POST",
1197
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1198
- body: revokeBody.toString()
1199
- });
1395
+ await fetchWithTimeout(
1396
+ revokeUrl,
1397
+ {
1398
+ method: "POST",
1399
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1400
+ body: revokeBody.toString()
1401
+ },
1402
+ REVOKE_TIMEOUT_MS
1403
+ );
1200
1404
  } catch {
1201
1405
  }
1202
1406
  }