Add standalone bonus program auth flow

This commit is contained in:
Ruslan Bakiev
2026-04-07 10:46:57 +07:00
parent 92592e2baa
commit b321075293
5 changed files with 197 additions and 15 deletions

View File

@@ -6,6 +6,7 @@ const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS ?? 60 *
const AUTH_LOGIN_CHALLENGE_TTL_SECONDS = Number(process.env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS ?? 10 * 60);
const AUTH_LOGIN_LINK_TTL_SECONDS = Number(process.env.AUTH_LOGIN_LINK_TTL_SECONDS ?? 5 * 60);
const AUTH_MESSENGER_START_TTL_SECONDS = Number(process.env.AUTH_MESSENGER_START_TTL_SECONDS ?? 10 * 60);
const BONUS_PROGRAM_LINK_TTL_SECONDS = Number(process.env.BONUS_PROGRAM_LINK_TTL_SECONDS ?? 7 * 24 * 60 * 60);
const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
const activeChallenges = new Map();
@@ -179,7 +180,7 @@ export function verifyLoginChallengeCode({ challengeToken, code }) {
};
}
export function createMessengerStartSession({ channel, email, userId, redirectPath }) {
export function createMessengerStartSession({ channel, email, userId, redirectPath, targetApp = 'MAIN' }) {
purgeExpiredMessengerStartSessions();
const startToken = crypto.randomBytes(24).toString('base64url');
@@ -189,6 +190,7 @@ export function createMessengerStartSession({ channel, email, userId, redirectPa
email,
userId,
redirectPath,
targetApp,
expiresAt,
});
@@ -212,6 +214,7 @@ export function consumeMessengerStartSession(startToken) {
email: payload.email,
userId: payload.userId,
redirectPath: payload.redirectPath,
targetApp: payload.targetApp || 'MAIN',
};
}
@@ -237,6 +240,59 @@ export function issueTemporaryLoginToken({ userId, messengerConnection = null })
};
}
export function issueBonusProgramLinkToken({ userId }) {
const now = Math.floor(Date.now() / 1000);
const exp = now + BONUS_PROGRAM_LINK_TTL_SECONDS;
const payload = {
type: 'BONUS_PROGRAM_LINK',
sub: userId,
iat: now,
exp,
jti: crypto.randomUUID(),
};
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = sign(payloadBase64);
return {
token: `${payloadBase64}.${signature}`,
expiresAt: new Date(exp * 1000),
};
}
export function verifyBonusProgramLinkToken(token) {
if (!token) {
throw new Error('Bonus program token is required.');
}
const parts = String(token).split('.');
if (parts.length !== 2) {
throw new Error('Bonus program token is invalid.');
}
const [payloadBase64, signature] = parts;
const expectedSignature = sign(payloadBase64);
if (expectedSignature !== signature) {
throw new Error('Bonus program token signature is invalid.');
}
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf8');
const payload = JSON.parse(payloadJson);
if (payload.type !== 'BONUS_PROGRAM_LINK') {
throw new Error('Bonus program token type is invalid.');
}
const exp = Number(payload.exp);
if (!Number.isFinite(exp) || exp <= Math.floor(Date.now() / 1000)) {
throw new Error('Bonus program token has expired.');
}
return {
userId: String(payload.sub),
expiresAt: new Date(exp * 1000),
};
}
export function consumeTemporaryLoginToken(loginToken) {
purgeExpiredLoginTokens();

View File

@@ -57,6 +57,27 @@ export function buildBonusProgramPath(entry = 'bonus-message') {
return query ? `/bonus-program?${query}` : '/bonus-program';
}
export function buildBonusProgramUrl(entry = 'bonus-message') {
const bonusBaseUrl = String(
process.env.BONUS_FRONTEND_URL ||
process.env.BONUS_PUBLIC_BASE_URL ||
'',
).trim().replace(/\/$/, '');
if (bonusBaseUrl) {
const params = new URLSearchParams();
const normalizedEntry = String(entry || '').trim();
if (normalizedEntry) {
params.set('entry', normalizedEntry);
}
const query = params.toString();
return query ? `${bonusBaseUrl}/?${query}` : `${bonusBaseUrl}/`;
}
return buildFrontendAppUrl(buildBonusProgramPath(entry));
}
export function buildLoginCodeEmailTemplate({ code, expiresAt }) {
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
const body = [
@@ -144,7 +165,7 @@ export function buildBonusCreditTemplate({ amount }) {
body,
message: body.join('\n'),
buttonText: 'Открыть бонусную программу',
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('balance')),
buttonUrl: buildBonusProgramUrl('balance'),
};
}
@@ -167,7 +188,7 @@ export function buildWithdrawalReviewNotificationTemplate({ status, reviewCommen
body,
message: body.join('\n'),
buttonText: 'Открыть бонусную программу',
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('withdrawal-review')),
buttonUrl: buildBonusProgramUrl('withdrawal-review'),
};
}

View File

