@nihplod/sso-sdk 1.6.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.
@@ -208,6 +208,55 @@ interface CallbackRouteConfig {
208
208
  }
209
209
  declare function createCallbackRouteHandler(config: CallbackRouteConfig): (request: NextRequest) => Promise<NextResponse<unknown>>;
210
210
 
211
+ /**
212
+ * App Router Refresh Route Handler
213
+ *
214
+ * 使用 httpOnly refresh cookie 在服务端换取新的 access/refresh/id token,
215
+ * 并更新 Cookie。适用于 Next.js BFF/Confidential Client:token 不暴露给浏览器 JS。
216
+ *
217
+ * 用法 (src/app/api/auth/refresh/route.ts):
218
+ * ```ts
219
+ * import { createRefreshRouteHandler } from "@nihplod/sso-sdk/next";
220
+ *
221
+ * export const POST = createRefreshRouteHandler({
222
+ * clientId: process.env.SSO_CLIENT_ID!,
223
+ * clientSecret: process.env.SSO_CLIENT_SECRET,
224
+ * ssoBaseUrl: "https://nihplod.cn",
225
+ * redirectUri: "https://myapp.com/api/auth/callback",
226
+ * });
227
+ * ```
228
+ */
229
+
230
+ interface RefreshRouteConfig {
231
+ /** OAuth Client ID */
232
+ clientId: string;
233
+ /** SSO 中心地址 */
234
+ ssoBaseUrl: string;
235
+ /** 回调 URL(用于同源校验,必须与注册的 redirect_uri 一致) */
236
+ redirectUri: string;
237
+ /** OAuth Client Secret(Confidential Client 必填) */
238
+ clientSecret?: string;
239
+ /** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */
240
+ accessTokenCookieName?: string;
241
+ /** Refresh Token Cookie 名称,默认 __Host-nihplod_sso_rt */
242
+ refreshTokenCookieName?: string;
243
+ /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */
244
+ idTokenCookieName?: string;
245
+ /**
246
+ * 本地 HTTP 开发模式(默认 false)。必须与 middleware / callback / logout 配置一致。
247
+ * 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下会被强制忽略并告警。
248
+ */
249
+ insecureLocalDev?: boolean;
250
+ /**
251
+ * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。
252
+ * 仅用于 token 交换;浏览器跳转仍使用 ssoBaseUrl 公网地址。
253
+ */
254
+ serverBaseUrl?: string;
255
+ /** 调试日志开关(默认 false) */
256
+ debug?: boolean;
257
+ }
258
+ declare function createRefreshRouteHandler(config: RefreshRouteConfig): (request: NextRequest) => Promise<NextResponse>;
259
+
211
260
  /**
212
261
  * App Router 登出 Route Handler
213
262
  *
@@ -427,4 +476,4 @@ declare function getSecureCookieOptions(maxAge?: number, path?: string, secure?:
427
476
  maxAge?: number;
428
477
  };
429
478
 
430
- export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
479
+ export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type RefreshRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createRefreshRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
@@ -208,6 +208,55 @@ interface CallbackRouteConfig {
208
208
  }
209
209
  declare function createCallbackRouteHandler(config: CallbackRouteConfig): (request: NextRequest) => Promise<NextResponse<unknown>>;
210
210
 
211
+ /**
212
+ * App Router Refresh Route Handler
213
+ *
214
+ * 使用 httpOnly refresh cookie 在服务端换取新的 access/refresh/id token,
215
+ * 并更新 Cookie。适用于 Next.js BFF/Confidential Client:token 不暴露给浏览器 JS。
216
+ *
217
+ * 用法 (src/app/api/auth/refresh/route.ts):
218
+ * ```ts
219
+ * import { createRefreshRouteHandler } from "@nihplod/sso-sdk/next";
220
+ *
221
+ * export const POST = createRefreshRouteHandler({
222
+ * clientId: process.env.SSO_CLIENT_ID!,
223
+ * clientSecret: process.env.SSO_CLIENT_SECRET,
224
+ * ssoBaseUrl: "https://nihplod.cn",
225
+ * redirectUri: "https://myapp.com/api/auth/callback",
226
+ * });
227
+ * ```
228
+ */
229
+
230
+ interface RefreshRouteConfig {
231
+ /** OAuth Client ID */
232
+ clientId: string;
233
+ /** SSO 中心地址 */
234
+ ssoBaseUrl: string;
235
+ /** 回调 URL(用于同源校验,必须与注册的 redirect_uri 一致) */
236
+ redirectUri: string;
237
+ /** OAuth Client Secret(Confidential Client 必填) */
238
+ clientSecret?: string;
239
+ /** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */
240
+ accessTokenCookieName?: string;
241
+ /** Refresh Token Cookie 名称,默认 __Host-nihplod_sso_rt */
242
+ refreshTokenCookieName?: string;
243
+ /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */
244
+ idTokenCookieName?: string;
245
+ /**
246
+ * 本地 HTTP 开发模式(默认 false)。必须与 middleware / callback / logout 配置一致。
247
+ * 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下会被强制忽略并告警。
248
+ */
249
+ insecureLocalDev?: boolean;
250
+ /**
251
+ * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。
252
+ * 仅用于 token 交换;浏览器跳转仍使用 ssoBaseUrl 公网地址。
253
+ */
254
+ serverBaseUrl?: string;
255
+ /** 调试日志开关(默认 false) */
256
+ debug?: boolean;
257
+ }
258
+ declare function createRefreshRouteHandler(config: RefreshRouteConfig): (request: NextRequest) => Promise<NextResponse>;
259
+
211
260
  /**
212
261
  * App Router 登出 Route Handler
213
262
  *
@@ -427,4 +476,4 @@ declare function getSecureCookieOptions(maxAge?: number, path?: string, secure?:
427
476
  maxAge?: number;
428
477
  };
429
478
 
430
- export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
479
+ export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type RefreshRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createRefreshRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
@@ -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,
@@ -126,6 +127,7 @@ var INTROSPECT_CACHE_TTL_MS = 3e4;
126
127
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
127
128
  var INTROSPECT_TIMEOUT_MS = 5e3;
128
129
  var warnedIntrospectClientAuthFailure = false;
130
+ var warnedIntrospectRateLimited = false;
129
131
  async function introspectCacheKey(token, clientId) {
130
132
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
131
133
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -176,6 +178,15 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
176
178
  signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
177
179
  });
178
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
+ }
179
190
  if (res.status !== 401 && res.status !== 403) {
180
191
  return "unreachable";
181
192
  }
@@ -417,7 +428,19 @@ async function fetchJwks(baseUrl, options = {}) {
417
428
  return cachedJwks.jwks;
418
429
  }
419
430
  const discovery = await fetchDiscoveryDoc(baseUrl);
420
- 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
+ }
421
444
  try {
422
445
  const res = await fetch(jwksUri, {
423
446
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -688,8 +711,14 @@ function createCallbackRouteHandler(config) {
688
711
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
689
712
  const secureCookies = !insecureLocalDev;
690
713
  const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
691
- const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
692
- 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
+ );
693
722
  const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
694
723
  const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
695
724
  const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
@@ -723,13 +752,7 @@ function createCallbackRouteHandler(config) {
723
752
  const error = searchParams.get("error");
724
753
  if (error) {
725
754
  const desc = searchParams.get("error_description") || error;
726
- return buildErrorResponse(
727
- request,
728
- 400,
729
- "authorization_failed",
730
- desc,
731
- config.renderErrorPage
732
- );
755
+ return buildErrorResponse(request, 400, "authorization_failed", desc, config.renderErrorPage);
733
756
  }
734
757
  const code = searchParams.get("code");
735
758
  const returnedState = searchParams.get("state");
@@ -783,6 +806,20 @@ function createCallbackRouteHandler(config) {
783
806
  config.renderErrorPage
784
807
  );
785
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
+ }
786
823
  const expectedNonce = readTransientCookie(nonceCookieName);
787
824
  const verifier = readTransientCookie(verifierCookieName);
788
825
  if (!verifier) {
@@ -806,35 +843,18 @@ function createCallbackRouteHandler(config) {
806
843
  }
807
844
  let res = null;
808
845
  let lastError;
809
- const maxRetries = 1;
810
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
811
- if (attempt > 0) {
812
- await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
813
- }
814
- try {
815
- res = await fetchWithTimeout(
816
- tokenEndpoint,
817
- {
818
- method: "POST",
819
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
820
- body: body.toString()
821
- },
822
- 1e4
823
- );
824
- lastError = null;
825
- break;
826
- } catch (err) {
827
- lastError = err;
828
- if (attempt >= maxRetries) {
829
- return buildErrorResponse(
830
- request,
831
- 502,
832
- "server_error",
833
- "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
834
- config.renderErrorPage
835
- );
836
- }
837
- }
846
+ try {
847
+ res = await fetchWithTimeout(
848
+ tokenEndpoint,
849
+ {
850
+ method: "POST",
851
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
852
+ body: body.toString()
853
+ },
854
+ 1e4
855
+ );
856
+ } catch (err) {
857
+ lastError = err;
838
858
  }
839
859
  if (lastError || !res) {
840
860
  return buildErrorResponse(
@@ -945,8 +965,169 @@ function createCallbackRouteHandler(config) {
945
965
  }
946
966
  }
947
967
 
948
- // src/next/logout.ts
968
+ // src/next/refresh.ts
949
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");
950
1131
 
951
1132
  // src/core/discovery.ts
952
1133
  var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
@@ -1017,6 +1198,14 @@ function generateRandomString2(length) {
1017
1198
  }
1018
1199
  return result;
1019
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
+ }
1020
1209
  function createLogoutRouteHandler(config) {
1021
1210
  const {
1022
1211
  clientId,
@@ -1055,31 +1244,31 @@ function createLogoutRouteHandler(config) {
1055
1244
  if (returnedState) {
1056
1245
  const savedState = request.cookies.get(logoutStateCookieName)?.value;
1057
1246
  if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
1058
- return import_server3.NextResponse.json(
1247
+ return import_server4.NextResponse.json(
1059
1248
  { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
1060
1249
  { status: 400 }
1061
1250
  );
1062
1251
  }
1063
- const res = import_server3.NextResponse.redirect(callbackOrigin + "/");
1252
+ const res = import_server4.NextResponse.redirect(callbackOrigin + "/");
1064
1253
  res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
1065
1254
  return res;
1066
1255
  }
1067
1256
  if (request.method === "GET") {
1068
- return new import_server3.NextResponse(buildLogoutConfirmHtml(), {
1257
+ return new import_server4.NextResponse(buildLogoutConfirmHtml(), {
1069
1258
  status: 200,
1070
1259
  headers: { "content-type": "text/html; charset=utf-8" }
1071
1260
  });
1072
1261
  }
1073
1262
  const requestOrigin = request.headers.get("origin");
1074
1263
  if (requestOrigin && requestOrigin !== callbackOrigin) {
1075
- return import_server3.NextResponse.json(
1264
+ return import_server4.NextResponse.json(
1076
1265
  { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1077
1266
  { status: 403 }
1078
1267
  );
1079
1268
  }
1080
1269
  const secFetchSite = request.headers.get("sec-fetch-site");
1081
1270
  if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
1082
- return import_server3.NextResponse.json(
1271
+ return import_server4.NextResponse.json(
1083
1272
  { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1084
1273
  { status: 403 }
1085
1274
  );
@@ -1095,7 +1284,7 @@ function createLogoutRouteHandler(config) {
1095
1284
  if (refreshToken) {
1096
1285
  try {
1097
1286
  const discovery = config.serverBaseUrl ? null : await fetchDiscoveryCached(normalizedServerBase);
1098
- 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`;
1099
1288
  const body = new URLSearchParams({
1100
1289
  token: refreshToken,
1101
1290
  token_type_hint: "refresh_token",
@@ -1104,12 +1293,20 @@ function createLogoutRouteHandler(config) {
1104
1293
  if (clientSecret) {
1105
1294
  body.set("client_secret", clientSecret);
1106
1295
  }
1107
- await fetch(revokeUrl, {
1296
+ const doRevoke = () => fetch(revokeUrl, {
1108
1297
  method: "POST",
1109
1298
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1110
1299
  body: body.toString(),
1111
1300
  signal: AbortSignal.timeout(3e3)
1112
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
+ }
1113
1310
  } catch {
1114
1311
  }
1115
1312
  }
@@ -1141,7 +1338,7 @@ function createLogoutRouteHandler(config) {
1141
1338
  };
1142
1339
  if (effectiveScope === "global") {
1143
1340
  const discovery = await fetchDiscoveryCached(normalizedServerBase);
1144
- 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`;
1145
1342
  const logoutUrl = new URL(endSessionEndpoint);
1146
1343
  logoutUrl.searchParams.set("client_id", clientId);
1147
1344
  logoutUrl.searchParams.set(
@@ -1153,18 +1350,18 @@ function createLogoutRouteHandler(config) {
1153
1350
  }
1154
1351
  const logoutState = generateRandomString2(32);
1155
1352
  logoutUrl.searchParams.set("state", logoutState);
1156
- const res = clearCookies(import_server3.NextResponse.redirect(logoutUrl.toString()));
1353
+ const res = clearCookies(import_server4.NextResponse.redirect(logoutUrl.toString()));
1157
1354
  res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
1158
1355
  return res;
1159
1356
  }
1160
1357
  return clearCookies(
1161
- import_server3.NextResponse.redirect(callbackOrigin + "/")
1358
+ import_server4.NextResponse.redirect(callbackOrigin + "/")
1162
1359
  );
1163
1360
  };
1164
1361
  }
1165
1362
 
1166
1363
  // src/next/backchannel-logout.ts
1167
- var import_server4 = require("next/server");
1364
+ var import_server5 = require("next/server");
1168
1365
 
1169
1366
  // src/core/logout-token.ts
1170
1367
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
@@ -1276,6 +1473,12 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, opti
1276
1473
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
1277
1474
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1278
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
+ }
1279
1482
  if (payload.type !== "logout_token") {
1280
1483
  throw new SsoError(
1281
1484
  "logout_token_invalid",
@@ -1354,7 +1557,7 @@ function createBackchannelLogoutRouteHandler(config) {
1354
1557
  );
1355
1558
  return async function handler(request) {
1356
1559
  if (request.method !== "POST") {
1357
- return import_server4.NextResponse.json(
1560
+ return import_server5.NextResponse.json(
1358
1561
  { error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
1359
1562
  { status: 405 }
1360
1563
  );
@@ -1363,7 +1566,7 @@ function createBackchannelLogoutRouteHandler(config) {
1363
1566
  "logout_token"
1364
1567
  );
1365
1568
  if (!logoutToken) {
1366
- return import_server4.NextResponse.json(
1569
+ return import_server5.NextResponse.json(
1367
1570
  { error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
1368
1571
  { status: 400 }
1369
1572
  );
@@ -1376,7 +1579,7 @@ function createBackchannelLogoutRouteHandler(config) {
1376
1579
  } catch (err) {
1377
1580
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1378
1581
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
1379
- return import_server4.NextResponse.json(
1582
+ return import_server5.NextResponse.json(
1380
1583
  { error: code, error_description: description },
1381
1584
  { status: 400 }
1382
1585
  );
@@ -1389,12 +1592,12 @@ function createBackchannelLogoutRouteHandler(config) {
1389
1592
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1390
1593
  err
1391
1594
  );
1392
- return import_server4.NextResponse.json(
1595
+ return import_server5.NextResponse.json(
1393
1596
  { error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
1394
1597
  { status: 500 }
1395
1598
  );
1396
1599
  }
1397
- const res = new import_server4.NextResponse(null, { status: 200 });
1600
+ const res = new import_server5.NextResponse(null, { status: 200 });
1398
1601
  res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1399
1602
  res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1400
1603
  res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
@@ -1414,6 +1617,7 @@ function createBackchannelLogoutRouteHandler(config) {
1414
1617
  createBackchannelLogoutRouteHandler,
1415
1618
  createCallbackRouteHandler,
1416
1619
  createLogoutRouteHandler,
1620
+ createRefreshRouteHandler,
1417
1621
  createSsoMiddleware,
1418
1622
  getHostCookieOptions,
1419
1623
  getSecureCookieOptions,