@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.
@@ -36,7 +36,12 @@ interface SsoMiddlewareConfig {
36
36
  redirectUri: string;
37
37
  /** 请求的 scope(空格分隔),默认 "openid profile" */
38
38
  scopes?: string;
39
- /** 公开路由前缀(不需要认证) */
39
+ /**
40
+ * 公开路由前缀(不需要认证)。
41
+ *
42
+ * 注意:/api/ 下的路径永不按静态资源扩展名(.js/.css/...)放行;
43
+ * 如需公开某个 API 路径,必须在此显式列出。
44
+ */
40
45
  publicPaths?: string[];
41
46
  /** 回调路径(不触发重定向),默认 "/api/auth/callback" */
42
47
  callbackPath?: string;
@@ -298,6 +303,28 @@ interface LogoutTokenPayload {
298
303
  sub?: string;
299
304
  sid?: string;
300
305
  }
306
+ /**
307
+ * jti 防重放存储接口(可注入)。
308
+ *
309
+ * 默认实现是进程内 Map(per-instance):多实例 / Serverless 部署下各实例
310
+ * 互不可见,重放检查形同虚设,必须注入共享存储(如 Redis)。
311
+ * key 为 `${issuer}:${jti}`,ttlSeconds 为建议过期时间(logout_token exp + 60s 宽限)。
312
+ */
313
+ interface LogoutJtiStore {
314
+ has(key: string): Promise<boolean> | boolean;
315
+ add(key: string, ttlSeconds: number): Promise<void> | void;
316
+ /**
317
+ * 原子 check-and-set(如 Redis `SET key 1 NX EX ttl`):返回 false 表示
318
+ * key 已存在(判重放)。提供时优先使用;缺失时回退 has+add
319
+ * (非原子,仅单实例部署可接受:并发窗口内同一 jti 可能双双通过)。
320
+ */
321
+ addIfAbsent?(key: string, ttlSeconds: number): Promise<boolean> | boolean;
322
+ /**
323
+ * 释放已记录的 key。onLogout 钩子失败后需要允许 IdP 用同一 logout_token
324
+ * 重投,SDK 会调用此方法;未实现时 IdP 重投将被重放检查以 400 拒绝。
325
+ */
326
+ remove?(key: string): Promise<void> | void;
327
+ }
301
328
 
302
329
  /**
303
330
  * App Router Backchannel Logout 接收端 Route Handler
@@ -340,6 +367,15 @@ interface BackchannelLogoutRouteConfig {
340
367
  refreshTokenCookieName?: string;
341
368
  /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */
342
369
  idTokenCookieName?: string;
370
+ /**
371
+ * jti 防重放存储(可选)。
372
+ *
373
+ * 默认实现是进程内 Map(per-instance):多实例 / Serverless 部署下各实例
374
+ * 互不可见,重放保护形同虚设(IdP 重投/攻击者重放到另一实例会被当作首次)。
375
+ * 这类部署 MUST 注入共享存储(如 Redis:addIfAbsent = `SET key 1 NX EX ttl`,
376
+ * remove = `DEL key`——onLogout 失败时用于释放 jti 允许 IdP 重投)。
377
+ */
378
+ jtiStore?: LogoutJtiStore;
343
379
  /**
344
380
  * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除
345
381
  * __Host-/__Secure- 前缀;必须与 middleware / callback / logout 的配置保持一致。
@@ -391,4 +427,4 @@ declare function getSecureCookieOptions(maxAge?: number, path?: string, secure?:
391
427
  maxAge?: number;
392
428
  };
393
429
 
394
- export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
430
+ export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
@@ -36,7 +36,12 @@ interface SsoMiddlewareConfig {
36
36
  redirectUri: string;
37
37
  /** 请求的 scope(空格分隔),默认 "openid profile" */
38
38
  scopes?: string;
39
- /** 公开路由前缀(不需要认证) */
39
+ /**
40
+ * 公开路由前缀(不需要认证)。
41
+ *
42
+ * 注意:/api/ 下的路径永不按静态资源扩展名(.js/.css/...)放行;
43
+ * 如需公开某个 API 路径,必须在此显式列出。
44
+ */
40
45
  publicPaths?: string[];
41
46
  /** 回调路径(不触发重定向),默认 "/api/auth/callback" */
42
47
  callbackPath?: string;
@@ -298,6 +303,28 @@ interface LogoutTokenPayload {
298
303
  sub?: string;
299
304
  sid?: string;
300
305
  }
306
+ /**
307
+ * jti 防重放存储接口(可注入)。
308
+ *
309
+ * 默认实现是进程内 Map(per-instance):多实例 / Serverless 部署下各实例
310
+ * 互不可见,重放检查形同虚设,必须注入共享存储(如 Redis)。
311
+ * key 为 `${issuer}:${jti}`,ttlSeconds 为建议过期时间(logout_token exp + 60s 宽限)。
312
+ */
313
+ interface LogoutJtiStore {
314
+ has(key: string): Promise<boolean> | boolean;
315
+ add(key: string, ttlSeconds: number): Promise<void> | void;
316
+ /**
317
+ * 原子 check-and-set(如 Redis `SET key 1 NX EX ttl`):返回 false 表示
318
+ * key 已存在(判重放)。提供时优先使用;缺失时回退 has+add
319
+ * (非原子,仅单实例部署可接受:并发窗口内同一 jti 可能双双通过)。
320
+ */
321
+ addIfAbsent?(key: string, ttlSeconds: number): Promise<boolean> | boolean;
322
+ /**
323
+ * 释放已记录的 key。onLogout 钩子失败后需要允许 IdP 用同一 logout_token
324
+ * 重投,SDK 会调用此方法;未实现时 IdP 重投将被重放检查以 400 拒绝。
325
+ */
326
+ remove?(key: string): Promise<void> | void;
327
+ }
301
328
 
302
329
  /**
303
330
  * App Router Backchannel Logout 接收端 Route Handler
@@ -340,6 +367,15 @@ interface BackchannelLogoutRouteConfig {
340
367
  refreshTokenCookieName?: string;
341
368
  /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */
342
369
  idTokenCookieName?: string;
370
+ /**
371
+ * jti 防重放存储(可选)。
372
+ *
373
+ * 默认实现是进程内 Map(per-instance):多实例 / Serverless 部署下各实例
374
+ * 互不可见,重放保护形同虚设(IdP 重投/攻击者重放到另一实例会被当作首次)。
375
+ * 这类部署 MUST 注入共享存储(如 Redis:addIfAbsent = `SET key 1 NX EX ttl`,
376
+ * remove = `DEL key`——onLogout 失败时用于释放 jti 允许 IdP 重投)。
377
+ */
378
+ jtiStore?: LogoutJtiStore;
343
379
  /**
344
380
  * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除
345
381
  * __Host-/__Secure- 前缀;必须与 middleware / callback / logout 的配置保持一致。
@@ -391,4 +427,4 @@ declare function getSecureCookieOptions(maxAge?: number, path?: string, secure?:
391
427
  maxAge?: number;
392
428
  };
393
429
 
394
- export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
430
+ export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
@@ -114,15 +114,18 @@ async function computeCodeChallenge(verifier) {
114
114
  function matchesPath(pathname, paths) {
115
115
  return paths.some((path) => {
116
116
  if (pathname === path) return true;
117
- if (path.endsWith("/:path*") && pathname.startsWith(path.replace("/:path*", ""))) return true;
118
- if (pathname.startsWith(path + "/")) return true;
119
- return false;
117
+ if (path.endsWith("/:path*")) {
118
+ const base = path.slice(0, -"/:path*".length);
119
+ return pathname === base || pathname.startsWith(base + "/");
120
+ }
121
+ return pathname.startsWith(path + "/");
120
122
  });
121
123
  }
122
124
  var introspectionCache = /* @__PURE__ */ new Map();
123
125
  var INTROSPECT_CACHE_TTL_MS = 3e4;
124
126
  var INTROSPECT_CACHE_MAX_ENTRIES = 500;
125
127
  var INTROSPECT_TIMEOUT_MS = 5e3;
128
+ var warnedIntrospectClientAuthFailure = false;
126
129
  async function introspectCacheKey(token, clientId) {
127
130
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
128
131
  const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -176,8 +179,13 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
176
179
  if (res.status !== 401 && res.status !== 403) {
177
180
  return "unreachable";
178
181
  }
179
- introspectCacheSet(cacheKey, false);
180
- return "inactive";
182
+ if (!warnedIntrospectClientAuthFailure) {
183
+ warnedIntrospectClientAuthFailure = true;
184
+ console.warn(
185
+ `[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`
186
+ );
187
+ }
188
+ return "unreachable";
181
189
  }
182
190
  const data = await res.json();
183
191
  const active = data.active === true;
@@ -220,7 +228,7 @@ function createSsoMiddleware(config) {
220
228
  }
221
229
  return async function ssoMiddleware(request) {
222
230
  const { pathname } = request.nextUrl;
223
- if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
231
+ if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
224
232
  return import_server.NextResponse.next();
225
233
  }
226
234
  if (pathname === callbackPath) {
@@ -539,7 +547,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
539
547
  if (Date.now() >= payload.exp * 1e3 + 6e4) {
540
548
  throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
541
549
  }
542
- if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
550
+ if (typeof payload.iat !== "number") {
551
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
552
+ }
553
+ if (payload.iat * 1e3 > Date.now() + 6e4) {
543
554
  throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
544
555
  }
545
556
  if (typeof payload.sub !== "string" || !payload.sub) {
@@ -556,10 +567,27 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
556
567
  if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
557
568
  throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
558
569
  }
570
+ } else if (typeof payload.nonce === "string" && payload.nonce) {
571
+ throw new SsoError(
572
+ "id_token_nonce_mismatch",
573
+ "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"
574
+ );
559
575
  }
560
576
  return { sub: payload.sub };
561
577
  }
