@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.
- package/README.md +55 -5
- package/dist/index.d.mts +25 -5
- package/dist/index.d.ts +25 -5
- package/dist/index.js +64 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +64 -4
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +73 -3
- package/dist/next/index.d.ts +73 -3
- package/dist/next/index.js +231 -12
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +230 -12
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +25 -5
- package/dist/react/index.d.ts +25 -5
- package/dist/react/index.js +72 -4
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +72 -4
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/next/index.d.ts
CHANGED
|
@@ -159,7 +159,9 @@ declare function createCallbackRouteHandler(config: CallbackRouteConfig): (reque
|
|
|
159
159
|
* 在 /api/auth/logout 路由中处理本地登出 + RP-Initiated Logout:
|
|
160
160
|
* 1. 从 cookie 读取 refresh_token 并调用 revocation_endpoint 撤销
|
|
161
161
|
* 2. 清除所有 SSO cookie
|
|
162
|
-
* 3.
|
|
162
|
+
* 3. 按退出范围(defaultScope / 表单 global 字段)决定:
|
|
163
|
+
* - "local"(默认):仅退出本站,重定向回本站首页
|
|
164
|
+
* - "global":重定向到 SSO 中心 end-session,全局退出所有 NIHPLOD 平台
|
|
163
165
|
*
|
|
164
166
|
* 用法 (src/app/api/auth/logout/route.ts):
|
|
165
167
|
* ```ts
|
|
@@ -193,7 +195,18 @@ interface LogoutRouteConfig {
|
|
|
193
195
|
clientSecret?: string;
|
|
194
196
|
/** 登出后跳转回子项目的地址,默认取 redirectUri 的 origin */
|
|
195
197
|
postLogoutRedirectUri?: string;
|
|
196
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* 默认退出范围(默认 "local"):
|
|
200
|
+
* - "local":仅退出本站(撤销 refresh_token + 清本地 cookie),不跳转 SSO 中心;
|
|
201
|
+
* - "global":同时跳转 SSO 中心 end-session 全局退出(RP-Initiated Logout)。
|
|
202
|
+
* 用户可在 GET 确认页勾选"同时退出所有 NIHPLOD 平台"后通过表单字段
|
|
203
|
+
* global=1 覆盖默认值;POST 表单携带 global 字段时以表单为准。
|
|
204
|
+
*/
|
|
205
|
+
defaultScope?: "local" | "global";
|
|
206
|
+
/**
|
|
207
|
+
* @deprecated 请改用 defaultScope(true → "global",false → "local")。
|
|
208
|
+
* 仅为兼容保留;与 defaultScope 同时传入时以 defaultScope 为准。
|
|
209
|
+
*/
|
|
197
210
|
redirectToSso?: boolean;
|
|
198
211
|
/** Access Token Cookie 名称 */
|
|
199
212
|
accessTokenCookieName?: string;
|
|
@@ -230,6 +243,63 @@ interface LogoutRouteConfig {
|
|
|
230
243
|
}
|
|
231
244
|
declare function createLogoutRouteHandler(config: LogoutRouteConfig): (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
232
245
|
|
|
246
|
+
/** 验证通过的 logout_token 负载(仅暴露子站清会话所需的标识) */
|
|
247
|
+
interface LogoutTokenPayload {
|
|
248
|
+
sub?: string;
|
|
249
|
+
sid?: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* App Router Backchannel Logout 接收端 Route Handler
|
|
254
|
+
*
|
|
255
|
+
* 接收 SSO 中心在用户全局登出 / 撤销授权时推送的 logout_token
|
|
256
|
+
* (OIDC Back-Channel Logout 1.0),验证通过后清除本站 SSO cookie 并
|
|
257
|
+
* 调用 onLogout 钩子(子站在钩子里清除自己的本地会话,如数据库 session)。
|
|
258
|
+
*
|
|
259
|
+
* 前置条件:在 SSO 中心管理后台为本 client 注册 backchannelLogoutUri
|
|
260
|
+
* 指向本路由的公网地址(必须 HTTPS)。
|
|
261
|
+
*
|
|
262
|
+
* 用法 (src/app/api/auth/backchannel-logout/route.ts):
|
|
263
|
+
* ```ts
|
|
264
|
+
* import { createBackchannelLogoutRouteHandler } from "@nihplod/sso-sdk/next";
|
|
265
|
+
*
|
|
266
|
+
* export const POST = createBackchannelLogoutRouteHandler({
|
|
267
|
+
* clientId: "my-app",
|
|
268
|
+
* ssoBaseUrl: "https://nihplod.cn",
|
|
269
|
+
* onLogout: async ({ sub, sid }) => {
|
|
270
|
+
* // 清除子站本地会话(按 sub 或 sid 定位)
|
|
271
|
+
* },
|
|
272
|
+
* });
|
|
273
|
+
* ```
|
|
274
|
+
*/
|
|
275
|
+
|
|
276
|
+
interface BackchannelLogoutRouteConfig {
|
|
277
|
+
/** OAuth Client ID(logout_token 的 aud 必须等于它) */
|
|
278
|
+
clientId: string;
|
|
279
|
+
/** SSO 中心地址(Discovery / JWKS 基准) */
|
|
280
|
+
ssoBaseUrl: string;
|
|
281
|
+
/**
|
|
282
|
+
* 登出通知钩子:logout_token 验证通过后调用,
|
|
283
|
+
* 子站在此处按 sub / sid 清除自己的本地会话(数据库 session 等)。
|
|
284
|
+
* 抛错时返回 500,让 IdP 重投。
|
|
285
|
+
*/
|
|
286
|
+
onLogout?: (payload: LogoutTokenPayload, request: NextRequest) => void | Promise<void>;
|
|
287
|
+
/** Access Token Cookie 名称 */
|
|
288
|
+
accessTokenCookieName?: string;
|
|
289
|
+
/** Refresh Token Cookie 名称 */
|
|
290
|
+
refreshTokenCookieName?: string;
|
|
291
|
+
/** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */
|
|
292
|
+
idTokenCookieName?: string;
|
|
293
|
+
/**
|
|
294
|
+
* 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除
|
|
295
|
+
* __Host-/__Secure- 前缀;必须与 middleware / callback / logout 的配置保持一致。
|
|
296
|
+
* 生产严禁启用——生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下
|
|
297
|
+
* 该开关会被强制忽略并告警。
|
|
298
|
+
*/
|
|
299
|
+
insecureLocalDev?: boolean;
|
|
300
|
+
}
|
|
301
|
+
declare function createBackchannelLogoutRouteHandler(config: BackchannelLogoutRouteConfig): (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
302
|
+
|
|
233
303
|
/**
|
|
234
304
|
* Next.js 集成默认 Cookie 名称
|
|
235
305
|
*
|
|
@@ -271,4 +341,4 @@ declare function getSecureCookieOptions(maxAge?: number, path?: string, secure?:
|
|
|
271
341
|
maxAge?: number;
|
|
272
342
|
};
|
|
273
343
|
|
|
274
|
-
export { 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, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
|
|
344
|
+
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 };
|
package/dist/next/index.js
CHANGED
|
@@ -28,6 +28,7 @@ __export(next_exports, {
|
|
|
28
28
|
DEFAULT_RETURN_COOKIE_NAME: () => DEFAULT_RETURN_COOKIE_NAME,
|
|
29
29
|
DEFAULT_STATE_COOKIE_NAME: () => DEFAULT_STATE_COOKIE_NAME,
|
|
30
30
|
DEFAULT_VERIFIER_COOKIE_NAME: () => DEFAULT_VERIFIER_COOKIE_NAME,
|
|
31
|
+
createBackchannelLogoutRouteHandler: () => createBackchannelLogoutRouteHandler,
|
|
31
32
|
createCallbackRouteHandler: () => createCallbackRouteHandler,
|
|
32
33
|
createLogoutRouteHandler: () => createLogoutRouteHandler,
|
|
33
34
|
createSsoMiddleware: () => createSsoMiddleware,
|
|
@@ -121,6 +122,7 @@ function matchesPath(pathname, paths) {
|
|
|
121
122
|
var introspectionCache = /* @__PURE__ */ new Map();
|
|
122
123
|
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
123
124
|
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
125
|
+
var INTROSPECT_TIMEOUT_MS = 5e3;
|
|
124
126
|
async function introspectCacheKey(token, clientId) {
|
|
125
127
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
126
128
|
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -153,7 +155,7 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
153
155
|
const cacheKey = await introspectCacheKey(token, clientId);
|
|
154
156
|
const cached = introspectCacheGet(cacheKey);
|
|
155
157
|
if (cached) {
|
|
156
|
-
return cached.active;
|
|
158
|
+
return cached.active ? "active" : "inactive";
|
|
157
159
|
}
|
|
158
160
|
try {
|
|
159
161
|
const body = new URLSearchParams({
|
|
@@ -167,21 +169,22 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
167
169
|
const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {
|
|
168
170
|
method: "POST",
|
|
169
171
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
170
|
-
body: body.toString()
|
|
172
|
+
body: body.toString(),
|
|
173
|
+
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
|
|
171
174
|
});
|
|
172
175
|
if (!res.ok) {
|
|
173
176
|
if (res.status !== 401 && res.status !== 403) {
|
|
174
|
-
return
|
|
177
|
+
return "unreachable";
|
|
175
178
|
}
|
|
176
179
|
introspectCacheSet(cacheKey, false);
|
|
177
|
-
return
|
|
180
|
+
return "inactive";
|
|
178
181
|
}
|
|
179
182
|
const data = await res.json();
|
|
180
183
|
const active = data.active === true;
|
|
181
184
|
introspectCacheSet(cacheKey, active);
|
|
182
|
-
return active;
|
|
185
|
+
return active ? "active" : "inactive";
|
|
183
186
|
} catch {
|
|
184
|
-
return
|
|
187
|
+
return "unreachable";
|
|
185
188
|
}
|
|
186
189
|
}
|
|
187
190
|
function createSsoMiddleware(config) {
|
|
@@ -231,13 +234,13 @@ function createSsoMiddleware(config) {
|
|
|
231
234
|
const ssoSession = request.cookies.get(ssoCookieName);
|
|
232
235
|
if (ssoSession?.value) {
|
|
233
236
|
if (validateSsoCookie) {
|
|
234
|
-
const
|
|
237
|
+
const introspectResult = await introspectAccessToken(
|
|
235
238
|
ssoSession.value,
|
|
236
239
|
normalizedServerBase,
|
|
237
240
|
clientId,
|
|
238
241
|
clientSecret
|
|
239
242
|
);
|
|
240
|
-
if (
|
|
243
|
+
if (introspectResult === "active") {
|
|
241
244
|
return import_server.NextResponse.next();
|
|
242
245
|
}
|
|
243
246
|
} else {
|
|
@@ -246,13 +249,19 @@ function createSsoMiddleware(config) {
|
|
|
246
249
|
}
|
|
247
250
|
const accessTokenCookie = request.cookies.get(accessTokenCookieName);
|
|
248
251
|
if (accessTokenCookie?.value) {
|
|
249
|
-
const
|
|
252
|
+
const introspectResult = await introspectAccessToken(
|
|
250
253
|
accessTokenCookie.value,
|
|
251
254
|
normalizedServerBase,
|
|
252
255
|
clientId,
|
|
253
256
|
clientSecret
|
|
254
257
|
);
|
|
255
|
-
if (
|
|
258
|
+
if (introspectResult === "active") {
|
|
259
|
+
return import_server.NextResponse.next();
|
|
260
|
+
}
|
|
261
|
+
if (introspectResult === "unreachable") {
|
|
262
|
+
console.warn(
|
|
263
|
+
"[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"
|
|
264
|
+
);
|
|
256
265
|
return import_server.NextResponse.next();
|
|
257
266
|
}
|
|
258
267
|
}
|
|
@@ -790,6 +799,9 @@ function buildLogoutConfirmHtml() {
|
|
|
790
799
|
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;">
|
|
791
800
|
<form method="post" style="text-align:center;">
|
|
792
801
|
<p>\u786E\u5B9A\u8981\u9000\u51FA\u767B\u5F55\u5417\uFF1F</p>
|
|
802
|
+
<label style="display:flex;align-items:center;justify-content:center;gap:6px;margin:12px 0;font-size:14px;color:#374151;cursor:pointer;">
|
|
803
|
+
<input type="checkbox" name="global" value="1">\u540C\u65F6\u9000\u51FA\u6240\u6709 NIHPLOD \u5E73\u53F0
|
|
804
|
+
</label>
|
|
793
805
|
<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>
|
|
794
806
|
<p><a href="/" style="color:#2563eb;text-decoration:underline;">\u53D6\u6D88\u5E76\u8FD4\u56DE\u9996\u9875</a></p>
|
|
795
807
|
</form>
|
|
@@ -817,10 +829,18 @@ function createLogoutRouteHandler(config) {
|
|
|
817
829
|
redirectUri,
|
|
818
830
|
clientSecret,
|
|
819
831
|
postLogoutRedirectUri = new URL(redirectUri).origin + "/",
|
|
820
|
-
redirectToSso = true,
|
|
821
832
|
callbackPath = "/api/auth/callback",
|
|
822
833
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
823
834
|
} = config;
|
|
835
|
+
let defaultScope = config.defaultScope ?? "local";
|
|
836
|
+
if (config.redirectToSso !== void 0) {
|
|
837
|
+
console.warn(
|
|
838
|
+
'[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'
|
|
839
|
+
);
|
|
840
|
+
if (config.defaultScope === void 0) {
|
|
841
|
+
defaultScope = config.redirectToSso ? "global" : "local";
|
|
842
|
+
}
|
|
843
|
+
}
|
|
824
844
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
825
845
|
const secureCookies = !insecureLocalDev;
|
|
826
846
|
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
@@ -855,6 +875,12 @@ function createLogoutRouteHandler(config) {
|
|
|
855
875
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
856
876
|
});
|
|
857
877
|
}
|
|
878
|
+
let formGlobal = null;
|
|
879
|
+
try {
|
|
880
|
+
formGlobal = new URLSearchParams(await request.text()).get("global");
|
|
881
|
+
} catch {
|
|
882
|
+
}
|
|
883
|
+
const effectiveScope = formGlobal !== null ? formGlobal === "1" || formGlobal === "true" ? "global" : "local" : defaultScope;
|
|
858
884
|
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
859
885
|
const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
|
|
860
886
|
if (refreshToken) {
|
|
@@ -889,7 +915,7 @@ function createLogoutRouteHandler(config) {
|
|
|
889
915
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
890
916
|
return res;
|
|
891
917
|
};
|
|
892
|
-
if (
|
|
918
|
+
if (effectiveScope === "global") {
|
|
893
919
|
const discovery = await fetchDiscoveryCached(normalizedServerBase);
|
|
894
920
|
const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
|
|
895
921
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
@@ -912,6 +938,198 @@ function createLogoutRouteHandler(config) {
|
|
|
912
938
|
);
|
|
913
939
|
};
|
|
914
940
|
}
|
|
941
|
+
|
|
942
|
+
// src/next/backchannel-logout.ts
|
|
943
|
+
var import_server4 = require("next/server");
|
|
944
|
+
|
|
945
|
+
// src/core/logout-token.ts
|
|
946
|
+
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
947
|
+
var JTI_CACHE_CAPACITY = 1e3;
|
|
948
|
+
var seenJti = /* @__PURE__ */ new Map();
|
|
949
|
+
function recordJti(jti, expiresAtMs) {
|
|
950
|
+
const now = Date.now();
|
|
951
|
+
const existing = seenJti.get(jti);
|
|
952
|
+
if (existing !== void 0 && existing > now) return false;
|
|
953
|
+
for (const [key, exp] of seenJti) {
|
|
954
|
+
if (exp <= now) seenJti.delete(key);
|
|
955
|
+
}
|
|
956
|
+
while (seenJti.size >= JTI_CACHE_CAPACITY) {
|
|
957
|
+
const oldest = seenJti.keys().next().value;
|
|
958
|
+
if (oldest === void 0) break;
|
|
959
|
+
seenJti.delete(oldest);
|
|
960
|
+
}
|
|
961
|
+
seenJti.set(jti, expiresAtMs);
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
965
|
+
const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
|
|
966
|
+
const header = decodeJwtHeader(logoutToken);
|
|
967
|
+
if (!header) {
|
|
968
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u683C\u5F0F\u9519\u8BEF");
|
|
969
|
+
}
|
|
970
|
+
if (header.alg !== "RS256") {
|
|
971
|
+
throw new SsoError(
|
|
972
|
+
"logout_token_unsupported_alg",
|
|
973
|
+
`\u4E0D\u652F\u6301\u7684 Logout Token \u7B7E\u540D\u7B97\u6CD5: ${String(header.alg)}`
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
977
|
+
const normalizedIssuer = (discovery?.issuer || baseUrl).replace(/\/+$/, "");
|
|
978
|
+
const jwks = await fetchJwks(baseUrl);
|
|
979
|
+
if (!jwks) {
|
|
980
|
+
throw new SsoError(
|
|
981
|
+
"logout_token_invalid_signature",
|
|
982
|
+
"\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 Logout Token \u7B7E\u540D"
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
986
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
987
|
+
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
988
|
+
);
|
|
989
|
+
const verifyAny = async (keys) => {
|
|
990
|
+
for (const key of keys) {
|
|
991
|
+
if (await verifyRs256Signature(logoutToken, key)) return true;
|
|
992
|
+
}
|
|
993
|
+
return false;
|
|
994
|
+
};
|
|
995
|
+
let candidates = matchCandidates(jwks);
|
|
996
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
997
|
+
if (candidates.length === 0 || !validSig) {
|
|
998
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
999
|
+
if (freshJwks) {
|
|
1000
|
+
candidates = matchCandidates(freshJwks);
|
|
1001
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (candidates.length === 0) {
|
|
1005
|
+
throw new SsoError(
|
|
1006
|
+
"logout_token_invalid_signature",
|
|
1007
|
+
"JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5"
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
if (!validSig) {
|
|
1011
|
+
throw new SsoError(
|
|
1012
|
+
"logout_token_invalid_signature",
|
|
1013
|
+
"Logout Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25"
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
const payload = decodeJwtPayload(logoutToken);
|
|
1017
|
+
if (!payload) {
|
|
1018
|
+
throw new SsoError("logout_token_invalid", "Logout Token payload \u89E3\u6790\u5931\u8D25");
|
|
1019
|
+
}
|
|
1020
|
+
const tokenIssuer = typeof payload.iss === "string" ? payload.iss.replace(/\/+$/, "") : "";
|
|
1021
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
1022
|
+
throw new SsoError(
|
|
1023
|
+
"logout_token_issuer_mismatch",
|
|
1024
|
+
"Logout Token issuer \u4E0D\u5339\u914D"
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
const aud = payload.aud;
|
|
1028
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
1029
|
+
if (!audList.includes(clientId)) {
|
|
1030
|
+
throw new SsoError(
|
|
1031
|
+
"logout_token_audience_mismatch",
|
|
1032
|
+
"Logout Token audience \u4E0D\u5339\u914D"
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
if (typeof payload.exp !== "number") {
|
|
1036
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
1037
|
+
}
|
|
1038
|
+
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
1039
|
+
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
1040
|
+
}
|
|
1041
|
+
const events = payload.events;
|
|
1042
|
+
if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
|
|
1043
|
+
throw new SsoError(
|
|
1044
|
+
"logout_token_invalid",
|
|
1045
|
+
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : void 0;
|
|
1049
|
+
const sid = typeof payload.sid === "string" && payload.sid ? payload.sid : void 0;
|
|
1050
|
+
if (!sub && !sid) {
|
|
1051
|
+
throw new SsoError(
|
|
1052
|
+
"logout_token_invalid",
|
|
1053
|
+
"Logout Token \u5FC5\u987B\u5305\u542B sub \u6216 sid \u81F3\u5C11\u5176\u4E00"
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
const jti = typeof payload.jti === "string" ? payload.jti : "";
|
|
1057
|
+
if (!jti) {
|
|
1058
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
|
|
1059
|
+
}
|
|
1060
|
+
if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
|
|
1061
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1062
|
+
}
|
|
1063
|
+
return { sub, sid };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// src/next/backchannel-logout.ts
|
|
1067
|
+
function createBackchannelLogoutRouteHandler(config) {
|
|
1068
|
+
const { clientId, ssoBaseUrl, onLogout } = config;
|
|
1069
|
+
const insecureLocalDev = resolveInsecureLocalDev(
|
|
1070
|
+
config.insecureLocalDev ?? false,
|
|
1071
|
+
ssoBaseUrl
|
|
1072
|
+
);
|
|
1073
|
+
const secureCookies = !insecureLocalDev;
|
|
1074
|
+
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
1075
|
+
const accessTokenCookieName = pickName(
|
|
1076
|
+
config.accessTokenCookieName,
|
|
1077
|
+
DEFAULT_ACCESS_TOKEN_COOKIE_NAME
|
|
1078
|
+
);
|
|
1079
|
+
const refreshTokenCookieName = pickName(
|
|
1080
|
+
config.refreshTokenCookieName,
|
|
1081
|
+
DEFAULT_REFRESH_TOKEN_COOKIE_NAME
|
|
1082
|
+
);
|
|
1083
|
+
const idTokenCookieName = pickName(
|
|
1084
|
+
config.idTokenCookieName,
|
|
1085
|
+
DEFAULT_ID_TOKEN_COOKIE_NAME
|
|
1086
|
+
);
|
|
1087
|
+
return async function handler(request) {
|
|
1088
|
+
if (request.method !== "POST") {
|
|
1089
|
+
return import_server4.NextResponse.json(
|
|
1090
|
+
{ error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
|
|
1091
|
+
{ status: 405 }
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
const logoutToken = new URLSearchParams(await request.text()).get(
|
|
1095
|
+
"logout_token"
|
|
1096
|
+
);
|
|
1097
|
+
if (!logoutToken) {
|
|
1098
|
+
return import_server4.NextResponse.json(
|
|
1099
|
+
{ error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
|
|
1100
|
+
{ status: 400 }
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
let payload;
|
|
1104
|
+
try {
|
|
1105
|
+
payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1108
|
+
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
1109
|
+
return import_server4.NextResponse.json(
|
|
1110
|
+
{ error: code, error_description: description },
|
|
1111
|
+
{ status: 400 }
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
try {
|
|
1115
|
+
await onLogout?.(payload, request);
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
console.error(
|
|
1118
|
+
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1119
|
+
err
|
|
1120
|
+
);
|
|
1121
|
+
return import_server4.NextResponse.json(
|
|
1122
|
+
{ error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
|
|
1123
|
+
{ status: 500 }
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
const res = new import_server4.NextResponse(null, { status: 200 });
|
|
1127
|
+
res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1128
|
+
res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1129
|
+
res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1130
|
+
return res;
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
915
1133
|
// Annotate the CommonJS export names for ESM import in node:
|
|
916
1134
|
0 && (module.exports = {
|
|
917
1135
|
DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
|
|
@@ -922,6 +1140,7 @@ function createLogoutRouteHandler(config) {
|
|
|
922
1140
|
DEFAULT_RETURN_COOKIE_NAME,
|
|
923
1141
|
DEFAULT_STATE_COOKIE_NAME,
|
|
924
1142
|
DEFAULT_VERIFIER_COOKIE_NAME,
|
|
1143
|
+
createBackchannelLogoutRouteHandler,
|
|
925
1144
|
createCallbackRouteHandler,
|
|
926
1145
|
createLogoutRouteHandler,
|
|
927
1146
|
createSsoMiddleware,
|