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() 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, } }