@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.
@@ -156,8 +156,7 @@ function createSsoMiddleware(config) {
156
156
  scopes = "openid profile",
157
157
  publicPaths = [],
158
158
  callbackPath = "/api/auth/callback",
159
- ssoCookieName = "__Host-user_token",
160
- validateSsoCookie = true,
159
+ failClosedOnIntrospectionError = false,
161
160
  insecureLocalDev: insecureLocalDevOpt = false
162
161
  } = config;
163
162
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -169,9 +168,9 @@ function createSsoMiddleware(config) {
169
168
  const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
170
169
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
171
170
  const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
172
- if (!validateSsoCookie) {
171
+ if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
173
172
  console.warn(
174
- "[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"
173
+ '[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'
175
174
  );
176
175
  }
177
176
  if (!clientSecret) {
@@ -191,22 +190,6 @@ function createSsoMiddleware(config) {
191
190
  if (matchesPath(pathname, allPublicPaths)) {
192
191
  return NextResponse.next();
193
192
  }
194
- const ssoSession = request.cookies.get(ssoCookieName);
195
- if (ssoSession?.value) {
196
- if (validateSsoCookie) {
197
- const introspectResult = await introspectAccessToken(
198
- ssoSession.value,
199
- normalizedServerBase,
200
- clientId,
201
- clientSecret
202
- );
203
- if (introspectResult === "active") {
204
- return NextResponse.next();
205
- }
206
- } else {
207
- return NextResponse.next();
208
- }
209
- }
210
193
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
211
194
  if (accessTokenCookie?.value) {
212
195
  const introspectResult = await introspectAccessToken(
@@ -219,6 +202,15 @@ function createSsoMiddleware(config) {
219
202
  return NextResponse.next();
220
203
  }
221
204
  if (introspectResult === "unreachable") {
205
+ if (failClosedOnIntrospectionError) {
206
+ console.warn(
207
+ "[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"
208
+ );
209
+ return new NextResponse(
210
+ "SSO \u8BA4\u8BC1\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08introspection unreachable\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
211
+ { status: 502 }
212
+ );
213
+ }
222
214
  console.warn(
223
215
  "[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"
224
216
  );
@@ -241,11 +233,28 @@ function createSsoMiddleware(config) {
241
233
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
242
234
  loginUrl.search = authorizeParams.toString();
243
235
  const response = NextResponse.redirect(loginUrl);
244
- response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
245
- response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));
246
- response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));
236
+ const attemptSuffix = `_${state}`;
237
+ response.cookies.set(
238
+ `${stateCookieName}${attemptSuffix}`,
239
+ state,
240
+ getHostCookieOptions(600, secureCookies)
241
+ );
242
+ response.cookies.set(
243
+ `${nonceCookieName}${attemptSuffix}`,
244
+ nonce,
245
+ getHostCookieOptions(600, secureCookies)
246
+ );
247
+ response.cookies.set(
248
+ `${verifierCookieName}${attemptSuffix}`,
249
+ verifier,
250
+ getSecureCookieOptions(600, callbackPath, secureCookies)
251
+ );
247
252
  const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
248
- response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
253
+ response.cookies.set(
254
+ `${returnUrlCookieName}${attemptSuffix}`,
255
+ safeReturnUrl,
256
+ getHostCookieOptions(600, secureCookies)
257
+ );
249
258
  if (accessTokenCookie?.value) {
250
259
  response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
251
260
  }
@@ -268,12 +277,17 @@ var SsoError = class extends Error {
268
277
  };
269
278
 
270
279
  // src/core/security.ts
280
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
271
281
  function isTrustedReturnUrl(url, currentOrigin) {
272
282
  if (!url) return false;
273
- if (url.includes("\\")) return false;
274
- if (url.startsWith("/") && !url.startsWith("//")) return true;
283
+ if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
284
+ if (url.startsWith("//")) return false;
285
+ if (url.startsWith("/")) return true;
275
286
  try {
276
- return new URL(url).origin === currentOrigin;
287
+ const parsed = new URL(url);
288
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
289
+ if (parsed.username || parsed.password) return false;
290
+ return parsed.origin === currentOrigin;
277
291
  } catch {
278
292
  return false;
279
293
  }
@@ -507,6 +521,92 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
507
521
  }
508
522
 
509
523
  // src/next/callback.ts
524
+ function escapeHtml(value) {
525
+ return value.replace(/[&<>"']/g, (ch) => {
526
+ switch (ch) {
527
+ case "&":
528
+ return "&amp;";
529
+ case "<":
530
+ return "&lt;";
531
+ case ">":
532
+ return "&gt;";
533
+ case '"':
534
+ return "&quot;";
535
+ default:
536
+ return "&#39;";
537
+ }
538
+ });
539
+ }
540
+ function buildErrorPage(status, error, errorDescription) {
541
+ const safeDescription = escapeHtml(errorDescription);
542
+ const safeError = escapeHtml(error);
543
+ return `<!doctype html>
544
+ <html lang="zh-CN">
545
+ <head>
546
+ <meta charset="utf-8" />
547
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
548
+ <meta name="robots" content="noindex" />
549
+ <title>\u767B\u5F55\u5931\u8D25</title>
550
+ <style>
551
+ :root { color-scheme: light; }
552
+ body { margin: 0; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #fafafa; color: #2c2c2c; }
553
+ main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }
554
+ h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }
555
+ p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }
556
+ .actions { display: flex; gap: 0.75rem; }
557
+ a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }
558
+ a.primary { background: #2c2c2c; color: #fff; }
559
+ a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }
560
+ .code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }
561
+ </style>
562
+ </head>
563
+ <body>
564
+ <main>
565
+ <h1>\u767B\u5F55\u5931\u8D25</h1>
566
+ <p>${safeDescription}</p>
567
+ <div class="actions">
568
+ <a class="primary" href="/">\u91CD\u65B0\u767B\u5F55</a>
569
+ <a class="secondary" href="/">\u8FD4\u56DE\u9996\u9875</a>
570
+ </div>
571
+ <p class="code">\u9519\u8BEF\u7801\uFF1A${safeError}\uFF08\u53CD\u9988\u95EE\u9898\u65F6\u8BF7\u9644\u4E0A\uFF09</p>
572
+ </main>
573
+ </body>
574
+ </html>`;
575
+ }
576
+ function buildErrorResponse(request, status, error, errorDescription, custom) {
577
+ if (custom) {
578
+ const handled = custom({ error, errorDescription, status, request });
579
+ if (handled instanceof Promise) {
580
+ return handled.then(
581
+ (res) => res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)
582
+ );
583
+ }
584
+ if (handled) return toNextResponse(handled);
585
+ }
586
+ return defaultErrorResponse(request, status, error, errorDescription);
587
+ }
588
+ function toNextResponse(res) {
589
+ if (res instanceof NextResponse2) return res;
590
+ return new NextResponse2(res.body, {
591
+ status: res.status,
592
+ statusText: res.statusText,
593
+ headers: res.headers
594
+ });
595
+ }
596
+ function defaultErrorResponse(request, status, error, errorDescription) {
597
+ const accept = request.headers.get("accept") ?? "";
598
+ const wantsHtml = accept.includes("text/html") && request.nextUrl.searchParams.get("format") !== "json";
599
+ if (wantsHtml) {
600
+ return new NextResponse2(buildErrorPage(status, error, errorDescription), {
601
+ status,
602
+ headers: {
603
+ "Content-Type": "text/html; charset=utf-8",
604
+ "Cache-Control": "no-store"
605
+ }
606
+ });
607
+ }
608
+ return NextResponse2.json({ error, error_description: errorDescription }, { status });
609
+ }
510
610
  function createCallbackRouteHandler(config) {
511
611
  const {
512
612
  clientId,
@@ -514,6 +614,7 @@ function createCallbackRouteHandler(config) {
514
614
  redirectUri,
515
615
  clientSecret,
516
616
  defaultReturnPath = "/",
617
+ scopes,
517
618
  insecureLocalDev: insecureLocalDevOpt = false
518
619
  } = config;
519
620
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -531,7 +632,21 @@ function createCallbackRouteHandler(config) {
531
632
  return async function GET(request) {
532
633
  const response = await handleCallback(request);
533
634
  if (response.status >= 400) {
635
+ if (config.debug) {
636
+ console.warn(
637
+ `[SSO SDK] \u56DE\u8C03\u5931\u8D25 status=${response.status}`,
638
+ request.nextUrl.searchParams.get("error") ?? ""
639
+ );
640
+ }
534
641
  response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
642
+ const returnedState = request.nextUrl.searchParams.get("state");
643
+ if (returnedState && /^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
644
+ response.cookies.set(
645
+ `${nonceCookieName}_${returnedState}`,
646
+ "",
647
+ getHostCookieOptions(0, secureCookies)
648
+ );
649
+ }
535
650
  }
536
651
  return response;
537
652
  };
@@ -540,50 +655,75 @@ function createCallbackRouteHandler(config) {
540
655
  const error = searchParams.get("error");
541
656
  if (error) {
542
657
  const desc = searchParams.get("error_description") || error;
543
- return NextResponse2.json(
544
- { error: "authorization_failed", error_description: desc },
545
- { status: 400 }
658
+ return buildErrorResponse(
659
+ request,
660
+ 400,
661
+ "authorization_failed",
662
+ desc,
663
+ config.renderErrorPage
546
664
  );
547
665
  }
548
666
  const code = searchParams.get("code");
549
667
  const returnedState = searchParams.get("state");
550
668
  if (!code) {
551
- return NextResponse2.json(
552
- {
553
- error: "invalid_request",
554
- error_description: "\u7F3A\u5C11 authorization code"
555
- },
556
- { status: 400 }
669
+ return buildErrorResponse(
670
+ request,
671
+ 400,
672
+ "invalid_request",
673
+ "\u767B\u5F55\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
674
+ config.renderErrorPage
557
675
  );
558
676
  }
559
- const savedState = request.cookies.get(stateCookieName)?.value;
677
+ if (!returnedState || !/^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
678
+ return buildErrorResponse(
679
+ request,
680
+ 400,
681
+ "invalid_request",
682
+ "State \u53C2\u6570\u7F3A\u5931\u6216\u683C\u5F0F\u975E\u6CD5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
683
+ config.renderErrorPage
684
+ );
685
+ }
686
+ const attemptSuffix = `_${returnedState}`;
687
+ const readTransientCookie = (name) => request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;
688
+ const savedState = readTransientCookie(stateCookieName);
560
689
  if (!savedState) {
561
- return NextResponse2.json(
562
- {
563
- error: "invalid_request",
564
- error_description: "State \u53C2\u6570\u7F3A\u5931\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42"
565
- },
566
- { status: 400 }
690
+ const alternateName = secureCookies ? toInsecureCookieName(stateCookieName) : DEFAULT_STATE_COOKIE_NAME;
691
+ const alternateValue = request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ?? request.cookies.get(alternateName)?.value;
692
+ if (alternateValue) {
693
+ return buildErrorResponse(
694
+ request,
695
+ 500,
696
+ "invalid_config",
697
+ "\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",
698
+ config.renderErrorPage
699
+ );
700
+ }
701
+ return buildErrorResponse(
702
+ request,
703
+ 400,
704
+ "invalid_request",
705
+ "\u767B\u5F55\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
706
+ config.renderErrorPage
567
707
  );
568
708
  }
569
- if (!timingSafeEqualString(returnedState ?? "", savedState)) {
570
- return NextResponse2.json(
571
- {
572
- error: "invalid_request",
573
- error_description: "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
574
- },
575
- { status: 400 }
709
+ if (!timingSafeEqualString(returnedState, savedState)) {
710
+ return buildErrorResponse(
711
+ request,
712
+ 400,
713
+ "invalid_request",
714
+ "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
715
+ config.renderErrorPage
576
716
  );
577
717
  }
578
- const expectedNonce = request.cookies.get(nonceCookieName)?.value;
579
- const verifier = request.cookies.get(verifierCookieName)?.value;
718
+ const expectedNonce = readTransientCookie(nonceCookieName);
719
+ const verifier = readTransientCookie(verifierCookieName);
580
720
  if (!verifier) {
581
- return NextResponse2.json(
582
- {
583
- error: "invalid_request",
584
- error_description: "PKCE verifier \u7F3A\u5931\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42"
585
- },
586
- { status: 400 }
721
+ return buildErrorResponse(
722
+ request,
723
+ 400,
724
+ "invalid_request",
725
+ "\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",
726
+ config.renderErrorPage
587
727
  );
588
728
  }
589
729
  const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
@@ -614,17 +754,23 @@ function createCallbackRouteHandler(config) {
614
754
  } catch (err) {
615
755
  lastError = err;
616
756
  if (attempt >= maxRetries) {
617
- return NextResponse2.json(
618
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25\uFF0C\u5DF2\u91CD\u8BD5\u4ECD\u4E0D\u53EF\u8FBE" },
619
- { status: 502 }
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
620
763
  );
621
764
  }
622
765
  }
623
766
  }
624
767
  if (lastError || !res) {
625
- return NextResponse2.json(
626
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25" },
627
- { status: 502 }
768
+ return buildErrorResponse(
769
+ request,
770
+ 502,
771
+ "server_error",
772
+ "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
773
+ config.renderErrorPage
628
774
  );
629
775
  }
630
776
  if (!res.ok) {
@@ -633,22 +779,32 @@ function createCallbackRouteHandler(config) {
633
779
  errData = await res.json();
634
780
  } catch {
635
781
  }
636
- return NextResponse2.json(
637
- {
638
- error: "token_request_failed",
639
- error_description: errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
640
- },
641
- { status: 502 }
782
+ return buildErrorResponse(
783
+ request,
784
+ 502,
785
+ "token_request_failed",
786
+ errData.error_description || `\u767B\u5F55\u5931\u8D25\uFF08Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
787
+ config.renderErrorPage
642
788
  );
643
789
  }
644
790
  const tokenData = await res.json();
645
- if (!tokenData.access_token || !tokenData.refresh_token) {
646
- return NextResponse2.json(
647
- {
648
- error: "server_error",
649
- error_description: "Token \u54CD\u5E94\u7F3A\u5C11 access_token \u6216 refresh_token"
650
- },
651
- { status: 502 }
791
+ if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in) || tokenData.expires_in <= 0) {
792
+ return buildErrorResponse(
793
+ request,
794
+ 502,
795
+ "server_error",
796
+ "\u767B\u5F55\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08Token \u54CD\u5E94\u4E0D\u5B8C\u6574\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
797
+ config.renderErrorPage
798
+ );
799
+ }
800
+ const requiresIdToken = scopes !== void 0 && scopes.split(" ").filter(Boolean).includes("openid");
801
+ if (requiresIdToken && !tokenData.id_token) {
802
+ return buildErrorResponse(
803
+ request,
804
+ 400,
805
+ "id_token_invalid",
806
+ "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
807
+ config.renderErrorPage
652
808
  );
653
809
  }
654
810
  if (tokenData.id_token) {
@@ -661,17 +817,17 @@ function createCallbackRouteHandler(config) {
661
817
  { expectedNonce }
662
818
  );
663
819
  } catch (err) {
664
- return NextResponse2.json(
665
- {
666
- error: "id_token_invalid",
667
- error_description: err instanceof Error ? err.message : "ID Token \u9A8C\u8BC1\u5931\u8D25"
668
- },
669
- { status: 400 }
820
+ return buildErrorResponse(
821
+ request,
822
+ 400,
823
+ "id_token_invalid",
824
+ err instanceof Error && /[\u4e00-\u9fff]/.test(err.message) ? err.message : "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
825
+ config.renderErrorPage
670
826
  );
671
827
  }
672
828
  }
673
829
  const callbackOrigin = new URL(redirectUri).origin;
674
- const rawReturnUrl = request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;
830
+ const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
675
831
  const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
676
832
  const response = NextResponse2.redirect(new URL(returnUrl, callbackOrigin));
677
833
  response.cookies.set(accessTokenCookieName, tokenData.access_token, {
@@ -701,11 +857,18 @@ function createCallbackRouteHandler(config) {
701
857
  });
702
858
  }
703
859
  }
704
- response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
705
- response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
706
- response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
707
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
708
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));
860
+ for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {
861
+ response.cookies.set(name, "", getHostCookieOptions(0, secureCookies));
862
+ response.cookies.set(`${name}${attemptSuffix}`, "", getHostCookieOptions(0, secureCookies));
863
+ }
864
+ for (const path of ["/", request.nextUrl.pathname]) {
865
+ response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, path, secureCookies));
866
+ response.cookies.set(
867
+ `${verifierCookieName}${attemptSuffix}`,
868
+ "",
869
+ getSecureCookieOptions(0, path, secureCookies)
870
+ );
871
+ }
709
872
  return response;
710
873
  }
711
874
  }
@@ -835,6 +998,20 @@ function createLogoutRouteHandler(config) {
835
998
  headers: { "content-type": "text/html; charset=utf-8" }
836
999
  });
837
1000
  }
1001
+ const requestOrigin = request.headers.get("origin");
1002
+ if (requestOrigin && requestOrigin !== callbackOrigin) {
1003
+ return NextResponse3.json(
1004
+ { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1005
+ { status: 403 }
1006
+ );
1007
+ }
1008
+ const secFetchSite = request.headers.get("sec-fetch-site");
1009
+ if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
1010
+ return NextResponse3.json(
1011
+ { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1012
+ { status: 403 }
1013
+ );
1014
+ }
838
1015
  let formGlobal = null;
839
1016
  try {
840
1017
  formGlobal = new URLSearchParams(await request.text()).get("global");
@@ -873,6 +1050,21 @@ function createLogoutRouteHandler(config) {
873
1050
  res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
874
1051
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
875
1052
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
1053
+ const suffixedPrefixes = [
1054
+ `${stateCookieName}_`,
1055
+ `${nonceCookieName}_`,
1056
+ `${returnUrlCookieName}_`,
1057
+ `${verifierCookieName}_`
1058
+ ];
1059
+ for (const cookie of request.cookies.getAll()) {
1060
+ if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;
1061
+ if (cookie.name.startsWith(`${verifierCookieName}_`)) {
1062
+ res.cookies.set(cookie.name, "", getSecureCookieOptions(0, "/", secureCookies));
1063
+ res.cookies.set(cookie.name, "", getSecureCookieOptions(0, callbackPath, secureCookies));
1064
+ } else {
1065
+ res.cookies.set(cookie.name, "", getHostCookieOptions(0, secureCookies));
1066
+ }
1067
+ }
876
1068
  return res;
877
1069
  };
878
1070
  if (effectiveScope === "global") {
@@ -906,6 +1098,9 @@ import { NextResponse as NextResponse4 } from "next/server";
906
1098
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
907
1099
  var JTI_CACHE_CAPACITY = 1e3;
908
1100
  var seenJti = /* @__PURE__ */ new Map();
1101
+ function releaseLogoutTokenJti(jti) {
1102
+ seenJti.delete(jti);
1103
+ }
909
1104
  function recordJti(jti, expiresAtMs) {
910
1105
  const now = Date.now();
911
1106
  const existing = seenJti.get(jti);
@@ -921,7 +1116,7 @@ function recordJti(jti, expiresAtMs) {
921
1116
  seenJti.set(jti, expiresAtMs);
922
1117
  return true;
923
1118
  }
924
- async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1119
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
925
1120
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
926
1121
  const header = decodeJwtHeader(logoutToken);
927
1122
  if (!header) {
@@ -998,8 +1193,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
998
1193
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
999
1194
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1000
1195
  }
1196
+ if (payload.type !== "logout_token") {
1197
+ throw new SsoError(
1198
+ "logout_token_invalid",
1199
+ "Logout Token type \u5FC5\u987B\u4E3A logout_token"
1200
+ );
1201
+ }
1001
1202
  const events = payload.events;
1002
- if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
1203
+ if (!events || typeof events !== "object" || Array.isArray(events)) {
1204
+ throw new SsoError(
1205
+ "logout_token_invalid",
1206
+ "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
1207
+ );
1208
+ }
1209
+ const logoutEvent = events[BACKCHANNEL_LOGOUT_EVENT];
1210
+ if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
1003
1211
  throw new SsoError(
1004
1212
  "logout_token_invalid",
1005
1213
  "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
@@ -1020,7 +1228,7 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1020
1228
  if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1021
1229
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1022
1230
  }
1023
- return { sub, sid };
1231
+ return { payload: { sub, sid }, jti };
1024
1232
  }
1025
1233
 
1026
1234
  // src/next/backchannel-logout.ts
@@ -1060,9 +1268,9 @@ function createBackchannelLogoutRouteHandler(config) {
1060
1268
  { status: 400 }
1061
1269
  );
1062
1270
  }
1063
- let payload;
1271
+ let verified;
1064
1272
  try {
1065
- payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
1273
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
1066
1274
  } catch (err) {
1067
1275
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1068
1276
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
@@ -1072,8 +1280,9 @@ function createBackchannelLogoutRouteHandler(config) {
1072
1280
  );
1073
1281
  }
1074
1282
  try {
1075
- await onLogout?.(payload, request);
1283
+ await onLogout?.(verified.payload, request);
1076
1284
  } catch (err) {
1285
+ releaseLogoutTokenJti(verified.jti);
1077
1286
  console.error(
1078
1287
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1079
1288
  err