@nihplod/sso-sdk 1.5.0 → 1.7.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.
@@ -31,6 +31,7 @@ __export(next_exports, {
31
31
  createBackchannelLogoutRouteHandler: () => createBackchannelLogoutRouteHandler,
32
32
  createCallbackRouteHandler: () => createCallbackRouteHandler,
33
33
  createLogoutRouteHandler: () => createLogoutRouteHandler,
34
+ createRefreshRouteHandler: () => createRefreshRouteHandler,
34
35
  createSsoMiddleware: () => createSsoMiddleware,
35
36
  getHostCookieOptions: () => getHostCookieOptions,
36
37
  getSecureCookieOptions: () => getSecureCookieOptions,
@@ -114,15 +115,19 @@ async function computeCodeChallenge(verifier) {
114
115
  function matchesPath(pathname, paths) {
115
116
  return paths.some((path) => {
116
117
  if (pathname === path) return true;
117
- if (path.endsWith("/:path*") && pathname.startsWith(path.replace("/:path*", ""))) return true;
118
- if (pathname.startsWith(path + "/")) return true;
119
- return false;
118
+ if (path.endsWith("/:path*")) {
119
+ const base = path.slice(0, -"/:path*".length);
120
+ return pathname === base || pathname.startsWith(base + "/");
121
+ }
122
+ return pathname.startsWith(path + "/");
120
123
  });
121
124
  }
122
125
  var introspectionCache = /* @__PURE__ */ new Map();
123
126
  var INTROSPECT_CACHE_TTL_MS = 3e4;
124
127
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
125
128
  var INTROSPECT_TIMEOUT_MS = 5e3;
129
+ var warnedIntrospectClientAuthFailure = false;
130
+ var warnedIntrospectRateLimited = false;
126
131
  async function introspectCacheKey(token, clientId) {
127
132
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
128
133
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -173,11 +178,25 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
173
178
  signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
174
179
  });
175
180
  if (!res.ok) {
181
+ if (res.status === 429) {
182
+ if (!warnedIntrospectRateLimited) {
183
+ warnedIntrospectRateLimited = true;
184
+ console.warn(
185
+ "[SSO SDK] introspection \u88AB\u9650\u6D41\uFF08HTTP 429\uFF09\uFF1A\u8BF7\u4E3A\u5B50\u7AD9 BFF \u914D\u7F6E\u72EC\u7ACB\u7684 client \u7EA7\u914D\u989D\uFF0C\u6216\u964D\u4F4E middleware introspection \u9891\u7387\u3002\u672C\u6B21\u6309 unreachable \u5904\u7406\uFF08\u9ED8\u8BA4 fail-open\uFF09\u3002"
186
+ );
187
+ }
188
+ return "unreachable";
189
+ }
176
190
  if (res.status !== 401 && res.status !== 403) {
177
191
  return "unreachable";
178
192
  }
179
- introspectCacheSet(cacheKey, false);
180
- return "inactive";
193
+ if (!warnedIntrospectClientAuthFailure) {
194
+ warnedIntrospectClientAuthFailure = true;
195
+ console.warn(
196
+ `[SSO SDK] introspection \u5BA2\u6237\u7AEF\u8BA4\u8BC1\u5931\u8D25\uFF08HTTP ${res.status}\uFF09\uFF1A\u8BF7\u68C0\u67E5 clientId/clientSecret \u4E0E\u4E3B\u7AD9\u6CE8\u518C\u4FE1\u606F\u662F\u5426\u4E00\u81F4\u3002\u8BE5\u6545\u969C\u4E0D\u4F1A\u88AB\u7F13\u5B58\u4E3A token \u5931\u6548\uFF0C\u4E5F\u4E0D\u4F1A\u89E6\u53D1\u9759\u9ED8\u91CD\u767B\u5F55\u5FAA\u73AF\u3002`
197
+ );
198
+ }
199
+ return "unreachable";
181
200
  }
182
201
  const data = await res.json();
183
202
  const active = data.active === true;
@@ -220,7 +239,7 @@ function createSsoMiddleware(config) {
220
239
  }
221
240
  return async function ssoMiddleware(request) {
222
241
  const { pathname } = request.nextUrl;
223
- if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
242
+ if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
224
243
  return import_server.NextResponse.next();
225
244
  }
226
245
  if (pathname === callbackPath) {
@@ -409,7 +428,19 @@ async function fetchJwks(baseUrl, options = {}) {
409
428
  return cachedJwks.jwks;
410
429
  }
411
430
  const discovery = await fetchDiscoveryDoc(baseUrl);
412
- const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
431
+ let jwksUri = `${baseUrl}/api/oauth/jwks`;
432
+ if (discovery?.jwks_uri) {
433
+ try {
434
+ if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
435
+ jwksUri = discovery.jwks_uri;
436
+ } else {
437
+ console.warn(
438
+ "[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
439
+ );
440
+ }
441
+ } catch {
442
+ }
443
+ }
413
444
  try {
414
445
  const res = await fetch(jwksUri, {
415
446
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -539,7 +570,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
539
570
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
540
571
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
541
572
  }
542
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
573
+ if (typeof payload.iat !== "number") {
574
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
575
+ }
576
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
543
577
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
544
578
  }
545
579
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -556,10 +590,27 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
556
590
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
557
591
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
558
592
  }
593
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
594
+ throw new SsoError(
595
+ "id_token_nonce_mismatch",
596
+ "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"
597
+ );
559
598
  }
560
599
  return { sub: payload.sub };
561
600
  }
