@nihplod/sso-sdk 1.4.1 → 1.5.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.
@@ -196,8 +196,7 @@ function createSsoMiddleware(config) {
196
196
  scopes = "openid profile",
197
197
  publicPaths = [],
198
198
  callbackPath = "/api/auth/callback",
199
- ssoCookieName = "__Host-user_token",
200
- validateSsoCookie = true,
199
+ failClosedOnIntrospectionError = false,
201
200
  insecureLocalDev: insecureLocalDevOpt = false
202
201
  } = config;
203
202
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -209,9 +208,9 @@ function createSsoMiddleware(config) {
209
208
  const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
210
209
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
211
210
  const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
212
- if (!validateSsoCookie) {
211
+ if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
213
212
  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"
213
+ '[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
214
  );
216
215
  }
217
216
  if (!clientSecret) {
@@ -231,22 +230,6 @@ function createSsoMiddleware(config) {
231
230
  if (matchesPath(pathname, allPublicPaths)) {
232
231
  return import_server.NextResponse.next();
233
232
  }
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
233
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
251
234
  if (accessTokenCookie?.value) {
252
235
  const introspectResult = await introspectAccessToken(
@@ -259,6 +242,15 @@ function createSsoMiddleware(config) {
259
242
  return import_server.NextResponse.next();
260
243
  }
261
244
  if (introspectResult === "unreachable") {
245
+ if (failClosedOnIntrospectionError) {
246
+ console.warn(
247
+ "[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"
248
+ );
249
+ return new import_server.NextResponse(
250
+ "SSO \u8BA4\u8BC1\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08introspection unreachable\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
251
+ { status: 502 }
252
+ );
253
+ }
262
254
  console.warn(
263
255
  "[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
256
  );
@@ -281,11 +273,28 @@ function createSsoMiddleware(config) {
281
273
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
282
274
  loginUrl.search = authorizeParams.toString();
283
275
  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));
276
+ const attemptSuffix = `_${state}`;
277
+ response.cookies.set(
278
+ `${stateCookieName}${attemptSuffix}`,
279
+ state,
280
+ getHostCookieOptions(600, secureCookies)
281
+ );
282
+ response.cookies.set(
283
+ `${nonceCookieName}${attemptSuffix}`,
284
+ nonce,
285
+ getHostCookieOptions(600, secureCookies)
286
+ );
287
+ response.cookies.set(
288
+ `${verifierCookieName}${attemptSuffix}`,
289
+ verifier,
290
+ getSecureCookieOptions(600, callbackPath, secureCookies)
291
+ );
287
292
  const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
288
- response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
293
+ response.cookies.set(
294
+ `${returnUrlCookieName}${attemptSuffix}`,
295
+ safeReturnUrl,
296
+ getHostCookieOptions(600, secureCookies)
297
+ );
289
298
  if (accessTokenCookie?.value) {
290
299
  response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
291
300
  }
@@ -308,12 +317,17 @@ var SsoError = class extends Error {
308
317
  };
309
318
 
310
319
  // src/core/security.ts
320
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
311
321
  function isTrustedReturnUrl(url, currentOrigin) {
312
322
  if (!url) return false;
313
- if (url.includes("\\")) return false;
314
- if (url.startsWith("/") && !url.startsWith("//")) return true;
323
+ if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
324
+ if (url.startsWith("//")) return false;
325
+ if (url.startsWith("/")) return true;
315
326
  try {
316
- return new URL(url).origin === currentOrigin;
327
+ const parsed = new URL(url);
328
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
329
+ if (parsed.username || parsed.password) return false;
330
+ return parsed.origin === currentOrigin;
317
331
  } catch {
318
332
  return false;
319
333
  }
@@ -547,6 +561,92 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
547
561
  }
548
562
 
549
563
  // src/next/callback.ts
564
+ function escapeHtml(value) {
565
+ return value.replace(/[&<>"']/g, (ch) => {
566
+ switch (ch) {
567
+ case "&":
568
+ return "&amp;";
569
+ case "<":
570
+ return "&lt;";
571
+ case ">":
572
+ return "&gt;";
573
+ case '"':
574
+ return "&quot;";
575
+ default:
576
+ return "&#39;";
577
+ }
578
+ });
579
+ }
580
+ function buildErrorPage(status, error, errorDescription) {
581
+ const safeDescription = escapeHtml(errorDescription);
582
+ const safeError = escapeHtml(error);
583
+ return `<!doctype html>
584
+ <html lang="zh-CN">
585
+ <head>
586
+ <meta charset="utf-8" />
587
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
588
+ <meta name="robots" content="noindex" />
589
+ <title>\u767B\u5F55\u5931\u8D25</title>
590
+ <style>
591
+ :root { color-scheme: light; }
592
+ body { margin: 0; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #fafafa; color: #2c2c2c; }
593
+ main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }
594
+ h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }
595
+ p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }
596
+ .actions { display: flex; gap: 0.75rem; }
597
+ a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }
598
+ a.primary { background: #2c2c2c; color: #fff; }
599
+ a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }
600
+ .code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }
601
+ </style>
602
+ </head>
603
+ <body>
604
+ <main>
605
+ <h1>\u767B\u5F55\u5931\u8D25</h1>
606
+ <p>${safeDescription}</p>
607
+ <div class="actions">
608
+ <a class="primary" href="/">\u91CD\u65B0\u767B\u5F55</a>
609
+ <a class="secondary" href="/">\u8FD4\u56DE\u9996\u9875</a>
610
+ </div>
611
+ <p class="code">\u9519\u8BEF\u7801\uFF1A${safeError}\uFF08\u53CD\u9988\u95EE\u9898\u65F6\u8BF7\u9644\u4E0A\uFF09</p>
612
+ </main>
613
+ </body>
614
+ </html>`;
615
+ }
616
+ function buildErrorResponse(request, status, error, errorDescription, custom) {
617
+ if (custom) {
618
+ const handled = custom({ error, errorDescription, status, request });
619
+ if (handled instanceof Promise) {
620
+ return handled.then(
621
+ (res) => res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)
622
+ );
623
+ }
624
+ if (handled) return toNextResponse(handled);
625
+ }
626
+ return defaultErrorResponse(request, status, error, errorDescription);
627
+ }
628
+ function toNextResponse(res) {
629
+ if (res instanceof import_server2.NextResponse) return res;
630
+ return new import_server2.NextResponse(res.body, {
631
+ status: res.status,
632
+ statusText: res.statusText,
633
+ headers: res.headers
634
+ });
635
+ }
636
+ function defaultErrorResponse(request, status, error, errorDescription) {
637
+ const accept = request.headers.get("accept") ?? "";
638
+ const wantsHtml = accept.includes("text/html") && request.nextUrl.searchParams.get("format") !== "json";
639
+ if (wantsHtml) {
640
+ return new import_server2.NextResponse(buildErrorPage(status, error, errorDescription), {
641
+ status,
642
+ headers: {
643
+ "Content-Type": "text/html; charset=utf-8",
644
+ "Cache-Control": "no-store"
645
+ }
646
+ });
647
+ }
648
+ return import_server2.NextResponse.json({ error, error_description: errorDescription }, { status });
649
+ }
550
650
  function createCallbackRouteHandler(config) {
551
651
  const {
552
652
  clientId,
@@ -554,6 +654,7 @@ function createCallbackRouteHandler(config) {
554
654
  redirectUri,
555
655
  clientSecret,
556
656
  defaultReturnPath = "/",
657
+ scopes,
557
658
  insecureLocalDev: insecureLocalDevOpt = false
558
659
  } = config;
559
660
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -571,7 +672,21 @@ function createCallbackRouteHandler(config) {
571
672
  return async function GET(request) {
572
673
  const response = await handleCallback(request);
573
674
  if (response.status >= 400) {
675
+ if (config.debug) {
676
+ console.warn(
677
+ `[SSO SDK] \u56DE\u8C03\u5931\u8D25 status=${response.status}`,
678
+ request.nextUrl.searchParams.get("error") ?? ""
679
+ );
680
+ }
574
681
  response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
682
+ const returnedState = request.nextUrl.searchParams.get("state");
683
+ if (returnedState && /^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
684
+ response.cookies.set(
685
+ `${nonceCookieName}_${returnedState}`,
686
+ "",
687
+ getHostCookieOptions(0, secureCookies)
688
+ );
689
+ }
575
690
  }
576
691
  return response;
577
692
  };
@@ -580,50 +695,75 @@ function createCallbackRouteHandler(config) {
580
695
  const error = searchParams.get("error");
581
696
  if (error) {
582
697
  const desc = searchParams.get("error_description") || error;
583
- return import_server2.NextResponse.json(
584
- { error: "authorization_failed", error_description: desc },
585
- { status: 400 }
698
+ return buildErrorResponse(
699
+ request,
700
+ 400,
701
+ "authorization_failed",
702
+ desc,
703
+ config.renderErrorPage
586
704
  );
587
705
  }
588
706
  const code = searchParams.get("code");
589
707
  const returnedState = searchParams.get("state");
590
708
  if (!code) {
591
- return import_server2.NextResponse.json(
592
- {
593
- error: "invalid_request",
594
- error_description: "\u7F3A\u5C11 authorization code"
595
- },
596
- { status: 400 }
709
+ return buildErrorResponse(
710
+ request,
711
+ 400,
712
+ "invalid_request",
713
+ "\u767B\u5F55\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
714
+ config.renderErrorPage
597
715
  );
598
716
  }
599
- const savedState = request.cookies.get(stateCookieName)?.value;
717
+ if (!returnedState || !/^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
718
+ return buildErrorResponse(
719
+ request,
720
+ 400,
721
+ "invalid_request",
722
+ "State \u53C2\u6570\u7F3A\u5931\u6216\u683C\u5F0F\u975E\u6CD5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
723
+ config.renderErrorPage
724
+ );
725
+ }
726
+ const attemptSuffix = `_${returnedState}`;
727
+ const readTransientCookie = (name) => request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;
728
+ const savedState = readTransientCookie(stateCookieName);
600
729
  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 }
730
+ const alternateName = secureCookies ? toInsecureCookieName(stateCookieName) : DEFAULT_STATE_COOKIE_NAME;
731
+ const alternateValue = request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ?? request.cookies.get(alternateName)?.value;
732
+ if (alternateValue) {
733
+ return buildErrorResponse(
734
+ request,
735
+ 500,
736
+ "invalid_config",
737
+ "\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",
738
+ config.renderErrorPage
739
+ );
740
+ }
741
+ return buildErrorResponse(
742
+ request,
743
+ 400,
744
+ "invalid_request",
745
+ "\u767B\u5F55\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
746
+ config.renderErrorPage
607
747
  );
608
748
  }
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 }
749
+ if (!timingSafeEqualString(returnedState, savedState)) {
750
+ return buildErrorResponse(
751
+ request,
752
+ 400,
753
+ "invalid_request",
754
+ "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
755
+ config.renderErrorPage
616
756
  );
617
757
  }
618
- const expectedNonce = request.cookies.get(nonceCookieName)?.value;
619
- const verifier = request.cookies.get(verifierCookieName)?.value;
758
+ const expectedNonce = readTransientCookie(nonceCookieName);
759
+ const verifier = readTransientCookie(verifierCookieName);
620
760
  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 }
761
+ return buildErrorResponse(
762
+ request,
763
+ 400,
764
+ "invalid_request",
765
+ "\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",
766
+ config.renderErrorPage
627
767
  );
628
768
  }
629
769
  const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
@@ -654,17 +794,23 @@ function createCallbackRouteHandler(config) {
654
794
  } catch (err) {
655
795
  lastError = err;
656
796
  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 }
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
660
803
  );
661
804
  }
662
805
  }
663
806
  }
