@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.
@@ -74,15 +74,19 @@ async function computeCodeChallenge(verifier) {
74
74
  function matchesPath(pathname, paths) {
75
75
  return paths.some((path) => {
76
76
  if (pathname === path) return true;
77
- if (path.endsWith("/:path*") && pathname.startsWith(path.replace("/:path*", ""))) return true;
78
- if (pathname.startsWith(path + "/")) return true;
79
- return false;
77
+ if (path.endsWith("/:path*")) {
78
+ const base = path.slice(0, -"/:path*".length);
79
+ return pathname === base || pathname.startsWith(base + "/");
80
+ }
81
+ return pathname.startsWith(path + "/");
80
82
  });
81
83
  }
82
84
  var introspectionCache = /* @__PURE__ */ new Map();
83
85
  var INTROSPECT_CACHE_TTL_MS = 3e4;
84
86
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
85
87
  var INTROSPECT_TIMEOUT_MS = 5e3;
88
+ var warnedIntrospectClientAuthFailure = false;
89
+ var warnedIntrospectRateLimited = false;
86
90
  async function introspectCacheKey(token, clientId) {
87
91
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
88
92
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -133,11 +137,25 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
133
137
  signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
134
138
  });
135
139
  if (!res.ok) {
140
+ if (res.status === 429) {
141
+ if (!warnedIntrospectRateLimited) {
142
+ warnedIntrospectRateLimited = true;
143
+ console.warn(
144
+ "[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"
145
+ );
146
+ }
147
+ return "unreachable";
148
+ }
136
149
  if (res.status !== 401 && res.status !== 403) {
137
150
  return "unreachable";
138
151
  }
139
- introspectCacheSet(cacheKey, false);
140
- return "inactive";
152
+ if (!warnedIntrospectClientAuthFailure) {
153
+ warnedIntrospectClientAuthFailure = true;
154
+ console.warn(
155
+ `[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`
156
+ );
157
+ }
158
+ return "unreachable";
141
159
  }
142
160
  const data = await res.json();
143
161
  const active = data.active === true;
@@ -180,7 +198,7 @@ function createSsoMiddleware(config) {
180
198
  }
181
199
  return async function ssoMiddleware(request) {
182
200
  const { pathname } = request.nextUrl;
183
- if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
201
+ if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
184
202
  return NextResponse.next();
185
203
  }
186
204
  if (pathname === callbackPath) {
@@ -369,7 +387,19 @@ async function fetchJwks(baseUrl, options = {}) {
369
387
  return cachedJwks.jwks;
370
388
  }
371
389
  const discovery = await fetchDiscoveryDoc(baseUrl);
372
- const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
390
+ let jwksUri = `${baseUrl}/api/oauth/jwks`;
391
+ if (discovery?.jwks_uri) {
392
+ try {
393
+ if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
394
+ jwksUri = discovery.jwks_uri;
395
+ } else {
396
+ console.warn(
397
+ "[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
398
+ );
399
+ }
400
+ } catch {
401
+ }
402
+ }
373
403
  try {
374
404
  const res = await fetch(jwksUri, {
375
405
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -499,7 +529,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
499
529
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
500
530
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
501
531
  }
502
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
532
+ if (typeof payload.iat !== "number") {
533
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
534
+ }
535
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
503
536
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
504
537
  }
505
538
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -516,10 +549,27 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
516
549
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
517
550
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
518
551
  }
552
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
553
+ throw new SsoError(
554
+ "id_token_nonce_mismatch",
555
+ "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"
556
+ );
519
557
  }
520
558
  return { sub: payload.sub };
521
559
  }
522
560
 
561
+ // src/core/http.ts
562
+ var REQUEST_TIMEOUT_MS = 1e4;
563
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
564
+ const controller = new AbortController();
565
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
566
+ try {
567
+ return await fetch(input, { ...init, signal: controller.signal });
568
+ } finally {
569
+ clearTimeout(timer);
570
+ }
571
+ }
572
+
523
573
  // src/next/callback.ts
