- queryClient: refreshSession возвращает reason (network/unauthorized); при network-ошибке не делаем logout, а бросаем network_error_during_refresh. - useAuth: checkAuth и refreshUser не сбрасывают сессию при network-ошибке во время refresh, переводят в офлайн-режим. - auth.service: remember берётся из сессии, race tolerance 5 минут. - access token lifetime унифицирован до 30 дней по умолчанию, cookie maxAge теперь совпадает с JWT expiry (было 15 минут fallback).
170 lines
5.0 KiB
TypeScript
170 lines
5.0 KiB
TypeScript
import jwt from 'jsonwebtoken';
|
|
import crypto from 'crypto';
|
|
|
|
export function parseExpiryToSeconds(expiry: string): number {
|
|
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
if (!match) return 15 * 60;
|
|
const [, num, unit] = match;
|
|
const n = parseInt(num, 10);
|
|
switch (unit) {
|
|
case 's': return n;
|
|
case 'm': return n * 60;
|
|
case 'h': return n * 3600;
|
|
case 'd': return n * 86400;
|
|
default: return 15 * 60;
|
|
}
|
|
}
|
|
|
|
function requireSecret(name: string): string {
|
|
const value = process.env[name];
|
|
if (!value) {
|
|
throw new Error(
|
|
`FATAL: JWT secrets not configured — missing ${name}.\n` +
|
|
'Set JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, JWT_SUPERADMIN_SECRET in environment variables.'
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const ACCESS_TOKEN_SECRET = requireSecret('JWT_ACCESS_SECRET');
|
|
const REFRESH_TOKEN_SECRET = requireSecret('JWT_REFRESH_SECRET');
|
|
const SUPERADMIN_TOKEN_SECRET = requireSecret('JWT_SUPERADMIN_SECRET');
|
|
const BOT_TOKEN_SECRET = process.env.JWT_BOT_SECRET || ACCESS_TOKEN_SECRET;
|
|
|
|
const ACCESS_TOKEN_EXPIRY = process.env.JWT_ACCESS_EXPIRES || '30d';
|
|
const REFRESH_TOKEN_EXPIRY_REMEMBER = process.env.JWT_REFRESH_EXPIRES_REMEMBER || '90d';
|
|
const REFRESH_TOKEN_EXPIRY_SESSION = process.env.JWT_REFRESH_EXPIRES_SESSION || '30d';
|
|
const SUPERADMIN_TOKEN_EXPIRY = process.env.JWT_SUPERADMIN_EXPIRES || '1h';
|
|
const BOT_TOKEN_EXPIRY = process.env.JWT_BOT_EXPIRES || '10m';
|
|
|
|
export interface TokenPayload {
|
|
userId: number;
|
|
organizationId: number;
|
|
appRole: string;
|
|
role?: string; // legacy fallback for old tokens
|
|
}
|
|
|
|
export interface BotServiceTokenPayload {
|
|
botId: number;
|
|
organizationId: number;
|
|
type: 'bot_service';
|
|
}
|
|
|
|
export interface SuperAdminTokenPayload {
|
|
superAdminId: number;
|
|
email: string;
|
|
isSuperAdmin: true;
|
|
}
|
|
|
|
export interface TokenPair {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresIn: number;
|
|
familyId?: string;
|
|
}
|
|
|
|
export function generateTokens(payload: TokenPayload, remember: boolean = false, familyId?: string): TokenPair {
|
|
// Ensure we don't include legacy role in new tokens
|
|
const { role, ...cleanPayload } = payload as any;
|
|
const accessToken = jwt.sign(cleanPayload, ACCESS_TOKEN_SECRET, {
|
|
expiresIn: ACCESS_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],
|
|
issuer: 'workflow-system',
|
|
audience: 'workflow-users'
|
|
});
|
|
|
|
const refreshToken = jwt.sign(cleanPayload, REFRESH_TOKEN_SECRET, {
|
|
expiresIn: (remember ? REFRESH_TOKEN_EXPIRY_REMEMBER : REFRESH_TOKEN_EXPIRY_SESSION) as jwt.SignOptions['expiresIn'],
|
|
issuer: 'workflow-system',
|
|
audience: 'workflow-users'
|
|
});
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
expiresIn: parseExpiryToSeconds(ACCESS_TOKEN_EXPIRY),
|
|
familyId
|
|
};
|
|
}
|
|
|
|
export function verifyAccessToken(token: string): TokenPayload {
|
|
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET, {
|
|
algorithms: ['HS256'],
|
|
issuer: 'workflow-system',
|
|
audience: 'workflow-users',
|
|
}) as TokenPayload;
|
|
// Fallback for old tokens that used 'role' instead of 'appRole'
|
|
if (!decoded.appRole && decoded.role) {
|
|
decoded.appRole = decoded.role;
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
export function verifyRefreshToken(token: string): TokenPayload {
|
|
const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET, {
|
|
algorithms: ['HS256'],
|
|
issuer: 'workflow-system',
|
|
audience: 'workflow-users',
|
|
}) as TokenPayload;
|
|
// Fallback for old tokens that used 'role' instead of 'appRole'
|
|
if (!decoded.appRole && decoded.role) {
|
|
decoded.appRole = decoded.role;
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
export function generateVerificationToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
export function generateResetToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
export function generateBotServiceToken(botId: number, organizationId: number): string {
|
|
const payload: BotServiceTokenPayload = {
|
|
botId,
|
|
organizationId,
|
|
type: 'bot_service'
|
|
};
|
|
|
|
return jwt.sign(payload, BOT_TOKEN_SECRET, {
|
|
expiresIn: BOT_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],
|
|
issuer: 'workflow-system',
|
|
audience: 'workflow-bots'
|
|
});
|
|
}
|
|
|
|
export function verifyBotServiceToken(token: string): BotServiceTokenPayload {
|
|
const payload = jwt.verify(token, BOT_TOKEN_SECRET, {
|
|
issuer: 'workflow-system',
|
|
audience: 'workflow-bots'
|
|
}) as BotServiceTokenPayload;
|
|
|
|
if (payload.type !== 'bot_service') {
|
|
throw new Error('Invalid token type');
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
export function generateSuperAdminToken(payload: SuperAdminTokenPayload): string {
|
|
return jwt.sign(payload, SUPERADMIN_TOKEN_SECRET, {
|
|
expiresIn: SUPERADMIN_TOKEN_EXPIRY as jwt.SignOptions['expiresIn'],
|
|
issuer: 'workflow-system',
|
|
audience: 'workflow-superadmin'
|
|
});
|
|
}
|
|
|
|
export function verifySuperAdminToken(token: string): SuperAdminTokenPayload {
|
|
const payload = jwt.verify(token, SUPERADMIN_TOKEN_SECRET, {
|
|
issuer: 'workflow-system',
|
|
audience: 'workflow-superadmin'
|
|
}) as SuperAdminTokenPayload;
|
|
|
|
if (!payload.isSuperAdmin) {
|
|
throw new Error('Invalid super admin token');
|
|
}
|
|
|
|
return payload;
|
|
}
|