@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.
@@ -122,6 +122,7 @@ function matchesPath(pathname, paths) {
122
122
  var introspectionCache = /* @__PURE__ */ new Map();
123
123
  var INTROSPECT_CACHE_TTL_MS = 3e4;
124
124
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
125
+ var INTROSPECT_TIMEOUT_MS = 5e3;
125
126
  async function introspectCacheKey(token, clientId) {
126
127
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
127
128
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -154,7 +155,7 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
154
155
  const cacheKey = await introspectCacheKey(token, clientId);
155
156
  const cached = introspectCacheGet(cacheKey);
156
157
  if (cached) {
157
- return cached.active;
158
+ return cached.active ? "active" : "inactive";
158
159
  }
159
160
  try {
160
161
  const body = new URLSearchParams({
@@ -168,21 +169,22 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
168
169
  const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {
169
170
  method: "POST",
170
171
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
171
- body: body.toString()
172
+ body: body.toString(),
173
+ signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
172
174
  });
173
175
  if (!res.ok) {
174
176
  if (res.status !== 401 && res.status !== 403) {
175
- return false;
177
+ return "unreachable";
176
178
  }
177
179
  introspectCacheSet(cacheKey, false);
178
- return false;
180
+ return "inactive";
179
181
  }
180
182
  const data = await res.json();
181
183
  const active = data.active === true;
182
184
  introspectCacheSet(cacheKey, active);
183
- return active;
185
+ return active ? "active" : "inactive";
184
186
  } catch {
185
- return false;
187
+ return "unreachable";
186
188
  }
187
189
  }
188
190
  function createSsoMiddleware(config) {
@@ -194,8 +196,7 @@ function createSsoMiddleware(config) {
194
196
  scopes = "openid profile",
195
197
  publicPaths = [],
196
198
  callbackPath = "/api/auth/callback",
197
- ssoCookieName = "__Host-user_token",
198
- validateSsoCookie = true,
199
+ failClosedOnIntrospectionError = false,
199
200
  insecureLocalDev: insecureLocalDevOpt = false
200
201
  } = config;
201
202
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -207,9 +208,9 @@ function createSsoMiddleware(config) {
207
208
  const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
208
209
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
209
210
  const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
210
- if (!validateSsoCookie) {
211
+ if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
211
212
  console.warn(
212
- "[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'
213
214
  );
214
215
  }
215
216
  if (!clientSecret) {
@@ -229,31 +230,30 @@ function createSsoMiddleware(config) {
229
230
  if (matchesPath(pathname, allPublicPaths)) {
230
231
  return import_server.NextResponse.next();
231
232
  }
232
- const ssoSession = request.cookies.get(ssoCookieName);
233
- if (ssoSession?.value) {
234
- if (validateSsoCookie) {
235
- const tokenActive = await introspectAccessToken(
236
- ssoSession.value,
237
- normalizedServerBase,
238
- clientId,
239
- clientSecret
240
- );
241
- if (tokenActive) {
242
- return import_server.NextResponse.next();
243
- }
244
- } else {
245
- return import_server.NextResponse.next();
246
- }
247
- }
248
233
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
249
234
  if (accessTokenCookie?.value) {
250
- const tokenActive = await introspectAccessToken(
235
+ const introspectResult = await introspectAccessToken(
251
236
  accessTokenCookie.value,
252
237
  normalizedServerBase,
253
238
  clientId,
254
239
  clientSecret
255
240
  );
256
- if (tokenActive) {
241
+ if (introspectResult === "active") {
242
+ return import_server.NextResponse.next();
243
+ }
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
+ }
254
+ console.warn(
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"
256
+ );
257
257
  return import_server.NextResponse.next();
258
258
  }
259
259
  }
@@ -273,11 +273,28 @@ function createSsoMiddleware(config) {
273
273
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
274
274
  loginUrl.search = authorizeParams.toString();
275
275
  const response = import_server.NextResponse.redirect(loginUrl);
276
- response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
277
- response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));
278
- 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
+ );
279
292
  const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
280
- response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
293
+ response.cookies.set(
294
+ `${returnUrlCookieName}${attemptSuffix}`,
295
+ safeReturnUrl,
296
+ getHostCookieOptions(600, secureCookies)
297
+ );
281
298
  if (accessTokenCookie?.value) {
282
299
  response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
283
300
  }
@@ -300,12 +317,17 @@ var SsoError = class extends Error {
300
317
  };
301
318
 
302
319
  // src/core/security.ts
320
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
303
321
  function isTrustedReturnUrl(url, currentOrigin) {
304
322
  if (!url) return false;
305
- if (url.includes("\\")) return false;
306
- 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;
307
326
  try {
308
- 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;
309
331
  } catch {
310
332
  return false;
311
333
  }
@@ -539,6 +561,92 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
539
561
  }
540
562
 
541
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
+ }
542
650
  function createCallbackRouteHandler(config) {
543
651
  const {
544
652
  clientId,
@@ -546,6 +654,7 @@ function createCallbackRouteHandler(config) {
546
654
  redirectUri,
547
655
  clientSecret,
548
656
  defaultReturnPath = "/",
657
+ scopes,
549
658
  insecureLocalDev: insecureLocalDevOpt = false
550
659
  } = config;
551
660
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -563,7 +672,21 @@ function createCallbackRouteHandler(config) {
563
672
  return async function GET(request) {
564
673
  const response = await handleCallback(request);
565
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
+ }
566
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
+ }
567
690
  }
568
691
  return response;
569
692
  };
@@ -572,50 +695,75 @@ function createCallbackRouteHandler(config) {
572
695
  const error = searchParams.get("error");
573
696
  if (error) {
574
697
  const desc = searchParams.get("error_description") || error;
575
- return import_server2.NextResponse.json(
576
- { error: "authorization_failed", error_description: desc },
577
- { status: 400 }
698
+ return buildErrorResponse(
699
+ request,
700
+ 400,
701
+ "authorization_failed",
702
+ desc,
703
+ config.renderErrorPage
578
704
  );
579
705
  }
580
706
  const code = searchParams.get("code");
581
707
  const returnedState = searchParams.get("state");
582
708
  if (!code) {
583
- return import_server2.NextResponse.json(
584
- {
585
- error: "invalid_request",
586
- error_description: "\u7F3A\u5C11 authorization code"
587
- },
588
- { 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
589
715
  );
590
716
  }
591
- 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);
592
729
  if (!savedState) {
593
- return import_server2.NextResponse.json(
594
- {
595
- error: "invalid_request",
596
- error_description: "State \u53C2\u6570\u7F3A\u5931\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42"
597
- },
598
- { 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
599
747
  );
600
748
  }
601
- if (!timingSafeEqualString(returnedState ?? "", savedState)) {
602
- return import_server2.NextResponse.json(
603
- {
604
- error: "invalid_request",
605
- error_description: "State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
606
- },
607
- { 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
608
756
  );
609
757
  }
610
- const expectedNonce = request.cookies.get(nonceCookieName)?.value;
611
- const verifier = request.cookies.get(verifierCookieName)?.value;
758
+ const expectedNonce = readTransientCookie(nonceCookieName);
759
+ const verifier = readTransientCookie(verifierCookieName);
612
760
  if (!verifier) {
613
- return import_server2.NextResponse.json(
614
- {
615
- error: "invalid_request",
616
- error_description: "PKCE verifier \u7F3A\u5931\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42"
617
- },
618
- { 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
619
767
  );
620
768
  }
621
769
  const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
@@ -646,17 +794,23 @@ function createCallbackRouteHandler(config) {
646
794
  } catch (err) {
647
795
  lastError = err;
648
796
  if (attempt >= maxRetries) {
649
- return import_server2.NextResponse.json(
650
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25\uFF0C\u5DF2\u91CD\u8BD5\u4ECD\u4E0D\u53EF\u8FBE" },
651
- { 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
652
803
  );
653
804
  }
654
805
  }
655
806
  }
656
807
  if (lastError || !res) {
657
- return import_server2.NextResponse.json(
658
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25" },
659
- { 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
660
814
  );
661
815
  }
662
816
  if (!res.ok) {
@@ -665,22 +819,32 @@ function createCallbackRouteHandler(config) {
665
819
  errData = await res.json();
666
820
  } catch {
667
821
  }
668
- return import_server2.NextResponse.json(
669
- {
670
- error: "token_request_failed",
671
- error_description: errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
672
- },
673
- { 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
674
828
  );
675
829
  }
676
830
  const tokenData = await res.json();
677
- if (!tokenData.access_token || !tokenData.refresh_token) {
678
- return import_server2.NextResponse.json(
679
- {
680
- error: "server_error",
681
- error_description: "Token \u54CD\u5E94\u7F3A\u5C11 access_token \u6216 refresh_token"
682
- },
683
- { 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
684
848
  );
685
849
  }
686
850
  if (tokenData.id_token) {
@@ -693,17 +857,17 @@ function createCallbackRouteHandler(config) {
693
857
  { expectedNonce }
694
858
  );
695
859
  } catch (err) {
696
- return import_server2.NextResponse.json(
697
- {
698
- error: "id_token_invalid",
699
- error_description: err instanceof Error ? err.message : "ID Token \u9A8C\u8BC1\u5931\u8D25"
700
- },
701
- { 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
702
866
  );
703
867
  }
704
868
  }
705
869
  const callbackOrigin = new URL(redirectUri).origin;
706
- const rawReturnUrl = request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;
870
+ const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
707
871
  const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
708
872
  const response = import_server2.NextResponse.redirect(new URL(returnUrl, callbackOrigin));
709
873
  response.cookies.set(accessTokenCookieName, tokenData.access_token, {
@@ -733,11 +897,18 @@ function createCallbackRouteHandler(config) {
733
897
  });
734
898
  }
735
899
  }
736
- response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
737
- response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
738
- response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
739
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
740
- 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
+ }
741
912
  return response;
742
913
  }
743
914
  }
@@ -867,6 +1038,20 @@ function createLogoutRouteHandler(config) {
867
1038
  headers: { "content-type": "text/html; charset=utf-8" }
868
1039
  });
869
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
+ }
870
1055
  let formGlobal = null;
871
1056
  try {
872
1057
  formGlobal = new URLSearchParams(await request.text()).get("global");
@@ -905,6 +1090,21 @@ function createLogoutRouteHandler(config) {
905
1090
  res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
906
1091
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
907
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
+ }
908
1108
  return res;
909
1109
  };
910
1110
  if (effectiveScope === "global") {
@@ -938,6 +1138,9 @@ var import_server4 = require("next/server");
938
1138
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
939
1139
  var JTI_CACHE_CAPACITY = 1e3;
940
1140
  var seenJti = /* @__PURE__ */ new Map();
1141
+ function releaseLogoutTokenJti(jti) {
1142
+ seenJti.delete(jti);
1143
+ }
941
1144
  function recordJti(jti, expiresAtMs) {
942
1145
  const now = Date.now();
943
1146
  const existing = seenJti.get(jti);
@@ -953,7 +1156,7 @@ function recordJti(jti, expiresAtMs) {
953
1156
  seenJti.set(jti, expiresAtMs);
954
1157
  return true;
955
1158
  }
956
- async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1159
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
957
1160
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
958
1161
  const header = decodeJwtHeader(logoutToken);
959
1162
  if (!header) {
@@ -1030,8 +1233,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1030
1233
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
1031
1234
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1032
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
+ }
1033
1242
  const events = payload.events;
1034
- 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)) {
1035
1251
  throw new SsoError(
1036
1252
  "logout_token_invalid",
1037
1253
  "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
@@ -1052,7 +1268,7 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1052
1268
  if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1053
1269
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1054
1270
  }
1055
- return { sub, sid };
1271
+ return { payload: { sub, sid }, jti };
1056
1272
  }
1057
1273
 
1058
1274
  // src/next/backchannel-logout.ts
@@ -1092,9 +1308,9 @@ function createBackchannelLogoutRouteHandler(config) {
1092
1308
  { status: 400 }
1093
1309
  );
1094
1310
  }
1095
- let payload;
1311
+ let verified;
1096
1312
  try {
1097
- payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
1313
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
1098
1314
  } catch (err) {
1099
1315
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1100
1316
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
@@ -1104,8 +1320,9 @@ function createBackchannelLogoutRouteHandler(config) {
1104
1320
  );
1105
1321
  }
1106
1322
  try {
1107
- await onLogout?.(payload, request);
1323
+ await onLogout?.(verified.payload, request);
1108
1324
  } catch (err) {
1325
+ releaseLogoutTokenJti(verified.jti);
1109
1326
  console.error(
1110
1327
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1111
1328
  err