import CryptoJS from 'crypto-js' import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { createError } from 'h3' export interface OAuthState { target: string csrf: string timestamp: number provider?: string returnTo?: string loginType?: string } export const getSafeOAuthReturnPath = (value: unknown): string | undefined => { const path = Array.isArray(value) ? value[0] : value return typeof path === 'string' && path.startsWith('/') && !path.startsWith('//') && !path.startsWith('/\\') ? path : undefined } const normalizePort = (url: URL): string => { if (url.port) return url.port if (url.protocol === 'https:') return '443' if (url.protocol === 'http:') return '80' return '' } const OAUTH_STATE_COOKIE_PREFIX = 'b64url:' export interface OAuthStateCookieNames { csrf: string fullState: string compactState: string } export const LEGACY_OAUTH_STATE_COOKIE_NAMES: OAuthStateCookieNames = { csrf: 'oauth_csrf', fullState: 'oauth_full_state', compactState: 'oauth_compact_state' } export const getOAuthStateCookieNames = (compactState?: string): OAuthStateCookieNames => { if (!compactState) return LEGACY_OAUTH_STATE_COOKIE_NAMES const suffix = createHash('sha256').update(compactState).digest('hex').slice(0, 24) return { csrf: `oauth_csrf_${suffix}`, fullState: `oauth_full_state_${suffix}`, compactState: `oauth_compact_state_${suffix}` } } // AES state 是标准 Base64,Cookie 中只需转换为 URL 安全格式以避免 +、/、= 被改写。 export const encodeOAuthStateCookie = (state: string): string => `${OAUTH_STATE_COOKIE_PREFIX}${Buffer.from(state, 'base64').toString('base64url')}` export const decodeOAuthStateCookie = (state: string): string => { if (!state.startsWith(OAUTH_STATE_COOKIE_PREFIX)) { return state } const encodedState = state.slice(OAUTH_STATE_COOKIE_PREFIX.length) if (!encodedState || !/^[A-Za-z0-9_-]+$/.test(encodedState)) { return '' } try { const decodedState = Buffer.from(encodedState, 'base64url') if (decodedState.toString('base64url') !== encodedState) { return '' } return decodedState.toString('base64') } catch { return '' } } // 生成 OAuth 状态参数 export const generateState = ( targetOrigin: string, provider?: string, secretKey?: string, returnTo?: string, loginType?: string ): { state: string; csrf: string } => { if (!secretKey) { throw createError({ statusCode: 500, message: 'OAuth State 密钥未配置,请在管理员后台配置 OAuth State 密钥' }) } const csrf = randomBytes(32).toString('hex') const payload: OAuthState = { target: targetOrigin, csrf, timestamp: Date.now(), provider, returnTo: getSafeOAuthReturnPath(returnTo), loginType } const json = JSON.stringify(payload) const state = CryptoJS.AES.encrypt(json, secretKey).toString() return { state, csrf } } // 聚合登录服务会把 state 截断为 100 字符,因此 Broker 只接收携带源站的可验签短路由信息。 // 完整状态保存在 HttpOnly Cookie 中,回调时仍会校验 Origin、CSRF 和过期时间。 export const generateCompactOAuthState = (targetOrigin: string, secretKey?: string): string => { if (!secretKey) { throw createError({ statusCode: 500, message: 'OAuth State 密钥未配置' }) } const target = new URL(targetOrigin).origin const encodedTarget = Buffer.from(target, 'utf8').toString('base64url') const nonce = randomBytes(8).toString('base64url') const payload = `v1.${encodedTarget}.${nonce}` const signature = createHmac('sha256', secretKey) .update(payload) .digest() .subarray(0, 12) .toString('base64url') const state = `${payload}.${signature}` if (state.length > 100) { throw createError({ statusCode: 400, message: '当前站点地址过长,无法兼容聚合登录的 state 长度限制' }) } return state } // 校验紧凑 state 的 HMAC 签名,阻止伪造的 state 进入后续流程。 export const verifyCompactOAuthState = (state: string, secretKey?: string): boolean => { if (!secretKey) { throw createError({ statusCode: 500, message: 'OAuth State 密钥未配置' }) } const lastDot = state.lastIndexOf('.') if (lastDot <= 0 || !state.startsWith('v1.')) return false try { const payload = state.slice(0, lastDot) const expected = createHmac('sha256', secretKey).update(payload).digest().subarray(0, 12) const actual = Buffer.from(state.slice(lastDot + 1), 'base64url') return actual.length === expected.length && timingSafeEqual(actual, expected) } catch { return false } } // 解析 OAuth 状态参数 export const parseState = ( stateStr: string, expectedOrigin?: string, expectedCsrf?: string, secretKey?: string ): OAuthState | null => { if (!secretKey) { throw createError({ statusCode: 500, message: 'OAuth State 密钥未配置,请在管理员后台配置 OAuth State 密钥' }) } // 修复 base64 中的 '+' 在 URL 传输中被错误解析为空格的问题 // 某些 OAuth 提供商或浏览器环境可能会导致 URL 查询参数中的 '+' 号变成空格 const sanitizedStateStr = stateStr.replace(/ /g, '+') try { const bytes = CryptoJS.AES.decrypt(sanitizedStateStr, secretKey) const json = bytes.toString(CryptoJS.enc.Utf8) if (!json) return null const payload = JSON.parse(json) // 检查时间戳是否过期(例如:10分钟) if (Date.now() - payload.timestamp > 10 * 60 * 1000) { console.error('OAuth state expired') return null } // 验证 target origin - 在Codespaces等环境中,两次请求可能使用不同的host // 所以我们只验证protocol相同,但允许不同的subdomain if (expectedOrigin && payload.target) { try { const expectedUrl = new URL(expectedOrigin) const payloadUrl = new URL(payload.target) // 如果protocol不匹配,拒绝 if (expectedUrl.protocol !== payloadUrl.protocol) { console.error( `OAuth state protocol mismatch: ${expectedUrl.protocol} vs ${payloadUrl.protocol}` ) return null } // 判断是否为相同的源站 // 使用 hostname 避免端口导致误判,端口单独比较 const expectedHost = expectedUrl.hostname const payloadHost = payloadUrl.hostname const expectedPort = normalizePort(expectedUrl) const payloadPort = normalizePort(payloadUrl) // hostname 相同时也要校验端口一致,避免同 host 不同端口被接受 if (expectedHost === payloadHost) { if (expectedPort !== payloadPort) { console.error(`OAuth state port mismatch: ${expectedPort} vs ${payloadPort}`) return null } } else { // host 不同 - 检查是否为已知的需要兼容的环境 // 仅在 *.github.dev(Codespaces 环境)中允许不同的子域名 const isExpectedGitHubDev = expectedHost.endsWith('.github.dev') || expectedHost === 'localhost' || expectedHost === '127.0.0.1' const isPayloadGitHubDev = payloadHost.endsWith('.github.dev') || payloadHost === 'localhost' || payloadHost === '127.0.0.1' if (isExpectedGitHubDev && isPayloadGitHubDev) { // 在 Codespaces 等环境中,允许子域名变化(例如端口号变化导致的 subdomain 变化) // 但仍需要验证基础域名相同(.github.dev) const expectedParts = expectedHost.split('.') const payloadParts = payloadHost.split('.') // 对于 localhost, 要求完全相同 if (expectedHost === 'localhost' || payloadHost === 'localhost') { if (expectedHost !== payloadHost || expectedPort !== payloadPort) { console.error( `OAuth state host/port mismatch (localhost): ${expectedHost}:${expectedPort} vs ${payloadHost}:${payloadPort}` ) return null } } else if (expectedHost === '127.0.0.1' || payloadHost === '127.0.0.1') { // 对于 127.0.0.1,也要求完全相同 if (expectedHost !== payloadHost || expectedPort !== payloadPort) { console.error( `OAuth state host/port mismatch (127.0.0.1): ${expectedHost}:${expectedPort} vs ${payloadHost}:${payloadPort}` ) return null } } else { // 对于 *.github.dev,允许不同的子域名,但要求基础部分相同 // 例如:crispy-funicular-q77p479p56r9hx7vr-3000.app.github.dev // 和 crispy-funicular-q77p479p56r9hx7vr.app.github.dev 都可以通过 const expectedBase = expectedParts.slice(-3).join('.') const payloadBase = payloadParts.slice(-3).join('.') if (expectedBase !== payloadBase) { console.error(`OAuth state domain mismatch: ${expectedBase} vs ${payloadBase}`) return null } } } else { // 对于非白名单环境,要求 host 完全相同 console.error(`OAuth state host mismatch: ${expectedHost} vs ${payloadHost}`) return null } } } catch (e) { console.error('Failed to parse OAuth state URLs', e) return null } } // 验证 CSRF if (!expectedCsrf || payload.csrf !== expectedCsrf) { console.error('OAuth state CSRF mismatch or missing cookie') return null } payload.returnTo = getSafeOAuthReturnPath(payload.returnTo) return payload } catch { // 解密失败通常来自密钥不一致或客户端状态损坏,不应作为服务端异常上报。 console.warn('OAuth state 解密失败:state 已损坏、被截断或 OAuth State 密钥不一致') return null } } export const getRedirectUri = (provider: string, redirectUriTemplate?: string): string => { let redirectUri = redirectUriTemplate if (!redirectUri) { throw createError({ statusCode: 500, message: 'OAuth 重定向 URI 未配置,请在管理员后台配置 OAuth 重定向 URI' }) } // 支持 [provider] 占位符 redirectUri = redirectUri.replace('[provider]', provider) // 兼容用户可能错误地将 "provider" 作为字面量填写的情况 if (redirectUri.includes('/provider/callback')) { redirectUri = redirectUri.replace('/provider/callback', `/${provider}/callback`) } return redirectUri }