@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.
- package/README.md +21 -2
- package/dist/index.d.mts +25 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +79 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +79 -6
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +38 -2
- package/dist/next/index.d.ts +38 -2
- package/dist/next/index.js +82 -20
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +82 -20
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +25 -0
- package/dist/react/index.d.ts +25 -0
- package/dist/react/index.js +90 -9
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +90 -9
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/next/index.d.mts
CHANGED
|
@@ -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 };
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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 };
|
package/dist/next/index.js
CHANGED
|
@@ -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*")
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
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
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
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(
|
|
1142
|
-
|
|
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
|
-
|
|
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.
|
|
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
|