@nihplod/sso-sdk 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,15 +74,18 @@ async function computeCodeChallenge(verifier) {
74
74
  function matchesPath(pathname, paths) {
75
75
  return paths.some((path) => {
76
76
  if (pathname === path) return true;
77
- if (path.endsWith("/:path*") && pathname.startsWith(path.replace("/:path*", ""))) return true;
78
- if (pathname.startsWith(path + "/")) return true;
79
- return false;
77
+ if (path.endsWith("/:path*")) {
78
+ const base = path.slice(0, -"/:path*".length);
79
+ return pathname === base || pathname.startsWith(base + "/");
80
+ }
81
+ return pathname.startsWith(path + "/");
80
82
  });
81
83
  }
82
84
  var introspectionCache = /* @__PURE__ */ new Map();
83
85
  var INTROSPECT_CACHE_TTL_MS = 3e4;
84
86
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
85
87
  var INTROSPECT_TIMEOUT_MS = 5e3;
88
+ var warnedIntrospectClientAuthFailure = false;
86
89
  async function introspectCacheKey(token, clientId) {
87
90
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
88
91
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -136,8 +139,13 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
136
139
  if (res.status !== 401 && res.status !== 403) {
137
140
  return "unreachable";
138
141
  }
139
- introspectCacheSet(cacheKey, false);
140
- return "inactive";
142
+ if (!warnedIntrospectClientAuthFailure) {
143
+ warnedIntrospectClientAuthFailure = true;
144
+ console.warn(
145
+ `[SSO SDK] introspection \u5BA2\u6237\u7AEF\u8BA4\u8BC1\u5931\u8D25\uFF08HTTP ${res.status}\uFF09\uFF1A\u8BF7\u68C0\u67E5 clientId/clientSecret \u4E0E\u4E3B\u7AD9\u6CE8\u518C\u4FE1\u606F\u662F\u5426\u4E00\u81F4\u3002\u8BE5\u6545\u969C\u4E0D\u4F1A\u88AB\u7F13\u5B58\u4E3A token \u5931\u6548\uFF0C\u4E5F\u4E0D\u4F1A\u89E6\u53D1\u9759\u9ED8\u91CD\u767B\u5F55\u5FAA\u73AF\u3002`
146
+ );
147
+ }
148
+ return "unreachable";
141
149
  }
142
150
  const data = await res.json();
143
151
  const active = data.active === true;
@@ -156,8 +164,7 @@ function createSsoMiddleware(config) {
156
164
  scopes = "openid profile",
157
165
  publicPaths = [],
158
166
  callbackPath = "/api/auth/callback",
159
- ssoCookieName = "__Host-user_token",
160
- validateSsoCookie = true,
167
+ failClosedOnIntrospectionError = false,
161
168
  insecureLocalDev: insecureLocalDevOpt = false
162
169
  } = config;
163
170
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -169,9 +176,9 @@ function createSsoMiddleware(config) {
169
176
  const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
170
177
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
171
178
  const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
172
- if (!validateSsoCookie) {
179
+ if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
173
180
  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"
181
+ '[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
182
  );
176
183
  }
177
184
  if (!clientSecret) {
@@ -181,7 +188,7 @@ function createSsoMiddleware(config) {
181
188
  }
182
189
  return async function ssoMiddleware(request) {
183
190
  const { pathname } = request.nextUrl;
184
- if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
191
+ if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
185
192
  return NextResponse.next();
186
193
  }
187
194
  if (pathname === callbackPath) {
@@ -191,22 +198,6 @@ function createSsoMiddleware(config) {
191
198
  if (matchesPath(pathname, allPublicPaths)) {
192
199
  return NextResponse.next();
193
200
  }
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
201
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
211
202
  if (accessTokenCookie?.value) {
212
203
  const introspectResult = await introspectAccessToken(
@@ -219,6 +210,15 @@ function createSsoMiddleware(config) {
219
210
  return NextResponse.next();
220
211
  }
221
212
  if (introspectResult === "unreachable") {
213
+ if (failClosedOnIntrospectionError) {
214
+ console.warn(
215
+ "[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"
216
+ );
217
+ return new NextResponse(
218
+ "SSO \u8BA4\u8BC1\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08introspection unreachable\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
219
+ { status: 502 }
220
+ );
221
+ }
222
222
  console.warn(
223
223
  "[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
224
  );
@@ -241,11 +241,28 @@ function createSsoMiddleware(config) {
241
241
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
242
242
  loginUrl.search = authorizeParams.toString();
243
243
  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));
244
+ const attemptSuffix = `_${state}`;
245
+ response.cookies.set(
246
+ `${stateCookieName}${attemptSuffix}`,
247
+ state,
248
+ getHostCookieOptions(600, secureCookies)
249
+ );
250
+ response.cookies.set(
251
+ `${nonceCookieName}${attemptSuffix}`,
252
+ nonce,
253
+ getHostCookieOptions(600, secureCookies)
254
+ );
255
+ response.cookies.set(
256
+ `${verifierCookieName}${attemptSuffix}`,
257
+ verifier,
258
+ getSecureCookieOptions(600, callbackPath, secureCookies)
259
+ );
247
260
  const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
248
- response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
261
+ response.cookies.set(
262
+ `${returnUrlCookieName}${attemptSuffix}`,
263
+ safeReturnUrl,
264
+ getHostCookieOptions(600, secureCookies)
265
+ );
249
266
  if (accessTokenCookie?.value) {
250
267
  response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
251
268
  }
@@ -268,12 +285,17 @@ var SsoError = class extends Error {
268
285
  };
269
286
 
270
287
  // src/core/security.ts
288
+ var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
271
289
  function isTrustedReturnUrl(url, currentOrigin) {
272
290
  if (!url) return false;
273
- if (url.includes("\\")) return false;
274
- if (url.startsWith("/") && !url.startsWith("//")) return true;
291
+ if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
292
+ if (url.startsWith("//")) return false;
293
+ if (url.startsWith("/")) return true;
275
294
  try {
276
- return new URL(url).origin === currentOrigin;
295
+ const parsed = new URL(url);
296
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
297
+ if (parsed.username || parsed.password) return false;
298
+ return parsed.origin === currentOrigin;
277
299
  } catch {
278
300
  return false;
279
301
  }
@@ -485,7 +507,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
485
507
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
486
508
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
487
509
  }
488
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
510
+ if (typeof payload.iat !== "number") {
511
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
512
+ }
513
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
489
514
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
490
515
  }
491
516
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -502,11 +527,114 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
502
527
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
503
528
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
504
529
  }
530
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
531
+ throw new SsoError(
532
+ "id_token_nonce_mismatch",
533
+ "ID Token \u643A\u5E26 nonce \u4F46\u672C\u5730\u65E0\u671F\u671B\u503C\uFF08\u53EF\u80FD\u975E\u672C\u6B21\u767B\u5F55\u4F1A\u8BDD\u7B7E\u53D1\uFF09"
534
+ );
505
535
  }
506
536
  return { sub: payload.sub };
507
537
  }
508
538
 
539
+ // src/core/http.ts
540
+ var REQUEST_TIMEOUT_MS = 1e4;
541
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
542
+ const controller = new AbortController();
543
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
544
+ try {
545
+ return await fetch(input, { ...init, signal: controller.signal });
546
+ } finally {
547
+ clearTimeout(timer);
548
+ }
549
+ }
550
+
509
551
  // src/next/callback.ts
552
+ function escapeHtml(value) {
553
+ return value.replace(/[&<>"']/g, (ch) => {
554
+ switch (ch) {
555
+ case "&":
556
+ return "&amp;";
557
+ case "<":
558
+ return "&lt;";
559
+ case ">":
560
+ return "&gt;";
561
+ case '"':
562
+ return "&quot;";
563
+ default:
564
+ return "&#39;";
565
+ }
566
+ });
567
+ }
568
+ function buildErrorPage(status, error, errorDescription) {
569
+ const safeDescription = escapeHtml(errorDescription);
570
+ const safeError = escapeHtml(error);
571
+ return `<!doctype html>
572
+ <html lang="zh-CN">
573
+ <head>
574
+ <meta charset="utf-8" />
575
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
576
+ <meta name="robots" content="noindex" />
577
+ <title>\u767B\u5F55\u5931\u8D25</title>
578
+ <style>
579
+ :root { color-scheme: light; }
580
+ body { margin: 0; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #fafafa; color: #2c2c2c; }
581
+ main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }
582
+ h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }
583
+ p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }
584
+ .actions { display: flex; gap: 0.75rem; }
585
+ a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }
586
+ a.primary { background: #2c2c2c; color: #fff; }
587
+ a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }
588
+ .code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }
589
+ </style>
590
+ </head>
591
+ <body>
592
+ <main>
593
+ <h1>\u767B\u5F55\u5931\u8D25</h1>
594
+ <p>${safeDescription}</p>
595
+ <div class="actions">
596
+ <a class="primary" href="/">\u91CD\u65B0\u767B\u5F55</a>
597
+ <a class="secondary" href="/">\u8FD4\u56DE\u9996\u9875</a>
598
+ </div>
599
+ <p class="code">\u9519\u8BEF\u7801\uFF1A${safeError}\uFF08\u53CD\u9988\u95EE\u9898\u65F6\u8BF7\u9644\u4E0A\uFF09</p>
600
+ </main>
601
+ </body>
602
+ </html>`;
603
+ }
604
+ function buildErrorResponse(request, status, error, errorDescription, custom) {
605
+ if (custom) {
606
+ const handled = custom({ error, errorDescription, status, request });
607
+ if (handled instanceof Promise) {
608
+ return handled.then(
609
+ (res) => res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)
610
+ );
611
+ }
612
+ if (handled) return toNextResponse(handled);
613
+ }
614
+ return defaultErrorResponse(request, status, error, errorDescription);
615
+ }
616
+ function toNextResponse(res) {
617
+ if (res instanceof NextResponse2) return res;
618
+ return new NextResponse2(res.body, {
619
+ status: res.status,
620
+ statusText: res.statusText,
621
+ headers: res.headers
622
+ });
623
+ }
624
+ function defaultErrorResponse(request, status, error, errorDescription) {
625
+ const accept = request.headers.get("accept") ?? "";
626
+ const wantsHtml = accept.includes("text/html") && request.nextUrl.searchParams.get("format") !== "json";
627
+ if (wantsHtml) {
628
+ return new NextResponse2(buildErrorPage(status, error, errorDescription), {
629
+ status,
630
+ headers: {
631
+ "Content-Type": "text/html; charset=utf-8",
632
+ "Cache-Control": "no-store"
633
+ }
634
+ });
635
+ }
636
+ return NextResponse2.json({ error, error_description: errorDescription }, { status });
637
+ }
510
638
  function createCallbackRouteHandler(config) {
511
639
  const {
512
640
  clientId,
@@ -514,6 +642,7 @@ function createCallbackRouteHandler(config) {
514
642
  redirectUri,
515
643
  clientSecret,
516
644
  defaultReturnPath = "/",
645
+ scopes,
517
646
  insecureLocalDev: insecureLocalDevOpt = false
518
647
  } = config;
519
648
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
@@ -531,7 +660,21 @@ function createCallbackRouteHandler(config) {
531
660
  return async function GET(request) {
532
661
  const response = await handleCallback(request);
533
662
  if (response.status >= 400) {
663
+ if (config.debug) {
664
+ console.warn(
665
+ `[SSO SDK] \u56DE\u8C03\u5931\u8D25 status=${response.status}`,
666
+ request.nextUrl.searchParams.get("error") ?? ""
667
+ );
668
+ }
534
669
  response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
670
+ const returnedState = request.nextUrl.searchParams.get("state");
671
+ if (returnedState && /^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
672
+ response.cookies.set(
673
+ `${nonceCookieName}_${returnedState}`,
674
+ "",
675
+ getHostCookieOptions(0, secureCookies)
676
+ );
677
+ }
535
678
  }
536
679
  return response;
537
680
  };
@@ -540,50 +683,75 @@ function createCallbackRouteHandler(config) {
540
683
  const error = searchParams.get("error");
541
684
  if (error) {
542
685
  const desc = searchParams.get("error_description") || error;
543
- return NextResponse2.json(
544
- { error: "authorization_failed", error_description: desc },
545
- { status: 400 }
686
+ return buildErrorResponse(
687
+ request,
688
+ 400,
689
+ "authorization_failed",
690
+ desc,
691
+ config.renderErrorPage
546
692
  );
547
693
  }
548
694
  const code = searchParams.get("code");
549
695
  const returnedState = searchParams.get("state");
550
696
  if (!code) {
551
- return NextResponse2.json(
552
- {
553
- error: "invalid_request",
554
- error_description: "\u7F3A\u5C11 authorization code"
555
- },
556
- { status: 400 }
697
+ return buildErrorResponse(
698
+ request,
699
+ 400,
700
+ "invalid_request",
701
+ "\u767B\u5F55\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
702
+ config.renderErrorPage
557
703
  );
558
704
  }
559
- const savedState = request.cookies.get(stateCookieName)?.value;
705
+ if (!returnedState || !/^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
706
+ return buildErrorResponse(
707
+ request,
708
+ 400,
709
+ "invalid_request",
710
+ "State \u53C2\u6570\u7F3A\u5931\u6216\u683C\u5F0F\u975E\u6CD5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
711
+ config.renderErrorPage
712
+ );
713
+ }
714
+ const attemptSuffix = `_${returnedState}`;
715
+ const readTransientCookie = (name) => request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;
716
+ const savedState = readTransientCookie(stateCookieName);
560
717
  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 }
718
+ const alternateName = secureCookies ? toInsecureCookieName(stateCookieName) : DEFAULT_STATE_COOKIE_NAME;
719
+ const alternateValue = request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ?? request.cookies.get(alternateName)?.value;
720
+ if (alternateValue) {
721
+ return buildErrorResponse(
722
+ request,
723
+ 500,
724
+ "invalid_config",
725
+ "\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",
726
+ config.renderErrorPage
727
+ );
728
+ }
729
+ return buildErrorResponse(
730
+ request,
731
+ 400,
732
+ "invalid_request",
733
+ "\u767B\u5F55\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
734
+ config.renderErrorPage
567
735
  );
568
736
  }
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 }
737
+ if (!timingSafeEqualString(returnedState, savedState)) {
738
+ return buildErrorResponse(
739
+ request,
740
+ 400,
741
+ "invalid_request",
742
+ "\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
743
+ config.renderErrorPage
576
744
  );
577
745
  }
578
- const expectedNonce = request.cookies.get(nonceCookieName)?.value;
579
- const verifier = request.cookies.get(verifierCookieName)?.value;
746
+ const expectedNonce = readTransientCookie(nonceCookieName);
747
+ const verifier = readTransientCookie(verifierCookieName);
580
748
  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 }
749
+ return buildErrorResponse(
750
+ request,
751
+ 400,
752
+ "invalid_request",
753
+ "\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",
754
+ config.renderErrorPage
587
755
  );
588
756
  }
589
757
  const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
@@ -604,27 +772,37 @@ function createCallbackRouteHandler(config) {
604
772
  await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
605
773
  }
606
774
  try {
607
- res = await fetch(tokenEndpoint, {
608
- method: "POST",
609
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
610
- body: body.toString()
611
- });
775
+ res = await fetchWithTimeout(
776
+ tokenEndpoint,
777
+ {
778
+ method: "POST",
779
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
780
+ body: body.toString()
781
+ },
782
+ 1e4
783
+ );
612
784
  lastError = null;
613
785
  break;
614
786
  } catch (err) {
615
787
  lastError = err;
616
788
  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 }
789
+ return buildErrorResponse(
790
+ request,
791
+ 502,
792
+ "server_error",
793
+ "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
794
+ config.renderErrorPage
620
795
  );
621
796
  }