@@ -4,6 +4,7 @@ import {
consumeTemporaryLoginToken,
createLoginChallenge,
getStaticAuthCode,
issueBonusProgramLinkToken,
issueAccessToken,
maskAuthDestination,
verifyLoginChallengeCode,
@@ -50,6 +51,30 @@ function latestDate(...values) {
return new Date(Math.max(...timestamps));
}
function buildBonusProgramLinkUrl(token) {
const baseUrl = String(
process.env.BONUS_FRONTEND_URL ||
process.env.BONUS_PUBLIC_BASE_URL ||
'',
).trim().replace(/\/$/, '');
if (baseUrl) {
return `${baseUrl}/?bonus_token=${encodeURIComponent(token)}`;
}
const fallbackBaseUrl = String(
process.env.WEB_FRONTEND_URL ||
process.env.NUXT_PUBLIC_SITE_URL ||
'',
).trim().replace(/\/$/, '');
if (!fallbackBaseUrl) {
return `/?bonus_token=${encodeURIComponent(token)}`;
}
return `${fallbackBaseUrl}/bonus-program?bonus_token=${encodeURIComponent(token)}`;
}
function requireUser(context) {
if (!context.user) {
throw new Error('Authentication required.');
@@ -2052,6 +2077,20 @@ export const resolvers = {
});
},
createBonusProgramLink: async (_, { userId }, context) => {
const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, userId);
const issued = issueBonusProgramLinkToken({ userId });
return {
userId,
token: issued.token,
url: buildBonusProgramLinkUrl(issued.token),
expiresAt: issued.expiresAt,
};
},
addBonusTransaction: async (_, { input }, context) => {
const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, input.userId);

View File

@@ -213,6 +213,13 @@ type IntegrationSyncDashboard {
items: [IntegrationSyncItem!]!
}
type BonusProgramLink {
userId: ID!
token: String!
url: String!
expiresAt: DateTime!
}
type Warehouse {
id: ID!
code: String!
@@ -563,6 +570,7 @@ type Mutation {
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
createReferral(input: CreateReferralInput!): ReferralLink!
createBonusProgramLink(userId: ID!): BonusProgramLink!
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!

View File

@@ -17,6 +17,7 @@ import {
hasMessengerStartSession,
issueAccessToken,
issueTemporaryLoginToken,
verifyBonusProgramLinkToken,
verifyAccessToken,
} from './auth.js';
import { canManagerAccessUser, isManagerRole } from './access.js';
@@ -103,6 +104,10 @@ function normalizeRedirectPath(value) {
return redirectPath;
}
function normalizeTargetApp(value) {
return String(value || '').trim().toUpperCase() === 'BONUS' ? 'BONUS' : 'MAIN';
}
function presentTelegramMiniAppUser(user) {
return presentMiniAppUser(user, 'Пользователь Telegram');
}
@@ -160,6 +165,7 @@ app.post('/auth/messenger-start', async (req, res) => {
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
const userId = authenticatedUser?.id ?? null;
const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
const targetApp = normalizeTargetApp(req.body?.targetApp);
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: 'A valid email is required.' });
@@ -171,6 +177,7 @@ app.post('/auth/messenger-start', async (req, res) => {
email,
userId,
redirectPath,
targetApp,
});
res.json({
@@ -181,6 +188,43 @@ app.post('/auth/messenger-start', async (req, res) => {
});
});
app.post('/auth/bonus-program-start', async (req, res) => {
const channel = String(req.body?.channel || 'TELEGRAM').toUpperCase();
if (channel !== 'TELEGRAM') {
res.status(400).json({ error: 'Only Telegram is supported for the bonus program.' });
return;
}
const token = String(req.body?.token || '').trim();
if (!token) {
res.status(400).json({ error: 'Bonus program token is required.' });
return;
}
let payload;
try {
payload = verifyBonusProgramLinkToken(token);
} catch (error) {
res.status(401).json({ error: error.message });
return;
}
const session = createMessengerStartSession({
channel,
email: '',
userId: payload.userId,
redirectPath: '/',
targetApp: 'BONUS',
});
res.json({
ok: true,
startToken: session.startToken,
expiresAt: session.expiresAt.toISOString(),
mode: 'login',
});
});
app.post('/auth/telegram-mini-app/session', async (req, res) => {
let telegram;
try {
@@ -409,23 +453,37 @@ app.post('/bot/messenger-login', async (req, res) => {
: normalizeMaxProfile(req.body?.profile)),
},
});
const frontendUrl = (
const mainFrontendUrl = (
process.env.WEB_FRONTEND_URL ||
process.env.NUXT_PUBLIC_SITE_URL ||
'http://localhost:3000'
).replace(/\/$/, '');
const bonusFrontendUrl = String(
process.env.BONUS_FRONTEND_URL ||
process.env.BONUS_PUBLIC_BASE_URL ||
'',
).trim().replace(/\/$/, '');
const frontendUrl = startSession.targetApp === 'BONUS'
? (bonusFrontendUrl || mainFrontendUrl)
: mainFrontendUrl;
let loginUrl = `${frontendUrl}/login?login_token=${encodeURIComponent(login.loginToken)}`;
if (startSession.targetApp === 'BONUS') {
loginUrl = `${frontendUrl}/?login_token=${encodeURIComponent(login.loginToken)}`;
} else {
const nextPath = startSession.redirectPath || (
channel === 'TELEGRAM' || channel === 'MAX'
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
: ''
);
if (nextPath) {
const loginQuery = new URLSearchParams({
login_token: login.loginToken,
next: nextPath,
});
if (nextPath) {
loginQuery.set('next', nextPath);
loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
}
}
const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
if (!skipDispatch) {
const template = buildMessengerLoginTemplate({