562
578
 
579
+ // src/core/http.ts
580
+ var REQUEST_TIMEOUT_MS = 1e4;
581
+ async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
582
+ const controller = new AbortController();
583
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
584
+ try {
585
+ return await fetch(input, { ...init, signal: controller.signal });
586
+ } finally {
587
+ clearTimeout(timer);
588
+ }
589
+ }
590
+
563
591
  // src/next/callback.ts
564
592
  function escapeHtml(value) {
565
593
  return value.replace(/[&<>"']/g, (ch) => {
@@ -784,11 +812,15 @@ function createCallbackRouteHandler(config) {
784
812
  await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
785
813
  }
786
814
  try {
787
- res = await fetch(tokenEndpoint, {
788
- method: "POST",
789
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
790
- body: body.toString()
791
- });
815
+ res = await fetchWithTimeout(
816
+ tokenEndpoint,
817
+ {
818
+ method: "POST",
819
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
820
+ body: body.toString()
821
+ },
822
+ 1e4
823
+ );
792
824
  lastError = null;
793
825
  break;
794
826
  } catch (err) {
@@ -1022,7 +1054,7 @@ function createLogoutRouteHandler(config) {
1022
1054
  const returnedState = request.nextUrl.searchParams.get("state");
1023
1055
  if (returnedState) {
1024
1056
  const savedState = request.cookies.get(logoutStateCookieName)?.value;
1025
- if (!savedState || savedState !== returnedState) {
1057
+ if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
1026
1058
  return import_server3.NextResponse.json(
1027
1059
  { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
1028
1060
  { status: 400 }
@@ -1138,8 +1170,19 @@ var import_server4 = require("next/server");
1138
1170
  var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
1139
1171
  var JTI_CACHE_CAPACITY = 1e3;
1140
1172
  var seenJti = /* @__PURE__ */ new Map();
1141
- function releaseLogoutTokenJti(jti) {
1142
- seenJti.delete(jti);
1173
+ async function releaseLogoutTokenJti(replayKey, options = {}) {
1174
+ const store = options.jtiStore;
1175
+ if (store?.remove) {
1176
+ await store.remove(replayKey);
1177
+ return;
1178
+ }
1179
+ if (store) {
1180
+ console.warn(
1181
+ "[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"
1182
+ );
1183
+ return;
1184
+ }
1185
+ seenJti.delete(replayKey);
1143
1186
  }
1144
1187
  function recordJti(jti, expiresAtMs) {
1145
1188
  const now = Date.now();
@@ -1156,7 +1199,7 @@ function recordJti(jti, expiresAtMs) {
1156
1199
  seenJti.set(jti, expiresAtMs);
1157
1200
  return true;
1158
1201
  }
1159
- async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1202
+ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
1160
1203
  const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
1161
1204
  const header = decodeJwtHeader(logoutToken);
1162
1205
  if (!header) {
@@ -1265,10 +1308,27 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
1265
1308
  if (!jti) {
1266
1309
  throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
1267
1310
  }
1268
- if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
1311
+ const replayKey = `${normalizedIssuer}:${jti}`;
1312
+ const replayTtlSeconds = Math.max(
1313
+ 1,
1314
+ Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
1315
+ );
1316
+ const jtiStore = options.jtiStore;
1317
+ if (jtiStore) {
1318
+ if (jtiStore.addIfAbsent) {
1319
+ if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
1320
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1321
+ }
1322
+ } else {
1323
+ if (await jtiStore.has(replayKey)) {
1324
+ throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1325
+ }
1326
+ await jtiStore.add(replayKey, replayTtlSeconds);
1327
+ }
1328
+ } else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
1269
1329
  throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
1270
1330
  }
1271
- return { payload: { sub, sid }, jti };
1331
+ return { payload: { sub, sid }, jti, replayKey };
1272
1332
  }
1273
1333
 
1274
1334
  // src/next/backchannel-logout.ts
@@ -1310,7 +1370,9 @@ function createBackchannelLogoutRouteHandler(config) {
1310
1370
  }
1311
1371
  let verified;
1312
1372
  try {
1313
- verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
1373
+ verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
1374
+ jtiStore: config.jtiStore
1375
+ });
1314
1376
  } catch (err) {
1315
1377
  const code = err instanceof SsoError ? err.code : "logout_token_invalid";
1316
1378
  const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
@@ -1322,7 +1384,7 @@ function createBackchannelLogoutRouteHandler(config) {
1322
1384
  try {
1323
1385
  await onLogout?.(verified.payload, request);
1324
1386
  } catch (err) {
1325
- releaseLogoutTokenJti(verified.jti);
1387
+ await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
1326
1388
  console.error(
1327
1389
  "[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
1328
1390
  err