feat(auth): secure messenger start token flow

This commit is contained in:
Ruslan Bakiev
2026-04-03 18:12:05 +07:00
parent 8267a48cb4
commit 6c5839d6ee
3 changed files with 154 additions and 32 deletions

View File

@@ -8,7 +8,14 @@ import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express5';
import { issueTemporaryLoginToken } from './auth.js';
import {
createMessengerStartSession,
consumeMessengerStartSession,
extractAuthTokenFromRequest,
hasMessengerStartSession,
issueTemporaryLoginToken,
verifyAccessToken,
} from './auth.js';
import { buildContext } from './context.js';
import { sendMessengerMessage } from './messenger.js';
import { prisma } from './prisma-client.js';
@@ -73,6 +80,49 @@ async function resolveUserForMessenger({ userId, email }) {
});
}
async function resolveAuthenticatedUserFromRequest(req) {
const authToken = extractAuthTokenFromRequest(req);
const auth = verifyAccessToken(authToken);
if (!auth?.userId) {
return null;
}
return prisma.user.findUnique({
where: { id: auth.userId },
});
}
app.post('/auth/messenger-start', async (req, res) => {
const channel = String(req.body?.channel || '').toUpperCase();
if (channel !== 'TELEGRAM' && channel !== 'MAX') {
res.status(400).json({ error: 'Unsupported channel.' });
return;
}
const authenticatedUser = await resolveAuthenticatedUserFromRequest(req);
const providedEmail = String(req.body?.email || '').trim().toLowerCase();
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
const userId = authenticatedUser?.id ?? null;
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: 'A valid email is required.' });
return;
}
const session = createMessengerStartSession({
channel,
email,
userId,
});
res.json({
ok: true,
startToken: session.startToken,
expiresAt: session.expiresAt.toISOString(),
mode: userId ? 'connect' : 'login',
});
});
app.post('/bot/messenger-login', async (req, res) => {
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
const providedToken = req.body?.token;
@@ -87,18 +137,29 @@ app.post('/bot/messenger-login', async (req, res) => {
return;
}
const userId = String(req.body?.userId || '').trim();
const email = String(req.body?.email || '').trim().toLowerCase();
const startToken = String(req.body?.startToken || '').trim();
const channelId = String(req.body?.channelId || '').trim();
const skipDispatch = req.body?.skipDispatch === true;
if (!channelId || (!userId && !email)) {
res.status(400).json({ error: 'channelId and (userId or email) are required.' });
if (!channelId || !startToken) {
res.status(400).json({ error: 'channelId and startToken are required.' });
return;
}
if (!hasMessengerStartSession(startToken)) {
res.status(400).json({ error: 'Messenger start token is invalid or expired.' });
return;
}
const startSession = consumeMessengerStartSession(startToken);
if (startSession.channel !== channel) {
res.status(400).json({ error: 'Start token channel mismatch.' });
return;
}
const user = await resolveUserForMessenger({
userId,
email,
userId: startSession.userId,
email: startSession.email,
});
if (!user) {
@@ -106,34 +167,13 @@ app.post('/bot/messenger-login', async (req, res) => {
return;
}
await prisma.messengerConnection.updateMany({
where: {
userId: user.id,
type: channel,
isActive: true,
NOT: { channelId },
},
data: { isActive: false },
});
await prisma.messengerConnection.upsert({
where: {
userId_type_channelId: {
userId: user.id,
type: channel,
channelId,
},
},
update: { isActive: true },
create: {
userId: user.id,
const login = issueTemporaryLoginToken({
userId: user.id,
messengerConnection: {
type: channel,
channelId,
isActive: true,
},
});
const login = issueTemporaryLoginToken(user.id);
const frontendUrl = (
process.env.WEB_FRONTEND_URL ||
process.env.NUXT_PUBLIC_SITE_URL ||
@@ -158,6 +198,7 @@ app.post('/bot/messenger-login', async (req, res) => {
res.json({
ok: true,
mode: startSession.userId ? 'connect' : 'login',
loginUrl,
expiresAt: login.expiresAt.toISOString(),
});