File size: 5,622 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | import { createHmac, timingSafeEqual } from 'node:crypto'
import type { Request, Response } from 'express'
import { nanoid } from 'nanoid'
import { config } from '../config.js'
import { logger } from '../utils/logger.js'
export const IDENTITY_COOKIE_NAME = 'mt_identity'
const IDENTITY_TOKEN_VERSION = 1
const COOKIE_SAME_SITE_LABEL = {
lax: 'Lax',
strict: 'Strict',
none: 'None',
} as const
interface IdentityPayload {
uid: string
iat: number
exp: number
ver: number
}
type IdentitySource = 'http' | 'socket'
function base64urlEncode(input: string): string {
return Buffer.from(input, 'utf8').toString('base64url')
}
function base64urlDecode(input: string): string {
return Buffer.from(input, 'base64url').toString('utf8')
}
function sign(unsignedToken: string): string {
return createHmac('sha256', config.identity.secret).update(unsignedToken).digest('base64url')
}
function parseCookies(cookieHeader?: string): Record<string, string> {
if (!cookieHeader) return {}
const result: Record<string, string> = {}
for (const chunk of cookieHeader.split(';')) {
const [k, ...rest] = chunk.trim().split('=')
if (!k) continue
const rawValue = rest.join('=') || ''
try {
result[k] = decodeURIComponent(rawValue)
} catch {
result[k] = rawValue
}
}
return result
}
function isSecureRequest(req: Request): boolean {
if (req.secure) return true
const forwardedProto = req.headers['x-forwarded-proto']
const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto
return typeof proto === 'string' && proto.split(',')[0].trim().toLowerCase() === 'https'
}
function serializeIdentityCookie(token: string, secure: boolean): string {
const maxAgeSec = config.identity.ttlDays * 24 * 60 * 60
const attrs = [`${IDENTITY_COOKIE_NAME}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', `Max-Age=${maxAgeSec}`]
attrs.push(`SameSite=${COOKIE_SAME_SITE_LABEL[config.identity.cookieSameSite]}`)
if (secure) attrs.push('Secure')
if (config.identity.cookiePartitioned) attrs.push('Partitioned')
return attrs.join('; ')
}
function issueToken(userId: string): { token: string; payload: IdentityPayload } {
const now = Date.now()
const payload: IdentityPayload = {
uid: userId,
iat: now,
exp: now + config.identity.ttlDays * 24 * 60 * 60 * 1000,
ver: IDENTITY_TOKEN_VERSION,
}
const encodedPayload = base64urlEncode(JSON.stringify(payload))
const unsigned = encodedPayload
const signature = sign(unsigned)
return { token: `${unsigned}.${signature}`, payload }
}
export function generateIdentityUserId(): string {
return nanoid(20)
}
export function issueIdentityCookie(req: Request, res: Response, userId?: string): { userId: string; expiresAt: number } {
const uid = userId ?? generateIdentityUserId()
const { token, payload } = issueToken(uid)
const secureRequired = config.identity.cookieSameSite === 'none' || config.identity.cookiePartitioned
const secure = secureRequired || (config.identity.cookieSecure ?? (config.isProd && isSecureRequest(req)))
res.setHeader('Set-Cookie', serializeIdentityCookie(token, secure))
return { userId: uid, expiresAt: payload.exp }
}
function logTokenInvalid(source: IdentitySource, reason: string, extra?: Record<string, unknown>): void {
logger.warn('Identity token verification failed', { source, reason, ...extra })
}
export function verifyIdentityToken(token: string, source: IdentitySource): IdentityPayload | null {
const parts = token.split('.')
if (parts.length !== 2) {
logTokenInvalid(source, 'invalid_parts', { parts: parts.length })
return null
}
const [encodedPayload, encodedSignature] = parts
if (!encodedPayload || !encodedSignature) {
logTokenInvalid(source, 'missing_payload_or_signature')
return null
}
const expectedSignature = sign(encodedPayload)
const expectedBuffer = Buffer.from(expectedSignature)
const actualBuffer = Buffer.from(encodedSignature)
if (expectedBuffer.length !== actualBuffer.length) {
logTokenInvalid(source, 'signature_length_mismatch')
return null
}
if (!timingSafeEqual(expectedBuffer, actualBuffer)) {
logTokenInvalid(source, 'signature_mismatch')
return null
}
try {
const payload = JSON.parse(base64urlDecode(encodedPayload)) as IdentityPayload
if (payload.ver !== IDENTITY_TOKEN_VERSION) {
logTokenInvalid(source, 'version_mismatch', { version: payload.ver })
return null
}
if (typeof payload.uid !== 'string' || payload.uid.length === 0) {
logTokenInvalid(source, 'invalid_uid')
return null
}
if (!Number.isFinite(payload.exp)) {
logTokenInvalid(source, 'invalid_exp')
return null
}
if (payload.exp <= Date.now()) {
logTokenInvalid(source, 'expired_token', { userId: payload.uid, exp: payload.exp })
return null
}
return payload
} catch {
logTokenInvalid(source, 'invalid_payload_json')
return null
}
}
export function getIdentityFromRequest(req: Request): { userId: string } | null {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[IDENTITY_COOKIE_NAME]
if (!token) return null
const payload = verifyIdentityToken(token, 'http')
if (!payload) return null
return { userId: payload.uid }
}
export function getIdentityFromCookieHeader(cookieHeader?: string): { userId: string } | null {
const token = parseCookies(cookieHeader)[IDENTITY_COOKIE_NAME]
if (!token) return null
const payload = verifyIdentityToken(token, 'socket')
if (!payload) return null
return { userId: payload.uid }
}
|