@nihplod/sso-sdk 1.4.0 → 1.5.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
+ }
319
+ }
320
+ }
321
+ for (const store of [_transient, _storage]) {
322
+ try {
323
+ for (const key of store.keys?.() ?? []) abstractKeys.add(key);
324
+ } catch {
267
325
  }
268
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
  }
@@ -513,6 +580,34 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
513
580
  }
514
581
 
515
582
  // src/core/SsoClient.ts
583
+ var REQUEST_TIMEOUT_MS = 1e4;
584
+ var REVOKE_TIMEOUT_MS = 3e3;
585
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
586
+ const controller = new AbortController();
587
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
588
+ try {
589
+ return await fetch(input, { ...init, signal: controller.signal });
590
+ } finally {
591
+ clearTimeout(timer);
592
+ }
593
+ }
594
+ function assertValidTokenResponse(data, requireRefreshToken) {
595
+ if (typeof data.access_token !== "string" || !data.access_token) {
596
+ throw new SsoError("token_request_failed", "Token \u54CD\u5E94\u7F3A\u5C11 access_token");
597
+ }
598
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
599
+ throw new SsoError(
600
+ "token_request_failed",
601
+ `Token \u54CD\u5E94 expires_in \u975E\u6CD5: ${String(data.expires_in)}`
602
+ );
603
+ }
604
+ if (requireRefreshToken && (typeof data.refresh_token !== "string" || !data.refresh_token)) {
605
+ throw new SsoError(
606
+ "token_request_failed",
607
+ "Token \u54CD\u5E94\u7F3A\u5C11 refresh_token\uFF08authorization_code \u4EA4\u6362\u5FC5\u987B\u8FD4\u56DE\uFF09"
608
+ );
609
+ }
610
+ }
516
611
  var SILENT_PROBE_ERRORS = /* @__PURE__ */ new Set([
517
612
  "login_required",
518
613
  "consent_required",
@@ -531,6 +626,19 @@ var _SsoClient = class _SsoClient {
531
626
  this._serverBase = (config.serverBaseUrl ?? base).replace(/\/+$/, "");
532
627
  }
533
628
  // ============================================
629
+ // 调试日志
630
+ // ============================================
631
+ /**
632
+ * 调试日志(config.debug=true 时输出,前缀 [SSO SDK]):
633
+ * 覆盖登录发起 / 回调结果 / 刷新失败 / 登出等关键节点,便于接入方定位问题。
634
+ * 不会输出 token/授权码等敏感值。
635
+ */
636
+ debugLog(...args) {
637
+ if (this.config.debug) {
638
+ console.warn("[SSO SDK]", ...args);
639
+ }
640
+ }
641
+ // ============================================
534
642
  // 内部方法
535
643
  // ============================================
536
644
  /**
@@ -910,7 +1018,7 @@ var _SsoClient = class _SsoClient {
910
1018
  }
911
1019
  let res;
912
1020
  try {
913
- res = await fetch(tokenEndpoint, {
1021
+ res = await fetchWithTimeout(tokenEndpoint, {
914
1022
  method: "POST",
915
1023
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
916
1024
  body: body.toString()
@@ -931,6 +1039,15 @@ var _SsoClient = class _SsoClient {
931
1039
  );
932
1040
  }
933
1041
  const data = await res.json();
1042
+ assertValidTokenResponse(data, true);
1043
+ const requestedScopes = (this.config.scopes || "openid profile").split(" ").filter(Boolean);
1044
+ if (requestedScopes.includes("openid") && !data.id_token) {
1045
+ removeTokenData(this.config.clientId);
1046
+ throw new SsoError(
1047
+ "id_token_invalid",
1048
+ "Token \u54CD\u5E94\u7F3A\u5C11 id_token\uFF08scope \u542B openid \u65F6\u5FC5\u9700\uFF09"
1049
+ );
1050
+ }
934
1051
  if (data.id_token) {
935
1052
  try {
936
1053
  await validateIdToken(
@@ -960,6 +1077,7 @@ var _SsoClient = class _SsoClient {
960
1077
  expires_at: now + data.expires_in * 1e3
961
1078
  };
962
1079
  saveTokenData(tokenData, this.config.clientId);
1080
+ this.debugLog("handleCallback: token \u4EA4\u6362\u6210\u529F", { clientId: this.config.clientId });
963
1081
  return tokenData;
964
1082
  }
965
1083
  /**
@@ -995,7 +1113,7 @@ var _SsoClient = class _SsoClient {
995
1113
  let lastErr;
996
1114
  for (let attempt = 0; attempt < 2; attempt++) {
997
1115
  try {
998
- res = await fetch(tokenEndpoint, {
1116
+ res = await fetchWithTimeout(tokenEndpoint, {
999
1117
  method: "POST",
1000
1118
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1001
1119
  body: body.toString()
@@ -1017,15 +1135,20 @@ var _SsoClient = class _SsoClient {
1017
1135
  } catch {
1018
1136
  }
1019
1137
  const errorCode = errData.error || "";
1020
- if (errorCode === "invalid_grant" || res.status === 401) {
1138
+ if (errorCode === "invalid_grant") {
1021
1139
  removeTokenData(this.config.clientId);
1022
1140
  }
1141
+ this.debugLog("refreshToken: \u5237\u65B0\u5931\u8D25", { status: res.status, error: errorCode });
1142
+ if (!errorCode && res.status === 401) {
1143
+ throw new SsoError("sso_server_error", "\u5237\u65B0 Token \u5931\u8D25\uFF08\u7F51\u5173\u5F02\u5E38\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5");
1144
+ }
1023
1145
  throw new SsoError(
1024
1146
  mapOAuthErrorToSsoCode(errorCode, "refresh"),
1025
1147
  errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
1026
1148
  );
1027
1149
  }
1028
1150
  const data = await res.json();
1151
+ assertValidTokenResponse(data, false);
1029
1152
  if (data.id_token) {
1030
1153
  try {
1031
1154
  await validateIdToken(
@@ -1069,7 +1192,7 @@ var _SsoClient = class _SsoClient {
1069
1192
  const userinfoEndpoint = await this._getUserinfoEndpoint();
1070
1193
  let res;
1071
1194
  try {
1072
- res = await fetch(userinfoEndpoint, {
1195
+ res = await fetchWithTimeout(userinfoEndpoint, {
1073
1196
  headers: {
1074
1197
  Authorization: `Bearer ${tokenData.access_token}`
1075
1198
  }
@@ -1126,6 +1249,10 @@ var _SsoClient = class _SsoClient {
1126
1249
  const refreshToken = tokenData?.refresh_token;
1127
1250
  const idTokenHint = tokenData?.id_token;
1128
1251
  clearAllSsoData(this.config.clientId);
1252
+ this.debugLog("logout: \u672C\u5730\u6570\u636E\u5DF2\u6E05\u9664", {
1253
+ clientId: this.config.clientId,
1254
+ redirectToSso
1255
+ });
1129
1256
  if (refreshToken && this.config.clientId) {
1130
1257
  try {
1131
1258
  const discovery = this.config.serverBaseUrl ? null : await this._getDiscovery();
@@ -1138,11 +1265,15 @@ var _SsoClient = class _SsoClient {
1138
1265
  if (this.config.clientSecret) {
1139
1266
  revokeBody.set("client_secret", this.config.clientSecret);
1140
1267
  }
1141
- await fetch(revokeUrl, {
1142
- method: "POST",
1143
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1144
- body: revokeBody.toString()
1145
- });
1268
+ await fetchWithTimeout(
1269
+ revokeUrl,
1270
+ {
1271
+ method: "POST",
1272
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1273
+ body: revokeBody.toString()
1274
+ },
1275
+ REVOKE_TIMEOUT_MS
1276
+ );
1146
1277
  } catch {
1147
1278
  }
1148
1279
  }