Add Telegram Mini App auth flow
This commit is contained in:
97
src/messenger-connections.js
Normal file
97
src/messenger-connections.js
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,35 @@ function maskChannel(channelId) {
|
|||||||
return `${text.slice(0, 3)}***${text.slice(-3)}`;
|
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 = {}) {
|
async function sendTelegramMessage(channelId, message, options = {}) {
|
||||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -16,6 +45,7 @@ async function sendTelegramMessage(channelId, message, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const button = buildTelegramButton(options.buttonUrl, options.buttonText);
|
||||||
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -23,14 +53,11 @@ async function sendTelegramMessage(channelId, message, options = {}) {
|
|||||||
chat_id: channelId,
|
chat_id: channelId,
|
||||||
text: message,
|
text: message,
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: true,
|
||||||
...(options.buttonUrl
|
...(button
|
||||||
? {
|
? {
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [[
|
inline_keyboard: [[
|
||||||
{
|
button,
|
||||||
text: options.buttonText || 'Открыть кабинет',
|
|
||||||
url: options.buttonUrl,
|
|
||||||
},
|
|
||||||
]],
|
]],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -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({
|
const connections = await prisma.messengerConnection.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@@ -139,6 +166,8 @@ export async function dispatchToUserConnections(prisma, userId, message) {
|
|||||||
type: connection.type,
|
type: connection.type,
|
||||||
channelId: connection.channelId,
|
channelId: connection.channelId,
|
||||||
message,
|
message,
|
||||||
|
buttonUrl: options.buttonUrl,
|
||||||
|
buttonText: options.buttonText,
|
||||||
});
|
});
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
isManagerRole,
|
isManagerRole,
|
||||||
} from './access.js';
|
} from './access.js';
|
||||||
import { sendLoginCodeEmail } from './mailer.js';
|
import { sendLoginCodeEmail } from './mailer.js';
|
||||||
|
import { upsertActiveMessengerConnection } from './messenger-connections.js';
|
||||||
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
|
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
|
||||||
import { dateTimeScalar, jsonScalar } from './scalars.js';
|
import { dateTimeScalar, jsonScalar } from './scalars.js';
|
||||||
import { fetchTelegramConnectionProfile } from './telegram.js';
|
import { fetchTelegramConnectionProfile } from './telegram.js';
|
||||||
@@ -281,6 +282,33 @@ function formatOrderStatusMessage(order, status, note) {
|
|||||||
return `Заказ ${order.code} изменил статус: ${status}.${suffix}`;
|
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) {
|
async function notifyOrderStakeholders(context, order, status, note) {
|
||||||
const recipients = [order.customerId, order.managerId].filter(Boolean);
|
const recipients = [order.customerId, order.managerId].filter(Boolean);
|
||||||
if (!recipients.length) {
|
if (!recipients.length) {
|
||||||
@@ -289,8 +317,22 @@ async function notifyOrderStakeholders(context, order, status, note) {
|
|||||||
|
|
||||||
const message = formatOrderStatusMessage(order, status, note);
|
const message = formatOrderStatusMessage(order, status, note);
|
||||||
const uniqueRecipients = [...new Set(recipients)];
|
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(
|
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) {
|
if (login.messengerConnection) {
|
||||||
await context.prisma.messengerConnection.updateMany({
|
await upsertActiveMessengerConnection(context.prisma, {
|
||||||
where: {
|
userId: user.id,
|
||||||
userId: user.id,
|
type: login.messengerConnection.type,
|
||||||
type: login.messengerConnection.type,
|
channelId: login.messengerConnection.channelId,
|
||||||
isActive: true,
|
profile: login.messengerConnection,
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
src/server.js
118
src/server.js
@@ -15,15 +15,18 @@ import {
|
|||||||
consumeMessengerStartSession,
|
consumeMessengerStartSession,
|
||||||
extractAuthTokenFromRequest,
|
extractAuthTokenFromRequest,
|
||||||
hasMessengerStartSession,
|
hasMessengerStartSession,
|
||||||
|
issueAccessToken,
|
||||||
issueTemporaryLoginToken,
|
issueTemporaryLoginToken,
|
||||||
verifyAccessToken,
|
verifyAccessToken,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
import { canManagerAccessUser, isManagerRole } from './access.js';
|
import { canManagerAccessUser, isManagerRole } from './access.js';
|
||||||
import { buildContext } from './context.js';
|
import { buildContext } from './context.js';
|
||||||
|
import { profileFromTelegramMiniAppUser, normalizeTelegramProfile, upsertActiveMessengerConnection } from './messenger-connections.js';
|
||||||
import { sendMessengerMessage } from './messenger.js';
|
import { sendMessengerMessage } from './messenger.js';
|
||||||
import { prisma } from './prisma-client.js';
|
import { prisma } from './prisma-client.js';
|
||||||
import { resolvers } from './resolvers.js';
|
import { resolvers } from './resolvers.js';
|
||||||
import { telegramApi, telegramFileUrl } from './telegram.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');
|
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
||||||
|
|
||||||
@@ -92,16 +95,28 @@ function normalizeRedirectPath(value) {
|
|||||||
return redirectPath;
|
return redirectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTelegramProfile(profile) {
|
function presentTelegramMiniAppUser(user) {
|
||||||
if (!profile || typeof profile !== 'object') {
|
const firstName = String(user?.first_name || '').trim();
|
||||||
return null;
|
const lastName = String(user?.last_name || '').trim();
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayName: String(profile.displayName || '').trim() || null,
|
id: String(user?.id || '').trim(),
|
||||||
username: String(profile.username || '').trim().replace(/^@+/, '') || null,
|
firstName,
|
||||||
avatarFileId: String(profile.avatarFileId || '').trim() || null,
|
lastName: lastName || null,
|
||||||
avatarFileUniqueId: String(profile.avatarFileUniqueId || '').trim() || 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) => {
|
app.post('/bot/messenger-login', async (req, res) => {
|
||||||
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
|
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
|
||||||
const providedToken = req.body?.token;
|
const providedToken = req.body?.token;
|
||||||
|
|||||||
90
src/telegram-mini-app.js
Normal file
90
src/telegram-mini-app.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user