@nihplod/sso-sdk 1.3.0 → 1.4.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.
@@ -6,6 +6,7 @@ var DEFAULT_ACCESS_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_at";
6
6
  var DEFAULT_REFRESH_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_rt";
7
7
  var DEFAULT_ID_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_id";
8
8
  var DEFAULT_STATE_COOKIE_NAME = "__Host-nihplod_sso_state";
9
+ var DEFAULT_NONCE_COOKIE_NAME = "__Host-nihplod_sso_nonce";
9
10
  var DEFAULT_RETURN_COOKIE_NAME = "__Host-nihplod_sso_return";
10
11
  var DEFAULT_VERIFIER_COOKIE_NAME = "__Secure-nihplod_sso_verifier";
11
12
  var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
@@ -161,21 +162,20 @@ function createSsoMiddleware(config) {
161
162
  const secureCookies = !insecureLocalDev;
162
163
  const accessTokenCookieName = insecureLocalDev ? toInsecureCookieName(config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME) : config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME;
163
164
  const stateCookieName = insecureLocalDev ? toInsecureCookieName(config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME) : config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME;
165
+ const nonceCookieName = insecureLocalDev ? toInsecureCookieName(config.nonceCookieName ?? DEFAULT_NONCE_COOKIE_NAME) : config.nonceCookieName ?? DEFAULT_NONCE_COOKIE_NAME;
164
166
  const returnUrlCookieName = insecureLocalDev ? toInsecureCookieName(config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME) : config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME;
165
167
  const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
166
168
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
167
169
  const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
168
- if (process.env.NODE_ENV !== "production") {
169
- if (!validateSsoCookie) {
170
- console.warn(
171
- "[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"
172
- );
173
- }
174
- if (!clientSecret) {
175
- console.warn(
176
- "[SSO SDK] \u672A\u914D\u7F6E clientSecret\uFF08Public Client \u6A21\u5F0F\uFF09\uFF1Aintrospection \u65E0\u5BA2\u6237\u7AEF\u8BA4\u8BC1\uFF0C\u4E2D\u95F4\u4EF6\u5224\u5B9A\u7ED3\u679C\u4EC5\u4F5C UX \u53C2\u8003\u3002Confidential Client\uFF08BFF\uFF09\u8BF7\u914D\u7F6E clientSecret\u3002"
177
- );
178
- }
170
+ if (!validateSsoCookie) {
171
+ 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
+ );
174
+ }
175
+ if (!clientSecret) {
176
+ console.warn(
177
+ "[SSO SDK] \u672A\u914D\u7F6E clientSecret\uFF08Public Client \u6A21\u5F0F\uFF09\uFF1Aintrospection \u65E0\u5BA2\u6237\u7AEF\u8BA4\u8BC1\uFF0C\u4E2D\u95F4\u4EF6\u5224\u5B9A\u7ED3\u679C\u4EC5\u4F5C UX \u53C2\u8003\u3002Confidential Client\uFF08BFF\uFF09\u8BF7\u914D\u7F6E clientSecret\u3002"
178
+ );
179
179
  }
180
180
  return async function ssoMiddleware(request) {
181
181
  const { pathname } = request.nextUrl;
@@ -218,6 +218,7 @@ function createSsoMiddleware(config) {
218
218
  }
219
219
  }
220
220
  const state = generateRandomString(32);
221
+ const nonce = generateRandomString(43);
221
222
  const verifier = generateRandomString(64);
222
223
  const challenge = await computeCodeChallenge(verifier);
223
224
  const authorizeParams = new URLSearchParams();
@@ -226,12 +227,14 @@ function createSsoMiddleware(config) {
226
227
  authorizeParams.set("redirect_uri", redirectUri);
227
228
  authorizeParams.set("scope", scopes);
228
229
  authorizeParams.set("state", state);
230
+ authorizeParams.set("nonce", nonce);
229
231
  authorizeParams.set("code_challenge", challenge);
230
232
  authorizeParams.set("code_challenge_method", "S256");
231
233
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
232
234
  loginUrl.search = authorizeParams.toString();
233
235
  const response = NextResponse.redirect(loginUrl);
234
236
  response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
237
+ response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));
235
238
  response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));
236
239
  const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
237
240
  response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
