@nihplod/sso-sdk 1.4.1 → 1.6.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.
@@ -114,15 +114,18 @@ async function computeCodeChallenge(verifier) {
114
114
  function matchesPath(pathname, paths) {
115
115
  return paths.some((path) => {
116
116
  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;
117
+ if (path.endsWith("/:path*")) {
118
+ const base = path.slice(0, -"/:path*".length);
119
+ return pathname === base || pathname.startsWith(base + "/");
120
+ }
121
+ return pathname.startsWith(path + "/");
120
122
  });
121
123
  }
122
124
  var introspectionCache = /* @__PURE__ */ new Map();
123
125
  var INTROSPECT_CACHE_TTL_MS = 3e4;
124
126
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
125
127
  var INTROSPECT_TIMEOUT_MS = 5e3;
128
+ var warnedIntrospectClientAuthFailure = false;
126
129
  async function introspectCacheKey(token, clientId) {
127
130
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
128
131
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -176,8 +179,13 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
176
179
  if (res.status !== 401 && res.status !== 403) {
177
180
  return "unreachable";
178
181
  }
179
- introspectCacheSet(cacheKey, false);
180
- return "inactive";
182
+ if (!warnedIntrospectClientAuthFailure) {
183
+ warnedIntrospectClientAuthFailure = true;
184
+ console.warn(
185
+ `[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`
186
+ );
187
+ }
188
+ return "unreachable";
181
189
  }
182
190
  const data = await res.json();
183
191
  const active = data.active === true;
@@ -196,8 +204,7 @@ function createSsoMiddleware(config) {
196
204
  scopes = "openid profile",
197
205
  publicPaths = [],
198
206
  callbackPath = "/api/auth/callback",
199
- ssoCookieName = "__Host-user_token",
200
- validateSsoCookie = true,
207
+ failClosedOnIntrospectionError = false,
201
208
  insecureLocalDev: insecureLocalDevOpt = false
202
209
  } = config;
203
210
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -209,9 +216,9 @@ function createSsoMiddleware(config) {
209
216
  const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
210
217
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
211
218
  const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
212
- if (!validateSsoCookie) {
219
+ if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
213
220
  console.warn(
214
- "[SSO SDK] validateSsoCookie=false\uFF1A\u4E2D\u95F4\u4EF6\u4EC5\u68C0\u67E5 Cookie \u5B58\u5728\u6027\uFF0C\u53EF\u80FD\u653E\u884C\u5DF2\u5931\u6548\u7684\u4F1A\u8BDD\u3002\u4E2D\u95F4\u4EF6\u53EA\u662F UX \u5C42\uFF0C\u654F\u611F\u6570\u636E\u7684\u9274\u6743\u5FC5\u987B\u5728 Route Handler / Server Component \u4E2D\u5B8C\u6210\u3002"
221
+ '[SSO SDK] validateSsoCookie / ssoCookieName \u5DF2\u5E9F\u5F03\uFF1A\u4E3B\u7AD9\u4F1A\u8BDD Cookie\uFF08__Host-user_token\uFF09\u662F\u4E3B\u7AD9\u5185\u90E8 token\uFF08type="user"\uFF09\uFF0Cintrospect \u7AEF\u70B9\u53EA\u63A5\u53D7 access_token\uFF0C\u6821\u9A8C\u5FC5\u7136\u5931\u8D25\uFF1B\u4E14 __Host- Cookie \u4E0D\u4F1A\u4E0B\u53D1\u5230\u5B50\u57DF\u3002\u8BE5\u5FEB\u901F\u901A\u9053\u4ECE\u672A\u751F\u6548\uFF0C\u5DF2\u79FB\u9664\u3002\u8BF7\u5220\u9664\u8FD9\u4E24\u4E2A\u914D\u7F6E\u9879\uFF1B\u4F1A\u8BDD\u68C0\u6D4B\u4EE5\u672C\u7AD9 access_token Cookie \u4E3A\u51C6\u3002'
215
222
  );
216
223
  }
217
224
  if (!clientSecret) {
@@ -221,7 +228,7 @@ function createSsoMiddleware(config) {
221
228
  }
222
229
  return async function ssoMiddleware(request) {
223
230
  const { pathname } = request.nextUrl;
224
- if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
231
+ if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
225
232
  return import_server.NextResponse.next();
226
233
  }
227
234
  if (pathname === callbackPath) {
@@ -231,22 +238,6 @@ function createSsoMiddleware(config) {
231
238
  if (matchesPath(pathname, allPublicPaths)) {
232
239
  return import_server.NextResponse.next();
233
240
  }
234
- const ssoSession = request.cookies.get(ssoCookieName);
235
- if (ssoSession?.value) {
236
- if (validateSsoCookie) {
237
- const introspectResult = await introspectAccessToken(
238
- ssoSession.value,
239
- normalizedServerBase,
240
- clientId,
241
- clientSecret
242
- );
243
- if (introspectResult === "active") {
244
- return import_server.NextResponse.next();
245
- }
246
- } else {
247
- return import_server.NextResponse.next();
248
- }
249
- }
250
241
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
251
242
  if (accessTokenCookie?.value) {
252
243
  const introspectResult = await introspectAccessToken(
@@ -259,6 +250,15 @@ function createSsoMiddleware(config) {
259
250
  return import_server.NextResponse.next();
260
251
  }
261
252
  if (introspectResult === "unreachable") {
253
+ if (failClosedOnIntrospectionError) {
254
+ console.warn(
255
+ "[SSO SDK] introspection \u4E0D\u53EF\u8FBE\uFF08\u7F51\u7EDC\u5F02\u5E38/\u8D85\u65F6/5xx\uFF09\uFF0CfailClosedOnIntrospectionError=true\uFF0C\u5BF9\u6301\u6709 SSO access token Cookie \u7684\u8BF7\u6C42 fail-closed \u8FD4\u56DE 502\u3002"
256
+ );
257
+ return new import_server.NextResponse(
258
+ "SSO \u8BA4\u8BC1\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08introspection unreachable\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
259
+ { status: 502 }
260
+ );
261
+ }
262
262
  console.warn(
263
263
  "[SSO SDK] introspection \u4E0D\u53EF\u8FBE\uFF08\u7F51\u7EDC\u5F02\u5E38/\u8D85\u65F6/5xx\uFF09\uFF0C\u5BF9\u6301\u6709 SSO access token Cookie \u7684\u8BF7\u6C42 fail-open \u653E\u884C\uFF1B\u654F\u611F\u6570\u636E\u7684\u9274\u6743\u7531 Route Handler \u515C\u5E95\u3002"
264
264
  );
@@ -281,11 +281,28 @@ function createSsoMiddleware(config) {
281
281
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
282
282
  loginUrl.search = authorizeParams.toString();
283
283
  const response = import_server.NextResponse.redirect(loginUrl);
284
- response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
285
- response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));
286
- response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));
284
+ const attemptSuffix = `_${state}`;
285
+ response.cookies.set(
286
+ `${stateCookieName}${attemptSuffix}`,
287
+ state,
288
+ getHostCookieOptions(600, secureCookies)
289
+ );
290
+ response.cookies.set(
291
+ `${nonceCookieName}${attemptSuffix}`,
292
+ nonce,
293
+ getHostCookieOptions(600, secureCookies)
294
+ );
295
+ response.cookies.set(
296
+ `${verifierCookieName}${attemptSuffix}`,
297
+ verifier,
298
+ getSecureCookieOptions(600, callbackPath, secureCookies)
299
+ );
287
300
  const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
288
- response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
301
+ response.cookies.set(
302
+ `${returnUrlCookieName}${attemptSuffix}`,
303
+ safeReturnUrl,
304
+ getHostCookieOptions(600, secureCookies)
305
+ );
289
306
  if (accessTokenCookie?.value) {
290
307
  response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
291
308
  }
@@ -308,12 +325,17 @@ var SsoError = class extends Error {
308
325
  };
309
326
 
310
327
  // src/core/security.ts
328
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
311
329
  function isTrustedReturnUrl(url, currentOrigin) {
312
330
  if (!url) return false;
313
- if (url.includes("\\")) return false;
314
- if (url.startsWith("/") && !url.startsWith("//")) return true;
331
+ if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
332
+ if (url.startsWith("//")) return false;
333
+ if (url.startsWith("/")) return true;
315
334
  try {
316
- return new URL(url).origin === currentOrigin;
335
+ const parsed = new URL(url);
336
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
337
+ if (parsed.username || parsed.password) return false;
338
+ return parsed.origin === currentOrigin;
317
339
  } catch {
318
340
  return false;
319
341
  }
@@ -525,7 +547,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
525
547
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
526
548
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
527
549
  }
528
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
550
+ if (typeof payload.iat !== "number") {
551
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
552
+ }
553
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
529
554
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
530
555
  }
531
556
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -542,11 +567,114 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
542
567
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
543
568
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
544
569
  }
570
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
571
+ throw new SsoError(
572
+ "id_token_nonce_mismatch",
573
+ "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"
574
+ );
545
575
  }
546
576
  return { sub: payload.sub };
547
577
  }
548
578
 
579
+ // src/core/http.ts
580
+ var REQUEST_TIMEOUT_MS = 1e4;
581
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
582
+ const controller = new AbortController();
583
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
584
+ try {
585
+ return await fetch(input, { ...init, signal: controller.signal });
586
+ } finally {
587
+ clearTimeout(timer);
588
+ }
589
+ }
590
+
549
591
  // src/next/callback.ts