562
601
 
602
+ // src/core/http.ts
603
+ var REQUEST_TIMEOUT_MS = 1e4;
604
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
605
+ const controller = new AbortController();
606
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
607
+ try {
608
+ return await fetch(input, { ...init, signal: controller.signal });
609
+ } finally {
610
+ clearTimeout(timer);
611
+ }
612
+ }
613
+
563
614
  // src/next/callback.ts
564
615
  function escapeHtml(value) {
565
616
  return value.replace(/[&<>"']/g, (ch) => {
@@ -660,8 +711,14 @@ function createCallbackRouteHandler(config) {
660
711
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
661
712
  const secureCookies = !insecureLocalDev;
662
713
  const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
663
- const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
664
- const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
714
+ const accessTokenCookieName = pickName(
715
+ config.accessTokenCookieName,
716
+ DEFAULT_ACCESS_TOKEN_COOKIE_NAME
717
+ );
718
+ const refreshTokenCookieName = pickName(
719
+ config.refreshTokenCookieName,
720
+ DEFAULT_REFRESH_TOKEN_COOKIE_NAME
721
+ );
665
722
  const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
666
723
  const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
667
724
  const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
@@ -695,13 +752,7 @@ function createCallbackRouteHandler(config) {
695
752
  const error = searchParams.get("error");
696
753
  if (error) {
697
754
  const desc = searchParams.get("error_description") || error;
698
- return buildErrorResponse(
699
- request,
700
- 400,
701
- "authorization_failed",
702
- desc,
703
- config.renderErrorPage
704
- );
755
+ return buildErrorResponse(request, 400, "authorization_failed", desc, config.renderErrorPage);
705
756
  }
706
757
  const code = searchParams.get("code");
707
758
  const returnedState = searchParams.get("state");
@@ -755,6 +806,20 @@ function createCallbackRouteHandler(config) {
755
806
  config.renderErrorPage
756
807
  );
757
808
  }
809
+ const returnedIss = searchParams.get("iss");
810
+ if (returnedIss) {
811
+ const discovery = await fetchDiscoveryDoc(normalizedBase);
812
+ const expectedIssuer = (discovery?.issuer || normalizedBase).replace(/\/+$/, "");
813
+ if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
814
+ return buildErrorResponse(
815
+ request,
816
+ 400,
817
+ "issuer_mismatch",
818
+ "\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
819
+ config.renderErrorPage
820
+ );
821
+ }
822
+ }
758
823
  const expectedNonce = readTransientCookie(nonceCookieName);
759
824
  const verifier = readTransientCookie(verifierCookieName);
760
825
  if (!verifier) {
@@ -778,31 +843,18 @@ function createCallbackRouteHandler(config) {
778
843
  }
779
844
  let res = null;
780
845
  let lastError;
781
- const maxRetries = 1;
782
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
783
- if (attempt > 0) {
784
- await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
785
- }
786
- try {
787
- res = await fetch(tokenEndpoint, {
846
+ try {
847
+ res = await fetchWithTimeout(
848
+ tokenEndpoint,
849
+ {
788
850
  method: "POST",
789
851
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
790
852
  body: body.toString()
791
- });
792
- lastError = null;
793
- break;
794
- } catch (err) {
795
- lastError = err;
796
- if (attempt >= maxRetries) {
797
- return buildErrorResponse(
798
- request,
799
- 502,
800
- "server_error",
801
- "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
802
- config.renderErrorPage
803
- );
804
- }
805
- }
853
+ },
854
+ 1e4
855
+ );
856
+ } catch (err) {
857
+ lastError = err;
806
858
  }
807
859
  if (lastError || !res) {
808
860
  return buildErrorResponse(
@@ -913,8 +965,169 @@ function createCallbackRouteHandler(config) {
913
965
  }
914
966
  }
915
967
 
916
- // src/next/logout.ts
968
+ // src/next/refresh.ts
917
969
  var import_server3 = require("next/server");
970
+ function noStoreJson(body, status = 200) {
971
+ return import_server3.NextResponse.json(body, {
972
+ status,
973
+ headers: { "Cache-Control": "no-store", Pragma: "no-cache" }
974
+ });
975
+ }
976
+ function clearAuthCookies(response, names, secureCookies) {
977
+ response.cookies.set(names.access, "", getHostCookieOptions(0, secureCookies));
978
+ response.cookies.set(names.refresh, "", getHostCookieOptions(0, secureCookies));
979
+ response.cookies.set(names.idToken, "", getHostCookieOptions(0, secureCookies));
980
+ return response;
981
+ }
982
+ function createRefreshRouteHandler(config) {
983
+ const {
984
+ clientId,
985
+ ssoBaseUrl,
986
+ redirectUri,
987
+ clientSecret,
988
+ insecureLocalDev: insecureLocalDevOpt = false,
989
+ debug = false
990
+ } = config;
991
+ const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
992
+ const secureCookies = !insecureLocalDev;
993
+ const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
994
+ const accessTokenCookieName = pickName(
995
+ config.accessTokenCookieName,
996
+ DEFAULT_ACCESS_TOKEN_COOKIE_NAME
997
+ );
998
+ const refreshTokenCookieName = pickName(
999
+ config.refreshTokenCookieName,
1000
+ DEFAULT_REFRESH_TOKEN_COOKIE_NAME
1001
+ );
1002
+ const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
1003
+ const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
1004
+ const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
1005
+ const callbackOrigin = new URL(redirectUri).origin;
1006
+ const cookieNames = {
1007
+ access: accessTokenCookieName,
1008
+ refresh: refreshTokenCookieName,
1009
+ idToken: idTokenCookieName
1010
+ };
1011
+ return async function POST(request) {
1012
+ const requestOrigin = request.headers.get("origin");
1013
+ if (requestOrigin && requestOrigin !== callbackOrigin) {
1014
+ return noStoreJson({ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" }, 403);
1015
+ }
1016
+ const secFetchSite = request.headers.get("sec-fetch-site");
1017
+ if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
1018
+ return noStoreJson({ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" }, 403);
1019
+ }
1020
+ const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
1021
+ if (!refreshToken) {
1022
+ return noStoreJson({ error: "no_refresh_token", error_description: "\u7F3A\u5C11 refresh_token" }, 401);
1023
+ }
1024
+ const body = new URLSearchParams({
1025
+ grant_type: "refresh_token",
1026
+ refresh_token: refreshToken,
1027
+ client_id: clientId
1028
+ });
1029
+ if (clientSecret) body.set("client_secret", clientSecret);
1030
+ let res = null;
1031
+ let lastError;
1032
+ try {
1033
+ res = await fetchWithTimeout(
1034
+ `${normalizedServerBase}/api/oauth/token`,
1035
+ {
1036
+ method: "POST",
1037
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1038
+ body: body.toString()
1039
+ },
1040
+ 1e4
1041
+ );
1042
+ } catch (err) {
1043
+ lastError = err;
1044
+ }
1045
+ if (lastError || !res) {
1046
+ if (debug) console.warn("[SSO SDK] refresh: token \u8BF7\u6C42\u5931\u8D25", lastError);
1047
+ return noStoreJson(
1048
+ { error: "server_error", error_description: "\u5237\u65B0\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528" },
1049
+ 502
1050
+ );
1051
+ }
1052
+ let data = {};
1053
+ try {
1054
+ data = await res.json();
1055
+ } catch {
1056
+ }
1057
+ if (!res.ok) {
1058
+ const error = typeof data.error === "string" ? data.error : "";
1059
+ const status = res.status === 429 ? 429 : res.status >= 500 ? 502 : 401;
1060
+ const response2 = noStoreJson(
1061
+ {
1062
+ error: error || "refresh_failed",
1063
+ error_description: data.error_description || "\u5237\u65B0\u767B\u5F55\u6001\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
1064
+ },
1065
+ status
1066
+ );
1067
+ if (res.status === 400 || res.status === 401) {
1068
+ return clearAuthCookies(response2, cookieNames, secureCookies);
1069
+ }
1070
+ return response2;
1071
+ }
1072
+ if (!data.access_token || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
1073
+ return clearAuthCookies(
1074
+ noStoreJson({ error: "server_error", error_description: "\u5237\u65B0\u54CD\u5E94\u4E0D\u5B8C\u6574" }, 502),
1075
+ cookieNames,
1076
+ secureCookies
1077
+ );
1078
+ }
1079
+ if (data.id_token) {
1080
+ try {
1081
+ await validateIdToken(
1082
+ data.id_token,
1083
+ data.access_token,
1084
+ normalizedBase,
1085
+ clientId
1086
+ );
1087
+ } catch (err) {
1088
+ if (debug) console.warn("[SSO SDK] refresh: ID Token \u6821\u9A8C\u5931\u8D25", err);
1089
+ return clearAuthCookies(
1090
+ noStoreJson({ error: "id_token_invalid", error_description: "\u767B\u5F55\u6821\u9A8C\u5931\u8D25" }, 401),
1091
+ cookieNames,
1092
+ secureCookies
1093
+ );
1094
+ }
1095
+ }
1096
+ const response = noStoreJson({ success: true, expires_in: data.expires_in });
1097
+ response.cookies.set(accessTokenCookieName, data.access_token, {
1098
+ ...getHostCookieOptions(data.expires_in, secureCookies)
1099
+ });
1100
+ response.cookies.set(refreshTokenCookieName, data.refresh_token || refreshToken, {
1101
+ ...getHostCookieOptions(
1102
+ typeof data.refresh_expires_in === "number" && data.refresh_expires_in > 0 ? data.refresh_expires_in : 30 * 24 * 60 * 60,
1103
+ secureCookies
1104
+ )
1105
+ });
1106
+ if (data.id_token) {
1107
+ let idTokenMaxAge = 3600;
1108
+ try {
1109
+ const payload = JSON.parse(
1110
+ Buffer.from(data.id_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(
1111
+ "utf8"
1112
+ )
1113
+ );
1114
+ if (typeof payload.exp === "number") {
1115
+ idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
1116
+ }
1117
+ } catch {
1118
+ }
1119
+ if (idTokenMaxAge > 0) {
1120
+ response.cookies.set(idTokenCookieName, data.id_token, {
1121
+ ...getHostCookieOptions(idTokenMaxAge, secureCookies)
1122
+ });
1123
+ }
1124
+ }
1125
+ return response;
1126
+ };
1127
+ }
1128
+
1129
+ // src/next/logout.ts
1130
+ var import_server4 = require("next/server");
918
1131
 
919
1132
  // src/core/discovery.ts
920
1133
  var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
@@ -985,6 +1198,14 @@ function generateRandomString2(length) {
985
1198
  }
986
1199
  return result;
987
1200
  }
1201
+ function trustedSameOriginEndpoint(endpoint, baseUrl) {
1202
+ if (!endpoint) return null;
1203
+ try {
1204
+ return new URL(endpoint).origin === new URL(baseUrl).origin ? endpoint : null;
1205
+ } catch {
1206
+ return null;
1207
+ }
1208
+ }
988
1209
  function createLogoutRouteHandler(config) {
989
1210
  const {
990
1211
  clientId,
@@ -1022,32 +1243,32 @@ function createLogoutRouteHandler(config) {
1022
1243
  const returnedState = request.nextUrl.searchParams.get("state");
1023
1244
  if (returnedState) {
1024
1245
  const savedState = request.cookies.get(logoutStateCookieName)?.value;
1025
- if (!savedState || savedState !== returnedState) {
1026
- return import_server3.NextResponse.json(
1246
+ if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
1247
+ return import_server4.NextResponse.json(
1027
1248
  { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
1028
1249
  { status: 400 }
1029
1250
  );
1030
1251
  }
1031
- const res = import_server3.NextResponse.redirect(callbackOrigin + "/");
1252
+ const res = import_server4.NextResponse.redirect(callbackOrigin + "/");
1032
1253
  res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
1033
1254
  return res;
1034
1255
  }
1035
1256
  if (request.method === "GET") {
1036
- return new import_server3.NextResponse(buildLogoutConfirmHtml(), {
1257
+ return new import_server4.NextResponse(buildLogoutConfirmHtml(), {
1037
1258
  status: 200,
1038
1259
  headers: { "content-type": "text/html; charset=utf-8" }
1039
1260
  });
1040
1261
  }
1041
1262
  const requestOrigin = request.headers.get("origin");
1042
1263
  if (requestOrigin && requestOrigin !== callbackOrigin) {
1043
- return import_server3.NextResponse.json(
1264
+ return import_server4.NextResponse.json(
1044
1265
  { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1045
1266
  { status: 403 }
1046
1267
  );
1047
1268
  }
1048
1269
  const secFetchSite = request.headers.get("sec-fetch-site");
1049
1270
  if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
1050
- return import_server3.NextResponse.json(
1271
+ return import_server4.NextResponse.json(
1051
1272
  { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1052
1273
  { status: 403 }
1053
1274
  );
@@ -1063,7 +1284,7 @@ function createLogoutRouteHandler(config) {
1063
1284
  if (refreshToken) {
1064
1285
  try {
1065
1286
  const discovery = config.serverBaseUrl ? null : await fetchDiscoveryCached(normalizedServerBase);
1066
- const revokeUrl = config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : discovery?.revocation_endpoint || `${normalizedBase}/api/oauth/revoke`;
1287
+ const revokeUrl = (config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : trustedSameOriginEndpoint(discovery?.revocation_endpoint, normalizedBase)) || `${normalizedBase}/api/oauth/revoke`;
1067
1288
  const body = new URLSearchParams({
1068
1289
  token: refreshToken,
1069
1290
  token_type_hint: "refresh_token",
@@ -1072,12 +1293,20 @@ function createLogoutRouteHandler(config) {
1072
1293
  if (clientSecret) {
1073
1294
  body.set("client_secret", clientSecret);
1074
1295
  }
1075
- await fetch(revokeUrl, {
1296
+ const doRevoke = () => fetch(revokeUrl, {
1076
1297
  method: "POST",
1077
1298
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1078
1299
  body: body.toString(),
1079
1300
  signal: AbortSignal.timeout(3e3)
1080
1301
  });
1302
+ let revokeRes = await doRevoke();
1303
+ if (!revokeRes.ok && (revokeRes.status >= 500 || revokeRes.status === 429)) {
1304
+ await new Promise((resolve) => setTimeout(resolve, 300));
1305
+ revokeRes = await doRevoke();
1306
+ }
1307
+ if (!revokeRes.ok) {
1308
+ console.warn(`[SSO SDK] \u767B\u51FA\u64A4\u9500 refresh_token \u5931\u8D25\uFF08HTTP ${revokeRes.status}\uFF09`);
1309
+ }
1081
1310
  } catch {
1082
1311
  }
1083
1312
  }
@@ -1109,7 +1338,7 @@ function createLogoutRouteHandler(config) {
1109
1338
  };
1110
1339
  if (effectiveScope === "global") {
1111
1340
  const discovery = await fetchDiscoveryCached(normalizedServerBase);
1112
- const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
1341
+ const endSessionEndpoint = trustedSameOriginEndpoint(discovery?.end_session_endpoint, normalizedBase) || `${normalizedBase}/api/oauth/end-session`;
1113
1342
  const logoutUrl = new URL(endSessionEndpoint);
1114
1343
  logoutUrl.searchParams.set("client_id", clientId);
1115
1344
  logoutUrl.searchParams.set(
@@ -1121,25 +1350,36 @@ function createLogoutRouteHandler(config) {
1121
1350
  }
1122
1351
  const logoutState = generateRandomString2(32);
1123
1352
  logoutUrl.searchParams.set("state", logoutState);
1124
- const res = clearCookies(import_server3.NextResponse.redirect(logoutUrl.toString()));
1353
+ const res = clearCookies(import_server4.NextResponse.redirect(logoutUrl.toString()));
1125
1354
  res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
1126
1355
  return res;
1127
1356
  }
1128
1357
  return clearCookies(
1129
- import_server3.NextResponse.redirect(callbackOrigin + "/")
1358
+ import_server4.NextResponse.redirect(callbackOrigin + "/")
1130
1359
  );
1131
1360
  };
1132
1361
  }
1133
1362
 
1134
1363
  // src/next/backchannel-logout.ts
1135
- var import_server4 = require("next/server");
1364
+ var import_server5 = require("next/server");
1136
1365
 
1137
1366
  // src/core/logout-token.ts
1138
1367
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
1139
1368
  var JTI_CACHE_CAPACITY = 1e3;
1140
1369
  var seenJti = /* @__PURE__ */ new Map();
1141
- function releaseLogoutTokenJti(jti) {
1142
- seenJti.delete(jti);
1370
+ async function releaseLogoutTokenJti(replayKey, options = {}) {
1371
+ const store = options.jtiStore;
1372
+ if (store?.remove) {
1373
+ await store.remove(replayKey);
1374
+ return;
1375
+ }
1376
+ if (store) {
1377
+ console.warn(
1378
+ "[SSO SDK] \u81EA\u5B9A\u4E49 jtiStore \u672A\u5B9E\u73B0 remove()\uFF1AonLogout \u5931\u8D25\u540E\u65E0\u6CD5\u91CA\u653E jti\uFF0CIdP \u7528\u540C\u4E00 logout_token \u91CD\u6295\u5C06\u88AB\u91CD\u653E\u68C0\u67E5\u62D2\u7EDD\uFF08logout_token_replay\uFF09"
1379
+ );
1380
+ return;
1381
+ }
1382
+ seenJti.delete(replayKey);
1143
1383
  }
1144
1384
  function recordJti(jti, expiresAtMs) {
1145
1385
  const now = Date.now();
@@ -1156,7 +1396,7 @@ function recordJti(jti, expiresAtMs) {
1156
1396
  seenJti.set(jti, expiresAtMs);
1157
1397
  return true;
1158
1398
  }
1159
- async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1399
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
1160
1400
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
1161
1401
  const header = decodeJwtHeader(logoutToken);
1162
1402
  if (!header) {
@@ -1233,6 +1473,12 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1233
1473
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
1234
1474
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1235
1475
  }
1476
+ if (typeof payload.iat !== "number") {
1477
+ throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 iat \u58F0\u660E");
1478
+ }
1479
+ if (payload.nonce !== void 0) {
1480
+ throw new SsoError("logout_token_invalid", "Logout Token \u4E0D\u5F97\u643A\u5E26 nonce");
1481
+ }
1236
1482
  if (payload.type !== "logout_token") {
1237
1483
  throw new SsoError(
1238
1484
  "logout_token_invalid",
@@ -1265,10 +1511,27 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1265
1511
  if (!jti) {
1266
1512
  throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
1267
1513
  }
1268
- if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1514
+ const replayKey = `${normalizedIssuer}:${jti}`;
1515
+ const replayTtlSeconds = Math.max(
1516
+ 1,
1517
+ Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
1518
+ );
1519
+ const jtiStore = options.jtiStore;
1520
+ if (jtiStore) {
1521
+ if (jtiStore.addIfAbsent) {
1522
+ if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
1523
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1524
+ }
1525
+ } else {
1526
+ if (await jtiStore.has(replayKey)) {
1527
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1528
+ }
1529
+ await jtiStore.add(replayKey, replayTtlSeconds);
1530
+ }
1531
+ } else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
1269
1532
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1270
1533
  }
1271
- return { payload: { sub, sid }, jti };
1534
+ return { payload: { sub, sid }, jti, replayKey };
1272
1535
  }
1273
1536
 
1274
1537
  // src/next/backchannel-logout.ts
@@ -1294,7 +1557,7 @@ function createBackchannelLogoutRouteHandler(config) {
1294
1557
  );
1295
1558
  return async function handler(request) {
1296
1559
  if (request.method !== "POST") {
1297
- return import_server4.NextResponse.json(
1560
+ return import_server5.NextResponse.json(
1298
1561
  { error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
1299
1562
  { status: 405 }
1300
1563
  );
@@ -1303,18 +1566,20 @@ function createBackchannelLogoutRouteHandler(config) {
1303
1566
  "logout_token"
1304
1567
  );
1305
1568
  if (!logoutToken) {
1306
- return import_server4.NextResponse.json(
1569
+ return import_server5.NextResponse.json(
1307
1570
  { error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
1308
1571
  { status: 400 }
1309
1572
  );
1310
1573
  }
1311
1574
  let verified;
1312
1575
  try {
1313
- verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
1576
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
1577
+ jtiStore: config.jtiStore
1578
+ });
1314
1579
  } catch (err) {
1315
1580
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1316
1581
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
1317
- return import_server4.NextResponse.json(
1582
+ return import_server5.NextResponse.json(
1318
1583
  { error: code, error_description: description },
1319
1584
  { status: 400 }
1320
1585
  );
@@ -1322,17 +1587,17 @@ function createBackchannelLogoutRouteHandler(config) {
1322
1587
  try {
1323
1588
  await onLogout?.(verified.payload, request);
1324
1589
  } catch (err) {
1325
- releaseLogoutTokenJti(verified.jti);
1590
+ await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
1326
1591
  console.error(
1327
1592
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1328
1593
  err
1329
1594
  );
1330
- return import_server4.NextResponse.json(
1595
+ return import_server5.NextResponse.json(
1331
1596
  { error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
1332
1597
  { status: 500 }
1333
1598
  );
1334
1599
  }
1335
- const res = new import_server4.NextResponse(null, { status: 200 });
1600
+ const res = new import_server5.NextResponse(null, { status: 200 });
1336
1601
  res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1337
1602
  res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1338
1603
  res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
@@ -1352,6 +1617,7 @@ function createBackchannelLogoutRouteHandler(config) {
1352
1617
  createBackchannelLogoutRouteHandler,
1353
1618
  createCallbackRouteHandler,
1354
1619
  createLogoutRouteHandler,
1620
+ createRefreshRouteHandler,
1355
1621
  createSsoMiddleware,
1356
1622
  getHostCookieOptions,
1357
1623
  getSecureCookieOptions,