@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.mjs CHANGED
@@ -100,18 +100,25 @@ function createMemoryStorageAdapter() {
100
100
  },
101
101
  remove(key) {
102
102
  store.delete(key);
103
+ },
104
+ keys() {
105
+ return [...store.keys()];
103
106
  }
104
107
  };
105
108
  }
106
109
  var memoryStorageAdapter = createMemoryStorageAdapter();
107
110
  var localStorageAdapter = {
108
111
  get(key) {
109
- if (typeof localStorage === "undefined") return null;
110
- return localStorage.getItem(STORAGE_PREFIX + key);
112
+ try {
113
+ if (typeof localStorage === "undefined") return null;
114
+ return localStorage.getItem(STORAGE_PREFIX + key);
115
+ } catch {
116
+ return null;
117
+ }
111
118
  },
112
119
  set(key, value) {
113
- if (typeof localStorage === "undefined") return;
114
120
  try {
121
+ if (typeof localStorage === "undefined") return;
115
122
  localStorage.setItem(STORAGE_PREFIX + key, value);
116
123
  } catch (err) {
117
124
  console.warn(
@@ -120,34 +127,68 @@ var localStorageAdapter = {
120
127
  }
121
128
  },
122
129
  remove(key) {
123
- if (typeof localStorage === "undefined") return;
124
- localStorage.removeItem(STORAGE_PREFIX + key);
130
+ try {
131
+ if (typeof localStorage === "undefined") return;
132
+ localStorage.removeItem(STORAGE_PREFIX + key);
133
+ } catch {
134
+ }
135
+ },
136
+ keys() {
137
+ try {
138
+ if (typeof localStorage === "undefined") return [];
139
+ const result = [];
140
+ for (let i = 0; i < localStorage.length; i++) {
141
+ const key = localStorage.key(i);
142
+ if (key?.startsWith(STORAGE_PREFIX)) result.push(key.slice(STORAGE_PREFIX.length));
143
+ }
144
+ return result;
145
+ } catch {
146
+ return [];
147
+ }
125
148
  }
126
149
  };
127
150
  function createSessionStorageAdapter() {
128
151
  const fallback = /* @__PURE__ */ new Map();
129
152
  return {
130
153
  get(key) {
131
- if (typeof sessionStorage !== "undefined") {
132
- return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
154
+ try {
155
+ if (typeof sessionStorage !== "undefined") {
156
+ return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
157
+ }
158
+ } catch {
133
159
  }
134
160
  return fallback.get(key) ?? null;
135
161
  },
136
162
  set(key, value) {
137
- if (typeof sessionStorage === "undefined") {
138
- fallback.set(key, value);
139
- return;
140
- }
141
163
  try {
142
- sessionStorage.setItem(STORAGE_PREFIX + key, value);
164
+ if (typeof sessionStorage !== "undefined") {
165
+ sessionStorage.setItem(STORAGE_PREFIX + key, value);
166
+ return;
167
+ }
143
168
  } catch {
144
- fallback.set(key, value);
145
169
  }
170
+ fallback.set(key, value);
146
171
  },
147
172
  remove(key) {
148
173
  fallback.delete(key);
149
- if (typeof sessionStorage === "undefined") return;
150
- sessionStorage.removeItem(STORAGE_PREFIX + key);
174
+ try {
175
+ if (typeof sessionStorage === "undefined") return;
176
+ sessionStorage.removeItem(STORAGE_PREFIX + key);
177
+ } catch {
178
+ }
179
+ },
180
+ keys() {
181
+ const result = new Set(fallback.keys());
182
+ try {
183
+ if (typeof sessionStorage !== "undefined") {
184
+ for (let i = 0; i < sessionStorage.length; i++) {
185
+ const key = sessionStorage.key(i);
186
+ if (key?.startsWith(STORAGE_PREFIX)) result.add(key.slice(STORAGE_PREFIX.length));
187
+ }
188
+ }
189
+ } catch {
190
+ }
191
+ return [...result];
151
192
  }
152
193
  };
153
194
  }
@@ -249,23 +290,44 @@ function clearAllSsoData(clientId) {
249
290
  removeReturnUrl();
250
291
  removeLogoutState();
251
292
  removeSilentProbe();
252
- const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
253
- const stores = [
254
- typeof sessionStorage !== "undefined" ? sessionStorage : null,
255
- typeof localStorage !== "undefined" ? localStorage : null
256
- ];
293
+ const stores = [];
294
+ try {
295
+ if (typeof sessionStorage !== "undefined") stores.push(sessionStorage);
296
+ } catch {
297
+ }
298
+ try {
299
+ if (typeof localStorage !== "undefined") stores.push(localStorage);
300
+ } catch {
301
+ }
302
+ const abstractKeys = /* @__PURE__ */ new Set();
257
303
  for (const store of stores) {
258
- if (!store) continue;
259
304
  const keys = [];
260
- for (let i = 0; i < store.length; i++) {
261
- const key = store.key(i);
262
- if (key?.startsWith(prefix)) keys.push(key);
305
+ try {
306
+ for (let i = 0; i < store.length; i++) {
307
+ const key = store.key(i);
308
+ if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
309
+ }
310
+ } catch {
311
+ continue;
263
312
  }
264
313
  for (const key of keys) {
265
- _transient.remove(key.slice(STORAGE_PREFIX.length));
266
- store.removeItem(key);
314
+ abstractKeys.add(key.slice(STORAGE_PREFIX.length));
315
+ try {
316
+ store.removeItem(key);
317
+ } catch {
318
+ }
267
319
  }
268
320
  }
321
+ for (const store of [_transient, _storage]) {
322
+ try {
323
+ for (const key of store.keys?.() ?? []) abstractKeys.add(key);
324
+ } catch {
325
+ }
326
+ }
327
+ for (const key of abstractKeys) {
328
+ _transient.remove(key);
329
+ _storage.remove(key);
330
+ }
269
331
  }
270
332
  function clearVerifiersForClients(clientIds) {
271
333
  for (const clientId of clientIds) {
@@ -274,12 +336,17 @@ function clearVerifiersForClients(clientIds) {
274
336
  }
275
337
 
276
338
  // src/core/security.ts
339
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
277
340
  function isTrustedReturnUrl(url, currentOrigin) {
278
341
  if (!url) return false;
279
- if (url.includes("\\")) return false;
280
- if (url.startsWith("/") && !url.startsWith("//")) return true;
342
+ if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
343
+ if (url.startsWith("//")) return false;
344
+ if (url.startsWith("/")) return true;
281
345
  try {
282
- return new URL(url).origin === currentOrigin;
346
+ const parsed = new URL(url);
347
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
348
+ if (parsed.username || parsed.password) return false;
349
+ return parsed.origin === currentOrigin;
283
350
  } catch {
284
351
  return false;
285
352
  }
@@ -491,7 +558,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
491
558
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
492
559
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
493
560
  }
494
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
561
+ if (typeof payload.iat !== "number") {
562
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
563
+ }
564
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
495
565
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
496
566
  }
497
567
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -508,11 +578,47 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
508
578
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
509
579
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
510
580
  }
581
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
582
+ throw new SsoError(
583
+ "id_token_nonce_mismatch",
584
+ "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"
585
+ );
511
586
  }
512
587
  return { sub: payload.sub };
513
588
  }
514
589
 
590
+ // src/core/http.ts
591
+ var REQUEST_TIMEOUT_MS = 1e4;
592
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
593
+ const controller = new AbortController();
594
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
595
+ try {
596
+ return await fetch(input, { ...init, signal: controller.signal });
597
+ } finally {
598
+ clearTimeout(timer);
599
+ }
600
+ }
601
+
515
602
  // src/core/SsoClient.ts
603
+ var REVOKE_TIMEOUT_MS = 3e3;
604
+ var EXPIRY_SKEW_MS = 1e4;
605
+ function assertValidTokenResponse(data, requireRefreshToken) {
606
+ if (typeof data.access_token !== "string" || !data.access_token) {
607
+ throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
608
+ }
609
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
610
+ throw new SsoError(
611
+ "token_request_failed",
612
+ `Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
613
+ );
614
+ }
615
+ if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
616
+ throw new SsoError(
617
+ "token_request_failed",
618
+ "Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
619
+ );
620
+ }
621
+ }
516
622
  var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
517
623
  "login_required",
518
624
  "consent_required",
@@ -523,6 +629,8 @@ var _SsoClient = class _SsoClient {
523
629
  this._discovery = null;
524
630
  this._discoveryFetchedAt = 0;
525
631
  this._refreshLock = null;
632
+ /** Discovery 端点 origin 校验失败只告警一次(拒绝的文档不缓存,避免每次调用刷屏) */
633
+ this._discoveryOriginWarned = false;
526
634
  if (!config.clientId) throw new SsoError("invalid_config", "clientId \u4E0D\u80FD\u4E3A\u7A7A");
527
635
  if (!config.redirectUri) throw new SsoError("invalid_config", "redirectUri \u4E0D\u80FD\u4E3A\u7A7A");
528
636
  if (!config.ssoBaseUrl) throw new SsoError("invalid_config", "ssoBaseUrl \u4E0D\u80FD\u4E3A\u7A7A");
@@ -531,8 +639,67 @@ var _SsoClient = class _SsoClient {
531
639
  this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
532
640
  }
533
641
  // ============================================
642
+ // 调试日志
643
+ // ============================================
644
+ /**
645
+ * 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
646
+ * 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
647
+ * 不会输出 token/授权码等敏感值。
648
+ */
649
+ debugLog(...args) {
650
+ if (this.config.debug) {
651
+ console.warn("[SSO SDK]", ...args);
652
+ }
653
+ }
654
+ // ============================================
534
655
  // 内部方法
535
656
  // ============================================
657
+ /**
658
+ * 校验 Discovery 文档端点 origin(防文档被篡改后 token/code 被导向攻击者服务器):
659
+ * - authorization_endpoint 必须与 ssoBaseUrl 同源(浏览器跳转目标);
660
+ * - token_endpoint / userinfo_endpoint 必须与 ssoBaseUrl 同源,
661
+ * 或(配置了 serverBaseUrl 时)与内网地址同源。
662
+ * 任一不匹配:告警并返回 null(上层回退到默认端点,fail-safe)。
663
+ * 可通过 config.allowCrossOriginDiscoveryEndpoints 关闭(见配置项警告)。
664
+ */
665
+ _sanitizeDiscovery(d) {
666
+ if (this.config.allowCrossOriginDiscoveryEndpoints) return d;
667
+ const allowedOrigins = /* @__PURE__ */ new Set();
668
+ try {
669
+ allowedOrigins.add(new URL(this.config.ssoBaseUrl).origin);
670
+ allowedOrigins.add(new URL(this._serverBase).origin);
671
+ } catch {
672
+ return null;
673
+ }
674
+ const sameOrigin = (endpoint) => {
675
+ try {
676
+ return allowedOrigins.has(new URL(endpoint).origin);
677
+ } catch {
678
+ return false;
679
+ }
680
+ };
681
+ const publicOrigin = new URL(this.config.ssoBaseUrl).origin;
682
+ let authorizationOk = false;
683
+ try {
684
+ authorizationOk = new URL(d.authorization_endpoint).origin === publicOrigin;
685
+ } catch {
686
+ authorizationOk = false;
687
+ }
688
+ const bad = [];
689
+ if (!authorizationOk) bad.push("authorization_endpoint");
690
+ if (!sameOrigin(d.token_endpoint)) bad.push("token_endpoint");
691
+ if (!sameOrigin(d.userinfo_endpoint)) bad.push("userinfo_endpoint");
692
+ if (bad.length > 0) {
693
+ if (!this._discoveryOriginWarned) {
694
+ this._discoveryOriginWarned = true;
695
+ console.warn(
696
+ `[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`
697
+ );
698
+ }
699
+ return null;
700
+ }
701
+ return d;
702
+ }
536
703
  /**
537
704
  * 获取 OIDC Discovery 文档(带缓存 + 超时)
538
705
  *
@@ -559,7 +726,10 @@ var _SsoClient = class _SsoClient {
559
726
  );
560
727
  return null;
561
728
  }
562
- this._discovery = await res.json();
729
+ const doc = await res.json();
730
+ const sanitized = this._sanitizeDiscovery(doc);
731
+ if (!sanitized) return null;
732
+ this._discovery = sanitized;
563
733
  this._discoveryFetchedAt = now;
564
734
  return this._discovery;
565
735
  } catch (err) {
@@ -910,7 +1080,7 @@ var _SsoClient = class _SsoClient {
910
1080
  }
911
1081
  let res;
912
1082
  try {
913
- res = await fetch(tokenEndpoint, {
1083
+ res = await fetchWithTimeout(tokenEndpoint, {
914
1084
  method: "POST",
915
1085
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
916
1086
  body: body.toString()
@@ -919,6 +1089,10 @@ var _SsoClient = class _SsoClient {
919
1089
  throw new SsoError("network_error", "\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", err);
920
1090
  }
921
1091
  if (!res.ok) {
1092
+ removeOAuthState(this.config.clientId);
1093
+ removeOAuthNonce(this.config.clientId);
1094
+ removePkceVerifier(this.config.clientId);
1095
+ removeSilentProbe(this.config.clientId);
922
1096
  let errData = {};
923
1097
  try {
924
1098
  errData = await res.json();
@@ -931,6 +1105,19 @@ var _SsoClient = class _SsoClient {
931
1105
  );
932
1106
  }
933
1107
  const data = await res.json();
1108
+ assertValidTokenResponse(data, true);
1109
+ const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1110
+ if (requestedScopes.includes("openid") && !data.id_token) {
1111
+ removeOAuthState(this.config.clientId);
1112
+ removeOAuthNonce(this.config.clientId);
1113
+ removePkceVerifier(this.config.clientId);
1114
+ removeSilentProbe(this.config.clientId);
1115
+ removeTokenData(this.config.clientId);
1116
+ throw new SsoError(
1117
+ "id_token_invalid",
1118
+ "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
1119
+ );
1120
+ }
934
1121
  if (data.id_token) {
935
1122
  try {
936
1123
  await validateIdToken(
@@ -960,6 +1147,7 @@ var _SsoClient = class _SsoClient {
960
1147
  expires_at: now + data.expires_in * 1e3
961
1148
  };
962
1149
  saveTokenData(tokenData, this.config.clientId);
1150
+ this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
963
1151
  return tokenData;
964
1152
  }
965
1153
  /**
@@ -995,7 +1183,7 @@ var _SsoClient = class _SsoClient {
995
1183
  let lastErr;
996
1184
  for (let attempt = 0; attempt < 2; attempt++) {
997
1185
  try {
998
- res = await fetch(tokenEndpoint, {
1186
+ res = await fetchWithTimeout(tokenEndpoint, {
999
1187
  method: "POST",
1000
1188
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1001
1189
  body: body.toString()
@@ -1017,15 +1205,20 @@ var _SsoClient = class _SsoClient {
1017
1205
  } catch {
1018
1206
  }
1019
1207
  const errorCode = errData.error || "";
1020
- if (errorCode === "invalid_grant" || res.status === 401) {
1208
+ if (errorCode === "invalid_grant") {
1021
1209
  removeTokenData(this.config.clientId);
1022
1210
  }
1211
+ this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
1212
+ if (!errorCode && res.status === 401) {
1213
+ throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
1214
+ }
1023
1215
  throw new SsoError(
1024
1216
  mapOAuthErrorToSsoCode(errorCode, "refresh"),
1025
1217
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
1026
1218
  );
1027
1219
  }
1028
1220
  const data = await res.json();
1221
+ assertValidTokenResponse(data, false);
1029
1222
  if (data.id_token) {
1030
1223
  try {
1031
1224
  await validateIdToken(
@@ -1063,13 +1256,13 @@ var _SsoClient = class _SsoClient {
1063
1256
  if (!tokenData) {
1064
1257
  throw new SsoError("not_authenticated", "\u672A\u767B\u5F55");
1065
1258
  }
1066
- if (Date.now() >= tokenData.expires_at) {
1259
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1067
1260
  tokenData = await this.refreshToken();
1068
1261
  }
1069
1262
  const userinfoEndpoint = await this._getUserinfoEndpoint();
1070
1263
  let res;
1071
1264
  try {
1072
- res = await fetch(userinfoEndpoint, {
1265
+ res = await fetchWithTimeout(userinfoEndpoint, {
1073
1266
  headers: {
1074
1267
  Authorization: `Bearer ${tokenData.access_token}`
1075
1268
  }
@@ -1098,7 +1291,7 @@ var _SsoClient = class _SsoClient {
1098
1291
  async getAccessToken() {
1099
1292
  let tokenData = getTokenData(this.config.clientId);
1100
1293
  if (!tokenData) return null;
1101
- if (Date.now() >= tokenData.expires_at) {
1294
+ if (Date.now() >= tokenData.expires_at - EXPIRY_SKEW_MS) {
1102
1295
  tokenData = await this.refreshToken();
1103
1296
  }
1104
1297
  return tokenData.access_token;
@@ -1107,6 +1300,9 @@ var _SsoClient = class _SsoClient {
1107
1300
  * 检查是否已认证(不发起网络请求)
1108
1301
  *
1109
1302
  * 仅检查本地是否存在未过期的 access_token。
1303
+ * 故意不做 EXPIRY_SKEW_MS 提前量:这是同步的 UI 提示判定(是否显示"已登录"),
1304
+ * 提前 10s 翻转 false 会让调用方在 token 仍有效时误显示未登录;真正发请求的
1305
+ * getAccessToken/getUserInfo 已带提前量。
1110
1306
  */
1111
1307
  isAuthenticated() {
1112
1308
  const tokenData = getTokenData(this.config.clientId);
@@ -1126,6 +1322,10 @@ var _SsoClient = class _SsoClient {
1126
1322
  const refreshToken = tokenData?.refresh_token;
1127
1323
  const idTokenHint = tokenData?.id_token;
1128
1324
  clearAllSsoData(this.config.clientId);
1325
+ this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
1326
+ clientId: this.config.clientId,
1327
+ redirectToSso
1328
+ });
1129
1329
  if (refreshToken && this.config.clientId) {
1130
1330
  try {
1131
1331
  const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
@@ -1138,11 +1338,15 @@ var _SsoClient = class _SsoClient {
1138
1338
  if (this.config.clientSecret) {
1139
1339
  revokeBody.set("client_secret", this.config.clientSecret);
1140
1340
  }
1141
- await fetch(revokeUrl, {
1142
- method: "POST",
1143
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1144
- body: revokeBody.toString()
1145
- });
1341
+ await fetchWithTimeout(
1342
+ revokeUrl,
1343
+ {
1344
+ method: "POST",
1345
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1346
+ body: revokeBody.toString()
1347
+ },
1348
+ REVOKE_TIMEOUT_MS
1349
+ );
1146
1350
  } catch {
1147
1351
  }
1148
1352
  }