@nihplod/sso-sdk 1.0.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 +360 -0
- package/dist/index.d.mts +272 -0
- package/dist/index.d.ts +272 -0
- package/dist/index.js +788 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +740 -0
- package/dist/index.mjs.map +1 -0
- package/dist/next/index.d.mts +201 -0
- package/dist/next/index.d.ts +201 -0
- package/dist/next/index.js +583 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/index.mjs +547 -0
- package/dist/next/index.mjs.map +1 -0
- package/dist/react/index.d.mts +301 -0
- package/dist/react/index.d.ts +301 -0
- package/dist/react/index.js +1037 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/index.mjs +1003 -0
- package/dist/react/index.mjs.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/next/middleware.ts","../../src/next/constants.ts","../../src/next/callback.ts","../../src/next/logout.ts"],"sourcesContent":["/**\r\n * Next.js Middleware 辅助函数\r\n *\r\n * 在 Edge Runtime 中运行,自动检测未认证请求并重定向到 SSO 登录页。\r\n *\r\n * ⚠️ Edge Runtime 限制:\r\n * - 不能使用 Node.js crypto 模块\r\n * - PKCE code_challenge 使用 Web Crypto API 的 crypto.subtle.digest(SHA-256)\r\n * (Edge Runtime 18+ 完全支持此 API)\r\n *\r\n * 用法 (src/middleware.ts):\r\n * ```ts\r\n * import { createSsoMiddleware } from \"@nihplod/sso-sdk/next\";\r\n *\r\n * export const middleware = createSsoMiddleware({\r\n * clientId: \"my-app\",\r\n * ssoBaseUrl: \"https://nihplod.cn\",\r\n * redirectUri: \"https://myapp.com/api/auth/callback\",\r\n * publicPaths: [\"/\", \"/public\", \"/api/public\"],\r\n * });\r\n * ```\r\n */\r\n\r\nimport { NextRequest, NextResponse } from \"next/server\";\r\nimport {\r\n DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\r\n DEFAULT_STATE_COOKIE_NAME,\r\n DEFAULT_RETURN_COOKIE_NAME,\r\n DEFAULT_VERIFIER_COOKIE_NAME,\r\n getHostCookieOptions,\r\n getSecureCookieOptions,\r\n} from \"./constants\";\r\n\r\n// ============================================\r\n// 类型定义\r\n// ============================================\r\n\r\nexport interface SsoMiddlewareConfig {\r\n /** OAuth Client ID */\r\n clientId: string;\r\n\r\n /** SSO 中心地址 */\r\n ssoBaseUrl: string;\r\n\r\n /** 回调 URL(须与注册的 redirect_uri 一致) */\r\n redirectUri: string;\r\n\r\n /** 请求的 scope(空格分隔),默认 \"openid profile\" */\r\n scopes?: string;\r\n\r\n /** 公开路由前缀(不需要认证) */\r\n publicPaths?: string[];\r\n\r\n /** 回调路径(不触发重定向),默认 \"/api/auth/callback\" */\r\n callbackPath?: string;\r\n\r\n /**\r\n * 主站用户会话 Cookie 名称,用于检测是否已有 SSO 会话。\r\n * 默认 \"__Host-user_token\"(与主站 C 端登录 Cookie 一致)。\r\n */\r\n ssoCookieName?: string;\r\n\r\n /**\r\n * OAuth Client Secret(Confidential Client 使用)\r\n * 浏览器端 Public Client 应省略。\r\n */\r\n clientSecret?: string;\r\n\r\n /** 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 /** 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// ============================================\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 * 从 JWT 中解码 payload(不验证签名,仅用于 Edge Runtime 快速检查 exp)\r\n */\r\nfunction 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 payload = parts[1];\r\n // base64url decode (Edge Runtime 兼容)\r\n const base64 = payload.replace(/-/g, \"+\").replace(/_/g, \"/\");\r\n const decoded = atob(base64);\r\n return JSON.parse(decoded) as Record<string, unknown>;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\n/**\r\n * 通过主站 Introspection 端点校验 access_token 是否仍有效。\r\n * Confidential Client(BFF)应配置 clientSecret,防止伪造 Cookie 绕过。\r\n */\r\nasync function introspectAccessToken(\r\n token: string,\r\n ssoBaseUrl: string,\r\n clientId: string,\r\n clientSecret?: string\r\n): Promise<boolean> {\r\n try {\r\n const body = new URLSearchParams({\r\n token,\r\n token_type_hint: \"access_token\",\r\n client_id: clientId,\r\n });\r\n if (clientSecret) {\r\n body.set(\"client_secret\", clientSecret);\r\n }\r\n\r\n const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: body.toString(),\r\n });\r\n\r\n if (!res.ok) return false;\r\n const data = (await res.json()) as { active?: boolean };\r\n return data.active === true;\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\n// ============================================\r\n// Middleware 工厂函数\r\n// ============================================\r\n\r\nexport function createSsoMiddleware(config: SsoMiddlewareConfig) {\r\n const {\r\n clientId,\r\n clientSecret,\r\n ssoBaseUrl,\r\n redirectUri,\r\n scopes = \"openid profile\",\r\n publicPaths = [],\r\n callbackPath = \"/api/auth/callback\",\r\n ssoCookieName = \"__Host-user_token\",\r\n accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\r\n stateCookieName = DEFAULT_STATE_COOKIE_NAME,\r\n returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,\r\n verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,\r\n } = config;\r\n\r\n // 规范化 ssoBaseUrl\r\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n\r\n return async function ssoMiddleware(request: NextRequest) {\r\n const { pathname } = request.nextUrl;\r\n\r\n // Exclude Next.js internal routes and static assets\r\n if (\r\n pathname.startsWith(\"/_next/\") ||\r\n pathname.startsWith(\"/favicon.ico\") ||\r\n pathname.match(/\\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)\r\n ) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Callback path: let the callback handler deal with it\r\n if (pathname === callbackPath) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Public paths: no auth required\r\n const allPublicPaths = [callbackPath, ...publicPaths];\r\n if (matchesPath(pathname, allPublicPaths)) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Check for existing SSO session\r\n const ssoSession = request.cookies.get(ssoCookieName);\r\n if (ssoSession?.value) {\r\n return NextResponse.next();\r\n }\r\n\r\n // Check for access_token in cookie (set by callback handler)\r\n const accessTokenCookie = request.cookies.get(accessTokenCookieName);\r\n if (accessTokenCookie?.value) {\r\n // 调用 Introspection 精确校验 token 是否仍有效。\r\n // Confidential Client 携带 clientSecret;Public Client 仅传 clientId。\r\n const tokenActive = await introspectAccessToken(\r\n accessTokenCookie.value,\r\n normalizedBase,\r\n clientId,\r\n clientSecret\r\n );\r\n\r\n if (tokenActive) {\r\n return NextResponse.next();\r\n }\r\n // Token 无效或已过期:清除 cookie 并继续到 SSO 重定向\r\n }\r\n\r\n // No auth: redirect to SSO\r\n const state = generateRandomString(32);\r\n 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(\"code_challenge\", challenge);\r\n authorizeParams.set(\"code_challenge_method\", \"S256\");\r\n\r\n const loginUrl = new URL(\"/api/oauth/authorize\", normalizedBase);\r\n loginUrl.search = authorizeParams.toString();\r\n\r\n const response = NextResponse.redirect(loginUrl);\r\n\r\n // Set state cookie for CSRF verification on callback\r\n response.cookies.set(stateCookieName, state, getHostCookieOptions(600));\r\n\r\n // Set PKCE verifier cookie(httpOnly,供 callback handler 使用)\r\n // 使用 __Secure- 前缀,允许写入 callbackPath\r\n response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath));\r\n\r\n // Set return URL cookie\r\n response.cookies.set(returnUrlCookieName, request.url, getHostCookieOptions(600));\r\n\r\n // 如果存在过期的 access_token cookie,立即清除\r\n if (accessTokenCookie?.value) {\r\n response.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0));\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\";\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\n/** __Host- 前缀 Cookie 的安全选项(Path 必须为 /) */\nexport function getHostCookieOptions(maxAge?: number): {\n httpOnly: true;\n secure: true;\n sameSite: \"lax\";\n path: \"/\";\n maxAge?: number;\n} {\n return {\n httpOnly: true,\n secure: true,\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): {\n httpOnly: true;\n secure: true;\n sameSite: \"lax\";\n path: string;\n maxAge?: number;\n} {\n return {\n httpOnly: true,\n secure: true,\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 {\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_RETURN_COOKIE_NAME,\r\n DEFAULT_VERIFIER_COOKIE_NAME,\r\n getHostCookieOptions,\r\n getSecureCookieOptions,\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 /** 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// ============================================\r\n// 工具函数\r\n// ============================================\r\n\r\n/**\r\n * 校验回调后的 returnUrl 是否可信。\r\n * 仅允许:相对路径(且不以 // 开头)或与当前 origin 完全一致。\r\n */\r\nfunction isTrustedReturnUrl(url: string, currentOrigin: string): boolean {\r\n if (!url) return false;\r\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return true;\r\n try {\r\n return new URL(url).origin === currentOrigin;\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\n// ============================================\r\n// ID Token 预校验(写 Cookie 之前)\r\n// ============================================\r\n\r\ninterface 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}\r\n\r\ninterface Jwks {\r\n keys: JwksKey[];\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\nfunction 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\nfunction 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\nasync function fetchJwks(baseUrl: string): Promise<Jwks | null> {\r\n try {\r\n const res = await fetch(`${baseUrl}/api/oauth/jwks`);\r\n if (!res.ok) return null;\r\n return (await res.json()) as Jwks;\r\n } catch {\r\n return null;\r\n }\r\n}\r\n\r\nasync 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 cryptoKey = await crypto.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 crypto.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\nasync function computeAtHash(accessToken: string): Promise<string> {\r\n const hash = await crypto.subtle.digest(\r\n \"SHA-256\",\r\n new TextEncoder().encode(accessToken)\r\n );\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/**\r\n * 在设置 Cookie 前校验 ID Token 基本声明与签名。\r\n * HS256 场景下无法验证签名(Confidential/BFF 应配置 RS256),仅校验声明与 at_hash。\r\n */\r\nasync function validateIdToken(\r\n idToken: string,\r\n accessToken: string,\r\n expectedIssuer: string,\r\n expectedClientId: string\r\n): Promise<void> {\r\n const header = decodeJwtHeader(idToken);\r\n if (!header) throw new Error(\"ID Token 格式错误\");\r\n\r\n const alg = header.alg;\r\n if (typeof alg !== \"string\" || (alg !== \"RS256\" && alg !== \"HS256\")) {\r\n throw new Error(`不支持的 ID Token 签名算法: ${alg}`);\r\n }\r\n\r\n if (alg === \"RS256\") {\r\n const jwks = await fetchJwks(expectedIssuer);\r\n if (!jwks) throw new Error(\"无法获取 JWKS\");\r\n const kid = typeof header.kid === \"string\" ? header.kid : undefined;\r\n const key = jwks.keys.find(\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 if (!key) throw new Error(\"JWKS 中未找到匹配公钥\");\r\n const valid = await verifyRs256Signature(idToken, key);\r\n if (!valid) throw new Error(\"ID Token 签名验证失败\");\r\n }\r\n\r\n // HS256:对称密钥不应通过 JWKS 暴露给 BFF,Confidential Client 应迁移到 RS256\r\n if (alg === \"HS256\") {\r\n // eslint-disable-next-line no-console\r\n console.warn(\r\n \"[SSO SDK/Next] ID Token 使用 HS256 签名。建议主站启用 RS256 以获得完整签名验证。\"\r\n );\r\n }\r\n\r\n const payload = decodeJwtPayload(idToken);\r\n if (!payload) throw new Error(\"ID Token payload 解析失败\");\r\n\r\n const normalizedIssuer = expectedIssuer.replace(/\\/+$/, \"\");\r\n const tokenIssuer =\r\n typeof payload.iss === \"string\" ? payload.iss.replace(/\\/+$/, \"\") : \"\";\r\n if (tokenIssuer !== normalizedIssuer) {\r\n throw new Error(\"ID Token issuer 不匹配\");\r\n }\r\n\r\n const aud = payload.aud;\r\n const audArr = Array.isArray(aud) ? aud : [aud];\r\n if (!audArr.includes(expectedClientId)) {\r\n throw new Error(\"ID Token audience 不匹配\");\r\n }\r\n\r\n if (typeof payload.exp === \"number\" && Date.now() >= payload.exp * 1000) {\r\n throw new Error(\"ID Token 已过期\");\r\n }\r\n if (typeof payload.sub !== \"string\" || !payload.sub) {\r\n throw new Error(\"ID Token 缺少 sub\");\r\n }\r\n\r\n if (typeof payload.at_hash === \"string\" && payload.at_hash) {\r\n const actual = await computeAtHash(accessToken);\r\n if (actual !== payload.at_hash) {\r\n throw new Error(\"ID Token at_hash 不匹配\");\r\n }\r\n }\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 accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\r\n refreshTokenCookieName = DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\r\n idTokenCookieName = DEFAULT_ID_TOKEN_COOKIE_NAME,\r\n stateCookieName = DEFAULT_STATE_COOKIE_NAME,\r\n returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,\r\n verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,\r\n } = config;\r\n\r\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\r\n\r\n return async function GET(request: NextRequest) {\r\n const { searchParams } = request.nextUrl;\r\n\r\n // 检查错误\r\n const error = searchParams.get(\"error\");\r\n if (error) {\r\n const desc = searchParams.get(\"error_description\") || error;\r\n return NextResponse.json(\r\n { error: \"authorization_failed\", error_description: desc },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n const code = searchParams.get(\"code\");\r\n const returnedState = searchParams.get(\"state\");\r\n\r\n if (!code) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"缺少 authorization code\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 验证 state(从 cookie 中读取原始 state,CSRF 必需)\r\n const savedState = request.cookies.get(stateCookieName)?.value;\r\n if (!savedState) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"State 参数缺失,请重新发起授权请求\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n if (returnedState !== savedState) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"State 参数不匹配,可能存在 CSRF 攻击\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 读取 PKCE code_verifier(middleware 存入的 httpOnly cookie)\r\n const verifier = request.cookies.get(verifierCookieName)?.value;\r\n if (!verifier) {\r\n return NextResponse.json(\r\n {\r\n error: \"invalid_request\",\r\n error_description: \"PKCE verifier 缺失,请重新发起授权请求\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n\r\n // 交换 token\r\n const tokenEndpoint = `${normalizedBase}/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;\r\n let lastError: unknown;\r\n const maxRetries = 1;\r\n\r\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\r\n if (attempt > 0) {\r\n // 指数退避:1s * 2^attempt\r\n await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));\r\n }\r\n try {\r\n res = await fetch(tokenEndpoint, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\r\n body: body.toString(),\r\n });\r\n lastError = null;\r\n break;\r\n } catch (err) {\r\n lastError = err;\r\n if (attempt >= maxRetries) {\r\n return NextResponse.json(\r\n { error: \"server_error\", error_description: \"Token 请求失败,已重试仍不可达\" },\r\n { status: 502 }\r\n );\r\n }\r\n }\r\n }\r\n\r\n if (lastError || !res!) {\r\n return NextResponse.json(\r\n { error: \"server_error\", error_description: \"Token 请求失败\" },\r\n { status: 502 }\r\n );\r\n }\r\n\r\n if (!res.ok) {\r\n let errData: Record<string, unknown> = {};\r\n try {\r\n errData = await res.json();\r\n } catch { /* ignore */ }\r\n return NextResponse.json(\r\n {\r\n error: \"token_request_failed\",\r\n error_description:\r\n (errData.error_description as string) || `Token 请求失败: HTTP ${res.status}`,\r\n },\r\n { status: 502 }\r\n );\r\n }\r\n\r\n const tokenData: {\r\n access_token: string;\r\n token_type: string;\r\n expires_in: number;\r\n refresh_token: string;\r\n id_token?: string;\r\n } = await res.json();\r\n\r\n // 在设置 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 );\r\n } catch (err) {\r\n return NextResponse.json(\r\n {\r\n error: \"id_token_invalid\",\r\n error_description: err instanceof Error ? err.message : \"ID Token 验证失败\",\r\n },\r\n { status: 400 }\r\n );\r\n }\r\n }\r\n\r\n // 读取 return URL,并做开放重定向防护\r\n const rawReturnUrl =\r\n request.cookies.get(returnUrlCookieName)?.value || defaultReturnPath;\r\n const returnUrl = isTrustedReturnUrl(rawReturnUrl, request.nextUrl.origin)\r\n ? rawReturnUrl\r\n : \"/\";\r\n\r\n // 重定向并设置 cookie\r\n const response = NextResponse.redirect(new URL(returnUrl, request.url));\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),\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),\r\n });\r\n\r\n // 设置 id_token cookie,用于 RP-Initiated Logout 的 id_token_hint\r\n if (tokenData.id_token) {\r\n response.cookies.set(idTokenCookieName, tokenData.id_token, {\r\n ...getHostCookieOptions(refreshMaxAge),\r\n });\r\n }\r\n\r\n // 清除临时 cookies: state / return URL\r\n response.cookies.set(stateCookieName, \"\", getHostCookieOptions(0));\r\n response.cookies.set(returnUrlCookieName, \"\", getHostCookieOptions(0));\r\n\r\n // 清除 PKCE verifier cookie,必须使用写入时的 path(callbackPath)\r\n // 由于 callback handler 不知道 middleware 的 callbackPath,这里保守地\r\n // 同时清除 path=/ 和 path=当前请求路径两种可能\r\n response.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, \"/\"));\r\n response.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, request.nextUrl.pathname));\r\n\r\n return response;\r\n };\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. 可选重定向到 SSO 中心登出页\n *\n * 用法 (src/app/api/auth/logout/route.ts):\n * ```ts\n * import { createLogoutRouteHandler } from \"@nihplod/sso-sdk/next\";\n *\n * export const GET = 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 */\n\nimport { NextRequest, NextResponse } from \"next/server\";\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_RETURN_COOKIE_NAME,\n DEFAULT_VERIFIER_COOKIE_NAME,\n getHostCookieOptions,\n getSecureCookieOptions,\n} from \"./constants\";\n\n// ============================================\n// 类型定义\n// ============================================\n\nexport interface LogoutRouteConfig {\n /** OAuth Client ID */\n clientId: string;\n\n /** SSO 中心地址 */\n ssoBaseUrl: string;\n\n /** 回调 URL(与注册 redirect_uri 一致,用于构造 post_logout_redirect_uri) */\n redirectUri: string;\n\n /**\n * OAuth Client Secret(可选)。\n * 撤销 refresh_token 时,Confidential Client 需要认证。\n */\n clientSecret?: string;\n\n /** 登出后跳转回子项目的地址,默认取 redirectUri 的 origin */\n postLogoutRedirectUri?: string;\n\n /** 是否重定向到 SSO 中心登出页,默认 true */\n redirectToSso?: boolean;\n\n /** Access Token Cookie 名称 */\n accessTokenCookieName?: string;\n\n /** Refresh Token Cookie 名称 */\n refreshTokenCookieName?: string;\n\n /** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */\n idTokenCookieName?: string;\n\n /** State Cookie 名称 */\n stateCookieName?: string;\n\n /** 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\ninterface OidcDiscovery {\n issuer: string;\n end_session_endpoint?: string;\n revocation_endpoint?: string;\n [key: string]: unknown;\n}\n\n// ============================================\n// 工具函数\n// ============================================\n\nasync function fetchDiscovery(ssoBaseUrl: string): Promise<OidcDiscovery | null> {\n try {\n const controller = new AbortController();\n setTimeout(() => controller.abort(), 5000);\n const res = await fetch(\n `${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,\n { signal: controller.signal }\n );\n if (!res.ok) return null;\n return (await res.json()) as OidcDiscovery;\n } catch {\n return null;\n }\n}\n\n// ============================================\n// Route Handler 工厂函数\n// ============================================\n\nexport function createLogoutRouteHandler(config: LogoutRouteConfig) {\n const {\n clientId,\n ssoBaseUrl,\n redirectUri,\n clientSecret,\n postLogoutRedirectUri = new URL(redirectUri).origin + \"/\",\n redirectToSso = true,\n accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,\n refreshTokenCookieName = DEFAULT_REFRESH_TOKEN_COOKIE_NAME,\n idTokenCookieName = DEFAULT_ID_TOKEN_COOKIE_NAME,\n stateCookieName = DEFAULT_STATE_COOKIE_NAME,\n returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,\n verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,\n callbackPath = \"/api/auth/callback\",\n } = config;\n\n const normalizedBase = ssoBaseUrl.replace(/\\/+$/, \"\");\n\n return async function GET(request: NextRequest) {\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 const discovery = await fetchDiscovery(normalizedBase);\n const revokeUrl =\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 await fetch(revokeUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n });\n } catch {\n // 撤销失败不影响本地登出\n }\n }\n\n // 2. 准备本地清除 SSO cookie 的响应\n const clearCookies = (res: NextResponse) => {\n res.cookies.set(accessTokenCookieName, \"\", getHostCookieOptions(0));\n res.cookies.set(refreshTokenCookieName, \"\", getHostCookieOptions(0));\n res.cookies.set(idTokenCookieName, \"\", getHostCookieOptions(0));\n res.cookies.set(stateCookieName, \"\", getHostCookieOptions(0));\n res.cookies.set(returnUrlCookieName, \"\", getHostCookieOptions(0));\n res.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, \"/\"));\n res.cookies.set(verifierCookieName, \"\", getSecureCookieOptions(0, callbackPath));\n return res;\n };\n\n // 3. 若需要 RP-Initiated Logout,重定向到 SSO 中心,同时必须清除本地 Cookie\n if (redirectToSso) {\n const discovery = await fetchDiscovery(normalizedBase);\n const endSessionEndpoint =\n discovery?.end_session_endpoint || `${normalizedBase}/logout`;\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 return clearCookies(NextResponse.redirect(logoutUrl.toString()));\n }\n\n return clearCookies(\n NextResponse.redirect(request.nextUrl.origin + \"/\")\n );\n };\n}\n"],"mappings":"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","names":["NextResponse","NextResponse","NextResponse","NextResponse"]}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import React, { ReactNode, ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Token 存储抽象层
|
|
5
|
+
*
|
|
6
|
+
* 默认使用 localStorage,以支持多 Tab 间自动同步 token 并避免并发刷新。
|
|
7
|
+
* 对 XSS 敏感的子项目可通过 setTokenStorage() 注入更安全的自定义实现
|
|
8
|
+
*(如内存存储、Service Worker 封装、或带加密的 storage)。
|
|
9
|
+
*
|
|
10
|
+
* 多 client 隔离:
|
|
11
|
+
* - token / state / return_url 均支持按 clientId 隔离 key
|
|
12
|
+
* - 不传 clientId 时使用全局 key,保持向后兼容
|
|
13
|
+
*/
|
|
14
|
+
/** Token 数据 */
|
|
15
|
+
interface TokenData {
|
|
16
|
+
access_token: string;
|
|
17
|
+
token_type: string;
|
|
18
|
+
expires_in: number;
|
|
19
|
+
refresh_token: string;
|
|
20
|
+
id_token?: string;
|
|
21
|
+
/** Token 签发时间(epoch ms),用于计算过期 */
|
|
22
|
+
issued_at: number;
|
|
23
|
+
/** 访问令牌实际过期时间(epoch ms)= issued_at + expires_in * 1000 */
|
|
24
|
+
expires_at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* SSO Client 核心类
|
|
29
|
+
*
|
|
30
|
+
* 封装完整的 OAuth 2.0 授权码 + PKCE S256 流程:
|
|
31
|
+
* - login(): 发起授权请求
|
|
32
|
+
* - handleCallback(): 处理回调,交换 token
|
|
33
|
+
* - refreshToken(): 刷新 access token(原子轮换)
|
|
34
|
+
* - getUserInfo(): 获取用户信息
|
|
35
|
+
* - logout(): 登出
|
|
36
|
+
* - isAuthenticated(): 检查认证状态
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** SSO 客户端配置 */
|
|
40
|
+
interface SsoClientConfig {
|
|
41
|
+
/** OAuth Client ID(从管理后台获取) */
|
|
42
|
+
clientId: string;
|
|
43
|
+
/** OAuth Client Secret(从管理后台获取)
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ 安全提醒:client_secret 不应暴露在前端代码中。
|
|
46
|
+
* 对于浏览器端 SPA(Public Client),应省略此字段;SDK 会使用 PKCE 完成授权,
|
|
47
|
+
* 且 token 端点不会发送 client_secret。
|
|
48
|
+
* 对于 BFF/Next.js 等 Confidential Client,可传入 client_secret。
|
|
49
|
+
*/
|
|
50
|
+
clientSecret?: string;
|
|
51
|
+
/** 回调 URL(必须与注册的 redirect_uri 完全一致) */
|
|
52
|
+
redirectUri: string;
|
|
53
|
+
/** SSO 中心地址,如 "https://nihplod.cn" */
|
|
54
|
+
ssoBaseUrl: string;
|
|
55
|
+
/** 请求的 scope(空格分隔),如 "openid profile phone" */
|
|
56
|
+
scopes?: string;
|
|
57
|
+
}
|
|
58
|
+
/** 用户信息 */
|
|
59
|
+
interface SsoUser {
|
|
60
|
+
sub: string;
|
|
61
|
+
nickname?: string;
|
|
62
|
+
avatar?: string;
|
|
63
|
+
phone?: string;
|
|
64
|
+
membership_level?: string;
|
|
65
|
+
total_points?: number;
|
|
66
|
+
}
|
|
67
|
+
/** OIDC Discovery 文档 */
|
|
68
|
+
interface OidcDiscovery {
|
|
69
|
+
issuer: string;
|
|
70
|
+
authorization_endpoint: string;
|
|
71
|
+
token_endpoint: string;
|
|
72
|
+
userinfo_endpoint: string;
|
|
73
|
+
jwks_uri: string;
|
|
74
|
+
introspection_endpoint: string;
|
|
75
|
+
revocation_endpoint?: string;
|
|
76
|
+
end_session_endpoint?: string;
|
|
77
|
+
scopes_supported: string[];
|
|
78
|
+
response_types_supported: string[];
|
|
79
|
+
grant_types_supported: string[];
|
|
80
|
+
code_challenge_methods_supported: string[];
|
|
81
|
+
}
|
|
82
|
+
declare class SsoClient {
|
|
83
|
+
readonly config: SsoClientConfig;
|
|
84
|
+
private _discovery;
|
|
85
|
+
private _discoveryFetchedAt;
|
|
86
|
+
private _refreshLock;
|
|
87
|
+
/** Discovery 文档缓存 TTL(5 分钟) */
|
|
88
|
+
private static readonly DISCOVERY_TTL_MS;
|
|
89
|
+
/** Discovery fetch 超时(10 秒) */
|
|
90
|
+
private static readonly DISCOVERY_TIMEOUT_MS;
|
|
91
|
+
constructor(config: SsoClientConfig);
|
|
92
|
+
/**
|
|
93
|
+
* 获取 OIDC Discovery 文档(带缓存 + 超时)
|
|
94
|
+
*
|
|
95
|
+
* 缓存 5 分钟,超时 10 秒。
|
|
96
|
+
* 失败时返回 null(上层调用方回退到硬编码默认端点)。
|
|
97
|
+
*/
|
|
98
|
+
private _getDiscovery;
|
|
99
|
+
/** 获取 authorize 端点 URL(优先 Discovery,回退默认) */
|
|
100
|
+
private _getAuthorizeEndpoint;
|
|
101
|
+
/** 获取 token 端点 URL(优先 Discovery,回退默认) */
|
|
102
|
+
private _getTokenEndpoint;
|
|
103
|
+
/** 获取 userinfo 端点 URL(优先 Discovery,回退默认) */
|
|
104
|
+
private _getUserinfoEndpoint;
|
|
105
|
+
/**
|
|
106
|
+
* 发起 SSO 登录
|
|
107
|
+
*
|
|
108
|
+
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
109
|
+
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
110
|
+
*
|
|
111
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage)
|
|
112
|
+
*/
|
|
113
|
+
login(returnUrl?: string): Promise<void>;
|
|
114
|
+
/**
|
|
115
|
+
* 构建登录 URL(不跳转,返回 URL 字符串)
|
|
116
|
+
*
|
|
117
|
+
* 适用于需要手动处理跳转的场景。
|
|
118
|
+
*/
|
|
119
|
+
getLoginUrl(returnUrl?: string): Promise<string>;
|
|
120
|
+
/**
|
|
121
|
+
* 处理 OAuth 回调
|
|
122
|
+
*
|
|
123
|
+
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
124
|
+
* 成功后 token 自动保存到 sessionStorage。
|
|
125
|
+
*
|
|
126
|
+
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
127
|
+
* @returns TokenData 或 null
|
|
128
|
+
*/
|
|
129
|
+
handleCallback(callbackUrl: string): Promise<TokenData>;
|
|
130
|
+
/**
|
|
131
|
+
* 刷新 Access Token
|
|
132
|
+
*
|
|
133
|
+
* 使用 refresh_token 换取新的 access_token。
|
|
134
|
+
* 采用互斥锁防止并发刷新。
|
|
135
|
+
* 支持 Refresh Token 原子轮换。
|
|
136
|
+
*/
|
|
137
|
+
refreshToken(): Promise<TokenData>;
|
|
138
|
+
private _doRefreshToken;
|
|
139
|
+
/**
|
|
140
|
+
* 获取用户信息
|
|
141
|
+
*
|
|
142
|
+
* 若 access_token 已过期则自动刷新后再请求。
|
|
143
|
+
*/
|
|
144
|
+
getUserInfo(): Promise<SsoUser>;
|
|
145
|
+
/**
|
|
146
|
+
* 获取当前 access_token
|
|
147
|
+
*
|
|
148
|
+
* 若已过期则自动刷新。用于子项目自行发起 API 请求时获取 Bearer token。
|
|
149
|
+
* 若无 token 返回 null;若刷新失败则抛出错误(与 getUserInfo 行为一致)。
|
|
150
|
+
*/
|
|
151
|
+
getAccessToken(): Promise<string | null>;
|
|
152
|
+
/**
|
|
153
|
+
* 检查是否已认证(不发起网络请求)
|
|
154
|
+
*
|
|
155
|
+
* 仅检查本地是否存在未过期的 access_token。
|
|
156
|
+
*/
|
|
157
|
+
isAuthenticated(): boolean;
|
|
158
|
+
/**
|
|
159
|
+
* 登出
|
|
160
|
+
*
|
|
161
|
+
* 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
|
|
162
|
+
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)
|
|
163
|
+
*/
|
|
164
|
+
logout(redirectToSso?: boolean): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* 获取 OIDC Discovery 文档
|
|
167
|
+
*
|
|
168
|
+
* 用于调试和获取 SSO 中心完整配置。
|
|
169
|
+
* 可能返回 null(当 Discovery 端点不可达且无缓存时)。
|
|
170
|
+
*/
|
|
171
|
+
getDiscovery(): Promise<OidcDiscovery | null>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* SsoProvider - React Context Provider
|
|
176
|
+
*
|
|
177
|
+
* 包裹子项目根组件,提供全局 SSO 认证状态管理:
|
|
178
|
+
* - 自动管理 token 刷新定时器(过期前 60s 静默刷新)
|
|
179
|
+
* - 监听 storage 事件实现跨 Tab 同步
|
|
180
|
+
* - 提供 useSso() hook
|
|
181
|
+
*/
|
|
182
|
+
|
|
183
|
+
interface SsoContextValue {
|
|
184
|
+
/** 当前用户信息 */
|
|
185
|
+
user: SsoUser | null;
|
|
186
|
+
/** 是否已认证 */
|
|
187
|
+
isAuthenticated: boolean;
|
|
188
|
+
/** 是否正在加载(初始化/刷新中) */
|
|
189
|
+
isLoading: boolean;
|
|
190
|
+
/** 发起登录 */
|
|
191
|
+
login: (returnUrl?: string) => Promise<void>;
|
|
192
|
+
/** 登出 */
|
|
193
|
+
logout: (redirectToSso?: boolean) => Promise<void>;
|
|
194
|
+
/** 刷新用户信息 */
|
|
195
|
+
refreshUser: () => Promise<void>;
|
|
196
|
+
/** 获取 access_token(自动刷新过期 token) */
|
|
197
|
+
getAccessToken: () => Promise<string | null>;
|
|
198
|
+
/** SsoClient 实例(高级用法) */
|
|
199
|
+
client: SsoClient;
|
|
200
|
+
}
|
|
201
|
+
interface SsoProviderProps {
|
|
202
|
+
/** SSO 客户端配置 */
|
|
203
|
+
config: SsoClientConfig;
|
|
204
|
+
/** 子组件 */
|
|
205
|
+
children: ReactNode;
|
|
206
|
+
/**
|
|
207
|
+
* 自动刷新阈值(秒)
|
|
208
|
+
* access_token 过期前多少秒触发静默刷新。
|
|
209
|
+
* 默认 60 秒。
|
|
210
|
+
*/
|
|
211
|
+
refreshThreshold?: number;
|
|
212
|
+
/**
|
|
213
|
+
* API 请求函数(可选)
|
|
214
|
+
* 用于在 token 刷新后自动重试失败的 API 请求。
|
|
215
|
+
*/
|
|
216
|
+
onTokenRefreshed?: (token: string) => void;
|
|
217
|
+
}
|
|
218
|
+
declare function SsoProvider({ config, children, refreshThreshold, onTokenRefreshed, }: SsoProviderProps): React.FunctionComponentElement<React.ProviderProps<SsoContextValue | null>>;
|
|
219
|
+
/**
|
|
220
|
+
* useSso Hook
|
|
221
|
+
*
|
|
222
|
+
* 在 SsoProvider 内部使用,获取 SSO 认证状态和操作方法。
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```tsx
|
|
226
|
+
* function MyComponent() {
|
|
227
|
+
* const { user, isAuthenticated, login, logout } = useSso();
|
|
228
|
+
* if (!isAuthenticated) return <button onClick={() => login()}>登录</button>;
|
|
229
|
+
* return <div>欢迎, {user?.nickname}</div>;
|
|
230
|
+
* }
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
declare function useSso(): SsoContextValue;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* RequireAuth / withAuth — 路由保护组件
|
|
237
|
+
*
|
|
238
|
+
* 用法:
|
|
239
|
+
* - <RequireAuth> 包裹需要登录才能访问的路由
|
|
240
|
+
* - withAuth(Component) HOC 包装单个页面组件
|
|
241
|
+
*/
|
|
242
|
+
|
|
243
|
+
interface RequireAuthProps {
|
|
244
|
+
/** 子组件(受保护的内容) */
|
|
245
|
+
children: React.ReactNode;
|
|
246
|
+
/** 未登录时的回退内容(默认显示加载中) */
|
|
247
|
+
fallback?: React.ReactNode;
|
|
248
|
+
/** 是否在检测到未登录时自动发起登录跳转 */
|
|
249
|
+
autoLogin?: boolean;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 路由保护组件
|
|
253
|
+
*
|
|
254
|
+
* 未登录时自动触发登录跳转或显示回退内容。
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```tsx
|
|
258
|
+
* <RequireAuth>
|
|
259
|
+
* <Dashboard />
|
|
260
|
+
* </RequireAuth>
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
declare function RequireAuth({ children, fallback, autoLogin, }: RequireAuthProps): string | number | bigint | true | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | React.FunctionComponentElement<React.FragmentProps>;
|
|
264
|
+
/**
|
|
265
|
+
* 高阶组件:包装页面组件,要求认证后才能访问
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* ```tsx
|
|
269
|
+
* function DashboardPage() { return <div>Dashboard</div>; }
|
|
270
|
+
* export default withAuth(DashboardPage);
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
273
|
+
declare function withAuth<P extends object>(Component: ComponentType<P>): ComponentType<P>;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* CallbackPage — 通用 OAuth 回调页面组件
|
|
277
|
+
*
|
|
278
|
+
* 子项目只需在回调路由渲染此组件即可完成 code → token 交换。
|
|
279
|
+
* 交换成功后自动跳转到 returnUrl 或首页。
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```tsx
|
|
283
|
+
* // 在回调页面路由中:
|
|
284
|
+
* import { CallbackPage } from "@nihplod/sso-sdk/react";
|
|
285
|
+
* export default function AuthCallback() {
|
|
286
|
+
* return <CallbackPage />;
|
|
287
|
+
* }
|
|
288
|
+
* ```
|
|
289
|
+
*/
|
|
290
|
+
|
|
291
|
+
declare function CallbackPage(): React.DetailedReactHTMLElement<{
|
|
292
|
+
style: {
|
|
293
|
+
display: "flex";
|
|
294
|
+
alignItems: "center";
|
|
295
|
+
justifyContent: "center";
|
|
296
|
+
minHeight: string;
|
|
297
|
+
fontFamily: "system-ui, sans-serif";
|
|
298
|
+
};
|
|
299
|
+
}, HTMLElement> | null;
|
|
300
|
+
|
|
301
|
+
export { CallbackPage, RequireAuth, SsoProvider, useSso, withAuth };
|