import { createHmac, randomUUID, timingSafeEqual } from 'crypto' export { DINGTALK_PERFORMANCE_SESSION_COOKIE } from '@/lib/dingtalk-performance-session-constants' export const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session' export type DingtalkPerformanceRole = 'USER' | 'ADMIN' | 'DEPT_ADMIN' | 'SYSTEM_ADMIN' export type DingtalkPerformanceSessionScope = | { kind: 'assignment' surveyId: string targetId: string } | { kind: 'results' surveyId: string targetId?: string | null } | { kind: 'workspace' } export type DingtalkPerformanceSessionPayload = { v: 1 purpose: 'performance-survey' userId: string email: string name: string avatar?: string | null role: DingtalkPerformanceRole department?: string | null managedDepartment?: string | null isSuperAdmin?: boolean | null scope: DingtalkPerformanceSessionScope iat: number exp: number nonce: string } type DingtalkPerformanceUser = { id: string email: string username: string avatar?: string | null role: string department?: string | null managedDepartment?: string | null isSuperAdmin?: boolean | null } const DEFAULT_TTL_SECONDS = 12 * 60 * 60 function getSecret() { const secret = process.env.NEXTAUTH_SECRET if (!secret) { throw new Error('考核调研钉钉免登未配置 NEXTAUTH_SECRET') } return secret } function base64url(input: Buffer | string) { return Buffer.from(input) .toString('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_') } function fromBase64url(input: string) { const normalized = input.replace(/-/g, '+').replace(/_/g, '/') const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)) return Buffer.from(normalized + padding, 'base64').toString('utf8') } function sign(encodedPayload: string) { return base64url(createHmac('sha256', getSecret()).update(encodedPayload).digest()) } function safeEqual(left: string, right: string) { const leftBuffer = Buffer.from(left) const rightBuffer = Buffer.from(right) if (leftBuffer.length !== rightBuffer.length) return false return timingSafeEqual(leftBuffer, rightBuffer) } function getTtlSeconds() { const configured = Number(process.env.DINGTALK_PERFORMANCE_SESSION_TTL_SECONDS || '') if (Number.isFinite(configured) && configured >= 300 && configured <= 7 * 24 * 60 * 60) { return Math.floor(configured) } return DEFAULT_TTL_SECONDS } function normalizeRole(role: string): DingtalkPerformanceRole { return role === 'ADMIN' || role === 'DEPT_ADMIN' || role === 'SYSTEM_ADMIN' ? role : 'USER' } export function resolveDingtalkPerformanceSessionScope( redirectPath: string, ): DingtalkPerformanceSessionScope | null { try { const url = new URL(redirectPath, 'https://performance-survey.local') const segments = url.pathname.split('/').filter(Boolean) const surveyId = segments[2] || '' const targetId = url.searchParams.get('targetId')?.trim() || '' if ( segments.length === 3 && segments[0] === 'performance-survey' && segments[1] === 'survey' && surveyId && targetId ) { return { kind: 'assignment', surveyId, targetId } } if ( segments.length === 3 && segments[0] === 'performance-survey' && segments[1] === 'results' && surveyId ) { return { kind: 'results', surveyId, targetId: targetId || null } } } catch { // redirectPath 已由入口层约束;异常值不能获得绩效工作区权限。 } return null } export function createDingtalkPerformanceSessionToken( user: DingtalkPerformanceUser, scope: DingtalkPerformanceSessionScope = { kind: 'workspace' }, ) { const now = Math.floor(Date.now() / 1000) const payload: DingtalkPerformanceSessionPayload = { v: 1, purpose: 'performance-survey', userId: user.id, email: user.email, name: user.username, avatar: user.avatar || null, role: normalizeRole(user.role), department: user.department || '', managedDepartment: user.managedDepartment || null, isSuperAdmin: user.isSuperAdmin ?? false, scope, iat: now, exp: now + getTtlSeconds(), nonce: randomUUID(), } const encodedPayload = base64url(JSON.stringify(payload)) return `${encodedPayload}.${sign(encodedPayload)}` } export function verifyDingtalkPerformanceSessionToken(token: string | null | undefined) { const normalized = String(token || '').trim() if (!normalized) return null const [encodedPayload, signature, extra] = normalized.split('.') if (!encodedPayload || !signature || extra) return null if (!safeEqual(sign(encodedPayload), signature)) return null try { const payload = JSON.parse(fromBase64url(encodedPayload)) as Partial const now = Math.floor(Date.now() / 1000) if ( payload.v !== 1 || payload.purpose !== 'performance-survey' || !payload.userId || !payload.email || !payload.name || !payload.role || normalizeRole(payload.role) !== payload.role || !isValidScope(payload.scope) || typeof payload.exp !== 'number' || payload.exp <= now ) { return null } return payload as DingtalkPerformanceSessionPayload } catch { return null } } function isValidScope(scope: unknown): scope is DingtalkPerformanceSessionScope { if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return false const value = scope as Record if (value.kind === 'workspace') return true if (value.kind !== 'assignment' && value.kind !== 'results') return false if (typeof value.surveyId !== 'string' || !value.surveyId.trim()) return false if (value.kind === 'assignment') { return typeof value.targetId === 'string' && Boolean(value.targetId.trim()) } return value.targetId == null || typeof value.targetId === 'string' }