524
574
  function escapeHtml(value) {
525
575
  return value.replace(/[&<>"']/g, (ch) => {
@@ -620,8 +670,14 @@ function createCallbackRouteHandler(config) {
620
670
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
621
671
  const secureCookies = !insecureLocalDev;
622
672
  const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
623
- const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
624
- const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
673
+ const accessTokenCookieName = pickName(
674
+ config.accessTokenCookieName,
675
+ DEFAULT_ACCESS_TOKEN_COOKIE_NAME
676
+ );
677
+ const refreshTokenCookieName = pickName(
678
+ config.refreshTokenCookieName,
679
+ DEFAULT_REFRESH_TOKEN_COOKIE_NAME
680
+ );
625
681
  const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
626
682
  const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
627
683
  const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
@@ -655,13 +711,7 @@ function createCallbackRouteHandler(config) {
655
711
  const error = searchParams.get("error");
656
712
  if (error) {
657
713
  const desc = searchParams.get("error_description") || error;
658
- return buildErrorResponse(
659
- request,
660
- 400,
661
- "authorization_failed",
662
- desc,
663
- config.renderErrorPage
664
- );
714
+ return buildErrorResponse(request, 400, "authorization_failed", desc, config.renderErrorPage);
665
715
  }
666
716
  const code = searchParams.get("code");
667
717
  const returnedState = searchParams.get("state");
@@ -715,6 +765,20 @@ function createCallbackRouteHandler(config) {
715
765
  config.renderErrorPage
716
766
  );
717
767
  }
768
+ const returnedIss = searchParams.get("iss");
769
+ if (returnedIss) {
770
+ const discovery = await fetchDiscoveryDoc(normalizedBase);
771
+ const expectedIssuer = (discovery?.issuer || normalizedBase).replace(/\/+$/, "");
772
+ if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
773
+ return buildErrorResponse(
774
+ request,
775
+ 400,
776
+ "issuer_mismatch",
777
+ "\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
778
+ config.renderErrorPage
779
+ );
780
+ }
781
+ }
718
782
  const expectedNonce = readTransientCookie(nonceCookieName);
719
783
  const verifier = readTransientCookie(verifierCookieName);
720
784
  if (!verifier) {
@@ -738,31 +802,18 @@ function createCallbackRouteHandler(config) {
738
802
  }
739
803
  let res = null;
740
804
  let lastError;
741
- const maxRetries = 1;
742
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
743
- if (attempt > 0) {
744
- await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
745
- }
746
- try {
747
- res = await fetch(tokenEndpoint, {
805
+ try {
806
+ res = await fetchWithTimeout(
807
+ tokenEndpoint,
808
+ {
748
809
  method: "POST",
749
810
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
750
811
  body: body.toString()
751
- });
752
- lastError = null;
753
- break;
754
- } catch (err) {
755
- lastError = err;
756
- if (attempt >= maxRetries) {
757
- return buildErrorResponse(
758
- request,
759
- 502,
760
- "server_error",
761
- "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
762
- config.renderErrorPage
763
- );
764
- }
765
- }
812
+ },
813
+ 1e4
814
+ );
815
+ } catch (err) {
816
+ lastError = err;
766
817
  }
767
818
  if (lastError || !res) {
768
819
  return buildErrorResponse(
@@ -873,8 +924,169 @@ function createCallbackRouteHandler(config) {
873
924
  }
874
925
  }
875
926
 
876
- // src/next/logout.ts
927
+ // src/next/refresh.ts
877
928
  import { NextResponse as NextResponse3 } from "next/server";
929
+ function noStoreJson(body, status = 200) {
930
+ return NextResponse3.json(body, {
931
+ status,
932
+ headers: { "Cache-Control": "no-store", Pragma: "no-cache" }
933
+ });
934
+ }
935
+ function clearAuthCookies(response, names, secureCookies) {
936
+ response.cookies.set(names.access, "", getHostCookieOptions(0, secureCookies));
937
+ response.cookies.set(names.refresh, "", getHostCookieOptions(0, secureCookies));
938
+ response.cookies.set(names.idToken, "", getHostCookieOptions(0, secureCookies));
939
+ return response;
940
+ }
941
+ function createRefreshRouteHandler(config) {
942
+ const {
943
+ clientId,
944
+ ssoBaseUrl,
945
+ redirectUri,
946
+ clientSecret,
947
+ insecureLocalDev: insecureLocalDevOpt = false,
948
+ debug = false
949
+ } = config;
950
+ const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
951
+ const secureCookies = !insecureLocalDev;
952
+ const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
953
+ const accessTokenCookieName = pickName(
954
+ config.accessTokenCookieName,
955
+ DEFAULT_ACCESS_TOKEN_COOKIE_NAME
956
+ );
957
+ const refreshTokenCookieName = pickName(
958
+ config.refreshTokenCookieName,
959
+ DEFAULT_REFRESH_TOKEN_COOKIE_NAME
960
+ );
961
+ const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
962
+ const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
963
+ const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
964
+ const callbackOrigin = new URL(redirectUri).origin;
965
+ const cookieNames = {
966
+ access: accessTokenCookieName,
967
+ refresh: refreshTokenCookieName,
968
+ idToken: idTokenCookieName
969
+ };
970
+ return async function POST(request) {
971
+ const requestOrigin = request.headers.get("origin");
972
+ if (requestOrigin && requestOrigin !== callbackOrigin) {
973
+ return noStoreJson({ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" }, 403);
974
+ }
975
+ const secFetchSite = request.headers.get("sec-fetch-site");
976
+ if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
977
+ return noStoreJson({ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" }, 403);
978
+ }
979
+ const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
980
+ if (!refreshToken) {
981
+ return noStoreJson({ error: "no_refresh_token", error_description: "\u7F3A\u5C11 refresh_token" }, 401);
982
+ }
983
+ const body = new URLSearchParams({
984
+ grant_type: "refresh_token",
985
+ refresh_token: refreshToken,
986
+ client_id: clientId
987
+ });
988
+ if (clientSecret) body.set("client_secret", clientSecret);
989
+ let res = null;
990
+ let lastError;
991
+ try {
992
+ res = await fetchWithTimeout(
993
+ `${normalizedServerBase}/api/oauth/token`,
994
+ {
995
+ method: "POST",
996
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
997
+ body: body.toString()
998
+ },
999
+ 1e4
1000
+ );
1001
+ } catch (err) {
1002
+ lastError = err;
1003
+ }
1004
+ if (lastError || !res) {
1005
+ if (debug) console.warn("[SSO SDK] refresh: token \u8BF7\u6C42\u5931\u8D25", lastError);
1006
+ return noStoreJson(
1007
+ { error: "server_error", error_description: "\u5237\u65B0\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528" },
1008
+ 502
1009
+ );
1010
+ }
1011
+ let data = {};
1012
+ try {
1013
+ data = await res.json();
1014
+ } catch {
1015
+ }
1016
+ if (!res.ok) {
1017
+ const error = typeof data.error === "string" ? data.error : "";
1018
+ const status = res.status === 429 ? 429 : res.status >= 500 ? 502 : 401;
1019
+ const response2 = noStoreJson(
1020
+ {
1021
+ error: error || "refresh_failed",
1022
+ error_description: data.error_description || "\u5237\u65B0\u767B\u5F55\u6001\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
1023
+ },
1024
+ status
1025
+ );
1026
+ if (res.status === 400 || res.status === 401) {
1027
+ return clearAuthCookies(response2, cookieNames, secureCookies);
1028
+ }
1029
+ return response2;
1030
+ }
1031
+ if (!data.access_token || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
1032
+ return clearAuthCookies(
1033
+ noStoreJson({ error: "server_error", error_description: "\u5237\u65B0\u54CD\u5E94\u4E0D\u5B8C\u6574" }, 502),
1034
+ cookieNames,
1035
+ secureCookies
1036
+ );
1037
+ }
1038
+ if (data.id_token) {
1039
+ try {
1040
+ await validateIdToken(
1041
+ data.id_token,
1042
+ data.access_token,
1043
+ normalizedBase,
1044
+ clientId
1045
+ );
1046
+ } catch (err) {
1047
+ if (debug) console.warn("[SSO SDK] refresh: ID Token \u6821\u9A8C\u5931\u8D25", err);
1048
+ return clearAuthCookies(
1049
+ noStoreJson({ error: "id_token_invalid", error_description: "\u767B\u5F55\u6821\u9A8C\u5931\u8D25" }, 401),
1050
+ cookieNames,
1051
+ secureCookies
1052
+ );
1053
+ }
1054
+ }
1055
+ const response = noStoreJson({ success: true, expires_in: data.expires_in });
1056
+ response.cookies.set(accessTokenCookieName, data.access_token, {
1057
+ ...getHostCookieOptions(data.expires_in, secureCookies)
1058
+ });
1059
+ response.cookies.set(refreshTokenCookieName, data.refresh_token || refreshToken, {
1060
+ ...getHostCookieOptions(
1061
+ typeof data.refresh_expires_in === "number" && data.refresh_expires_in > 0 ? data.refresh_expires_in : 30 * 24 * 60 * 60,
1062
+ secureCookies
1063
+ )
1064
+ });
1065
+ if (data.id_token) {
1066
+ let idTokenMaxAge = 3600;
1067
+ try {
1068
+ const payload = JSON.parse(
1069
+ Buffer.from(data.id_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(
1070
+ "utf8"
1071
+ )
1072
+ );
1073
+ if (typeof payload.exp === "number") {
1074
+ idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
1075
+ }
1076
+ } catch {
1077
+ }
1078
+ if (idTokenMaxAge > 0) {
1079
+ response.cookies.set(idTokenCookieName, data.id_token, {
1080
+ ...getHostCookieOptions(idTokenMaxAge, secureCookies)
1081
+ });
1082
+ }
1083
+ }
1084
+ return response;
1085
+ };
1086
+ }
1087
+
1088
+ // src/next/logout.ts
1089
+ import { NextResponse as NextResponse4 } from "next/server";
878
1090
 
879
1091
  // src/core/discovery.ts
880
1092
  var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
@@ -945,6 +1157,14 @@ function generateRandomString2(length) {
945
1157
  }
946
1158
  return result;
947
1159
  }
1160
+ function trustedSameOriginEndpoint(endpoint, baseUrl) {
1161
+ if (!endpoint) return null;
1162
+ try {
1163
+ return new URL(endpoint).origin === new URL(baseUrl).origin ? endpoint : null;
1164
+ } catch {
1165
+ return null;
1166
+ }
1167
+ }
948
1168
  function createLogoutRouteHandler(config) {
949
1169
  const {
950
1170
  clientId,
@@ -982,32 +1202,32 @@ function createLogoutRouteHandler(config) {
982
1202
  const returnedState = request.nextUrl.searchParams.get("state");
983
1203
  if (returnedState) {
984
1204
  const savedState = request.cookies.get(logoutStateCookieName)?.value;
985
- if (!savedState || savedState !== returnedState) {
986
- return NextResponse3.json(
1205
+ if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
1206
+ return NextResponse4.json(
987
1207
  { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
988
1208
  { status: 400 }
989
1209
  );
990
1210
  }
991
- const res = NextResponse3.redirect(callbackOrigin + "/");
1211
+ const res = NextResponse4.redirect(callbackOrigin + "/");
992
1212
  res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
993
1213
  return res;
994
1214
  }
995
1215
  if (request.method === "GET") {
996
- return new NextResponse3(buildLogoutConfirmHtml(), {
1216
+ return new NextResponse4(buildLogoutConfirmHtml(), {
997
1217
  status: 200,
998
1218
  headers: { "content-type": "text/html; charset=utf-8" }
999
1219
  });
1000
1220
  }
1001
1221
  const requestOrigin = request.headers.get("origin");
1002
1222
  if (requestOrigin && requestOrigin !== callbackOrigin) {
1003
- return NextResponse3.json(
1223
+ return NextResponse4.json(
1004
1224
  { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1005
1225
  { status: 403 }
1006
1226
  );
1007
1227
  }
1008
1228
  const secFetchSite = request.headers.get("sec-fetch-site");
1009
1229
  if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
1010
- return NextResponse3.json(
1230
+ return NextResponse4.json(
1011
1231
  { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1012
1232
  { status: 403 }
1013
1233
  );
@@ -1023,7 +1243,7 @@ function createLogoutRouteHandler(config) {
1023
1243
  if (refreshToken) {
1024
1244
  try {
1025
1245
  const discovery = config.serverBaseUrl ? null : await fetchDiscoveryCached(normalizedServerBase);
1026
- const revokeUrl = config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : discovery?.revocation_endpoint || `${normalizedBase}/api/oauth/revoke`;
1246
+ const revokeUrl = (config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : trustedSameOriginEndpoint(discovery?.revocation_endpoint, normalizedBase)) || `${normalizedBase}/api/oauth/revoke`;
1027
1247
  const body = new URLSearchParams({
1028
1248
  token: refreshToken,
1029
1249
  token_type_hint: "refresh_token",
@@ -1032,12 +1252,20 @@ function createLogoutRouteHandler(config) {
1032
1252
  if (clientSecret) {
1033
1253
  body.set("client_secret", clientSecret);
1034
1254
  }
1035
- await fetch(revokeUrl, {
1255
+ const doRevoke = () => fetch(revokeUrl, {
1036
1256
  method: "POST",
1037
1257
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1038
1258
  body: body.toString(),
1039
1259
  signal: AbortSignal.timeout(3e3)
1040
1260
  });
1261
+ let revokeRes = await doRevoke();
1262
+ if (!revokeRes.ok && (revokeRes.status >= 500 || revokeRes.status === 429)) {
1263
+ await new Promise((resolve) => setTimeout(resolve, 300));
1264
+ revokeRes = await doRevoke();
1265
+ }
1266
+ if (!revokeRes.ok) {
1267
+ console.warn(`[SSO SDK] \u767B\u51FA\u64A4\u9500 refresh_token \u5931\u8D25\uFF08HTTP ${revokeRes.status}\uFF09`);
1268
+ }
1041
1269
  } catch {
1042
1270
  }
1043
1271
  }
@@ -1069,7 +1297,7 @@ function createLogoutRouteHandler(config) {
1069
1297
  };
1070
1298
  if (effectiveScope === "global") {
1071
1299
  const discovery = await fetchDiscoveryCached(normalizedServerBase);
1072
- const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
1300
+ const endSessionEndpoint = trustedSameOriginEndpoint(discovery?.end_session_endpoint, normalizedBase) || `${normalizedBase}/api/oauth/end-session`;
1073
1301
  const logoutUrl = new URL(endSessionEndpoint);
1074
1302
  logoutUrl.searchParams.set("client_id", clientId);
1075
1303
  logoutUrl.searchParams.set(
@@ -1081,25 +1309,36 @@ function createLogoutRouteHandler(config) {
1081
1309
  }
1082
1310
  const logoutState = generateRandomString2(32);
1083
1311
  logoutUrl.searchParams.set("state", logoutState);
1084
- const res = clearCookies(NextResponse3.redirect(logoutUrl.toString()));
1312
+ const res = clearCookies(NextResponse4.redirect(logoutUrl.toString()));
1085
1313
  res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
1086
1314
  return res;
1087
1315
  }
1088
1316
  return clearCookies(
1089
- NextResponse3.redirect(callbackOrigin + "/")
1317
+ NextResponse4.redirect(callbackOrigin + "/")
1090
1318
  );
1091
1319
  };
1092
1320
  }
1093
1321
 
1094
1322
  // src/next/backchannel-logout.ts
1095
- import { NextResponse as NextResponse4 } from "next/server";
1323
+ import { NextResponse as NextResponse5 } from "next/server";
1096
1324
 
1097
1325
  // src/core/logout-token.ts
1098
1326
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
1099
1327
  var JTI_CACHE_CAPACITY = 1e3;
1100
1328
  var seenJti = /* @__PURE__ */ new Map();
1101
- function releaseLogoutTokenJti(jti) {
1102
- seenJti.delete(jti);
1329
+ async function releaseLogoutTokenJti(replayKey, options = {}) {
1330
+ const store = options.jtiStore;
1331
+ if (store?.remove) {
1332
+ await store.remove(replayKey);
1333
+ return;
1334
+ }
1335
+ if (store) {
1336
+ console.warn(
1337
+ "[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"
1338
+ );
1339
+ return;
1340
+ }
1341
+ seenJti.delete(replayKey);
1103
1342
  }
1104
1343
  function recordJti(jti, expiresAtMs) {
1105
1344
  const now = Date.now();
@@ -1116,7 +1355,7 @@ function recordJti(jti, expiresAtMs) {
1116
1355
  seenJti.set(jti, expiresAtMs);
1117
1356
  return true;
1118
1357
  }
1119
- async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1358
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
1120
1359
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
1121
1360
  const header = decodeJwtHeader(logoutToken);
1122
1361
  if (!header) {
@@ -1193,6 +1432,12 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1193
1432
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
1194
1433
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1195
1434
  }
1435
+ if (typeof payload.iat !== "number") {
1436
+ throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 iat \u58F0\u660E");
1437
+ }
1438
+ if (payload.nonce !== void 0) {
1439
+ throw new SsoError("logout_token_invalid", "Logout Token \u4E0D\u5F97\u643A\u5E26 nonce");
1440
+ }
1196
1441
  if (payload.type !== "logout_token") {
1197
1442
  throw new SsoError(
1198
1443
  "logout_token_invalid",
@@ -1225,10 +1470,27 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1225
1470
  if (!jti) {
1226
1471
  throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
1227
1472
  }
1228
- if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1473
+ const replayKey = `${normalizedIssuer}:${jti}`;
1474
+ const replayTtlSeconds = Math.max(
1475
+ 1,
1476
+ Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
1477
+ );
1478
+ const jtiStore = options.jtiStore;
1479
+ if (jtiStore) {
1480
+ if (jtiStore.addIfAbsent) {
1481
+ if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
1482
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1483
+ }
1484
+ } else {
1485
+ if (await jtiStore.has(replayKey)) {
1486
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1487
+ }
1488
+ await jtiStore.add(replayKey, replayTtlSeconds);
1489
+ }
1490
+ } else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
1229
1491
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1230
1492
  }
1231
- return { payload: { sub, sid }, jti };
1493
+ return { payload: { sub, sid }, jti, replayKey };
1232
1494
  }
1233
1495
 
1234
1496
  // src/next/backchannel-logout.ts
@@ -1254,7 +1516,7 @@ function createBackchannelLogoutRouteHandler(config) {
1254
1516
  );
1255
1517
  return async function handler(request) {
1256
1518
  if (request.method !== "POST") {
1257
- return NextResponse4.json(
1519
+ return NextResponse5.json(
1258
1520
  { error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
1259
1521
  { status: 405 }
1260
1522
  );
@@ -1263,18 +1525,20 @@ function createBackchannelLogoutRouteHandler(config) {
1263
1525
  "logout_token"
1264
1526
  );
1265
1527
  if (!logoutToken) {
1266
- return NextResponse4.json(
1528
+ return NextResponse5.json(
1267
1529
  { error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
1268
1530
  { status: 400 }
1269
1531
  );
1270
1532
  }
1271
1533
  let verified;
1272
1534
  try {
1273
- verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
1535
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
1536
+ jtiStore: config.jtiStore
1537
+ });
1274
1538
  } catch (err) {
1275
1539
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1276
1540
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
1277
- return NextResponse4.json(
1541
+ return NextResponse5.json(
1278
1542
  { error: code, error_description: description },
1279
1543
  { status: 400 }
1280
1544
  );
@@ -1282,17 +1546,17 @@ function createBackchannelLogoutRouteHandler(config) {
1282
1546
  try {
1283
1547
  await onLogout?.(verified.payload, request);
1284
1548
  } catch (err) {
1285
- releaseLogoutTokenJti(verified.jti);
1549
+ await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
1286
1550
  console.error(
1287
1551
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1288
1552
  err
1289
1553
  );
1290
- return NextResponse4.json(
1554
+ return NextResponse5.json(
1291
1555
  { error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
1292
1556
  { status: 500 }
1293
1557
  );
1294
1558
  }
1295
- const res = new NextResponse4(null, { status: 200 });
1559
+ const res = new NextResponse5(null, { status: 200 });
1296
1560
  res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1297
1561
  res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1298
1562
  res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
@@ -1311,6 +1575,7 @@ export {
1311
1575
  createBackchannelLogoutRouteHandler,
1312
1576
  createCallbackRouteHandler,
1313
1577
  createLogoutRouteHandler,
1578
+ createRefreshRouteHandler,
1314
1579
  createSsoMiddleware,
1315
1580
  getHostCookieOptions,
1316
1581
  getSecureCookieOptions,