@nihplod/sso-sdk 1.5.0 → 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;
@@ -180,7 +188,7 @@ function createSsoMiddleware(config) {
180
188
  }
181
189
  return async function ssoMiddleware(request) {
182
190
  const { pathname } = request.nextUrl;
183
- 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?)$/))) {
184
192
  return NextResponse.next();
185
193
  }
186
194
  if (pathname === callbackPath) {
@@ -499,7 +507,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
499
507
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
500
508
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
501
509
  }
502
- 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) {
503
514
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
504
515
  }
505
516
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -516,10 +527,27 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
516
527
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
517
528
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
518
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
+ );
519
535
  }
520
536
  return { sub: payload.sub };
521
537
  }
522
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
+
523
551
  // src/next/callback.ts
524
552
  function escapeHtml(value) {
525
553
  return value.replace(/[&<>"']/g, (ch) => {
@@ -744,11 +772,15 @@ function createCallbackRouteHandler(config) {
744
772
  await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
745
773
  }
746
774
  try {
747
- res = await fetch(tokenEndpoint, {
748
- method: "POST",
749
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
750
- body: body.toString()
751
- });
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
+ );
752
784
  lastError = null;
753
785
  break;
754
786
  } catch (err) {
@@ -982,7 +1014,7 @@ function createLogoutRouteHandler(config) {
982
1014
  const returnedState = request.nextUrl.searchParams.get("state");
983
1015
  if (returnedState) {
984
1016
  const savedState = request.cookies.get(logoutStateCookieName)?.value;
985
- if (!savedState || savedState !== returnedState) {
1017
+ if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
986
1018
  return NextResponse3.json(
987
1019
  { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
988
1020
  { status: 400 }
@@ -1098,8 +1130,19 @@ import { NextResponse as NextResponse4 } from "next/server";
1098
1130
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
1099
1131
  var JTI_CACHE_CAPACITY = 1e3;
1100
1132
  var seenJti = /* @__PURE__ */ new Map();
1101
- function releaseLogoutTokenJti(jti) {
1102
- seenJti.delete(jti);
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);
1103
1146
  }
1104
1147
  function recordJti(jti, expiresAtMs) {
1105
1148
  const now = Date.now();
@@ -1116,7 +1159,7 @@ function recordJti(jti, expiresAtMs) {
1116
1159
  seenJti.set(jti, expiresAtMs);
1117
1160
  return true;
1118
1161
  }
1119
- async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1162
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
1120
1163
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
1121
1164
  const header = decodeJwtHeader(logoutToken);
1122
1165
  if (!header) {
@@ -1225,10 +1268,27 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1225
1268
  if (!jti) {
1226
1269
  throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
1227
1270
  }
1228
- 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)) {
1229
1289
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1230
1290
  }
1231
- return { payload: { sub, sid }, jti };
1291
+ return { payload: { sub, sid }, jti, replayKey };
1232
1292
  }
1233
1293
 
1234
1294
  // src/next/backchannel-logout.ts
@@ -1270,7 +1330,9 @@ function createBackchannelLogoutRouteHandler(config) {
1270
1330
  }
1271
1331
  let verified;
1272
1332
  try {
1273
- verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
1333
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
1334
+ jtiStore: config.jtiStore
1335
+ });
1274
1336
  } catch (err) {
1275
1337
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1276
1338
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
@@ -1282,7 +1344,7 @@ function createBackchannelLogoutRouteHandler(config) {
1282
1344
  try {
1283
1345
  await onLogout?.(verified.payload, request);
1284
1346
  } catch (err) {
1285
- releaseLogoutTokenJti(verified.jti);
1347
+ await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
1286
1348
  console.error(
1287
1349
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1288
1350
  err