feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
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
}