@@ -320,18 +323,21 @@ function normalizeIssuer(url) {
320
323
  var cachedJwks = null;
321
324
  var cachedDiscovery = null;
322
325
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
326
+ var FETCH_TIMEOUT_MS = 1e4;
323
327
  async function fetchDiscoveryDoc(baseUrl) {
324
328
  const now = Date.now();
325
329
  if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
326
330
  return cachedDiscovery.doc;
327
331
  }
328
332
  try {
329
- const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
330
- const doc = res.ok ? await res.json() : null;
333
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
334
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
335
+ });
336
+ if (!res.ok) return null;
337
+ const doc = await res.json();
331
338
  cachedDiscovery = { baseUrl, doc, fetchedAt: now };
332
339
  return doc;
333
340
  } catch {
334
- cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
335
341
  return null;
336
342
  }
337
343
  }
@@ -343,7 +349,10 @@ async function fetchJwks(baseUrl, options = {}) {
343
349
  const discovery = await fetchDiscoveryDoc(baseUrl);
344
350
  const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
345
351
  try {
346
- const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
352
+ const res = await fetch(jwksUri, {
353
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
354
+ ...options.forceRefresh ? { cache: "no-cache" } : {}
355
+ });
347
356
  if (!res.ok) return null;
348
357
  const jwks = await res.json();
349
358
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -390,7 +399,7 @@ async function computeAtHash(accessToken) {
390
399
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
391
400
  }
392
401
  async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
393
- const { rejectHs256WhenRs256Available = true } = options;
402
+ const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
394
403
  const header = decodeJwtHeader(idToken);
395
404
  if (!header) {
396
405
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -480,6 +489,12 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
480
489
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
481
490
  }
482
491
  }
492
+ if (expectedNonce !== void 0) {
493
+ const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
494
+ if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
495
+ throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
496
+ }
497
+ }
483
498
  return { sub: payload.sub };
484
499
  }
485
500
 
