Add Telegram Mini App auth flow

This commit is contained in:
Ruslan Bakiev
2026-04-04 14:21:18 +07:00
parent a0cbae390c
commit 2499aa1a6a
5 changed files with 380 additions and 50 deletions

View File

@@ -0,0 +1,97 @@
function normalizeOptionalText(value) {
const normalized = String(value || '').trim();
return normalized || null;
}
export function normalizeTelegramProfile(profile) {
if (!profile || typeof profile !== 'object') {
return {
displayName: null,
username: null,
avatarFileId: null,
avatarFileUniqueId: null,
};
}
return {
displayName: normalizeOptionalText(profile.displayName),
username: normalizeOptionalText(profile.username)?.replace(/^@+/, '') || null,
avatarFileId: normalizeOptionalText(profile.avatarFileId),
avatarFileUniqueId: normalizeOptionalText(profile.avatarFileUniqueId),
};
}
export function profileFromTelegramMiniAppUser(user) {
if (!user || typeof user !== 'object') {
return normalizeTelegramProfile(null);
}
const firstName = String(user.first_name || '').trim();
const lastName = String(user.last_name || '').trim();
const displayName = `${firstName} ${lastName}`.trim();
return normalizeTelegramProfile({
displayName,
username: user.username,
});
}
export async function upsertActiveMessengerConnection(prisma, { userId, type, channelId, profile = null }) {
const normalizedChannelId = String(channelId || '').trim();
if (!userId || !type || !normalizedChannelId) {
throw new Error('userId, type and channelId are required to connect messenger.');
}
const normalizedProfile = type === 'TELEGRAM'
? normalizeTelegramProfile(profile)
: normalizeTelegramProfile(null);
return prisma.$transaction(async (tx) => {
await tx.messengerConnection.updateMany({
where: {
userId,
type,
isActive: true,
NOT: { channelId: normalizedChannelId },
},
data: { isActive: false },
});
await tx.messengerConnection.updateMany({
where: {
type,
channelId: normalizedChannelId,
isActive: true,
NOT: { userId },
},
data: { isActive: false },
});
return tx.messengerConnection.upsert({
where: {
userId_type_channelId: {
userId,
type,
channelId: normalizedChannelId,
},
},
update: {
isActive: true,
displayName: normalizedProfile.displayName,
username: normalizedProfile.username,
avatarFileId: normalizedProfile.avatarFileId,
avatarFileUniqueId: normalizedProfile.avatarFileUniqueId,
},
create: {
userId,
type,
channelId: normalizedChannelId,
isActive: true,
displayName: normalizedProfile.displayName,
username: normalizedProfile.username,
avatarFileId: normalizedProfile.avatarFileId,
avatarFileUniqueId: normalizedProfile.avatarFileUniqueId,
},
});
});
}

View File

