@nihplod/sso-sdk 1.1.0 → 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.
@@ -84,6 +84,7 @@ var TOKEN_KEY = "token";
84
84
  var VERIFIER_KEY_PREFIX = "pkce_verifier_";
85
85
  var STATE_KEY = "oauth_state";
86
86
  var RETURN_URL_KEY = "return_url";
87
+ var LOGOUT_STATE_KEY = "logout_state";
87
88
  function buildKey(base, clientId) {
88
89
  return clientId ? `${base}:${clientId}` : base;
89
90
  }
@@ -130,7 +131,7 @@ function createSessionStorageAdapter() {
130
131
  };
131
132
  }
132
133
  var _transient = createSessionStorageAdapter();
133
- var _storage = memoryStorageAdapter;
134
+ var _storage = createSessionStorageAdapter();
134
135
  function saveTokenData(data, clientId) {
135
136
  _storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
136
137
  }
@@ -164,6 +165,15 @@ function getOAuthState(clientId) {
164
165
  function removeOAuthState(clientId) {
165
166
  _transient.remove(buildKey(STATE_KEY, clientId));
166
167
  }
168
+ function saveLogoutState(state, clientId) {
169
+ _transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
170
+ }
171
+ function getLogoutState(clientId) {
172
+ return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
173
+ }
174
+ function removeLogoutState(clientId) {
175
+ _transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
176
+ }
167
177
  function saveReturnUrl(url, clientId) {
168
178
  _transient.set(buildKey(RETURN_URL_KEY, clientId), url);
169
179
  }
@@ -178,6 +188,7 @@ function clearAllSsoData(clientId) {
178
188
  removeTokenData(clientId);
179
189
  removeOAuthState(clientId);
180
190
  removeReturnUrl(clientId);
191
+ removeLogoutState(clientId);
181
192
  removePkceVerifier(clientId);
182
193
  removePkceVerifier(`${clientId}_popup_nonce`);
183
194
  return;
@@ -185,6 +196,7 @@ function clearAllSsoData(clientId) {
185
196
  removeTokenData();
186
197
  removeOAuthState();
187
198
  removeReturnUrl();
199
+ removeLogoutState();
188
200
  const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
189
201
  const stores = [
190
202
  typeof sessionStorage !== "undefined" ? sessionStorage : null,
@@ -207,6 +219,7 @@ function clearAllSsoData(clientId) {
207
219
  // src/core/security.ts
208
220
  function isTrustedReturnUrl(url, currentOrigin) {
209
221
  if (!url) return false;
222
+ if (url.includes("\\")) return false;
210
223
  if (url.startsWith("/") && !url.startsWith("//")) return true;
211
224
  try {
212
225
  return new URL(url).origin === currentOrigin;
@@ -412,7 +425,7 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
412
425
  if (typeof payload.exp !== "number") {
413
426
  throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
414
427
  }
415
- if (Date.now() >= payload.exp * 1e3) {
428
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
416
429
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
417
430
  }
418
431
  if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
@@ -505,7 +518,7 @@ var _SsoClient = class _SsoClient {
505
518
  */
506
519
  _saveReturnUrlIfTrusted(returnUrl) {
507
520
  if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
508
- saveReturnUrl(returnUrl);
521
+ saveReturnUrl(returnUrl, this.config.clientId);
509
522
  } else {
510
523
  console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
511
524
  }
@@ -521,6 +534,10 @@ var _SsoClient = class _SsoClient {
521
534
  *
522
535
  * @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
523
536
  * 仅允许相对路径或同源绝对 URL,否则忽略并告警)
537
+ *
538
+ * ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
539
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
540
+ * 同一次登录只使用其中一个入口。
524
541
  */
525
542
  async login(returnUrl) {
526
543
  const verifier = generateCodeVerifier();
@@ -546,6 +563,10 @@ var _SsoClient = class _SsoClient {
546
563
  * 构建登录 URL(不跳转,返回 URL 字符串)
547
564
  *
548
565
  * 适用于需要手动处理跳转的场景。
566
+ *
567
+ * ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
568
+ * state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
569
+ * 同一次登录只使用其中一个入口。
549
570
  */
550
571
  async getLoginUrl(returnUrl) {
551
572
  const verifier = generateCodeVerifier();
@@ -698,7 +719,7 @@ var _SsoClient = class _SsoClient {
698
719
  * 处理 OAuth 回调
699
720
  *
700
721
  * 解析回调 URL,校验 state 参数,用授权码交换 token。
701
- * 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
722
+ * 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
702
723
  *
703
724
  * @param callbackUrl - 完整的回调 URL(window.location.href)
704
725
  * @returns TokenData 或 null
@@ -708,12 +729,16 @@ var _SsoClient = class _SsoClient {
708
729
  const params = url.searchParams;
709
730
  const error = params.get("error");
710
731
  if (error) {
732
+ removeOAuthState(this.config.clientId);
733
+ removePkceVerifier(this.config.clientId);
711
734
  const desc = params.get("error_description") || error;
712
- throw new SsoError("token_request_failed", `\u6388\u6743\u5931\u8D25: ${desc}`);
735
+ throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
713
736
  }
714
737
  const code = params.get("code");
715
738
  const returnedState = params.get("state");
716
739
  if (!code) {
740
+ removeOAuthState(this.config.clientId);
741
+ removePkceVerifier(this.config.clientId);
717
742
  throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
718
743
  }
719
744
  const savedState = getOAuthState(this.config.clientId);
@@ -765,8 +790,6 @@ var _SsoClient = class _SsoClient {
765
790
  );
766
791
  }
767
792
  const data = await res.json();
768
- removeOAuthState(this.config.clientId);
769
- removePkceVerifier(this.config.clientId);
770
793
  if (data.id_token) {
771
794
  try {
772
795
  await validateIdToken(
@@ -780,6 +803,8 @@ var _SsoClient = class _SsoClient {
780
803
  throw err;
781
804
  }
782
805
  }
806
+ removeOAuthState(this.config.clientId);
807
+ removePkceVerifier(this.config.clientId);
783
808
  const now = Date.now();
784
809
  const tokenData = {
785
810
  access_token: data.access_token,
@@ -948,7 +973,9 @@ var _SsoClient = class _SsoClient {
948
973
  * 登出
949
974
  *
950
975
  * 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
951
- * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
976
+ * @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
977
+ * 为 true 时携带 state 参数(已保存到 sessionStorage),
978
+ * 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
952
979
  */
953
980
  async logout(redirectToSso = false) {
954
981
  const tokenData = getTokenData(this.config.clientId);
@@ -991,10 +1018,36 @@ var _SsoClient = class _SsoClient {
991
1018
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
992
1019
  }
993
1020
  const state = generateState();
1021
+ saveLogoutState(state, this.config.clientId);
994
1022
  logoutUrl.searchParams.set("state", state);
995
1023
  window.location.href = logoutUrl.toString();
996
1024
  }
997
1025
  }
1026
+ /**
1027
+ * 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
1028
+ *
1029
+ * 在 post_logout_redirect_uri 指向的页面加载时调用;
1030
+ * 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
1031
+ * 校验后清除已保存的 logout state(一次性)。
1032
+ *
1033
+ * @param url - 当前页面完整 URL(window.location.href)
1034
+ *
1035
+ * @example
1036
+ * ```typescript
1037
+ * if (sso.validateLogoutState(window.location.href)) {
1038
+ * // 来自 SSO 登出的可信回跳
1039
+ * }
1040
+ * ```
1041
+ */
1042
+ validateLogoutState(url) {
1043
+ const returnedState = new URL(url).searchParams.get("state");
1044
+ if (!returnedState) return false;
1045
+ const savedState = getLogoutState(this.config.clientId);
1046
+ if (!savedState) return false;
1047
+ if (!timingSafeEqualString(savedState, returnedState)) return false;
1048
+ removeLogoutState(this.config.clientId);
1049
+ return true;
1050
+ }
998
1051
  /**
999
1052
  * 获取 OIDC Discovery 文档
1000
1053
  *
@@ -1078,6 +1131,7 @@ function SsoProvider({
1078
1131
  const [user, setUser] = useState(null);
1079
1132
  const [isLoading, setIsLoading] = useState(true);
1080
1133
  const [isAuthenticated, setIsAuthenticated] = useState(false);
1134
+ const [error, setError] = useState(null);
1081
1135
  const [client] = useState(() => new SsoClient(config));
1082
1136
  const refreshTimerRef = useRef(null);
1083
1137
  const loadedRef = useRef(false);
@@ -1086,6 +1140,7 @@ function SsoProvider({
1086
1140
  if (!tokenData) {
1087
1141
  setUser(null);
1088
1142
  setIsAuthenticated(false);
1143
+ setError(null);
1089
1144
  setIsLoading(false);
1090
1145
  return;
1091
1146
  }
@@ -1093,9 +1148,13 @@ function SsoProvider({
1093
1148
  const u = await client.getUserInfo();
1094
1149
  setUser(u);
1095
1150
  setIsAuthenticated(true);
1096
- } catch {
1151
+ setError(null);
1152
+ } catch (err) {
1097
1153
  setUser(null);
1098
1154
  setIsAuthenticated(false);
1155
+ setError(
1156
+ err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
1157
+ );
1099
1158
  removeTokenData(client.config.clientId);
1100
1159
  } finally {
1101
1160
  setIsLoading(false);
@@ -1190,6 +1249,7 @@ function SsoProvider({
1190
1249
  await client.logout(redirectToSso);
1191
1250
  setUser(null);
1192
1251
  setIsAuthenticated(false);
1252
+ setError(null);
1193
1253
  },
1194
1254
  [client]
1195
1255
  );
@@ -1203,6 +1263,7 @@ function SsoProvider({
1203
1263
  user,
1204
1264
  isAuthenticated,
1205
1265
  isLoading,
1266
+ error,
1206
1267
  login,
1207
1268
  loginPopup,
1208
1269
  logout,
@@ -1221,34 +1282,67 @@ function useSso() {
1221
1282
  }
1222
1283
 
1223
1284
  // src/react/RequireAuth.tsx
1224
- import React2, { useEffect as useEffect2, useRef as useRef2 } from "react";
1285
+ import React2, { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
1225
1286
  function RequireAuth({
1226
1287
  children,
1227
1288
  fallback,
1228
1289
  autoLogin = true,
1229
- usePopup = false
1290
+ usePopup = false,
1291
+ onError,
1292
+ renderLoginError
1230
1293
  }) {
1231
1294
  const { isAuthenticated, isLoading, login, loginPopup } = useSso();
1232
1295
  const loginTriggeredRef = useRef2(false);
1296
+ const [loginError, setLoginError] = useState2(null);
1297
+ const triggerLogin = () => {
1298
+ loginTriggeredRef.current = true;
1299
+ const currentPath = window.location.pathname + window.location.search;
1300
+ const handleFailure = (err) => {
1301
+ loginTriggeredRef.current = false;
1302
+ setLoginError(err);
1303
+ onError?.(err);
1304
+ };
1305
+ if (usePopup) {
1306
+ loginPopup({ returnUrl: currentPath }).catch((err) => {
1307
+ if (err.code === "popup_blocked") {
1308
+ login(currentPath).catch(handleFailure);
1309
+ return;
1310
+ }
1311
+ handleFailure(err);
1312
+ });
1313
+ } else {
1314
+ login(currentPath).catch(handleFailure);
1315
+ }
1316
+ };
1317
+ const retry = () => {
1318
+ setLoginError(null);
1319
+ triggerLogin();
1320
+ };
1233
1321
  useEffect2(() => {
1234
1322
  if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
1235
- loginTriggeredRef.current = true;
1236
- const currentPath = window.location.pathname + window.location.search;
1237
- if (usePopup) {
1238
- loginPopup({ returnUrl: currentPath }).catch((err) => {
1239
- if (err.code === "popup_blocked") {
1240
- login(currentPath);
1241
- }
1242
- });
1243
- } else {
1244
- login(currentPath);
1245
- }
1323
+ triggerLogin();
1246
1324
  }
1247
1325
  }, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
1248
1326
  if (isLoading) {
1249
1327
  return fallback || React2.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
1250
1328
  }
1251
1329
  if (!isAuthenticated) {
1330
+ if (loginError) {
1331
+ if (renderLoginError) {
1332
+ return React2.createElement(
1333
+ React2.Fragment,
1334
+ null,
1335
+ renderLoginError(loginError, retry)
1336
+ );
1337
+ }
1338
+ const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
1339
+ return React2.createElement(
1340
+ "div",
1341
+ null,
1342
+ React2.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
1343
+ React2.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
1344
+ );
1345
+ }
1252
1346
  if (fallback) return React2.createElement(React2.Fragment, null, fallback);
1253
1347
  return React2.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");
1254
1348
  }
@@ -1268,11 +1362,42 @@ function withAuth(Component) {
1268
1362
  }
1269
1363
 
1270
1364
  // src/react/CallbackPage.tsx
1271
- import React3, { useEffect as useEffect3, useState as useState2 } from "react";
1272
- function CallbackPage() {
1365
+ import React3, { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
1366
+ function DefaultCallbackError({ error }) {
1367
+ return React3.createElement(
1368
+ "div",
1369
+ {
1370
+ style: {
1371
+ display: "flex",
1372
+ flexDirection: "column",
1373
+ alignItems: "center",
1374
+ justifyContent: "center",
1375
+ minHeight: "100vh",
1376
+ fontFamily: "system-ui, sans-serif"
1377
+ }
1378
+ },
1379
+ React3.createElement(
1380
+ "p",
1381
+ { style: { color: "#dc2626", marginBottom: "1rem" } },
1382
+ error
1383
+ ),
1384
+ React3.createElement(
1385
+ "a",
1386
+ { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1387
+ "\u8FD4\u56DE\u9996\u9875"
1388
+ )
1389
+ );
1390
+ }
1391
+ function CallbackPage({ onSuccess, onError, renderError } = {}) {
1273
1392
  const { client, refreshUser } = useSso();
1274
- const [error, setError] = useState2(null);
1275
- const [processing, setProcessing] = useState2(true);
1393
+ const [error, setError] = useState3(null);
1394
+ const [processing, setProcessing] = useState3(true);
1395
+ const onSuccessRef = useRef3(onSuccess);
1396
+ const onErrorRef = useRef3(onError);
1397
+ useEffect3(() => {
1398
+ onSuccessRef.current = onSuccess;
1399
+ onErrorRef.current = onError;
1400
+ });
1276
1401
  useEffect3(() => {
1277
1402
  if (window.opener && !window.opener.closed) {
1278
1403
  const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
@@ -1291,22 +1416,27 @@ function CallbackPage() {
1291
1416
  let cancelled = false;
1292
1417
  async function handleCallback() {
1293
1418
  try {
1294
- await client.handleCallback(window.location.href);
1419
+ const tokenData = await client.handleCallback(window.location.href);
1295
1420
  if (cancelled) return;
1296
1421
  await refreshUser();
1297
- const returnUrl = getReturnUrl();
1298
- removeReturnUrl();
1422
+ if (cancelled) return;
1423
+ const clientId = client.config.clientId;
1424
+ const returnUrl = getReturnUrl(clientId);
1425
+ removeReturnUrl(clientId);
1426
+ const onSuccessCb = onSuccessRef.current;
1427
+ if (onSuccessCb) {
1428
+ onSuccessCb(tokenData);
1429
+ return;
1430
+ }
1299
1431
  window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
1300
1432
  } catch (err) {
1301
1433
  if (cancelled) return;
1302
- if (err instanceof SsoError) {
1303
- setError(err.description || `SSO \u9519\u8BEF (${err.code})`);
1304
- } else if (err instanceof TypeError && err.message.includes("fetch")) {
1305
- setError("\u7F51\u7EDC\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5");
1306
- } else if (err instanceof Error) {
1307
- setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${err.message}`);
1434
+ const errorObj = err instanceof Error ? err : new Error(String(err));
1435
+ onErrorRef.current?.(errorObj);
1436
+ if (errorObj instanceof SsoError) {
1437
+ setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
1308
1438
  } else {
1309
- setError("\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5");
1439
+ setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
1310
1440
  }
1311
1441
  setProcessing(false);
1312
1442
  }
@@ -1317,29 +1447,8 @@ function CallbackPage() {
1317
1447
  };
1318
1448
  }, [client, refreshUser]);
1319
1449
  if (error) {
1320
- return React3.createElement(
1321
- "div",
1322
- {
1323
- style: {
1324
- display: "flex",
1325
- flexDirection: "column",
1326
- alignItems: "center",
1327
- justifyContent: "center",
1328
- minHeight: "100vh",
1329
- fontFamily: "system-ui, sans-serif"
1330
- }
1331
- },
1332
- React3.createElement(
1333
- "p",
1334
- { style: { color: "#dc2626", marginBottom: "1rem" } },
1335
- error
1336
- ),
1337
- React3.createElement(
1338
- "a",
1339
- { href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
1340
- "\u8FD4\u56DE\u9996\u9875"
1341
- )
1342
- );
1450
+ if (renderError) return React3.createElement(React3.Fragment, null, renderError(error));
1451
+ return React3.createElement(DefaultCallbackError, { error });
1343
1452
  }
1344
1453
  if (processing) {
1345
1454
  return React3.createElement(
@@ -1360,6 +1469,7 @@ function CallbackPage() {
1360
1469
  }
1361
1470
  export {
1362
1471
  CallbackPage,
1472
+ DefaultCallbackError,
1363
1473
  RequireAuth,
1364
1474
  SsoProvider,
1365
1475
  useSso,