@@ -500,11 +515,19 @@ function createCallbackRouteHandler(config) {
500
515
  const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
501
516
  const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
502
517
  const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
518
+ const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
503
519
  const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
504
520
  const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
505
521
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
506
522
  const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
507
523
  return async function GET(request) {
524
+ const response = await handleCallback(request);
525
+ if (response.status >= 400) {
526
+ response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
527
+ }
528
+ return response;
529
+ };
530
+ async function handleCallback(request) {
508
531
  const { searchParams } = request.nextUrl;
509
532
  const error = searchParams.get("error");
510
533
  if (error) {
@@ -535,7 +558,7 @@ function createCallbackRouteHandler(config) {
535
558
  { status: 400 }
536
559
  );
537
560
  }
538
- if (returnedState !== savedState) {
561
+ if (!timingSafeEqualString(returnedState ?? "", savedState)) {
539
562
  return NextResponse2.json(
540
563
  {
541
564
  error: "invalid_request",
@@ -544,6 +567,7 @@ function createCallbackRouteHandler(config) {
544
567
  { status: 400 }
545
568
  );
546
569
  }
570
+ const expectedNonce = request.cookies.get(nonceCookieName)?.value;
547
571
  const verifier = request.cookies.get(verifierCookieName)?.value;
548
572
  if (!verifier) {
549
573
  return NextResponse2.json(
@@ -625,7 +649,8 @@ function createCallbackRouteHandler(config) {
625
649
  tokenData.id_token,
626
650
  tokenData.access_token,
627
651
  normalizedBase,
628
- clientId
652
+ clientId,
653
+ { expectedNonce }
629
654
  );
630
655
  } catch (err) {
631
656
  return NextResponse2.json(
@@ -669,11 +694,12 @@ function createCallbackRouteHandler(config) {
669
694
  }
670
695
  }
671
696
  response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
697
+ response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
672
698
  response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
673
699
  response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
674
700
  response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));
675
701
  return response;
676
- };
702
+ }
677
703
  }
678
704
 
679
705
  // src/next/logout.ts
@@ -681,7 +707,7 @@ import { NextResponse as NextResponse3 } from "next/server";
681
707
 
682
708
  // src/core/discovery.ts
683
709
  var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
684
- var FETCH_TIMEOUT_MS = 5e3;
710
+ var FETCH_TIMEOUT_MS2 = 5e3;
685
711
  var cache = /* @__PURE__ */ new Map();
686
712
  var inflight = /* @__PURE__ */ new Map();
687
713
  function fetchDiscoveryCached(baseUrl) {
@@ -693,7 +719,7 @@ function fetchDiscoveryCached(baseUrl) {
693
719
  if (existing) return existing;
694
720
  const promise = (async () => {
695
721
  const controller = new AbortController();
696
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
722
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS2);
697
723
  try {
698
724
  const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
699
725
  signal: controller.signal
@@ -714,6 +740,26 @@ function fetchDiscoveryCached(baseUrl) {
714
740
  }
715
741
 
716
742
  // src/next/logout.ts
743
+ function buildLogoutConfirmHtml() {
744
+ return `<!doctype html>
745
+ <html lang="zh-CN">
746
+ <head>
747
+ <meta charset="utf-8">
748
+ <meta name="viewport" content="width=device-width, initial-scale=1">
749
+ <title>\u786E\u8BA4\u9000\u51FA\u767B\u5F55</title>
750
+ </head>
751
+ <body style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;">
752
+ <form method="post" style="text-align:center;">
753
+ <p>\u786E\u5B9A\u8981\u9000\u51FA\u767B\u5F55\u5417\uFF1F</p>
754
+ <label style="display:flex;align-items:center;justify-content:center;gap:6px;margin:12px 0;font-size:14px;color:#374151;cursor:pointer;">
755
+ <input type="checkbox" name="global" value="1">\u540C\u65F6\u9000\u51FA\u6240\u6709 NIHPLOD \u5E73\u53F0
756
+ </label>
757
+ <button type="submit" style="padding:10px 20px;background-color:#ef4444;color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px;">\u9000\u51FA\u767B\u5F55</button>
758
+ <p><a href="/" style="color:#2563eb;text-decoration:underline;">\u53D6\u6D88\u5E76\u8FD4\u56DE\u9996\u9875</a></p>
759
+ </form>
760
+ </body>
761
+ </html>`;
762
+ }
717
763
  function generateRandomString2(length) {
718
764
  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
719
765
  const maxValid = Math.floor(256 / chars.length) * chars.length;
@@ -735,10 +781,18 @@ function createLogoutRouteHandler(config) {
735
781
  redirectUri,
736
782
  clientSecret,
737
783
  postLogoutRedirectUri = new URL(redirectUri).origin + "/",
738
- redirectToSso = true,
739
784
  callbackPath = "/api/auth/callback",
740
785
  insecureLocalDev: insecureLocalDevOpt = false
741
786
  } = config;
787
+ let defaultScope = config.defaultScope ?? "local";
788
+ if (config.redirectToSso !== void 0) {
789
+ console.warn(
790
+ '[SSO SDK] redirectToSso \u5DF2\u5F03\u7528\uFF0C\u8BF7\u6539\u7528 defaultScope\uFF08redirectToSso: true \u2192 defaultScope: "global"\uFF0Cfalse \u2192 "local"\uFF09\u3002\u6CE8\u610F\uFF1A\u9ED8\u8BA4\u9000\u51FA\u8303\u56F4\u5DF2\u53D8\u66F4\u4E3A "local"\uFF08\u4EC5\u9000\u51FA\u672C\u7AD9\uFF0C\u4E0D\u8DF3\u8F6C SSO \u4E2D\u5FC3\uFF09\u3002'
791
+ );
792
+ if (config.defaultScope === void 0) {
793
+ defaultScope = config.redirectToSso ? "global" : "local";
794
+ }
795
+ }
742
796
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
743
797
  const secureCookies = !insecureLocalDev;
744
798
  const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
@@ -746,6 +800,7 @@ function createLogoutRouteHandler(config) {
746
800
  const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
747
801
  const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
748
802
  const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
803
+ const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
749
804
  const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
750
805
  const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
751
806
  const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);
@@ -766,6 +821,18 @@ function createLogoutRouteHandler(config) {
766
821
  res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
767
822
  return res;
768
823
  }
824
+ if (request.method === "GET") {
825
+ return new NextResponse3(buildLogoutConfirmHtml(), {
826
+ status: 200,
827
+ headers: { "content-type": "text/html; charset=utf-8" }
828
+ });
829
+ }
830
+ let formGlobal = null;
831
+ try {
832
+ formGlobal = new URLSearchParams(await request.text()).get("global");
833
+ } catch {
834
+ }
835
+ const effectiveScope = formGlobal !== null ? formGlobal === "1" || formGlobal === "true" ? "global" : "local" : defaultScope;
769
836
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
770
837
  const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
771
838
  if (refreshToken) {
@@ -794,12 +861,13 @@ function createLogoutRouteHandler(config) {
794
861
  res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
795
862
  res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
796
863
  res.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
864
+ res.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
797
865
  res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
798
866
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
799
867
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
800
868
  return res;
801
869
  };
802
- if (redirectToSso) {
870
+ if (effectiveScope === "global") {
803
871
  const discovery = await fetchDiscoveryCached(normalizedServerBase);
804
872
  const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
805
873
  const logoutUrl = new URL(endSessionEndpoint);
@@ -822,14 +890,208 @@ function createLogoutRouteHandler(config) {
822
890
  );
823
891
  };
824
892
  }
893
+
894
+ // src/next/backchannel-logout.ts
895
+ import { NextResponse as NextResponse4 } from "next/server";
896
+
897
+ // src/core/logout-token.ts
898
+ var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
899
+ var JTI_CACHE_CAPACITY = 1e3;
900
+ var seenJti = /* @__PURE__ */ new Map();
901
+ function recordJti(jti, expiresAtMs) {
902
+ const now = Date.now();
903
+ const existing = seenJti.get(jti);
904
+ if (existing !== void 0 && existing > now) return false;
905
+ for (const [key, exp] of seenJti) {
906
+ if (exp <= now) seenJti.delete(key);
907
+ }
908
+ while (seenJti.size >= JTI_CACHE_CAPACITY) {
909
+ const oldest = seenJti.keys().next().value;
910
+ if (oldest === void 0) break;
911
+ seenJti.delete(oldest);
912
+ }
913
+ seenJti.set(jti, expiresAtMs);
914
+ return true;
915
+ }
916
+ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
917
+ const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
918
+ const header = decodeJwtHeader(logoutToken);
919
+ if (!header) {
920
+ throw new SsoError("logout_token_invalid", "Logout Token \u683C\u5F0F\u9519\u8BEF");
921
+ }
922
+ if (header.alg !== "RS256") {
923
+ throw new SsoError(
924
+ "logout_token_unsupported_alg",
925
+ `\u4E0D\u652F\u6301\u7684 Logout Token \u7B7E\u540D\u7B97\u6CD5: ${String(header.alg)}`
926
+ );
927
+ }
928
+ const discovery = await fetchDiscoveryDoc(baseUrl);
929
+ const normalizedIssuer = (discovery?.issuer || baseUrl).replace(/\/+$/, "");
930
+ const jwks = await fetchJwks(baseUrl);
931
+ if (!jwks) {
932
+ throw new SsoError(
933
+ "logout_token_invalid_signature",
934
+ "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 Logout Token \u7B7E\u540D"
935
+ );
936
+ }
937
+ const kid = typeof header.kid === "string" ? header.kid : void 0;
938
+ const matchCandidates = (set) => set.keys.filter(
939
+ (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
940
+ );
941
+ const verifyAny = async (keys) => {
942
+ for (const key of keys) {
943
+ if (await verifyRs256Signature(logoutToken, key)) return true;
944
+ }
945
+ return false;
946
+ };
947
+ let candidates = matchCandidates(jwks);
948
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
949
+ if (candidates.length === 0 || !validSig) {
950
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
951
+ if (freshJwks) {
952
+ candidates = matchCandidates(freshJwks);
953
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
954
+ }
955
+ }
956
+ if (candidates.length === 0) {
957
+ throw new SsoError(
958
+ "logout_token_invalid_signature",
959
+ "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5"
960
+ );
961
+ }
962
+ if (!validSig) {
963
+ throw new SsoError(
964
+ "logout_token_invalid_signature",
965
+ "Logout Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25"
966
+ );
967
+ }
968
+ const payload = decodeJwtPayload(logoutToken);
969
+ if (!payload) {
970
+ throw new SsoError("logout_token_invalid", "Logout Token payload \u89E3\u6790\u5931\u8D25");
971
+ }
972
+ const tokenIssuer = typeof payload.iss === "string" ? payload.iss.replace(/\/+$/, "") : "";
973
+ if (tokenIssuer !== normalizedIssuer) {
974
+ throw new SsoError(
975
+ "logout_token_issuer_mismatch",
976
+ "Logout Token issuer \u4E0D\u5339\u914D"
977
+ );
978
+ }
979
+ const aud = payload.aud;
980
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
981
+ if (!audList.includes(clientId)) {
982
+ throw new SsoError(
983
+ "logout_token_audience_mismatch",
984
+ "Logout Token audience \u4E0D\u5339\u914D"
985
+ );
986
+ }
987
+ if (typeof payload.exp !== "number") {
988
+ throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 exp \u58F0\u660E");
989
+ }
990
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
991
+ throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
992
+ }
993
+ const events = payload.events;
994
+ if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
995
+ throw new SsoError(
996
+ "logout_token_invalid",
997
+ "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
998
+ );
999
+ }
1000
+ const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : void 0;
1001
+ const sid = typeof payload.sid === "string" && payload.sid ? payload.sid : void 0;
1002
+ if (!sub && !sid) {
1003
+ throw new SsoError(
1004
+ "logout_token_invalid",
1005
+ "Logout Token \u5FC5\u987B\u5305\u542B sub \u6216 sid \u81F3\u5C11\u5176\u4E00"
1006
+ );
1007
+ }
1008
+ const jti = typeof payload.jti === "string" ? payload.jti : "";
1009
+ if (!jti) {
1010
+ throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
1011
+ }
1012
+ if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1013
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1014
+ }
1015
+ return { sub, sid };
1016
+ }
1017
+
1018
+ // src/next/backchannel-logout.ts
1019
+ function createBackchannelLogoutRouteHandler(config) {
1020
+ const { clientId, ssoBaseUrl, onLogout } = config;
1021
+ const insecureLocalDev = resolveInsecureLocalDev(
1022
+ config.insecureLocalDev ?? false,
1023
+ ssoBaseUrl
1024
+ );
1025
+ const secureCookies = !insecureLocalDev;
1026
+ const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
1027
+ const accessTokenCookieName = pickName(
1028
+ config.accessTokenCookieName,
1029
+ DEFAULT_ACCESS_TOKEN_COOKIE_NAME
1030
+ );
1031
+ const refreshTokenCookieName = pickName(
1032
+ config.refreshTokenCookieName,
1033
+ DEFAULT_REFRESH_TOKEN_COOKIE_NAME
1034
+ );
1035
+ const idTokenCookieName = pickName(
1036
+ config.idTokenCookieName,
1037
+ DEFAULT_ID_TOKEN_COOKIE_NAME
1038
+ );
1039
+ return async function handler(request) {
1040
+ if (request.method !== "POST") {
1041
+ return NextResponse4.json(
1042
+ { error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
1043
+ { status: 405 }
1044
+ );
1045
+ }
1046
+ const logoutToken = new URLSearchParams(await request.text()).get(
1047
+ "logout_token"
1048
+ );
1049
+ if (!logoutToken) {
1050
+ return NextResponse4.json(
1051
+ { error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
1052
+ { status: 400 }
1053
+ );
1054
+ }
1055
+ let payload;
1056
+ try {
1057
+ payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
1058
+ } catch (err) {
1059
+ const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1060
+ const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
1061
+ return NextResponse4.json(
1062
+ { error: code, error_description: description },
1063
+ { status: 400 }
1064
+ );
1065
+ }
1066
+ try {
1067
+ await onLogout?.(payload, request);
1068
+ } catch (err) {
1069
+ console.error(
1070
+ "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1071
+ err
1072
+ );
1073
+ return NextResponse4.json(
1074
+ { error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
1075
+ { status: 500 }
1076
+ );
1077
+ }
1078
+ const res = new NextResponse4(null, { status: 200 });
1079
+ res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1080
+ res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1081
+ res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1082
+ return res;
1083
+ };
1084
+ }
825
1085
  export {
826
1086
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
827
1087
  DEFAULT_ID_TOKEN_COOKIE_NAME,
828
1088
  DEFAULT_LOGOUT_STATE_COOKIE_NAME,
1089
+ DEFAULT_NONCE_COOKIE_NAME,
829
1090
  DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
830
1091
  DEFAULT_RETURN_COOKIE_NAME,
831
1092
  DEFAULT_STATE_COOKIE_NAME,
832
1093
  DEFAULT_VERIFIER_COOKIE_NAME,
1094
+ createBackchannelLogoutRouteHandler,
833
1095
  createCallbackRouteHandler,
834
1096
  createLogoutRouteHandler,
835
1097
  createSsoMiddleware,