@nihplod/sso-sdk 1.1.0 → 1.2.1

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
@@ -29,6 +29,7 @@ __export(src_exports, {
29
29
  generateCodeChallenge: () => generateCodeChallenge,
30
30
  generateCodeVerifier: () => generateCodeVerifier,
31
31
  generateState: () => generateState,
32
+ getLogoutState: () => getLogoutState,
32
33
  getOAuthState: () => getOAuthState,
33
34
  getPkceVerifier: () => getPkceVerifier,
34
35
  getReturnUrl: () => getReturnUrl,
@@ -36,10 +37,12 @@ __export(src_exports, {
36
37
  getTokenStorage: () => getTokenStorage,
37
38
  isTrustedReturnUrl: () => isTrustedReturnUrl,
38
39
  mapOAuthErrorToSsoCode: () => mapOAuthErrorToSsoCode,
40
+ removeLogoutState: () => removeLogoutState,
39
41
  removeOAuthState: () => removeOAuthState,
40
42
  removePkceVerifier: () => removePkceVerifier,
41
43
  removeReturnUrl: () => removeReturnUrl,
42
44
  removeTokenData: () => removeTokenData,
45
+ saveLogoutState: () => saveLogoutState,
43
46
  saveOAuthState: () => saveOAuthState,
44
47
  savePkceVerifier: () => savePkceVerifier,
45
48
  saveReturnUrl: () => saveReturnUrl,
@@ -134,6 +137,7 @@ var TOKEN_KEY = "token";
134
137
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
135
138
  var STATE_KEY = "oauth_state";
136
139
  var RETURN_URL_KEY = "return_url";
140
+ var LOGOUT_STATE_KEY = "logout_state";
137
141
  function buildKey(base, clientId) {
138
142
  return clientId ? `${base}:${clientId}` : base;
139
143
  }
@@ -201,9 +205,9 @@ function createSessionStorageAdapter() {
201
205
  }
202
206
  var _transient = createSessionStorageAdapter();
203
207
  function createSecureStorage(options = {}) {
204
- return options.persist ? localStorageAdapter : memoryStorageAdapter;
208
+ return options.persist ? localStorageAdapter : createSessionStorageAdapter();
205
209
  }
206
- var _storage = memoryStorageAdapter;
210
+ var _storage = createSessionStorageAdapter();
207
211
  function setTokenStorage(storage) {
208
212
  _storage = storage;
209
213
  }
@@ -243,6 +247,15 @@ function getOAuthState(clientId) {
243
247
  function removeOAuthState(clientId) {
244
248
  _transient.remove(buildKey(STATE_KEY, clientId));
245
249
  }
250
+ function saveLogoutState(state, clientId) {
251
+ _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
252
+ }
253
+ function getLogoutState(clientId) {
254
+ return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
255
+ }
256
+ function removeLogoutState(clientId) {
257
+ _transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
258
+ }
246
259
  function saveReturnUrl(url, clientId) {
247
260
  _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
248
261
  }
@@ -257,6 +270,7 @@ function clearAllSsoData(clientId) {
257
270
  removeTokenData(clientId);
258
271
  removeOAuthState(clientId);
259
272
  removeReturnUrl(clientId);
273
+ removeLogoutState(clientId);
260
274
  removePkceVerifier(clientId);
261
275
  removePkceVerifier(`${clientId}_popup_nonce`);
262
276
  return;
@@ -264,6 +278,7 @@ function clearAllSsoData(clientId) {
264
278
  removeTokenData();
265
279
  removeOAuthState();
266
280
  removeReturnUrl();
281
+ removeLogoutState();
267
282
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
268
283
  const stores = [
269
284
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -291,6 +306,7 @@ function clearVerifiersForClients(clientIds) {
291
306
  // src/core/security.ts
292
307
  function isTrustedReturnUrl(url, currentOrigin) {
293
308
  if (!url) return false;
309
+ if (url.includes("\\")) return false;
294
310
  if (url.startsWith("/") && !url.startsWith("//")) return true;
295
311
  try {
296
312
  return new URL(url).origin === currentOrigin;
@@ -496,7 +512,7 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
496
512
  if (typeof payload.exp !== "number") {
497
513
  throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
498
514
  }
499
- if (Date.now() >= payload.exp * 1e3) {
515
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
500
516
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
501
517
  }
502
518
  if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
@@ -589,7 +605,7 @@ var _SsoClient = class _SsoClient {
589
605
  */
590
606
  _saveReturnUrlIfTrusted(returnUrl) {
591
607
  if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
592
- saveReturnUrl(returnUrl);
608
+ saveReturnUrl(returnUrl, this.config.clientId);
593
609
  } else {
594
610
  console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
595
611
  }
@@ -605,6 +621,10 @@ var _SsoClient = class _SsoClient {
605
621
  *
606
622
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
607
623
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
624
+ *
625
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
626
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
627
+ * 同一次登录只使用其中一个入口。
608
628
  */
609
629
  async login(returnUrl) {
610
630
  const verifier = generateCodeVerifier();
@@ -630,6 +650,10 @@ var _SsoClient = class _SsoClient {
630
650
  * 构建登录 URL(不跳转,返回 URL 字符串)
631
651
  *
632
652
  * 适用于需要手动处理跳转的场景。
653
+ *
654
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
655
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
656
+ * 同一次登录只使用其中一个入口。
633
657
  */
634
658
  async getLoginUrl(returnUrl) {
635
659
  const verifier = generateCodeVerifier();
@@ -782,7 +806,7 @@ var _SsoClient = class _SsoClient {
782
806
  * 处理 OAuth 回调
783
807
  *
784
808
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
785
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
809
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
786
810
  *
787
811
  * @param callbackUrl - 完整的回调 URL(window.location.href)
788
812
  * @returns TokenData 或 null
@@ -792,12 +816,16 @@ var _SsoClient = class _SsoClient {
792
816
  const params = url.searchParams;
793
817
  const error = params.get("error");
794
818
  if (error) {
819
+ removeOAuthState(this.config.clientId);
820
+ removePkceVerifier(this.config.clientId);
795
821
  const desc = params.get("error_description") || error;
796
- throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
822
+ throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
797
823
  }
798
824
  const code = params.get("code");
799
825
  const returnedState = params.get("state");
800
826
  if (!code) {
827
+ removeOAuthState(this.config.clientId);
828
+ removePkceVerifier(this.config.clientId);
801
829
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
802
830
  }
803
831
  const savedState = getOAuthState(this.config.clientId);
@@ -849,8 +877,6 @@ var _SsoClient = class _SsoClient {
849
877
  );
850
878
  }
851
879
  const data = await res.json();
852
- removeOAuthState(this.config.clientId);
853
- removePkceVerifier(this.config.clientId);
854
880
  if (data.id_token) {
855
881
  try {
856
882
  await validateIdToken(
@@ -864,6 +890,8 @@ var _SsoClient = class _SsoClient {
864
890
  throw err;
865
891
  }
866
892
  }
893
+ removeOAuthState(this.config.clientId);
894
+ removePkceVerifier(this.config.clientId);
867
895
  const now = Date.now();
868
896
  const tokenData = {
869
897
  access_token: data.access_token,
@@ -1032,7 +1060,9 @@ var _SsoClient = class _SsoClient {
1032
1060
  * 登出
1033
1061
  *
1034
1062
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
1035
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
1063
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
1064
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
1065
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
1036
1066
  */
1037
1067
  async logout(redirectToSso = false) {
1038
1068
  const tokenData = getTokenData(this.config.clientId);
@@ -1075,10 +1105,36 @@ var _SsoClient = class _SsoClient {
1075
1105
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
1076
1106
  }
1077
1107
  const state = generateState();
1108
+ saveLogoutState(state, this.config.clientId);
1078
1109
  logoutUrl.searchParams.set("state", state);
1079
1110
  window.location.href = logoutUrl.toString();
1080
1111
  }
1081
1112
  }
1113
+ /**
1114
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
1115
+ *
1116
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
1117
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
1118
+ * 校验后清除已保存的 logout state(一次性)。
1119
+ *
1120
+ * @param url - 当前页面完整 URL(window.location.href)
1121
+ *
1122
+ * @example
1123
+ * ```typescript
1124
+ * if (sso.validateLogoutState(window.location.href)) {
1125
+ * // 来自 SSO 登出的可信回跳
1126
+ * }
1127
+ * ```
1128
+ */
1129
+ validateLogoutState(url) {
1130
+ const returnedState = new URL(url).searchParams.get("state");
1131
+ if (!returnedState) return false;
1132
+ const savedState = getLogoutState(this.config.clientId);
1133
+ if (!savedState) return false;
1134
+ if (!timingSafeEqualString(savedState, returnedState)) return false;
1135
+ removeLogoutState(this.config.clientId);
1136
+ return true;
1137
+ }
1082
1138
  /**
1083
1139
  * 获取 OIDC Discovery 文档
1084
1140
  *
@@ -1105,6 +1161,7 @@ var SsoClient = _SsoClient;
1105
1161
  generateCodeChallenge,
1106
1162
  generateCodeVerifier,
1107
1163
  generateState,
1164
+ getLogoutState,
1108
1165
  getOAuthState,
1109
1166
  getPkceVerifier,
1110
1167
  getReturnUrl,
@@ -1112,10 +1169,12 @@ var SsoClient = _SsoClient;
1112
1169
  getTokenStorage,
1113
1170
  isTrustedReturnUrl,
1114
1171
  mapOAuthErrorToSsoCode,
1172
+ removeLogoutState,
1115
1173
  removeOAuthState,
1116
1174
  removePkceVerifier,
1117
1175
  removeReturnUrl,
1118
1176
  removeTokenData,
1177
+ saveLogoutState,
1119
1178
  saveOAuthState,
1120
1179
  savePkceVerifier,
1121
1180
  saveReturnUrl,