Add MAX Mini App auth flow

This commit is contained in:
Ruslan Bakiev
2026-04-04 21:51:40 +07:00
parent 6c5b9ef98e
commit 5acafba77c
3 changed files with 253 additions and 6 deletions

115
src/max-mini-app.js Normal file
View File

@@ -0,0 +1,115 @@
import crypto from 'node:crypto';
const MAX_MINI_APP_AUTH_MAX_AGE_SECONDS = Number(process.env.MAX_MINI_APP_AUTH_MAX_AGE_SECONDS ?? 60 * 60);
function requireMaxBotToken() {
const token = String(process.env.MAX_BOT_TOKEN || '').trim();
if (!token) {
throw new Error('MAX_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 parseJsonParam(rawValue, fieldName) {
if (!rawValue) {
return null;
}
let parsed;
try {
parsed = JSON.parse(rawValue);
} catch {
throw new Error(`MAX ${fieldName} is invalid.`);
}
if (!parsed || typeof parsed !== 'object') {
throw new Error(`MAX ${fieldName} is invalid.`);
}
return parsed;
}
function parseMaxUser(rawUser) {
const parsed = parseJsonParam(rawUser, 'user');
if (!parsed?.id) {
throw new Error('MAX user is missing in initData.');
}
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,
};
}
function parseMaxChat(rawChat) {
const parsed = parseJsonParam(rawChat, 'chat');
if (!parsed?.id) {
return null;
}
return {
id: String(parsed.id),
type: String(parsed.type || '').trim() || null,
};
}
export function validateMaxMiniAppInitData(initDataRaw) {
const initData = String(initDataRaw || '').trim();
if (!initData) {
throw new Error('MAX initData is required.');
}
const params = new URLSearchParams(initData);
const receivedHashes = params.getAll('hash').map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
if (receivedHashes.length !== 1) {
throw new Error('MAX initData hash is missing.');
}
const authDate = Number(params.get('auth_date'));
if (!Number.isFinite(authDate) || authDate <= 0) {
throw new Error('MAX initData auth_date is invalid.');
}
const authDateSeconds = authDate > 1e12 ? Math.floor(authDate / 1000) : Math.floor(authDate);
const now = Math.floor(Date.now() / 1000);
if (now - authDateSeconds > MAX_MINI_APP_AUTH_MAX_AGE_SECONDS) {
throw new Error('MAX initData is expired.');
}
const dataCheckString = [...params.entries()]
.filter(([key]) => key !== 'hash')
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(requireMaxBotToken()).digest();
const expectedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
if (!timingSafeEqualHex(expectedHash, receivedHashes[0])) {
throw new Error('MAX initData signature is invalid.');
}
return {
authDate: new Date(authDateSeconds * 1000),
queryId: String(params.get('query_id') || '').trim() || null,
startParam: String(params.get('start_param') || '').trim() || null,
user: parseMaxUser(params.get('user')),
chat: parseMaxChat(params.get('chat')),
};
}

View File

@@ -3,7 +3,7 @@ function normalizeOptionalText(value) {
return normalized || null;
}
export function normalizeTelegramProfile(profile) {
function normalizeMessengerProfile(profile) {
if (!profile || typeof profile !== 'object') {
return {
displayName: null,
@@ -21,6 +21,17 @@ export function normalizeTelegramProfile(profile) {
};
}
export function normalizeTelegramProfile(profile) {
return normalizeMessengerProfile(profile);
}
export function normalizeMaxProfile(profile) {
return normalizeMessengerProfile({
displayName: profile?.displayName,
username: profile?.username,
});
}
export function profileFromTelegramMiniAppUser(user) {
if (!user || typeof user !== 'object') {
return normalizeTelegramProfile(null);
@@ -36,6 +47,21 @@ export function profileFromTelegramMiniAppUser(user) {
});
}
export function profileFromMaxMiniAppUser(user) {
if (!user || typeof user !== 'object') {
return normalizeMaxProfile(null);
}
const firstName = String(user.first_name || '').trim();
const lastName = String(user.last_name || '').trim();
const displayName = `${firstName} ${lastName}`.trim();
return normalizeMaxProfile({
displayName,
username: user.username,
});
}
export async function upsertActiveMessengerConnection(prisma, { userId, type, channelId, profile = null }) {
const normalizedChannelId = String(channelId || '').trim();
if (!userId || !type || !normalizedChannelId) {
@@ -44,7 +70,9 @@ export async function upsertActiveMessengerConnection(prisma, { userId, type, ch
const normalizedProfile = type === 'TELEGRAM'
? normalizeTelegramProfile(profile)
: normalizeTelegramProfile(null);
: type === 'MAX'
? normalizeMaxProfile(profile)
: normalizeMessengerProfile(null);
return prisma.$transaction(async (tx) => {
await tx.messengerConnection.updateMany({

View File

@@ -21,7 +21,14 @@ import {
} from './auth.js';
import { canManagerAccessUser, isManagerRole } from './access.js';
import { buildContext } from './context.js';
import { profileFromTelegramMiniAppUser, normalizeTelegramProfile, upsertActiveMessengerConnection } from './messenger-connections.js';
import {
normalizeMaxProfile,
normalizeTelegramProfile,
profileFromMaxMiniAppUser,
profileFromTelegramMiniAppUser,
upsertActiveMessengerConnection,
} from './messenger-connections.js';
import { validateMaxMiniAppInitData } from './max-mini-app.js';
import { sendMessengerMessage } from './messenger.js';
import { prisma } from './prisma-client.js';
import { resolvers } from './resolvers.js';
@@ -96,6 +103,14 @@ function normalizeRedirectPath(value) {
}
function presentTelegramMiniAppUser(user) {
return presentMiniAppUser(user, 'Пользователь Telegram');
}
function presentMaxMiniAppUser(user) {
return presentMiniAppUser(user, 'Пользователь MAX');
}
function presentMiniAppUser(user, fallbackDisplayName) {
const firstName = String(user?.first_name || '').trim();
const lastName = String(user?.last_name || '').trim();
@@ -106,7 +121,7 @@ function presentTelegramMiniAppUser(user) {
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',
displayName: `${firstName} ${lastName}`.trim() || firstName || fallbackDisplayName,
};
}
@@ -252,6 +267,93 @@ app.post('/auth/telegram-mini-app/connect', async (req, res) => {
});
});
app.post('/auth/max-mini-app/session', async (req, res) => {
let max;
try {
max = validateMaxMiniAppInitData(req.body?.initData);
} catch (error) {
res.status(401).json({ error: error.message });
return;
}
const channelId = String(max.user.id);
const connection = await prisma.messengerConnection.findFirst({
where: {
type: 'MAX',
channelId,
isActive: true,
},
include: {
user: true,
},
orderBy: { createdAt: 'desc' },
});
if (!connection?.user) {
res.json({
ok: true,
authenticated: false,
maxUser: presentMaxMiniAppUser(max.user),
});
return;
}
await upsertActiveMessengerConnection(prisma, {
userId: connection.user.id,
type: 'MAX',
channelId,
profile: profileFromMaxMiniAppUser(max.user),
});
const session = issueAccessToken(connection.user.id);
res.json({
ok: true,
authenticated: true,
accessToken: session.accessToken,
expiresAt: session.expiresAt.toISOString(),
user: presentAuthUser(connection.user),
maxUser: presentMaxMiniAppUser(max.user),
});
});
app.post('/auth/max-mini-app/connect', async (req, res) => {
const user = await resolveAuthenticatedUserFromRequest(req);
if (!user) {
res.status(401).json({ error: 'Authentication required.' });
return;
}
let max;
try {
max = validateMaxMiniAppInitData(req.body?.initData);
} catch (error) {
res.status(401).json({ error: error.message });
return;
}
const connection = await upsertActiveMessengerConnection(prisma, {
userId: user.id,
type: 'MAX',
channelId: max.user.id,
profile: profileFromMaxMiniAppUser(max.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,
},
maxUser: presentMaxMiniAppUser(max.user),
});
});
app.post('/bot/messenger-login', async (req, res) => {
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
const providedToken = req.body?.token;
@@ -301,7 +403,9 @@ app.post('/bot/messenger-login', async (req, res) => {
messengerConnection: {
type: channel,
channelId,
...normalizeTelegramProfile(req.body?.profile),
...(channel === 'TELEGRAM'
? normalizeTelegramProfile(req.body?.profile)
: normalizeMaxProfile(req.body?.profile)),
},
});
const frontendUrl = (
@@ -310,7 +414,7 @@ app.post('/bot/messenger-login', async (req, res) => {
'http://localhost:3000'
).replace(/\/$/, '');
const nextPath = startSession.redirectPath || (
channel === 'TELEGRAM'
channel === 'TELEGRAM' || channel === 'MAX'
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
: ''
);