@nihplod/sso-sdk 1.4.0 → 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.
@@ -82,6 +82,7 @@ function matchesPath(pathname, paths) {
82
82
  var introspectionCache = /* @__PURE__ */ new Map();
83
83
  var INTROSPECT_CACHE_TTL_MS = 3e4;
84
84
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
85
+ var INTROSPECT_TIMEOUT_MS = 5e3;
85
86
  async function introspectCacheKey(token, clientId) {
86
87
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
87
88
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -114,7 +115,7 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
114
115
  const cacheKey = await introspectCacheKey(token, clientId);
115
116
  const cached = introspectCacheGet(cacheKey);
116
117
  if (cached) {
117
- return cached.active;
118
+ return cached.active ? "active" : "inactive";
118
119
  }
119
120
  try {
120
121
  const body = new URLSearchParams({
@@ -128,21 +129,22 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
128
129
  const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {
129
130
  method: "POST",
130
131
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
131
- body: body.toString()
132
+ body: body.toString(),
133
+ signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
132
134
  });
133
135
  if (!res.ok) {
134
136
  if (res.status !== 401 && res.status !== 403) {
135
- return false;
137
+ return "unreachable";
136
138
  }
137
139
  introspectCacheSet(cacheKey, false);
138
- return false;
140
+ return "inactive";
139
141
  }
140
142
  const data = await res.json();
141
143
  const active = data.active === true;
142
144
  introspectCacheSet(cacheKey, active);
143
- return active;
145
+ return active ? "active" : "inactive";
144
146
  } catch {
145
- return false;
147
+ return "unreachable";
146
148
  }
147
149
  }
148
150
  function createSsoMiddleware(config) {
@@ -154,8 +156,7 @@ function createSsoMiddleware(config) {
154
156
  scopes = "openid profile",
155
157
  publicPaths = [],
156
158
  callbackPath = "/api/auth/callback",
157
- ssoCookieName = "__Host-user_token",
158
- validateSsoCookie = true,
159
+ failClosedOnIntrospectionError = false,
159
160
  insecureLocalDev: insecureLocalDevOpt = false
160
161
  } = config;
161
162
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -167,9 +168,9 @@ function createSsoMiddleware(config) {
167
168
  const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
168
169
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
169
170
  const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
170
- if (!validateSsoCookie) {
171
+ if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
171
172
  console.warn(
172
- "[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'
173
174
  );
174
175
  }
175
176
  if (!clientSecret) {
@@ -189,31 +190,30 @@ function createSsoMiddleware(config) {
189
190
  if (matchesPath(pathname, allPublicPaths)) {
190
191
  return NextResponse.next();
191
192
  }
192
- const ssoSession = request.cookies.get(ssoCookieName);
193
- if (ssoSession?.value) {
194
- if (validateSsoCookie) {
195
- const tokenActive = await introspectAccessToken(
196
- ssoSession.value,
197
- normalizedServerBase,
198
- clientId,
199
- clientSecret
200
- );
201
- if (tokenActive) {
202
- return NextResponse.next();
203
- }
204
- } else {
205
- return NextResponse.next();
206
- }
207
- }
208
193
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
209
194
  if (accessTokenCookie?.value) {
210
- const tokenActive = await introspectAccessToken(
195
+ const introspectResult = await introspectAccessToken(
211
196
  accessTokenCookie.value,
212
197
  normalizedServerBase,
213
198
  clientId,
214
199
  clientSecret
215
200
  );
216
- if (tokenActive) {
201
+ if (introspectResult === "active") {
202
+ return NextResponse.next();
203
+ }
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
+ }
214
+ console.warn(
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"
216
+ );
217
217
  return NextResponse.next();
218
218
  }
219
219
  }
@@ -233,11 +233,28 @@ function createSsoMiddleware(config) {
233
233
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
234
234
  loginUrl.search = authorizeParams.toString();
235
235
  const response = NextResponse.redirect(loginUrl);
236
- response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
237
- response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));
238
- 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
+ );
239
252
  const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
240
- response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
253
+ response.cookies.set(
254
+ `${returnUrlCookieName}${attemptSuffix}`,
255
+ safeReturnUrl,
256
+ getHostCookieOptions(600, secureCookies)
257
+ );
241
258
  if (accessTokenCookie?.value) {
242
259
  response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
243
260
  }
@@ -260,12 +277,17 @@ var SsoError = class extends Error {
260
277
  };
261
278
 
262
279
  // src/core/security.ts
280
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
263
281
  function isTrustedReturnUrl(url, currentOrigin) {
264
282
  if (!url) return false;
265
- if (url.includes("\\")) return false;
266
- 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;
267
286
  try {
268
- 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;
269
291
  } catch {
270
292
  return false;
271
293
  }
@@ -499,6 +521,92 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
499
521
  }
500
522
 
501
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
+ }
502
610
  function createCallbackRouteHandler(config) {
503
611
  const {
504
612
  clientId,
@@ -506,6 +614,7 @@ function createCallbackRouteHandler(config) {
506
614
  redirectUri,
507
615
  clientSecret,
508
616
  defaultReturnPath = "/",
617
+ scopes,
509
618
  insecureLocalDev: insecureLocalDevOpt = false
510
619
  } = config;
511
620
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -523,7 +632,21 @@ function createCallbackRouteHandler(config) {
523
632
  return async function GET(request) {
524
633
  const response = await handleCallback(request);
525
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
+ }
526
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
+ }
527
650
  }
528
651
  return response;
529
652
  };
@@ -532,50 +655,75 @@ function createCallbackRouteHandler(config) {
532
655
  const error = searchParams.get("error");
533
656
  if (error) {
534
657
  const desc = searchParams.get("error_description") || error;
535
- return NextResponse2.json(
536
- { error: "authorization_failed", error_description: desc },
537
- { status: 400 }
658
+ return buildErrorResponse(
659
+ request,
660
+ 400,
661
+ "authorization_failed",
662
+ desc,
663
+ config.renderErrorPage
538
664
  );
539
665
  }
540
666
  const code = searchParams.get("code");
541
667
  const returnedState = searchParams.get("state");
542
668
  if (!code) {
543
- return NextResponse2.json(
544
- {
545
- error: "invalid_request",
546
- error_description: "\u7F3A\u5C11 authorization code"
547
- },
548
- { 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
549
675
  );
550
676
  }
551
- 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);
552
689
  if (!savedState) {
553
- return NextResponse2.json(
554
- {
555
- error: "invalid_request",
556
- error_description: "State \u53C2\u6570\u7F3A\u5931\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42"
557
- },
558
- { 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
559
707
  );
560
708
  }
561
- if (!timingSafeEqualString(returnedState ?? "", savedState)) {
562
- return NextResponse2.json(
563
- {
564
- error: "invalid_request",
565
- error_description: "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
566
- },
567
- { 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
568
716
  );
569
717
  }
570
- const expectedNonce = request.cookies.get(nonceCookieName)?.value;
571
- const verifier = request.cookies.get(verifierCookieName)?.value;
718
+ const expectedNonce = readTransientCookie(nonceCookieName);
719
+ const verifier = readTransientCookie(verifierCookieName);
572
720
  if (!verifier) {
573
- return NextResponse2.json(
574
- {
575
- error: "invalid_request",
576
- error_description: "PKCE verifier \u7F3A\u5931\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42"
577
- },
578
- { 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
579
727
  );
580
728
  }
581
729
  const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
@@ -606,17 +754,23 @@ function createCallbackRouteHandler(config) {
606
754
  } catch (err) {
607
755
  lastError = err;
608
756
  if (attempt >= maxRetries) {
609
- return NextResponse2.json(
610
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25\uFF0C\u5DF2\u91CD\u8BD5\u4ECD\u4E0D\u53EF\u8FBE" },
611
- { 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
612
763
  );
613
764
  }
614
765
  }
615
766
  }
616
767
  if (lastError || !res) {
617
- return NextResponse2.json(
618
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25" },
619
- { 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
620
774
  );
621
775
  }
622
776
  if (!res.ok) {
@@ -625,22 +779,32 @@ function createCallbackRouteHandler(config) {
625
779
  errData = await res.json();
626
780
  } catch {
627
781
  }
628
- return NextResponse2.json(
629
- {
630
- error: "token_request_failed",
631
- error_description: errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
632
- },
633
- { 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
634
788
  );
635
789
  }
636
790
  const tokenData = await res.json();
637
- if (!tokenData.access_token || !tokenData.refresh_token) {
638
- return NextResponse2.json(
639
- {
640
- error: "server_error",
641
- error_description: "Token \u54CD\u5E94\u7F3A\u5C11 access_token \u6216 refresh_token"
642
- },
643
- { 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
644
808
  );
645
809
  }
646
810
  if (tokenData.id_token) {
@@ -653,17 +817,17 @@ function createCallbackRouteHandler(config) {
653
817
  { expectedNonce }
654
818
  );
655
819
  } catch (err) {
656
- return NextResponse2.json(
657
- {
658
- error: "id_token_invalid",
659
- error_description: err instanceof Error ? err.message : "ID Token \u9A8C\u8BC1\u5931\u8D25"
660
- },
661
- { 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
662
826
  );
663
827
  }
664
828
  }
665
829
  const callbackOrigin = new URL(redirectUri).origin;
666
- const rawReturnUrl = request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;
830
+ const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
667
831
  const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
668
832
  const response = NextResponse2.redirect(new URL(returnUrl, callbackOrigin));
669
833
  response.cookies.set(accessTokenCookieName, tokenData.access_token, {
@@ -693,11 +857,18 @@ function createCallbackRouteHandler(config) {
693
857
  });
694
858
  }
695
859
  }
696
- response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
697
- response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
698
- response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
699
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
700
- 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
+ }
701
872
  return response;
702
873
  }
703
874
  }
@@ -827,6 +998,20 @@ function createLogoutRouteHandler(config) {
827
998
  headers: { "content-type": "text/html; charset=utf-8" }
828
999
  });
829
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
+ }
830
1015
  let formGlobal = null;
831
1016
  try {
832
1017
  formGlobal = new URLSearchParams(await request.text()).get("global");
@@ -865,6 +1050,21 @@ function createLogoutRouteHandler(config) {
865
1050
  res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
866
1051
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
867
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
+ }
868
1068
  return res;
869
1069
  };
870
1070
  if (effectiveScope === "global") {
@@ -898,6 +1098,9 @@ import { NextResponse as NextResponse4 } from "next/server";
898
1098
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
899
1099
  var JTI_CACHE_CAPACITY = 1e3;
900
1100
  var seenJti = /* @__PURE__ */ new Map();
1101
+ function releaseLogoutTokenJti(jti) {
1102
+ seenJti.delete(jti);
1103
+ }
901
1104
  function recordJti(jti, expiresAtMs) {
902
1105
  const now = Date.now();
903
1106
  const existing = seenJti.get(jti);
@@ -913,7 +1116,7 @@ function recordJti(jti, expiresAtMs) {
913
1116
  seenJti.set(jti, expiresAtMs);
914
1117
  return true;
915
1118
  }
916
- async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1119
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
917
1120
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
918
1121
  const header = decodeJwtHeader(logoutToken);
919
1122
  if (!header) {
@@ -990,8 +1193,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
990
1193
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
991
1194
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
992
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
+ }
993
1202
  const events = payload.events;
994
- 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)) {
995
1211
  throw new SsoError(
996
1212
  "logout_token_invalid",
997
1213
  "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
@@ -1012,7 +1228,7 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1012
1228
  if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1013
1229
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1014
1230
  }
1015
- return { sub, sid };
1231
+ return { payload: { sub, sid }, jti };
1016
1232
  }
1017
1233
 
1018
1234
  // src/next/backchannel-logout.ts
@@ -1052,9 +1268,9 @@ function createBackchannelLogoutRouteHandler(config) {
1052
1268
  { status: 400 }
1053
1269
  );
1054
1270
  }
1055
- let payload;
1271
+ let verified;
1056
1272
  try {
1057
- payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
1273
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
1058
1274
  } catch (err) {
1059
1275
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1060
1276
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
@@ -1064,8 +1280,9 @@ function createBackchannelLogoutRouteHandler(config) {
1064
1280
  );
1065
1281
  }
1066
1282
  try {
1067
- await onLogout?.(payload, request);
1283
+ await onLogout?.(verified.payload, request);
1068
1284
  } catch (err) {
1285
+ releaseLogoutTokenJti(verified.jti);
1069
1286
  console.error(
1070
1287
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1071
1288
  err