gongxiangp2/lib/dingtalk-session-ticket.ts
2026-07-17 18:55:37 +08:00

195 lines
5.2 KiB
TypeScript

import { createHash, randomUUID, timingSafeEqual } from 'crypto'
type DingtalkSessionTicket = {
id: string
redirectPath: string
cookies?: string[]
userId?: string | null
targetOrigin?: string | null
handoffId?: string | null
handoffBindingHash?: string | null
nonce?: string | null
expiresAt: number
}
const TICKET_TTL_SECONDS = 60
const TICKET_TTL_MS = TICKET_TTL_SECONDS * 1_000
const REDIS_KEY_PREFIX = 'dingtalk:session-ticket:'
const tickets = new Map<string, DingtalkSessionTicket>()
function cleanupExpiredTickets() {
const now = Date.now()
tickets.forEach((ticket, id) => {
if (ticket.expiresAt <= now) {
tickets.delete(id)
}
})
}
function shouldUseRedisTicketStore() {
return process.env.USE_REDIS === 'true' && Boolean(process.env.REDIS_URL)
}
function getRedisKey(id: string) {
return `${REDIS_KEY_PREFIX}${id}`
}
function normalizeTicket(id: string, raw: string): DingtalkSessionTicket | null {
try {
const ticket = JSON.parse(raw) as DingtalkSessionTicket
if (
ticket.id !== id ||
(!Array.isArray(ticket.cookies) && !ticket.userId) ||
(Array.isArray(ticket.cookies) && !ticket.cookies.every((cookie) => typeof cookie === 'string' && cookie.length > 0)) ||
typeof ticket.redirectPath !== 'string' ||
!Number.isFinite(ticket.expiresAt) ||
ticket.expiresAt <= Date.now()
) {
return null
}
return ticket
} catch {
return null
}
}
function matchesExpectedTargetOrigin(ticket: DingtalkSessionTicket, expectedTargetOrigin?: string | null) {
if (!ticket.targetOrigin) {
return true
}
return ticket.targetOrigin === String(expectedTargetOrigin || '').trim()
}
function createBindingHash(value: string) {
return createHash('sha256').update(value).digest('hex')
}
function matchesExpectedHandoffBinding(ticket: DingtalkSessionTicket, expectedHandoffBinding?: string | null) {
if (!ticket.handoffBindingHash) {
return true
}
const expected = String(expectedHandoffBinding || '')
if (!expected) return false
const expectedHash = createBindingHash(expected)
const left = Buffer.from(ticket.handoffBindingHash, 'hex')
const right = Buffer.from(expectedHash, 'hex')
return left.length === right.length && timingSafeEqual(left, right)
}
export async function createDingtalkSessionTicket(input: {
redirectPath: string
cookies?: string[]
userId?: string | null
targetOrigin?: string | null
handoffId?: string | null
handoffBindingHash?: string | null
nonce?: string | null
}) {
if (!input.userId && (!Array.isArray(input.cookies) || input.cookies.length === 0)) {
throw new Error('创建钉钉登录会话交接票据时缺少会话内容')
}
const id = randomUUID()
const ticket: DingtalkSessionTicket = {
id,
redirectPath: input.redirectPath,
cookies: input.cookies || [],
userId: input.userId || null,
targetOrigin: input.targetOrigin || null,
handoffId: input.handoffId || null,
handoffBindingHash: input.handoffBindingHash || null,
nonce: input.nonce || null,
expiresAt: Date.now() + TICKET_TTL_MS,
}
if (shouldUseRedisTicketStore()) {
const { redis } = await import('@/lib/upload-sessions-redis')
const saved = await redis.set(
getRedisKey(id),
JSON.stringify(ticket),
'EX',
TICKET_TTL_SECONDS,
'NX',
)
if (saved !== 'OK') {
throw new Error('创建钉钉登录会话交接票据失败')
}
return id
}
cleanupExpiredTickets()
tickets.set(id, ticket)
return id
}
export async function consumeDingtalkSessionTicket(
id: string | null | undefined,
options?: {
expectedTargetOrigin?: string | null
expectedHandoffBinding?: string | null
},
) {
const normalizedId = String(id || '').trim()
if (!normalizedId) {
return null
}
if (shouldUseRedisTicketStore()) {
const { redis } = await import('@/lib/upload-sessions-redis')
const key = getRedisKey(normalizedId)
const raw = await redis.get(key)
if (!raw) return null
const ticket = normalizeTicket(normalizedId, raw)
if (
!ticket ||
!matchesExpectedTargetOrigin(ticket, options?.expectedTargetOrigin) ||
!matchesExpectedHandoffBinding(ticket, options?.expectedHandoffBinding)
) {
return null
}
const consumed = await redis.eval(
'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0',
1,
key,
raw,
)
if (Number(consumed) !== 1) return null
return {
redirectPath: ticket.redirectPath,
cookies: ticket.cookies || [],
userId: ticket.userId || null,
targetOrigin: ticket.targetOrigin || null,
handoffId: ticket.handoffId || null,
}
}
cleanupExpiredTickets()
const ticket = tickets.get(normalizedId)
if (
!ticket ||
ticket.expiresAt <= Date.now() ||
!matchesExpectedTargetOrigin(ticket, options?.expectedTargetOrigin) ||
!matchesExpectedHandoffBinding(ticket, options?.expectedHandoffBinding)
) {
return null
}
tickets.delete(normalizedId)
return {
redirectPath: ticket.redirectPath,
cookies: ticket.cookies || [],
userId: ticket.userId || null,
targetOrigin: ticket.targetOrigin || null,
handoffId: ticket.handoffId || null,
}
}