@@ -6,6 +6,35 @@ function maskChannel(channelId) {
return `${text.slice(0, 3)}***${text.slice(-3)}`;
}
function buildTelegramButton(buttonUrl, buttonText) {
const url = String(buttonUrl || '').trim();
if (!url) {
return null;
}
const text = String(buttonText || '').trim() || 'Открыть кабинет';
const miniAppBaseUrl = String(
process.env.TELEGRAM_MINI_APP_URL ||
process.env.WEB_FRONTEND_URL ||
process.env.NUXT_PUBLIC_SITE_URL ||
'',
).trim().replace(/\/$/, '');
if (miniAppBaseUrl && url.startsWith(miniAppBaseUrl)) {
return {
text,
web_app: {
url,
},
};
}
return {
text,
url,
};
}
async function sendTelegramMessage(channelId, message, options = {}) {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) {
@@ -16,6 +45,7 @@ async function sendTelegramMessage(channelId, message, options = {}) {
}
try {
const button = buildTelegramButton(options.buttonUrl, options.buttonText);
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -23,14 +53,11 @@ async function sendTelegramMessage(channelId, message, options = {}) {
chat_id: channelId,
text: message,
disable_web_page_preview: true,
...(options.buttonUrl
...(button
? {
reply_markup: {
inline_keyboard: [[
{
text: options.buttonText || 'Открыть кабинет',
url: options.buttonUrl,
},
button,
]],
},
}
@@ -121,7 +148,7 @@ export async function sendMessengerMessage({ type, channelId, message, buttonUrl
};
}
export async function dispatchToUserConnections(prisma, userId, message) {
export async function dispatchToUserConnections(prisma, userId, message, options = {}) {
const connections = await prisma.messengerConnection.findMany({
where: {
userId,
@@ -139,6 +166,8 @@ export async function dispatchToUserConnections(prisma, userId, message) {
type: connection.type,
channelId: connection.channelId,
message,
buttonUrl: options.buttonUrl,
buttonText: options.buttonText,
});
results.push({

View File

@@ -15,6 +15,7 @@ import {
isManagerRole,
} from './access.js';
import { sendLoginCodeEmail } from './mailer.js';
import { upsertActiveMessengerConnection } from './messenger-connections.js';
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
import { dateTimeScalar, jsonScalar } from './scalars.js';
import { fetchTelegramConnectionProfile } from './telegram.js';
@@ -281,6 +282,33 @@ function formatOrderStatusMessage(order, status, note) {
return `Заказ ${order.code} изменил статус: ${status}.${suffix}`;
}
function buildFrontendAppUrl(path) {
const baseUrl = String(
process.env.TELEGRAM_MINI_APP_URL ||
process.env.WEB_FRONTEND_URL ||
process.env.NUXT_PUBLIC_SITE_URL ||
'',
).trim().replace(/\/$/, '');
const normalizedPath = String(path || '').trim();
if (!baseUrl || !normalizedPath.startsWith('/')) {
return null;
}
return `${baseUrl}${normalizedPath}`;
}
function buildUserOrderPath(orderId, role) {
const normalizedOrderId = String(orderId || '').trim();
if (!normalizedOrderId) {
return '';
}
return isManagerRole(role)
? `/client-orders/${normalizedOrderId}`
: `/orders/${normalizedOrderId}`;
}
async function notifyOrderStakeholders(context, order, status, note) {
const recipients = [order.customerId, order.managerId].filter(Boolean);
if (!recipients.length) {
@@ -289,8 +317,22 @@ async function notifyOrderStakeholders(context, order, status, note) {
const message = formatOrderStatusMessage(order, status, note);
const uniqueRecipients = [...new Set(recipients)];
const users = await context.prisma.user.findMany({
where: {
id: { in: uniqueRecipients },
},
select: {
id: true,
role: true,
},
});
const userRoleMap = new Map(users.map((user) => [user.id, user.role]));
await Promise.allSettled(
uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message)),
uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message, {
buttonUrl: buildFrontendAppUrl(buildUserOrderPath(order.id, userRoleMap.get(userId))),
buttonText: 'Открыть заказ',
})),
);
}
@@ -820,41 +862,11 @@ export const resolvers = {
}
if (login.messengerConnection) {
await context.prisma.messengerConnection.updateMany({
where: {
userId: user.id,
type: login.messengerConnection.type,
isActive: true,
NOT: { channelId: login.messengerConnection.channelId },
},
data: { isActive: false },
});
await context.prisma.messengerConnection.upsert({
where: {
userId_type_channelId: {
userId: user.id,
type: login.messengerConnection.type,
channelId: login.messengerConnection.channelId,
},
},
update: {
isActive: true,
displayName: login.messengerConnection.displayName ?? null,
username: login.messengerConnection.username ?? null,
avatarFileId: login.messengerConnection.avatarFileId ?? null,
avatarFileUniqueId: login.messengerConnection.avatarFileUniqueId ?? null,
},
create: {
userId: user.id,
type: login.messengerConnection.type,
channelId: login.messengerConnection.channelId,
isActive: true,
displayName: login.messengerConnection.displayName ?? null,
username: login.messengerConnection.username ?? null,
avatarFileId: login.messengerConnection.avatarFileId ?? null,
avatarFileUniqueId: login.messengerConnection.avatarFileUniqueId ?? null,
},
await upsertActiveMessengerConnection(context.prisma, {
userId: user.id,
type: login.messengerConnection.type,
channelId: login.messengerConnection.channelId,
profile: login.messengerConnection,
});
}

View File

@@ -15,15 +15,18 @@ import {
consumeMessengerStartSession,
extractAuthTokenFromRequest,
hasMessengerStartSession,
issueAccessToken,
issueTemporaryLoginToken,
verifyAccessToken,
} from './auth.js';
import { canManagerAccessUser, isManagerRole } from './access.js';
import { buildContext } from './context.js';
import { profileFromTelegramMiniAppUser, normalizeTelegramProfile, upsertActiveMessengerConnection } from './messenger-connections.js';
import { sendMessengerMessage } from './messenger.js';
import { prisma } from './prisma-client.js';
import { resolvers } from './resolvers.js';
import { telegramApi, telegramFileUrl } from './telegram.js';
import { validateTelegramMiniAppInitData } from './telegram-mini-app.js';
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
@@ -92,16 +95,28 @@ function normalizeRedirectPath(value) {
return redirectPath;
}
function normalizeTelegramProfile(profile) {
if (!profile || typeof profile !== 'object') {
return null;
}
function presentTelegramMiniAppUser(user) {
const firstName = String(user?.first_name || '').trim();
const lastName = String(user?.last_name || '').trim();
return {
displayName: String(profile.displayName || '').trim() || null,
username: String(profile.username || '').trim().replace(/^@+/, '') || null,
avatarFileId: String(profile.avatarFileId || '').trim() || null,
avatarFileUniqueId: String(profile.avatarFileUniqueId || '').trim() || null,
id: String(user?.id || '').trim(),
firstName,
lastName: lastName || null,
username: String(user?.username || '').trim() || null,
languageCode: String(user?.language_code || '').trim() || null,
photoUrl: String(user?.photo_url || '').trim() || null,
displayName: `${firstName} ${lastName}`.trim() || firstName || 'Пользователь Telegram',
};
}
function presentAuthUser(user) {
return {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role,
companyId: user.companyId ?? null,
};
}
@@ -150,6 +165,93 @@ app.post('/auth/messenger-start', async (req, res) => {
});
});
app.post('/auth/telegram-mini-app/session', async (req, res) => {
let telegram;
try {
telegram = validateTelegramMiniAppInitData(req.body?.initData);
} catch (error) {
res.status(401).json({ error: error.message });
return;
}
const channelId = String(telegram.user.id);
const connection = await prisma.messengerConnection.findFirst({
where: {
type: 'TELEGRAM',
channelId,
isActive: true,
},
include: {
user: true,
},
orderBy: { createdAt: 'desc' },
});
if (!connection?.user) {
res.json({
ok: true,
authenticated: false,
telegramUser: presentTelegramMiniAppUser(telegram.user),
});
return;
}
await upsertActiveMessengerConnection(prisma, {
userId: connection.user.id,
type: 'TELEGRAM',
channelId,
profile: profileFromTelegramMiniAppUser(telegram.user),
});
const session = issueAccessToken(connection.user.id);
res.json({
ok: true,
authenticated: true,
accessToken: session.accessToken,
expiresAt: session.expiresAt.toISOString(),
user: presentAuthUser(connection.user),
telegramUser: presentTelegramMiniAppUser(telegram.user),
});
});
app.post('/auth/telegram-mini-app/connect', async (req, res) => {
const user = await resolveAuthenticatedUserFromRequest(req);
if (!user) {
res.status(401).json({ error: 'Authentication required.' });
return;
}
let telegram;
try {
telegram = validateTelegramMiniAppInitData(req.body?.initData);
} catch (error) {
res.status(401).json({ error: error.message });
return;
}
const connection = await upsertActiveMessengerConnection(prisma, {
userId: user.id,
type: 'TELEGRAM',
channelId: telegram.user.id,
profile: profileFromTelegramMiniAppUser(telegram.user),
});
res.json({
ok: true,
connection: {
id: connection.id,
userId: connection.userId,
type: connection.type,
channelId: connection.channelId,
displayName: connection.displayName,
username: connection.username,
avatarAvailable: Boolean(connection.avatarFileId),
isActive: connection.isActive,
},
telegramUser: presentTelegramMiniAppUser(telegram.user),
});
});
app.post('/bot/messenger-login', async (req, res) => {
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
const providedToken = req.body?.token;

90
src/telegram-mini-app.js Normal file
View File

@@ -0,0 +1,90 @@
import crypto from 'node:crypto';
const TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS = Number(process.env.TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS ?? 60 * 60);
function requireTelegramBotToken() {
const token = String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
if (!token) {
throw new Error('TELEGRAM_BOT_TOKEN is not configured.');
}
return token;
}
function timingSafeEqualHex(left, right) {
const leftBuffer = Buffer.from(String(left || ''), 'hex');
const rightBuffer = Buffer.from(String(right || ''), 'hex');
if (!leftBuffer.length || leftBuffer.length !== rightBuffer.length) {
return false;
}
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function parseTelegramUser(rawUser) {
if (!rawUser) {
return null;
}
const parsed = JSON.parse(rawUser);
if (!parsed || typeof parsed !== 'object' || !parsed.id) {
return null;
}
return {
id: String(parsed.id),
first_name: String(parsed.first_name || '').trim(),
last_name: String(parsed.last_name || '').trim() || null,
username: String(parsed.username || '').trim() || null,
language_code: String(parsed.language_code || '').trim() || null,
photo_url: String(parsed.photo_url || '').trim() || null,
};
}
export function validateTelegramMiniAppInitData(initDataRaw) {
const initData = String(initDataRaw || '').trim();
if (!initData) {
throw new Error('Telegram initData is required.');
}
const params = new URLSearchParams(initData);
const receivedHash = String(params.get('hash') || '').trim().toLowerCase();
if (!receivedHash) {
throw new Error('Telegram initData hash is missing.');
}
const authDate = Number(params.get('auth_date'));
if (!Number.isFinite(authDate) || authDate <= 0) {
throw new Error('Telegram initData auth_date is invalid.');
}
const now = Math.floor(Date.now() / 1000);
if (now - authDate > TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS) {
throw new Error('Telegram initData is expired.');
}
const checkEntries = [...params.entries()]
.filter(([key]) => key !== 'hash' && key !== 'signature')
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, value]) => `${key}=${value}`);
const dataCheckString = checkEntries.join('\n');
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(requireTelegramBotToken()).digest();
const expectedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
if (!timingSafeEqualHex(expectedHash, receivedHash)) {
throw new Error('Telegram initData signature is invalid.');
}
const user = parseTelegramUser(params.get('user'));
if (!user?.id) {
throw new Error('Telegram user is missing in initData.');
}
return {
authDate: new Date(authDate * 1000),
queryId: String(params.get('query_id') || '').trim() || null,
startParam: String(params.get('start_param') || '').trim() || null,
user,
};
}