622
797
  }
623
798
  }
624
799
  if (lastError || !res) {
625
- return NextResponse2.json(
626
- { error: "server_error", error_description: "Token \u8BF7\u6C42\u5931\u8D25" },
627
- { status: 502 }
800
+ return buildErrorResponse(
801
+ request,
802
+ 502,
803
+ "server_error",
804
+ "\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
805
+ config.renderErrorPage
628
806
  );
629
807
  }
630
808
  if (!res.ok) {
@@ -633,22 +811,32 @@ function createCallbackRouteHandler(config) {
633
811
  errData = await res.json();
634
812
  } catch {
635
813
  }
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 }
814
+ return buildErrorResponse(
815
+ request,
816
+ 502,
817
+ "token_request_failed",
818
+ errData.error_description || `\u767B\u5F55\u5931\u8D25\uFF08Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
819
+ config.renderErrorPage
642
820
  );
643
821
  }
644
822
  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 }
823
+ if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in) || tokenData.expires_in <= 0) {
824
+ return buildErrorResponse(
825
+ request,
826
+ 502,
827
+ "server_error",
828
+ "\u767B\u5F55\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08Token \u54CD\u5E94\u4E0D\u5B8C\u6574\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
829
+ config.renderErrorPage
830
+ );
831
+ }
832
+ const requiresIdToken = scopes !== void 0 && scopes.split(" ").filter(Boolean).includes("openid");
833
+ if (requiresIdToken && !tokenData.id_token) {
834
+ return buildErrorResponse(
835
+ request,
836
+ 400,
837
+ "id_token_invalid",
838
+ "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
839
+ config.renderErrorPage
652
840
  );
653
841
  }
654
842
  if (tokenData.id_token) {
@@ -661,17 +849,17 @@ function createCallbackRouteHandler(config) {
661
849
  { expectedNonce }
662
850
  );
663
851
  } 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 }
852
+ return buildErrorResponse(
853
+ request,
854
+ 400,
855
+ "id_token_invalid",
856
+ err instanceof Error && /[\u4e00-\u9fff]/.test(err.message) ? err.message : "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
857
+ config.renderErrorPage
670
858
  );
671
859
  }
672
860
  }
673
861
  const callbackOrigin = new URL(redirectUri).origin;
674
- const rawReturnUrl = request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;
862
+ const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
675
863
  const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
676
864
  const response = NextResponse2.redirect(new URL(returnUrl, callbackOrigin));
677
865
  response.cookies.set(accessTokenCookieName, tokenData.access_token, {
@@ -701,11 +889,18 @@ function createCallbackRouteHandler(config) {
701
889
  });
702
890
  }
703
891
  }
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));
892
+ for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {
893
+ response.cookies.set(name, "", getHostCookieOptions(0, secureCookies));
894
+ response.cookies.set(`${name}${attemptSuffix}`, "", getHostCookieOptions(0, secureCookies));
895
+ }
896
+ for (const path of ["/", request.nextUrl.pathname]) {
897
+ response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, path, secureCookies));
898
+ response.cookies.set(
899
+ `${verifierCookieName}${attemptSuffix}`,
900
+ "",
901
+ getSecureCookieOptions(0, path, secureCookies)
902
+ );
903
+ }
709
904
  return response;
710
905
  }
711
906
  }
@@ -819,7 +1014,7 @@ function createLogoutRouteHandler(config) {
819
1014
  const returnedState = request.nextUrl.searchParams.get("state");
820
1015
  if (returnedState) {
821
1016
  const savedState = request.cookies.get(logoutStateCookieName)?.value;
822
- if (!savedState || savedState !== returnedState) {
1017
+ if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
823
1018
  return NextResponse3.json(
824
1019
  { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
825
1020
  { status: 400 }
@@ -835,6 +1030,20 @@ function createLogoutRouteHandler(config) {
835
1030
  headers: { "content-type": "text/html; charset=utf-8" }
836
1031
  });
837
1032
  }
1033
+ const requestOrigin = request.headers.get("origin");
1034
+ if (requestOrigin && requestOrigin !== callbackOrigin) {
1035
+ return NextResponse3.json(
1036
+ { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1037
+ { status: 403 }
1038
+ );
1039
+ }
1040
+ const secFetchSite = request.headers.get("sec-fetch-site");
1041
+ if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
1042
+ return NextResponse3.json(
1043
+ { error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
1044
+ { status: 403 }
1045
+ );
1046
+ }
838
1047
  let formGlobal = null;
839
1048
  try {
840
1049
  formGlobal = new URLSearchParams(await request.text()).get("global");
@@ -873,6 +1082,21 @@ function createLogoutRouteHandler(config) {
873
1082
  res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
874
1083
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
875
1084
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
1085
+ const suffixedPrefixes = [
1086
+ `${stateCookieName}_`,
1087
+ `${nonceCookieName}_`,
1088
+ `${returnUrlCookieName}_`,
1089
+ `${verifierCookieName}_`
1090
+ ];
1091
+ for (const cookie of request.cookies.getAll()) {
1092
+ if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;
1093
+ if (cookie.name.startsWith(`${verifierCookieName}_`)) {
1094
+ res.cookies.set(cookie.name, "", getSecureCookieOptions(0, "/", secureCookies));
1095
+ res.cookies.set(cookie.name, "", getSecureCookieOptions(0, callbackPath, secureCookies));
1096
+ } else {
1097
+ res.cookies.set(cookie.name, "", getHostCookieOptions(0, secureCookies));
1098
+ }
1099
+ }
876
1100
  return res;
877
1101
  };
878
1102
  if (effectiveScope === "global") {
@@ -906,6 +1130,20 @@ import { NextResponse as NextResponse4 } from "next/server";
906
1130
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
907
1131
  var JTI_CACHE_CAPACITY = 1e3;
908
1132
  var seenJti = /* @__PURE__ */ new Map();
1133
+ async function releaseLogoutTokenJti(replayKey, options = {}) {
1134
+ const store = options.jtiStore;
1135
+ if (store?.remove) {
1136
+ await store.remove(replayKey);
1137
+ return;
1138
+ }
1139
+ if (store) {
1140
+ console.warn(
1141
+ "[SSO SDK] \u81EA\u5B9A\u4E49 jtiStore \u672A\u5B9E\u73B0 remove()\uFF1AonLogout \u5931\u8D25\u540E\u65E0\u6CD5\u91CA\u653E jti\uFF0CIdP \u7528\u540C\u4E00 logout_token \u91CD\u6295\u5C06\u88AB\u91CD\u653E\u68C0\u67E5\u62D2\u7EDD\uFF08logout_token_replay\uFF09"
1142
+ );
1143
+ return;
1144
+ }
1145
+ seenJti.delete(replayKey);
1146
+ }
909
1147
  function recordJti(jti, expiresAtMs) {
910
1148
  const now = Date.now();
911
1149
  const existing = seenJti.get(jti);
@@ -921,7 +1159,7 @@ function recordJti(jti, expiresAtMs) {
921
1159
  seenJti.set(jti, expiresAtMs);
922
1160
  return true;
923
1161
  }
924
- async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1162
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
925
1163
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
926
1164
  const header = decodeJwtHeader(logoutToken);
927
1165
  if (!header) {
@@ -998,8 +1236,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
998
1236
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
999
1237
  throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1000
1238
  }
1239
+ if (payload.type !== "logout_token") {
1240
+ throw new SsoError(
1241
+ "logout_token_invalid",
1242
+ "Logout Token type \u5FC5\u987B\u4E3A logout_token"
1243
+ );
1244
+ }
1001
1245
  const events = payload.events;
1002
- if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
1246
+ if (!events || typeof events !== "object" || Array.isArray(events)) {
1247
+ throw new SsoError(
1248
+ "logout_token_invalid",
1249
+ "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
1250
+ );
1251
+ }
1252
+ const logoutEvent = events[BACKCHANNEL_LOGOUT_EVENT];
1253
+ if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
1003
1254
  throw new SsoError(
1004
1255
  "logout_token_invalid",
1005
1256
  "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
@@ -1017,10 +1268,27 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
1017
1268
  if (!jti) {
1018
1269
  throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
1019
1270
  }
1020
- if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1271
+ const replayKey = `${normalizedIssuer}:${jti}`;
1272
+ const replayTtlSeconds = Math.max(
1273
+ 1,
1274
+ Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
1275
+ );
1276
+ const jtiStore = options.jtiStore;
1277
+ if (jtiStore) {
1278
+ if (jtiStore.addIfAbsent) {
1279
+ if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
1280
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1281
+ }
1282
+ } else {
1283
+ if (await jtiStore.has(replayKey)) {
1284
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1285
+ }
1286
+ await jtiStore.add(replayKey, replayTtlSeconds);
1287
+ }
1288
+ } else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
1021
1289
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1022
1290
  }
1023
- return { sub, sid };
1291
+ return { payload: { sub, sid }, jti, replayKey };
1024
1292
  }
1025
1293
 
1026
1294
  // src/next/backchannel-logout.ts
@@ -1060,9 +1328,11 @@ function createBackchannelLogoutRouteHandler(config) {
1060
1328
  { status: 400 }
1061
1329
  );
1062
1330
  }
1063
- let payload;
1331
+ let verified;
1064
1332
  try {
1065
- payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
1333
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
1334
+ jtiStore: config.jtiStore
1335
+ });
1066
1336
  } catch (err) {
1067
1337
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1068
1338
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
@@ -1072,8 +1342,9 @@ function createBackchannelLogoutRouteHandler(config) {
1072
1342
  );
1073
1343
  }
1074
1344
  try {
1075
- await onLogout?.(payload, request);
1345
+ await onLogout?.(verified.payload, request);
1076
1346
  } catch (err) {
1347
+ await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
1077
1348
  console.error(
1078
1349
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1079
1350
  err