feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user