@nihplod/sso-sdk 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,6 +82,7 @@ function matchesPath(pathname, paths) {
82
82
  var introspectionCache = /* @__PURE__ */ new Map();
83
83
  var INTROSPECT_CACHE_TTL_MS = 3e4;
84
84
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
85
+ var INTROSPECT_TIMEOUT_MS = 5e3;
85
86
  async function introspectCacheKey(token, clientId) {
86
87
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
87
88
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -114,7 +115,7 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
114
115
  const cacheKey = await introspectCacheKey(token, clientId);
115
116
  const cached = introspectCacheGet(cacheKey);
116
117
  if (cached) {
117
- return cached.active;
118
+ return cached.active ? "active" : "inactive";
118
119
  }
119
120
  try {
120
121
  const body = new URLSearchParams({
@@ -128,21 +129,22 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
128
129
  const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {
129
130
  method: "POST",
130
131
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
131
- body: body.toString()
132
+ body: body.toString(),
133
+ signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
132
134
  });
133
135
  if (!res.ok) {
134
136
  if (res.status !== 401 && res.status !== 403) {
135
- return false;
137
+ return "unreachable";
136
138
  }
137
139
  introspectCacheSet(cacheKey, false);
138
- return false;
140
+ return "inactive";
139
141
  }
140
142
  const data = await res.json();
141
143
  const active = data.active === true;
142
144
  introspectCacheSet(cacheKey, active);
143
- return active;
145
+ return active ? "active" : "inactive";
144
146
  } catch {
145
- return false;
147
+ return "unreachable";
146
148
  }
147
149
  }
148
150
  function createSsoMiddleware(config) {
@@ -192,13 +194,13 @@ function createSsoMiddleware(config) {
192
194
  const ssoSession = request.cookies.get(ssoCookieName);
193
195
  if (ssoSession?.value) {
194
196
  if (validateSsoCookie) {
195
- const tokenActive = await introspectAccessToken(
197
+ const introspectResult = await introspectAccessToken(
196
198
  ssoSession.value,
197
199
  normalizedServerBase,
198
200
  clientId,
199
201
  clientSecret
200
202
  );
201
- if (tokenActive) {
203
+ if (introspectResult === "active") {
202
204
  return NextResponse.next();
203
205
  }
204
206
  } else {
@@ -207,13 +209,19 @@ function createSsoMiddleware(config) {
207
209
  }
208
210
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
209
211
  if (accessTokenCookie?.value) {
210
- const tokenActive = await introspectAccessToken(
212
+ const introspectResult = await introspectAccessToken(
211
213
  accessTokenCookie.value,
212
214
  normalizedServerBase,
213
215
  clientId,
214
216
  clientSecret
215
217
  );
216
- if (tokenActive) {
218
+ if (introspectResult === "active") {
219
+ return NextResponse.next();
220
+ }
221
+ if (introspectResult === "unreachable") {
222
+ console.warn(
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
+ );
217
225
  return NextResponse.next();
218
226
  }
219
227
  }
@@ -751,6 +759,9 @@ function buildLogoutConfirmHtml() {
751
759
  <body style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;">
752
760
  <form method="post" style="text-align:center;">
753
761
  <p>\u786E\u5B9A\u8981\u9000\u51FA\u767B\u5F55\u5417\uFF1F</p>
762
+ <label style="display:flex;align-items:center;justify-content:center;gap:6px;margin:12px 0;font-size:14px;color:#374151;cursor:pointer;">
763
+ <input type="checkbox" name="global" value="1">\u540C\u65F6\u9000\u51FA\u6240\u6709 NIHPLOD \u5E73\u53F0
764
+ </label>
754
765
  <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
766
  <p><a href="/" style="color:#2563eb;text-decoration:underline;">\u53D6\u6D88\u5E76\u8FD4\u56DE\u9996\u9875</a></p>
756
767
  </form>
@@ -778,10 +789,18 @@ function createLogoutRouteHandler(config) {
778
789
  redirectUri,
779
790
  clientSecret,
780
791
  postLogoutRedirectUri = new URL(redirectUri).origin + "/",
781
- redirectToSso = true,
782
792
  callbackPath = "/api/auth/callback",
783
793
  insecureLocalDev: insecureLocalDevOpt = false
784
794
  } = config;
795
+ let defaultScope = config.defaultScope ?? "local";
796
+ if (config.redirectToSso !== void 0) {
797
+ console.warn(
798
+ '[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'
799
+ );
800
+ if (config.defaultScope === void 0) {
801
+ defaultScope = config.redirectToSso ? "global" : "local";
802
+ }
803
+ }
785
804
  const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
786
805
  const secureCookies = !insecureLocalDev;
787
806
  const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
@@ -816,6 +835,12 @@ function createLogoutRouteHandler(config) {
816
835
  headers: { "content-type": "text/html; charset=utf-8" }
817
836
  });
818
837
  }
838
+ let formGlobal = null;
839
+ try {
840
+ formGlobal = new URLSearchParams(await request.text()).get("global");
841
+ } catch {
842
+ }
843
+ const effectiveScope = formGlobal !== null ? formGlobal === "1" || formGlobal === "true" ? "global" : "local" : defaultScope;
819
844
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
820
845
  const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
821
846
  if (refreshToken) {
@@ -850,7 +875,7 @@ function createLogoutRouteHandler(config) {
850
875
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
851
876
  return res;
852
877
  };
853
- if (redirectToSso) {
878
+ if (effectiveScope === "global") {
854
879
  const discovery = await fetchDiscoveryCached(normalizedServerBase);
855
880
  const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
856
881
  const logoutUrl = new URL(endSessionEndpoint);
@@ -873,6 +898,198 @@ function createLogoutRouteHandler(config) {
873
898
  );
874
899
  };
875
900
  }
901
+
902
+ // src/next/backchannel-logout.ts
903
+ import { NextResponse as NextResponse4 } from "next/server";
904
+
905
+ // src/core/logout-token.ts
906
+ var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
907
+ var JTI_CACHE_CAPACITY = 1e3;
908
+ var seenJti = /* @__PURE__ */ new Map();
909
+ function recordJti(jti, expiresAtMs) {
910
+ const now = Date.now();
911
+ const existing = seenJti.get(jti);
912
+ if (existing !== void 0 && existing > now) return false;
913
+ for (const [key, exp] of seenJti) {
914
+ if (exp <= now) seenJti.delete(key);
915
+ }
916
+ while (seenJti.size >= JTI_CACHE_CAPACITY) {
917
+ const oldest = seenJti.keys().next().value;
918
+ if (oldest === void 0) break;
919
+ seenJti.delete(oldest);
920
+ }
921
+ seenJti.set(jti, expiresAtMs);
922
+ return true;
923
+ }
924
+ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
925
+ const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
926
+ const header = decodeJwtHeader(logoutToken);
927
+ if (!header) {
928
+ throw new SsoError("logout_token_invalid", "Logout Token \u683C\u5F0F\u9519\u8BEF");
929
+ }
930
+ if (header.alg !== "RS256") {
931
+ throw new SsoError(
932
+ "logout_token_unsupported_alg",
933
+ `\u4E0D\u652F\u6301\u7684 Logout Token \u7B7E\u540D\u7B97\u6CD5: ${String(header.alg)}`
934
+ );
935
+ }
936
+ const discovery = await fetchDiscoveryDoc(baseUrl);
937
+ const normalizedIssuer = (discovery?.issuer || baseUrl).replace(/\/+$/, "");
938
+ const jwks = await fetchJwks(baseUrl);
939
+ if (!jwks) {
940
+ throw new SsoError(
941
+ "logout_token_invalid_signature",
942
+ "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 Logout Token \u7B7E\u540D"
943
+ );
944
+ }
945
+ const kid = typeof header.kid === "string" ? header.kid : void 0;
946
+ const matchCandidates = (set) => set.keys.filter(
947
+ (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
948
+ );
949
+ const verifyAny = async (keys) => {
950
+ for (const key of keys) {
951
+ if (await verifyRs256Signature(logoutToken, key)) return true;
952
+ }
953
+ return false;
954
+ };
955
+ let candidates = matchCandidates(jwks);
956
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
957
+ if (candidates.length === 0 || !validSig) {
958
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
959
+ if (freshJwks) {
960
+ candidates = matchCandidates(freshJwks);
961
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
962
+ }
963
+ }
964
+ if (candidates.length === 0) {
965
+ throw new SsoError(
966
+ "logout_token_invalid_signature",
967
+ "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5"
968
+ );
969
+ }
970
+ if (!validSig) {
971
+ throw new SsoError(
972
+ "logout_token_invalid_signature",
973
+ "Logout Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25"
974
+ );
975
+ }
976
+ const payload = decodeJwtPayload(logoutToken);
977
+ if (!payload) {
978
+ throw new SsoError("logout_token_invalid", "Logout Token payload \u89E3\u6790\u5931\u8D25");
979
+ }
980
+ const tokenIssuer = typeof payload.iss === "string" ? payload.iss.replace(/\/+$/, "") : "";
981
+ if (tokenIssuer !== normalizedIssuer) {
982
+ throw new SsoError(
983
+ "logout_token_issuer_mismatch",
984
+ "Logout Token issuer \u4E0D\u5339\u914D"
985
+ );
986
+ }
987
+ const aud = payload.aud;
988
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
989
+ if (!audList.includes(clientId)) {
990
+ throw new SsoError(
991
+ "logout_token_audience_mismatch",
992
+ "Logout Token audience \u4E0D\u5339\u914D"
993
+ );
994
+ }
995
+ if (typeof payload.exp !== "number") {
996
+ throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 exp \u58F0\u660E");
997
+ }
998
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
999
+ throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
1000
+ }
1001
+ const events = payload.events;
1002
+ if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
1003
+ throw new SsoError(
1004
+ "logout_token_invalid",
1005
+ "Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
1006
+ );
1007
+ }
1008
+ const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : void 0;
1009
+ const sid = typeof payload.sid === "string" && payload.sid ? payload.sid : void 0;
1010
+ if (!sub && !sid) {
1011
+ throw new SsoError(
1012
+ "logout_token_invalid",
1013
+ "Logout Token \u5FC5\u987B\u5305\u542B sub \u6216 sid \u81F3\u5C11\u5176\u4E00"
1014
+ );
1015
+ }
1016
+ const jti = typeof payload.jti === "string" ? payload.jti : "";
1017
+ if (!jti) {
1018
+ throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
1019
+ }
1020
+ if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1021
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1022
+ }
1023
+ return { sub, sid };
1024
+ }
1025
+
1026
+ // src/next/backchannel-logout.ts
1027
+ function createBackchannelLogoutRouteHandler(config) {
1028
+ const { clientId, ssoBaseUrl, onLogout } = config;
1029
+ const insecureLocalDev = resolveInsecureLocalDev(
1030
+ config.insecureLocalDev ?? false,
1031
+ ssoBaseUrl
1032
+ );
1033
+ const secureCookies = !insecureLocalDev;
1034
+ const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
1035
+ const accessTokenCookieName = pickName(
1036
+ config.accessTokenCookieName,
1037
+ DEFAULT_ACCESS_TOKEN_COOKIE_NAME
1038
+ );
1039
+ const refreshTokenCookieName = pickName(
1040
+ config.refreshTokenCookieName,
1041
+ DEFAULT_REFRESH_TOKEN_COOKIE_NAME
1042
+ );
1043
+ const idTokenCookieName = pickName(
1044
+ config.idTokenCookieName,
1045
+ DEFAULT_ID_TOKEN_COOKIE_NAME
1046
+ );
1047
+ return async function handler(request) {
1048
+ if (request.method !== "POST") {
1049
+ return NextResponse4.json(
1050
+ { error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
1051
+ { status: 405 }
1052
+ );
1053
+ }
1054
+ const logoutToken = new URLSearchParams(await request.text()).get(
1055
+ "logout_token"
1056
+ );
1057
+ if (!logoutToken) {
1058
+ return NextResponse4.json(
1059
+ { error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
1060
+ { status: 400 }
1061
+ );
1062
+ }
1063
+ let payload;
1064
+ try {
1065
+ payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
1066
+ } catch (err) {
1067
+ const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1068
+ const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
1069
+ return NextResponse4.json(
1070
+ { error: code, error_description: description },
1071
+ { status: 400 }
1072
+ );
1073
+ }
1074
+ try {
1075
+ await onLogout?.(payload, request);
1076
+ } catch (err) {
1077
+ console.error(
1078
+ "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1079
+ err
1080
+ );
1081
+ return NextResponse4.json(
1082
+ { error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
1083
+ { status: 500 }
1084
+ );
1085
+ }
1086
+ const res = new NextResponse4(null, { status: 200 });
1087
+ res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1088
+ res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1089
+ res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
1090
+ return res;
1091
+ };
1092
+ }
876
1093
  export {
877
1094
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
878
1095
  DEFAULT_ID_TOKEN_COOKIE_NAME,
@@ -882,6 +1099,7 @@ export {
882
1099
  DEFAULT_RETURN_COOKIE_NAME,
883
1100
  DEFAULT_STATE_COOKIE_NAME,
884
1101
  DEFAULT_VERIFIER_COOKIE_NAME,
1102
+ createBackchannelLogoutRouteHandler,
885
1103
  createCallbackRouteHandler,
886
1104
  createLogoutRouteHandler,
887
1105
  createSsoMiddleware,