@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.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/next/index.ts","../../src/next/middleware.ts","../../src/next/constants.ts","../../src/next/callback.ts","../../src/core/errors.ts","../../src/core/security.ts","../../src/core/id-token.ts","../../src/next/logout.ts","../../src/core/discovery.ts"],"sourcesContent":["/**\n * @nihplod/sso-sdk/next\n *\n * Next.js App Router 绑定:\n * - createSsoMiddleware: Next.js Middleware 辅助函数\n * - createCallbackRouteHandler: App Router 回调 Route Handler\n * - createLogoutRouteHandler: App Router 登出 Route Handler\n */\n\nexport { createSsoMiddleware } from \"./middleware\";\nexport type { SsoMiddlewareConfig } from \"./middleware\";\n\nexport { createCallbackRouteHandler } from \"./callback\";\nexport type { CallbackRouteConfig } from \"./callback\";\n\nexport { createLogoutRouteHandler } from \"./logout\";\nexport type { LogoutRouteConfig } from \"./logout\";\n\nexport {\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\n DEFAULT_ID_TOKEN_COOKIE_NAME,\n DEFAULT_STATE_COOKIE_NAME,\n DEFAULT_NONCE_COOKIE_NAME,\n DEFAULT_RETURN_COOKIE_NAME,\n DEFAULT_VERIFIER_COOKIE_NAME,\n DEFAULT_LOGOUT_STATE_COOKIE_NAME,\n getHostCookieOptions,\n getSecureCookieOptions,\n toInsecureCookieName,\n} from \"./constants\";\n","/**\r\n * Next.js Middleware 辅助函数\r\n *\r\n * 在 Edge Runtime 中运行,自动检测未认证请求并重定向到 SSO 登录页。\r\n *\r\n * ⚠️ Edge Runtime 限制:\r\n * - 不能使用 Node.js crypto 模块\r\n * - PKCE code_challenge 使用 Web Crypto API 的 crypto.subtle.digest(SHA-256)\r\n * (Edge Runtime 18+ 完全支持此 API)\r\n *\r\n * 用法 (src/middleware.ts):\r\n * ```ts\r\n * import { createSsoMiddleware } from \"@nihplod/sso-sdk/next\";\r\n *\r\n * export const middleware = createSsoMiddleware({\r\n * clientId: \"my-app\",\r\n * ssoBaseUrl: \"https://nihplod.cn\",\r\n * redirectUri: \"https://myapp.com/api/auth/callback\",\r\n * publicPaths: [\"/\", \"/public\", \"/api/public\"],\r\n * });\r\n * ```\r\n */\r\n\r\nimport { NextRequest, NextResponse } from \"next/server\";\r\nimport {\r\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\r\n DEFAULT_STATE_COOKIE_NAME,\r\n DEFAULT_NONCE_COOKIE_NAME,\r\n DEFAULT_RETURN_COOKIE_NAME,\r\n DEFAULT_VERIFIER_COOKIE_NAME,\r\n getHostCookieOptions,\r\n getSecureCookieOptions,\r\n resolveInsecureLocalDev,\r\n toInsecureCookieName,\r\n} from \"./constants\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface SsoMiddlewareConfig {\r\n /** OAuth Client ID */\r\n clientId: string;\r\n\r\n /** SSO 中心地址 */\r\n ssoBaseUrl: string;\r\n\r\n /** 回调 URL(须与注册的 redirect_uri 一致) */\r\n redirectUri: string;\r\n\r\n /** 请求的 scope(空格分隔),默认 \"openid profile\" */\r\n scopes?: string;\r\n\r\n /** 公开路由前缀(不需要认证) */\r\n publicPaths?: string[];\r\n\r\n /** 回调路径(不触发重定向),默认 \"/api/auth/callback\" */\r\n callbackPath?: string;\r\n\r\n /**\r\n * 主站用户会话 Cookie 名称,用于检测是否已有 SSO 会话。\r\n * 默认 \"__Host-user_token\"(与主站 C 端登录 Cookie 一致)。\r\n */\r\n ssoCookieName?: string;\r\n\r\n /**\r\n * OAuth Client Secret(Confidential Client 使用)\r\n * 浏览器端 Public Client 应省略。\r\n */\r\n clientSecret?: string;\r\n\r\n /**\r\n * 是否对 ssoCookieName 对应的主站 Cookie 进行 Introspection 二次验证。\r\n * 默认 true(推荐)。设为 false 时仅检查 Cookie 存在性,延迟最低但可能放行\r\n * 已失效/被撤销的会话 —— 中间件本质上只是 UX 层,敏感数据必须在\r\n * Route Handler / Server Component 中二次校验。\r\n */\r\n validateSsoCookie?: boolean;\r\n\r\n /** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */\r\n accessTokenCookieName?: string;\r\n\r\n /** State Cookie 名称,默认 __Host-nihplod_sso_state */\r\n stateCookieName?: string;\r\n\r\n /** OIDC Nonce Cookie 名称,默认 __Host-nihplod_sso_nonce */\r\n nonceCookieName?: string;\r\n\r\n /** Return URL Cookie 名称,默认 __Host-nihplod_sso_return */\r\n returnUrlCookieName?: string;\r\n\r\n /** PKCE Verifier Cookie 名称,默认 __Secure-nihplod_sso_verifier */\r\n verifierCookieName?: string;\r\n\r\n /**\r\n * 本地 HTTP 开发模式(默认 false)。\r\n *\r\n * ⚠️ 仅限 http://localhost 开发:关闭 Cookie 的 Secure 属性并去除\r\n * __Host-/__Secure- 前缀(浏览器拒绝在 HTTP 下写入带这两个前缀的 Cookie,\r\n * 否则会出现「登录后 cookie 写不进去 → middleware 永远判定未登录 →\r\n * 反复跳 SSO」的无限重定向)。开启时启动告警;生产环境严禁启用——\r\n * 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下该开关会被\r\n * 强制忽略并告警,仍走 secure cookie。\r\n * middleware / callback / logout 三处配置需保持一致。\r\n */\r\n insecureLocalDev?: boolean;\r\n\r\n /**\r\n * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。\r\n * 仅用于 introspect 等服务器间请求;浏览器跳转(authorize)仍使用\r\n * ssoBaseUrl 公网地址。适用于子站与 SSO 中心同机/同内网部署。\r\n */\r\n serverBaseUrl?: string;\r\n}\r\n\r\n// ============================================\r\n// 工具函数\r\n// ============================================\r\n\r\n/**\r\n * 生成安全随机字符串(Edge Runtime 兼容)\r\n * 使用 crypto.getRandomValues(Edge Runtime 支持)\r\n * 拒绝采样法保证 charset 均匀分布\r\n */\r\nfunction generateRandomString(length: number): string {\r\n const chars =\r\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~\";\r\n const maxValid = Math.floor(256 / chars.length) * chars.length;\r\n let result = \"\";\r\n\r\n while (result.length < length) {\r\n const array = new Uint8Array(length * 2);\r\n crypto.getRandomValues(array);\r\n for (let i = 0; i < array.length && result.length < length; i++) {\r\n if (array[i] >= maxValid) continue;\r\n result += chars[array[i] % chars.length];\r\n }\r\n }\r\n return result;\r\n}\r\n\r\n/**\r\n * 计算 PKCE S256 code_challenge(Edge Runtime 兼容)\r\n *\r\n * 使用 Web Crypto API 的 crypto.subtle.digest(\"SHA-256\"),\r\n * Edge Runtime 18+ 完全支持。\r\n */\r\nasync function computeCodeChallenge(verifier: string): Promise<string> {\r\n const encoder = new TextEncoder();\r\n const data = encoder.encode(verifier);\r\n const hash = await crypto.subtle.digest(\"SHA-256\", data);\r\n const bytes = new Uint8Array(hash);\r\n let binary = \"\";\r\n for (let i = 0; i < bytes.byteLength; i++) {\r\n binary += String.fromCharCode(bytes[i]);\r\n }\r\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\r\n}\r\n\r\n/**\r\n * 路径匹配(支持 /:path* 通配符)\r\n */\r\nfunction matchesPath(pathname: string, paths: string[]): boolean {\r\n return paths.some((path) => {\r\n if (pathname === path) return true;\r\n if (path.endsWith(\"/:path*\") && pathname.startsWith(path.replace(\"/:path*\", \"\"))) return true;\r\n if (pathname.startsWith(path + \"/\")) return true;\r\n return false;\r\n });\r\n}\r\n\r\n/**\r\n * 通过主站 Introspection 端点校验 access_token 是否仍有效。\r\n * Confidential Client(BFF)应配置 clientSecret,防止伪造 Cookie 绕过。\r\n *\r\n * 附带进程级缓存(key = token 的 SHA-256 hex + clientId),\r\n * 减少同一用户 session 在短时间内对 SSO 中心的重复 introspection 调用。\r\n * 缓存带 TTL(30s)与容量上限(LRU 淘汰),避免无界增长。\r\n */\r\nconst introspectionCache = new Map<string, { active: boolean; until: number }>();\r\nconst INTROSPECT_CACHE_TTL_MS = 30_000; // 30 秒\r\nconst INTROSPECT_CACHE_MAX_ENTRIES = 500;\r\n\r\n/** 计算 token 的 SHA-256 hex(Edge Runtime Web Crypto),用作缓存 key 避免跨用户碰撞 */\r\nasync function introspectCacheKey(token: string, clientId: string): Promise<string> {\r\n const hash = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(token));\r\n const hex = Array.from(new Uint8Array(hash))\r\n .map((b) => b.toString(16).padStart(2, \"0\"))\r\n .join(\"\");\r\n return `${hex}|${clientId}`;\r\n}\r\n\r\n/** 读取缓存(命中时刷新插入顺序,实现 LRU) */\r\nfunction introspectCacheGet(key: string): { active: boolean; until: number } | null {\r\n const entry = introspectionCache.get(key);\r\n if (!entry) return null;\r\n if (entry.until <= Date.now()) {\r\n introspectionCache.delete(key);\r\n return null;\r\n }\r\n // LRU:重新插入到 Map 尾部\r\n introspectionCache.delete(key);\r\n introspectionCache.set(key, entry);\r\n return entry;\r\n}\r\n\r\n/** 写入缓存(先清过期项,再按插入顺序淘汰最旧项) */\r\nfunction introspectCacheSet(key: string, active: boolean): void {\r\n const now = Date.now();\r\n for (const [k, v] of introspectionCache) {\r\n if (v.until <= now) introspectionCache.delete(k);\r\n }\r\n while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {\r\n const oldest = introspectionCache.keys().next();\r\n if (oldest.done) break;\r\n introspectionCache.delete(oldest.value);\r\n }\r\n introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });\r\n}\r\n\r\nasync function introspectAccessToken(\r\n token: string,\r\n ssoBaseUrl: string,\r\n clientId: string,\r\n clientSecret?: string\r\n): Promise<boolean> {\r\n const cacheKey = await introspectCacheKey(token, clientId);\r\n const cached = introspectCacheGet(cacheKey);\r\n if (cached) {\r\n return cached.active;\r\n }\r\n\r\n try {\r\n const body = new URLSearchParams({\r\n token,\r\n token_type_hint: \"access_token\",\r\n client_id: clientId,\r\n });\r\n if (clientSecret) {\r\n body.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: body.toString(),\r\n });\r\n\r\n if (!res.ok) {\r\n // 仅缓存确定性的\"token 无效\"结果;网络错误不缓存,下次重试\r\n if (res.status !== 401 && res.status !== 403) {\r\n // 非认证错误(如 500、网络问题):不缓存,让下次请求重试\r\n return false;\r\n }\r\n introspectCacheSet(cacheKey, false);\r\n return false;\r\n }\r\n const data = (await res.json()) as { active?: boolean };\r\n const active = data.active === true;\r\n introspectCacheSet(cacheKey, active);\r\n return active;\r\n } catch {\r\n // 网络异常不缓存:下次请求会重试 introspection\r\n return false;\r\n }\r\n}\r\n\r\n// ============================================\r\n// Middleware 工厂函数\r\n// ============================================\r\n\r\nexport function createSsoMiddleware(config: SsoMiddlewareConfig) {\r\n const {\r\n clientId,\r\n clientSecret,\r\n ssoBaseUrl,\r\n redirectUri,\r\n scopes = \"openid profile\",\r\n publicPaths = [],\r\n callbackPath = \"/api/auth/callback\",\r\n ssoCookieName = \"__Host-user_token\",\r\n validateSsoCookie = true,\r\n insecureLocalDev: insecureLocalDevOpt = false,\r\n } = config;\r\n\r\n // 生产守卫:NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关(仍走 secure cookie)\r\n const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);\r\n\r\n // insecureLocalDev:HTTP 本地开发下去除 Cookie 前缀(浏览器拒绝无 Secure 的前缀 Cookie)\r\n const secureCookies = !insecureLocalDev;\r\n const accessTokenCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME)\r\n : config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME;\r\n const stateCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME)\r\n : config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME;\r\n const nonceCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.nonceCookieName ?? DEFAULT_NONCE_COOKIE_NAME)\r\n : config.nonceCookieName ?? DEFAULT_NONCE_COOKIE_NAME;\r\n const returnUrlCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME)\r\n : config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME;\r\n const verifierCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME)\r\n : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;\r\n\r\n // 规范化 ssoBaseUrl\r\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n // 服务器间调用(introspect)的基准地址:配置了内网地址时走内网;\r\n // authorize 等浏览器跳转仍用 normalizedBase(公网)\r\n const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\\/+$/, \"\");\r\n\r\n // 弱配置告警不区分环境:生产环境风险最高,同样输出(不应静默)\r\n if (!validateSsoCookie) {\r\n console.warn(\r\n \"[SSO SDK] validateSsoCookie=false:中间件仅检查 Cookie 存在性,可能放行已失效的会话。\" +\r\n \"中间件只是 UX 层,敏感数据的鉴权必须在 Route Handler / Server Component 中完成。\"\r\n );\r\n }\r\n if (!clientSecret) {\r\n console.warn(\r\n \"[SSO SDK] 未配置 clientSecret(Public Client 模式):introspection 无客户端认证,\" +\r\n \"中间件判定结果仅作 UX 参考。Confidential Client(BFF)请配置 clientSecret。\"\r\n );\r\n }\r\n\r\n return async function ssoMiddleware(request: NextRequest) {\r\n const { pathname } = request.nextUrl;\r\n\r\n // Exclude Next.js internal routes and static assets\r\n if (\r\n pathname.startsWith(\"/_next/\") ||\r\n pathname.startsWith(\"/favicon.ico\") ||\r\n pathname.match(/\\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)\r\n ) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Callback path: let the callback handler deal with it\r\n if (pathname === callbackPath) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Public paths: no auth required\r\n const allPublicPaths = [callbackPath, ...publicPaths];\r\n if (matchesPath(pathname, allPublicPaths)) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Check for existing SSO session\r\n const ssoSession = request.cookies.get(ssoCookieName);\r\n if (ssoSession?.value) {\r\n // 可选:对主站 Cookie 进行 Introspection 二次验证\r\n if (validateSsoCookie) {\r\n const tokenActive = await introspectAccessToken(\r\n ssoSession.value,\r\n normalizedServerBase,\r\n clientId,\r\n clientSecret\r\n );\r\n if (tokenActive) {\r\n return NextResponse.next();\r\n }\r\n // Token 无效:继续到 access_token cookie 检查或重定向\r\n } else {\r\n return NextResponse.next();\r\n }\r\n }\r\n\r\n // Check for access_token in cookie (set by callback handler)\r\n const accessTokenCookie = request.cookies.get(accessTokenCookieName);\r\n if (accessTokenCookie?.value) {\r\n // 调用 Introspection 精确校验 token 是否仍有效。\r\n // Confidential Client 携带 clientSecret;Public Client 仅传 clientId。\r\n const tokenActive = await introspectAccessToken(\r\n accessTokenCookie.value,\r\n normalizedServerBase,\r\n clientId,\r\n clientSecret\r\n );\r\n\r\n if (tokenActive) {\r\n return NextResponse.next();\r\n }\r\n // Token 无效或已过期:清除 cookie 并继续到 SSO 重定向\r\n }\r\n\r\n // No auth: redirect to SSO\r\n const state = generateRandomString(32);\r\n // OIDC nonce(43 字符 ≈ 260 bit 熵,满足 256-bit 要求):\r\n // 绑定 ID Token 到本次登录会话,回调时 fail-closed 校验,防 ID Token 重放\r\n const nonce = generateRandomString(43);\r\n const verifier = generateRandomString(64);\r\n\r\n // 在 Edge Runtime 中计算 PKCE code_challenge(SHA-256 + base64url)\r\n const challenge = await computeCodeChallenge(verifier);\r\n\r\n const authorizeParams = new URLSearchParams();\r\n authorizeParams.set(\"response_type\", \"code\");\r\n authorizeParams.set(\"client_id\", clientId);\r\n authorizeParams.set(\"redirect_uri\", redirectUri);\r\n authorizeParams.set(\"scope\", scopes);\r\n authorizeParams.set(\"state\", state);\r\n authorizeParams.set(\"nonce\", nonce);\r\n authorizeParams.set(\"code_challenge\", challenge);\r\n authorizeParams.set(\"code_challenge_method\", \"S256\");\r\n\r\n const loginUrl = new URL(\"/api/oauth/authorize\", normalizedBase);\r\n loginUrl.search = authorizeParams.toString();\r\n\r\n const response = NextResponse.redirect(loginUrl);\r\n\r\n // Set state cookie for CSRF verification on callback\r\n response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));\r\n\r\n // Set nonce cookie(与 state 同规格的 __Host- httpOnly cookie,供 callback 校验 ID Token)\r\n response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));\r\n\r\n // Set PKCE verifier cookie(httpOnly,供 callback handler 使用)\r\n // 使用 __Secure- 前缀,允许写入 callbackPath\r\n response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));\r\n\r\n // Set return URL cookie(仅存储 pathname + search,截断至安全长度)\r\n const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);\r\n response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));\r\n\r\n // 如果存在过期的 access_token cookie,立即清除\r\n if (accessTokenCookie?.value) {\r\n response.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n }\r\n\r\n return response;\r\n };\r\n}\r\n","/**\n * Next.js 集成默认 Cookie 名称\n *\n * 除 verifier 外统一使用 __Host- 前缀,要求:\n * - Secure\n * - Path=/\n * - 无 Domain 属性\n *\n * verifier 需要写入回调路径(非 /),因此使用 __Secure- 前缀。\n */\n\nexport const DEFAULT_ACCESS_TOKEN_COOKIE_NAME = \"__Host-nihplod_sso_at\";\nexport const DEFAULT_REFRESH_TOKEN_COOKIE_NAME = \"__Host-nihplod_sso_rt\";\nexport const DEFAULT_ID_TOKEN_COOKIE_NAME = \"__Host-nihplod_sso_id\";\nexport const DEFAULT_STATE_COOKIE_NAME = \"__Host-nihplod_sso_state\";\n// OIDC nonce cookie(ID Token 重放防护,与 state 同规格的 __Host- httpOnly cookie)\nexport const DEFAULT_NONCE_COOKIE_NAME = \"__Host-nihplod_sso_nonce\";\nexport const DEFAULT_RETURN_COOKIE_NAME = \"__Host-nihplod_sso_return\";\n// Verifier 需要写入回调路径(非 /),因此使用 __Secure- 前缀而非 __Host-\nexport const DEFAULT_VERIFIER_COOKIE_NAME = \"__Secure-nihplod_sso_verifier\";\n// RP-Initiated Logout 的 state cookie(登出 CSRF 防护)\nexport const DEFAULT_LOGOUT_STATE_COOKIE_NAME = \"__Host-nihplod_sso_logout_state\";\n\n/**\n * insecureLocalDev 场景:去除 __Host-/__Secure- 前缀。\n * 浏览器强制要求带这两个前缀的 Cookie 必须设置 Secure,\n * HTTP 本地开发时若保留前缀,即使 secure=false 也会被拒绝写入。\n */\nexport function toInsecureCookieName(name: string): string {\n return name.replace(/^__(Host|Secure)-/, \"\");\n}\n\n/**\n * 解析 insecureLocalDev 开关并输出告警(middleware / callback / logout 三处共用,保证行为一致)。\n *\n * 生产守卫:当 NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关,\n * Cookie 仍使用 Secure 属性并保留 __Host-/__Secure- 前缀,\n * 避免误配置把生产环境的 Cookie 降级为 HTTP 可写。\n *\n * 返回值为实际生效的 insecureLocalDev 状态。\n */\nexport function resolveInsecureLocalDev(enabled: boolean, ssoBaseUrl: string): boolean {\n if (!enabled) return false;\n if (process.env.NODE_ENV === \"production\" && /^https:\\/\\//i.test(ssoBaseUrl)) {\n console.warn(\n \"[SSO SDK] insecureLocalDev=true 已被忽略:当前为生产环境(NODE_ENV=production)且 ssoBaseUrl 使用 HTTPS,\" +\n \"Cookie 仍保持 Secure 属性与 __Host-/__Secure- 前缀。请从生产配置中移除该开关。\"\n );\n return false;\n }\n console.warn(\n \"[SSO SDK] insecureLocalDev=true:Cookie 的 Secure 属性已关闭且去除 __Host-/__Secure- 前缀。\" +\n \"仅限 http://localhost 本地开发使用,生产环境必须移除该配置(生产必须用 HTTPS)。\"\n );\n return true;\n}\n\n/** __Host- 前缀 Cookie 的安全选项(Path 必须为 /) */\nexport function getHostCookieOptions(\n maxAge?: number,\n secure: boolean = true\n): {\n httpOnly: true;\n secure: boolean;\n sameSite: \"lax\";\n path: \"/\";\n maxAge?: number;\n} {\n return {\n httpOnly: true,\n secure,\n sameSite: \"lax\",\n path: \"/\",\n ...(maxAge !== undefined ? { maxAge } : {}),\n };\n}\n\n/** __Secure- 前缀 Cookie 的安全选项(允许自定义 Path) */\nexport function getSecureCookieOptions(\n maxAge?: number,\n path = \"/\",\n secure: boolean = true\n): {\n httpOnly: true;\n secure: boolean;\n sameSite: \"lax\";\n path: string;\n maxAge?: number;\n} {\n return {\n httpOnly: true,\n secure,\n sameSite: \"lax\",\n path,\n ...(maxAge !== undefined ? { maxAge } : {}),\n };\n}\n","/**\r\n * App Router 回调 Route Handler\r\n *\r\n * 在 /api/auth/callback 路由中处理 OAuth 回调:\r\n * 1. 验证 state 参数\r\n * 2. 用授权码交换 token\r\n * 3. 设置 session cookie\r\n * 4. 重定向到原始页面\r\n *\r\n * ⚠️ 此 handler 仅运行在 Node.js Runtime(非 Edge),可以使用 crypto.subtle。\r\n *\r\n * 用法 (src/app/api/auth/callback/route.ts):\r\n * ```ts\r\n * import { createCallbackRouteHandler } from \"@nihplod/sso-sdk/next\";\r\n *\r\n * export const GET = createCallbackRouteHandler({\r\n * clientId: \"my-app\",\r\n * ssoBaseUrl: \"https://nihplod.cn\",\r\n * redirectUri: \"https://myapp.com/api/auth/callback\",\r\n * tokenCookieName: \"nihplod_sso_at\",\r\n * });\r\n * ```\r\n */\r\n\r\nimport { NextRequest, NextResponse } from \"next/server\";\r\nimport { validateIdToken } from \"../core/id-token\";\r\nimport { isTrustedReturnUrl, timingSafeEqualString } from \"../core/security\";\r\nimport {\r\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\r\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\r\n DEFAULT_ID_TOKEN_COOKIE_NAME,\r\n DEFAULT_STATE_COOKIE_NAME,\r\n DEFAULT_NONCE_COOKIE_NAME,\r\n DEFAULT_RETURN_COOKIE_NAME,\r\n DEFAULT_VERIFIER_COOKIE_NAME,\r\n getHostCookieOptions,\r\n getSecureCookieOptions,\r\n resolveInsecureLocalDev,\r\n toInsecureCookieName,\r\n} from \"./constants\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface CallbackRouteConfig {\r\n /** OAuth Client ID */\r\n clientId: string;\r\n\r\n /** SSO 中心地址 */\r\n ssoBaseUrl: string;\r\n\r\n /** 回调 URL(须与注册的 redirect_uri 一致) */\r\n redirectUri: string;\r\n\r\n /**\r\n * OAuth Client Secret(可选)。\r\n * 对于 Confidential Client(BFF/Next.js),应传入 clientSecret\r\n * 以提供第二因素认证。对于 Public Client(SPA),应省略此字段。\r\n */\r\n clientSecret?: string;\r\n\r\n /** 成功回调后重定向的默认路径,默认 \"/\" */\r\n defaultReturnPath?: string;\r\n\r\n /** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */\r\n accessTokenCookieName?: string;\r\n\r\n /** Refresh Token Cookie 名称,默认 __Host-nihplod_sso_rt */\r\n refreshTokenCookieName?: string;\r\n\r\n /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */\r\n idTokenCookieName?: string;\r\n\r\n /** State Cookie 名称,默认 __Host-nihplod_sso_state */\r\n stateCookieName?: string;\r\n\r\n /** OIDC Nonce Cookie 名称,默认 __Host-nihplod_sso_nonce(须与 createSsoMiddleware 一致) */\r\n nonceCookieName?: string;\r\n\r\n /** Return URL Cookie 名称,默认 __Host-nihplod_sso_return */\r\n returnUrlCookieName?: string;\r\n\r\n /** PKCE Verifier Cookie 名称,默认 __Secure-nihplod_sso_verifier */\r\n verifierCookieName?: string;\r\n\r\n /**\r\n * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除\r\n * __Host-/__Secure- 前缀;必须与 createSsoMiddleware 的配置保持一致,\r\n * 否则读不到 middleware 写入的 state/verifier Cookie。生产严禁启用——\r\n * 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下该开关会被\r\n * 强制忽略并告警。\r\n */\r\n insecureLocalDev?: boolean;\r\n\r\n /**\r\n * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。\r\n * 仅用于 token 交换等服务器间请求;浏览器跳转仍使用 ssoBaseUrl 公网地址。\r\n */\r\n serverBaseUrl?: string;\r\n}\r\n\r\n// ============================================\r\n// 工具函数\r\n// ============================================\r\n\r\n// returnUrl 开放重定向校验统一使用 ../core/security 的 isTrustedReturnUrl\r\n\r\n// ID Token 预校验逻辑已收敛到 ../core/id-token.ts\r\n\r\n// ============================================\r\n// Route Handler 工厂函数\r\n// ============================================\r\n\r\nexport function createCallbackRouteHandler(config: CallbackRouteConfig) {\r\n const {\r\n clientId,\r\n ssoBaseUrl,\r\n redirectUri,\r\n clientSecret,\r\n defaultReturnPath = \"/\",\r\n insecureLocalDev: insecureLocalDevOpt = false,\r\n } = config;\r\n\r\n // 生产守卫:NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关(与 middleware 一致)\r\n const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);\r\n\r\n // insecureLocalDev:与 middleware 一致地去前缀 + 关 Secure,否则读不到 state/verifier\r\n const secureCookies = !insecureLocalDev;\r\n const pickName = (explicit: string | undefined, fallback: string) =>\r\n insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;\r\n const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);\r\n const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);\r\n const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);\r\n const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);\r\n const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);\r\n const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);\r\n const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);\r\n\r\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n // 服务器间调用(token 交换)的基准地址:配置了内网地址时走内网\r\n const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\\/+$/, \"\");\r\n\r\n return async function GET(request: NextRequest) {\r\n const response = await handleCallback(request);\r\n // 错误路径(4xx/5xx)统一清除 nonce cookie,避免残留;\r\n // 成功路径在重定向响应中随 state/returnUrl cookie 一并清除\r\n if (response.status >= 400) {\r\n response.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n }\r\n return response;\r\n };\r\n\r\n async function handleCallback(request: NextRequest): Promise<NextResponse> {\r\n const { searchParams } = request.nextUrl;\r\n\r\n // 检查错误\r\n const error = searchParams.get(\"error\");\r\n if (error) {\r\n const desc = searchParams.get(\"error_description\") || error;\r\n return NextResponse.json(\r\n { error: \"authorization_failed\", error_description: desc },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n const code = searchParams.get(\"code\");\r\n const returnedState = searchParams.get(\"state\");\r\n\r\n if (!code) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"缺少 authorization code\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 验证 state(从 cookie 中读取原始 state,CSRF 必需)\r\n const savedState = request.cookies.get(stateCookieName)?.value;\r\n if (!savedState) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"State 参数缺失,请重新发起授权请求\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n // state 比较使用常量时间比较(与 core/SsoClient.handleCallback 一致,防时序侧信道)\r\n if (!timingSafeEqualString(returnedState ?? \"\", savedState)) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"State 参数不匹配,可能存在 CSRF 攻击\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 读取 middleware 发起授权时写入的 OIDC nonce(httpOnly cookie)。\r\n // cookie 存在时 validateIdToken 会 fail-closed 校验 ID Token 的 nonce claim\r\n const expectedNonce = request.cookies.get(nonceCookieName)?.value;\r\n\r\n // 读取 PKCE code_verifier(middleware 存入的 httpOnly cookie)\r\n const verifier = request.cookies.get(verifierCookieName)?.value;\r\n if (!verifier) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"PKCE verifier 缺失,请重新发起授权请求\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 交换 token(服务器间调用:配置了 serverBaseUrl 时走内网直连)\r\n const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;\r\n const body = new URLSearchParams();\r\n body.set(\"grant_type\", \"authorization_code\");\r\n body.set(\"code\", code);\r\n body.set(\"client_id\", clientId);\r\n body.set(\"redirect_uri\", redirectUri);\r\n body.set(\"code_verifier\", verifier);\r\n if (clientSecret) {\r\n body.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n // 带重试的 token 交换(1 次重试 + 指数退避)\r\n let res: Response | null = null;\r\n let lastError: unknown;\r\n const maxRetries = 1;\r\n\r\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\r\n if (attempt > 0) {\r\n // 指数退避:1s * 2^attempt\r\n await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));\r\n }\r\n try {\r\n res = await fetch(tokenEndpoint, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: body.toString(),\r\n });\r\n lastError = null;\r\n break;\r\n } catch (err) {\r\n lastError = err;\r\n if (attempt >= maxRetries) {\r\n return NextResponse.json(\r\n { error: \"server_error\", error_description: \"Token 请求失败,已重试仍不可达\" },\r\n { status: 502 }\r\n );\r\n }\r\n }\r\n }\r\n\r\n if (lastError || !res) {\r\n return NextResponse.json(\r\n { error: \"server_error\", error_description: \"Token 请求失败\" },\r\n { status: 502 }\r\n );\r\n }\r\n\r\n if (!res.ok) {\r\n let errData: Record<string, unknown> = {};\r\n try {\r\n errData = await res.json();\r\n } catch { /* ignore */ }\r\n return NextResponse.json(\r\n {\r\n error: \"token_request_failed\",\r\n error_description:\r\n (errData.error_description as string) || `Token 请求失败: HTTP ${res.status}`,\r\n },\r\n { status: 502 }\r\n );\r\n }\r\n\r\n const tokenData: {\r\n access_token: string;\r\n token_type: string;\r\n expires_in: number;\r\n refresh_token: string;\r\n id_token?: string;\r\n } = await res.json();\r\n\r\n // 服务端异常可能省略必要字段;不校验会把字符串 \"undefined\" 写进 cookie\r\n if (!tokenData.access_token || !tokenData.refresh_token) {\r\n return NextResponse.json(\r\n {\r\n error: \"server_error\",\r\n error_description: \"Token 响应缺少 access_token 或 refresh_token\",\r\n },\r\n { status: 502 }\r\n );\r\n }\r\n\r\n // 在设置 Cookie 前预校验 ID Token:防止伪造 token 写入浏览器\r\n if (tokenData.id_token) {\r\n try {\r\n await validateIdToken(\r\n tokenData.id_token,\r\n tokenData.access_token,\r\n normalizedBase,\r\n clientId,\r\n { expectedNonce }\r\n );\r\n } catch (err) {\r\n return NextResponse.json(\r\n {\r\n error: \"id_token_invalid\",\r\n error_description: err instanceof Error ? err.message : \"ID Token 验证失败\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n }\r\n\r\n // 读取 return URL,并做开放重定向防护。\r\n // 可信 origin 与跳转基准一律取 redirectUri 的 origin(部署时配置的公网地址),\r\n // 而非 request.url / request.nextUrl.origin:Next standalone 部署下后者是进程\r\n // 监听地址(如 http://0.0.0.0:3002),反代场景会把用户重定向到不可达地址。\r\n const callbackOrigin = new URL(redirectUri).origin;\r\n const rawReturnUrl =\r\n request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;\r\n const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin)\r\n ? rawReturnUrl\r\n : \"/\";\r\n\r\n // 重定向并设置 cookie\r\n const response = NextResponse.redirect(new URL(returnUrl, callbackOrigin));\r\n\r\n // 设置 access_token cookie (httpOnly, Secure, SameSite=Lax, Path=/)\r\n response.cookies.set(accessTokenCookieName, tokenData.access_token, {\r\n ...getHostCookieOptions(tokenData.expires_in, secureCookies),\r\n });\r\n\r\n // 设置 refresh_token cookie(使用服务端返回的过期时间动态计算)\r\n // 服务端 refresh_token 通常是 30 天,此处使用 expires_in 映射(若响应包含)\r\n // 回退到 30 天默认值\r\n const refreshMaxAge =\r\n (tokenData as Record<string, unknown>).refresh_expires_in != null\r\n ? (tokenData as Record<string, unknown>).refresh_expires_in as number\r\n : 30 * 24 * 60 * 60;\r\n\r\n response.cookies.set(refreshTokenCookieName, tokenData.refresh_token, {\r\n ...getHostCookieOptions(refreshMaxAge, secureCookies),\r\n });\r\n\r\n // 设置 id_token cookie,用于 RP-Initiated Logout 的 id_token_hint\r\n // 使用 ID Token 自身的过期时间(通常 1 小时),而非 refresh token 的 30 天\r\n if (tokenData.id_token) {\r\n let idTokenMaxAge = 3600; // 默认 1 小时\r\n try {\r\n const parts = tokenData.id_token.split(\".\");\r\n if (parts.length === 3) {\r\n const payload = JSON.parse(\r\n Buffer.from(parts[1].replace(/-/g, \"+\").replace(/_/g, \"/\"), \"base64\").toString(\"utf8\")\r\n ) as { exp?: number };\r\n if (payload.exp && typeof payload.exp === \"number\") {\r\n // 已过期(剩余 <= 0)的 id_token 不写入 cookie,避免携带失效凭证登出\r\n idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1000);\r\n }\r\n }\r\n } catch {\r\n // 解码失败,使用默认值\r\n }\r\n if (idTokenMaxAge > 0) {\r\n response.cookies.set(idTokenCookieName, tokenData.id_token, {\r\n ...getHostCookieOptions(idTokenMaxAge, secureCookies),\r\n });\r\n }\r\n }\r\n\r\n // 清除临时 cookies: state / nonce / return URL\r\n response.cookies.set(stateCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n response.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n response.cookies.set(returnUrlCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n\r\n // 清除 PKCE verifier cookie,必须使用写入时的 path(callbackPath)\r\n // 由于 callback handler 不知道 middleware 的 callbackPath,这里保守地\r\n // 同时清除 path=/ 和 path=当前请求路径两种可能\r\n response.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, \"/\", secureCookies));\r\n response.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));\r\n\r\n return response;\r\n }\r\n}\r\n","/**\r\n * SSO SDK 错误类型\r\n */\r\n\r\n/** SSO 错误码 */\r\nexport type SsoErrorCode =\r\n | \"invalid_config\"\r\n | \"state_mismatch\"\r\n | \"pkce_required\"\r\n | \"token_request_failed\"\r\n | \"session_expired\"\r\n | \"no_refresh_token\"\r\n | \"userinfo_failed\"\r\n | \"not_authenticated\"\r\n | \"authorization_code_expired\"\r\n // 保留:当前 SDK 不主动抛出,预留给服务端错误透传/未来版本使用\r\n | \"authorization_code_used\"\r\n | \"client_disabled\"\r\n | \"user_denied_authorization\"\r\n // 保留:当前 SDK 不主动抛出,预留给服务端错误透传/未来版本使用\r\n | \"account_disabled\"\r\n | \"sso_server_error\"\r\n | \"rate_limited\"\r\n | \"network_error\"\r\n | \"popup_blocked\"\r\n | \"popup_closed\"\r\n | \"id_token_invalid\"\r\n | \"id_token_unsupported_alg\"\r\n | \"id_token_hs256_unsupported\"\r\n // 保留:当前 SDK 不主动抛出(HS256 一律拒绝,见 id_token_hs256_unsupported)\r\n | \"id_token_missing_secret\"\r\n | \"id_token_invalid_signature\"\r\n | \"id_token_issuer_mismatch\"\r\n | \"id_token_audience_mismatch\"\r\n | \"id_token_expired\"\r\n | \"id_token_missing_sub\"\r\n | \"id_token_at_hash_mismatch\"\r\n | \"id_token_nonce_mismatch\";\r\n\r\n/**\r\n * 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode\r\n *\r\n * @param oauthError 服务端返回的 error 字段\r\n * @param context 调用上下文:\"token_exchange\"(授权码换 token)或 \"refresh\"(刷新)。\r\n * invalid_grant 在换 token 场景多为授权码过期/已用,在刷新场景多为会话失效,\r\n * 需按上下文细化映射。\r\n */\r\nexport function mapOAuthErrorToSsoCode(\r\n oauthError: string,\r\n context: \"token_exchange\" | \"refresh\" = \"token_exchange\"\r\n): SsoErrorCode {\r\n switch (oauthError) {\r\n case \"invalid_grant\":\r\n // 刷新场景的 invalid_grant 表示 refresh_token 已撤销/过期(调用方另会清除本地登录态)\r\n return context === \"refresh\" ? \"session_expired\" : \"authorization_code_expired\";\r\n case \"invalid_client\":\r\n return \"client_disabled\";\r\n case \"access_denied\":\r\n return \"user_denied_authorization\";\r\n case \"server_error\":\r\n return \"sso_server_error\";\r\n case \"rate_limited\":\r\n // 限流是独立的可重试错误,不应误报为 network_error\r\n return \"rate_limited\";\r\n case \"invalid_scope\":\r\n case \"invalid_request\":\r\n case \"unauthorized_client\":\r\n case \"unsupported_grant_type\":\r\n default:\r\n return \"token_request_failed\";\r\n }\r\n}\r\n\r\n/**\r\n * SSO SDK 自定义错误\r\n */\r\nexport class SsoError extends Error {\r\n public readonly code: SsoErrorCode;\r\n public readonly description: string;\r\n public readonly cause?: unknown;\r\n\r\n constructor(code: SsoErrorCode, description: string, cause?: unknown) {\r\n super(`[SSO SDK] ${code}: ${description}`);\r\n this.name = \"SsoError\";\r\n this.code = code;\r\n this.description = description;\r\n this.cause = cause;\r\n }\r\n}\r\n\r\n/**\r\n * OAuth 2.0 协议层错误(RFC 6749 §5.2)\r\n *\r\n * 保留用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),\r\n * 与 SDK 语义的 SsoError 区分。当前 SDK 内部不抛出此类型(统一使用 SsoError +\r\n * mapOAuthErrorToSsoCode 映射),作为公共类型导出供子项目自行解析 OAuth 响应时使用。\r\n */\r\nexport class OAuthError extends Error {\r\n public readonly code: string;\r\n public readonly description: string;\r\n public readonly uri?: string;\r\n\r\n constructor(code: string, description: string, uri?: string) {\r\n super(`[OAuth] ${code}: ${description}`);\r\n this.name = \"OAuthError\";\r\n this.code = code;\r\n this.description = description;\r\n this.uri = uri;\r\n }\r\n}\r\n","/**\n * 安全相关小工具:returnUrl 开放重定向校验、常量时间字符串比较\n */\n\n/**\n * 校验 returnUrl 是否可信(防开放重定向)。\n * 仅允许:相对路径(且不以 // 开头)或与 currentOrigin 完全同源的绝对 URL。\n * 拒绝一切含反斜杠 \\ 的值:浏览器会把 \"/\\evil.com\" 归一化为 \"//evil.com\",\n * 形成协议相对 URL 开放重定向。\n */\nexport function isTrustedReturnUrl(url: string, currentOrigin: string): boolean {\n if (!url) return false;\n if (url.includes(\"\\\\\")) return false;\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\n try {\n return new URL(url).origin === currentOrigin;\n } catch {\n return false;\n }\n}\n\n/**\n * 常量时间字符串比较(避免 state / at_hash / nonce 等机密值的时序侧信道)。\n * 长度不同也执行完整循环,不提前返回。\n */\nexport function timingSafeEqualString(a: string, b: string): boolean {\n const ba = new TextEncoder().encode(a);\n const bb = new TextEncoder().encode(b);\n if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;\n const len = Math.max(ba.length, bb.length);\n let diff = ba.length ^ bb.length;\n for (let i = 0; i < len; i++) {\n diff |= ba[i % ba.length] ^ bb[i % bb.length];\n }\n return diff === 0;\n}\n","/**\n * ID Token 验证工具(浏览器 / Edge Runtime 通用)\n *\n * 提取 SsoClient 与 Next.js callback 中重复的 ID Token 校验逻辑,\n * 包括 base64url 解码、JWKS 获取、RS256 签名验证、at_hash 计算等。\n */\n\nimport { SsoError } from \"./errors\";\nimport { timingSafeEqualString } from \"./security\";\n\n// 运行时 crypto.subtle 检测:现代浏览器 / Edge Runtime / Node 20+ 均提供全局 crypto\nconst _crypto: Pick<Crypto, \"subtle\"> | null =\n typeof crypto !== \"undefined\" && (crypto as Crypto).subtle ? (crypto as Crypto) : null;\n\nfunction getCrypto(): Crypto | null {\n return (_crypto as Crypto | null) ?? null;\n}\n\nexport interface JwksKey {\n kty: string;\n kid?: string;\n alg?: string;\n use?: string;\n n?: string;\n e?: string;\n x5c?: string[];\n}\n\nexport interface Jwks {\n keys: JwksKey[];\n}\n\nexport interface ValidateIdTokenResult {\n sub: string;\n}\n\nexport interface ValidateIdTokenOptions {\n /**\n * 发现 SSO 中心已启用 RS256 时,是否拒绝 HS256 ID Token。\n * 默认 true:只要 JWKS 中存在 RS256 签名密钥,就拒绝 HS256(安全推荐)。\n */\n rejectHs256WhenRs256Available?: boolean;\n /**\n * 期望的 OIDC nonce(login 时生成并随 authorize 请求发送)。\n * 传入后 ID Token 必须携带 nonce claim 且与该值常量时间相等(fail-closed),\n * 否则抛出 id_token_nonce_mismatch,防止 ID Token 重放。\n * 不传则跳过 nonce 校验(如 refresh 流程签发的 ID Token)。\n */\n expectedNonce?: string;\n}\n\nfunction base64UrlDecode(input: string): Uint8Array {\n const base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), \"=\");\n const binary = atob(padded);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n}\n\n/** 解码 JWT payload(不验证签名) */\nexport function decodeJwtPayload(token: string): Record<string, unknown> | null {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const json = new TextDecoder().decode(base64UrlDecode(parts[1]));\n return JSON.parse(json) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\n/** 解码 JWT header */\nexport function decodeJwtHeader(token: string): Record<string, unknown> | null {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const json = new TextDecoder().decode(base64UrlDecode(parts[0]));\n return JSON.parse(json) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\n/** 标准化 issuer/base URL,去除末尾斜杠 */\nfunction normalizeIssuer(url: string): string {\n return url.replace(/\\/+$/, \"\");\n}\n\n/** OIDC Discovery 文档(仅取本模块关心的字段) */\ninterface OidcDiscoveryDoc {\n issuer?: string;\n jwks_uri?: string;\n}\n\nlet cachedJwks: { baseUrl: string; jwks: Jwks; fetchedAt: number } | null = null;\nlet cachedDiscovery: { baseUrl: string; doc: OidcDiscoveryDoc; fetchedAt: number } | null = null;\nconst JWKS_CACHE_TTL_MS = 5 * 60 * 1000;\n/** discovery / JWKS 拉取超时(与 SsoClient._getDiscovery 的 10s 一致) */\nconst FETCH_TIMEOUT_MS = 10000;\n\n/**\n * 拉取 OIDC Discovery 文档(带内存缓存;失败返回 null 不抛错)\n *\n * 只缓存成功结果:失败(网络错误 / 非 2xx)不缓存,主站短暂抖动不会让\n * 登录持续失败到 TTL 期满,下一次调用立即重试(与 core/discovery.ts 策略一致)。\n */\nasync function fetchDiscoveryDoc(baseUrl: string): Promise<OidcDiscoveryDoc | null> {\n const now = Date.now();\n if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {\n return cachedDiscovery.doc;\n }\n\n try {\n const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n });\n if (!res.ok) return null;\n const doc = (await res.json()) as OidcDiscoveryDoc;\n cachedDiscovery = { baseUrl, doc, fetchedAt: now };\n return doc;\n } catch {\n return null;\n }\n}\n\nexport interface FetchJwksOptions {\n /**\n * 绕过内存缓存与浏览器 HTTP 缓存(cache: \"no-cache\")强制重取 JWKS,\n * 并同步刷新模块级缓存。用于密钥轮换后 kid 失配的自愈重试。\n */\n forceRefresh?: boolean;\n}\n\n/** 从 SSO 中心拉取 JWKS(带内存缓存;优先 Discovery 的 jwks_uri,回退硬编码路径) */\nexport async function fetchJwks(baseUrl: string, options: FetchJwksOptions = {}): Promise<Jwks | null> {\n const now = Date.now();\n if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {\n return cachedJwks.jwks;\n }\n\n const discovery = await fetchDiscoveryDoc(baseUrl);\n const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;\n\n try {\n const res = await fetch(jwksUri, {\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n ...(options.forceRefresh ? { cache: \"no-cache\" as RequestCache } : {}),\n });\n if (!res.ok) return null;\n const jwks = (await res.json()) as Jwks;\n cachedJwks = { baseUrl, jwks, fetchedAt: now };\n return jwks;\n } catch {\n return null;\n }\n}\n\n/** 清空 JWKS / Discovery 内存缓存(测试与密钥轮换场景使用) */\nexport function clearIdTokenCaches(): void {\n cachedJwks = null;\n cachedDiscovery = null;\n}\n\n/** 使用 RS256 公钥验证 ID Token 签名 */\nexport async function verifyRs256Signature(token: string, jwk: JwksKey): Promise<boolean> {\n try {\n const [headerB64, payloadB64, signature] = token.split(\".\");\n if (!signature || !jwk.n || !jwk.e) return false;\n\n const c = getCrypto();\n if (!c) return false;\n\n const cryptoKey = await c.subtle.importKey(\n \"jwk\",\n { kty: \"RSA\", n: jwk.n, e: jwk.e, alg: \"RS256\", ext: false },\n { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-256\" },\n false,\n [\"verify\"]\n );\n\n const signatureBytes = base64UrlDecode(signature);\n const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);\n return await c.subtle.verify(\n \"RSASSA-PKCS1-v1_5\",\n cryptoKey,\n signatureBytes as unknown as BufferSource,\n data as unknown as BufferSource\n );\n } catch {\n return false;\n }\n}\n\n/** 计算 OIDC at_hash(access_token SHA-256 前 128bit base64url) */\nexport async function computeAtHash(accessToken: string): Promise<string> {\n const c = getCrypto();\n if (!c) return \"\";\n const hash = await c.subtle.digest(\"SHA-256\", new TextEncoder().encode(accessToken));\n const bytes = new Uint8Array(hash);\n const half = bytes.slice(0, bytes.length / 2);\n let binary = \"\";\n for (const b of half) {\n binary += String.fromCharCode(b);\n }\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** 验证 ID Token(iss、aud、exp、iat、azp、sub、签名、at_hash) */\nexport async function validateIdToken(\n idToken: string,\n accessToken: string,\n expectedIssuer: string,\n expectedClientId: string,\n options: ValidateIdTokenOptions = {}\n): Promise<ValidateIdTokenResult> {\n const { rejectHs256WhenRs256Available = true, expectedNonce } = options;\n\n const header = decodeJwtHeader(idToken);\n if (!header) {\n throw new SsoError(\"id_token_invalid\", \"ID Token 格式错误\");\n }\n\n const alg = header.alg;\n if (typeof alg !== \"string\" || (alg !== \"RS256\" && alg !== \"HS256\")) {\n throw new SsoError(\"id_token_unsupported_alg\", `不支持的 ID Token 签名算法: ${alg}`);\n }\n\n const baseUrl = normalizeIssuer(expectedIssuer);\n // issuer 以 Discovery 文档的 issuer 为准(防伪站伪造 iss 通过校验),回退调用方传入值\n const discovery = await fetchDiscoveryDoc(baseUrl);\n const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);\n\n // RS256:通过 JWKS 验证签名\n if (alg === \"RS256\") {\n const jwks = await fetchJwks(baseUrl);\n if (!jwks) {\n throw new SsoError(\"id_token_invalid_signature\", \"无法获取 JWKS 验证 ID Token 签名\");\n }\n const kid = typeof header.kid === \"string\" ? header.kid : undefined;\n // 有 kid 时精确匹配;无 kid 时逐个尝试所有 RS256 签名公钥\n const matchCandidates = (set: Jwks) =>\n set.keys.filter(\n (k) =>\n k.kty === \"RSA\" &&\n k.alg === \"RS256\" &&\n k.use === \"sig\" &&\n (kid ? k.kid === kid : true)\n );\n const verifyAny = async (keys: JwksKey[]): Promise<boolean> => {\n for (const key of keys) {\n if (await verifyRs256Signature(idToken, key)) return true;\n }\n return false;\n };\n\n let candidates = matchCandidates(jwks);\n let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\n\n // 密钥轮换自愈:按 kid 过滤无候选,或所有候选验签失败时,\n // 可能是本地 JWKS 缓存陈旧 —— 强制重取一次 JWKS 后重试,仍失败才抛错\n if (candidates.length === 0 || !validSig) {\n const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });\n if (freshJwks) {\n candidates = matchCandidates(freshJwks);\n validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\n }\n }\n\n if (candidates.length === 0) {\n throw new SsoError(\"id_token_invalid_signature\", \"JWKS 中未找到匹配的 RS256 公钥\");\n }\n if (!validSig) {\n throw new SsoError(\"id_token_invalid_signature\", \"ID Token 签名验证失败\");\n }\n }\n\n // HS256:对称密钥无法安全分发给 Public Client / BFF。\n // SDK 拒绝 HS256 ID Token:主站需配置 RS256 密钥对以启用安全验证。\n if (alg === \"HS256\") {\n if (rejectHs256WhenRs256Available) {\n const jwks = await fetchJwks(baseUrl);\n const hasRs256 = jwks?.keys?.some((k) => k.alg === \"RS256\" && k.use === \"sig\");\n if (hasRs256) {\n throw new SsoError(\"id_token_unsupported_alg\", \"SSO 中心已配置 RS256,拒绝 HS256 ID Token\");\n }\n }\n throw new SsoError(\n \"id_token_hs256_unsupported\",\n \"ID Token 使用 HS256 对称签名,SDK 无法安全验证。\" +\n \"请联系管理员在 SSO 主站配置 RS256 密钥对(JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY)后重新签发。\"\n );\n }\n\n const payload = decodeJwtPayload(idToken);\n if (!payload) {\n throw new SsoError(\"id_token_invalid\", \"ID Token payload 解析失败\");\n }\n\n const tokenIssuer = typeof payload.iss === \"string\" ? normalizeIssuer(payload.iss) : \"\";\n if (tokenIssuer !== normalizedIssuer) {\n throw new SsoError(\"id_token_issuer_mismatch\", \"ID Token issuer 不匹配\");\n }\n\n // aud 校验:单值或数组包含 clientId;多 aud 时按 OIDC Core §3.1.3.7 要求 azp 必须等于 clientId\n const aud = payload.aud;\n const audList = Array.isArray(aud) ? aud : typeof aud === \"string\" ? [aud] : [];\n if (!audList.includes(expectedClientId)) {\n throw new SsoError(\"id_token_audience_mismatch\", \"ID Token audience 不匹配\");\n }\n if (audList.length > 1 && payload.azp !== expectedClientId) {\n throw new SsoError(\"id_token_audience_mismatch\", \"ID Token 多 audience 时 azp 必须为当前 client\");\n }\n\n // exp 必需且不得过期(允许 60s clock skew,与 iat 校验对称)\n if (typeof payload.exp !== \"number\") {\n throw new SsoError(\"id_token_invalid\", \"ID Token 缺少 exp 声明\");\n }\n if (Date.now() >= payload.exp * 1000 + 60_000) {\n throw new SsoError(\"id_token_expired\", \"ID Token 已过期\");\n }\n\n // iat 不得在未来(允许 60s clock skew)\n if (typeof payload.iat === \"number\" && payload.iat * 1000 > Date.now() + 60_000) {\n throw new SsoError(\"id_token_invalid\", \"ID Token iat 在未来,疑似伪造或时钟异常\");\n }\n\n if (typeof payload.sub !== \"string\" || !payload.sub) {\n throw new SsoError(\"id_token_missing_sub\", \"ID Token 缺少 sub\");\n }\n\n // 校验 at_hash:确保 ID Token 与当前 access_token 绑定(常量时间比较)\n if (typeof payload.at_hash === \"string\" && payload.at_hash) {\n const actual = await computeAtHash(accessToken);\n if (!timingSafeEqualString(actual, payload.at_hash)) {\n throw new SsoError(\"id_token_at_hash_mismatch\", \"ID Token at_hash 不匹配\");\n }\n }\n\n // 校验 nonce(OIDC Core §3.1.3.7):调用方传入 expectedNonce 时 fail-closed,\n // ID Token 必须携带 nonce 且与登录时生成的值常量时间相等,防 ID Token 重放\n if (expectedNonce !== undefined) {\n const tokenNonce = typeof payload.nonce === \"string\" ? payload.nonce : \"\";\n if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {\n throw new SsoError(\"id_token_nonce_mismatch\", \"ID Token nonce 不匹配\");\n }\n }\n\n return { sub: payload.sub };\n}\n","/**\n * App Router 登出 Route Handler\n *\n * 在 /api/auth/logout 路由中处理本地登出 + RP-Initiated Logout:\n * 1. 从 cookie 读取 refresh_token 并调用 revocation_endpoint 撤销\n * 2. 清除所有 SSO cookie\n * 3. 可选重定向到 SSO 中心登出页\n *\n * 用法 (src/app/api/auth/logout/route.ts):\n * ```ts\n * import { createLogoutRouteHandler } from \"@nihplod/sso-sdk/next\";\n *\n * const handler = createLogoutRouteHandler({\n * clientId: \"my-app\",\n * clientSecret: \"optional-secret\",\n * ssoBaseUrl: \"https://nihplod.cn\",\n * redirectUri: \"https://myapp.com/api/auth/callback\",\n * postLogoutRedirectUri: \"https://myapp.com/\",\n * });\n *\n * // 推荐使用 POST 触发登出(防登出 CSRF);GET 不执行登出,仅返回确认页\n * export const GET = handler;\n * export const POST = handler;\n * ```\n */\n\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { fetchDiscoveryCached } from \"../core/discovery\";\nimport {\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\n DEFAULT_ID_TOKEN_COOKIE_NAME,\n DEFAULT_STATE_COOKIE_NAME,\n DEFAULT_NONCE_COOKIE_NAME,\n DEFAULT_RETURN_COOKIE_NAME,\n DEFAULT_VERIFIER_COOKIE_NAME,\n DEFAULT_LOGOUT_STATE_COOKIE_NAME,\n getHostCookieOptions,\n getSecureCookieOptions,\n resolveInsecureLocalDev,\n toInsecureCookieName,\n} from \"./constants\";\n\n// ============================================\n// 类型定义\n// ============================================\n\nexport interface LogoutRouteConfig {\n /** OAuth Client ID */\n clientId: string;\n\n /** SSO 中心地址 */\n ssoBaseUrl: string;\n\n /** 回调 URL(与注册 redirect_uri 一致,用于构造 post_logout_redirect_uri) */\n redirectUri: string;\n\n /**\n * OAuth Client Secret(可选)。\n * 撤销 refresh_token 时,Confidential Client 需要认证。\n */\n clientSecret?: string;\n\n /** 登出后跳转回子项目的地址,默认取 redirectUri 的 origin */\n postLogoutRedirectUri?: string;\n\n /** 是否重定向到 SSO 中心登出页,默认 true */\n redirectToSso?: boolean;\n\n /** Access Token Cookie 名称 */\n accessTokenCookieName?: string;\n\n /** Refresh Token Cookie 名称 */\n refreshTokenCookieName?: string;\n\n /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */\n idTokenCookieName?: string;\n\n /** State Cookie 名称 */\n stateCookieName?: string;\n\n /** OIDC Nonce Cookie 名称,默认 __Host-nihplod_sso_nonce(须与 middleware/callback 一致) */\n nonceCookieName?: string;\n\n /** Return URL Cookie 名称 */\n returnUrlCookieName?: string;\n\n /** PKCE Verifier Cookie 名称 */\n verifierCookieName?: string;\n\n /** 回调路径(用于清除 verifier cookie),默认 \"/api/auth/callback\" */\n callbackPath?: string;\n\n /** Logout State Cookie 名称(RP-Initiated Logout CSRF 防护),默认 __Host-nihplod_sso_logout_state */\n logoutStateCookieName?: string;\n\n /**\n * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。\n * 仅用于 discovery / revocation 等服务器间请求;浏览器跳转\n * (end-session 等)始终使用 ssoBaseUrl 公网地址。\n * 适用于子站与 SSO 中心同机/同内网部署:避免经公网代理回源的延迟。\n */\n serverBaseUrl?: string;\n\n /**\n * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除\n * __Host-/__Secure- 前缀;必须与 middleware / callback 的配置保持一致,\n * 否则无法清除它们写入的 Cookie。生产严禁启用——生产环境\n * (NODE_ENV=production 且 ssoBaseUrl 为 https)下该开关会被强制忽略并告警。\n */\n insecureLocalDev?: boolean;\n}\n\n// ============================================\n// 工具函数\n// ============================================\n\n/** GET 确认页 HTML(登出 CSRF 防护:GET 不执行登出,由用户点击按钮发起 POST) */\nfunction buildLogoutConfirmHtml(): string {\n return `<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>确认退出登录</title>\n</head>\n<body style=\"display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;\">\n <form method=\"post\" style=\"text-align:center;\">\n <p>确定要退出登录吗?</p>\n <button type=\"submit\" style=\"padding:10px 20px;background-color:#ef4444;color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px;\">退出登录</button>\n <p><a href=\"/\" style=\"color:#2563eb;text-decoration:underline;\">取消并返回首页</a></p>\n </form>\n</body>\n</html>`;\n}\n\n/** 生成安全随机字符串(用于 logout state,Node/Edge Runtime 均支持 Web Crypto) */\nfunction generateRandomString(length: number): string {\n const chars =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~\";\n const maxValid = Math.floor(256 / chars.length) * chars.length;\n let result = \"\";\n while (result.length < length) {\n const array = new Uint8Array(length * 2);\n crypto.getRandomValues(array);\n for (let i = 0; i < array.length && result.length < length; i++) {\n if (array[i] >= maxValid) continue;\n result += chars[array[i] % chars.length];\n }\n }\n return result;\n}\n\n// ============================================\n// Route Handler 工厂函数\n// ============================================\n\nexport function createLogoutRouteHandler(config: LogoutRouteConfig) {\n const {\n clientId,\n ssoBaseUrl,\n redirectUri,\n clientSecret,\n postLogoutRedirectUri = new URL(redirectUri).origin + \"/\",\n redirectToSso = true,\n callbackPath = \"/api/auth/callback\",\n insecureLocalDev: insecureLocalDevOpt = false,\n } = config;\n\n // 生产守卫:NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关(与 middleware/callback 一致)\n const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);\n\n // insecureLocalDev:与 middleware/callback 一致地去前缀 + 关 Secure\n const secureCookies = !insecureLocalDev;\n const pickName = (explicit: string | undefined, fallback: string) =>\n insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;\n const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);\n const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);\n const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);\n const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);\n const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);\n const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);\n const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);\n const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);\n\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\n // 服务器间调用(discovery/revoke)的基准地址:配置了内网地址时走内网,\n // 避免经公网代理回源的延迟;浏览器跳转仍用 normalizedBase(公网)\n const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\\/+$/, \"\");\n\n // 本地跳转的 origin:取 redirectUri 的 origin 而非 request.nextUrl.origin——\n // standalone 部署下后者是进程监听地址(如 http://0.0.0.0:3002),反代场景不可靠。\n const callbackOrigin = new URL(redirectUri).origin;\n\n /**\n * 登出 handler:同时适配 GET 与 POST(函数本身不区分 method,\n * 在 route.ts 中 `export const GET = handler; export const POST = handler;` 即可)。\n *\n * ⚠️ CSRF 防护:GET 请求可被跨站触发(如 `<img src=\"/api/auth/logout\">`),\n * 因此 GET 且未携带合法 logout state 时不执行登出,仅返回确认页 HTML,\n * 由用户点击按钮以 POST 确认;POST(或携带合法 state 的 GET 回跳)才执行登出。\n *\n * 当请求携带 state 参数时,视为 RP-Initiated Logout 的回跳(post_logout_redirect_uri\n * 指向本路由的场景),校验 logout state cookie 后放行,防止伪造回跳。\n */\n return async function handler(request: NextRequest) {\n // RP-Initiated Logout 回跳:校验 state(CSRF 防护)\n const returnedState = request.nextUrl.searchParams.get(\"state\");\n if (returnedState) {\n const savedState = request.cookies.get(logoutStateCookieName)?.value;\n if (!savedState || savedState !== returnedState) {\n return NextResponse.json(\n { error: \"invalid_request\", error_description: \"Logout state 不匹配\" },\n { status: 400 }\n );\n }\n const res = NextResponse.redirect(callbackOrigin + \"/\");\n res.cookies.set(logoutStateCookieName, \"\", getHostCookieOptions(0, secureCookies));\n return res;\n }\n\n // GET 无 state:可能是跨站图片/预取触发的登出 CSRF,不执行登出,\n // 返回确认页由用户主动 POST 确认(表单 POST 不受 <img>/预取影响)\n if (request.method === \"GET\") {\n return new NextResponse(buildLogoutConfirmHtml(), {\n status: 200,\n headers: { \"content-type\": \"text/html; charset=utf-8\" },\n });\n }\n\n const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;\n const idTokenHint = request.cookies.get(idTokenCookieName)?.value;\n\n // 1. best-effort 撤销服务端 refresh_token\n if (refreshToken) {\n try {\n // 配置了内网地址时直连默认端点(跳过 discovery 的公网端点,\n // 否则文档里的公网 URL 会让调用仍绕行公网);否则用缓存的 discovery\n const discovery = config.serverBaseUrl\n ? null\n : await fetchDiscoveryCached(normalizedServerBase);\n const revokeUrl = config.serverBaseUrl\n ? `${normalizedServerBase}/api/oauth/revoke`\n : discovery?.revocation_endpoint || `${normalizedBase}/api/oauth/revoke`;\n const body = new URLSearchParams({\n token: refreshToken,\n token_type_hint: \"refresh_token\",\n client_id: clientId,\n });\n if (clientSecret) {\n body.set(\"client_secret\", clientSecret);\n }\n // 3s 超时兜底:撤销是 best-effort,主站缓慢时不得拖住登出流程\n await fetch(revokeUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n signal: AbortSignal.timeout(3000),\n });\n } catch {\n // 撤销失败不影响本地登出\n }\n }\n\n // 2. 准备本地清除 SSO cookie 的响应\n const clearCookies = (res: NextResponse) => {\n res.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(refreshTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(idTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(stateCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(returnUrlCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, \"/\", secureCookies));\n res.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, callbackPath, secureCookies));\n return res;\n };\n\n // 3. 若需要 RP-Initiated Logout,重定向到 SSO 中心,同时必须清除本地 Cookie\n if (redirectToSso) {\n // discovery 经内网地址拉取(若配置),但文档内端点是 SSO 中心按公网 origin\n // 生成的,可直接用于浏览器跳转;兜底也用公网 base,不得使用内网地址\n const discovery = await fetchDiscoveryCached(normalizedServerBase);\n const endSessionEndpoint =\n discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;\n const logoutUrl = new URL(endSessionEndpoint);\n logoutUrl.searchParams.set(\"client_id\", clientId);\n logoutUrl.searchParams.set(\n \"post_logout_redirect_uri\",\n postLogoutRedirectUri\n );\n if (idTokenHint) {\n logoutUrl.searchParams.set(\"id_token_hint\", idTokenHint);\n }\n // 携带 state 防登出 CSRF:回跳时校验(见 handler 开头)\n const logoutState = generateRandomString(32);\n logoutUrl.searchParams.set(\"state\", logoutState);\n const res = clearCookies(NextResponse.redirect(logoutUrl.toString()));\n res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));\n return res;\n }\n\n return clearCookies(\n NextResponse.redirect(callbackOrigin + \"/\")\n );\n };\n}\n","/**\n * OIDC Discovery 文档的共享获取:模块级缓存 + 单飞。\n *\n * Discovery 文档是准静态的(端点路径极少变更),但 logout 等热路径\n * 原先每次调用都重新拉取(一次登出甚至连续拉两次)。在子站与 SSO 中心\n * 经公网/代理互联的部署下,每次拉取都是一次数百毫秒的往返。\n *\n * 设计要点:\n * - 只缓存成功结果(5 分钟):失败返回 null 不缓存,主站短暂抖动不会\n * 把\"无 discovery 可用\"的状态固话住,下一次调用立即重试;\n * - 单飞:并发调用共享同一个在途 Promise,缓存未命中时不会打出并发请求;\n * - 模块级 Map:Node/Edge Runtime 均可(middleware 在 Edge 下按 isolate 各自缓存,\n * 对正确性无影响)。\n */\n\nexport interface SharedDiscoveryDoc {\n issuer?: string;\n revocation_endpoint?: string;\n end_session_endpoint?: string;\n [key: string]: unknown;\n}\n\nconst DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000;\nconst FETCH_TIMEOUT_MS = 5000;\n\nconst cache = new Map<string, { doc: SharedDiscoveryDoc; expiresAt: number }>();\nconst inflight = new Map<string, Promise<SharedDiscoveryDoc | null>>();\n\nexport function fetchDiscoveryCached(baseUrl: string): Promise<SharedDiscoveryDoc | null> {\n const hit = cache.get(baseUrl);\n if (hit && hit.expiresAt > Date.now()) {\n return Promise.resolve(hit.doc);\n }\n\n const existing = inflight.get(baseUrl);\n if (existing) return existing;\n\n const promise = (async () => {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);\n try {\n const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {\n signal: controller.signal,\n });\n if (!res.ok) return null;\n const doc = (await res.json()) as SharedDiscoveryDoc;\n // 只缓存成功结果\n cache.set(baseUrl, { doc, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS });\n return doc;\n } catch {\n return null;\n } finally {\n clearTimeout(timeoutId);\n inflight.delete(baseUrl);\n }\n })();\n\n inflight.set(baseUrl, promise);\n return promise;\n}\n\n/** 清空 discovery 缓存(测试用) */\nexport function clearDiscoveryCache(): void {\n cache.clear();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACuBA,oBAA0C;;;ACZnC,IAAM,mCAAmC;AACzC,IAAM,oCAAoC;AAC1C,IAAM,+BAA+B;AACrC,IAAM,4BAA4B;AAElC,IAAM,4BAA4B;AAClC,IAAM,6BAA6B;AAEnC,IAAM,+BAA+B;AAErC,IAAM,mCAAmC;AAOzC,SAAS,qBAAqB,MAAsB;AACzD,SAAO,KAAK,QAAQ,qBAAqB,EAAE;AAC7C;AAWO,SAAS,wBAAwB,SAAkB,YAA6B;AACrF,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,QAAQ,IAAI,aAAa,gBAAgB,eAAe,KAAK,UAAU,GAAG;AAC5E,YAAQ;AAAA,MACN;AAAA,IAEF;AACA,WAAO;AAAA,EACT;AACA,UAAQ;AAAA,IACN;AAAA,EAEF;AACA,SAAO;AACT;AAGO,SAAS,qBACd,QACA,SAAkB,MAOlB;AACA,SAAO;AAAA,IACL,UAAU;AAAA,IACV;AAAA,IACA,UAAU;AAAA,IACV,MAAM;AAAA,IACN,GAAI,WAAW,SAAY,EAAE,OAAO,IAAI,CAAC;AAAA,EAC3C;AACF;AAGO,SAAS,uBACd,QACA,OAAO,KACP,SAAkB,MAOlB;AACA,SAAO;AAAA,IACL,UAAU;AAAA,IACV;AAAA,IACA,UAAU;AAAA,IACV;AAAA,IACA,GAAI,WAAW,SAAY,EAAE,OAAO,IAAI,CAAC;AAAA,EAC3C;AACF;;;AD4BA,SAAS,qBAAqB,QAAwB;AACpD,QAAM,QACJ;AACF,QAAM,WAAW,KAAK,MAAM,MAAM,MAAM,MAAM,IAAI,MAAM;AACxD,MAAI,SAAS;AAEb,SAAO,OAAO,SAAS,QAAQ;AAC7B,UAAM,QAAQ,IAAI,WAAW,SAAS,CAAC;AACvC,WAAO,gBAAgB,KAAK;AAC5B,aAAS,IAAI,GAAG,IAAI,MAAM,UAAU,OAAO,SAAS,QAAQ,KAAK;AAC/D,UAAI,MAAM,CAAC,KAAK,SAAU;AAC1B,gBAAU,MAAM,MAAM,CAAC,IAAI,MAAM,MAAM;AAAA,IACzC;AAAA,EACF;AACA,SAAO;AACT;AAQA,eAAe,qBAAqB,UAAmC;AACrE,QAAM,UAAU,IAAI,YAAY;AAChC,QAAM,OAAO,QAAQ,OAAO,QAAQ;AACpC,QAAM,OAAO,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI;AACvD,QAAM,QAAQ,IAAI,WAAW,IAAI;AACjC,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,YAAY,KAAK;AACzC,cAAU,OAAO,aAAa,MAAM,CAAC,CAAC;AAAA,EACxC;AACA,SAAO,KAAK,MAAM,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AAC/E;AAKA,SAAS,YAAY,UAAkB,OAA0B;AAC/D,SAAO,MAAM,KAAK,CAAC,SAAS;AAC1B,QAAI,aAAa,KAAM,QAAO;AAC9B,QAAI,KAAK,SAAS,SAAS,KAAK,SAAS,WAAW,KAAK,QAAQ,WAAW,EAAE,CAAC,EAAG,QAAO;AACzF,QAAI,SAAS,WAAW,OAAO,GAAG,EAAG,QAAO;AAC5C,WAAO;AAAA,EACT,CAAC;AACH;AAUA,IAAM,qBAAqB,oBAAI,IAAgD;AAC/E,IAAM,0BAA0B;AAChC,IAAM,+BAA+B;AAGrC,eAAe,mBAAmB,OAAe,UAAmC;AAClF,QAAM,OAAO,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,EAAE,OAAO,KAAK,CAAC;AAClF,QAAM,MAAM,MAAM,KAAK,IAAI,WAAW,IAAI,CAAC,EACxC,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAC1C,KAAK,EAAE;AACV,SAAO,GAAG,GAAG,IAAI,QAAQ;AAC3B;AAGA,SAAS,mBAAmB,KAAwD;AAClF,QAAM,QAAQ,mBAAmB,IAAI,GAAG;AACxC,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,MAAM,SAAS,KAAK,IAAI,GAAG;AAC7B,uBAAmB,OAAO,GAAG;AAC7B,WAAO;AAAA,EACT;AAEA,qBAAmB,OAAO,GAAG;AAC7B,qBAAmB,IAAI,KAAK,KAAK;AACjC,SAAO;AACT;AAGA,SAAS,mBAAmB,KAAa,QAAuB;AAC9D,QAAM,MAAM,KAAK,IAAI;AACrB,aAAW,CAAC,GAAG,CAAC,KAAK,oBAAoB;AACvC,QAAI,EAAE,SAAS,IAAK,oBAAmB,OAAO,CAAC;AAAA,EACjD;AACA,SAAO,mBAAmB,QAAQ,8BAA8B;AAC9D,UAAM,SAAS,mBAAmB,KAAK,EAAE,KAAK;AAC9C,QAAI,OAAO,KAAM;AACjB,uBAAmB,OAAO,OAAO,KAAK;AAAA,EACxC;AACA,qBAAmB,IAAI,KAAK,EAAE,QAAQ,OAAO,MAAM,wBAAwB,CAAC;AAC9E;AAEA,eAAe,sBACb,OACA,YACA,UACA,cACkB;AAClB,QAAM,WAAW,MAAM,mBAAmB,OAAO,QAAQ;AACzD,QAAM,SAAS,mBAAmB,QAAQ;AAC1C,MAAI,QAAQ;AACV,WAAO,OAAO;AAAA,EAChB;AAEA,MAAI;AACF,UAAM,OAAO,IAAI,gBAAgB;AAAA,MAC/B;AAAA,MACA,iBAAiB;AAAA,MACjB,WAAW;AAAA,IACb,CAAC;AACD,QAAI,cAAc;AAChB,WAAK,IAAI,iBAAiB,YAAY;AAAA,IACxC;AAEA,UAAM,MAAM,MAAM,MAAM,GAAG,UAAU,yBAAyB;AAAA,MAC5D,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,MAC/D,MAAM,KAAK,SAAS;AAAA,IACtB,CAAC;AAED,QAAI,CAAC,IAAI,IAAI;AAEX,UAAI,IAAI,WAAW,OAAO,IAAI,WAAW,KAAK;AAE5C,eAAO;AAAA,MACT;AACA,yBAAmB,UAAU,KAAK;AAClC,aAAO;AAAA,IACT;AACA,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,UAAM,SAAS,KAAK,WAAW;AAC/B,uBAAmB,UAAU,MAAM;AACnC,WAAO;AAAA,EACT,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAMO,SAAS,oBAAoB,QAA6B;AAC/D,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,SAAS;AAAA,IACT,cAAc,CAAC;AAAA,IACf,eAAe;AAAA,IACf,gBAAgB;AAAA,IAChB,oBAAoB;AAAA,IACpB,kBAAkB,sBAAsB;AAAA,EAC1C,IAAI;AAGJ,QAAM,mBAAmB,wBAAwB,qBAAqB,UAAU;AAGhF,QAAM,gBAAgB,CAAC;AACvB,QAAM,wBAAwB,mBAC1B,qBAAqB,OAAO,yBAAyB,gCAAgC,IACrF,OAAO,yBAAyB;AACpC,QAAM,kBAAkB,mBACpB,qBAAqB,OAAO,mBAAmB,yBAAyB,IACxE,OAAO,mBAAmB;AAC9B,QAAM,kBAAkB,mBACpB,qBAAqB,OAAO,mBAAmB,yBAAyB,IACxE,OAAO,mBAAmB;AAC9B,QAAM,sBAAsB,mBACxB,qBAAqB,OAAO,uBAAuB,0BAA0B,IAC7E,OAAO,uBAAuB;AAClC,QAAM,qBAAqB,mBACvB,qBAAqB,OAAO,sBAAsB,4BAA4B,IAC9E,OAAO,sBAAsB;AAGjC,QAAM,iBAAiB,WAAW,QAAQ,QAAQ,EAAE;AAGpD,QAAM,wBAAwB,OAAO,iBAAiB,YAAY,QAAQ,QAAQ,EAAE;AAGpF,MAAI,CAAC,mBAAmB;AACtB,YAAQ;AAAA,MACN;AAAA,IAEF;AAAA,EACF;AACA,MAAI,CAAC,cAAc;AACjB,YAAQ;AAAA,MACN;AAAA,IAEF;AAAA,EACF;AAEA,SAAO,eAAe,cAAc,SAAsB;AACxD,UAAM,EAAE,SAAS,IAAI,QAAQ;AAG7B,QACE,SAAS,WAAW,SAAS,KAC7B,SAAS,WAAW,cAAc,KAClC,SAAS,MAAM,yCAAyC,GACxD;AACA,aAAO,2BAAa,KAAK;AAAA,IAC3B;AAGA,QAAI,aAAa,cAAc;AAC7B,aAAO,2BAAa,KAAK;AAAA,IAC3B;AAGA,UAAM,iBAAiB,CAAC,cAAc,GAAG,WAAW;AACpD,QAAI,YAAY,UAAU,cAAc,GAAG;AACzC,aAAO,2BAAa,KAAK;AAAA,IAC3B;AAGA,UAAM,aAAa,QAAQ,QAAQ,IAAI,aAAa;AACpD,QAAI,YAAY,OAAO;AAErB,UAAI,mBAAmB;AACrB,cAAM,cAAc,MAAM;AAAA,UACxB,WAAW;AAAA,UACX;AAAA,UACA;AAAA,UACA;AAAA,QACF;AACA,YAAI,aAAa;AACf,iBAAO,2BAAa,KAAK;AAAA,QAC3B;AAAA,MAEF,OAAO;AACL,eAAO,2BAAa,KAAK;AAAA,MAC3B;AAAA,IACF;AAGA,UAAM,oBAAoB,QAAQ,QAAQ,IAAI,qBAAqB;AACnE,QAAI,mBAAmB,OAAO;AAG5B,YAAM,cAAc,MAAM;AAAA,QACxB,kBAAkB;AAAA,QAClB;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAEA,UAAI,aAAa;AACf,eAAO,2BAAa,KAAK;AAAA,MAC3B;AAAA,IAEF;AAGA,UAAM,QAAQ,qBAAqB,EAAE;AAGrC,UAAM,QAAQ,qBAAqB,EAAE;AACrC,UAAM,WAAW,qBAAqB,EAAE;AAGxC,UAAM,YAAY,MAAM,qBAAqB,QAAQ;AAErD,UAAM,kBAAkB,IAAI,gBAAgB;AAC5C,oBAAgB,IAAI,iBAAiB,MAAM;AAC3C,oBAAgB,IAAI,aAAa,QAAQ;AACzC,oBAAgB,IAAI,gBAAgB,WAAW;AAC/C,oBAAgB,IAAI,SAAS,MAAM;AACnC,oBAAgB,IAAI,SAAS,KAAK;AAClC,oBAAgB,IAAI,SAAS,KAAK;AAClC,oBAAgB,IAAI,kBAAkB,SAAS;AAC/C,oBAAgB,IAAI,yBAAyB,MAAM;AAEnD,UAAM,WAAW,IAAI,IAAI,wBAAwB,cAAc;AAC/D,aAAS,SAAS,gBAAgB,SAAS;AAE3C,UAAM,WAAW,2BAAa,SAAS,QAAQ;AAG/C,aAAS,QAAQ,IAAI,iBAAiB,OAAO,qBAAqB,KAAK,aAAa,CAAC;AAGrF,aAAS,QAAQ,IAAI,iBAAiB,OAAO,qBAAqB,KAAK,aAAa,CAAC;AAIrF,aAAS,QAAQ,IAAI,oBAAoB,UAAU,uBAAuB,KAAK,cAAc,aAAa,CAAC;AAG3G,UAAM,iBAAiB,QAAQ,QAAQ,WAAW,QAAQ,QAAQ,QAAQ,MAAM,GAAG,IAAI;AACvF,aAAS,QAAQ,IAAI,qBAAqB,eAAe,qBAAqB,KAAK,aAAa,CAAC;AAGjG,QAAI,mBAAmB,OAAO;AAC5B,eAAS,QAAQ,IAAI,uBAAuB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAAA,IACxF;AAEA,WAAO;AAAA,EACT;AACF;;;AEzZA,IAAAA,iBAA0C;;;ACoDnC,IAAM,WAAN,cAAuB,MAAM;AAAA,EAKlC,YAAY,MAAoB,aAAqB,OAAiB;AACpE,UAAM,aAAa,IAAI,KAAK,WAAW,EAAE;AACzC,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,cAAc;AACnB,SAAK,QAAQ;AAAA,EACf;AACF;;;AC9EO,SAAS,mBAAmB,KAAa,eAAgC;AAC9E,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI,IAAI,SAAS,IAAI,EAAG,QAAO;AAC/B,MAAI,IAAI,WAAW,GAAG,KAAK,CAAC,IAAI,WAAW,IAAI,EAAG,QAAO;AACzD,MAAI;AACF,WAAO,IAAI,IAAI,GAAG,EAAE,WAAW;AAAA,EACjC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAMO,SAAS,sBAAsB,GAAW,GAAoB;AACnE,QAAM,KAAK,IAAI,YAAY,EAAE,OAAO,CAAC;AACrC,QAAM,KAAK,IAAI,YAAY,EAAE,OAAO,CAAC;AACrC,MAAI,GAAG,WAAW,KAAK,GAAG,WAAW,EAAG,QAAO,GAAG,WAAW,GAAG;AAChE,QAAM,MAAM,KAAK,IAAI,GAAG,QAAQ,GAAG,MAAM;AACzC,MAAI,OAAO,GAAG,SAAS,GAAG;AAC1B,WAAS,IAAI,GAAG,IAAI,KAAK,KAAK;AAC5B,YAAQ,GAAG,IAAI,GAAG,MAAM,IAAI,GAAG,IAAI,GAAG,MAAM;AAAA,EAC9C;AACA,SAAO,SAAS;AAClB;;;ACxBA,IAAM,UACJ,OAAO,WAAW,eAAgB,OAAkB,SAAU,SAAoB;AAEpF,SAAS,YAA2B;AAClC,SAAQ,WAA6B;AACvC;AAmCA,SAAS,gBAAgB,OAA2B;AAClD,QAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG;AACzD,QAAM,SAAS,OAAO,OAAO,OAAO,UAAW,IAAK,OAAO,SAAS,KAAM,GAAI,GAAG;AACjF,QAAM,SAAS,KAAK,MAAM;AAC1B,QAAM,QAAQ,IAAI,WAAW,OAAO,MAAM;AAC1C,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;AACtC,UAAM,CAAC,IAAI,OAAO,WAAW,CAAC;AAAA,EAChC;AACA,SAAO;AACT;AAGO,SAAS,iBAAiB,OAA+C;AAC9E,MAAI;AACF,UAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,UAAM,OAAO,IAAI,YAAY,EAAE,OAAO,gBAAgB,MAAM,CAAC,CAAC,CAAC;AAC/D,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,SAAS,gBAAgB,OAA+C;AAC7E,MAAI;AACF,UAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,UAAM,OAAO,IAAI,YAAY,EAAE,OAAO,gBAAgB,MAAM,CAAC,CAAC,CAAC;AAC/D,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,SAAS,gBAAgB,KAAqB;AAC5C,SAAO,IAAI,QAAQ,QAAQ,EAAE;AAC/B;AAQA,IAAI,aAAwE;AAC5E,IAAI,kBAAwF;AAC5F,IAAM,oBAAoB,IAAI,KAAK;AAEnC,IAAM,mBAAmB;AAQzB,eAAe,kBAAkB,SAAmD;AAClF,QAAM,MAAM,KAAK,IAAI;AACrB,MAAI,mBAAmB,gBAAgB,YAAY,WAAW,MAAM,gBAAgB,YAAY,mBAAmB;AACjH,WAAO,gBAAgB;AAAA,EACzB;AAEA,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,GAAG,OAAO,+CAA+C;AAAA,MAC/E,QAAQ,YAAY,QAAQ,gBAAgB;AAAA,IAC9C,CAAC;AACD,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,UAAM,MAAO,MAAM,IAAI,KAAK;AAC5B,sBAAkB,EAAE,SAAS,KAAK,WAAW,IAAI;AACjD,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAWA,eAAsB,UAAU,SAAiB,UAA4B,CAAC,GAAyB;AACrG,QAAM,MAAM,KAAK,IAAI;AACrB,MAAI,CAAC,QAAQ,gBAAgB,cAAc,WAAW,YAAY,WAAW,MAAM,WAAW,YAAY,mBAAmB;AAC3H,WAAO,WAAW;AAAA,EACpB;AAEA,QAAM,YAAY,MAAM,kBAAkB,OAAO;AACjD,QAAM,UAAU,WAAW,YAAY,GAAG,OAAO;AAEjD,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,SAAS;AAAA,MAC/B,QAAQ,YAAY,QAAQ,gBAAgB;AAAA,MAC5C,GAAI,QAAQ,eAAe,EAAE,OAAO,WAA2B,IAAI,CAAC;AAAA,IACtE,CAAC;AACD,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,iBAAa,EAAE,SAAS,MAAM,WAAW,IAAI;AAC7C,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AASA,eAAsB,qBAAqB,OAAe,KAAgC;AACxF,MAAI;AACF,UAAM,CAAC,WAAW,YAAY,SAAS,IAAI,MAAM,MAAM,GAAG;AAC1D,QAAI,CAAC,aAAa,CAAC,IAAI,KAAK,CAAC,IAAI,EAAG,QAAO;AAE3C,UAAM,IAAI,UAAU;AACpB,QAAI,CAAC,EAAG,QAAO;AAEf,UAAM,YAAY,MAAM,EAAE,OAAO;AAAA,MAC/B;AAAA,MACA,EAAE,KAAK,OAAO,GAAG,IAAI,GAAG,GAAG,IAAI,GAAG,KAAK,SAAS,KAAK,MAAM;AAAA,MAC3D,EAAE,MAAM,qBAAqB,MAAM,UAAU;AAAA,MAC7C;AAAA,MACA,CAAC,QAAQ;AAAA,IACX;AAEA,UAAM,iBAAiB,gBAAgB,SAAS;AAChD,UAAM,OAAO,IAAI,YAAY,EAAE,OAAO,GAAG,SAAS,IAAI,UAAU,EAAE;AAClE,WAAO,MAAM,EAAE,OAAO;AAAA,MACpB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,eAAsB,cAAc,aAAsC;AACxE,QAAM,IAAI,UAAU;AACpB,MAAI,CAAC,EAAG,QAAO;AACf,QAAM,OAAO,MAAM,EAAE,OAAO,OAAO,WAAW,IAAI,YAAY,EAAE,OAAO,WAAW,CAAC;AACnF,QAAM,QAAQ,IAAI,WAAW,IAAI;AACjC,QAAM,OAAO,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC;AAC5C,MAAI,SAAS;AACb,aAAW,KAAK,MAAM;AACpB,cAAU,OAAO,aAAa,CAAC;AAAA,EACjC;AACA,SAAO,KAAK,MAAM,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,GAAG,EAAE,QAAQ,OAAO,EAAE;AAC/E;AAGA,eAAsB,gBACpB,SACA,aACA,gBACA,kBACA,UAAkC,CAAC,GACH;AAChC,QAAM,EAAE,gCAAgC,MAAM,cAAc,IAAI;AAEhE,QAAM,SAAS,gBAAgB,OAAO;AACtC,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,SAAS,oBAAoB,mCAAe;AAAA,EACxD;AAEA,QAAM,MAAM,OAAO;AACnB,MAAI,OAAO,QAAQ,YAAa,QAAQ,WAAW,QAAQ,SAAU;AACnE,UAAM,IAAI,SAAS,4BAA4B,+DAAuB,GAAG,EAAE;AAAA,EAC7E;AAEA,QAAM,UAAU,gBAAgB,cAAc;AAE9C,QAAM,YAAY,MAAM,kBAAkB,OAAO;AACjD,QAAM,mBAAmB,gBAAgB,WAAW,UAAU,OAAO;AAGrE,MAAI,QAAQ,SAAS;AACnB,UAAM,OAAO,MAAM,UAAU,OAAO;AACpC,QAAI,CAAC,MAAM;AACT,YAAM,IAAI,SAAS,8BAA8B,kEAA0B;AAAA,IAC7E;AACA,UAAM,MAAM,OAAO,OAAO,QAAQ,WAAW,OAAO,MAAM;AAE1D,UAAM,kBAAkB,CAAC,QACvB,IAAI,KAAK;AAAA,MACP,CAAC,MACC,EAAE,QAAQ,SACV,EAAE,QAAQ,WACV,EAAE,QAAQ,UACT,MAAM,EAAE,QAAQ,MAAM;AAAA,IAC3B;AACF,UAAM,YAAY,OAAO,SAAsC;AAC7D,iBAAW,OAAO,MAAM;AACtB,YAAI,MAAM,qBAAqB,SAAS,GAAG,EAAG,QAAO;AAAA,MACvD;AACA,aAAO;AAAA,IACT;AAEA,QAAI,aAAa,gBAAgB,IAAI;AACrC,QAAI,WAAW,WAAW,SAAS,IAAI,MAAM,UAAU,UAAU,IAAI;AAIrE,QAAI,WAAW,WAAW,KAAK,CAAC,UAAU;AACxC,YAAM,YAAY,MAAM,UAAU,SAAS,EAAE,cAAc,KAAK,CAAC;AACjE,UAAI,WAAW;AACb,qBAAa,gBAAgB,SAAS;AACtC,mBAAW,WAAW,SAAS,IAAI,MAAM,UAAU,UAAU,IAAI;AAAA,MACnE;AAAA,IACF;AAEA,QAAI,WAAW,WAAW,GAAG;AAC3B,YAAM,IAAI,SAAS,8BAA8B,oEAAuB;AAAA,IAC1E;AACA,QAAI,CAAC,UAAU;AACb,YAAM,IAAI,SAAS,8BAA8B,+CAAiB;AAAA,IACpE;AAAA,EACF;AAIA,MAAI,QAAQ,SAAS;AACnB,QAAI,+BAA+B;AACjC,YAAM,OAAO,MAAM,UAAU,OAAO;AACpC,YAAM,WAAW,MAAM,MAAM,KAAK,CAAC,MAAM,EAAE,QAAQ,WAAW,EAAE,QAAQ,KAAK;AAC7E,UAAI,UAAU;AACZ,cAAM,IAAI,SAAS,4BAA4B,2EAAmC;AAAA,MACpF;AAAA,IACF;AACA,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IAEF;AAAA,EACF;AAEA,QAAM,UAAU,iBAAiB,OAAO;AACxC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,SAAS,oBAAoB,2CAAuB;AAAA,EAChE;AAEA,QAAM,cAAc,OAAO,QAAQ,QAAQ,WAAW,gBAAgB,QAAQ,GAAG,IAAI;AACrF,MAAI,gBAAgB,kBAAkB;AACpC,UAAM,IAAI,SAAS,4BAA4B,oCAAqB;AAAA,EACtE;AAGA,QAAM,MAAM,QAAQ;AACpB,QAAM,UAAU,MAAM,QAAQ,GAAG,IAAI,MAAM,OAAO,QAAQ,WAAW,CAAC,GAAG,IAAI,CAAC;AAC9E,MAAI,CAAC,QAAQ,SAAS,gBAAgB,GAAG;AACvC,UAAM,IAAI,SAAS,8BAA8B,sCAAuB;AAAA,EAC1E;AACA,MAAI,QAAQ,SAAS,KAAK,QAAQ,QAAQ,kBAAkB;AAC1D,UAAM,IAAI,SAAS,8BAA8B,2EAAwC;AAAA,EAC3F;AAGA,MAAI,OAAO,QAAQ,QAAQ,UAAU;AACnC,UAAM,IAAI,SAAS,oBAAoB,wCAAoB;AAAA,EAC7D;AACA,MAAI,KAAK,IAAI,KAAK,QAAQ,MAAM,MAAO,KAAQ;AAC7C,UAAM,IAAI,SAAS,oBAAoB,6BAAc;AAAA,EACvD;AAGA,MAAI,OAAO,QAAQ,QAAQ,YAAY,QAAQ,MAAM,MAAO,KAAK,IAAI,IAAI,KAAQ;AAC/E,UAAM,IAAI,SAAS,oBAAoB,6FAA4B;AAAA,EACrE;AAEA,MAAI,OAAO,QAAQ,QAAQ,YAAY,CAAC,QAAQ,KAAK;AACnD,UAAM,IAAI,SAAS,wBAAwB,2BAAiB;AAAA,EAC9D;AAGA,MAAI,OAAO,QAAQ,YAAY,YAAY,QAAQ,SAAS;AAC1D,UAAM,SAAS,MAAM,cAAc,WAAW;AAC9C,QAAI,CAAC,sBAAsB,QAAQ,QAAQ,OAAO,GAAG;AACnD,YAAM,IAAI,SAAS,6BAA6B,qCAAsB;AAAA,IACxE;AAAA,EACF;AAIA,MAAI,kBAAkB,QAAW;AAC/B,UAAM,aAAa,OAAO,QAAQ,UAAU,WAAW,QAAQ,QAAQ;AACvE,QAAI,CAAC,cAAc,CAAC,sBAAsB,eAAe,UAAU,GAAG;AACpE,YAAM,IAAI,SAAS,2BAA2B,mCAAoB;AAAA,IACpE;AAAA,EACF;AAEA,SAAO,EAAE,KAAK,QAAQ,IAAI;AAC5B;;;AH7OO,SAAS,2BAA2B,QAA6B;AACtE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,oBAAoB;AAAA,IACpB,kBAAkB,sBAAsB;AAAA,EAC1C,IAAI;AAGJ,QAAM,mBAAmB,wBAAwB,qBAAqB,UAAU;AAGhF,QAAM,gBAAgB,CAAC;AACvB,QAAM,WAAW,CAAC,UAA8B,aAC9C,mBAAmB,qBAAqB,YAAY,QAAQ,IAAI,YAAY;AAC9E,QAAM,wBAAwB,SAAS,OAAO,uBAAuB,gCAAgC;AACrG,QAAM,yBAAyB,SAAS,OAAO,wBAAwB,iCAAiC;AACxG,QAAM,oBAAoB,SAAS,OAAO,mBAAmB,4BAA4B;AACzF,QAAM,kBAAkB,SAAS,OAAO,iBAAiB,yBAAyB;AAClF,QAAM,kBAAkB,SAAS,OAAO,iBAAiB,yBAAyB;AAClF,QAAM,sBAAsB,SAAS,OAAO,qBAAqB,0BAA0B;AAC3F,QAAM,qBAAqB,SAAS,OAAO,oBAAoB,4BAA4B;AAE3F,QAAM,iBAAiB,WAAW,QAAQ,QAAQ,EAAE;AAEpD,QAAM,wBAAwB,OAAO,iBAAiB,YAAY,QAAQ,QAAQ,EAAE;AAEpF,SAAO,eAAe,IAAI,SAAsB;AAC9C,UAAM,WAAW,MAAM,eAAe,OAAO;AAG7C,QAAI,SAAS,UAAU,KAAK;AAC1B,eAAS,QAAQ,IAAI,iBAAiB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAAA,IAClF;AACA,WAAO;AAAA,EACT;AAEA,iBAAe,eAAe,SAA6C;AACzE,UAAM,EAAE,aAAa,IAAI,QAAQ;AAGjC,UAAM,QAAQ,aAAa,IAAI,OAAO;AACtC,QAAI,OAAO;AACT,YAAM,OAAO,aAAa,IAAI,mBAAmB,KAAK;AACtD,aAAO,4BAAa;AAAA,QAClB,EAAE,OAAO,wBAAwB,mBAAmB,KAAK;AAAA,QACzD,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAEA,UAAM,OAAO,aAAa,IAAI,MAAM;AACpC,UAAM,gBAAgB,aAAa,IAAI,OAAO;AAE9C,QAAI,CAAC,MAAM;AACT,aAAO,4BAAa;AAAA,QAClB;AAAA,UACE,OAAO;AAAA,UACP,mBAAmB;AAAA,QACrB;AAAA,QACA,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAGA,UAAM,aAAa,QAAQ,QAAQ,IAAI,eAAe,GAAG;AACzD,QAAI,CAAC,YAAY;AACf,aAAO,4BAAa;AAAA,QAClB;AAAA,UACE,OAAO;AAAA,UACP,mBAAmB;AAAA,QACrB;AAAA,QACA,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAEA,QAAI,CAAC,sBAAsB,iBAAiB,IAAI,UAAU,GAAG;AAC3D,aAAO,4BAAa;AAAA,QAClB;AAAA,UACE,OAAO;AAAA,UACP,mBAAmB;AAAA,QACrB;AAAA,QACA,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAIA,UAAM,gBAAgB,QAAQ,QAAQ,IAAI,eAAe,GAAG;AAG5D,UAAM,WAAW,QAAQ,QAAQ,IAAI,kBAAkB,GAAG;AAC1D,QAAI,CAAC,UAAU;AACb,aAAO,4BAAa;AAAA,QAClB;AAAA,UACE,OAAO;AAAA,UACP,mBAAmB;AAAA,QACrB;AAAA,QACA,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAGA,UAAM,gBAAgB,GAAG,oBAAoB;AAC7C,UAAM,OAAO,IAAI,gBAAgB;AACjC,SAAK,IAAI,cAAc,oBAAoB;AAC3C,SAAK,IAAI,QAAQ,IAAI;AACrB,SAAK,IAAI,aAAa,QAAQ;AAC9B,SAAK,IAAI,gBAAgB,WAAW;AACpC,SAAK,IAAI,iBAAiB,QAAQ;AAClC,QAAI,cAAc;AAChB,WAAK,IAAI,iBAAiB,YAAY;AAAA,IACxC;AAGA,QAAI,MAAuB;AAC3B,QAAI;AACJ,UAAM,aAAa;AAEnB,aAAS,UAAU,GAAG,WAAW,YAAY,WAAW;AACtD,UAAI,UAAU,GAAG;AAEf,cAAM,IAAI,QAAQ,CAAC,MAAM,WAAW,GAAG,MAAO,KAAK,IAAI,GAAG,UAAU,CAAC,CAAC,CAAC;AAAA,MACzE;AACA,UAAI;AACF,cAAM,MAAM,MAAM,eAAe;AAAA,UAC/B,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,UAC/D,MAAM,KAAK,SAAS;AAAA,QACtB,CAAC;AACD,oBAAY;AACZ;AAAA,MACF,SAAS,KAAK;AACZ,oBAAY;AACZ,YAAI,WAAW,YAAY;AACzB,iBAAO,4BAAa;AAAA,YAClB,EAAE,OAAO,gBAAgB,mBAAmB,iFAAqB;AAAA,YACjE,EAAE,QAAQ,IAAI;AAAA,UAChB;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,QAAI,aAAa,CAAC,KAAK;AACrB,aAAO,4BAAa;AAAA,QAClB,EAAE,OAAO,gBAAgB,mBAAmB,iCAAa;AAAA,QACzD,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAEA,QAAI,CAAC,IAAI,IAAI;AACX,UAAI,UAAmC,CAAC;AACxC,UAAI;AACF,kBAAU,MAAM,IAAI,KAAK;AAAA,MAC3B,QAAQ;AAAA,MAAe;AACvB,aAAO,4BAAa;AAAA,QAClB;AAAA,UACE,OAAO;AAAA,UACP,mBACG,QAAQ,qBAAgC,wCAAoB,IAAI,MAAM;AAAA,QAC3E;AAAA,QACA,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAEA,UAAM,YAMF,MAAM,IAAI,KAAK;AAGnB,QAAI,CAAC,UAAU,gBAAgB,CAAC,UAAU,eAAe;AACvD,aAAO,4BAAa;AAAA,QAClB;AAAA,UACE,OAAO;AAAA,UACP,mBAAmB;AAAA,QACrB;AAAA,QACA,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAGA,QAAI,UAAU,UAAU;AACtB,UAAI;AACF,cAAM;AAAA,UACJ,UAAU;AAAA,UACV,UAAU;AAAA,UACV;AAAA,UACA;AAAA,UACA,EAAE,cAAc;AAAA,QAClB;AAAA,MACF,SAAS,KAAK;AACZ,eAAO,4BAAa;AAAA,UAClB;AAAA,YACE,OAAO;AAAA,YACP,mBAAmB,eAAe,QAAQ,IAAI,UAAU;AAAA,UAC1D;AAAA,UACA,EAAE,QAAQ,IAAI;AAAA,QAChB;AAAA,MACF;AAAA,IACF;AAMA,UAAM,iBAAiB,IAAI,IAAI,WAAW,EAAE;AAC5C,UAAM,eACJ,QAAQ,QAAQ,IAAI,mBAAmB,GAAG,SAAS;AACrD,UAAM,YAAY,mBAAmB,cAAc,cAAc,IAC7D,eACA;AAGJ,UAAM,WAAW,4BAAa,SAAS,IAAI,IAAI,WAAW,cAAc,CAAC;AAGzE,aAAS,QAAQ,IAAI,uBAAuB,UAAU,cAAc;AAAA,MAClE,GAAG,qBAAqB,UAAU,YAAY,aAAa;AAAA,IAC7D,CAAC;AAKD,UAAM,gBACH,UAAsC,sBAAsB,OACxD,UAAsC,qBACvC,KAAK,KAAK,KAAK;AAErB,aAAS,QAAQ,IAAI,wBAAwB,UAAU,eAAe;AAAA,MACpE,GAAG,qBAAqB,eAAe,aAAa;AAAA,IACtD,CAAC;AAID,QAAI,UAAU,UAAU;AACtB,UAAI,gBAAgB;AACpB,UAAI;AACF,cAAM,QAAQ,UAAU,SAAS,MAAM,GAAG;AAC1C,YAAI,MAAM,WAAW,GAAG;AACtB,gBAAM,UAAU,KAAK;AAAA,YACnB,OAAO,KAAK,MAAM,CAAC,EAAE,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,GAAG,QAAQ,EAAE,SAAS,MAAM;AAAA,UACvF;AACA,cAAI,QAAQ,OAAO,OAAO,QAAQ,QAAQ,UAAU;AAElD,4BAAgB,QAAQ,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAAA,UAC5D;AAAA,QACF;AAAA,MACF,QAAQ;AAAA,MAER;AACA,UAAI,gBAAgB,GAAG;AACrB,iBAAS,QAAQ,IAAI,mBAAmB,UAAU,UAAU;AAAA,UAC1D,GAAG,qBAAqB,eAAe,aAAa;AAAA,QACtD,CAAC;AAAA,MACH;AAAA,IACF;AAGA,aAAS,QAAQ,IAAI,iBAAiB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAChF,aAAS,QAAQ,IAAI,iBAAiB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAChF,aAAS,QAAQ,IAAI,qBAAqB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAKpF,aAAS,QAAQ,IAAI,oBAAoB,IAAI,uBAAuB,GAAG,KAAK,aAAa,CAAC;AAC1F,aAAS,QAAQ,IAAI,oBAAoB,IAAI,uBAAuB,GAAG,QAAQ,QAAQ,UAAU,aAAa,CAAC;AAE/G,WAAO;AAAA,EACT;AACF;;;AI3WA,IAAAC,iBAA0C;;;ACJ1C,IAAM,yBAAyB,IAAI,KAAK;AACxC,IAAMC,oBAAmB;AAEzB,IAAM,QAAQ,oBAAI,IAA4D;AAC9E,IAAM,WAAW,oBAAI,IAAgD;AAE9D,SAAS,qBAAqB,SAAqD;AACxF,QAAM,MAAM,MAAM,IAAI,OAAO;AAC7B,MAAI,OAAO,IAAI,YAAY,KAAK,IAAI,GAAG;AACrC,WAAO,QAAQ,QAAQ,IAAI,GAAG;AAAA,EAChC;AAEA,QAAM,WAAW,SAAS,IAAI,OAAO;AACrC,MAAI,SAAU,QAAO;AAErB,QAAM,WAAW,YAAY;AAC3B,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAGA,iBAAgB;AACvE,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,GAAG,OAAO,+CAA+C;AAAA,QAC/E,QAAQ,WAAW;AAAA,MACrB,CAAC;AACD,UAAI,CAAC,IAAI,GAAI,QAAO;AACpB,YAAM,MAAO,MAAM,IAAI,KAAK;AAE5B,YAAM,IAAI,SAAS,EAAE,KAAK,WAAW,KAAK,IAAI,IAAI,uBAAuB,CAAC;AAC1E,aAAO;AAAA,IACT,QAAQ;AACN,aAAO;AAAA,IACT,UAAE;AACA,mBAAa,SAAS;AACtB,eAAS,OAAO,OAAO;AAAA,IACzB;AAAA,EACF,GAAG;AAEH,WAAS,IAAI,SAAS,OAAO;AAC7B,SAAO;AACT;;;AD2DA,SAAS,yBAAiC;AACxC,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAeT;AAGA,SAASC,sBAAqB,QAAwB;AACpD,QAAM,QACJ;AACF,QAAM,WAAW,KAAK,MAAM,MAAM,MAAM,MAAM,IAAI,MAAM;AACxD,MAAI,SAAS;AACb,SAAO,OAAO,SAAS,QAAQ;AAC7B,UAAM,QAAQ,IAAI,WAAW,SAAS,CAAC;AACvC,WAAO,gBAAgB,KAAK;AAC5B,aAAS,IAAI,GAAG,IAAI,MAAM,UAAU,OAAO,SAAS,QAAQ,KAAK;AAC/D,UAAI,MAAM,CAAC,KAAK,SAAU;AAC1B,gBAAU,MAAM,MAAM,CAAC,IAAI,MAAM,MAAM;AAAA,IACzC;AAAA,EACF;AACA,SAAO;AACT;AAMO,SAAS,yBAAyB,QAA2B;AAClE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,wBAAwB,IAAI,IAAI,WAAW,EAAE,SAAS;AAAA,IACtD,gBAAgB;AAAA,IAChB,eAAe;AAAA,IACf,kBAAkB,sBAAsB;AAAA,EAC1C,IAAI;AAGJ,QAAM,mBAAmB,wBAAwB,qBAAqB,UAAU;AAGhF,QAAM,gBAAgB,CAAC;AACvB,QAAM,WAAW,CAAC,UAA8B,aAC9C,mBAAmB,qBAAqB,YAAY,QAAQ,IAAI,YAAY;AAC9E,QAAM,wBAAwB,SAAS,OAAO,uBAAuB,gCAAgC;AACrG,QAAM,yBAAyB,SAAS,OAAO,wBAAwB,iCAAiC;AACxG,QAAM,oBAAoB,SAAS,OAAO,mBAAmB,4BAA4B;AACzF,QAAM,kBAAkB,SAAS,OAAO,iBAAiB,yBAAyB;AAClF,QAAM,kBAAkB,SAAS,OAAO,iBAAiB,yBAAyB;AAClF,QAAM,sBAAsB,SAAS,OAAO,qBAAqB,0BAA0B;AAC3F,QAAM,qBAAqB,SAAS,OAAO,oBAAoB,4BAA4B;AAC3F,QAAM,wBAAwB,SAAS,OAAO,uBAAuB,gCAAgC;AAErG,QAAM,iBAAiB,WAAW,QAAQ,QAAQ,EAAE;AAGpD,QAAM,wBAAwB,OAAO,iBAAiB,YAAY,QAAQ,QAAQ,EAAE;AAIpF,QAAM,iBAAiB,IAAI,IAAI,WAAW,EAAE;AAa5C,SAAO,eAAe,QAAQ,SAAsB;AAElD,UAAM,gBAAgB,QAAQ,QAAQ,aAAa,IAAI,OAAO;AAC9D,QAAI,eAAe;AACjB,YAAM,aAAa,QAAQ,QAAQ,IAAI,qBAAqB,GAAG;AAC/D,UAAI,CAAC,cAAc,eAAe,eAAe;AAC/C,eAAO,4BAAa;AAAA,UAClB,EAAE,OAAO,mBAAmB,mBAAmB,kCAAmB;AAAA,UAClE,EAAE,QAAQ,IAAI;AAAA,QAChB;AAAA,MACF;AACA,YAAM,MAAM,4BAAa,SAAS,iBAAiB,GAAG;AACtD,UAAI,QAAQ,IAAI,uBAAuB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AACjF,aAAO;AAAA,IACT;AAIA,QAAI,QAAQ,WAAW,OAAO;AAC5B,aAAO,IAAI,4BAAa,uBAAuB,GAAG;AAAA,QAChD,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,2BAA2B;AAAA,MACxD,CAAC;AAAA,IACH;AAEA,UAAM,eAAe,QAAQ,QAAQ,IAAI,sBAAsB,GAAG;AAClE,UAAM,cAAc,QAAQ,QAAQ,IAAI,iBAAiB,GAAG;AAG5D,QAAI,cAAc;AAChB,UAAI;AAGF,cAAM,YAAY,OAAO,gBACrB,OACA,MAAM,qBAAqB,oBAAoB;AACnD,cAAM,YAAY,OAAO,gBACrB,GAAG,oBAAoB,sBACvB,WAAW,uBAAuB,GAAG,cAAc;AACvD,cAAM,OAAO,IAAI,gBAAgB;AAAA,UAC/B,OAAO;AAAA,UACP,iBAAiB;AAAA,UACjB,WAAW;AAAA,QACb,CAAC;AACD,YAAI,cAAc;AAChB,eAAK,IAAI,iBAAiB,YAAY;AAAA,QACxC;AAEA,cAAM,MAAM,WAAW;AAAA,UACrB,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,UAC/D,MAAM,KAAK,SAAS;AAAA,UACpB,QAAQ,YAAY,QAAQ,GAAI;AAAA,QAClC,CAAC;AAAA,MACH,QAAQ;AAAA,MAER;AAAA,IACF;AAGA,UAAM,eAAe,CAAC,QAAsB;AAC1C,UAAI,QAAQ,IAAI,uBAAuB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AACjF,UAAI,QAAQ,IAAI,wBAAwB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAClF,UAAI,QAAQ,IAAI,mBAAmB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAC7E,UAAI,QAAQ,IAAI,iBAAiB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAC3E,UAAI,QAAQ,IAAI,iBAAiB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAC3E,UAAI,QAAQ,IAAI,qBAAqB,IAAI,qBAAqB,GAAG,aAAa,CAAC;AAC/E,UAAI,QAAQ,IAAI,oBAAoB,IAAI,uBAAuB,GAAG,KAAK,aAAa,CAAC;AACrF,UAAI,QAAQ,IAAI,oBAAoB,IAAI,uBAAuB,GAAG,cAAc,aAAa,CAAC;AAC9F,aAAO;AAAA,IACT;AAGA,QAAI,eAAe;AAGjB,YAAM,YAAY,MAAM,qBAAqB,oBAAoB;AACjE,YAAM,qBACJ,WAAW,wBAAwB,GAAG,cAAc;AACtD,YAAM,YAAY,IAAI,IAAI,kBAAkB;AAC5C,gBAAU,aAAa,IAAI,aAAa,QAAQ;AAChD,gBAAU,aAAa;AAAA,QACrB;AAAA,QACA;AAAA,MACF;AACA,UAAI,aAAa;AACf,kBAAU,aAAa,IAAI,iBAAiB,WAAW;AAAA,MACzD;AAEA,YAAM,cAAcA,sBAAqB,EAAE;AAC3C,gBAAU,aAAa,IAAI,SAAS,WAAW;AAC/C,YAAM,MAAM,aAAa,4BAAa,SAAS,UAAU,SAAS,CAAC,CAAC;AACpE,UAAI,QAAQ,IAAI,uBAAuB,aAAa,qBAAqB,KAAK,aAAa,CAAC;AAC5F,aAAO;AAAA,IACT;AAEA,WAAO;AAAA,MACL,4BAAa,SAAS,iBAAiB,GAAG;AAAA,IAC5C;AAAA,EACF;AACF;","names":["import_server","import_server","FETCH_TIMEOUT_MS","generateRandomString"]}
|
|
1
|
+
{"version":3,"sources":["../../src/next/index.ts","../../src/next/middleware.ts","../../src/next/constants.ts","../../src/next/callback.ts","../../src/core/errors.ts","../../src/core/security.ts","../../src/core/id-token.ts","../../src/next/logout.ts","../../src/core/discovery.ts","../../src/next/backchannel-logout.ts","../../src/core/logout-token.ts"],"sourcesContent":["/**\n * @nihplod/sso-sdk/next\n *\n * Next.js App Router 绑定:\n * - createSsoMiddleware: Next.js Middleware 辅助函数\n * - createCallbackRouteHandler: App Router 回调 Route Handler\n * - createLogoutRouteHandler: App Router 登出 Route Handler\n * - createBackchannelLogoutRouteHandler: App Router Backchannel Logout 接收端\n */\n\nexport { createSsoMiddleware } from \"./middleware\";\nexport type { SsoMiddlewareConfig } from \"./middleware\";\n\nexport { createCallbackRouteHandler } from \"./callback\";\nexport type { CallbackRouteConfig } from \"./callback\";\n\nexport { createLogoutRouteHandler } from \"./logout\";\nexport type { LogoutRouteConfig } from \"./logout\";\n\nexport { createBackchannelLogoutRouteHandler } from \"./backchannel-logout\";\nexport type { BackchannelLogoutRouteConfig } from \"./backchannel-logout\";\n\nexport {\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\n DEFAULT_ID_TOKEN_COOKIE_NAME,\n DEFAULT_STATE_COOKIE_NAME,\n DEFAULT_NONCE_COOKIE_NAME,\n DEFAULT_RETURN_COOKIE_NAME,\n DEFAULT_VERIFIER_COOKIE_NAME,\n DEFAULT_LOGOUT_STATE_COOKIE_NAME,\n getHostCookieOptions,\n getSecureCookieOptions,\n toInsecureCookieName,\n} from \"./constants\";\n","/**\r\n * Next.js Middleware 辅助函数\r\n *\r\n * 在 Edge Runtime 中运行,自动检测未认证请求并重定向到 SSO 登录页。\r\n *\r\n * ⚠️ Edge Runtime 限制:\r\n * - 不能使用 Node.js crypto 模块\r\n * - PKCE code_challenge 使用 Web Crypto API 的 crypto.subtle.digest(SHA-256)\r\n * (Edge Runtime 18+ 完全支持此 API)\r\n *\r\n * 用法 (src/middleware.ts):\r\n * ```ts\r\n * import { createSsoMiddleware } from \"@nihplod/sso-sdk/next\";\r\n *\r\n * export const middleware = createSsoMiddleware({\r\n * clientId: \"my-app\",\r\n * ssoBaseUrl: \"https://nihplod.cn\",\r\n * redirectUri: \"https://myapp.com/api/auth/callback\",\r\n * publicPaths: [\"/\", \"/public\", \"/api/public\"],\r\n * });\r\n * ```\r\n */\r\n\r\nimport { NextRequest, NextResponse } from \"next/server\";\r\nimport {\r\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\r\n DEFAULT_STATE_COOKIE_NAME,\r\n DEFAULT_NONCE_COOKIE_NAME,\r\n DEFAULT_RETURN_COOKIE_NAME,\r\n DEFAULT_VERIFIER_COOKIE_NAME,\r\n getHostCookieOptions,\r\n getSecureCookieOptions,\r\n resolveInsecureLocalDev,\r\n toInsecureCookieName,\r\n} from \"./constants\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface SsoMiddlewareConfig {\r\n /** OAuth Client ID */\r\n clientId: string;\r\n\r\n /** SSO 中心地址 */\r\n ssoBaseUrl: string;\r\n\r\n /** 回调 URL(须与注册的 redirect_uri 一致) */\r\n redirectUri: string;\r\n\r\n /** 请求的 scope(空格分隔),默认 \"openid profile\" */\r\n scopes?: string;\r\n\r\n /** 公开路由前缀(不需要认证) */\r\n publicPaths?: string[];\r\n\r\n /** 回调路径(不触发重定向),默认 \"/api/auth/callback\" */\r\n callbackPath?: string;\r\n\r\n /**\r\n * 主站用户会话 Cookie 名称,用于检测是否已有 SSO 会话。\r\n * 默认 \"__Host-user_token\"(与主站 C 端登录 Cookie 一致)。\r\n */\r\n ssoCookieName?: string;\r\n\r\n /**\r\n * OAuth Client Secret(Confidential Client 使用)\r\n * 浏览器端 Public Client 应省略。\r\n */\r\n clientSecret?: string;\r\n\r\n /**\r\n * 是否对 ssoCookieName 对应的主站 Cookie 进行 Introspection 二次验证。\r\n * 默认 true(推荐)。设为 false 时仅检查 Cookie 存在性,延迟最低但可能放行\r\n * 已失效/被撤销的会话 —— 中间件本质上只是 UX 层,敏感数据必须在\r\n * Route Handler / Server Component 中二次校验。\r\n */\r\n validateSsoCookie?: boolean;\r\n\r\n /** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */\r\n accessTokenCookieName?: string;\r\n\r\n /** State Cookie 名称,默认 __Host-nihplod_sso_state */\r\n stateCookieName?: string;\r\n\r\n /** OIDC Nonce Cookie 名称,默认 __Host-nihplod_sso_nonce */\r\n nonceCookieName?: string;\r\n\r\n /** Return URL Cookie 名称,默认 __Host-nihplod_sso_return */\r\n returnUrlCookieName?: string;\r\n\r\n /** PKCE Verifier Cookie 名称,默认 __Secure-nihplod_sso_verifier */\r\n verifierCookieName?: string;\r\n\r\n /**\r\n * 本地 HTTP 开发模式(默认 false)。\r\n *\r\n * ⚠️ 仅限 http://localhost 开发:关闭 Cookie 的 Secure 属性并去除\r\n * __Host-/__Secure- 前缀(浏览器拒绝在 HTTP 下写入带这两个前缀的 Cookie,\r\n * 否则会出现「登录后 cookie 写不进去 → middleware 永远判定未登录 →\r\n * 反复跳 SSO」的无限重定向)。开启时启动告警;生产环境严禁启用——\r\n * 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下该开关会被\r\n * 强制忽略并告警,仍走 secure cookie。\r\n * middleware / callback / logout 三处配置需保持一致。\r\n */\r\n insecureLocalDev?: boolean;\r\n\r\n /**\r\n * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。\r\n * 仅用于 introspect 等服务器间请求;浏览器跳转(authorize)仍使用\r\n * ssoBaseUrl 公网地址。适用于子站与 SSO 中心同机/同内网部署。\r\n */\r\n serverBaseUrl?: string;\r\n}\r\n\r\n// ============================================\r\n// 工具函数\r\n// ============================================\r\n\r\n/**\r\n * 生成安全随机字符串(Edge Runtime 兼容)\r\n * 使用 crypto.getRandomValues(Edge Runtime 支持)\r\n * 拒绝采样法保证 charset 均匀分布\r\n */\r\nfunction generateRandomString(length: number): string {\r\n const chars =\r\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~\";\r\n const maxValid = Math.floor(256 / chars.length) * chars.length;\r\n let result = \"\";\r\n\r\n while (result.length < length) {\r\n const array = new Uint8Array(length * 2);\r\n crypto.getRandomValues(array);\r\n for (let i = 0; i < array.length && result.length < length; i++) {\r\n if (array[i] >= maxValid) continue;\r\n result += chars[array[i] % chars.length];\r\n }\r\n }\r\n return result;\r\n}\r\n\r\n/**\r\n * 计算 PKCE S256 code_challenge(Edge Runtime 兼容)\r\n *\r\n * 使用 Web Crypto API 的 crypto.subtle.digest(\"SHA-256\"),\r\n * Edge Runtime 18+ 完全支持。\r\n */\r\nasync function computeCodeChallenge(verifier: string): Promise<string> {\r\n const encoder = new TextEncoder();\r\n const data = encoder.encode(verifier);\r\n const hash = await crypto.subtle.digest(\"SHA-256\", data);\r\n const bytes = new Uint8Array(hash);\r\n let binary = \"\";\r\n for (let i = 0; i < bytes.byteLength; i++) {\r\n binary += String.fromCharCode(bytes[i]);\r\n }\r\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\r\n}\r\n\r\n/**\r\n * 路径匹配(支持 /:path* 通配符)\r\n */\r\nfunction matchesPath(pathname: string, paths: string[]): boolean {\r\n return paths.some((path) => {\r\n if (pathname === path) return true;\r\n if (path.endsWith(\"/:path*\") && pathname.startsWith(path.replace(\"/:path*\", \"\"))) return true;\r\n if (pathname.startsWith(path + \"/\")) return true;\r\n return false;\r\n });\r\n}\r\n\r\n/**\r\n * 通过主站 Introspection 端点校验 access_token 是否仍有效。\r\n * Confidential Client(BFF)应配置 clientSecret,防止伪造 Cookie 绕过。\r\n *\r\n * 附带进程级缓存(key = token 的 SHA-256 hex + clientId),\r\n * 减少同一用户 session 在短时间内对 SSO 中心的重复 introspection 调用。\r\n * 缓存带 TTL(30s)与容量上限(LRU 淘汰),避免无界增长。\r\n *\r\n * 返回三态以区分两类失败:\r\n * - \"inactive\":token 确证无效(401/403 或 active:false),调用方应重定向登录\r\n * - \"unreachable\":introspect 不可达(网络异常/超时/5xx),结果不确定,\r\n * 由调用方决定 fail-open 或 fail-closed;此结果不缓存,下次请求重试\r\n */\r\nconst introspectionCache = new Map<string, { active: boolean; until: number }>();\r\nconst INTROSPECT_CACHE_TTL_MS = 30_000; // 30 秒\r\nconst INTROSPECT_CACHE_MAX_ENTRIES = 500;\r\nconst INTROSPECT_TIMEOUT_MS = 5_000; // introspect 请求超时,避免 SSO 中心 hang 住 middleware\r\n\r\n/** 计算 token 的 SHA-256 hex(Edge Runtime Web Crypto),用作缓存 key 避免跨用户碰撞 */\r\nasync function introspectCacheKey(token: string, clientId: string): Promise<string> {\r\n const hash = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(token));\r\n const hex = Array.from(new Uint8Array(hash))\r\n .map((b) => b.toString(16).padStart(2, \"0\"))\r\n .join(\"\");\r\n return `${hex}|${clientId}`;\r\n}\r\n\r\n/** 读取缓存(命中时刷新插入顺序,实现 LRU) */\r\nfunction introspectCacheGet(key: string): { active: boolean; until: number } | null {\r\n const entry = introspectionCache.get(key);\r\n if (!entry) return null;\r\n if (entry.until <= Date.now()) {\r\n introspectionCache.delete(key);\r\n return null;\r\n }\r\n // LRU:重新插入到 Map 尾部\r\n introspectionCache.delete(key);\r\n introspectionCache.set(key, entry);\r\n return entry;\r\n}\r\n\r\n/** 写入缓存(先清过期项,再按插入顺序淘汰最旧项) */\r\nfunction introspectCacheSet(key: string, active: boolean): void {\r\n const now = Date.now();\r\n for (const [k, v] of introspectionCache) {\r\n if (v.until <= now) introspectionCache.delete(k);\r\n }\r\n while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {\r\n const oldest = introspectionCache.keys().next();\r\n if (oldest.done) break;\r\n introspectionCache.delete(oldest.value);\r\n }\r\n introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });\r\n}\r\n\r\ntype IntrospectResult = \"active\" | \"inactive\" | \"unreachable\";\r\n\r\nasync function introspectAccessToken(\r\n token: string,\r\n ssoBaseUrl: string,\r\n clientId: string,\r\n clientSecret?: string\r\n): Promise<IntrospectResult> {\r\n const cacheKey = await introspectCacheKey(token, clientId);\r\n const cached = introspectCacheGet(cacheKey);\r\n if (cached) {\r\n return cached.active ? \"active\" : \"inactive\";\r\n }\r\n\r\n try {\r\n const body = new URLSearchParams({\r\n token,\r\n token_type_hint: \"access_token\",\r\n client_id: clientId,\r\n });\r\n if (clientSecret) {\r\n body.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: body.toString(),\r\n signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS),\r\n });\r\n\r\n if (!res.ok) {\r\n if (res.status !== 401 && res.status !== 403) {\r\n // 非认证错误(如 500):introspect 不可达,不缓存,让下次请求重试\r\n return \"unreachable\";\r\n }\r\n // 401/403:token 确证无效,缓存该确定性结论\r\n introspectCacheSet(cacheKey, false);\r\n return \"inactive\";\r\n }\r\n const data = (await res.json()) as { active?: boolean };\r\n const active = data.active === true;\r\n introspectCacheSet(cacheKey, active);\r\n return active ? \"active\" : \"inactive\";\r\n } catch {\r\n // 网络异常/超时:introspect 不可达,不缓存,下次请求会重试 introspection\r\n return \"unreachable\";\r\n }\r\n}\r\n\r\n// ============================================\r\n// Middleware 工厂函数\r\n// ============================================\r\n\r\nexport function createSsoMiddleware(config: SsoMiddlewareConfig) {\r\n const {\r\n clientId,\r\n clientSecret,\r\n ssoBaseUrl,\r\n redirectUri,\r\n scopes = \"openid profile\",\r\n publicPaths = [],\r\n callbackPath = \"/api/auth/callback\",\r\n ssoCookieName = \"__Host-user_token\",\r\n validateSsoCookie = true,\r\n insecureLocalDev: insecureLocalDevOpt = false,\r\n } = config;\r\n\r\n // 生产守卫:NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关(仍走 secure cookie)\r\n const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);\r\n\r\n // insecureLocalDev:HTTP 本地开发下去除 Cookie 前缀(浏览器拒绝无 Secure 的前缀 Cookie)\r\n const secureCookies = !insecureLocalDev;\r\n const accessTokenCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME)\r\n : config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME;\r\n const stateCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME)\r\n : config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME;\r\n const nonceCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.nonceCookieName ?? DEFAULT_NONCE_COOKIE_NAME)\r\n : config.nonceCookieName ?? DEFAULT_NONCE_COOKIE_NAME;\r\n const returnUrlCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME)\r\n : config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME;\r\n const verifierCookieName = insecureLocalDev\r\n ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME)\r\n : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;\r\n\r\n // 规范化 ssoBaseUrl\r\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n // 服务器间调用(introspect)的基准地址:配置了内网地址时走内网;\r\n // authorize 等浏览器跳转仍用 normalizedBase(公网)\r\n const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\\/+$/, \"\");\r\n\r\n // 弱配置告警不区分环境:生产环境风险最高,同样输出(不应静默)\r\n if (!validateSsoCookie) {\r\n console.warn(\r\n \"[SSO SDK] validateSsoCookie=false:中间件仅检查 Cookie 存在性,可能放行已失效的会话。\" +\r\n \"中间件只是 UX 层,敏感数据的鉴权必须在 Route Handler / Server Component 中完成。\"\r\n );\r\n }\r\n if (!clientSecret) {\r\n console.warn(\r\n \"[SSO SDK] 未配置 clientSecret(Public Client 模式):introspection 无客户端认证,\" +\r\n \"中间件判定结果仅作 UX 参考。Confidential Client(BFF)请配置 clientSecret。\"\r\n );\r\n }\r\n\r\n return async function ssoMiddleware(request: NextRequest) {\r\n const { pathname } = request.nextUrl;\r\n\r\n // Exclude Next.js internal routes and static assets\r\n if (\r\n pathname.startsWith(\"/_next/\") ||\r\n pathname.startsWith(\"/favicon.ico\") ||\r\n pathname.match(/\\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)\r\n ) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Callback path: let the callback handler deal with it\r\n if (pathname === callbackPath) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Public paths: no auth required\r\n const allPublicPaths = [callbackPath, ...publicPaths];\r\n if (matchesPath(pathname, allPublicPaths)) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Check for existing SSO session\r\n const ssoSession = request.cookies.get(ssoCookieName);\r\n if (ssoSession?.value) {\r\n // 可选:对主站 Cookie 进行 Introspection 二次验证\r\n if (validateSsoCookie) {\r\n const introspectResult = await introspectAccessToken(\r\n ssoSession.value,\r\n normalizedServerBase,\r\n clientId,\r\n clientSecret\r\n );\r\n if (introspectResult === \"active\") {\r\n return NextResponse.next();\r\n }\r\n // Token 无效(inactive)或 introspect 不可达(unreachable):\r\n // 继续到 access_token cookie 检查或重定向\r\n } else {\r\n return NextResponse.next();\r\n }\r\n }\r\n\r\n // Check for access_token in cookie (set by callback handler)\r\n const accessTokenCookie = request.cookies.get(accessTokenCookieName);\r\n if (accessTokenCookie?.value) {\r\n // 调用 Introspection 精确校验 token 是否仍有效。\r\n // Confidential Client 携带 clientSecret;Public Client 仅传 clientId。\r\n const introspectResult = await introspectAccessToken(\r\n accessTokenCookie.value,\r\n normalizedServerBase,\r\n clientId,\r\n clientSecret\r\n );\r\n\r\n if (introspectResult === \"active\") {\r\n return NextResponse.next();\r\n }\r\n if (introspectResult === \"unreachable\") {\r\n // introspect 不可达(网络异常/超时/5xx):对已持有 SSO access token\r\n // Cookie 的请求 fail-open 放行——middleware 只是 UX 层,Route Handler /\r\n // Server Component 仍会强制鉴权;此时重定向到 SSO 也只会得到同样的故障。\r\n // token 确证无效(inactive)不在此列,仍走下方重定向。\r\n console.warn(\r\n \"[SSO SDK] introspection 不可达(网络异常/超时/5xx),对持有 SSO access token Cookie 的请求 fail-open 放行;敏感数据的鉴权由 Route Handler 兜底。\"\r\n );\r\n return NextResponse.next();\r\n }\r\n // Token 确证无效或已过期:清除 cookie 并继续到 SSO 重定向\r\n }\r\n\r\n // No auth: redirect to SSO\r\n const state = generateRandomString(32);\r\n // OIDC nonce(43 字符 ≈ 260 bit 熵,满足 256-bit 要求):\r\n // 绑定 ID Token 到本次登录会话,回调时 fail-closed 校验,防 ID Token 重放\r\n const nonce = generateRandomString(43);\r\n const verifier = generateRandomString(64);\r\n\r\n // 在 Edge Runtime 中计算 PKCE code_challenge(SHA-256 + base64url)\r\n const challenge = await computeCodeChallenge(verifier);\r\n\r\n const authorizeParams = new URLSearchParams();\r\n authorizeParams.set(\"response_type\", \"code\");\r\n authorizeParams.set(\"client_id\", clientId);\r\n authorizeParams.set(\"redirect_uri\", redirectUri);\r\n authorizeParams.set(\"scope\", scopes);\r\n authorizeParams.set(\"state\", state);\r\n authorizeParams.set(\"nonce\", nonce);\r\n authorizeParams.set(\"code_challenge\", challenge);\r\n authorizeParams.set(\"code_challenge_method\", \"S256\");\r\n\r\n const loginUrl = new URL(\"/api/oauth/authorize\", normalizedBase);\r\n loginUrl.search = authorizeParams.toString();\r\n\r\n const response = NextResponse.redirect(loginUrl);\r\n\r\n // Set state cookie for CSRF verification on callback\r\n response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));\r\n\r\n // Set nonce cookie(与 state 同规格的 __Host- httpOnly cookie,供 callback 校验 ID Token)\r\n response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));\r\n\r\n // Set PKCE verifier cookie(httpOnly,供 callback handler 使用)\r\n // 使用 __Secure- 前缀,允许写入 callbackPath\r\n response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));\r\n\r\n // Set return URL cookie(仅存储 pathname + search,截断至安全长度)\r\n const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);\r\n response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));\r\n\r\n // 如果存在过期的 access_token cookie,立即清除\r\n if (accessTokenCookie?.value) {\r\n response.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n }\r\n\r\n return response;\r\n };\r\n}\r\n","/**\n * Next.js 集成默认 Cookie 名称\n *\n * 除 verifier 外统一使用 __Host- 前缀,要求:\n * - Secure\n * - Path=/\n * - 无 Domain 属性\n *\n * verifier 需要写入回调路径(非 /),因此使用 __Secure- 前缀。\n */\n\nexport const DEFAULT_ACCESS_TOKEN_COOKIE_NAME = \"__Host-nihplod_sso_at\";\nexport const DEFAULT_REFRESH_TOKEN_COOKIE_NAME = \"__Host-nihplod_sso_rt\";\nexport const DEFAULT_ID_TOKEN_COOKIE_NAME = \"__Host-nihplod_sso_id\";\nexport const DEFAULT_STATE_COOKIE_NAME = \"__Host-nihplod_sso_state\";\n// OIDC nonce cookie(ID Token 重放防护,与 state 同规格的 __Host- httpOnly cookie)\nexport const DEFAULT_NONCE_COOKIE_NAME = \"__Host-nihplod_sso_nonce\";\nexport const DEFAULT_RETURN_COOKIE_NAME = \"__Host-nihplod_sso_return\";\n// Verifier 需要写入回调路径(非 /),因此使用 __Secure- 前缀而非 __Host-\nexport const DEFAULT_VERIFIER_COOKIE_NAME = \"__Secure-nihplod_sso_verifier\";\n// RP-Initiated Logout 的 state cookie(登出 CSRF 防护)\nexport const DEFAULT_LOGOUT_STATE_COOKIE_NAME = \"__Host-nihplod_sso_logout_state\";\n\n/**\n * insecureLocalDev 场景:去除 __Host-/__Secure- 前缀。\n * 浏览器强制要求带这两个前缀的 Cookie 必须设置 Secure,\n * HTTP 本地开发时若保留前缀,即使 secure=false 也会被拒绝写入。\n */\nexport function toInsecureCookieName(name: string): string {\n return name.replace(/^__(Host|Secure)-/, \"\");\n}\n\n/**\n * 解析 insecureLocalDev 开关并输出告警(middleware / callback / logout 三处共用,保证行为一致)。\n *\n * 生产守卫:当 NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关,\n * Cookie 仍使用 Secure 属性并保留 __Host-/__Secure- 前缀,\n * 避免误配置把生产环境的 Cookie 降级为 HTTP 可写。\n *\n * 返回值为实际生效的 insecureLocalDev 状态。\n */\nexport function resolveInsecureLocalDev(enabled: boolean, ssoBaseUrl: string): boolean {\n if (!enabled) return false;\n if (process.env.NODE_ENV === \"production\" && /^https:\\/\\//i.test(ssoBaseUrl)) {\n console.warn(\n \"[SSO SDK] insecureLocalDev=true 已被忽略:当前为生产环境(NODE_ENV=production)且 ssoBaseUrl 使用 HTTPS,\" +\n \"Cookie 仍保持 Secure 属性与 __Host-/__Secure- 前缀。请从生产配置中移除该开关。\"\n );\n return false;\n }\n console.warn(\n \"[SSO SDK] insecureLocalDev=true:Cookie 的 Secure 属性已关闭且去除 __Host-/__Secure- 前缀。\" +\n \"仅限 http://localhost 本地开发使用,生产环境必须移除该配置(生产必须用 HTTPS)。\"\n );\n return true;\n}\n\n/** __Host- 前缀 Cookie 的安全选项(Path 必须为 /) */\nexport function getHostCookieOptions(\n maxAge?: number,\n secure: boolean = true\n): {\n httpOnly: true;\n secure: boolean;\n sameSite: \"lax\";\n path: \"/\";\n maxAge?: number;\n} {\n return {\n httpOnly: true,\n secure,\n sameSite: \"lax\",\n path: \"/\",\n ...(maxAge !== undefined ? { maxAge } : {}),\n };\n}\n\n/** __Secure- 前缀 Cookie 的安全选项(允许自定义 Path) */\nexport function getSecureCookieOptions(\n maxAge?: number,\n path = \"/\",\n secure: boolean = true\n): {\n httpOnly: true;\n secure: boolean;\n sameSite: \"lax\";\n path: string;\n maxAge?: number;\n} {\n return {\n httpOnly: true,\n secure,\n sameSite: \"lax\",\n path,\n ...(maxAge !== undefined ? { maxAge } : {}),\n };\n}\n","/**\r\n * App Router 回调 Route Handler\r\n *\r\n * 在 /api/auth/callback 路由中处理 OAuth 回调:\r\n * 1. 验证 state 参数\r\n * 2. 用授权码交换 token\r\n * 3. 设置 session cookie\r\n * 4. 重定向到原始页面\r\n *\r\n * ⚠️ 此 handler 仅运行在 Node.js Runtime(非 Edge),可以使用 crypto.subtle。\r\n *\r\n * 用法 (src/app/api/auth/callback/route.ts):\r\n * ```ts\r\n * import { createCallbackRouteHandler } from \"@nihplod/sso-sdk/next\";\r\n *\r\n * export const GET = createCallbackRouteHandler({\r\n * clientId: \"my-app\",\r\n * ssoBaseUrl: \"https://nihplod.cn\",\r\n * redirectUri: \"https://myapp.com/api/auth/callback\",\r\n * tokenCookieName: \"nihplod_sso_at\",\r\n * });\r\n * ```\r\n */\r\n\r\nimport { NextRequest, NextResponse } from \"next/server\";\r\nimport { validateIdToken } from \"../core/id-token\";\r\nimport { isTrustedReturnUrl, timingSafeEqualString } from \"../core/security\";\r\nimport {\r\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\r\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\r\n DEFAULT_ID_TOKEN_COOKIE_NAME,\r\n DEFAULT_STATE_COOKIE_NAME,\r\n DEFAULT_NONCE_COOKIE_NAME,\r\n DEFAULT_RETURN_COOKIE_NAME,\r\n DEFAULT_VERIFIER_COOKIE_NAME,\r\n getHostCookieOptions,\r\n getSecureCookieOptions,\r\n resolveInsecureLocalDev,\r\n toInsecureCookieName,\r\n} from \"./constants\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface CallbackRouteConfig {\r\n /** OAuth Client ID */\r\n clientId: string;\r\n\r\n /** SSO 中心地址 */\r\n ssoBaseUrl: string;\r\n\r\n /** 回调 URL(须与注册的 redirect_uri 一致) */\r\n redirectUri: string;\r\n\r\n /**\r\n * OAuth Client Secret(可选)。\r\n * 对于 Confidential Client(BFF/Next.js),应传入 clientSecret\r\n * 以提供第二因素认证。对于 Public Client(SPA),应省略此字段。\r\n */\r\n clientSecret?: string;\r\n\r\n /** 成功回调后重定向的默认路径,默认 \"/\" */\r\n defaultReturnPath?: string;\r\n\r\n /** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */\r\n accessTokenCookieName?: string;\r\n\r\n /** Refresh Token Cookie 名称,默认 __Host-nihplod_sso_rt */\r\n refreshTokenCookieName?: string;\r\n\r\n /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */\r\n idTokenCookieName?: string;\r\n\r\n /** State Cookie 名称,默认 __Host-nihplod_sso_state */\r\n stateCookieName?: string;\r\n\r\n /** OIDC Nonce Cookie 名称,默认 __Host-nihplod_sso_nonce(须与 createSsoMiddleware 一致) */\r\n nonceCookieName?: string;\r\n\r\n /** Return URL Cookie 名称,默认 __Host-nihplod_sso_return */\r\n returnUrlCookieName?: string;\r\n\r\n /** PKCE Verifier Cookie 名称,默认 __Secure-nihplod_sso_verifier */\r\n verifierCookieName?: string;\r\n\r\n /**\r\n * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除\r\n * __Host-/__Secure- 前缀;必须与 createSsoMiddleware 的配置保持一致,\r\n * 否则读不到 middleware 写入的 state/verifier Cookie。生产严禁启用——\r\n * 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下该开关会被\r\n * 强制忽略并告警。\r\n */\r\n insecureLocalDev?: boolean;\r\n\r\n /**\r\n * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。\r\n * 仅用于 token 交换等服务器间请求;浏览器跳转仍使用 ssoBaseUrl 公网地址。\r\n */\r\n serverBaseUrl?: string;\r\n}\r\n\r\n// ============================================\r\n// 工具函数\r\n// ============================================\r\n\r\n// returnUrl 开放重定向校验统一使用 ../core/security 的 isTrustedReturnUrl\r\n\r\n// ID Token 预校验逻辑已收敛到 ../core/id-token.ts\r\n\r\n// ============================================\r\n// Route Handler 工厂函数\r\n// ============================================\r\n\r\nexport function createCallbackRouteHandler(config: CallbackRouteConfig) {\r\n const {\r\n clientId,\r\n ssoBaseUrl,\r\n redirectUri,\r\n clientSecret,\r\n defaultReturnPath = \"/\",\r\n insecureLocalDev: insecureLocalDevOpt = false,\r\n } = config;\r\n\r\n // 生产守卫:NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关(与 middleware 一致)\r\n const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);\r\n\r\n // insecureLocalDev:与 middleware 一致地去前缀 + 关 Secure,否则读不到 state/verifier\r\n const secureCookies = !insecureLocalDev;\r\n const pickName = (explicit: string | undefined, fallback: string) =>\r\n insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;\r\n const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);\r\n const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);\r\n const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);\r\n const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);\r\n const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);\r\n const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);\r\n const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);\r\n\r\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n // 服务器间调用(token 交换)的基准地址:配置了内网地址时走内网\r\n const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\\/+$/, \"\");\r\n\r\n return async function GET(request: NextRequest) {\r\n const response = await handleCallback(request);\r\n // 错误路径(4xx/5xx)统一清除 nonce cookie,避免残留;\r\n // 成功路径在重定向响应中随 state/returnUrl cookie 一并清除\r\n if (response.status >= 400) {\r\n response.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n }\r\n return response;\r\n };\r\n\r\n async function handleCallback(request: NextRequest): Promise<NextResponse> {\r\n const { searchParams } = request.nextUrl;\r\n\r\n // 检查错误\r\n const error = searchParams.get(\"error\");\r\n if (error) {\r\n const desc = searchParams.get(\"error_description\") || error;\r\n return NextResponse.json(\r\n { error: \"authorization_failed\", error_description: desc },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n const code = searchParams.get(\"code\");\r\n const returnedState = searchParams.get(\"state\");\r\n\r\n if (!code) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"缺少 authorization code\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 验证 state(从 cookie 中读取原始 state,CSRF 必需)\r\n const savedState = request.cookies.get(stateCookieName)?.value;\r\n if (!savedState) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"State 参数缺失,请重新发起授权请求\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n // state 比较使用常量时间比较(与 core/SsoClient.handleCallback 一致,防时序侧信道)\r\n if (!timingSafeEqualString(returnedState ?? \"\", savedState)) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"State 参数不匹配,可能存在 CSRF 攻击\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 读取 middleware 发起授权时写入的 OIDC nonce(httpOnly cookie)。\r\n // cookie 存在时 validateIdToken 会 fail-closed 校验 ID Token 的 nonce claim\r\n const expectedNonce = request.cookies.get(nonceCookieName)?.value;\r\n\r\n // 读取 PKCE code_verifier(middleware 存入的 httpOnly cookie)\r\n const verifier = request.cookies.get(verifierCookieName)?.value;\r\n if (!verifier) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"PKCE verifier 缺失,请重新发起授权请求\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 交换 token(服务器间调用:配置了 serverBaseUrl 时走内网直连)\r\n const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;\r\n const body = new URLSearchParams();\r\n body.set(\"grant_type\", \"authorization_code\");\r\n body.set(\"code\", code);\r\n body.set(\"client_id\", clientId);\r\n body.set(\"redirect_uri\", redirectUri);\r\n body.set(\"code_verifier\", verifier);\r\n if (clientSecret) {\r\n body.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n // 带重试的 token 交换(1 次重试 + 指数退避)\r\n let res: Response | null = null;\r\n let lastError: unknown;\r\n const maxRetries = 1;\r\n\r\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\r\n if (attempt > 0) {\r\n // 指数退避:1s * 2^attempt\r\n await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));\r\n }\r\n try {\r\n res = await fetch(tokenEndpoint, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: body.toString(),\r\n });\r\n lastError = null;\r\n break;\r\n } catch (err) {\r\n lastError = err;\r\n if (attempt >= maxRetries) {\r\n return NextResponse.json(\r\n { error: \"server_error\", error_description: \"Token 请求失败,已重试仍不可达\" },\r\n { status: 502 }\r\n );\r\n }\r\n }\r\n }\r\n\r\n if (lastError || !res) {\r\n return NextResponse.json(\r\n { error: \"server_error\", error_description: \"Token 请求失败\" },\r\n { status: 502 }\r\n );\r\n }\r\n\r\n if (!res.ok) {\r\n let errData: Record<string, unknown> = {};\r\n try {\r\n errData = await res.json();\r\n } catch { /* ignore */ }\r\n return NextResponse.json(\r\n {\r\n error: \"token_request_failed\",\r\n error_description:\r\n (errData.error_description as string) || `Token 请求失败: HTTP ${res.status}`,\r\n },\r\n { status: 502 }\r\n );\r\n }\r\n\r\n const tokenData: {\r\n access_token: string;\r\n token_type: string;\r\n expires_in: number;\r\n refresh_token: string;\r\n id_token?: string;\r\n } = await res.json();\r\n\r\n // 服务端异常可能省略必要字段;不校验会把字符串 \"undefined\" 写进 cookie\r\n if (!tokenData.access_token || !tokenData.refresh_token) {\r\n return NextResponse.json(\r\n {\r\n error: \"server_error\",\r\n error_description: \"Token 响应缺少 access_token 或 refresh_token\",\r\n },\r\n { status: 502 }\r\n );\r\n }\r\n\r\n // 在设置 Cookie 前预校验 ID Token:防止伪造 token 写入浏览器\r\n if (tokenData.id_token) {\r\n try {\r\n await validateIdToken(\r\n tokenData.id_token,\r\n tokenData.access_token,\r\n normalizedBase,\r\n clientId,\r\n { expectedNonce }\r\n );\r\n } catch (err) {\r\n return NextResponse.json(\r\n {\r\n error: \"id_token_invalid\",\r\n error_description: err instanceof Error ? err.message : \"ID Token 验证失败\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n }\r\n\r\n // 读取 return URL,并做开放重定向防护。\r\n // 可信 origin 与跳转基准一律取 redirectUri 的 origin(部署时配置的公网地址),\r\n // 而非 request.url / request.nextUrl.origin:Next standalone 部署下后者是进程\r\n // 监听地址(如 http://0.0.0.0:3002),反代场景会把用户重定向到不可达地址。\r\n const callbackOrigin = new URL(redirectUri).origin;\r\n const rawReturnUrl =\r\n request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;\r\n const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin)\r\n ? rawReturnUrl\r\n : \"/\";\r\n\r\n // 重定向并设置 cookie\r\n const response = NextResponse.redirect(new URL(returnUrl, callbackOrigin));\r\n\r\n // 设置 access_token cookie (httpOnly, Secure, SameSite=Lax, Path=/)\r\n response.cookies.set(accessTokenCookieName, tokenData.access_token, {\r\n ...getHostCookieOptions(tokenData.expires_in, secureCookies),\r\n });\r\n\r\n // 设置 refresh_token cookie(使用服务端返回的过期时间动态计算)\r\n // 服务端 refresh_token 通常是 30 天,此处使用 expires_in 映射(若响应包含)\r\n // 回退到 30 天默认值\r\n const refreshMaxAge =\r\n (tokenData as Record<string, unknown>).refresh_expires_in != null\r\n ? (tokenData as Record<string, unknown>).refresh_expires_in as number\r\n : 30 * 24 * 60 * 60;\r\n\r\n response.cookies.set(refreshTokenCookieName, tokenData.refresh_token, {\r\n ...getHostCookieOptions(refreshMaxAge, secureCookies),\r\n });\r\n\r\n // 设置 id_token cookie,用于 RP-Initiated Logout 的 id_token_hint\r\n // 使用 ID Token 自身的过期时间(通常 1 小时),而非 refresh token 的 30 天\r\n if (tokenData.id_token) {\r\n let idTokenMaxAge = 3600; // 默认 1 小时\r\n try {\r\n const parts = tokenData.id_token.split(\".\");\r\n if (parts.length === 3) {\r\n const payload = JSON.parse(\r\n Buffer.from(parts[1].replace(/-/g, \"+\").replace(/_/g, \"/\"), \"base64\").toString(\"utf8\")\r\n ) as { exp?: number };\r\n if (payload.exp && typeof payload.exp === \"number\") {\r\n // 已过期(剩余 <= 0)的 id_token 不写入 cookie,避免携带失效凭证登出\r\n idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1000);\r\n }\r\n }\r\n } catch {\r\n // 解码失败,使用默认值\r\n }\r\n if (idTokenMaxAge > 0) {\r\n response.cookies.set(idTokenCookieName, tokenData.id_token, {\r\n ...getHostCookieOptions(idTokenMaxAge, secureCookies),\r\n });\r\n }\r\n }\r\n\r\n // 清除临时 cookies: state / nonce / return URL\r\n response.cookies.set(stateCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n response.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n response.cookies.set(returnUrlCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n\r\n // 清除 PKCE verifier cookie,必须使用写入时的 path(callbackPath)\r\n // 由于 callback handler 不知道 middleware 的 callbackPath,这里保守地\r\n // 同时清除 path=/ 和 path=当前请求路径两种可能\r\n response.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, \"/\", secureCookies));\r\n response.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));\r\n\r\n return response;\r\n }\r\n}\r\n","/**\r\n * SSO SDK 错误类型\r\n */\r\n\r\n/** SSO 错误码 */\r\nexport type SsoErrorCode =\r\n | \"invalid_config\"\r\n | \"state_mismatch\"\r\n | \"pkce_required\"\r\n | \"token_request_failed\"\r\n | \"session_expired\"\r\n | \"no_refresh_token\"\r\n | \"userinfo_failed\"\r\n | \"not_authenticated\"\r\n | \"authorization_code_expired\"\r\n // 保留:当前 SDK 不主动抛出,预留给服务端错误透传/未来版本使用\r\n | \"authorization_code_used\"\r\n | \"client_disabled\"\r\n | \"user_denied_authorization\"\r\n // 保留:当前 SDK 不主动抛出,预留给服务端错误透传/未来版本使用\r\n | \"account_disabled\"\r\n | \"sso_server_error\"\r\n | \"rate_limited\"\r\n | \"network_error\"\r\n | \"popup_blocked\"\r\n | \"popup_closed\"\r\n | \"id_token_invalid\"\r\n | \"id_token_unsupported_alg\"\r\n | \"id_token_hs256_unsupported\"\r\n // 保留:当前 SDK 不主动抛出(HS256 一律拒绝,见 id_token_hs256_unsupported)\r\n | \"id_token_missing_secret\"\r\n | \"id_token_invalid_signature\"\r\n | \"id_token_issuer_mismatch\"\r\n | \"id_token_audience_mismatch\"\r\n | \"id_token_expired\"\r\n | \"id_token_missing_sub\"\r\n | \"id_token_at_hash_mismatch\"\r\n | \"id_token_nonce_mismatch\"\r\n | \"logout_token_invalid\"\r\n | \"logout_token_unsupported_alg\"\r\n | \"logout_token_invalid_signature\"\r\n | \"logout_token_issuer_mismatch\"\r\n | \"logout_token_audience_mismatch\"\r\n | \"logout_token_expired\"\r\n | \"logout_token_replay\";\r\n\r\n/**\r\n * 将 OAuth 2.0 服务端 error 字段映射到 SsoErrorCode\r\n *\r\n * @param oauthError 服务端返回的 error 字段\r\n * @param context 调用上下文:\"token_exchange\"(授权码换 token)或 \"refresh\"(刷新)。\r\n * invalid_grant 在换 token 场景多为授权码过期/已用,在刷新场景多为会话失效,\r\n * 需按上下文细化映射。\r\n */\r\nexport function mapOAuthErrorToSsoCode(\r\n oauthError: string,\r\n context: \"token_exchange\" | \"refresh\" = \"token_exchange\"\r\n): SsoErrorCode {\r\n switch (oauthError) {\r\n case \"invalid_grant\":\r\n // 刷新场景的 invalid_grant 表示 refresh_token 已撤销/过期(调用方另会清除本地登录态)\r\n return context === \"refresh\" ? \"session_expired\" : \"authorization_code_expired\";\r\n case \"invalid_client\":\r\n return \"client_disabled\";\r\n case \"access_denied\":\r\n return \"user_denied_authorization\";\r\n case \"server_error\":\r\n return \"sso_server_error\";\r\n case \"rate_limited\":\r\n // 限流是独立的可重试错误,不应误报为 network_error\r\n return \"rate_limited\";\r\n case \"invalid_scope\":\r\n case \"invalid_request\":\r\n case \"unauthorized_client\":\r\n case \"unsupported_grant_type\":\r\n default:\r\n return \"token_request_failed\";\r\n }\r\n}\r\n\r\n/**\r\n * SSO SDK 自定义错误\r\n */\r\nexport class SsoError extends Error {\r\n public readonly code: SsoErrorCode;\r\n public readonly description: string;\r\n public readonly cause?: unknown;\r\n\r\n constructor(code: SsoErrorCode, description: string, cause?: unknown) {\r\n super(`[SSO SDK] ${code}: ${description}`);\r\n this.name = \"SsoError\";\r\n this.code = code;\r\n this.description = description;\r\n this.cause = cause;\r\n }\r\n}\r\n\r\n/**\r\n * OAuth 2.0 协议层错误(RFC 6749 §5.2)\r\n *\r\n * 保留用于表示服务端原样返回的 OAuth 错误(error / error_description / error_uri),\r\n * 与 SDK 语义的 SsoError 区分。当前 SDK 内部不抛出此类型(统一使用 SsoError +\r\n * mapOAuthErrorToSsoCode 映射),作为公共类型导出供子项目自行解析 OAuth 响应时使用。\r\n */\r\nexport class OAuthError extends Error {\r\n public readonly code: string;\r\n public readonly description: string;\r\n public readonly uri?: string;\r\n\r\n constructor(code: string, description: string, uri?: string) {\r\n super(`[OAuth] ${code}: ${description}`);\r\n this.name = \"OAuthError\";\r\n this.code = code;\r\n this.description = description;\r\n this.uri = uri;\r\n }\r\n}\r\n","/**\n * 安全相关小工具:returnUrl 开放重定向校验、常量时间字符串比较\n */\n\n/**\n * 校验 returnUrl 是否可信(防开放重定向)。\n * 仅允许:相对路径(且不以 // 开头)或与 currentOrigin 完全同源的绝对 URL。\n * 拒绝一切含反斜杠 \\ 的值:浏览器会把 \"/\\evil.com\" 归一化为 \"//evil.com\",\n * 形成协议相对 URL 开放重定向。\n */\nexport function isTrustedReturnUrl(url: string, currentOrigin: string): boolean {\n if (!url) return false;\n if (url.includes(\"\\\\\")) return false;\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\n try {\n return new URL(url).origin === currentOrigin;\n } catch {\n return false;\n }\n}\n\n/**\n * 常量时间字符串比较(避免 state / at_hash / nonce 等机密值的时序侧信道)。\n * 长度不同也执行完整循环,不提前返回。\n */\nexport function timingSafeEqualString(a: string, b: string): boolean {\n const ba = new TextEncoder().encode(a);\n const bb = new TextEncoder().encode(b);\n if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;\n const len = Math.max(ba.length, bb.length);\n let diff = ba.length ^ bb.length;\n for (let i = 0; i < len; i++) {\n diff |= ba[i % ba.length] ^ bb[i % bb.length];\n }\n return diff === 0;\n}\n","/**\n * ID Token 验证工具(浏览器 / Edge Runtime 通用)\n *\n * 提取 SsoClient 与 Next.js callback 中重复的 ID Token 校验逻辑,\n * 包括 base64url 解码、JWKS 获取、RS256 签名验证、at_hash 计算等。\n */\n\nimport { SsoError } from \"./errors\";\nimport { timingSafeEqualString } from \"./security\";\n\n// 运行时 crypto.subtle 检测:现代浏览器 / Edge Runtime / Node 20+ 均提供全局 crypto\nconst _crypto: Pick<Crypto, \"subtle\"> | null =\n typeof crypto !== \"undefined\" && (crypto as Crypto).subtle ? (crypto as Crypto) : null;\n\nfunction getCrypto(): Crypto | null {\n return (_crypto as Crypto | null) ?? null;\n}\n\nexport interface JwksKey {\n kty: string;\n kid?: string;\n alg?: string;\n use?: string;\n n?: string;\n e?: string;\n x5c?: string[];\n}\n\nexport interface Jwks {\n keys: JwksKey[];\n}\n\nexport interface ValidateIdTokenResult {\n sub: string;\n}\n\nexport interface ValidateIdTokenOptions {\n /**\n * 发现 SSO 中心已启用 RS256 时,是否拒绝 HS256 ID Token。\n * 默认 true:只要 JWKS 中存在 RS256 签名密钥,就拒绝 HS256(安全推荐)。\n */\n rejectHs256WhenRs256Available?: boolean;\n /**\n * 期望的 OIDC nonce(login 时生成并随 authorize 请求发送)。\n * 传入后 ID Token 必须携带 nonce claim 且与该值常量时间相等(fail-closed),\n * 否则抛出 id_token_nonce_mismatch,防止 ID Token 重放。\n * 不传则跳过 nonce 校验(如 refresh 流程签发的 ID Token)。\n */\n expectedNonce?: string;\n}\n\nfunction base64UrlDecode(input: string): Uint8Array {\n const base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), \"=\");\n const binary = atob(padded);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n}\n\n/** 解码 JWT payload(不验证签名) */\nexport function decodeJwtPayload(token: string): Record<string, unknown> | null {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const json = new TextDecoder().decode(base64UrlDecode(parts[1]));\n return JSON.parse(json) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\n/** 解码 JWT header */\nexport function decodeJwtHeader(token: string): Record<string, unknown> | null {\n try {\n const parts = token.split(\".\");\n if (parts.length !== 3) return null;\n const json = new TextDecoder().decode(base64UrlDecode(parts[0]));\n return JSON.parse(json) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\n/** 标准化 issuer/base URL,去除末尾斜杠 */\nfunction normalizeIssuer(url: string): string {\n return url.replace(/\\/+$/, \"\");\n}\n\n/** OIDC Discovery 文档(仅取本模块关心的字段) */\nexport interface OidcDiscoveryDoc {\n issuer?: string;\n jwks_uri?: string;\n}\n\nlet cachedJwks: { baseUrl: string; jwks: Jwks; fetchedAt: number } | null = null;\nlet cachedDiscovery: { baseUrl: string; doc: OidcDiscoveryDoc; fetchedAt: number } | null = null;\nconst JWKS_CACHE_TTL_MS = 5 * 60 * 1000;\n/** discovery / JWKS 拉取超时(与 SsoClient._getDiscovery 的 10s 一致) */\nconst FETCH_TIMEOUT_MS = 10000;\n\n/**\n * 拉取 OIDC Discovery 文档(带内存缓存;失败返回 null 不抛错)\n *\n * 只缓存成功结果:失败(网络错误 / 非 2xx)不缓存,主站短暂抖动不会让\n * 登录持续失败到 TTL 期满,下一次调用立即重试(与 core/discovery.ts 策略一致)。\n */\nexport async function fetchDiscoveryDoc(baseUrl: string): Promise<OidcDiscoveryDoc | null> {\n const now = Date.now();\n if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {\n return cachedDiscovery.doc;\n }\n\n try {\n const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n });\n if (!res.ok) return null;\n const doc = (await res.json()) as OidcDiscoveryDoc;\n cachedDiscovery = { baseUrl, doc, fetchedAt: now };\n return doc;\n } catch {\n return null;\n }\n}\n\nexport interface FetchJwksOptions {\n /**\n * 绕过内存缓存与浏览器 HTTP 缓存(cache: \"no-cache\")强制重取 JWKS,\n * 并同步刷新模块级缓存。用于密钥轮换后 kid 失配的自愈重试。\n */\n forceRefresh?: boolean;\n}\n\n/** 从 SSO 中心拉取 JWKS(带内存缓存;优先 Discovery 的 jwks_uri,回退硬编码路径) */\nexport async function fetchJwks(baseUrl: string, options: FetchJwksOptions = {}): Promise<Jwks | null> {\n const now = Date.now();\n if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {\n return cachedJwks.jwks;\n }\n\n const discovery = await fetchDiscoveryDoc(baseUrl);\n const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;\n\n try {\n const res = await fetch(jwksUri, {\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n ...(options.forceRefresh ? { cache: \"no-cache\" as RequestCache } : {}),\n });\n if (!res.ok) return null;\n const jwks = (await res.json()) as Jwks;\n cachedJwks = { baseUrl, jwks, fetchedAt: now };\n return jwks;\n } catch {\n return null;\n }\n}\n\n/** 清空 JWKS / Discovery 内存缓存(测试与密钥轮换场景使用) */\nexport function clearIdTokenCaches(): void {\n cachedJwks = null;\n cachedDiscovery = null;\n}\n\n/** 使用 RS256 公钥验证 ID Token 签名 */\nexport async function verifyRs256Signature(token: string, jwk: JwksKey): Promise<boolean> {\n try {\n const [headerB64, payloadB64, signature] = token.split(\".\");\n if (!signature || !jwk.n || !jwk.e) return false;\n\n const c = getCrypto();\n if (!c) return false;\n\n const cryptoKey = await c.subtle.importKey(\n \"jwk\",\n { kty: \"RSA\", n: jwk.n, e: jwk.e, alg: \"RS256\", ext: false },\n { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-256\" },\n false,\n [\"verify\"]\n );\n\n const signatureBytes = base64UrlDecode(signature);\n const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);\n return await c.subtle.verify(\n \"RSASSA-PKCS1-v1_5\",\n cryptoKey,\n signatureBytes as unknown as BufferSource,\n data as unknown as BufferSource\n );\n } catch {\n return false;\n }\n}\n\n/** 计算 OIDC at_hash(access_token SHA-256 前 128bit base64url) */\nexport async function computeAtHash(accessToken: string): Promise<string> {\n const c = getCrypto();\n if (!c) return \"\";\n const hash = await c.subtle.digest(\"SHA-256\", new TextEncoder().encode(accessToken));\n const bytes = new Uint8Array(hash);\n const half = bytes.slice(0, bytes.length / 2);\n let binary = \"\";\n for (const b of half) {\n binary += String.fromCharCode(b);\n }\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\n/** 验证 ID Token(iss、aud、exp、iat、azp、sub、签名、at_hash) */\nexport async function validateIdToken(\n idToken: string,\n accessToken: string,\n expectedIssuer: string,\n expectedClientId: string,\n options: ValidateIdTokenOptions = {}\n): Promise<ValidateIdTokenResult> {\n const { rejectHs256WhenRs256Available = true, expectedNonce } = options;\n\n const header = decodeJwtHeader(idToken);\n if (!header) {\n throw new SsoError(\"id_token_invalid\", \"ID Token 格式错误\");\n }\n\n const alg = header.alg;\n if (typeof alg !== \"string\" || (alg !== \"RS256\" && alg !== \"HS256\")) {\n throw new SsoError(\"id_token_unsupported_alg\", `不支持的 ID Token 签名算法: ${alg}`);\n }\n\n const baseUrl = normalizeIssuer(expectedIssuer);\n // issuer 以 Discovery 文档的 issuer 为准(防伪站伪造 iss 通过校验),回退调用方传入值\n const discovery = await fetchDiscoveryDoc(baseUrl);\n const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);\n\n // RS256:通过 JWKS 验证签名\n if (alg === \"RS256\") {\n const jwks = await fetchJwks(baseUrl);\n if (!jwks) {\n throw new SsoError(\"id_token_invalid_signature\", \"无法获取 JWKS 验证 ID Token 签名\");\n }\n const kid = typeof header.kid === \"string\" ? header.kid : undefined;\n // 有 kid 时精确匹配;无 kid 时逐个尝试所有 RS256 签名公钥\n const matchCandidates = (set: Jwks) =>\n set.keys.filter(\n (k) =>\n k.kty === \"RSA\" &&\n k.alg === \"RS256\" &&\n k.use === \"sig\" &&\n (kid ? k.kid === kid : true)\n );\n const verifyAny = async (keys: JwksKey[]): Promise<boolean> => {\n for (const key of keys) {\n if (await verifyRs256Signature(idToken, key)) return true;\n }\n return false;\n };\n\n let candidates = matchCandidates(jwks);\n let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\n\n // 密钥轮换自愈:按 kid 过滤无候选,或所有候选验签失败时,\n // 可能是本地 JWKS 缓存陈旧 —— 强制重取一次 JWKS 后重试,仍失败才抛错\n if (candidates.length === 0 || !validSig) {\n const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });\n if (freshJwks) {\n candidates = matchCandidates(freshJwks);\n validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\n }\n }\n\n if (candidates.length === 0) {\n throw new SsoError(\"id_token_invalid_signature\", \"JWKS 中未找到匹配的 RS256 公钥\");\n }\n if (!validSig) {\n throw new SsoError(\"id_token_invalid_signature\", \"ID Token 签名验证失败\");\n }\n }\n\n // HS256:对称密钥无法安全分发给 Public Client / BFF。\n // SDK 拒绝 HS256 ID Token:主站需配置 RS256 密钥对以启用安全验证。\n if (alg === \"HS256\") {\n if (rejectHs256WhenRs256Available) {\n const jwks = await fetchJwks(baseUrl);\n const hasRs256 = jwks?.keys?.some((k) => k.alg === \"RS256\" && k.use === \"sig\");\n if (hasRs256) {\n throw new SsoError(\"id_token_unsupported_alg\", \"SSO 中心已配置 RS256,拒绝 HS256 ID Token\");\n }\n }\n throw new SsoError(\n \"id_token_hs256_unsupported\",\n \"ID Token 使用 HS256 对称签名,SDK 无法安全验证。\" +\n \"请联系管理员在 SSO 主站配置 RS256 密钥对(JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY)后重新签发。\"\n );\n }\n\n const payload = decodeJwtPayload(idToken);\n if (!payload) {\n throw new SsoError(\"id_token_invalid\", \"ID Token payload 解析失败\");\n }\n\n const tokenIssuer = typeof payload.iss === \"string\" ? normalizeIssuer(payload.iss) : \"\";\n if (tokenIssuer !== normalizedIssuer) {\n throw new SsoError(\"id_token_issuer_mismatch\", \"ID Token issuer 不匹配\");\n }\n\n // aud 校验:单值或数组包含 clientId;多 aud 时按 OIDC Core §3.1.3.7 要求 azp 必须等于 clientId\n const aud = payload.aud;\n const audList = Array.isArray(aud) ? aud : typeof aud === \"string\" ? [aud] : [];\n if (!audList.includes(expectedClientId)) {\n throw new SsoError(\"id_token_audience_mismatch\", \"ID Token audience 不匹配\");\n }\n if (audList.length > 1 && payload.azp !== expectedClientId) {\n throw new SsoError(\"id_token_audience_mismatch\", \"ID Token 多 audience 时 azp 必须为当前 client\");\n }\n\n // exp 必需且不得过期(允许 60s clock skew,与 iat 校验对称)\n if (typeof payload.exp !== \"number\") {\n throw new SsoError(\"id_token_invalid\", \"ID Token 缺少 exp 声明\");\n }\n if (Date.now() >= payload.exp * 1000 + 60_000) {\n throw new SsoError(\"id_token_expired\", \"ID Token 已过期\");\n }\n\n // iat 不得在未来(允许 60s clock skew)\n if (typeof payload.iat === \"number\" && payload.iat * 1000 > Date.now() + 60_000) {\n throw new SsoError(\"id_token_invalid\", \"ID Token iat 在未来,疑似伪造或时钟异常\");\n }\n\n if (typeof payload.sub !== \"string\" || !payload.sub) {\n throw new SsoError(\"id_token_missing_sub\", \"ID Token 缺少 sub\");\n }\n\n // 校验 at_hash:确保 ID Token 与当前 access_token 绑定(常量时间比较)\n if (typeof payload.at_hash === \"string\" && payload.at_hash) {\n const actual = await computeAtHash(accessToken);\n if (!timingSafeEqualString(actual, payload.at_hash)) {\n throw new SsoError(\"id_token_at_hash_mismatch\", \"ID Token at_hash 不匹配\");\n }\n }\n\n // 校验 nonce(OIDC Core §3.1.3.7):调用方传入 expectedNonce 时 fail-closed,\n // ID Token 必须携带 nonce 且与登录时生成的值常量时间相等,防 ID Token 重放\n if (expectedNonce !== undefined) {\n const tokenNonce = typeof payload.nonce === \"string\" ? payload.nonce : \"\";\n if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {\n throw new SsoError(\"id_token_nonce_mismatch\", \"ID Token nonce 不匹配\");\n }\n }\n\n return { sub: payload.sub };\n}\n","/**\n * App Router 登出 Route Handler\n *\n * 在 /api/auth/logout 路由中处理本地登出 + RP-Initiated Logout:\n * 1. 从 cookie 读取 refresh_token 并调用 revocation_endpoint 撤销\n * 2. 清除所有 SSO cookie\n * 3. 按退出范围(defaultScope / 表单 global 字段)决定:\n * - \"local\"(默认):仅退出本站,重定向回本站首页\n * - \"global\":重定向到 SSO 中心 end-session,全局退出所有 NIHPLOD 平台\n *\n * 用法 (src/app/api/auth/logout/route.ts):\n * ```ts\n * import { createLogoutRouteHandler } from \"@nihplod/sso-sdk/next\";\n *\n * const handler = createLogoutRouteHandler({\n * clientId: \"my-app\",\n * clientSecret: \"optional-secret\",\n * ssoBaseUrl: \"https://nihplod.cn\",\n * redirectUri: \"https://myapp.com/api/auth/callback\",\n * postLogoutRedirectUri: \"https://myapp.com/\",\n * });\n *\n * // 推荐使用 POST 触发登出(防登出 CSRF);GET 不执行登出,仅返回确认页\n * export const GET = handler;\n * export const POST = handler;\n * ```\n */\n\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { fetchDiscoveryCached } from \"../core/discovery\";\nimport {\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\n DEFAULT_ID_TOKEN_COOKIE_NAME,\n DEFAULT_STATE_COOKIE_NAME,\n DEFAULT_NONCE_COOKIE_NAME,\n DEFAULT_RETURN_COOKIE_NAME,\n DEFAULT_VERIFIER_COOKIE_NAME,\n DEFAULT_LOGOUT_STATE_COOKIE_NAME,\n getHostCookieOptions,\n getSecureCookieOptions,\n resolveInsecureLocalDev,\n toInsecureCookieName,\n} from \"./constants\";\n\n// ============================================\n// 类型定义\n// ============================================\n\nexport interface LogoutRouteConfig {\n /** OAuth Client ID */\n clientId: string;\n\n /** SSO 中心地址 */\n ssoBaseUrl: string;\n\n /** 回调 URL(与注册 redirect_uri 一致,用于构造 post_logout_redirect_uri) */\n redirectUri: string;\n\n /**\n * OAuth Client Secret(可选)。\n * 撤销 refresh_token 时,Confidential Client 需要认证。\n */\n clientSecret?: string;\n\n /** 登出后跳转回子项目的地址,默认取 redirectUri 的 origin */\n postLogoutRedirectUri?: string;\n\n /**\n * 默认退出范围(默认 \"local\"):\n * - \"local\":仅退出本站(撤销 refresh_token + 清本地 cookie),不跳转 SSO 中心;\n * - \"global\":同时跳转 SSO 中心 end-session 全局退出(RP-Initiated Logout)。\n * 用户可在 GET 确认页勾选\"同时退出所有 NIHPLOD 平台\"后通过表单字段\n * global=1 覆盖默认值;POST 表单携带 global 字段时以表单为准。\n */\n defaultScope?: \"local\" | \"global\";\n\n /**\n * @deprecated 请改用 defaultScope(true → \"global\",false → \"local\")。\n * 仅为兼容保留;与 defaultScope 同时传入时以 defaultScope 为准。\n */\n redirectToSso?: boolean;\n\n /** Access Token Cookie 名称 */\n accessTokenCookieName?: string;\n\n /** Refresh Token Cookie 名称 */\n refreshTokenCookieName?: string;\n\n /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */\n idTokenCookieName?: string;\n\n /** State Cookie 名称 */\n stateCookieName?: string;\n\n /** OIDC Nonce Cookie 名称,默认 __Host-nihplod_sso_nonce(须与 middleware/callback 一致) */\n nonceCookieName?: string;\n\n /** Return URL Cookie 名称 */\n returnUrlCookieName?: string;\n\n /** PKCE Verifier Cookie 名称 */\n verifierCookieName?: string;\n\n /** 回调路径(用于清除 verifier cookie),默认 \"/api/auth/callback\" */\n callbackPath?: string;\n\n /** Logout State Cookie 名称(RP-Initiated Logout CSRF 防护),默认 __Host-nihplod_sso_logout_state */\n logoutStateCookieName?: string;\n\n /**\n * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。\n * 仅用于 discovery / revocation 等服务器间请求;浏览器跳转\n * (end-session 等)始终使用 ssoBaseUrl 公网地址。\n * 适用于子站与 SSO 中心同机/同内网部署:避免经公网代理回源的延迟。\n */\n serverBaseUrl?: string;\n\n /**\n * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除\n * __Host-/__Secure- 前缀;必须与 middleware / callback 的配置保持一致,\n * 否则无法清除它们写入的 Cookie。生产严禁启用——生产环境\n * (NODE_ENV=production 且 ssoBaseUrl 为 https)下该开关会被强制忽略并告警。\n */\n insecureLocalDev?: boolean;\n}\n\n// ============================================\n// 工具函数\n// ============================================\n\n/** GET 确认页 HTML(登出 CSRF 防护:GET 不执行登出,由用户点击按钮发起 POST) */\nfunction buildLogoutConfirmHtml(): string {\n return `<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>确认退出登录</title>\n</head>\n<body style=\"display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;\">\n <form method=\"post\" style=\"text-align:center;\">\n <p>确定要退出登录吗?</p>\n <label style=\"display:flex;align-items:center;justify-content:center;gap:6px;margin:12px 0;font-size:14px;color:#374151;cursor:pointer;\">\n <input type=\"checkbox\" name=\"global\" value=\"1\">同时退出所有 NIHPLOD 平台\n </label>\n <button type=\"submit\" style=\"padding:10px 20px;background-color:#ef4444;color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px;\">退出登录</button>\n <p><a href=\"/\" style=\"color:#2563eb;text-decoration:underline;\">取消并返回首页</a></p>\n </form>\n</body>\n</html>`;\n}\n\n/** 生成安全随机字符串(用于 logout state,Node/Edge Runtime 均支持 Web Crypto) */\nfunction generateRandomString(length: number): string {\n const chars =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~\";\n const maxValid = Math.floor(256 / chars.length) * chars.length;\n let result = \"\";\n while (result.length < length) {\n const array = new Uint8Array(length * 2);\n crypto.getRandomValues(array);\n for (let i = 0; i < array.length && result.length < length; i++) {\n if (array[i] >= maxValid) continue;\n result += chars[array[i] % chars.length];\n }\n }\n return result;\n}\n\n// ============================================\n// Route Handler 工厂函数\n// ============================================\n\nexport function createLogoutRouteHandler(config: LogoutRouteConfig) {\n const {\n clientId,\n ssoBaseUrl,\n redirectUri,\n clientSecret,\n postLogoutRedirectUri = new URL(redirectUri).origin + \"/\",\n callbackPath = \"/api/auth/callback\",\n insecureLocalDev: insecureLocalDevOpt = false,\n } = config;\n\n // 退出范围解析:defaultScope 优先(默认 \"local\",仅退出本站);\n // redirectToSso 为 deprecated 别名,传入时告警并按 true→global / false→local 映射\n let defaultScope: \"local\" | \"global\" = config.defaultScope ?? \"local\";\n if (config.redirectToSso !== undefined) {\n console.warn(\n \"[SSO SDK] redirectToSso 已弃用,请改用 defaultScope\" +\n \"(redirectToSso: true → defaultScope: \\\"global\\\",false → \\\"local\\\")。\" +\n \"注意:默认退出范围已变更为 \\\"local\\\"(仅退出本站,不跳转 SSO 中心)。\"\n );\n if (config.defaultScope === undefined) {\n defaultScope = config.redirectToSso ? \"global\" : \"local\";\n }\n }\n\n // 生产守卫:NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关(与 middleware/callback 一致)\n const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);\n\n // insecureLocalDev:与 middleware/callback 一致地去前缀 + 关 Secure\n const secureCookies = !insecureLocalDev;\n const pickName = (explicit: string | undefined, fallback: string) =>\n insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;\n const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);\n const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);\n const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);\n const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);\n const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);\n const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);\n const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);\n const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);\n\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\n // 服务器间调用(discovery/revoke)的基准地址:配置了内网地址时走内网,\n // 避免经公网代理回源的延迟;浏览器跳转仍用 normalizedBase(公网)\n const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\\/+$/, \"\");\n\n // 本地跳转的 origin:取 redirectUri 的 origin 而非 request.nextUrl.origin——\n // standalone 部署下后者是进程监听地址(如 http://0.0.0.0:3002),反代场景不可靠。\n const callbackOrigin = new URL(redirectUri).origin;\n\n /**\n * 登出 handler:同时适配 GET 与 POST(函数本身不区分 method,\n * 在 route.ts 中 `export const GET = handler; export const POST = handler;` 即可)。\n *\n * ⚠️ CSRF 防护:GET 请求可被跨站触发(如 `<img src=\"/api/auth/logout\">`),\n * 因此 GET 且未携带合法 logout state 时不执行登出,仅返回确认页 HTML,\n * 由用户点击按钮以 POST 确认;POST(或携带合法 state 的 GET 回跳)才执行登出。\n *\n * 当请求携带 state 参数时,视为 RP-Initiated Logout 的回跳(post_logout_redirect_uri\n * 指向本路由的场景),校验 logout state cookie 后放行,防止伪造回跳。\n */\n return async function handler(request: NextRequest) {\n // RP-Initiated Logout 回跳:校验 state(CSRF 防护)\n const returnedState = request.nextUrl.searchParams.get(\"state\");\n if (returnedState) {\n const savedState = request.cookies.get(logoutStateCookieName)?.value;\n if (!savedState || savedState !== returnedState) {\n return NextResponse.json(\n { error: \"invalid_request\", error_description: \"Logout state 不匹配\" },\n { status: 400 }\n );\n }\n const res = NextResponse.redirect(callbackOrigin + \"/\");\n res.cookies.set(logoutStateCookieName, \"\", getHostCookieOptions(0, secureCookies));\n return res;\n }\n\n // GET 无 state:可能是跨站图片/预取触发的登出 CSRF,不执行登出,\n // 返回确认页由用户主动 POST 确认(表单 POST 不受 <img>/预取影响)\n if (request.method === \"GET\") {\n return new NextResponse(buildLogoutConfirmHtml(), {\n status: 200,\n headers: { \"content-type\": \"text/html; charset=utf-8\" },\n });\n }\n\n // 解析表单中的 global 字段(确认页勾选\"同时退出所有 NIHPLOD 平台\"后提交 global=1);\n // 表单未携带 global 字段时回落到配置的 defaultScope\n let formGlobal: string | null = null;\n try {\n formGlobal = new URLSearchParams(await request.text()).get(\"global\");\n } catch {\n // body 读取失败时视为未携带,按 defaultScope 处理\n }\n const effectiveScope: \"local\" | \"global\" =\n formGlobal !== null\n ? formGlobal === \"1\" || formGlobal === \"true\"\n ? \"global\"\n : \"local\"\n : defaultScope;\n\n const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;\n const idTokenHint = request.cookies.get(idTokenCookieName)?.value;\n\n // 1. best-effort 撤销服务端 refresh_token\n if (refreshToken) {\n try {\n // 配置了内网地址时直连默认端点(跳过 discovery 的公网端点,\n // 否则文档里的公网 URL 会让调用仍绕行公网);否则用缓存的 discovery\n const discovery = config.serverBaseUrl\n ? null\n : await fetchDiscoveryCached(normalizedServerBase);\n const revokeUrl = config.serverBaseUrl\n ? `${normalizedServerBase}/api/oauth/revoke`\n : discovery?.revocation_endpoint || `${normalizedBase}/api/oauth/revoke`;\n const body = new URLSearchParams({\n token: refreshToken,\n token_type_hint: \"refresh_token\",\n client_id: clientId,\n });\n if (clientSecret) {\n body.set(\"client_secret\", clientSecret);\n }\n // 3s 超时兜底:撤销是 best-effort,主站缓慢时不得拖住登出流程\n await fetch(revokeUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n signal: AbortSignal.timeout(3000),\n });\n } catch {\n // 撤销失败不影响本地登出\n }\n }\n\n // 2. 准备本地清除 SSO cookie 的响应\n const clearCookies = (res: NextResponse) => {\n res.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(refreshTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(idTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(stateCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(returnUrlCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, \"/\", secureCookies));\n res.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, callbackPath, secureCookies));\n return res;\n };\n\n // 3. global 范围:RP-Initiated Logout 重定向到 SSO 中心,同时必须清除本地 Cookie;\n // local 范围:仅本站退出,重定向回本站首页\n if (effectiveScope === \"global\") {\n // discovery 经内网地址拉取(若配置),但文档内端点是 SSO 中心按公网 origin\n // 生成的,可直接用于浏览器跳转;兜底也用公网 base,不得使用内网地址\n const discovery = await fetchDiscoveryCached(normalizedServerBase);\n const endSessionEndpoint =\n discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;\n const logoutUrl = new URL(endSessionEndpoint);\n logoutUrl.searchParams.set(\"client_id\", clientId);\n logoutUrl.searchParams.set(\n \"post_logout_redirect_uri\",\n postLogoutRedirectUri\n );\n if (idTokenHint) {\n logoutUrl.searchParams.set(\"id_token_hint\", idTokenHint);\n }\n // 携带 state 防登出 CSRF:回跳时校验(见 handler 开头)\n const logoutState = generateRandomString(32);\n logoutUrl.searchParams.set(\"state\", logoutState);\n const res = clearCookies(NextResponse.redirect(logoutUrl.toString()));\n res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));\n return res;\n }\n\n return clearCookies(\n NextResponse.redirect(callbackOrigin + \"/\")\n );\n };\n}\n","/**\n * OIDC Discovery 文档的共享获取:模块级缓存 + 单飞。\n *\n * Discovery 文档是准静态的(端点路径极少变更),但 logout 等热路径\n * 原先每次调用都重新拉取(一次登出甚至连续拉两次)。在子站与 SSO 中心\n * 经公网/代理互联的部署下,每次拉取都是一次数百毫秒的往返。\n *\n * 设计要点:\n * - 只缓存成功结果(5 分钟):失败返回 null 不缓存,主站短暂抖动不会\n * 把\"无 discovery 可用\"的状态固话住,下一次调用立即重试;\n * - 单飞:并发调用共享同一个在途 Promise,缓存未命中时不会打出并发请求;\n * - 模块级 Map:Node/Edge Runtime 均可(middleware 在 Edge 下按 isolate 各自缓存,\n * 对正确性无影响)。\n */\n\nexport interface SharedDiscoveryDoc {\n issuer?: string;\n revocation_endpoint?: string;\n end_session_endpoint?: string;\n [key: string]: unknown;\n}\n\nconst DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000;\nconst FETCH_TIMEOUT_MS = 5000;\n\nconst cache = new Map<string, { doc: SharedDiscoveryDoc; expiresAt: number }>();\nconst inflight = new Map<string, Promise<SharedDiscoveryDoc | null>>();\n\nexport function fetchDiscoveryCached(baseUrl: string): Promise<SharedDiscoveryDoc | null> {\n const hit = cache.get(baseUrl);\n if (hit && hit.expiresAt > Date.now()) {\n return Promise.resolve(hit.doc);\n }\n\n const existing = inflight.get(baseUrl);\n if (existing) return existing;\n\n const promise = (async () => {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);\n try {\n const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {\n signal: controller.signal,\n });\n if (!res.ok) return null;\n const doc = (await res.json()) as SharedDiscoveryDoc;\n // 只缓存成功结果\n cache.set(baseUrl, { doc, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS });\n return doc;\n } catch {\n return null;\n } finally {\n clearTimeout(timeoutId);\n inflight.delete(baseUrl);\n }\n })();\n\n inflight.set(baseUrl, promise);\n return promise;\n}\n\n/** 清空 discovery 缓存(测试用) */\nexport function clearDiscoveryCache(): void {\n cache.clear();\n}\n","/**\n * App Router Backchannel Logout 接收端 Route Handler\n *\n * 接收 SSO 中心在用户全局登出 / 撤销授权时推送的 logout_token\n * (OIDC Back-Channel Logout 1.0),验证通过后清除本站 SSO cookie 并\n * 调用 onLogout 钩子(子站在钩子里清除自己的本地会话,如数据库 session)。\n *\n * 前置条件:在 SSO 中心管理后台为本 client 注册 backchannelLogoutUri\n * 指向本路由的公网地址(必须 HTTPS)。\n *\n * 用法 (src/app/api/auth/backchannel-logout/route.ts):\n * ```ts\n * import { createBackchannelLogoutRouteHandler } from \"@nihplod/sso-sdk/next\";\n *\n * export const POST = createBackchannelLogoutRouteHandler({\n * clientId: \"my-app\",\n * ssoBaseUrl: \"https://nihplod.cn\",\n * onLogout: async ({ sub, sid }) => {\n * // 清除子站本地会话(按 sub 或 sid 定位)\n * },\n * });\n * ```\n */\n\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { SsoError } from \"../core/errors\";\nimport { verifyLogoutToken, type LogoutTokenPayload } from \"../core/logout-token\";\nimport {\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\n DEFAULT_ID_TOKEN_COOKIE_NAME,\n getHostCookieOptions,\n resolveInsecureLocalDev,\n toInsecureCookieName,\n} from \"./constants\";\n\n// ============================================\n// 类型定义\n// ============================================\n\nexport interface BackchannelLogoutRouteConfig {\n /** OAuth Client ID(logout_token 的 aud 必须等于它) */\n clientId: string;\n\n /** SSO 中心地址(Discovery / JWKS 基准) */\n ssoBaseUrl: string;\n\n /**\n * 登出通知钩子:logout_token 验证通过后调用,\n * 子站在此处按 sub / sid 清除自己的本地会话(数据库 session 等)。\n * 抛错时返回 500,让 IdP 重投。\n */\n onLogout?: (\n payload: LogoutTokenPayload,\n request: NextRequest\n ) => void | Promise<void>;\n\n /** Access Token Cookie 名称 */\n accessTokenCookieName?: string;\n\n /** Refresh Token Cookie 名称 */\n refreshTokenCookieName?: string;\n\n /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */\n idTokenCookieName?: string;\n\n /**\n * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除\n * __Host-/__Secure- 前缀;必须与 middleware / callback / logout 的配置保持一致。\n * 生产严禁启用——生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下\n * 该开关会被强制忽略并告警。\n */\n insecureLocalDev?: boolean;\n}\n\n// ============================================\n// Route Handler 工厂函数\n// ============================================\n\nexport function createBackchannelLogoutRouteHandler(\n config: BackchannelLogoutRouteConfig\n) {\n const { clientId, ssoBaseUrl, onLogout } = config;\n\n // 生产守卫:与 middleware/callback/logout 一致\n const insecureLocalDev = resolveInsecureLocalDev(\n config.insecureLocalDev ?? false,\n ssoBaseUrl\n );\n const secureCookies = !insecureLocalDev;\n const pickName = (explicit: string | undefined, fallback: string) =>\n insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;\n const accessTokenCookieName = pickName(\n config.accessTokenCookieName,\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME\n );\n const refreshTokenCookieName = pickName(\n config.refreshTokenCookieName,\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME\n );\n const idTokenCookieName = pickName(\n config.idTokenCookieName,\n DEFAULT_ID_TOKEN_COOKIE_NAME\n );\n\n return async function handler(request: NextRequest) {\n // 仅接受 POST(IdP 以 application/x-www-form-urlencoded POST 推送)\n if (request.method !== \"POST\") {\n return NextResponse.json(\n { error: \"method_not_allowed\", error_description: \"仅接受 POST 请求\" },\n { status: 405 }\n );\n }\n\n const logoutToken = new URLSearchParams(await request.text()).get(\n \"logout_token\"\n );\n if (!logoutToken) {\n return NextResponse.json(\n { error: \"invalid_request\", error_description: \"缺少 logout_token\" },\n { status: 400 }\n );\n }\n\n let payload: LogoutTokenPayload;\n try {\n payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);\n } catch (err) {\n // 验证失败一律 400(规范:RP 认为 token 无效时返回 400,IdP 不再以该 token 重试)\n const code = err instanceof SsoError ? err.code : \"logout_token_invalid\";\n const description =\n err instanceof SsoError ? err.description : \"Logout Token 验证失败\";\n return NextResponse.json(\n { error: code, error_description: description },\n { status: 400 }\n );\n }\n\n // 先调用钩子清子站本地会话:失败返回 500,让 IdP 重投\n try {\n await onLogout?.(payload, request);\n } catch (err) {\n console.error(\n \"[SSO SDK] backchannel logout onLogout 钩子执行失败:\",\n err\n );\n return NextResponse.json(\n { error: \"server_error\", error_description: \"onLogout 处理失败\" },\n { status: 500 }\n );\n }\n\n // 清除本站 SSO cookie(backchannel 是服务器间调用,清 cookie 影响不到\n // 用户浏览器——用户侧由下一次请求时 middleware 发现会话失效兜住;照做无害)\n const res = new NextResponse(null, { status: 200 });\n res.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(refreshTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n res.cookies.set(idTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\n return res;\n };\n}\n","/**\n * OIDC Backchannel Logout Token 验证\n *\n * 复用 core/id-token.ts 的 Discovery + JWKS 基建验证 IdP 推送的 logout_token:\n * - alg 仅允许 RS256(logout_token 是服务器间凭证,不接受对称签名)\n * - issuer 以 Discovery 文档为准(防伪站伪造 iss 通过校验)\n * - aud 必须等于本 client、exp 必须有效\n * - events 必须包含 backchannel-logout 事件键\n * - sub / sid 至少居一;jti 必须存在且未重放(进程内 LRU)\n *\n * 与 ID Token 的差异:logout_token 不得要求 nonce(规范明确禁止携带),\n * 也不校验 at_hash / iat。\n */\n\nimport { SsoError } from \"./errors\";\nimport {\n decodeJwtHeader,\n decodeJwtPayload,\n fetchDiscoveryDoc,\n fetchJwks,\n verifyRs256Signature,\n type Jwks,\n type JwksKey,\n} from \"./id-token\";\n\n/** Backchannel Logout 事件键(OIDC Back-Channel Logout 1.0) */\nexport const BACKCHANNEL_LOGOUT_EVENT =\n \"http://schemas.openid.net/event/backchannel-logout\";\n\n/** 验证通过的 logout_token 负载(仅暴露子站清会话所需的标识) */\nexport interface LogoutTokenPayload {\n sub?: string;\n sid?: string;\n}\n\n/** jti 防重放缓存:jti → 过期时间(epoch ms),容量上限 1000 */\nconst JTI_CACHE_CAPACITY = 1000;\nconst seenJti = new Map<string, number>();\n\n/** 清空 jti 防重放缓存(测试用) */\nexport function clearLogoutTokenReplayCache(): void {\n seenJti.clear();\n}\n\n/**\n * 记录 jti;返回 false 表示重放(该 jti 尚未过期且已出现过)。\n * 缓存到 token 自身 exp 为止;超过容量时先清理过期项,再按插入序淘汰最旧项。\n */\nfunction recordJti(jti: string, expiresAtMs: number): boolean {\n const now = Date.now();\n const existing = seenJti.get(jti);\n if (existing !== undefined && existing > now) return false;\n\n // 清理过期项,避免缓存被历史数据撑满\n for (const [key, exp] of seenJti) {\n if (exp <= now) seenJti.delete(key);\n }\n // 仍超容量:淘汰最旧插入项(Map 保持插入序)\n while (seenJti.size >= JTI_CACHE_CAPACITY) {\n const oldest = seenJti.keys().next().value;\n if (oldest === undefined) break;\n seenJti.delete(oldest);\n }\n seenJti.set(jti, expiresAtMs);\n return true;\n}\n\n/**\n * 验证 Backchannel Logout Token\n *\n * @param logoutToken IdP POST 到 backchannelLogoutUri 的 logout_token(JWT)\n * @param ssoBaseUrl SSO 中心地址(Discovery / JWKS 基准)\n * @param clientId 本应用 Client ID(aud 必须等于它)\n * @returns 验证通过的 sub / sid\n * @throws SsoError 任一校验失败\n */\nexport async function verifyLogoutToken(\n logoutToken: string,\n ssoBaseUrl: string,\n clientId: string\n): Promise<LogoutTokenPayload> {\n const baseUrl = ssoBaseUrl.replace(/\\/+$/, \"\");\n\n const header = decodeJwtHeader(logoutToken);\n if (!header) {\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 格式错误\");\n }\n\n // alg 白名单仅 RS256:logout_token 是服务器间凭证,一律拒绝对称签名\n if (header.alg !== \"RS256\") {\n throw new SsoError(\n \"logout_token_unsupported_alg\",\n `不支持的 Logout Token 签名算法: ${String(header.alg)}`\n );\n }\n\n // issuer 以 Discovery 文档为准,回退调用方传入的 ssoBaseUrl\n const discovery = await fetchDiscoveryDoc(baseUrl);\n const normalizedIssuer = (discovery?.issuer || baseUrl).replace(/\\/+$/, \"\");\n\n // RS256 签名验证(kid 精确匹配;无 kid 时逐个尝试所有 RS256 签名公钥)\n const jwks = await fetchJwks(baseUrl);\n if (!jwks) {\n throw new SsoError(\n \"logout_token_invalid_signature\",\n \"无法获取 JWKS 验证 Logout Token 签名\"\n );\n }\n const kid = typeof header.kid === \"string\" ? header.kid : undefined;\n const matchCandidates = (set: Jwks) =>\n set.keys.filter(\n (k) =>\n k.kty === \"RSA\" &&\n k.alg === \"RS256\" &&\n k.use === \"sig\" &&\n (kid ? k.kid === kid : true)\n );\n const verifyAny = async (keys: JwksKey[]): Promise<boolean> => {\n for (const key of keys) {\n if (await verifyRs256Signature(logoutToken, key)) return true;\n }\n return false;\n };\n\n let candidates = matchCandidates(jwks);\n let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\n\n // 密钥轮换自愈:与 validateIdToken 一致,候选缺失或验签失败时强制重取 JWKS 重试一次\n if (candidates.length === 0 || !validSig) {\n const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });\n if (freshJwks) {\n candidates = matchCandidates(freshJwks);\n validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\n }\n }\n\n if (candidates.length === 0) {\n throw new SsoError(\n \"logout_token_invalid_signature\",\n \"JWKS 中未找到匹配的 RS256 公钥\"\n );\n }\n if (!validSig) {\n throw new SsoError(\n \"logout_token_invalid_signature\",\n \"Logout Token 签名验证失败\"\n );\n }\n\n const payload = decodeJwtPayload(logoutToken);\n if (!payload) {\n throw new SsoError(\"logout_token_invalid\", \"Logout Token payload 解析失败\");\n }\n\n const tokenIssuer =\n typeof payload.iss === \"string\" ? payload.iss.replace(/\\/+$/, \"\") : \"\";\n if (tokenIssuer !== normalizedIssuer) {\n throw new SsoError(\n \"logout_token_issuer_mismatch\",\n \"Logout Token issuer 不匹配\"\n );\n }\n\n // aud 校验:单值或数组包含 clientId\n const aud = payload.aud;\n const audList = Array.isArray(aud) ? aud : typeof aud === \"string\" ? [aud] : [];\n if (!audList.includes(clientId)) {\n throw new SsoError(\n \"logout_token_audience_mismatch\",\n \"Logout Token audience 不匹配\"\n );\n }\n\n // exp 必需且不得过期(允许 60s clock skew,与 ID Token 校验一致)\n if (typeof payload.exp !== \"number\") {\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 缺少 exp 声明\");\n }\n if (Date.now() >= payload.exp * 1000 + 60_000) {\n throw new SsoError(\"logout_token_expired\", \"Logout Token 已过期\");\n }\n\n // events 必须包含 backchannel-logout 事件键\n const events = payload.events;\n if (\n !events ||\n typeof events !== \"object\" ||\n !(BACKCHANNEL_LOGOUT_EVENT in (events as Record<string, unknown>))\n ) {\n throw new SsoError(\n \"logout_token_invalid\",\n \"Logout Token 缺少 backchannel-logout events 声明\"\n );\n }\n\n // sub / sid 至少居一\n const sub = typeof payload.sub === \"string\" && payload.sub ? payload.sub : undefined;\n const sid = typeof payload.sid === \"string\" && payload.sid ? payload.sid : undefined;\n if (!sub && !sid) {\n throw new SsoError(\n \"logout_token_invalid\",\n \"Logout Token 必须包含 sub 或 sid 至少其一\"\n );\n }\n\n // jti 必须存在且未重放(缓存到 token exp 为止)\n const jti = typeof payload.jti === \"string\" ? payload.jti : \"\";\n if (!jti) {\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 缺少 jti 声明\");\n }\n if (!recordJti(jti, payload.exp * 1000 + 60_000)) {\n throw new SsoError(\"logout_token_replay\", \"Logout Token jti 重放\");\n }\n\n return { sub, sid };\n}\n"],"mappings":"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","names":["import_server","import_server","FETCH_TIMEOUT_MS","generateRandomString","import_server"]}
|