gongxiangp2/lib/dingtalk-performance-session.ts
2026-09-04 11:50:50 +08:00

203 lines
5.9 KiB
TypeScript

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<DingtalkPerformanceSessionPayload>
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<string, unknown>
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'
}