592
+ function escapeHtml(value) {
593
+ return value.replace(/[&<>"']/g, (ch) => {
594
+ switch (ch) {
595
+ case "&":
596
+ return "&amp;";
597
+ case "<":
598
+ return "&lt;";
599
+ case ">":
600
+ return "&gt;";
601
+ case '"':
602
+ return "&quot;";
603
+ default:
604
+ return "&#39;";
605
+ }
606
+ });
607
+ }
608
+ function buildErrorPage(status, error, errorDescription) {
609
+ const safeDescription = escapeHtml(errorDescription);
610
+ const safeError = escapeHtml(error);
611
+ return `<!doctype html>
612
+ <html lang="zh-CN">
613
+ <head>
614
+ <meta charset="utf-8" />
615
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
616
+ <meta name="robots" content="noindex" />
617
+ <title>\u767B\u5F55\u5931\u8D25</title>
618
+ <style>
619
+ :root { color-scheme: light; }
620
+ body { margin: 0; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #fafafa; color: #2c2c2c; }
621
+ main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }
622
+ h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }
623
+ p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }
624
+ .actions { display: flex; gap: 0.75rem; }
625
+ a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }
626
+ a.primary { background: #2c2c2c; color: #fff; }
627
+ a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }
628
+ .code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }
629
+ </style>
630
+ </head>
631
+ <body>
632
+ <main>
633
+ <h1>\u767B\u5F55\u5931\u8D25</h1>
634
+ <p>${safeDescription}</p>
635
+ <div class="actions">
636
+ <a class="primary" href="/">\u91CD\u65B0\u767B\u5F55</a>
637
+ <a class="secondary" href="/">\u8FD4\u56DE\u9996\u9875</a>
638
+ </div>
639
+ <p class="code">\u9519\u8BEF\u7801\uFF1A${safeError}\uFF08\u53CD\u9988\u95EE\u9898\u65F6\u8BF7\u9644\u4E0A\uFF09</p>
640
+ </main>
641
+ </body>
642
+ </html>`;
643
+ }
644
+ function buildErrorResponse(request, status, error, errorDescription, custom) {
645
+ if (custom) {
646
+ const handled = custom({ error, errorDescription, status, request });
647
+ if (handled instanceof Promise) {
648
+ return handled.then(
649
+ (res) => res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)
650
+ );
651
+ }
652
+ if (handled) return toNextResponse(handled);
653
+ }
654
+ return defaultErrorResponse(request, status, error, errorDescription);
655
+ }
656
+ function toNextResponse(res) {
657
+ if (res instanceof import_server2.NextResponse) return res;
658
+ return new import_server2.NextResponse(res.body, {
659
+ status: res.status,
660
+ statusText: res.statusText,
661
+ headers: res.headers
662
+ });
663
+ }
664
+ function defaultErrorResponse(request, status, error, errorDescription) {
665
+ const accept = request.headers.get("accept") ?? "";
666
+ const wantsHtml = accept.includes("text/html") && request.nextUrl.searchParams.get("format") !== "json";
667
+ if (wantsHtml) {
668
+ return new import_server2.NextResponse(buildErrorPage(status, error, errorDescription), {
669
+ status,
670
+ headers: {
671
+ "Content-Type": "text/html; charset=utf-8",
672
+ "Cache-Control": "no-store"
673
+ }
674
+ });
675
+ }
676
+ return import_server2.NextResponse.json({ error, error_description: errorDescription }, { status });
677
+ }
550
678
  function createCallbackRouteHandler(config) {
551
679
  const {
552
680
  clientId,
@@ -554,6 +682,7 @@ function createCallbackRouteHandler(config) {
554
682
  redirectUri,
555
683
  clientSecret,
556
684
  defaultReturnPath = "/",
685
+ scopes,
557
686
  insecureLocalDev: insecureLocalDevOpt = false
558
687
  } = config;
559
688
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -571,7 +700,21 @@ function createCallbackRouteHandler(config) {
571
700
  return async function GET(request) {
572
701
  const response = await handleCallback(request);
573
702
  if (response.status >= 400) {
703
+ if (config.debug) {
704
+ console.warn(
705
+ `[SSO SDK] \u56DE\u8C03\u5931\u8D25 status=${response.status}`,
706
+ request.nextUrl.searchParams.get("error") ?? ""
707
+ );
708
+ }
574
709
  response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
710
+ const returnedState = request.nextUrl.searchParams.get("state");
711
+ if (returnedState && /^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
712
+ response.cookies.set(
713
+ `${nonceCookieName}_${returnedState}`,
714
+ "",
715
+ getHostCookieOptions(0, secureCookies)
716
+ );
717
+ }
575
718
  }
576
719
  return response;
577
720
  };
@@ -580,50 +723,75 @@ function createCallbackRouteHandler(config) {
580
723
  const error = searchParams.get("error");
581
724
  if (error) {
582
725
  const desc = searchParams.get("error_description") || error;
583
- return import_server2.NextResponse.json(
584
- { error: "authorization_failed", error_description: desc },
585
- { status: 400 }
726
+ return buildErrorResponse(
727
+ request,
728
+ 400,
729
+ "authorization_failed",
730
+ desc,
731
+ config.renderErrorPage
586
732
  );
587
733
  }
588
734
  const code = searchParams.get("code");
589
735
  const returnedState = searchParams.get("state");
590
736
  if (!code) {
591
- return import_server2.NextResponse.json(
592
- {
593
- error: "invalid_request",
594
- error_description: "\u7F3A\u5C11 authorization code"
595
- },
596
- { status: 400 }
737
+ return buildErrorResponse(
738
+ request,
739
+ 400,
740
+ "invalid_request",
741
+ "\u767B\u5F55\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
742
+ config.renderErrorPage
597
743
  );
598
744
  }
599
- const savedState = request.cookies.get(stateCookieName)?.value;
745
+ if (!returnedState || !/^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
746
+ return buildErrorResponse(
747
+ request,
748
+ 400,
749
+ "invalid_request",
750
+ "State \u53C2\u6570\u7F3A\u5931\u6216\u683C\u5F0F\u975E\u6CD5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
751
+ config.renderErrorPage
752
+ );
753
+ }
754
+ const attemptSuffix = `_${returnedState}`;
755
+ const readTransientCookie = (name) => request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;
756
+ const savedState = readTransientCookie(stateCookieName);
600
757
  if (!savedState) {
601
- return import_server2.NextResponse.json(
602
- {
603
- error: "invalid_request",
604
- error_description: "State \u53C2\u6570\u7F3A\u5931\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42"
605
- },
606
- { status: 400 }
758
+ const alternateName = secureCookies ? toInsecureCookieName(stateCookieName) : DEFAULT_STATE_COOKIE_NAME;
759
+ const alternateValue = request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ?? request.cookies.get(alternateName)?.value;
760
+ if (alternateValue) {
761
+ return buildErrorResponse(
762
+ request,
763
+ 500,
764
+ "invalid_config",
765
+ "\u68C0\u6D4B\u5230 SSO SDK \u914D\u7F6E\u4E0D\u4E00\u81F4\uFF1Amiddleware \u4E0E callback \u7684 insecureLocalDev \u6216 Cookie \u540D\u79F0\u4E0D\u5339\u914D\uFF0C\u8BF7\u7EDF\u4E00\u914D\u7F6E",
766
+ config.renderErrorPage
767
+ );
768
+ }
769
+ return buildErrorResponse(
770
+ request,
771
+ 400,
772
+ "invalid_request",
773
+ "\u767B\u5F55\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
774
+ config.renderErrorPage
607
775
  );
608
776
  }
609
- if (!timingSafeEqualString(returnedState ?? "", savedState)) {
610
- return import_server2.NextResponse.json(
611
- {
612
- error: "invalid_request",
613
- error_description: "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
614
- },
615
- { status: 400 }
777
+ if (!timingSafeEqualString(returnedState, savedState)) {
778
+ return buildErrorResponse(
779
+ request,
780
+ 400,
781
+ "invalid_request",
782
+ "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
783
+ config.renderErrorPage
616
784
  );
617
785
  }
618
- const expectedNonce = request.cookies.get(nonceCookieName)?.value;
619
- const verifier = request.cookies.get(verifierCookieName)?.value;
786
+ const expectedNonce = readTransientCookie(nonceCookieName);
787
+ const verifier = readTransientCookie(verifierCookieName);
620
788
  if (!verifier) {
621
- return import_server2.NextResponse.json(
622
- {
623
- error: "invalid_request",
624
- error_description: "PKCE verifier \u7F3A\u5931\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42"
625
- },
626
- { status: 400 }
789
+ return buildErrorResponse(
790
+ request,
791
+ 400,
792
+ "invalid_request",
793
+ "\u767B\u5F55\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF08\u53EF\u80FD\u5207\u6362\u4E86\u6807\u7B7E\u9875\uFF09\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
794
+ config.renderErrorPage
627
795
  );
628
796
  }
629
797
  const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
@@ -644,27 +812,37 @@ function createCallbackRouteHandler(config) {
644
812
  await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
645
813
  }
646
814
  try {
647
- res = await fetch(tokenEndpoint, {
648
- method: "POST",
649
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
650
- body: body.toString()
651
- });
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
+ );
652
824
  lastError = null;
653
825
  break;
654
826
  } catch (err) {
655
827
  lastError = err;
656
828
  if (attempt >= maxRetries) {
657
- return import_server2.NextResponse.json(
658
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25\uFF0C\u5DF2\u91CD\u8BD5\u4ECD\u4E0D\u53EF\u8FBE" },
659
- { status: 502 }
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
660
835
  );
661
836
  }
662
837
  }
663
838
  }
664
839
  if (lastError || !res) {
665
- return import_server2.NextResponse.json(
666
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25" },
667
- { status: 502 }
840
+ return buildErrorResponse(
841
+ request,
842
+ 502,
843
+ "server_error",
844
+ "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
845
+ config.renderErrorPage
668
846
  );
669
847
  }
670
848
  if (!res.ok) {
@@ -673,22 +851,32 @@ function createCallbackRouteHandler(config) {
673
851
  errData = await res.json();
674
852
  } catch {
675
853
  }
676
- return import_server2.NextResponse.json(
677
- {
678
- error: "token_request_failed",
679
- error_description: errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
680
- },
681
- { status: 502 }
854
+ return buildErrorResponse(
855
+ request,
856
+ 502,
857
+ "token_request_failed",
858
+ errData.error_description || `\u767B\u5F55\u5931\u8D25\uFF08Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
859
+ config.renderErrorPage
682
860
  );
683
861
  }
684
862
  const tokenData = await res.json();
685
- if (!tokenData.access_token || !tokenData.refresh_token) {
686
- return import_server2.NextResponse.json(
687
- {
688
- error: "server_error",
689
- error_description: "Token \u54CD\u5E94\u7F3A\u5C11 access_token \u6216 refresh_token"
690
- },
691
- { status: 502 }
863
+ if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in) || tokenData.expires_in <= 0) {
864
+ return buildErrorResponse(
865
+ request,
866
+ 502,
867
+ "server_error",
868
+ "\u767B\u5F55\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08Token \u54CD\u5E94\u4E0D\u5B8C\u6574\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
869
+ config.renderErrorPage
870
+ );
871
+ }
872
+ const requiresIdToken = scopes !== void 0 && scopes.split(" ").filter(Boolean).includes("openid");
873
+ if (requiresIdToken && !tokenData.id_token) {
874
+ return buildErrorResponse(
875
+ request,
876
+ 400,
877
+ "id_token_invalid",
878
+ "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
879
+ config.renderErrorPage
692
880
  );
693
881
  }
694
882
  if (tokenData.id_token) {
@@ -701,17 +889,17 @@ function createCallbackRouteHandler(config) {
701
889
  { expectedNonce }
702
890
  );
703
891
  } catch (err) {
704
- return import_server2.NextResponse.json(
705
- {
706
- error: "id_token_invalid",
707
- error_description: err instanceof Error ? err.message : "ID Token \u9A8C\u8BC1\u5931\u8D25"
708
- },
709
- { status: 400 }
892
+ return buildErrorResponse(
893
+ request,
894
+ 400,
895
+ "id_token_invalid",
896
+ err instanceof Error && /[\u4e00-\u9fff]/.test(err.message) ? err.message : "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
897
+ config.renderErrorPage
710
898
  );
711
899
  }
712
900
  }
713
901
  const callbackOrigin = new URL(redirectUri).origin;
714
- const rawReturnUrl = request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;
902
+ const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
715
903
  const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
716
904
  const response = import_server2.NextResponse.redirect(new URL(returnUrl, callbackOrigin));
717
905
  response.cookies.set(accessTokenCookieName, tokenData.access_token, {
@@ -741,11 +929,18 @@ function createCallbackRouteHandler(config) {
741
929
  });
742
930
  }
743
931
  }
744
- response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
745
- response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
746
- response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
747
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
748
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));
932
+ for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {
933
+ response.cookies.set(name, "", getHostCookieOptions(0, secureCookies));
934
+ response.cookies.set(`${name}${attemptSuffix}`, "", getHostCookieOptions(0, secureCookies));
935
+ }
936
+ for (const path of ["/", request.nextUrl.pathname]) {
937
+ response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, path, secureCookies));
938
+ response.cookies.set(
939
+ `${verifierCookieName}${attemptSuffix}`,
940
+ "",
941
+ getSecureCookieOptions(0, path, secureCookies)
942
+ );
943
+ }
749
944
  return response;
750
945
  }
751
946
  }
@@ -859,7 +1054,7 @@ function createLogoutRouteHandler(config) {
859
1054
  const returnedState = request.nextUrl.searchParams.get("state");
860
1055
  if (returnedState) {
861
1056
  const savedState = request.cookies.get(logoutStateCookieName)?.value;
862
- if (!savedState || savedState !== returnedState) {
1057
+ if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
863
1058
  return import_server3.NextResponse.json(
864
1059
  { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
865
1060
  { status: 400 }
@@ -875,6 +1070,20 @@ function createLogoutRouteHandler(config) {
875
1070
  headers: { "content-type": "text/html; charset=utf-8" }
876
1071
  });
877
1072
  }
1073
+ const requestOrigin = request.headers.get("origin");
1074
+ if (requestOrigin && requestOrigin !== callbackOrigin) {
1075
+ return import_server3.NextResponse.json(
1076
+ { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1077
+ { status: 403 }
1078
+ );
1079
+ }
1080
+ const secFetchSite = request.headers.get("sec-fetch-site");
1081
+ if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
1082
+ return import_server3.NextResponse.json(
1083
+ { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1084
+ { status: 403 }
1085
+ );
1086
+ }
878
1087
  let formGlobal = null;
879
1088
  try {
880
1089
  formGlobal = new URLSearchParams(await request.text()).get("global");
@@ -913,6 +1122,21 @@ function createLogoutRouteHandler(config) {
913
1122
  res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
914
1123
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
915
1124
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
1125
+ const suffixedPrefixes = [
1126
+ `${stateCookieName}_`,
1127
+ `${nonceCookieName}_`,
1128
+ `${returnUrlCookieName}_`,
1129
+ `${verifierCookieName}_`
1130
+ ];
1131
+ for (const cookie of request.cookies.getAll()) {
1132
+ if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;
1133
+ if (cookie.name.startsWith(`${verifierCookieName}_`)) {
1134
+ res.cookies.set(cookie.name, "", getSecureCookieOptions(0, "/", secureCookies));
1135
+ res.cookies.set(cookie.name, "", getSecureCookieOptions(0, callbackPath, secureCookies));
1136
+ } else {
1137
+ res.cookies.set(cookie.name, "", getHostCookieOptions(0, secureCookies));
1138
+ }
1139
+ }
916
1140
  return res;
917
1141
  };
918
1142
  if (effectiveScope === "global") {
@@ -946,6 +1170,20 @@ var import_server4 = require("next/server");
946
1170
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
947
1171
  var JTI_CACHE_CAPACITY = 1e3;
948
1172
  var seenJti = /* @__PURE__ */ new Map();
1173
+ async function releaseLogoutTokenJti(replayKey, options = {}) {
1174
+ const store = options.jtiStore;
1175
+ if (store?.remove) {
1176
+ await store.remove(replayKey);
1177
+ return;
1178
+ }
1179
+ if (store) {
1180
+ console.warn(
1181
+ "[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"
1182
+ );
1183
+ return;
1184
+ }
1185
+ seenJti.delete(replayKey);
1186
+ }
949
1187
  function recordJti(jti, expiresAtMs) {
950
1188
  const now = Date.now();
951
1189
  const existing = seenJti.get(jti);
@@ -961,7 +1199,7 @@ function recordJti(jti, expiresAtMs) {
961
1199
  seenJti.set(jti, expiresAtMs);
962
1200
  return true;
963
1201
  }
964
- async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1202
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
965
1203
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
966
1204
  const header = decodeJwtHeader(logoutToken);
967
1205
  if (!header) {
@@ -1038,8 +1276,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1038
1276
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
1039
1277
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1040
1278
  }
1279
+ if (payload.type !== "logout_token") {
1280
+ throw new SsoError(
1281
+ "logout_token_invalid",
1282
+ "Logout Token type \u5FC5\u987B\u4E3A logout_token"
1283
+ );
1284
+ }
1041
1285
  const events = payload.events;
1042
- if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
1286
+ if (!events || typeof events !== "object" || Array.isArray(events)) {
1287
+ throw new SsoError(
1288
+ "logout_token_invalid",
1289
+ "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
1290
+ );
1291
+ }
1292
+ const logoutEvent = events[BACKCHANNEL_LOGOUT_EVENT];
1293
+ if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
1043
1294
  throw new SsoError(
1044
1295
  "logout_token_invalid",
1045
1296
  "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
@@ -1057,10 +1308,27 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1057
1308
  if (!jti) {
1058
1309
  throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
1059
1310
  }
1060
- if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1311
+ const replayKey = `${normalizedIssuer}:${jti}`;
1312
+ const replayTtlSeconds = Math.max(
1313
+ 1,
1314
+ Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
1315
+ );
1316
+ const jtiStore = options.jtiStore;
1317
+ if (jtiStore) {
1318
+ if (jtiStore.addIfAbsent) {
1319
+ if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
1320
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1321
+ }
1322
+ } else {
1323
+ if (await jtiStore.has(replayKey)) {
1324
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1325
+ }
1326
+ await jtiStore.add(replayKey, replayTtlSeconds);
1327
+ }
1328
+ } else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
1061
1329
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1062
1330
  }
1063
- return { sub, sid };
1331
+ return { payload: { sub, sid }, jti, replayKey };
1064
1332
  }
1065
1333
 
1066
1334
  // src/next/backchannel-logout.ts
@@ -1100,9 +1368,11 @@ function createBackchannelLogoutRouteHandler(config) {
1100
1368
  { status: 400 }
1101
1369
  );
1102
1370
  }
1103
- let payload;
1371
+ let verified;
1104
1372
  try {
1105
- payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
1373
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
1374
+ jtiStore: config.jtiStore
1375
+ });
1106
1376
  } catch (err) {
1107
1377
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1108
1378
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
@@ -1112,8 +1382,9 @@ function createBackchannelLogoutRouteHandler(config) {
1112
1382
  );
1113
1383
  }
1114
1384
  try {
1115
- await onLogout?.(payload, request);
1385
+ await onLogout?.(verified.payload, request);
1116
1386
  } catch (err) {
1387
+ await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
1117
1388
  console.error(
1118
1389
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1119
1390
  err