664
807
  if (lastError || !res) {
665
- return import_server2.NextResponse.json(
666
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25" },
667
- { status: 502 }
808
+ return buildErrorResponse(
809
+ request,
810
+ 502,
811
+ "server_error",
812
+ "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
813
+ config.renderErrorPage
668
814
  );
669
815
  }
670
816
  if (!res.ok) {
@@ -673,22 +819,32 @@ function createCallbackRouteHandler(config) {
673
819
  errData = await res.json();
674
820
  } catch {
675
821
  }
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 }
822
+ return buildErrorResponse(
823
+ request,
824
+ 502,
825
+ "token_request_failed",
826
+ errData.error_description || `\u767B\u5F55\u5931\u8D25\uFF08Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
827
+ config.renderErrorPage
682
828
  );
683
829
  }
684
830
  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 }
831
+ if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in) || tokenData.expires_in <= 0) {
832
+ return buildErrorResponse(
833
+ request,
834
+ 502,
835
+ "server_error",
836
+ "\u767B\u5F55\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08Token \u54CD\u5E94\u4E0D\u5B8C\u6574\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
837
+ config.renderErrorPage
838
+ );
839
+ }
840
+ const requiresIdToken = scopes !== void 0 && scopes.split(" ").filter(Boolean).includes("openid");
841
+ if (requiresIdToken && !tokenData.id_token) {
842
+ return buildErrorResponse(
843
+ request,
844
+ 400,
845
+ "id_token_invalid",
846
+ "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
847
+ config.renderErrorPage
692
848
  );
693
849
  }
694
850
  if (tokenData.id_token) {
@@ -701,17 +857,17 @@ function createCallbackRouteHandler(config) {
701
857
  { expectedNonce }
702
858
  );
703
859
  } 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 }
860
+ return buildErrorResponse(
861
+ request,
862
+ 400,
863
+ "id_token_invalid",
864
+ err instanceof Error && /[\u4e00-\u9fff]/.test(err.message) ? err.message : "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
865
+ config.renderErrorPage
710
866
  );
711
867
  }
712
868
  }
713
869
  const callbackOrigin = new URL(redirectUri).origin;
714
- const rawReturnUrl = request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;
870
+ const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
715
871
  const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
716
872
  const response = import_server2.NextResponse.redirect(new URL(returnUrl, callbackOrigin));
717
873
  response.cookies.set(accessTokenCookieName, tokenData.access_token, {
@@ -741,11 +897,18 @@ function createCallbackRouteHandler(config) {
741
897
  });
742
898
  }
743
899
  }
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));
900
+ for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {
901
+ response.cookies.set(name, "", getHostCookieOptions(0, secureCookies));
902
+ response.cookies.set(`${name}${attemptSuffix}`, "", getHostCookieOptions(0, secureCookies));
903
+ }
904
+ for (const path of ["/", request.nextUrl.pathname]) {
905
+ response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, path, secureCookies));
906
+ response.cookies.set(
907
+ `${verifierCookieName}${attemptSuffix}`,
908
+ "",
909
+ getSecureCookieOptions(0, path, secureCookies)
910
+ );
911
+ }
749
912
  return response;
750
913
  }
751
914
  }
@@ -875,6 +1038,20 @@ function createLogoutRouteHandler(config) {
875
1038
  headers: { "content-type": "text/html; charset=utf-8" }
876
1039
  });
877
1040
  }
1041
+ const requestOrigin = request.headers.get("origin");
1042
+ if (requestOrigin && requestOrigin !== callbackOrigin) {
1043
+ return import_server3.NextResponse.json(
1044
+ { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1045
+ { status: 403 }
1046
+ );
1047
+ }
1048
+ const secFetchSite = request.headers.get("sec-fetch-site");
1049
+ if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
1050
+ return import_server3.NextResponse.json(
1051
+ { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1052
+ { status: 403 }
1053
+ );
1054
+ }
878
1055
  let formGlobal = null;
879
1056
  try {
880
1057
  formGlobal = new URLSearchParams(await request.text()).get("global");
@@ -913,6 +1090,21 @@ function createLogoutRouteHandler(config) {
913
1090
  res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
914
1091
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
915
1092
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
1093
+ const suffixedPrefixes = [
1094
+ `${stateCookieName}_`,
1095
+ `${nonceCookieName}_`,
1096
+ `${returnUrlCookieName}_`,
1097
+ `${verifierCookieName}_`
1098
+ ];
1099
+ for (const cookie of request.cookies.getAll()) {
1100
+ if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;
1101
+ if (cookie.name.startsWith(`${verifierCookieName}_`)) {
1102
+ res.cookies.set(cookie.name, "", getSecureCookieOptions(0, "/", secureCookies));
1103
+ res.cookies.set(cookie.name, "", getSecureCookieOptions(0, callbackPath, secureCookies));
1104
+ } else {
1105
+ res.cookies.set(cookie.name, "", getHostCookieOptions(0, secureCookies));
1106
+ }
1107
+ }
916
1108
  return res;
917
1109
  };
918
1110
  if (effectiveScope === "global") {
@@ -946,6 +1138,9 @@ var import_server4 = require("next/server");
946
1138
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
947
1139
  var JTI_CACHE_CAPACITY = 1e3;
948
1140
  var seenJti = /* @__PURE__ */ new Map();
1141
+ function releaseLogoutTokenJti(jti) {
1142
+ seenJti.delete(jti);
1143
+ }
949
1144
  function recordJti(jti, expiresAtMs) {
950
1145
  const now = Date.now();
951
1146
  const existing = seenJti.get(jti);
@@ -961,7 +1156,7 @@ function recordJti(jti, expiresAtMs) {
961
1156
  seenJti.set(jti, expiresAtMs);
962
1157
  return true;
963
1158
  }
964
- async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1159
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
965
1160
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
966
1161
  const header = decodeJwtHeader(logoutToken);
967
1162
  if (!header) {
@@ -1038,8 +1233,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1038
1233
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
1039
1234
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1040
1235
  }
1236
+ if (payload.type !== "logout_token") {
1237
+ throw new SsoError(
1238
+ "logout_token_invalid",
1239
+ "Logout Token type \u5FC5\u987B\u4E3A logout_token"
1240
+ );
1241
+ }
1041
1242
  const events = payload.events;
1042
- if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
1243
+ if (!events || typeof events !== "object" || Array.isArray(events)) {
1244
+ throw new SsoError(
1245
+ "logout_token_invalid",
1246
+ "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
1247
+ );
1248
+ }
1249
+ const logoutEvent = events[BACKCHANNEL_LOGOUT_EVENT];
1250
+ if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
1043
1251
  throw new SsoError(
1044
1252
  "logout_token_invalid",
1045
1253
  "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
@@ -1060,7 +1268,7 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1060
1268
  if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1061
1269
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1062
1270
  }
1063
- return { sub, sid };
1271
+ return { payload: { sub, sid }, jti };
1064
1272
  }
1065
1273
 
1066
1274
  // src/next/backchannel-logout.ts
@@ -1100,9 +1308,9 @@ function createBackchannelLogoutRouteHandler(config) {
1100
1308
  { status: 400 }
1101
1309
  );
1102
1310
  }
1103
- let payload;
1311
+ let verified;
1104
1312
  try {
1105
- payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
1313
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
1106
1314
  } catch (err) {
1107
1315
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1108
1316
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
@@ -1112,8 +1320,9 @@ function createBackchannelLogoutRouteHandler(config) {
1112
1320
  );
1113
1321
  }
1114
1322
  try {
1115
- await onLogout?.(payload, request);
1323
+ await onLogout?.(verified.payload, request);
1116
1324
  } catch (err) {
1325
+ releaseLogoutTokenJti(verified.jti);
1117
1326
  console.error(
1118
1327
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1119
1328
  err