@nihplod/sso-sdk 1.3.1 → 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.
@@ -751,6 +751,9 @@ function buildLogoutConfirmHtml() {
751
751
  <body style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;">
752
752
  <form method="post" style="text-align:center;">
753
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>
754
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>
755
758
  <p><a href="/" style="color:#2563eb;text-decoration:underline;">\u53D6\u6D88\u5E76\u8FD4\u56DE\u9996\u9875</a></p>
756
759
  </form>
@@ -778,10 +781,18 @@ function createLogoutRouteHandler(config) {
778
781
  redirectUri,
779
782
  clientSecret,
780
783
  postLogoutRedirectUri = new URL(redirectUri).origin + "/",
781
- redirectToSso = true,
782
784
  callbackPath = "/api/auth/callback",
783
785
  insecureLocalDev: insecureLocalDevOpt = false
784
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
+ }
785
796
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
786
797
  const secureCookies = !insecureLocalDev;
787
798
  const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
@@ -816,6 +827,12 @@ function createLogoutRouteHandler(config) {
816
827
  headers: { "content-type": "text/html; charset=utf-8" }
817
828
  });
818
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;
819
836
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
820
837
  const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
821
838
  if (refreshToken) {
@@ -850,7 +867,7 @@ function createLogoutRouteHandler(config) {
850
867
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
851
868
  return res;
852
869
  };
853
- if (redirectToSso) {
870
+ if (effectiveScope === "global") {
854
871
  const discovery = await fetchDiscoveryCached(normalizedServerBase);
855
872
  const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
856
873
  const logoutUrl = new URL(endSessionEndpoint);
@@ -873,6 +890,198 @@ function createLogoutRouteHandler(config) {
873
890
  );
874
891
  };
875
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
+ }
876
1085
  export {
877
1086
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
878
1087
  DEFAULT_ID_TOKEN_COOKIE_NAME,
@@ -882,6 +1091,7 @@ export {
882
1091
  DEFAULT_RETURN_COOKIE_NAME,
883
1092
  DEFAULT_STATE_COOKIE_NAME,
884
1093
  DEFAULT_VERIFIER_COOKIE_NAME,
1094
+ createBackchannelLogoutRouteHandler,
885
1095
  createCallbackRouteHandler,
886
1096
  createLogoutRouteHandler,
887
1097
  createSsoMiddleware,