@nihplod/sso-sdk 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/dist/index.d.mts +26 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.js +148 -84
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +148 -84
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +87 -2
- package/dist/next/index.d.ts +87 -2
- package/dist/next/index.js +330 -64
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +329 -64
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +32 -2
- package/dist/react/index.d.ts +32 -2
- package/dist/react/index.js +196 -105
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +196 -105
- 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","../../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 * ⚠️ 安全须知:middleware 只是 UX 层(未认证重定向的入口优化),\r\n * 不能作为安全边界。敏感接口必须在 Route Handler / Server Component 中\r\n * 用 @nihplod/sso-verify 对 access_token 做二次校验。\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 * @deprecated 该「主站会话 Cookie 快速通道」已移除,此选项不再生效:\r\n * 1. `__Host-user_token` 存的是主站内部 token(type=\"user\", aud=\"user\"),\r\n * introspect 端点只接受 type=\"access_token\",校验必然返回 inactive;\r\n * 2. `__Host-` Cookie 不会下发到子域/其他域名,跨域子项目根本收不到。\r\n * 该分支永不命中,却让每个请求白付一次 introspection 往返并被负缓存 30s。\r\n * 保留字段仅为兼容旧配置,请勿使用;会话检测以本站 access_token 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 * @deprecated 随「主站会话 Cookie 快速通道」一并移除(见 ssoCookieName),\r\n * 此选项不再生效。保留字段仅为兼容旧配置,请勿使用。\r\n */\r\n validateSsoCookie?: boolean;\r\n\r\n /**\r\n * Introspection 不可达(网络异常/超时/5xx,token 有效性未确证)时的策略。\r\n *\r\n * 默认 false(fail-open):对已持有 access_token Cookie 的请求放行——\r\n * middleware 只是 UX 层,敏感接口的鉴权由 Route Handler / Server Component\r\n * 兜底,此时重定向到一个同样不可达的 SSO 中心没有意义。\r\n *\r\n * 设为 true(fail-closed)时:对持有 access_token Cookie 的请求返回\r\n * 502 错误页而不是放行。适用于对可用性敏感、宁可拒绝也不放行的场景。\r\n * 注意 fail-closed 只影响「不可达」这一种结果;token 确证无效(inactive)\r\n * 无论该选项如何都会清除 Cookie 并重定向到 SSO 登录。\r\n *\r\n * ⚠️ 无论取何值,middleware 都只是 UX 层:敏感接口必须在\r\n * Route Handler / Server Component 中用 @nihplod/sso-verify 二次校验。\r\n */\r\n failClosedOnIntrospectionError?: 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 failClosedOnIntrospectionError = false,\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 // 已废弃选项告警(不区分环境,配置错误不应静默):主站会话 Cookie 快速通道已移除\r\n if (config.validateSsoCookie !== undefined || config.ssoCookieName !== undefined) {\r\n console.warn(\r\n \"[SSO SDK] validateSsoCookie / ssoCookieName 已废弃:主站会话 Cookie(__Host-user_token)\" +\r\n \"是主站内部 token(type=\\\"user\\\"),introspect 端点只接受 access_token,校验必然失败;\" +\r\n \"且 __Host- Cookie 不会下发到子域。该快速通道从未生效,已移除。\" +\r\n \"请删除这两个配置项;会话检测以本站 access_token Cookie 为准。\"\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 access_token in cookie (set by callback handler)\r\n //\r\n // 注意:这里曾有「主站会话 Cookie(__Host-user_token)快速通道」,已移除:\r\n // 该 Cookie 存的是主站内部 token(type=\"user\", aud=\"user\"),introspect 端点\r\n // 只接受 type=\"access_token\",校验必然返回 inactive;且 __Host- Cookie 不会\r\n // 下发到子域,跨域子项目根本收不到。该分支永不命中,却让每个请求白付一次\r\n // introspection 往返并被负缓存 30s。会话检测以本站 access_token Cookie 为准。\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):token 有效性未确证。\r\n // 默认 fail-open:对已持有 SSO access token Cookie 的请求放行——\r\n // middleware 只是 UX 层,Route Handler / Server Component 仍会强制鉴权;\r\n // 此时重定向到 SSO 也只会得到同样的故障。\r\n // failClosedOnIntrospectionError=true 时改为 fail-closed:返回 502,\r\n // 宁可拒绝也不放行(无 Cookie 的请求不受影响,仍走下方登录重定向)。\r\n // token 确证无效(inactive)不在此列,仍走下方重定向。\r\n if (failClosedOnIntrospectionError) {\r\n console.warn(\r\n \"[SSO SDK] introspection 不可达(网络异常/超时/5xx),failClosedOnIntrospectionError=true,对持有 SSO access token Cookie 的请求 fail-closed 返回 502。\"\r\n );\r\n return new NextResponse(\r\n \"SSO 认证服务暂时不可用(introspection unreachable),请稍后重试\",\r\n { status: 502 }\r\n );\r\n }\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 // 瞬态 cookie 以本次 state 为后缀:浏览器 cookie 是全局的,固定名称会让\r\n // 并发标签页的登录互相覆盖(state/nonce/verifier 错配 → 登录失败或低度 DoS)。\r\n // 回调侧按返回的 state 精确查找对应 cookie;旧固定名称仅作过渡期回退。\r\n const attemptSuffix = `_${state}`;\r\n\r\n // Set state cookie for CSRF verification on callback\r\n response.cookies.set(\r\n `${stateCookieName}${attemptSuffix}`,\r\n state,\r\n getHostCookieOptions(600, secureCookies)\r\n );\r\n\r\n // Set nonce cookie(与 state 同规格的 __Host- httpOnly cookie,供 callback 校验 ID Token)\r\n response.cookies.set(\r\n `${nonceCookieName}${attemptSuffix}`,\r\n nonce,\r\n getHostCookieOptions(600, secureCookies)\r\n );\r\n\r\n // Set PKCE verifier cookie(httpOnly,供 callback handler 使用)\r\n // 使用 __Secure- 前缀,允许写入 callbackPath\r\n response.cookies.set(\r\n `${verifierCookieName}${attemptSuffix}`,\r\n verifier,\r\n getSecureCookieOptions(600, callbackPath, secureCookies)\r\n );\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(\r\n `${returnUrlCookieName}${attemptSuffix}`,\r\n safeReturnUrl,\r\n getHostCookieOptions(600, secureCookies)\r\n );\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 * 请求的 OAuth scope(空格分隔),建议与 createSsoMiddleware 的 scopes 保持一致。\r\n * 仅当**显式配置**且包含 openid 时回调才强制要求 token 响应携带 id_token(fail-closed);\r\n * 未配置时保持兼容行为(id_token 存在则校验,缺失不拒绝),避免升级后旧接入方登录失败。\r\n */\r\n scopes?: 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 * 传入后优先生效;返回 undefined 时回退默认行为。\r\n * 默认行为:浏览器导航(Accept 含 text/html 且未带 ?format=json)渲染内置中文错误页\r\n * (含\"重新登录\"入口);其他请求(fetch/自动化)返回 JSON,保持 API 兼容。\r\n */\r\n renderErrorPage?: (ctx: {\r\n error: string;\r\n errorDescription: string;\r\n status: number;\r\n request: NextRequest;\r\n }) => Response | undefined | Promise<Response | undefined>;\r\n\r\n /** 调试日志开关(默认 false):回调失败时输出服务端告警日志,便于定位配置/流程问题 */\r\n debug?: boolean;\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/** HTML 转义(错误描述可能含服务端返回的任意文本) */\r\nfunction escapeHtml(value: string): string {\r\n return value.replace(/[&<>\"']/g, (ch) => {\r\n switch (ch) {\r\n case \"&\":\r\n return \"&\";\r\n case \"<\":\r\n return \"<\";\r\n case \">\":\r\n return \">\";\r\n case '\"':\r\n return \""\";\r\n default:\r\n return \"'\";\r\n }\r\n });\r\n}\r\n\r\n/** 内置品牌化错误页:面向浏览器用户,避免裸 JSON 与\"重新登录\"断头路 */\r\nfunction buildErrorPage(status: number, error: string, errorDescription: string): string {\r\n const safeDescription = escapeHtml(errorDescription);\r\n const safeError = escapeHtml(error);\r\n return `<!doctype html>\r\n<html lang=\"zh-CN\">\r\n<head>\r\n<meta charset=\"utf-8\" />\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\r\n<meta name=\"robots\" content=\"noindex\" />\r\n<title>登录失败</title>\r\n<style>\r\n :root { color-scheme: light; }\r\n body { margin: 0; font-family: system-ui, -apple-system, \"PingFang SC\", \"Microsoft YaHei\", sans-serif; background: #fafafa; color: #2c2c2c; }\r\n main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }\r\n h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }\r\n p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }\r\n .actions { display: flex; gap: 0.75rem; }\r\n a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }\r\n a.primary { background: #2c2c2c; color: #fff; }\r\n a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }\r\n .code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }\r\n</style>\r\n</head>\r\n<body>\r\n<main>\r\n <h1>登录失败</h1>\r\n <p>${safeDescription}</p>\r\n <div class=\"actions\">\r\n <a class=\"primary\" href=\"/\">重新登录</a>\r\n <a class=\"secondary\" href=\"/\">返回首页</a>\r\n </div>\r\n <p class=\"code\">错误码:${safeError}(反馈问题时请附上)</p>\r\n</main>\r\n</body>\r\n</html>`;\r\n}\r\n\r\n/** 错误响应:自定义渲染 > HTML(浏览器)> JSON(API/自动化) */\r\nfunction buildErrorResponse(\r\n request: NextRequest,\r\n status: number,\r\n error: string,\r\n errorDescription: string,\r\n custom?: CallbackRouteConfig[\"renderErrorPage\"]\r\n): NextResponse | Promise<NextResponse> {\r\n if (custom) {\r\n const handled = custom({ error, errorDescription, status, request });\r\n if (handled instanceof Promise) {\r\n return handled.then((res) =>\r\n res\r\n ? toNextResponse(res)\r\n : defaultErrorResponse(request, status, error, errorDescription)\r\n );\r\n }\r\n if (handled) return toNextResponse(handled);\r\n }\r\n return defaultErrorResponse(request, status, error, errorDescription);\r\n}\r\n\r\n/** 将自定义 Response 包装为 NextResponse(GET 包装层需要改写 cookies 清 nonce) */\r\nfunction toNextResponse(res: Response): NextResponse {\r\n if (res instanceof NextResponse) return res;\r\n return new NextResponse(res.body, {\r\n status: res.status,\r\n statusText: res.statusText,\r\n headers: res.headers,\r\n });\r\n}\r\n\r\nfunction defaultErrorResponse(\r\n request: NextRequest,\r\n status: number,\r\n error: string,\r\n errorDescription: string\r\n): NextResponse {\r\n const accept = request.headers.get(\"accept\") ?? \"\";\r\n const wantsHtml =\r\n accept.includes(\"text/html\") && request.nextUrl.searchParams.get(\"format\") !== \"json\";\r\n if (wantsHtml) {\r\n return new NextResponse(buildErrorPage(status, error, errorDescription), {\r\n status,\r\n headers: {\r\n \"Content-Type\": \"text/html; charset=utf-8\",\r\n \"Cache-Control\": \"no-store\",\r\n },\r\n });\r\n }\r\n return NextResponse.json({ error, error_description: errorDescription }, { status });\r\n}\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 scopes,\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 if (config.debug) {\r\n console.warn(\r\n `[SSO SDK] 回调失败 status=${response.status}`,\r\n request.nextUrl.searchParams.get(\"error\") ?? \"\"\r\n );\r\n }\r\n response.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n const returnedState = request.nextUrl.searchParams.get(\"state\");\r\n if (returnedState && /^[A-Za-z0-9\\-._~]{8,512}$/.test(returnedState)) {\r\n response.cookies.set(\r\n `${nonceCookieName}_${returnedState}`,\r\n \"\",\r\n getHostCookieOptions(0, secureCookies)\r\n );\r\n }\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 buildErrorResponse(\r\n request,\r\n 400,\r\n \"authorization_failed\",\r\n desc,\r\n config.renderErrorPage\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 buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"登录信息不完整,请重新发起登录\",\r\n config.renderErrorPage\r\n );\r\n }\r\n\r\n // 验证 state(从 cookie 中读取原始 state,CSRF 必需)。\r\n // state 同时是瞬态 cookie 的名称后缀,先做字符集/长度校验(middleware 生成 32 位;\r\n // 放宽下限仅为兼容测试/自定义 state,安全性由下方常量时间比较保证)\r\n if (!returnedState || !/^[A-Za-z0-9\\-._~]{8,512}$/.test(returnedState)) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"State 参数缺失或格式非法,请重新发起授权请求\",\r\n config.renderErrorPage\r\n );\r\n }\r\n const attemptSuffix = `_${returnedState}`;\r\n // 瞬态 cookie 按 state 隔离读取;旧固定名称仅作滚动升级过渡期回退\r\n const readTransientCookie = (name: string): string | undefined =>\r\n request.cookies.get(`${name}${attemptSuffix}`)?.value ??\r\n request.cookies.get(name)?.value;\r\n\r\n const savedState = readTransientCookie(stateCookieName);\r\n if (!savedState) {\r\n // 配置一致性诊断:若以\"另一种命名口径\"存在的 state cookie,说明\r\n // insecureLocalDev/Cookie 名称在 middleware 与 callback 之间不一致(最常见错配,\r\n // 表现为登录成功却被判未登录/无限跳转),给出针对性错误而非泛化的 state 缺失\r\n const alternateName = secureCookies\r\n ? toInsecureCookieName(stateCookieName)\r\n : DEFAULT_STATE_COOKIE_NAME;\r\n const alternateValue =\r\n request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ??\r\n request.cookies.get(alternateName)?.value;\r\n if (alternateValue) {\r\n return buildErrorResponse(\r\n request,\r\n 500,\r\n \"invalid_config\",\r\n \"检测到 SSO SDK 配置不一致:middleware 与 callback 的 insecureLocalDev 或 Cookie 名称不匹配,请统一配置\",\r\n config.renderErrorPage\r\n );\r\n }\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"登录会话已失效,请重新发起授权请求\",\r\n config.renderErrorPage\r\n );\r\n }\r\n // state 比较使用常量时间比较(与 core/SsoClient.handleCallback 一致,防时序侧信道)\r\n if (!timingSafeEqualString(returnedState, savedState)) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"登录会话校验失败,请重新发起授权请求\",\r\n config.renderErrorPage\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 = readTransientCookie(nonceCookieName);\r\n\r\n // 读取 PKCE code_verifier(middleware 存入的 httpOnly cookie)\r\n const verifier = readTransientCookie(verifierCookieName);\r\n if (!verifier) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"登录会话已过期(可能切换了标签页),请重新发起登录\",\r\n config.renderErrorPage\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 buildErrorResponse(\r\n request,\r\n 502,\r\n \"server_error\",\r\n \"登录服务暂时不可用(Token 请求失败),请稍后重试\",\r\n config.renderErrorPage\r\n );\r\n }\r\n }\r\n }\r\n\r\n if (lastError || !res) {\r\n return buildErrorResponse(\r\n request,\r\n 502,\r\n \"server_error\",\r\n \"登录服务暂时不可用(Token 请求失败),请稍后重试\",\r\n config.renderErrorPage\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 buildErrorResponse(\r\n request,\r\n 502,\r\n \"token_request_failed\",\r\n (errData.error_description as string) || `登录失败(Token 请求失败: HTTP ${res.status}),请稍后重试`,\r\n config.renderErrorPage\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 // expires_in 必须为有限正数,否则 access_token cookie 的 maxAge 为 NaN\r\n // (退化为会话 cookie 甚至立即失效)。\r\n if (\r\n !tokenData.access_token ||\r\n !tokenData.refresh_token ||\r\n typeof tokenData.expires_in !== \"number\" ||\r\n !Number.isFinite(tokenData.expires_in) ||\r\n tokenData.expires_in <= 0\r\n ) {\r\n return buildErrorResponse(\r\n request,\r\n 502,\r\n \"server_error\",\r\n \"登录服务返回异常(Token 响应不完整),请稍后重试\",\r\n config.renderErrorPage\r\n );\r\n }\r\n\r\n // OIDC fail-closed(仅在显式配置 scopes 且含 openid 时生效):\r\n // 防止 nonce/at_hash 绑定校验被静默跳过。未显式配置 scope 的旧接入方保持兼容行为。\r\n const requiresIdToken =\r\n scopes !== undefined && scopes.split(\" \").filter(Boolean).includes(\"openid\");\r\n if (requiresIdToken && !tokenData.id_token) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"id_token_invalid\",\r\n \"登录校验失败,请重新发起登录\",\r\n config.renderErrorPage\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 buildErrorResponse(\r\n request,\r\n 400,\r\n \"id_token_invalid\",\r\n err instanceof Error && /[\\u4e00-\\u9fff]/.test(err.message)\r\n ? err.message\r\n : \"登录校验失败,请重新发起登录\",\r\n config.renderErrorPage\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 = readTransientCookie(returnUrlCookieName) || 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(同时清除本次 state 后缀名与旧固定名)\r\n for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {\r\n response.cookies.set(name, \"\", getHostCookieOptions(0, secureCookies));\r\n response.cookies.set(`${name}${attemptSuffix}`, \"\", getHostCookieOptions(0, secureCookies));\r\n }\r\n\r\n // 清除 PKCE verifier cookie,必须使用写入时的 path(callbackPath)\r\n // 由于 callback handler 不知道 middleware 的 callbackPath,这里保守地\r\n // 同时清除 path=/ 和 path=当前请求路径两种可能\r\n for (const path of [\"/\", request.nextUrl.pathname]) {\r\n response.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, path, secureCookies));\r\n response.cookies.set(\r\n `${verifierCookieName}${attemptSuffix}`,\r\n \"\",\r\n getSecureCookieOptions(0, path, secureCookies)\r\n );\r\n }\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 * 反斜杠或 ASCII 控制字符(tab/newline/CR/DEL)。\n * WHATWG URL 解析会在解析前剥离 TAB/LF/CR,并把 \"\\\" 归一化为 \"/\",\n * 因此 \"/\\evil.com\"、\"/\\n//evil.com\" 能通过字符串前缀判断后变成跨站 URL。\n */\nconst UNSAFE_URL_CHAR_PATTERN = /[\\\\\\u0000-\\u001F\\u007F]/;\n\n/**\n * 校验 returnUrl 是否可信(防开放重定向)。\n * 仅允许:\n * - 站内相对路径(以单个 \"/\" 开头且不以 \"//\" 开头)\n * - 与 currentOrigin 完全同源的 http(s) 绝对 URL\n * 拒绝一切含反斜杠/控制字符的值(浏览器会将 \"/\\evil.com\" 解析为跨站 URL),\n * 拒绝 userinfo 与危险 scheme。\n */\nexport function isTrustedReturnUrl(url: string, currentOrigin: string): boolean {\n if (!url) return false;\n if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;\n if (url.startsWith(\"//\")) return false;\n if (url.startsWith(\"/\")) return true;\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") return false;\n if (parsed.username || parsed.password) return false;\n return parsed.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","/**\r\n * ID Token 验证工具(浏览器 / Edge Runtime 通用)\r\n *\r\n * 提取 SsoClient 与 Next.js callback 中重复的 ID Token 校验逻辑,\r\n * 包括 base64url 解码、JWKS 获取、RS256 签名验证、at_hash 计算等。\r\n */\r\n\r\nimport { SsoError } from \"./errors\";\r\nimport { timingSafeEqualString } from \"./security\";\r\n\r\n// 运行时 crypto.subtle 检测:现代浏览器 / Edge Runtime / Node 20+ 均提供全局 crypto\r\nconst _crypto: Pick<Crypto, \"subtle\"> | null =\r\n typeof crypto !== \"undefined\" && (crypto as Crypto).subtle ? (crypto as Crypto) : null;\r\n\r\nfunction getCrypto(): Crypto | null {\r\n return (_crypto as Crypto | null) ?? null;\r\n}\r\n\r\nexport interface JwksKey {\r\n kty: string;\r\n kid?: string;\r\n alg?: string;\r\n use?: string;\r\n n?: string;\r\n e?: string;\r\n x5c?: string[];\r\n}\r\n\r\nexport interface Jwks {\r\n keys: JwksKey[];\r\n}\r\n\r\nexport interface ValidateIdTokenResult {\r\n sub: string;\r\n}\r\n\r\nexport interface ValidateIdTokenOptions {\r\n /**\r\n * 发现 SSO 中心已启用 RS256 时,是否拒绝 HS256 ID Token。\r\n * 默认 true:只要 JWKS 中存在 RS256 签名密钥,就拒绝 HS256(安全推荐)。\r\n *\r\n * @deprecated 无实际作用:HS256 ID Token 一律被拒绝(对称密钥无法安全分发给\r\n * Public Client / BFF),该选项只影响走哪条抛错路径\r\n * (id_token_unsupported_alg 或 id_token_hs256_unsupported),\r\n * 不影响最终结果。保留仅为避免破坏 API,请勿依赖;将于未来大版本移除。\r\n */\r\n rejectHs256WhenRs256Available?: boolean;\r\n /**\r\n * 期望的 OIDC nonce(login 时生成并随 authorize 请求发送)。\r\n * 传入后 ID Token 必须携带 nonce claim 且与该值常量时间相等(fail-closed),\r\n * 否则抛出 id_token_nonce_mismatch,防止 ID Token 重放。\r\n * 不传则跳过 nonce 校验(如 refresh 流程签发的 ID Token)。\r\n */\r\n expectedNonce?: string;\r\n}\r\n\r\nfunction base64UrlDecode(input: string): Uint8Array {\r\n const base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\r\n const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), \"=\");\r\n const binary = atob(padded);\r\n const bytes = new Uint8Array(binary.length);\r\n for (let i = 0; i < binary.length; i++) {\r\n bytes[i] = binary.charCodeAt(i);\r\n }\r\n return bytes;\r\n}\r\n\r\n/** 解码 JWT payload(不验证签名) */\r\nexport function decodeJwtPayload(token: string): Record<string, unknown> | null {\r\n try {\r\n const parts = token.split(\".\");\r\n if (parts.length !== 3) return null;\r\n const json = new TextDecoder().decode(base64UrlDecode(parts[1]));\r\n return JSON.parse(json) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/** 解码 JWT header */\r\nexport function decodeJwtHeader(token: string): Record<string, unknown> | null {\r\n try {\r\n const parts = token.split(\".\");\r\n if (parts.length !== 3) return null;\r\n const json = new TextDecoder().decode(base64UrlDecode(parts[0]));\r\n return JSON.parse(json) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/** 标准化 issuer/base URL,去除末尾斜杠 */\r\nfunction normalizeIssuer(url: string): string {\r\n return url.replace(/\\/+$/, \"\");\r\n}\r\n\r\n/** OIDC Discovery 文档(仅取本模块关心的字段) */\r\nexport interface OidcDiscoveryDoc {\r\n issuer?: string;\r\n jwks_uri?: string;\r\n}\r\n\r\nlet cachedJwks: { baseUrl: string; jwks: Jwks; fetchedAt: number } | null = null;\r\nlet cachedDiscovery: { baseUrl: string; doc: OidcDiscoveryDoc; fetchedAt: number } | null = null;\r\nconst JWKS_CACHE_TTL_MS = 5 * 60 * 1000;\r\n/** discovery / JWKS 拉取超时(与 SsoClient._getDiscovery 的 10s 一致) */\r\nconst FETCH_TIMEOUT_MS = 10000;\r\n\r\n/**\r\n * 拉取 OIDC Discovery 文档(带内存缓存;失败返回 null 不抛错)\r\n *\r\n * 只缓存成功结果:失败(网络错误 / 非 2xx)不缓存,主站短暂抖动不会让\r\n * 登录持续失败到 TTL 期满,下一次调用立即重试(与 core/discovery.ts 策略一致)。\r\n */\r\nexport async function fetchDiscoveryDoc(baseUrl: string): Promise<OidcDiscoveryDoc | null> {\r\n const now = Date.now();\r\n if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {\r\n return cachedDiscovery.doc;\r\n }\r\n\r\n try {\r\n const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {\r\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\r\n });\r\n if (!res.ok) return null;\r\n const doc = (await res.json()) as OidcDiscoveryDoc;\r\n cachedDiscovery = { baseUrl, doc, fetchedAt: now };\r\n return doc;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nexport interface FetchJwksOptions {\r\n /**\r\n * 绕过内存缓存与浏览器 HTTP 缓存(cache: \"no-cache\")强制重取 JWKS,\r\n * 并同步刷新模块级缓存。用于密钥轮换后 kid 失配的自愈重试。\r\n */\r\n forceRefresh?: boolean;\r\n}\r\n\r\n/** 从 SSO 中心拉取 JWKS(带内存缓存;优先 Discovery 的 jwks_uri,回退硬编码路径) */\r\nexport async function fetchJwks(baseUrl: string, options: FetchJwksOptions = {}): Promise<Jwks | null> {\r\n const now = Date.now();\r\n if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {\r\n return cachedJwks.jwks;\r\n }\r\n\r\n const discovery = await fetchDiscoveryDoc(baseUrl);\r\n const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;\r\n\r\n try {\r\n const res = await fetch(jwksUri, {\r\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\r\n ...(options.forceRefresh ? { cache: \"no-cache\" as RequestCache } : {}),\r\n });\r\n if (!res.ok) return null;\r\n const jwks = (await res.json()) as Jwks;\r\n cachedJwks = { baseUrl, jwks, fetchedAt: now };\r\n return jwks;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/** 清空 JWKS / Discovery 内存缓存(测试与密钥轮换场景使用) */\r\nexport function clearIdTokenCaches(): void {\r\n cachedJwks = null;\r\n cachedDiscovery = null;\r\n}\r\n\r\n/** 使用 RS256 公钥验证 ID Token 签名 */\r\nexport async function verifyRs256Signature(token: string, jwk: JwksKey): Promise<boolean> {\r\n try {\r\n const [headerB64, payloadB64, signature] = token.split(\".\");\r\n if (!signature || !jwk.n || !jwk.e) return false;\r\n\r\n const c = getCrypto();\r\n if (!c) return false;\r\n\r\n const cryptoKey = await c.subtle.importKey(\r\n \"jwk\",\r\n { kty: \"RSA\", n: jwk.n, e: jwk.e, alg: \"RS256\", ext: false },\r\n { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-256\" },\r\n false,\r\n [\"verify\"]\r\n );\r\n\r\n const signatureBytes = base64UrlDecode(signature);\r\n const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);\r\n return await c.subtle.verify(\r\n \"RSASSA-PKCS1-v1_5\",\r\n cryptoKey,\r\n signatureBytes as unknown as BufferSource,\r\n data as unknown as BufferSource\r\n );\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\n/** 计算 OIDC at_hash(access_token SHA-256 前 128bit base64url) */\r\nexport async function computeAtHash(accessToken: string): Promise<string> {\r\n const c = getCrypto();\r\n if (!c) return \"\";\r\n const hash = await c.subtle.digest(\"SHA-256\", new TextEncoder().encode(accessToken));\r\n const bytes = new Uint8Array(hash);\r\n const half = bytes.slice(0, bytes.length / 2);\r\n let binary = \"\";\r\n for (const b of half) {\r\n binary += String.fromCharCode(b);\r\n }\r\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\r\n}\r\n\r\n/** 验证 ID Token(iss、aud、exp、iat、azp、sub、签名、at_hash) */\r\nexport async function validateIdToken(\r\n idToken: string,\r\n accessToken: string,\r\n expectedIssuer: string,\r\n expectedClientId: string,\r\n options: ValidateIdTokenOptions = {}\r\n): Promise<ValidateIdTokenResult> {\r\n const { rejectHs256WhenRs256Available = true, expectedNonce } = options;\r\n\r\n const header = decodeJwtHeader(idToken);\r\n if (!header) {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token 格式错误\");\r\n }\r\n\r\n const alg = header.alg;\r\n if (typeof alg !== \"string\" || (alg !== \"RS256\" && alg !== \"HS256\")) {\r\n throw new SsoError(\"id_token_unsupported_alg\", `不支持的 ID Token 签名算法: ${alg}`);\r\n }\r\n\r\n const baseUrl = normalizeIssuer(expectedIssuer);\r\n // issuer 以 Discovery 文档的 issuer 为准(防伪站伪造 iss 通过校验),回退调用方传入值\r\n const discovery = await fetchDiscoveryDoc(baseUrl);\r\n const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);\r\n\r\n // RS256:通过 JWKS 验证签名\r\n if (alg === \"RS256\") {\r\n const jwks = await fetchJwks(baseUrl);\r\n if (!jwks) {\r\n throw new SsoError(\"id_token_invalid_signature\", \"无法获取 JWKS 验证 ID Token 签名\");\r\n }\r\n const kid = typeof header.kid === \"string\" ? header.kid : undefined;\r\n // 有 kid 时精确匹配;无 kid 时逐个尝试所有 RS256 签名公钥\r\n const matchCandidates = (set: Jwks) =>\r\n set.keys.filter(\r\n (k) =>\r\n k.kty === \"RSA\" &&\r\n k.alg === \"RS256\" &&\r\n k.use === \"sig\" &&\r\n (kid ? k.kid === kid : true)\r\n );\r\n const verifyAny = async (keys: JwksKey[]): Promise<boolean> => {\r\n for (const key of keys) {\r\n if (await verifyRs256Signature(idToken, key)) return true;\r\n }\r\n return false;\r\n };\r\n\r\n let candidates = matchCandidates(jwks);\r\n let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\r\n\r\n // 密钥轮换自愈:按 kid 过滤无候选,或所有候选验签失败时,\r\n // 可能是本地 JWKS 缓存陈旧 —— 强制重取一次 JWKS 后重试,仍失败才抛错\r\n if (candidates.length === 0 || !validSig) {\r\n const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });\r\n if (freshJwks) {\r\n candidates = matchCandidates(freshJwks);\r\n validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\r\n }\r\n }\r\n\r\n if (candidates.length === 0) {\r\n throw new SsoError(\"id_token_invalid_signature\", \"JWKS 中未找到匹配的 RS256 公钥\");\r\n }\r\n if (!validSig) {\r\n throw new SsoError(\"id_token_invalid_signature\", \"ID Token 签名验证失败\");\r\n }\r\n }\r\n\r\n // HS256:对称密钥无法安全分发给 Public Client / BFF。\r\n // SDK 拒绝 HS256 ID Token:主站需配置 RS256 密钥对以启用安全验证。\r\n if (alg === \"HS256\") {\r\n if (rejectHs256WhenRs256Available) {\r\n const jwks = await fetchJwks(baseUrl);\r\n const hasRs256 = jwks?.keys?.some((k) => k.alg === \"RS256\" && k.use === \"sig\");\r\n if (hasRs256) {\r\n throw new SsoError(\"id_token_unsupported_alg\", \"SSO 中心已配置 RS256,拒绝 HS256 ID Token\");\r\n }\r\n }\r\n throw new SsoError(\r\n \"id_token_hs256_unsupported\",\r\n \"ID Token 使用 HS256 对称签名,SDK 无法安全验证。\" +\r\n \"请联系管理员在 SSO 主站配置 RS256 密钥对(JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY)后重新签发。\"\r\n );\r\n }\r\n\r\n const payload = decodeJwtPayload(idToken);\r\n if (!payload) {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token payload 解析失败\");\r\n }\r\n\r\n const tokenIssuer = typeof payload.iss === \"string\" ? normalizeIssuer(payload.iss) : \"\";\r\n if (tokenIssuer !== normalizedIssuer) {\r\n throw new SsoError(\"id_token_issuer_mismatch\", \"ID Token issuer 不匹配\");\r\n }\r\n\r\n // aud 校验:单值或数组包含 clientId;多 aud 时按 OIDC Core §3.1.3.7 要求 azp 必须等于 clientId\r\n const aud = payload.aud;\r\n const audList = Array.isArray(aud) ? aud : typeof aud === \"string\" ? [aud] : [];\r\n if (!audList.includes(expectedClientId)) {\r\n throw new SsoError(\"id_token_audience_mismatch\", \"ID Token audience 不匹配\");\r\n }\r\n if (audList.length > 1 && payload.azp !== expectedClientId) {\r\n throw new SsoError(\"id_token_audience_mismatch\", \"ID Token 多 audience 时 azp 必须为当前 client\");\r\n }\r\n\r\n // exp 必需且不得过期(允许 60s clock skew,与 iat 校验对称)\r\n if (typeof payload.exp !== \"number\") {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token 缺少 exp 声明\");\r\n }\r\n if (Date.now() >= payload.exp * 1000 + 60_000) {\r\n throw new SsoError(\"id_token_expired\", \"ID Token 已过期\");\r\n }\r\n\r\n // iat 不得在未来(允许 60s clock skew)\r\n if (typeof payload.iat === \"number\" && payload.iat * 1000 > Date.now() + 60_000) {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token iat 在未来,疑似伪造或时钟异常\");\r\n }\r\n\r\n if (typeof payload.sub !== \"string\" || !payload.sub) {\r\n throw new SsoError(\"id_token_missing_sub\", \"ID Token 缺少 sub\");\r\n }\r\n\r\n // 校验 at_hash:确保 ID Token 与当前 access_token 绑定(常量时间比较)\r\n if (typeof payload.at_hash === \"string\" && payload.at_hash) {\r\n const actual = await computeAtHash(accessToken);\r\n if (!timingSafeEqualString(actual, payload.at_hash)) {\r\n throw new SsoError(\"id_token_at_hash_mismatch\", \"ID Token at_hash 不匹配\");\r\n }\r\n }\r\n\r\n // 校验 nonce(OIDC Core §3.1.3.7):调用方传入 expectedNonce 时 fail-closed,\r\n // ID Token 必须携带 nonce 且与登录时生成的值常量时间相等,防 ID Token 重放\r\n if (expectedNonce !== undefined) {\r\n const tokenNonce = typeof payload.nonce === \"string\" ? payload.nonce : \"\";\r\n if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {\r\n throw new SsoError(\"id_token_nonce_mismatch\", \"ID Token nonce 不匹配\");\r\n }\r\n }\r\n\r\n return { sub: payload.sub };\r\n}\r\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 // POST 登出 CSRF 防护:必须是同源发起(Origin 优先,Sec-Fetch-Site 兜底)。\n // 跨站表单 POST 会携带 Origin/Sec-Fetch-Site: cross-site,直接拒绝。\n const requestOrigin = request.headers.get(\"origin\");\n if (requestOrigin && requestOrigin !== callbackOrigin) {\n return NextResponse.json(\n { error: \"forbidden\", error_description: \"跨站请求被拒绝\" },\n { status: 403 }\n );\n }\n const secFetchSite = request.headers.get(\"sec-fetch-site\");\n if (\n !requestOrigin &&\n secFetchSite &&\n secFetchSite !== \"same-origin\" &&\n secFetchSite !== \"none\"\n ) {\n return NextResponse.json(\n { error: \"forbidden\", error_description: \"跨站请求被拒绝\" },\n { status: 403 }\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\n // 清理带 state 后缀的瞬态 cookie 残留(middleware 的多标签页隔离格式:\n // `${name}_${state}`)。过期时间为 10 分钟,这里主动清理避免残留。\n const suffixedPrefixes = [\n `${stateCookieName}_`,\n `${nonceCookieName}_`,\n `${returnUrlCookieName}_`,\n `${verifierCookieName}_`,\n ];\n for (const cookie of request.cookies.getAll()) {\n if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;\n if (cookie.name.startsWith(`${verifierCookieName}_`)) {\n res.cookies.set(cookie.name, \"\", getSecureCookieOptions(0, \"/\", secureCookies));\n res.cookies.set(cookie.name, \"\", getSecureCookieOptions(0, callbackPath, secureCookies));\n } else {\n res.cookies.set(cookie.name, \"\", getHostCookieOptions(0, secureCookies));\n }\n }\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 {\n verifyLogoutTokenDetailed,\n releaseLogoutTokenJti,\n type LogoutTokenPayload,\n type VerifiedLogoutToken,\n} 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 verified: VerifiedLogoutToken;\n try {\n verified = await verifyLogoutTokenDetailed(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?.(verified.payload, request);\n } catch (err) {\n // 钩子失败时释放 jti:否则 IdP 用同一 logout_token 重投会被重放检查\n // 以 400 拒绝(IdP 视为 token 无效、不再重试),RP 本地会话永远清不掉。\n // 成功路径不得释放。\n releaseLogoutTokenJti(verified.jti);\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","/**\r\n * OIDC Backchannel Logout Token 验证\r\n *\r\n * 复用 core/id-token.ts 的 Discovery + JWKS 基建验证 IdP 推送的 logout_token:\r\n * - alg 仅允许 RS256(logout_token 是服务器间凭证,不接受对称签名)\r\n * - issuer 以 Discovery 文档为准(防伪站伪造 iss 通过校验)\r\n * - aud 必须等于本 client、exp 必须有效\r\n * - type 必须为 \"logout_token\"(防跨用途 token 混用,与 sso-verify 对齐)\r\n * - events 必须包含 backchannel-logout 事件键,且事件值必须是对象\r\n * - sub / sid 至少居一;jti 必须存在且未重放(进程内 LRU)\r\n *\r\n * 与 ID Token 的差异:logout_token 不得要求 nonce(规范明确禁止携带),\r\n * 也不校验 at_hash / iat。\r\n */\r\n\r\nimport { SsoError } from \"./errors\";\r\nimport {\r\n decodeJwtHeader,\r\n decodeJwtPayload,\r\n fetchDiscoveryDoc,\r\n fetchJwks,\r\n verifyRs256Signature,\r\n type Jwks,\r\n type JwksKey,\r\n} from \"./id-token\";\r\n\r\n/** Backchannel Logout 事件键(OIDC Back-Channel Logout 1.0) */\r\nexport const BACKCHANNEL_LOGOUT_EVENT =\r\n \"http://schemas.openid.net/event/backchannel-logout\";\r\n\r\n/** 验证通过的 logout_token 负载(仅暴露子站清会话所需的标识) */\r\nexport interface LogoutTokenPayload {\r\n sub?: string;\r\n sid?: string;\r\n}\r\n\r\n/** 验证结果:负载 + jti(供调用方在本地处理失败时释放重放标记,允许 IdP 重投) */\r\nexport interface VerifiedLogoutToken {\r\n payload: LogoutTokenPayload;\r\n jti: string;\r\n}\r\n\r\n/** jti 防重放缓存:jti → 过期时间(epoch ms),容量上限 1000 */\r\nconst JTI_CACHE_CAPACITY = 1000;\r\nconst seenJti = new Map<string, number>();\r\n\r\n/** 清空 jti 防重放缓存(测试用) */\r\nexport function clearLogoutTokenReplayCache(): void {\r\n seenJti.clear();\r\n}\r\n\r\n/**\r\n * 释放已记录的 jti。\r\n *\r\n * 仅当 logout_token 验证通过、但调用方的本地会话清理(onLogout 钩子)失败、\r\n * 需要允许 IdP 用同一 logout_token 重投时调用。成功后调用会使重放保护失效,\r\n * 因此正常成功路径不得调用。\r\n */\r\nexport function releaseLogoutTokenJti(jti: string): void {\r\n seenJti.delete(jti);\r\n}\r\n\r\n/**\r\n * 记录 jti;返回 false 表示重放(该 jti 尚未过期且已出现过)。\r\n * 缓存到 token 自身 exp 为止;超过容量时先清理过期项,再按插入序淘汰最旧项。\r\n */\r\nfunction recordJti(jti: string, expiresAtMs: number): boolean {\r\n const now = Date.now();\r\n const existing = seenJti.get(jti);\r\n if (existing !== undefined && existing > now) return false;\r\n\r\n // 清理过期项,避免缓存被历史数据撑满\r\n for (const [key, exp] of seenJti) {\r\n if (exp <= now) seenJti.delete(key);\r\n }\r\n // 仍超容量:淘汰最旧插入项(Map 保持插入序)\r\n while (seenJti.size >= JTI_CACHE_CAPACITY) {\r\n const oldest = seenJti.keys().next().value;\r\n if (oldest === undefined) break;\r\n seenJti.delete(oldest);\r\n }\r\n seenJti.set(jti, expiresAtMs);\r\n return true;\r\n}\r\n\r\n/**\r\n * 验证 Backchannel Logout Token 并返回负载 + jti\r\n *\r\n * @param logoutToken IdP POST 到 backchannelLogoutUri 的 logout_token(JWT)\r\n * @param ssoBaseUrl SSO 中心地址(Discovery / JWKS 基准)\r\n * @param clientId 本应用 Client ID(aud 必须等于它)\r\n * @returns 验证通过的负载与 jti\r\n * @throws SsoError 任一校验失败\r\n */\r\nexport async function verifyLogoutTokenDetailed(\r\n logoutToken: string,\r\n ssoBaseUrl: string,\r\n clientId: string\r\n): Promise<VerifiedLogoutToken> {\r\n const baseUrl = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n\r\n const header = decodeJwtHeader(logoutToken);\r\n if (!header) {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 格式错误\");\r\n }\r\n\r\n // alg 白名单仅 RS256:logout_token 是服务器间凭证,一律拒绝对称签名\r\n if (header.alg !== \"RS256\") {\r\n throw new SsoError(\r\n \"logout_token_unsupported_alg\",\r\n `不支持的 Logout Token 签名算法: ${String(header.alg)}`\r\n );\r\n }\r\n\r\n // issuer 以 Discovery 文档为准,回退调用方传入的 ssoBaseUrl\r\n const discovery = await fetchDiscoveryDoc(baseUrl);\r\n const normalizedIssuer = (discovery?.issuer || baseUrl).replace(/\\/+$/, \"\");\r\n\r\n // RS256 签名验证(kid 精确匹配;无 kid 时逐个尝试所有 RS256 签名公钥)\r\n const jwks = await fetchJwks(baseUrl);\r\n if (!jwks) {\r\n throw new SsoError(\r\n \"logout_token_invalid_signature\",\r\n \"无法获取 JWKS 验证 Logout Token 签名\"\r\n );\r\n }\r\n const kid = typeof header.kid === \"string\" ? header.kid : undefined;\r\n const matchCandidates = (set: Jwks) =>\r\n set.keys.filter(\r\n (k) =>\r\n k.kty === \"RSA\" &&\r\n k.alg === \"RS256\" &&\r\n k.use === \"sig\" &&\r\n (kid ? k.kid === kid : true)\r\n );\r\n const verifyAny = async (keys: JwksKey[]): Promise<boolean> => {\r\n for (const key of keys) {\r\n if (await verifyRs256Signature(logoutToken, key)) return true;\r\n }\r\n return false;\r\n };\r\n\r\n let candidates = matchCandidates(jwks);\r\n let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\r\n\r\n // 密钥轮换自愈:与 validateIdToken 一致,候选缺失或验签失败时强制重取 JWKS 重试一次\r\n if (candidates.length === 0 || !validSig) {\r\n const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });\r\n if (freshJwks) {\r\n candidates = matchCandidates(freshJwks);\r\n validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\r\n }\r\n }\r\n\r\n if (candidates.length === 0) {\r\n throw new SsoError(\r\n \"logout_token_invalid_signature\",\r\n \"JWKS 中未找到匹配的 RS256 公钥\"\r\n );\r\n }\r\n if (!validSig) {\r\n throw new SsoError(\r\n \"logout_token_invalid_signature\",\r\n \"Logout Token 签名验证失败\"\r\n );\r\n }\r\n\r\n const payload = decodeJwtPayload(logoutToken);\r\n if (!payload) {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token payload 解析失败\");\r\n }\r\n\r\n const tokenIssuer =\r\n typeof payload.iss === \"string\" ? payload.iss.replace(/\\/+$/, \"\") : \"\";\r\n if (tokenIssuer !== normalizedIssuer) {\r\n throw new SsoError(\r\n \"logout_token_issuer_mismatch\",\r\n \"Logout Token issuer 不匹配\"\r\n );\r\n }\r\n\r\n // aud 校验:单值或数组包含 clientId\r\n const aud = payload.aud;\r\n const audList = Array.isArray(aud) ? aud : typeof aud === \"string\" ? [aud] : [];\r\n if (!audList.includes(clientId)) {\r\n throw new SsoError(\r\n \"logout_token_audience_mismatch\",\r\n \"Logout Token audience 不匹配\"\r\n );\r\n }\r\n\r\n // exp 必需且不得过期(允许 60s clock skew,与 ID Token 校验一致)\r\n if (typeof payload.exp !== \"number\") {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 缺少 exp 声明\");\r\n }\r\n if (Date.now() >= payload.exp * 1000 + 60_000) {\r\n throw new SsoError(\"logout_token_expired\", \"Logout Token 已过期\");\r\n }\r\n\r\n // type 必须为 logout_token:防止拿其他用途的 token(如 access_token /\r\n // profile_event token)冒充 logout_token 触发登出(与 sso-verify 对齐)\r\n if (payload.type !== \"logout_token\") {\r\n throw new SsoError(\r\n \"logout_token_invalid\",\r\n \"Logout Token type 必须为 logout_token\"\r\n );\r\n }\r\n\r\n // events 必须是对象,且必须包含 backchannel-logout 事件键、事件值必须是对象\r\n // (通常为空对象 {};数组/null/原始值视为畸形,与 sso-verify 对齐)\r\n const events = payload.events;\r\n if (!events || typeof events !== \"object\" || Array.isArray(events)) {\r\n throw new SsoError(\r\n \"logout_token_invalid\",\r\n \"Logout Token 缺少 backchannel-logout events 声明\"\r\n );\r\n }\r\n const logoutEvent = (events as Record<string, unknown>)[BACKCHANNEL_LOGOUT_EVENT];\r\n if (\r\n !logoutEvent ||\r\n typeof logoutEvent !== \"object\" ||\r\n Array.isArray(logoutEvent)\r\n ) {\r\n throw new SsoError(\r\n \"logout_token_invalid\",\r\n \"Logout Token 缺少 backchannel-logout events 声明\"\r\n );\r\n }\r\n\r\n // sub / sid 至少居一\r\n const sub = typeof payload.sub === \"string\" && payload.sub ? payload.sub : undefined;\r\n const sid = typeof payload.sid === \"string\" && payload.sid ? payload.sid : undefined;\r\n if (!sub && !sid) {\r\n throw new SsoError(\r\n \"logout_token_invalid\",\r\n \"Logout Token 必须包含 sub 或 sid 至少其一\"\r\n );\r\n }\r\n\r\n // jti 必须存在且未重放(缓存到 token exp 为止)\r\n const jti = typeof payload.jti === \"string\" ? payload.jti : \"\";\r\n if (!jti) {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 缺少 jti 声明\");\r\n }\r\n if (!recordJti(jti, payload.exp * 1000 + 60_000)) {\r\n throw new SsoError(\"logout_token_replay\", \"Logout Token jti 重放\");\r\n }\r\n\r\n return { payload: { sub, sid }, jti };\r\n}\r\n\r\n/**\r\n * 验证 Backchannel Logout Token(仅返回负载,向后兼容入口)\r\n *\r\n * 注意:除验证外还会记录 jti 防重放。调用方若在验证通过后本地处理失败,\r\n * 需改用 verifyLogoutTokenDetailed 获取 jti 并调用 releaseLogoutTokenJti 释放,\r\n * 以便 IdP 重投。\r\n */\r\nexport async function verifyLogoutToken(\r\n logoutToken: string,\r\n ssoBaseUrl: string,\r\n clientId: string\r\n): Promise<LogoutTokenPayload> {\r\n const verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);\r\n return verified.payload;\r\n}\r\n"],"mappings":"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","names":["import_server","import_server","FETCH_TIMEOUT_MS","generateRandomString","import_server"]}
|
|
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/core/http.ts","../../src/next/refresh.ts","../../src/next/logout.ts","../../src/core/discovery.ts","../../src/next/backchannel-logout.ts","../../src/core/logout-token.ts"],"sourcesContent":["/**\r\n * @nihplod/sso-sdk/next\r\n *\r\n * Next.js App Router 绑定:\r\n * - createSsoMiddleware: Next.js Middleware 辅助函数\r\n * - createCallbackRouteHandler: App Router 回调 Route Handler\r\n * - createRefreshRouteHandler: App Router 刷新 Route Handler(httpOnly refresh cookie)\r\n * - createLogoutRouteHandler: App Router 登出 Route Handler\r\n * - createBackchannelLogoutRouteHandler: App Router Backchannel Logout 接收端\r\n */\r\n\r\nexport { createSsoMiddleware } from \"./middleware\";\r\nexport type { SsoMiddlewareConfig } from \"./middleware\";\r\n\r\nexport { createCallbackRouteHandler } from \"./callback\";\r\nexport type { CallbackRouteConfig } from \"./callback\";\r\n\r\nexport { createRefreshRouteHandler } from \"./refresh\";\r\nexport type { RefreshRouteConfig } from \"./refresh\";\r\n\r\nexport { createLogoutRouteHandler } from \"./logout\";\r\nexport type { LogoutRouteConfig } from \"./logout\";\r\n\r\nexport { createBackchannelLogoutRouteHandler } from \"./backchannel-logout\";\r\nexport type { BackchannelLogoutRouteConfig } from \"./backchannel-logout\";\r\nexport type { LogoutJtiStore } from \"../core/logout-token\";\r\n\r\nexport {\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 DEFAULT_LOGOUT_STATE_COOKIE_NAME,\r\n getHostCookieOptions,\r\n getSecureCookieOptions,\r\n toInsecureCookieName,\r\n} from \"./constants\";\r\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 * ⚠️ 安全须知:middleware 只是 UX 层(未认证重定向的入口优化),\r\n * 不能作为安全边界。敏感接口必须在 Route Handler / Server Component 中\r\n * 用 @nihplod/sso-verify 对 access_token 做二次校验。\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 * 公开路由前缀(不需要认证)。\r\n *\r\n * 注意:/api/ 下的路径永不按静态资源扩展名(.js/.css/...)放行;\r\n * 如需公开某个 API 路径,必须在此显式列出。\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 * @deprecated 该「主站会话 Cookie 快速通道」已移除,此选项不再生效:\r\n * 1. `__Host-user_token` 存的是主站内部 token(type=\"user\", aud=\"user\"),\r\n * introspect 端点只接受 type=\"access_token\",校验必然返回 inactive;\r\n * 2. `__Host-` Cookie 不会下发到子域/其他域名,跨域子项目根本收不到。\r\n * 该分支永不命中,却让每个请求白付一次 introspection 往返并被负缓存 30s。\r\n * 保留字段仅为兼容旧配置,请勿使用;会话检测以本站 access_token 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 * @deprecated 随「主站会话 Cookie 快速通道」一并移除(见 ssoCookieName),\r\n * 此选项不再生效。保留字段仅为兼容旧配置,请勿使用。\r\n */\r\n validateSsoCookie?: boolean;\r\n\r\n /**\r\n * Introspection 不可达(网络异常/超时/5xx,token 有效性未确证)时的策略。\r\n *\r\n * 默认 false(fail-open):对已持有 access_token Cookie 的请求放行——\r\n * middleware 只是 UX 层,敏感接口的鉴权由 Route Handler / Server Component\r\n * 兜底,此时重定向到一个同样不可达的 SSO 中心没有意义。\r\n *\r\n * 设为 true(fail-closed)时:对持有 access_token Cookie 的请求返回\r\n * 502 错误页而不是放行。适用于对可用性敏感、宁可拒绝也不放行的场景。\r\n * 注意 fail-closed 只影响「不可达」这一种结果;token 确证无效(inactive)\r\n * 无论该选项如何都会清除 Cookie 并重定向到 SSO 登录。\r\n *\r\n * ⚠️ 无论取何值,middleware 都只是 UX 层:敏感接口必须在\r\n * Route Handler / Server Component 中用 @nihplod/sso-verify 二次校验。\r\n */\r\n failClosedOnIntrospectionError?: 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\n * 通配符必须按路径段边界匹配:\"/docs/:path*\" 只应命中 /docs 与 /docs/...,\r\n * 不能命中 /docs-private(否则 publicPaths 会意外公开兄弟路径,造成鉴权绕过)\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*\")) {\r\n const base = path.slice(0, -\"/:path*\".length);\r\n return pathname === base || pathname.startsWith(base + \"/\");\r\n }\r\n return pathname.startsWith(path + \"/\");\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 确证无效(introspect 返回 200 且 active:false),调用方应重定向登录\r\n * - \"unreachable\":introspect 不可达(网络异常/超时/5xx/客户端认证失败 401/403),\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// 客户端认证失败(clientSecret/clientId 配置错误)只告警一次,避免日志刷屏\r\nlet warnedIntrospectClientAuthFailure = false;\r\n// 限流(429)只告警一次:继续按 unreachable 处理(见下方说明),但不能再静默\r\nlet warnedIntrospectRateLimited = false;\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 === 429) {\r\n // 限流表示“未确证 token 是否有效”,不能当作 inactive 清 Cookie,也不能静默:\r\n // 按 unreachable 处理并告警,提醒运维放宽/拆分 client 级限流配额。\r\n if (!warnedIntrospectRateLimited) {\r\n warnedIntrospectRateLimited = true;\r\n console.warn(\r\n \"[SSO SDK] introspection 被限流(HTTP 429):请为子站 BFF 配置独立的 client 级配额,\" +\r\n \"或降低 middleware introspection 频率。本次按 unreachable 处理(默认 fail-open)。\"\r\n );\r\n }\r\n return \"unreachable\";\r\n }\r\n if (res.status !== 401 && res.status !== 403) {\r\n // 非认证错误(如 500):introspect 不可达,不缓存,让下次请求重试\r\n return \"unreachable\";\r\n }\r\n // 401/403 只可能来自客户端认证失败:introspect 对无效/过期 token 返回\r\n // 200 {active:false},仅 invalid_client(clientSecret/clientId 配置错误)才 401/403。\r\n // 若误判为 token 失效并写负缓存,会形成\"清 Cookie -> authorize -> 回调写回 ->\r\n // 再次 401\"的静默重定向死循环。按不可达处理:不缓存,默认 fail-open,\r\n // 敏感路由仍由 Route Handler / Server Component 强制鉴权兜底。\r\n if (!warnedIntrospectClientAuthFailure) {\r\n warnedIntrospectClientAuthFailure = true;\r\n console.warn(\r\n `[SSO SDK] introspection 客户端认证失败(HTTP ${res.status}):` +\r\n \"请检查 clientId/clientSecret 与主站注册信息是否一致。\" +\r\n \"该故障不会被缓存为 token 失效,也不会触发静默重登录循环。\"\r\n );\r\n }\r\n return \"unreachable\";\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 failClosedOnIntrospectionError = false,\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 // 已废弃选项告警(不区分环境,配置错误不应静默):主站会话 Cookie 快速通道已移除\r\n if (config.validateSsoCookie !== undefined || config.ssoCookieName !== undefined) {\r\n console.warn(\r\n \"[SSO SDK] validateSsoCookie / ssoCookieName 已废弃:主站会话 Cookie(__Host-user_token)\" +\r\n \"是主站内部 token(type=\\\"user\\\"),introspect 端点只接受 access_token,校验必然失败;\" +\r\n \"且 __Host- Cookie 不会下发到子域。该快速通道从未生效,已移除。\" +\r\n \"请删除这两个配置项;会话检测以本站 access_token Cookie 为准。\"\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 // /api/ 下的路径永不按静态资源放行:攻击者可构造 /api/xxx.js 形式的 API 路径\r\n // 绕过鉴权;API 路由即使扩展名像静态资源也必须走完整认证检查\r\n if (\r\n !pathname.startsWith(\"/api/\") &&\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 access_token in cookie (set by callback handler)\r\n //\r\n // 注意:这里曾有「主站会话 Cookie(__Host-user_token)快速通道」,已移除:\r\n // 该 Cookie 存的是主站内部 token(type=\"user\", aud=\"user\"),introspect 端点\r\n // 只接受 type=\"access_token\",校验必然返回 inactive;且 __Host- Cookie 不会\r\n // 下发到子域,跨域子项目根本收不到。该分支永不命中,却让每个请求白付一次\r\n // introspection 往返并被负缓存 30s。会话检测以本站 access_token Cookie 为准。\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):token 有效性未确证。\r\n // 默认 fail-open:对已持有 SSO access token Cookie 的请求放行——\r\n // middleware 只是 UX 层,Route Handler / Server Component 仍会强制鉴权;\r\n // 此时重定向到 SSO 也只会得到同样的故障。\r\n // failClosedOnIntrospectionError=true 时改为 fail-closed:返回 502,\r\n // 宁可拒绝也不放行(无 Cookie 的请求不受影响,仍走下方登录重定向)。\r\n // token 确证无效(inactive)不在此列,仍走下方重定向。\r\n if (failClosedOnIntrospectionError) {\r\n console.warn(\r\n \"[SSO SDK] introspection 不可达(网络异常/超时/5xx),failClosedOnIntrospectionError=true,对持有 SSO access token Cookie 的请求 fail-closed 返回 502。\"\r\n );\r\n return new NextResponse(\r\n \"SSO 认证服务暂时不可用(introspection unreachable),请稍后重试\",\r\n { status: 502 }\r\n );\r\n }\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 // 瞬态 cookie 以本次 state 为后缀:浏览器 cookie 是全局的,固定名称会让\r\n // 并发标签页的登录互相覆盖(state/nonce/verifier 错配 → 登录失败或低度 DoS)。\r\n // 回调侧按返回的 state 精确查找对应 cookie;旧固定名称仅作过渡期回退。\r\n const attemptSuffix = `_${state}`;\r\n\r\n // Set state cookie for CSRF verification on callback\r\n response.cookies.set(\r\n `${stateCookieName}${attemptSuffix}`,\r\n state,\r\n getHostCookieOptions(600, secureCookies)\r\n );\r\n\r\n // Set nonce cookie(与 state 同规格的 __Host- httpOnly cookie,供 callback 校验 ID Token)\r\n response.cookies.set(\r\n `${nonceCookieName}${attemptSuffix}`,\r\n nonce,\r\n getHostCookieOptions(600, secureCookies)\r\n );\r\n\r\n // Set PKCE verifier cookie(httpOnly,供 callback handler 使用)\r\n // 使用 __Secure- 前缀,允许写入 callbackPath\r\n response.cookies.set(\r\n `${verifierCookieName}${attemptSuffix}`,\r\n verifier,\r\n getSecureCookieOptions(600, callbackPath, secureCookies)\r\n );\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(\r\n `${returnUrlCookieName}${attemptSuffix}`,\r\n safeReturnUrl,\r\n getHostCookieOptions(600, secureCookies)\r\n );\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 { fetchDiscoveryDoc, validateIdToken } from \"../core/id-token\";\r\nimport { isTrustedReturnUrl, timingSafeEqualString } from \"../core/security\";\r\nimport { fetchWithTimeout } from \"../core/http\";\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 * 请求的 OAuth scope(空格分隔),建议与 createSsoMiddleware 的 scopes 保持一致。\r\n * 仅当**显式配置**且包含 openid 时回调才强制要求 token 响应携带 id_token(fail-closed);\r\n * 未配置时保持兼容行为(id_token 存在则校验,缺失不拒绝),避免升级后旧接入方登录失败。\r\n */\r\n scopes?: 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 * 传入后优先生效;返回 undefined 时回退默认行为。\r\n * 默认行为:浏览器导航(Accept 含 text/html 且未带 ?format=json)渲染内置中文错误页\r\n * (含\"重新登录\"入口);其他请求(fetch/自动化)返回 JSON,保持 API 兼容。\r\n */\r\n renderErrorPage?: (ctx: {\r\n error: string;\r\n errorDescription: string;\r\n status: number;\r\n request: NextRequest;\r\n }) => Response | undefined | Promise<Response | undefined>;\r\n\r\n /** 调试日志开关(默认 false):回调失败时输出服务端告警日志,便于定位配置/流程问题 */\r\n debug?: boolean;\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/** HTML 转义(错误描述可能含服务端返回的任意文本) */\r\nfunction escapeHtml(value: string): string {\r\n return value.replace(/[&<>\"']/g, (ch) => {\r\n switch (ch) {\r\n case \"&\":\r\n return \"&\";\r\n case \"<\":\r\n return \"<\";\r\n case \">\":\r\n return \">\";\r\n case '\"':\r\n return \""\";\r\n default:\r\n return \"'\";\r\n }\r\n });\r\n}\r\n\r\n/** 内置品牌化错误页:面向浏览器用户,避免裸 JSON 与\"重新登录\"断头路 */\r\nfunction buildErrorPage(status: number, error: string, errorDescription: string): string {\r\n const safeDescription = escapeHtml(errorDescription);\r\n const safeError = escapeHtml(error);\r\n return `<!doctype html>\r\n<html lang=\"zh-CN\">\r\n<head>\r\n<meta charset=\"utf-8\" />\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\r\n<meta name=\"robots\" content=\"noindex\" />\r\n<title>登录失败</title>\r\n<style>\r\n :root { color-scheme: light; }\r\n body { margin: 0; font-family: system-ui, -apple-system, \"PingFang SC\", \"Microsoft YaHei\", sans-serif; background: #fafafa; color: #2c2c2c; }\r\n main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }\r\n h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }\r\n p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }\r\n .actions { display: flex; gap: 0.75rem; }\r\n a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }\r\n a.primary { background: #2c2c2c; color: #fff; }\r\n a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }\r\n .code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }\r\n</style>\r\n</head>\r\n<body>\r\n<main>\r\n <h1>登录失败</h1>\r\n <p>${safeDescription}</p>\r\n <div class=\"actions\">\r\n <a class=\"primary\" href=\"/\">重新登录</a>\r\n <a class=\"secondary\" href=\"/\">返回首页</a>\r\n </div>\r\n <p class=\"code\">错误码:${safeError}(反馈问题时请附上)</p>\r\n</main>\r\n</body>\r\n</html>`;\r\n}\r\n\r\n/** 错误响应:自定义渲染 > HTML(浏览器)> JSON(API/自动化) */\r\nfunction buildErrorResponse(\r\n request: NextRequest,\r\n status: number,\r\n error: string,\r\n errorDescription: string,\r\n custom?: CallbackRouteConfig[\"renderErrorPage\"]\r\n): NextResponse | Promise<NextResponse> {\r\n if (custom) {\r\n const handled = custom({ error, errorDescription, status, request });\r\n if (handled instanceof Promise) {\r\n return handled.then((res) =>\r\n res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)\r\n );\r\n }\r\n if (handled) return toNextResponse(handled);\r\n }\r\n return defaultErrorResponse(request, status, error, errorDescription);\r\n}\r\n\r\n/** 将自定义 Response 包装为 NextResponse(GET 包装层需要改写 cookies 清 nonce) */\r\nfunction toNextResponse(res: Response): NextResponse {\r\n if (res instanceof NextResponse) return res;\r\n return new NextResponse(res.body, {\r\n status: res.status,\r\n statusText: res.statusText,\r\n headers: res.headers,\r\n });\r\n}\r\n\r\nfunction defaultErrorResponse(\r\n request: NextRequest,\r\n status: number,\r\n error: string,\r\n errorDescription: string\r\n): NextResponse {\r\n const accept = request.headers.get(\"accept\") ?? \"\";\r\n const wantsHtml =\r\n accept.includes(\"text/html\") && request.nextUrl.searchParams.get(\"format\") !== \"json\";\r\n if (wantsHtml) {\r\n return new NextResponse(buildErrorPage(status, error, errorDescription), {\r\n status,\r\n headers: {\r\n \"Content-Type\": \"text/html; charset=utf-8\",\r\n \"Cache-Control\": \"no-store\",\r\n },\r\n });\r\n }\r\n return NextResponse.json({ error, error_description: errorDescription }, { status });\r\n}\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 scopes,\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(\r\n config.accessTokenCookieName,\r\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME\r\n );\r\n const refreshTokenCookieName = pickName(\r\n config.refreshTokenCookieName,\r\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME\r\n );\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 if (config.debug) {\r\n console.warn(\r\n `[SSO SDK] 回调失败 status=${response.status}`,\r\n request.nextUrl.searchParams.get(\"error\") ?? \"\"\r\n );\r\n }\r\n response.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n const returnedState = request.nextUrl.searchParams.get(\"state\");\r\n if (returnedState && /^[A-Za-z0-9\\-._~]{8,512}$/.test(returnedState)) {\r\n response.cookies.set(\r\n `${nonceCookieName}_${returnedState}`,\r\n \"\",\r\n getHostCookieOptions(0, secureCookies)\r\n );\r\n }\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 buildErrorResponse(request, 400, \"authorization_failed\", desc, config.renderErrorPage);\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 buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"登录信息不完整,请重新发起登录\",\r\n config.renderErrorPage\r\n );\r\n }\r\n\r\n // 验证 state(从 cookie 中读取原始 state,CSRF 必需)。\r\n // state 同时是瞬态 cookie 的名称后缀,先做字符集/长度校验(middleware 生成 32 位;\r\n // 放宽下限仅为兼容测试/自定义 state,安全性由下方常量时间比较保证)\r\n if (!returnedState || !/^[A-Za-z0-9\\-._~]{8,512}$/.test(returnedState)) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"State 参数缺失或格式非法,请重新发起授权请求\",\r\n config.renderErrorPage\r\n );\r\n }\r\n const attemptSuffix = `_${returnedState}`;\r\n // 瞬态 cookie 按 state 隔离读取;旧固定名称仅作滚动升级过渡期回退\r\n const readTransientCookie = (name: string): string | undefined =>\r\n request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;\r\n\r\n const savedState = readTransientCookie(stateCookieName);\r\n if (!savedState) {\r\n // 配置一致性诊断:若以\"另一种命名口径\"存在的 state cookie,说明\r\n // insecureLocalDev/Cookie 名称在 middleware 与 callback 之间不一致(最常见错配,\r\n // 表现为登录成功却被判未登录/无限跳转),给出针对性错误而非泛化的 state 缺失\r\n const alternateName = secureCookies\r\n ? toInsecureCookieName(stateCookieName)\r\n : DEFAULT_STATE_COOKIE_NAME;\r\n const alternateValue =\r\n request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ??\r\n request.cookies.get(alternateName)?.value;\r\n if (alternateValue) {\r\n return buildErrorResponse(\r\n request,\r\n 500,\r\n \"invalid_config\",\r\n \"检测到 SSO SDK 配置不一致:middleware 与 callback 的 insecureLocalDev 或 Cookie 名称不匹配,请统一配置\",\r\n config.renderErrorPage\r\n );\r\n }\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"登录会话已失效,请重新发起授权请求\",\r\n config.renderErrorPage\r\n );\r\n }\r\n // state 比较使用常量时间比较(与 core/SsoClient.handleCallback 一致,防时序侧信道)\r\n if (!timingSafeEqualString(returnedState, savedState)) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"登录会话校验失败,请重新发起授权请求\",\r\n config.renderErrorPage\r\n );\r\n }\r\n\r\n // RFC 9207:校验授权响应中的 iss,防止混合 IdP / 错误 IdP 的回调被本应用接受。\r\n const returnedIss = searchParams.get(\"iss\");\r\n if (returnedIss) {\r\n const discovery = await fetchDiscoveryDoc(normalizedBase);\r\n const expectedIssuer = (discovery?.issuer || normalizedBase).replace(/\\/+$/, \"\");\r\n if (returnedIss.replace(/\\/+$/, \"\") !== expectedIssuer) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"issuer_mismatch\",\r\n \"授权响应 issuer 不匹配,请重新发起登录\",\r\n config.renderErrorPage\r\n );\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 = readTransientCookie(nonceCookieName);\r\n\r\n // 读取 PKCE code_verifier(middleware 存入的 httpOnly cookie)\r\n const verifier = readTransientCookie(verifierCookieName);\r\n if (!verifier) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"invalid_request\",\r\n \"登录会话已过期(可能切换了标签页),请重新发起登录\",\r\n config.renderErrorPage\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 let res: Response | null = null;\r\n let lastError: unknown;\r\n try {\r\n res = await fetchWithTimeout(\r\n tokenEndpoint,\r\n {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: body.toString(),\r\n },\r\n 10_000\r\n );\r\n } catch (err) {\r\n lastError = err;\r\n }\r\n\r\n if (lastError || !res) {\r\n return buildErrorResponse(\r\n request,\r\n 502,\r\n \"server_error\",\r\n \"登录服务暂时不可用(Token 请求失败),请稍后重试\",\r\n config.renderErrorPage\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 {\r\n /* ignore */\r\n }\r\n return buildErrorResponse(\r\n request,\r\n 502,\r\n \"token_request_failed\",\r\n (errData.error_description as string) ||\r\n `登录失败(Token 请求失败: HTTP ${res.status}),请稍后重试`,\r\n config.renderErrorPage\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 // expires_in 必须为有限正数,否则 access_token cookie 的 maxAge 为 NaN\r\n // (退化为会话 cookie 甚至立即失效)。\r\n if (\r\n !tokenData.access_token ||\r\n !tokenData.refresh_token ||\r\n typeof tokenData.expires_in !== \"number\" ||\r\n !Number.isFinite(tokenData.expires_in) ||\r\n tokenData.expires_in <= 0\r\n ) {\r\n return buildErrorResponse(\r\n request,\r\n 502,\r\n \"server_error\",\r\n \"登录服务返回异常(Token 响应不完整),请稍后重试\",\r\n config.renderErrorPage\r\n );\r\n }\r\n\r\n // OIDC fail-closed(仅在显式配置 scopes 且含 openid 时生效):\r\n // 防止 nonce/at_hash 绑定校验被静默跳过。未显式配置 scope 的旧接入方保持兼容行为。\r\n const requiresIdToken =\r\n scopes !== undefined && scopes.split(\" \").filter(Boolean).includes(\"openid\");\r\n if (requiresIdToken && !tokenData.id_token) {\r\n return buildErrorResponse(\r\n request,\r\n 400,\r\n \"id_token_invalid\",\r\n \"登录校验失败,请重新发起登录\",\r\n config.renderErrorPage\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 buildErrorResponse(\r\n request,\r\n 400,\r\n \"id_token_invalid\",\r\n err instanceof Error && /[\\u4e00-\\u9fff]/.test(err.message)\r\n ? err.message\r\n : \"登录校验失败,请重新发起登录\",\r\n config.renderErrorPage\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 = readTransientCookie(returnUrlCookieName) || defaultReturnPath;\r\n const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : \"/\";\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(同时清除本次 state 后缀名与旧固定名)\r\n for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {\r\n response.cookies.set(name, \"\", getHostCookieOptions(0, secureCookies));\r\n response.cookies.set(`${name}${attemptSuffix}`, \"\", getHostCookieOptions(0, secureCookies));\r\n }\r\n\r\n // 清除 PKCE verifier cookie,必须使用写入时的 path(callbackPath)\r\n // 由于 callback handler 不知道 middleware 的 callbackPath,这里保守地\r\n // 同时清除 path=/ 和 path=当前请求路径两种可能\r\n for (const path of [\"/\", request.nextUrl.pathname]) {\r\n response.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, path, secureCookies));\r\n response.cookies.set(\r\n `${verifierCookieName}${attemptSuffix}`,\r\n \"\",\r\n getSecureCookieOptions(0, path, secureCookies)\r\n );\r\n }\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 | \"issuer_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 * 反斜杠或 ASCII 控制字符(tab/newline/CR/DEL)。\n * WHATWG URL 解析会在解析前剥离 TAB/LF/CR,并把 \"\\\" 归一化为 \"/\",\n * 因此 \"/\\evil.com\"、\"/\\n//evil.com\" 能通过字符串前缀判断后变成跨站 URL。\n */\nconst UNSAFE_URL_CHAR_PATTERN = /[\\\\\\u0000-\\u001F\\u007F]/;\n\n/**\n * 校验 returnUrl 是否可信(防开放重定向)。\n * 仅允许:\n * - 站内相对路径(以单个 \"/\" 开头且不以 \"//\" 开头)\n * - 与 currentOrigin 完全同源的 http(s) 绝对 URL\n * 拒绝一切含反斜杠/控制字符的值(浏览器会将 \"/\\evil.com\" 解析为跨站 URL),\n * 拒绝 userinfo 与危险 scheme。\n */\nexport function isTrustedReturnUrl(url: string, currentOrigin: string): boolean {\n if (!url) return false;\n if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;\n if (url.startsWith(\"//\")) return false;\n if (url.startsWith(\"/\")) return true;\n try {\n const parsed = new URL(url);\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") return false;\n if (parsed.username || parsed.password) return false;\n return parsed.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","/**\r\n * ID Token 验证工具(浏览器 / Edge Runtime 通用)\r\n *\r\n * 提取 SsoClient 与 Next.js callback 中重复的 ID Token 校验逻辑,\r\n * 包括 base64url 解码、JWKS 获取、RS256 签名验证、at_hash 计算等。\r\n */\r\n\r\nimport { SsoError } from \"./errors\";\r\nimport { timingSafeEqualString } from \"./security\";\r\n\r\n// 运行时 crypto.subtle 检测:现代浏览器 / Edge Runtime / Node 20+ 均提供全局 crypto\r\nconst _crypto: Pick<Crypto, \"subtle\"> | null =\r\n typeof crypto !== \"undefined\" && (crypto as Crypto).subtle ? (crypto as Crypto) : null;\r\n\r\nfunction getCrypto(): Crypto | null {\r\n return (_crypto as Crypto | null) ?? null;\r\n}\r\n\r\nexport interface JwksKey {\r\n kty: string;\r\n kid?: string;\r\n alg?: string;\r\n use?: string;\r\n n?: string;\r\n e?: string;\r\n x5c?: string[];\r\n}\r\n\r\nexport interface Jwks {\r\n keys: JwksKey[];\r\n}\r\n\r\nexport interface ValidateIdTokenResult {\r\n sub: string;\r\n}\r\n\r\nexport interface ValidateIdTokenOptions {\r\n /**\r\n * 发现 SSO 中心已启用 RS256 时,是否拒绝 HS256 ID Token。\r\n * 默认 true:只要 JWKS 中存在 RS256 签名密钥,就拒绝 HS256(安全推荐)。\r\n *\r\n * @deprecated 无实际作用:HS256 ID Token 一律被拒绝(对称密钥无法安全分发给\r\n * Public Client / BFF),该选项只影响走哪条抛错路径\r\n * (id_token_unsupported_alg 或 id_token_hs256_unsupported),\r\n * 不影响最终结果。保留仅为避免破坏 API,请勿依赖;将于未来大版本移除。\r\n */\r\n rejectHs256WhenRs256Available?: boolean;\r\n /**\r\n * 期望的 OIDC nonce(login 时生成并随 authorize 请求发送)。\r\n * 传入后 ID Token 必须携带 nonce claim 且与该值常量时间相等(fail-closed),\r\n * 否则抛出 id_token_nonce_mismatch,防止 ID Token 重放。\r\n * 不传时(如 refresh 流程):ID Token 若仍携带 nonce 会被拒绝(OIDC Core §12.2,\r\n * refresh 签发的 ID Token 不得携带 nonce),否则跳过 nonce 校验。\r\n */\r\n expectedNonce?: string;\r\n}\r\n\r\nfunction base64UrlDecode(input: string): Uint8Array {\r\n const base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\r\n const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), \"=\");\r\n const binary = atob(padded);\r\n const bytes = new Uint8Array(binary.length);\r\n for (let i = 0; i < binary.length; i++) {\r\n bytes[i] = binary.charCodeAt(i);\r\n }\r\n return bytes;\r\n}\r\n\r\n/** 解码 JWT payload(不验证签名) */\r\nexport function decodeJwtPayload(token: string): Record<string, unknown> | null {\r\n try {\r\n const parts = token.split(\".\");\r\n if (parts.length !== 3) return null;\r\n const json = new TextDecoder().decode(base64UrlDecode(parts[1]));\r\n return JSON.parse(json) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/** 解码 JWT header */\r\nexport function decodeJwtHeader(token: string): Record<string, unknown> | null {\r\n try {\r\n const parts = token.split(\".\");\r\n if (parts.length !== 3) return null;\r\n const json = new TextDecoder().decode(base64UrlDecode(parts[0]));\r\n return JSON.parse(json) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/** 标准化 issuer/base URL,去除末尾斜杠 */\r\nfunction normalizeIssuer(url: string): string {\r\n return url.replace(/\\/+$/, \"\");\r\n}\r\n\r\n/** OIDC Discovery 文档(仅取本模块关心的字段) */\r\nexport interface OidcDiscoveryDoc {\r\n issuer?: string;\r\n jwks_uri?: string;\r\n}\r\n\r\nlet cachedJwks: { baseUrl: string; jwks: Jwks; fetchedAt: number } | null = null;\r\nlet cachedDiscovery: { baseUrl: string; doc: OidcDiscoveryDoc; fetchedAt: number } | null = null;\r\nconst JWKS_CACHE_TTL_MS = 5 * 60 * 1000;\r\n/** discovery / JWKS 拉取超时(与 SsoClient._getDiscovery 的 10s 一致) */\r\nconst FETCH_TIMEOUT_MS = 10000;\r\n\r\n/**\r\n * 拉取 OIDC Discovery 文档(带内存缓存;失败返回 null 不抛错)\r\n *\r\n * 只缓存成功结果:失败(网络错误 / 非 2xx)不缓存,主站短暂抖动不会让\r\n * 登录持续失败到 TTL 期满,下一次调用立即重试(与 core/discovery.ts 策略一致)。\r\n */\r\nexport async function fetchDiscoveryDoc(baseUrl: string): Promise<OidcDiscoveryDoc | null> {\r\n const now = Date.now();\r\n if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {\r\n return cachedDiscovery.doc;\r\n }\r\n\r\n try {\r\n const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {\r\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\r\n });\r\n if (!res.ok) return null;\r\n const doc = (await res.json()) as OidcDiscoveryDoc;\r\n cachedDiscovery = { baseUrl, doc, fetchedAt: now };\r\n return doc;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nexport interface FetchJwksOptions {\r\n /**\r\n * 绕过内存缓存与浏览器 HTTP 缓存(cache: \"no-cache\")强制重取 JWKS,\r\n * 并同步刷新模块级缓存。用于密钥轮换后 kid 失配的自愈重试。\r\n */\r\n forceRefresh?: boolean;\r\n}\r\n\r\n/** 从 SSO 中心拉取 JWKS(带内存缓存;优先 Discovery 的 jwks_uri,回退硬编码路径) */\r\nexport async function fetchJwks(baseUrl: string, options: FetchJwksOptions = {}): Promise<Jwks | null> {\r\n const now = Date.now();\r\n if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {\r\n return cachedJwks.jwks;\r\n }\r\n\r\n const discovery = await fetchDiscoveryDoc(baseUrl);\r\n // Discovery 文档中的 jwks_uri 必须与 baseUrl 同源:否则被篡改的文档可把验签公钥\r\n // 指向攻击者服务器,从而伪造任意 ID Token。跨源 JWKS 请由服务端显式代理。\r\n let jwksUri = `${baseUrl}/api/oauth/jwks`;\r\n if (discovery?.jwks_uri) {\r\n try {\r\n if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {\r\n jwksUri = discovery.jwks_uri;\r\n } else {\r\n console.warn(\r\n \"[SSO SDK] Discovery 的 jwks_uri 与 ssoBaseUrl 不同源,已忽略并使用默认 JWKS 端点\"\r\n );\r\n }\r\n } catch {\r\n // 非法 URL:使用默认端点\r\n }\r\n }\r\n\r\n try {\r\n const res = await fetch(jwksUri, {\r\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\r\n ...(options.forceRefresh ? { cache: \"no-cache\" as RequestCache } : {}),\r\n });\r\n if (!res.ok) return null;\r\n const jwks = (await res.json()) as Jwks;\r\n cachedJwks = { baseUrl, jwks, fetchedAt: now };\r\n return jwks;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/** 清空 JWKS / Discovery 内存缓存(测试与密钥轮换场景使用) */\r\nexport function clearIdTokenCaches(): void {\r\n cachedJwks = null;\r\n cachedDiscovery = null;\r\n}\r\n\r\n/** 使用 RS256 公钥验证 ID Token 签名 */\r\nexport async function verifyRs256Signature(token: string, jwk: JwksKey): Promise<boolean> {\r\n try {\r\n const [headerB64, payloadB64, signature] = token.split(\".\");\r\n if (!signature || !jwk.n || !jwk.e) return false;\r\n\r\n const c = getCrypto();\r\n if (!c) return false;\r\n\r\n const cryptoKey = await c.subtle.importKey(\r\n \"jwk\",\r\n { kty: \"RSA\", n: jwk.n, e: jwk.e, alg: \"RS256\", ext: false },\r\n { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-256\" },\r\n false,\r\n [\"verify\"]\r\n );\r\n\r\n const signatureBytes = base64UrlDecode(signature);\r\n const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);\r\n return await c.subtle.verify(\r\n \"RSASSA-PKCS1-v1_5\",\r\n cryptoKey,\r\n signatureBytes as unknown as BufferSource,\r\n data as unknown as BufferSource\r\n );\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\n/** 计算 OIDC at_hash(access_token SHA-256 前 128bit base64url) */\r\nexport async function computeAtHash(accessToken: string): Promise<string> {\r\n const c = getCrypto();\r\n if (!c) return \"\";\r\n const hash = await c.subtle.digest(\"SHA-256\", new TextEncoder().encode(accessToken));\r\n const bytes = new Uint8Array(hash);\r\n const half = bytes.slice(0, bytes.length / 2);\r\n let binary = \"\";\r\n for (const b of half) {\r\n binary += String.fromCharCode(b);\r\n }\r\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\r\n}\r\n\r\n/** 验证 ID Token(iss、aud、exp、iat、azp、sub、签名、at_hash) */\r\nexport async function validateIdToken(\r\n idToken: string,\r\n accessToken: string,\r\n expectedIssuer: string,\r\n expectedClientId: string,\r\n options: ValidateIdTokenOptions = {}\r\n): Promise<ValidateIdTokenResult> {\r\n const { rejectHs256WhenRs256Available = true, expectedNonce } = options;\r\n\r\n const header = decodeJwtHeader(idToken);\r\n if (!header) {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token 格式错误\");\r\n }\r\n\r\n const alg = header.alg;\r\n if (typeof alg !== \"string\" || (alg !== \"RS256\" && alg !== \"HS256\")) {\r\n throw new SsoError(\"id_token_unsupported_alg\", `不支持的 ID Token 签名算法: ${alg}`);\r\n }\r\n\r\n const baseUrl = normalizeIssuer(expectedIssuer);\r\n // issuer 以 Discovery 文档的 issuer 为准(防伪站伪造 iss 通过校验),回退调用方传入值\r\n const discovery = await fetchDiscoveryDoc(baseUrl);\r\n const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);\r\n\r\n // RS256:通过 JWKS 验证签名\r\n if (alg === \"RS256\") {\r\n const jwks = await fetchJwks(baseUrl);\r\n if (!jwks) {\r\n throw new SsoError(\"id_token_invalid_signature\", \"无法获取 JWKS 验证 ID Token 签名\");\r\n }\r\n const kid = typeof header.kid === \"string\" ? header.kid : undefined;\r\n // 有 kid 时精确匹配;无 kid 时逐个尝试所有 RS256 签名公钥\r\n const matchCandidates = (set: Jwks) =>\r\n set.keys.filter(\r\n (k) =>\r\n k.kty === \"RSA\" &&\r\n k.alg === \"RS256\" &&\r\n k.use === \"sig\" &&\r\n (kid ? k.kid === kid : true)\r\n );\r\n const verifyAny = async (keys: JwksKey[]): Promise<boolean> => {\r\n for (const key of keys) {\r\n if (await verifyRs256Signature(idToken, key)) return true;\r\n }\r\n return false;\r\n };\r\n\r\n let candidates = matchCandidates(jwks);\r\n let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\r\n\r\n // 密钥轮换自愈:按 kid 过滤无候选,或所有候选验签失败时,\r\n // 可能是本地 JWKS 缓存陈旧 —— 强制重取一次 JWKS 后重试,仍失败才抛错\r\n if (candidates.length === 0 || !validSig) {\r\n const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });\r\n if (freshJwks) {\r\n candidates = matchCandidates(freshJwks);\r\n validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\r\n }\r\n }\r\n\r\n if (candidates.length === 0) {\r\n throw new SsoError(\"id_token_invalid_signature\", \"JWKS 中未找到匹配的 RS256 公钥\");\r\n }\r\n if (!validSig) {\r\n throw new SsoError(\"id_token_invalid_signature\", \"ID Token 签名验证失败\");\r\n }\r\n }\r\n\r\n // HS256:对称密钥无法安全分发给 Public Client / BFF。\r\n // SDK 拒绝 HS256 ID Token:主站需配置 RS256 密钥对以启用安全验证。\r\n if (alg === \"HS256\") {\r\n if (rejectHs256WhenRs256Available) {\r\n const jwks = await fetchJwks(baseUrl);\r\n const hasRs256 = jwks?.keys?.some((k) => k.alg === \"RS256\" && k.use === \"sig\");\r\n if (hasRs256) {\r\n throw new SsoError(\"id_token_unsupported_alg\", \"SSO 中心已配置 RS256,拒绝 HS256 ID Token\");\r\n }\r\n }\r\n throw new SsoError(\r\n \"id_token_hs256_unsupported\",\r\n \"ID Token 使用 HS256 对称签名,SDK 无法安全验证。\" +\r\n \"请联系管理员在 SSO 主站配置 RS256 密钥对(JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY)后重新签发。\"\r\n );\r\n }\r\n\r\n const payload = decodeJwtPayload(idToken);\r\n if (!payload) {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token payload 解析失败\");\r\n }\r\n\r\n const tokenIssuer = typeof payload.iss === \"string\" ? normalizeIssuer(payload.iss) : \"\";\r\n if (tokenIssuer !== normalizedIssuer) {\r\n throw new SsoError(\"id_token_issuer_mismatch\", \"ID Token issuer 不匹配\");\r\n }\r\n\r\n // aud 校验:单值或数组包含 clientId;多 aud 时按 OIDC Core §3.1.3.7 要求 azp 必须等于 clientId\r\n const aud = payload.aud;\r\n const audList = Array.isArray(aud) ? aud : typeof aud === \"string\" ? [aud] : [];\r\n if (!audList.includes(expectedClientId)) {\r\n throw new SsoError(\"id_token_audience_mismatch\", \"ID Token audience 不匹配\");\r\n }\r\n if (audList.length > 1 && payload.azp !== expectedClientId) {\r\n throw new SsoError(\"id_token_audience_mismatch\", \"ID Token 多 audience 时 azp 必须为当前 client\");\r\n }\r\n\r\n // exp 必需且不得过期(允许 60s clock skew,与 iat 校验对称)\r\n if (typeof payload.exp !== \"number\") {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token 缺少 exp 声明\");\r\n }\r\n if (Date.now() >= payload.exp * 1000 + 60_000) {\r\n throw new SsoError(\"id_token_expired\", \"ID Token 已过期\");\r\n }\r\n\r\n // iat 必需(OIDC Core §2:ID Token 必须包含 iat)且不得在未来(允许 60s clock skew)\r\n if (typeof payload.iat !== \"number\") {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token 缺少 iat 声明\");\r\n }\r\n if (payload.iat * 1000 > Date.now() + 60_000) {\r\n throw new SsoError(\"id_token_invalid\", \"ID Token iat 在未来,疑似伪造或时钟异常\");\r\n }\r\n\r\n if (typeof payload.sub !== \"string\" || !payload.sub) {\r\n throw new SsoError(\"id_token_missing_sub\", \"ID Token 缺少 sub\");\r\n }\r\n\r\n // 校验 at_hash:确保 ID Token 与当前 access_token 绑定(常量时间比较)\r\n if (typeof payload.at_hash === \"string\" && payload.at_hash) {\r\n const actual = await computeAtHash(accessToken);\r\n if (!timingSafeEqualString(actual, payload.at_hash)) {\r\n throw new SsoError(\"id_token_at_hash_mismatch\", \"ID Token at_hash 不匹配\");\r\n }\r\n }\r\n\r\n // 校验 nonce(OIDC Core §3.1.3.7):调用方传入 expectedNonce 时 fail-closed,\r\n // ID Token 必须携带 nonce 且与登录时生成的值常量时间相等,防 ID Token 重放。\r\n // 未传 expectedNonce(如本地 nonce cookie 已被清除/过期)但 ID Token 携带 nonce\r\n // 时同样拒绝(OIDC Core §12.2):该 token 声称绑定某次登录会话,本地却无法核对,\r\n // 可能是非本次登录签发的重放 token。refresh 签发的 ID Token 按规范不携带 nonce,\r\n // 不受影响(refresh 流程不传 expectedNonce 且 token 无 nonce claim)。\r\n if (expectedNonce !== undefined) {\r\n const tokenNonce = typeof payload.nonce === \"string\" ? payload.nonce : \"\";\r\n if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {\r\n throw new SsoError(\"id_token_nonce_mismatch\", \"ID Token nonce 不匹配\");\r\n }\r\n } else if (typeof payload.nonce === \"string\" && payload.nonce) {\r\n throw new SsoError(\r\n \"id_token_nonce_mismatch\",\r\n \"ID Token 携带 nonce 但本地无期望值(可能非本次登录会话签发)\"\r\n );\r\n }\r\n\r\n return { sub: payload.sub };\r\n}\r\n","/**\n * 网络请求工具:带超时的 fetch(AbortController)\n *\n * 内部模块:不从包入口导出(core/SsoClient 与 next/callback 共用)。\n */\n\n/** token / userinfo 请求默认超时(毫秒):避免回调页/登录流程无限挂起 */\nexport const REQUEST_TIMEOUT_MS = 10_000;\n\n/** 带超时的 fetch(AbortController);超时抛 AbortError(调用方按网络错误处理) */\nexport async function fetchWithTimeout(\n input: RequestInfo | URL,\n init: RequestInit = {},\n timeoutMs: number = REQUEST_TIMEOUT_MS\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n return await fetch(input, { ...init, signal: controller.signal });\n } finally {\n clearTimeout(timer);\n }\n}\n","/**\n * App Router Refresh Route Handler\n *\n * 使用 httpOnly refresh cookie 在服务端换取新的 access/refresh/id token,\n * 并更新 Cookie。适用于 Next.js BFF/Confidential Client:token 不暴露给浏览器 JS。\n *\n * 用法 (src/app/api/auth/refresh/route.ts):\n * ```ts\n * import { createRefreshRouteHandler } from \"@nihplod/sso-sdk/next\";\n *\n * export const POST = createRefreshRouteHandler({\n * clientId: process.env.SSO_CLIENT_ID!,\n * clientSecret: process.env.SSO_CLIENT_SECRET,\n * ssoBaseUrl: \"https://nihplod.cn\",\n * redirectUri: \"https://myapp.com/api/auth/callback\",\n * });\n * ```\n */\n\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { fetchWithTimeout } from \"../core/http\";\nimport { validateIdToken } from \"../core/id-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\nexport interface RefreshRouteConfig {\n /** OAuth Client ID */\n clientId: string;\n\n /** SSO 中心地址 */\n ssoBaseUrl: string;\n\n /** 回调 URL(用于同源校验,必须与注册的 redirect_uri 一致) */\n redirectUri: string;\n\n /** OAuth Client Secret(Confidential Client 必填) */\n clientSecret?: string;\n\n /** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */\n accessTokenCookieName?: string;\n\n /** Refresh Token Cookie 名称,默认 __Host-nihplod_sso_rt */\n refreshTokenCookieName?: string;\n\n /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */\n idTokenCookieName?: string;\n\n /**\n * 本地 HTTP 开发模式(默认 false)。必须与 middleware / callback / logout 配置一致。\n * 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下会被强制忽略并告警。\n */\n insecureLocalDev?: boolean;\n\n /**\n * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。\n * 仅用于 token 交换;浏览器跳转仍使用 ssoBaseUrl 公网地址。\n */\n serverBaseUrl?: string;\n\n /** 调试日志开关(默认 false) */\n debug?: boolean;\n}\n\nfunction noStoreJson(body: unknown, status = 200): NextResponse {\n return NextResponse.json(body, {\n status,\n headers: { \"Cache-Control\": \"no-store\", Pragma: \"no-cache\" },\n });\n}\n\nfunction clearAuthCookies(\n response: NextResponse,\n names: { access: string; refresh: string; idToken: string },\n secureCookies: boolean\n): NextResponse {\n response.cookies.set(names.access, \"\", getHostCookieOptions(0, secureCookies));\n response.cookies.set(names.refresh, \"\", getHostCookieOptions(0, secureCookies));\n response.cookies.set(names.idToken, \"\", getHostCookieOptions(0, secureCookies));\n return response;\n}\n\nexport function createRefreshRouteHandler(config: RefreshRouteConfig) {\n const {\n clientId,\n ssoBaseUrl,\n redirectUri,\n clientSecret,\n insecureLocalDev: insecureLocalDevOpt = false,\n debug = false,\n } = config;\n\n const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);\n const secureCookies = !insecureLocalDev;\n const pickName = (explicit: string | undefined, fallback: string) =>\n insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : (explicit ?? fallback);\n\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(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);\n\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\n const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\\/+$/, \"\");\n const callbackOrigin = new URL(redirectUri).origin;\n const cookieNames = {\n access: accessTokenCookieName,\n refresh: refreshTokenCookieName,\n idToken: idTokenCookieName,\n };\n\n return async function POST(request: NextRequest): Promise<NextResponse> {\n // 刷新是写 Cookie 的敏感操作:仅允许同源发起,防跨站强制刷新(CSRF)。\n const requestOrigin = request.headers.get(\"origin\");\n if (requestOrigin && requestOrigin !== callbackOrigin) {\n return noStoreJson({ error: \"forbidden\", error_description: \"跨站请求被拒绝\" }, 403);\n }\n const secFetchSite = request.headers.get(\"sec-fetch-site\");\n if (\n !requestOrigin &&\n secFetchSite &&\n secFetchSite !== \"same-origin\" &&\n secFetchSite !== \"none\"\n ) {\n return noStoreJson({ error: \"forbidden\", error_description: \"跨站请求被拒绝\" }, 403);\n }\n\n const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;\n if (!refreshToken) {\n return noStoreJson({ error: \"no_refresh_token\", error_description: \"缺少 refresh_token\" }, 401);\n }\n\n const body = new URLSearchParams({\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n client_id: clientId,\n });\n if (clientSecret) body.set(\"client_secret\", clientSecret);\n\n let res: Response | null = null;\n let lastError: unknown;\n try {\n res = await fetchWithTimeout(\n `${normalizedServerBase}/api/oauth/token`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n },\n 10_000\n );\n } catch (err) {\n lastError = err;\n }\n\n if (lastError || !res) {\n if (debug) console.warn(\"[SSO SDK] refresh: token 请求失败\", lastError);\n return noStoreJson(\n { error: \"server_error\", error_description: \"刷新服务暂时不可用\" },\n 502\n );\n }\n\n let data: {\n access_token?: string;\n token_type?: string;\n expires_in?: number;\n refresh_token?: string;\n refresh_expires_in?: number;\n id_token?: string;\n error?: string;\n error_description?: string;\n } = {};\n try {\n data = (await res.json()) as typeof data;\n } catch {\n // 网关返回 HTML/空体等\n }\n\n if (!res.ok) {\n const error = typeof data.error === \"string\" ? data.error : \"\";\n // 限流/上游故障可重试:保留 Cookie,不要误判为登录态失效\n const status = res.status === 429 ? 429 : res.status >= 500 ? 502 : 401;\n const response = noStoreJson(\n {\n error: error || \"refresh_failed\",\n error_description: data.error_description || \"刷新登录态失败,请重新登录\",\n },\n status\n );\n // invalid_grant / invalid_client 等不可恢复:清除本地 Cookie,避免无限重试\n if (res.status === 400 || res.status === 401) {\n return clearAuthCookies(response, cookieNames, secureCookies);\n }\n return response;\n }\n\n if (\n !data.access_token ||\n typeof data.expires_in !== \"number\" ||\n !Number.isFinite(data.expires_in) ||\n data.expires_in <= 0\n ) {\n return clearAuthCookies(\n noStoreJson({ error: \"server_error\", error_description: \"刷新响应不完整\" }, 502),\n cookieNames,\n secureCookies\n );\n }\n\n // OIDC:refresh 响应若包含 ID Token,必须在写入 Cookie 前验签;\n // 按 OIDC Core §12.2,refresh 签发的 ID Token 不应携带 nonce。\n if (data.id_token) {\n try {\n await validateIdToken(\n data.id_token,\n data.access_token,\n normalizedBase,\n clientId\n );\n } catch (err) {\n if (debug) console.warn(\"[SSO SDK] refresh: ID Token 校验失败\", err);\n return clearAuthCookies(\n noStoreJson({ error: \"id_token_invalid\", error_description: \"登录校验失败\" }, 401),\n cookieNames,\n secureCookies\n );\n }\n }\n\n const response = noStoreJson({ success: true, expires_in: data.expires_in });\n response.cookies.set(accessTokenCookieName, data.access_token, {\n ...getHostCookieOptions(data.expires_in, secureCookies),\n });\n response.cookies.set(refreshTokenCookieName, data.refresh_token || refreshToken, {\n ...getHostCookieOptions(\n typeof data.refresh_expires_in === \"number\" && data.refresh_expires_in > 0\n ? data.refresh_expires_in\n : 30 * 24 * 60 * 60,\n secureCookies\n ),\n });\n if (data.id_token) {\n let idTokenMaxAge = 3600;\n try {\n const payload = JSON.parse(\n Buffer.from(data.id_token.split(\".\")[1].replace(/-/g, \"+\").replace(/_/g, \"/\"), \"base64\").toString(\n \"utf8\"\n )\n ) as { exp?: number };\n if (typeof payload.exp === \"number\") {\n idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1000);\n }\n } catch {\n // 解码失败使用默认值\n }\n if (idTokenMaxAge > 0) {\n response.cookies.set(idTokenCookieName, data.id_token, {\n ...getHostCookieOptions(idTokenMaxAge, secureCookies),\n });\n }\n }\n\n return response;\n };\n}\n","/**\r\n * App Router 登出 Route Handler\r\n *\r\n * 在 /api/auth/logout 路由中处理本地登出 + RP-Initiated Logout:\r\n * 1. 从 cookie 读取 refresh_token 并调用 revocation_endpoint 撤销\r\n * 2. 清除所有 SSO cookie\r\n * 3. 按退出范围(defaultScope / 表单 global 字段)决定:\r\n * - \"local\"(默认):仅退出本站,重定向回本站首页\r\n * - \"global\":重定向到 SSO 中心 end-session,全局退出所有 NIHPLOD 平台\r\n *\r\n * 用法 (src/app/api/auth/logout/route.ts):\r\n * ```ts\r\n * import { createLogoutRouteHandler } from \"@nihplod/sso-sdk/next\";\r\n *\r\n * const handler = createLogoutRouteHandler({\r\n * clientId: \"my-app\",\r\n * clientSecret: \"optional-secret\",\r\n * ssoBaseUrl: \"https://nihplod.cn\",\r\n * redirectUri: \"https://myapp.com/api/auth/callback\",\r\n * postLogoutRedirectUri: \"https://myapp.com/\",\r\n * });\r\n *\r\n * // 推荐使用 POST 触发登出(防登出 CSRF);GET 不执行登出,仅返回确认页\r\n * export const GET = handler;\r\n * export const POST = handler;\r\n * ```\r\n */\r\n\r\nimport { NextRequest, NextResponse } from \"next/server\";\r\nimport { fetchDiscoveryCached } from \"../core/discovery\";\r\nimport { 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 DEFAULT_LOGOUT_STATE_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 LogoutRouteConfig {\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 一致,用于构造 post_logout_redirect_uri) */\r\n redirectUri: string;\r\n\r\n /**\r\n * OAuth Client Secret(可选)。\r\n * 撤销 refresh_token 时,Confidential Client 需要认证。\r\n */\r\n clientSecret?: string;\r\n\r\n /** 登出后跳转回子项目的地址,默认取 redirectUri 的 origin */\r\n postLogoutRedirectUri?: string;\r\n\r\n /**\r\n * 默认退出范围(默认 \"local\"):\r\n * - \"local\":仅退出本站(撤销 refresh_token + 清本地 cookie),不跳转 SSO 中心;\r\n * - \"global\":同时跳转 SSO 中心 end-session 全局退出(RP-Initiated Logout)。\r\n * 用户可在 GET 确认页勾选\"同时退出所有 NIHPLOD 平台\"后通过表单字段\r\n * global=1 覆盖默认值;POST 表单携带 global 字段时以表单为准。\r\n */\r\n defaultScope?: \"local\" | \"global\";\r\n\r\n /**\r\n * @deprecated 请改用 defaultScope(true → \"global\",false → \"local\")。\r\n * 仅为兼容保留;与 defaultScope 同时传入时以 defaultScope 为准。\r\n */\r\n redirectToSso?: boolean;\r\n\r\n /** Access Token Cookie 名称 */\r\n accessTokenCookieName?: string;\r\n\r\n /** Refresh Token Cookie 名称 */\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 名称 */\r\n stateCookieName?: string;\r\n\r\n /** OIDC Nonce Cookie 名称,默认 __Host-nihplod_sso_nonce(须与 middleware/callback 一致) */\r\n nonceCookieName?: string;\r\n\r\n /** Return URL Cookie 名称 */\r\n returnUrlCookieName?: string;\r\n\r\n /** PKCE Verifier Cookie 名称 */\r\n verifierCookieName?: string;\r\n\r\n /** 回调路径(用于清除 verifier cookie),默认 \"/api/auth/callback\" */\r\n callbackPath?: string;\r\n\r\n /** Logout State Cookie 名称(RP-Initiated Logout CSRF 防护),默认 __Host-nihplod_sso_logout_state */\r\n logoutStateCookieName?: string;\r\n\r\n /**\r\n * 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。\r\n * 仅用于 discovery / revocation 等服务器间请求;浏览器跳转\r\n * (end-session 等)始终使用 ssoBaseUrl 公网地址。\r\n * 适用于子站与 SSO 中心同机/同内网部署:避免经公网代理回源的延迟。\r\n */\r\n serverBaseUrl?: string;\r\n\r\n /**\r\n * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除\r\n * __Host-/__Secure- 前缀;必须与 middleware / callback 的配置保持一致,\r\n * 否则无法清除它们写入的 Cookie。生产严禁启用——生产环境\r\n * (NODE_ENV=production 且 ssoBaseUrl 为 https)下该开关会被强制忽略并告警。\r\n */\r\n insecureLocalDev?: boolean;\r\n}\r\n\r\n// ============================================\r\n// 工具函数\r\n// ============================================\r\n\r\n/** GET 确认页 HTML(登出 CSRF 防护:GET 不执行登出,由用户点击按钮发起 POST) */\r\nfunction buildLogoutConfirmHtml(): string {\r\n return `<!doctype html>\r\n<html lang=\"zh-CN\">\r\n<head>\r\n <meta charset=\"utf-8\">\r\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\r\n <title>确认退出登录</title>\r\n</head>\r\n<body style=\"display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;\">\r\n <form method=\"post\" style=\"text-align:center;\">\r\n <p>确定要退出登录吗?</p>\r\n <label style=\"display:flex;align-items:center;justify-content:center;gap:6px;margin:12px 0;font-size:14px;color:#374151;cursor:pointer;\">\r\n <input type=\"checkbox\" name=\"global\" value=\"1\">同时退出所有 NIHPLOD 平台\r\n </label>\r\n <button type=\"submit\" style=\"padding:10px 20px;background-color:#ef4444;color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px;\">退出登录</button>\r\n <p><a href=\"/\" style=\"color:#2563eb;text-decoration:underline;\">取消并返回首页</a></p>\r\n </form>\r\n</body>\r\n</html>`;\r\n}\r\n\r\n/** 生成安全随机字符串(用于 logout state,Node/Edge Runtime 均支持 Web Crypto) */\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 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/** 仅接受与 SSO base 同源的 Discovery 端点,防止被篡改的文档把凭证导向攻击者 */\r\nfunction trustedSameOriginEndpoint(endpoint: string | undefined, baseUrl: string): string | null {\r\n if (!endpoint) return null;\r\n try {\r\n return new URL(endpoint).origin === new URL(baseUrl).origin ? endpoint : null;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n// ============================================\r\n// Route Handler 工厂函数\r\n// ============================================\r\n\r\nexport function createLogoutRouteHandler(config: LogoutRouteConfig) {\r\n const {\r\n clientId,\r\n ssoBaseUrl,\r\n redirectUri,\r\n clientSecret,\r\n postLogoutRedirectUri = new URL(redirectUri).origin + \"/\",\r\n callbackPath = \"/api/auth/callback\",\r\n insecureLocalDev: insecureLocalDevOpt = false,\r\n } = config;\r\n\r\n // 退出范围解析:defaultScope 优先(默认 \"local\",仅退出本站);\r\n // redirectToSso 为 deprecated 别名,传入时告警并按 true→global / false→local 映射\r\n let defaultScope: \"local\" | \"global\" = config.defaultScope ?? \"local\";\r\n if (config.redirectToSso !== undefined) {\r\n console.warn(\r\n \"[SSO SDK] redirectToSso 已弃用,请改用 defaultScope\" +\r\n \"(redirectToSso: true → defaultScope: \\\"global\\\",false → \\\"local\\\")。\" +\r\n \"注意:默认退出范围已变更为 \\\"local\\\"(仅退出本站,不跳转 SSO 中心)。\"\r\n );\r\n if (config.defaultScope === undefined) {\r\n defaultScope = config.redirectToSso ? \"global\" : \"local\";\r\n }\r\n }\r\n\r\n // 生产守卫:NODE_ENV=production 且 ssoBaseUrl 为 https 时强制忽略该开关(与 middleware/callback 一致)\r\n const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);\r\n\r\n // insecureLocalDev:与 middleware/callback 一致地去前缀 + 关 Secure\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 const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);\r\n\r\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n // 服务器间调用(discovery/revoke)的基准地址:配置了内网地址时走内网,\r\n // 避免经公网代理回源的延迟;浏览器跳转仍用 normalizedBase(公网)\r\n const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\\/+$/, \"\");\r\n\r\n // 本地跳转的 origin:取 redirectUri 的 origin 而非 request.nextUrl.origin——\r\n // standalone 部署下后者是进程监听地址(如 http://0.0.0.0:3002),反代场景不可靠。\r\n const callbackOrigin = new URL(redirectUri).origin;\r\n\r\n /**\r\n * 登出 handler:同时适配 GET 与 POST(函数本身不区分 method,\r\n * 在 route.ts 中 `export const GET = handler; export const POST = handler;` 即可)。\r\n *\r\n * ⚠️ CSRF 防护:GET 请求可被跨站触发(如 `<img src=\"/api/auth/logout\">`),\r\n * 因此 GET 且未携带合法 logout state 时不执行登出,仅返回确认页 HTML,\r\n * 由用户点击按钮以 POST 确认;POST(或携带合法 state 的 GET 回跳)才执行登出。\r\n *\r\n * 当请求携带 state 参数时,视为 RP-Initiated Logout 的回跳(post_logout_redirect_uri\r\n * 指向本路由的场景),校验 logout state cookie 后放行,防止伪造回跳。\r\n */\r\n return async function handler(request: NextRequest) {\r\n // RP-Initiated Logout 回跳:校验 state(CSRF 防护)\r\n const returnedState = request.nextUrl.searchParams.get(\"state\");\r\n if (returnedState) {\r\n const savedState = request.cookies.get(logoutStateCookieName)?.value;\r\n if (!savedState || !timingSafeEqualString(savedState, returnedState)) {\r\n return NextResponse.json(\r\n { error: \"invalid_request\", error_description: \"Logout state 不匹配\" },\r\n { status: 400 }\r\n );\r\n }\r\n const res = NextResponse.redirect(callbackOrigin + \"/\");\r\n res.cookies.set(logoutStateCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n return res;\r\n }\r\n\r\n // GET 无 state:可能是跨站图片/预取触发的登出 CSRF,不执行登出,\r\n // 返回确认页由用户主动 POST 确认(表单 POST 不受 <img>/预取影响)\r\n if (request.method === \"GET\") {\r\n return new NextResponse(buildLogoutConfirmHtml(), {\r\n status: 200,\r\n headers: { \"content-type\": \"text/html; charset=utf-8\" },\r\n });\r\n }\r\n\r\n // POST 登出 CSRF 防护:必须是同源发起(Origin 优先,Sec-Fetch-Site 兜底)。\r\n // 跨站表单 POST 会携带 Origin/Sec-Fetch-Site: cross-site,直接拒绝。\r\n const requestOrigin = request.headers.get(\"origin\");\r\n if (requestOrigin && requestOrigin !== callbackOrigin) {\r\n return NextResponse.json(\r\n { error: \"forbidden\", error_description: \"跨站请求被拒绝\" },\r\n { status: 403 }\r\n );\r\n }\r\n const secFetchSite = request.headers.get(\"sec-fetch-site\");\r\n if (\r\n !requestOrigin &&\r\n secFetchSite &&\r\n secFetchSite !== \"same-origin\" &&\r\n secFetchSite !== \"none\"\r\n ) {\r\n return NextResponse.json(\r\n { error: \"forbidden\", error_description: \"跨站请求被拒绝\" },\r\n { status: 403 }\r\n );\r\n }\r\n\r\n // 解析表单中的 global 字段(确认页勾选\"同时退出所有 NIHPLOD 平台\"后提交 global=1);\r\n // 表单未携带 global 字段时回落到配置的 defaultScope\r\n let formGlobal: string | null = null;\r\n try {\r\n formGlobal = new URLSearchParams(await request.text()).get(\"global\");\r\n } catch {\r\n // body 读取失败时视为未携带,按 defaultScope 处理\r\n }\r\n const effectiveScope: \"local\" | \"global\" =\r\n formGlobal !== null\r\n ? formGlobal === \"1\" || formGlobal === \"true\"\r\n ? \"global\"\r\n : \"local\"\r\n : defaultScope;\r\n\r\n const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;\r\n const idTokenHint = request.cookies.get(idTokenCookieName)?.value;\r\n\r\n // 1. best-effort 撤销服务端 refresh_token\r\n if (refreshToken) {\r\n try {\r\n // 配置了内网地址时直连默认端点(跳过 discovery 的公网端点,\r\n // 否则文档里的公网 URL 会让调用仍绕行公网);否则用缓存的 discovery\r\n const discovery = config.serverBaseUrl\r\n ? null\r\n : await fetchDiscoveryCached(normalizedServerBase);\r\n const revokeUrl =\r\n (config.serverBaseUrl\r\n ? `${normalizedServerBase}/api/oauth/revoke`\r\n : trustedSameOriginEndpoint(discovery?.revocation_endpoint, normalizedBase)) ||\r\n `${normalizedBase}/api/oauth/revoke`;\r\n const body = new URLSearchParams({\r\n token: refreshToken,\r\n token_type_hint: \"refresh_token\",\r\n client_id: clientId,\r\n });\r\n if (clientSecret) {\r\n body.set(\"client_secret\", clientSecret);\r\n }\r\n // 3s 超时兜底:撤销是 best-effort,主站缓慢时不得拖住登出流程\r\n const doRevoke = () =>\r\n fetch(revokeUrl, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: body.toString(),\r\n signal: AbortSignal.timeout(3000),\r\n });\r\n let revokeRes = await doRevoke();\r\n if (!revokeRes.ok && (revokeRes.status >= 500 || revokeRes.status === 429)) {\r\n await new Promise((resolve) => setTimeout(resolve, 300));\r\n revokeRes = await doRevoke();\r\n }\r\n if (!revokeRes.ok) {\r\n console.warn(`[SSO SDK] 登出撤销 refresh_token 失败(HTTP ${revokeRes.status})`);\r\n }\r\n } catch {\r\n // 撤销失败不影响本地登出\r\n }\r\n }\r\n\r\n // 2. 准备本地清除 SSO cookie 的响应\r\n const clearCookies = (res: NextResponse) => {\r\n res.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n res.cookies.set(refreshTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n res.cookies.set(idTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n res.cookies.set(stateCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n res.cookies.set(nonceCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n res.cookies.set(returnUrlCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n res.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, \"/\", secureCookies));\r\n res.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, callbackPath, secureCookies));\r\n\r\n // 清理带 state 后缀的瞬态 cookie 残留(middleware 的多标签页隔离格式:\r\n // `${name}_${state}`)。过期时间为 10 分钟,这里主动清理避免残留。\r\n const suffixedPrefixes = [\r\n `${stateCookieName}_`,\r\n `${nonceCookieName}_`,\r\n `${returnUrlCookieName}_`,\r\n `${verifierCookieName}_`,\r\n ];\r\n for (const cookie of request.cookies.getAll()) {\r\n if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;\r\n if (cookie.name.startsWith(`${verifierCookieName}_`)) {\r\n res.cookies.set(cookie.name, \"\", getSecureCookieOptions(0, \"/\", secureCookies));\r\n res.cookies.set(cookie.name, \"\", getSecureCookieOptions(0, callbackPath, secureCookies));\r\n } else {\r\n res.cookies.set(cookie.name, \"\", getHostCookieOptions(0, secureCookies));\r\n }\r\n }\r\n return res;\r\n };\r\n\r\n // 3. global 范围:RP-Initiated Logout 重定向到 SSO 中心,同时必须清除本地 Cookie;\r\n // local 范围:仅本站退出,重定向回本站首页\r\n if (effectiveScope === \"global\") {\r\n // discovery 经内网地址拉取(若配置),但文档内端点是 SSO 中心按公网 origin\r\n // 生成的,可直接用于浏览器跳转;兜底也用公网 base,不得使用内网地址\r\n const discovery = await fetchDiscoveryCached(normalizedServerBase);\r\n const endSessionEndpoint =\r\n trustedSameOriginEndpoint(discovery?.end_session_endpoint, normalizedBase) ||\r\n `${normalizedBase}/api/oauth/end-session`;\r\n const logoutUrl = new URL(endSessionEndpoint);\r\n logoutUrl.searchParams.set(\"client_id\", clientId);\r\n logoutUrl.searchParams.set(\r\n \"post_logout_redirect_uri\",\r\n postLogoutRedirectUri\r\n );\r\n if (idTokenHint) {\r\n logoutUrl.searchParams.set(\"id_token_hint\", idTokenHint);\r\n }\r\n // 携带 state 防登出 CSRF:回跳时校验(见 handler 开头)\r\n const logoutState = generateRandomString(32);\r\n logoutUrl.searchParams.set(\"state\", logoutState);\r\n const res = clearCookies(NextResponse.redirect(logoutUrl.toString()));\r\n res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));\r\n return res;\r\n }\r\n\r\n return clearCookies(\r\n NextResponse.redirect(callbackOrigin + \"/\")\r\n );\r\n };\r\n}\r\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","/**\r\n * App Router Backchannel Logout 接收端 Route Handler\r\n *\r\n * 接收 SSO 中心在用户全局登出 / 撤销授权时推送的 logout_token\r\n * (OIDC Back-Channel Logout 1.0),验证通过后清除本站 SSO cookie 并\r\n * 调用 onLogout 钩子(子站在钩子里清除自己的本地会话,如数据库 session)。\r\n *\r\n * 前置条件:在 SSO 中心管理后台为本 client 注册 backchannelLogoutUri\r\n * 指向本路由的公网地址(必须 HTTPS)。\r\n *\r\n * 用法 (src/app/api/auth/backchannel-logout/route.ts):\r\n * ```ts\r\n * import { createBackchannelLogoutRouteHandler } from \"@nihplod/sso-sdk/next\";\r\n *\r\n * export const POST = createBackchannelLogoutRouteHandler({\r\n * clientId: \"my-app\",\r\n * ssoBaseUrl: \"https://nihplod.cn\",\r\n * onLogout: async ({ sub, sid }) => {\r\n * // 清除子站本地会话(按 sub 或 sid 定位)\r\n * },\r\n * });\r\n * ```\r\n */\r\n\r\nimport { NextRequest, NextResponse } from \"next/server\";\r\nimport { SsoError } from \"../core/errors\";\r\nimport {\r\n verifyLogoutTokenDetailed,\r\n releaseLogoutTokenJti,\r\n type LogoutJtiStore,\r\n type LogoutTokenPayload,\r\n type VerifiedLogoutToken,\r\n} from \"../core/logout-token\";\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 getHostCookieOptions,\r\n resolveInsecureLocalDev,\r\n toInsecureCookieName,\r\n} from \"./constants\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface BackchannelLogoutRouteConfig {\r\n /** OAuth Client ID(logout_token 的 aud 必须等于它) */\r\n clientId: string;\r\n\r\n /** SSO 中心地址(Discovery / JWKS 基准) */\r\n ssoBaseUrl: string;\r\n\r\n /**\r\n * 登出通知钩子:logout_token 验证通过后调用,\r\n * 子站在此处按 sub / sid 清除自己的本地会话(数据库 session 等)。\r\n * 抛错时返回 500,让 IdP 重投。\r\n */\r\n onLogout?: (\r\n payload: LogoutTokenPayload,\r\n request: NextRequest\r\n ) => void | Promise<void>;\r\n\r\n /** Access Token Cookie 名称 */\r\n accessTokenCookieName?: string;\r\n\r\n /** Refresh Token Cookie 名称 */\r\n refreshTokenCookieName?: string;\r\n\r\n /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */\r\n idTokenCookieName?: string;\r\n\r\n /**\r\n * jti 防重放存储(可选)。\r\n *\r\n * 默认实现是进程内 Map(per-instance):多实例 / Serverless 部署下各实例\r\n * 互不可见,重放保护形同虚设(IdP 重投/攻击者重放到另一实例会被当作首次)。\r\n * 这类部署 MUST 注入共享存储(如 Redis:addIfAbsent = `SET key 1 NX EX ttl`,\r\n * remove = `DEL key`——onLogout 失败时用于释放 jti 允许 IdP 重投)。\r\n */\r\n jtiStore?: LogoutJtiStore;\r\n\r\n /**\r\n * 本地 HTTP 开发模式(默认 false)。关闭 Cookie 的 Secure 属性并去除\r\n * __Host-/__Secure- 前缀;必须与 middleware / callback / logout 的配置保持一致。\r\n * 生产严禁启用——生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下\r\n * 该开关会被强制忽略并告警。\r\n */\r\n insecureLocalDev?: boolean;\r\n}\r\n\r\n// ============================================\r\n// Route Handler 工厂函数\r\n// ============================================\r\n\r\nexport function createBackchannelLogoutRouteHandler(\r\n config: BackchannelLogoutRouteConfig\r\n) {\r\n const { clientId, ssoBaseUrl, onLogout } = config;\r\n\r\n // 生产守卫:与 middleware/callback/logout 一致\r\n const insecureLocalDev = resolveInsecureLocalDev(\r\n config.insecureLocalDev ?? false,\r\n ssoBaseUrl\r\n );\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(\r\n config.accessTokenCookieName,\r\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME\r\n );\r\n const refreshTokenCookieName = pickName(\r\n config.refreshTokenCookieName,\r\n DEFAULT_REFRESH_TOKEN_COOKIE_NAME\r\n );\r\n const idTokenCookieName = pickName(\r\n config.idTokenCookieName,\r\n DEFAULT_ID_TOKEN_COOKIE_NAME\r\n );\r\n\r\n return async function handler(request: NextRequest) {\r\n // 仅接受 POST(IdP 以 application/x-www-form-urlencoded POST 推送)\r\n if (request.method !== \"POST\") {\r\n return NextResponse.json(\r\n { error: \"method_not_allowed\", error_description: \"仅接受 POST 请求\" },\r\n { status: 405 }\r\n );\r\n }\r\n\r\n const logoutToken = new URLSearchParams(await request.text()).get(\r\n \"logout_token\"\r\n );\r\n if (!logoutToken) {\r\n return NextResponse.json(\r\n { error: \"invalid_request\", error_description: \"缺少 logout_token\" },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n let verified: VerifiedLogoutToken;\r\n try {\r\n verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {\r\n jtiStore: config.jtiStore,\r\n });\r\n } catch (err) {\r\n // 验证失败一律 400(规范:RP 认为 token 无效时返回 400,IdP 不再以该 token 重试)\r\n const code = err instanceof SsoError ? err.code : \"logout_token_invalid\";\r\n const description =\r\n err instanceof SsoError ? err.description : \"Logout Token 验证失败\";\r\n return NextResponse.json(\r\n { error: code, error_description: description },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 先调用钩子清子站本地会话:失败返回 500,让 IdP 重投\r\n try {\r\n await onLogout?.(verified.payload, request);\r\n } catch (err) {\r\n // 钩子失败时释放 jti:否则 IdP 用同一 logout_token 重投会被重放检查\r\n // 以 400 拒绝(IdP 视为 token 无效、不再重试),RP 本地会话永远清不掉。\r\n // 成功路径不得释放。\r\n await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });\r\n console.error(\r\n \"[SSO SDK] backchannel logout onLogout 钩子执行失败:\",\r\n err\r\n );\r\n return NextResponse.json(\r\n { error: \"server_error\", error_description: \"onLogout 处理失败\" },\r\n { status: 500 }\r\n );\r\n }\r\n\r\n // 清除本站 SSO cookie(backchannel 是服务器间调用,清 cookie 影响不到\r\n // 用户浏览器——用户侧由下一次请求时 middleware 发现会话失效兜住;照做无害)\r\n const res = new NextResponse(null, { status: 200 });\r\n res.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n res.cookies.set(refreshTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n res.cookies.set(idTokenCookieName, \"\", getHostCookieOptions(0, secureCookies));\r\n return res;\r\n };\r\n}\r\n","/**\r\n * OIDC Backchannel Logout Token 验证\r\n *\r\n * 复用 core/id-token.ts 的 Discovery + JWKS 基建验证 IdP 推送的 logout_token:\r\n * - alg 仅允许 RS256(logout_token 是服务器间凭证,不接受对称签名)\r\n * - issuer 以 Discovery 文档为准(防伪站伪造 iss 通过校验)\r\n * - aud 必须等于本 client、exp 必须有效\r\n * - type 必须为 \"logout_token\"(防跨用途 token 混用,与 sso-verify 对齐)\r\n * - events 必须包含 backchannel-logout 事件键,且事件值必须是对象\r\n * - sub / sid 至少居一;jti 必须存在且未重放(默认进程内 LRU,可注入 LogoutJtiStore)\r\n *\r\n * 与 ID Token 的差异:logout_token 不得要求 nonce(规范明确禁止携带),\r\n * 也不校验 at_hash / iat。\r\n */\r\n\r\nimport { SsoError } from \"./errors\";\r\nimport {\r\n decodeJwtHeader,\r\n decodeJwtPayload,\r\n fetchDiscoveryDoc,\r\n fetchJwks,\r\n verifyRs256Signature,\r\n type Jwks,\r\n type JwksKey,\r\n} from \"./id-token\";\r\n\r\n/** Backchannel Logout 事件键(OIDC Back-Channel Logout 1.0) */\r\nexport const BACKCHANNEL_LOGOUT_EVENT =\r\n \"http://schemas.openid.net/event/backchannel-logout\";\r\n\r\n/** 验证通过的 logout_token 负载(仅暴露子站清会话所需的标识) */\r\nexport interface LogoutTokenPayload {\r\n sub?: string;\r\n sid?: string;\r\n}\r\n\r\n/** 验证结果:负载 + jti(供调用方在本地处理失败时释放重放标记,允许 IdP 重投) */\r\nexport interface VerifiedLogoutToken {\r\n payload: LogoutTokenPayload;\r\n jti: string;\r\n /**\r\n * 内部重放键(`${issuer}:${jti}`),releaseLogoutTokenJti 应传入此值。\r\n * 跨 issuer 隔离 jti 命名空间,避免不同 IdP 的相同 jti 互相误判重放。\r\n */\r\n replayKey: string;\r\n}\r\n\r\n/**\r\n * jti 防重放存储接口(可注入)。\r\n *\r\n * 默认实现是进程内 Map(per-instance):多实例 / Serverless 部署下各实例\r\n * 互不可见,重放检查形同虚设,必须注入共享存储(如 Redis)。\r\n * key 为 `${issuer}:${jti}`,ttlSeconds 为建议过期时间(logout_token exp + 60s 宽限)。\r\n */\r\nexport interface LogoutJtiStore {\r\n has(key: string): Promise<boolean> | boolean;\r\n add(key: string, ttlSeconds: number): Promise<void> | void;\r\n /**\r\n * 原子 check-and-set(如 Redis `SET key 1 NX EX ttl`):返回 false 表示\r\n * key 已存在(判重放)。提供时优先使用;缺失时回退 has+add\r\n * (非原子,仅单实例部署可接受:并发窗口内同一 jti 可能双双通过)。\r\n */\r\n addIfAbsent?(key: string, ttlSeconds: number): Promise<boolean> | boolean;\r\n /**\r\n * 释放已记录的 key。onLogout 钩子失败后需要允许 IdP 用同一 logout_token\r\n * 重投,SDK 会调用此方法;未实现时 IdP 重投将被重放检查以 400 拒绝。\r\n */\r\n remove?(key: string): Promise<void> | void;\r\n}\r\n\r\n/** jti 防重放缓存:jti → 过期时间(epoch ms),容量上限 1000 */\r\nconst JTI_CACHE_CAPACITY = 1000;\r\nconst seenJti = new Map<string, number>();\r\n\r\n/** 清空 jti 防重放缓存(测试用) */\r\nexport function clearLogoutTokenReplayCache(): void {\r\n seenJti.clear();\r\n}\r\n\r\n/**\r\n * 释放已记录的 jti。\r\n *\r\n * 仅当 logout_token 验证通过、但调用方的本地会话清理(onLogout 钩子)失败、\r\n * 需要允许 IdP 用同一 logout_token 重投时调用。成功后调用会使重放保护失效,\r\n * 因此正常成功路径不得调用。\r\n *\r\n * @param replayKey VerifiedLogoutToken.replayKey(`${issuer}:${jti}`)\r\n * @param options.jtiStore 与验证时相同的自定义存储:实现了 remove() 时经它释放\r\n * (IdP 重投可成功);未实现 remove() 时无法释放,告警说明 IdP 重投将被\r\n * 重放检查拒绝;未提供存储时从进程内默认缓存删除。\r\n */\r\nexport async function releaseLogoutTokenJti(\r\n replayKey: string,\r\n options: { jtiStore?: LogoutJtiStore } = {}\r\n): Promise<void> {\r\n const store = options.jtiStore;\r\n if (store?.remove) {\r\n await store.remove(replayKey);\r\n return;\r\n }\r\n if (store) {\r\n console.warn(\r\n \"[SSO SDK] 自定义 jtiStore 未实现 remove():onLogout 失败后无法释放 jti,\" +\r\n \"IdP 用同一 logout_token 重投将被重放检查拒绝(logout_token_replay)\"\r\n );\r\n return;\r\n }\r\n seenJti.delete(replayKey);\r\n}\r\n\r\n/**\r\n * 记录 jti;返回 false 表示重放(该 jti 尚未过期且已出现过)。\r\n * 缓存到 token 自身 exp 为止;超过容量时先清理过期项,再按插入序淘汰最旧项。\r\n */\r\nfunction recordJti(jti: string, expiresAtMs: number): boolean {\r\n const now = Date.now();\r\n const existing = seenJti.get(jti);\r\n if (existing !== undefined && existing > now) return false;\r\n\r\n // 清理过期项,避免缓存被历史数据撑满\r\n for (const [key, exp] of seenJti) {\r\n if (exp <= now) seenJti.delete(key);\r\n }\r\n // 仍超容量:淘汰最旧插入项(Map 保持插入序)\r\n while (seenJti.size >= JTI_CACHE_CAPACITY) {\r\n const oldest = seenJti.keys().next().value;\r\n if (oldest === undefined) break;\r\n seenJti.delete(oldest);\r\n }\r\n seenJti.set(jti, expiresAtMs);\r\n return true;\r\n}\r\n\r\n/**\r\n * 验证 Backchannel Logout Token 并返回负载 + jti\r\n *\r\n * @param logoutToken IdP POST 到 backchannelLogoutUri 的 logout_token(JWT)\r\n * @param ssoBaseUrl SSO 中心地址(Discovery / JWKS 基准)\r\n * @param clientId 本应用 Client ID(aud 必须等于它)\r\n * @param options.jtiStore 自定义 jti 防重放存储。默认进程内 Map(per-instance);\r\n * 多实例 / Serverless 部署 MUST 注入共享存储(如 Redis SET NX EX),\r\n * 否则重放检查只在单实例内有效。\r\n * @returns 验证通过的负载与 jti\r\n * @throws SsoError 任一校验失败\r\n */\r\nexport async function verifyLogoutTokenDetailed(\r\n logoutToken: string,\r\n ssoBaseUrl: string,\r\n clientId: string,\r\n options: { jtiStore?: LogoutJtiStore } = {}\r\n): Promise<VerifiedLogoutToken> {\r\n const baseUrl = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n\r\n const header = decodeJwtHeader(logoutToken);\r\n if (!header) {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 格式错误\");\r\n }\r\n\r\n // alg 白名单仅 RS256:logout_token 是服务器间凭证,一律拒绝对称签名\r\n if (header.alg !== \"RS256\") {\r\n throw new SsoError(\r\n \"logout_token_unsupported_alg\",\r\n `不支持的 Logout Token 签名算法: ${String(header.alg)}`\r\n );\r\n }\r\n\r\n // issuer 以 Discovery 文档为准,回退调用方传入的 ssoBaseUrl\r\n const discovery = await fetchDiscoveryDoc(baseUrl);\r\n const normalizedIssuer = (discovery?.issuer || baseUrl).replace(/\\/+$/, \"\");\r\n\r\n // RS256 签名验证(kid 精确匹配;无 kid 时逐个尝试所有 RS256 签名公钥)\r\n const jwks = await fetchJwks(baseUrl);\r\n if (!jwks) {\r\n throw new SsoError(\r\n \"logout_token_invalid_signature\",\r\n \"无法获取 JWKS 验证 Logout Token 签名\"\r\n );\r\n }\r\n const kid = typeof header.kid === \"string\" ? header.kid : undefined;\r\n const matchCandidates = (set: Jwks) =>\r\n set.keys.filter(\r\n (k) =>\r\n k.kty === \"RSA\" &&\r\n k.alg === \"RS256\" &&\r\n k.use === \"sig\" &&\r\n (kid ? k.kid === kid : true)\r\n );\r\n const verifyAny = async (keys: JwksKey[]): Promise<boolean> => {\r\n for (const key of keys) {\r\n if (await verifyRs256Signature(logoutToken, key)) return true;\r\n }\r\n return false;\r\n };\r\n\r\n let candidates = matchCandidates(jwks);\r\n let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\r\n\r\n // 密钥轮换自愈:与 validateIdToken 一致,候选缺失或验签失败时强制重取 JWKS 重试一次\r\n if (candidates.length === 0 || !validSig) {\r\n const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });\r\n if (freshJwks) {\r\n candidates = matchCandidates(freshJwks);\r\n validSig = candidates.length > 0 ? await verifyAny(candidates) : false;\r\n }\r\n }\r\n\r\n if (candidates.length === 0) {\r\n throw new SsoError(\r\n \"logout_token_invalid_signature\",\r\n \"JWKS 中未找到匹配的 RS256 公钥\"\r\n );\r\n }\r\n if (!validSig) {\r\n throw new SsoError(\r\n \"logout_token_invalid_signature\",\r\n \"Logout Token 签名验证失败\"\r\n );\r\n }\r\n\r\n const payload = decodeJwtPayload(logoutToken);\r\n if (!payload) {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token payload 解析失败\");\r\n }\r\n\r\n const tokenIssuer =\r\n typeof payload.iss === \"string\" ? payload.iss.replace(/\\/+$/, \"\") : \"\";\r\n if (tokenIssuer !== normalizedIssuer) {\r\n throw new SsoError(\r\n \"logout_token_issuer_mismatch\",\r\n \"Logout Token issuer 不匹配\"\r\n );\r\n }\r\n\r\n // aud 校验:单值或数组包含 clientId\r\n const aud = payload.aud;\r\n const audList = Array.isArray(aud) ? aud : typeof aud === \"string\" ? [aud] : [];\r\n if (!audList.includes(clientId)) {\r\n throw new SsoError(\r\n \"logout_token_audience_mismatch\",\r\n \"Logout Token audience 不匹配\"\r\n );\r\n }\r\n\r\n // exp 必需且不得过期(允许 60s clock skew,与 ID Token 校验一致)\r\n if (typeof payload.exp !== \"number\") {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 缺少 exp 声明\");\r\n }\r\n if (Date.now() >= payload.exp * 1000 + 60_000) {\r\n throw new SsoError(\"logout_token_expired\", \"Logout Token 已过期\");\r\n }\r\n if (typeof payload.iat !== \"number\") {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 缺少 iat 声明\");\r\n }\r\n // OIDC Back-Channel Logout 1.0:logout_token 禁止携带 nonce(与主站校验对齐)\r\n if (payload.nonce !== undefined) {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 不得携带 nonce\");\r\n }\r\n\r\n // type 必须为 logout_token:防止拿其他用途的 token(如 access_token /\r\n // profile_event token)冒充 logout_token 触发登出(与 sso-verify 对齐)\r\n if (payload.type !== \"logout_token\") {\r\n throw new SsoError(\r\n \"logout_token_invalid\",\r\n \"Logout Token type 必须为 logout_token\"\r\n );\r\n }\r\n\r\n // events 必须是对象,且必须包含 backchannel-logout 事件键、事件值必须是对象\r\n // (通常为空对象 {};数组/null/原始值视为畸形,与 sso-verify 对齐)\r\n const events = payload.events;\r\n if (!events || typeof events !== \"object\" || Array.isArray(events)) {\r\n throw new SsoError(\r\n \"logout_token_invalid\",\r\n \"Logout Token 缺少 backchannel-logout events 声明\"\r\n );\r\n }\r\n const logoutEvent = (events as Record<string, unknown>)[BACKCHANNEL_LOGOUT_EVENT];\r\n if (\r\n !logoutEvent ||\r\n typeof logoutEvent !== \"object\" ||\r\n Array.isArray(logoutEvent)\r\n ) {\r\n throw new SsoError(\r\n \"logout_token_invalid\",\r\n \"Logout Token 缺少 backchannel-logout events 声明\"\r\n );\r\n }\r\n\r\n // sub / sid 至少居一\r\n const sub = typeof payload.sub === \"string\" && payload.sub ? payload.sub : undefined;\r\n const sid = typeof payload.sid === \"string\" && payload.sid ? payload.sid : undefined;\r\n if (!sub && !sid) {\r\n throw new SsoError(\r\n \"logout_token_invalid\",\r\n \"Logout Token 必须包含 sub 或 sid 至少其一\"\r\n );\r\n }\r\n\r\n // jti 必须存在且未重放(缓存到 token exp + 60s 宽限为止)。\r\n // 以 issuer 为前缀隔离不同 IdP 的 jti 命名空间,防跨 issuer 碰撞误判重放\r\n const jti = typeof payload.jti === \"string\" ? payload.jti : \"\";\r\n if (!jti) {\r\n throw new SsoError(\"logout_token_invalid\", \"Logout Token 缺少 jti 声明\");\r\n }\r\n const replayKey = `${normalizedIssuer}:${jti}`;\r\n const replayTtlSeconds = Math.max(\r\n 1,\r\n Math.ceil((payload.exp * 1000 + 60_000 - Date.now()) / 1000)\r\n );\r\n const jtiStore = options.jtiStore;\r\n if (jtiStore) {\r\n if (jtiStore.addIfAbsent) {\r\n // 原子 check-and-set(推荐,如 Redis SET NX EX)\r\n if (!(await jtiStore.addIfAbsent(replayKey, replayTtlSeconds))) {\r\n throw new SsoError(\"logout_token_replay\", \"Logout Token jti 重放\");\r\n }\r\n } else {\r\n // 非原子 has+add 回退:仅单实例部署可接受\r\n // (并发窗口内同一 jti 可能双双通过检查)\r\n if (await jtiStore.has(replayKey)) {\r\n throw new SsoError(\"logout_token_replay\", \"Logout Token jti 重放\");\r\n }\r\n await jtiStore.add(replayKey, replayTtlSeconds);\r\n }\r\n } else if (!recordJti(replayKey, payload.exp * 1000 + 60_000)) {\r\n throw new SsoError(\"logout_token_replay\", \"Logout Token jti 重放\");\r\n }\r\n\r\n return { payload: { sub, sid }, jti, replayKey };\r\n}\r\n\r\n/**\r\n * 验证 Backchannel Logout Token(仅返回负载,向后兼容入口)\r\n *\r\n * 注意:除验证外还会记录 jti 防重放。调用方若在验证通过后本地处理失败,\r\n * 需改用 verifyLogoutTokenDetailed 获取 jti/replayKey 并调用 releaseLogoutTokenJti\r\n * 释放,以便 IdP 重投。\r\n *\r\n * @param options.jtiStore 同 verifyLogoutTokenDetailed\r\n */\r\nexport async function verifyLogoutToken(\r\n logoutToken: string,\r\n ssoBaseUrl: string,\r\n clientId: string,\r\n options: { jtiStore?: LogoutJtiStore } = {}\r\n): Promise<LogoutTokenPayload> {\r\n const verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options);\r\n return verified.payload;\r\n}\r\n"],"mappings":"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","names":["import_server","import_server","response","import_server","FETCH_TIMEOUT_MS","generateRandomString","import_server"]}
|