Add MAX Mini App auth flow
This commit is contained in:
115
src/max-mini-app.js
Normal file
115
src/max-mini-app.js
Normal 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')),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ function normalizeOptionalText(value) {
|
|||||||
return normalized || null;
|
return normalized || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTelegramProfile(profile) {
|
function normalizeMessengerProfile(profile) {
|
||||||
if (!profile || typeof profile !== 'object') {
|
if (!profile || typeof profile !== 'object') {
|
||||||
return {
|
return {
|
||||||
displayName: null,
|
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) {
|
export function profileFromTelegramMiniAppUser(user) {
|
||||||
if (!user || typeof user !== 'object') {
|
if (!user || typeof user !== 'object') {
|
||||||
return normalizeTelegramProfile(null);
|
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 }) {
|
export async function upsertActiveMessengerConnection(prisma, { userId, type, channelId, profile = null }) {
|
||||||
const normalizedChannelId = String(channelId || '').trim();
|
const normalizedChannelId = String(channelId || '').trim();
|
||||||
if (!userId || !type || !normalizedChannelId) {
|
if (!userId || !type || !normalizedChannelId) {
|
||||||
@@ -44,7 +70,9 @@ export async function upsertActiveMessengerConnection(prisma, { userId, type, ch
|
|||||||
|
|
||||||
const normalizedProfile = type === 'TELEGRAM'
|
const normalizedProfile = type === 'TELEGRAM'
|
||||||
? normalizeTelegramProfile(profile)
|
? normalizeTelegramProfile(profile)
|
||||||
: normalizeTelegramProfile(null);
|
: type === 'MAX'
|
||||||
|
? normalizeMaxProfile(profile)
|
||||||
|
: normalizeMessengerProfile(null);
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
await tx.messengerConnection.updateMany({
|
await tx.messengerConnection.updateMany({
|
||||||
|
|||||||
112
src/server.js
112
src/server.js
@@ -21,7 +21,14 @@ import {
|
|||||||
} 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 {
|
||||||
|
normalizeMaxProfile,
|
||||||
|
normalizeTelegramProfile,
|
||||||
|
profileFromMaxMiniAppUser,
|
||||||
|
profileFromTelegramMiniAppUser,
|
||||||
|
upsertActiveMessengerConnection,
|
||||||
|
} from './messenger-connections.js';
|
||||||
|
import { validateMaxMiniAppInitData } from './max-mini-app.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';
|
||||||
@@ -96,6 +103,14 @@ function normalizeRedirectPath(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function presentTelegramMiniAppUser(user) {
|
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 firstName = String(user?.first_name || '').trim();
|
||||||
const lastName = String(user?.last_name || '').trim();
|
const lastName = String(user?.last_name || '').trim();
|
||||||
|
|
||||||
@@ -106,7 +121,7 @@ function presentTelegramMiniAppUser(user) {
|
|||||||
username: String(user?.username || '').trim() || null,
|
username: String(user?.username || '').trim() || null,
|
||||||
languageCode: String(user?.language_code || '').trim() || null,
|
languageCode: String(user?.language_code || '').trim() || null,
|
||||||
photoUrl: String(user?.photo_url || '').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) => {
|
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;
|
||||||
@@ -301,7 +403,9 @@ app.post('/bot/messenger-login', async (req, res) => {
|
|||||||
messengerConnection: {
|
messengerConnection: {
|
||||||
type: channel,
|
type: channel,
|
||||||
channelId,
|
channelId,
|
||||||
...normalizeTelegramProfile(req.body?.profile),
|
...(channel === 'TELEGRAM'
|
||||||
|
? normalizeTelegramProfile(req.body?.profile)
|
||||||
|
: normalizeMaxProfile(req.body?.profile)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const frontendUrl = (
|
const frontendUrl = (
|
||||||
@@ -310,7 +414,7 @@ app.post('/bot/messenger-login', async (req, res) => {
|
|||||||
'http://localhost:3000'
|
'http://localhost:3000'
|
||||||
).replace(/\/$/, '');
|
).replace(/\/$/, '');
|
||||||
const nextPath = startSession.redirectPath || (
|
const nextPath = startSession.redirectPath || (
|
||||||
channel === 'TELEGRAM'
|
channel === 'TELEGRAM' || channel === 'MAX'
|
||||||
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user