gongxiangp2/lib/dingtalk-performance-session.ts

135 lines
3.8 KiB
TypeScript

import { createHmac, randomUUID, timingSafeEqual } from 'crypto'
export const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session'
export type DingtalkPerformanceRole = 'USER' | 'ADMIN' | 'DEPT_ADMIN' | 'SYSTEM_ADMIN'
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
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 createDingtalkPerformanceSessionToken(user: DingtalkPerformanceUser) {
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,
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 ||
typeof payload.exp !== 'number' ||
payload.exp <= now
) {
return null
}
return payload as DingtalkPerformanceSessionPayload
} catch {
return null
}
}