Support Telegram login widget auth
Some checks failed
Build and deploy Backend / build (push) Has been cancelled

This commit is contained in:
Ruslan Bakiev
2026-05-08 18:26:49 +07:00
parent fbe961c358
commit fe8a69d9b8
3 changed files with 137 additions and 36 deletions

View File

@@ -1,4 +1,4 @@
import { createHmac, timingSafeEqual } from 'node:crypto'; import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { config } from '../config.js'; import { config } from '../config.js';
import { prisma } from '../prisma.js'; import { prisma } from '../prisma.js';
@@ -12,41 +12,75 @@ type TelegramInitDataUser = {
language_code?: string; language_code?: string;
}; };
export type TelegramLoginData = {
id: number;
first_name?: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
};
type TelegramUserPayload = {
id: number;
username?: string;
firstName?: string;
lastName?: string;
photoUrl?: string;
languageCode?: string;
};
type TelegramAuthData = {
telegramInitData?: string;
telegramLoginData?: string;
};
function hmacSha256(key: string | Buffer, value: string) { function hmacSha256(key: string | Buffer, value: string) {
return createHmac('sha256', key).update(value).digest(); return createHmac('sha256', key).update(value).digest();
} }
function assertValidHash(receivedHash: string, expectedHash: Buffer) { function sha256(value: string) {
return createHash('sha256').update(value).digest();
}
function assertValidHash(receivedHash: string, expectedHash: Buffer, dataType: string) {
const received = Buffer.from(receivedHash, 'hex'); const received = Buffer.from(receivedHash, 'hex');
if (received.length !== expectedHash.length) { if (received.length !== expectedHash.length) {
throw new Error('Telegram init data hash is invalid.'); throw new Error(`Telegram ${dataType} hash is invalid.`);
} }
if (!timingSafeEqual(received, expectedHash)) { if (!timingSafeEqual(received, expectedHash)) {
throw new Error('Telegram init data hash is invalid.'); throw new Error(`Telegram ${dataType} hash is invalid.`);
} }
} }
function parseTelegramInitData(initData: string) { function assertConfiguredBotToken() {
if (!config.telegramMiniAppBotToken) { if (!config.telegramMiniAppBotToken) {
throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.'); throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.');
} }
}
function assertFreshAuthDate(authDate: number, dataType: string) {
if (!Number.isFinite(authDate)) {
throw new Error(`Telegram ${dataType} auth_date is required.`);
}
const ageSeconds = Math.floor(Date.now() / 1000) - authDate;
if (ageSeconds > config.telegramAuthMaxAgeSeconds) {
throw new Error(`Telegram ${dataType} is expired.`);
}
}
function parseTelegramInitData(initData: string): TelegramUserPayload {
assertConfiguredBotToken();
const params = new URLSearchParams(initData); const params = new URLSearchParams(initData);
const receivedHash = params.get('hash'); const receivedHash = params.get('hash');
if (!receivedHash) { if (!receivedHash) {
throw new Error('Telegram init data hash is required.'); throw new Error('Telegram init data hash is required.');
} }
const authDate = Number(params.get('auth_date')); assertFreshAuthDate(Number(params.get('auth_date')), 'init data');
if (!Number.isFinite(authDate)) {
throw new Error('Telegram auth_date is required.');
}
const ageSeconds = Math.floor(Date.now() / 1000) - authDate;
if (ageSeconds > config.telegramAuthMaxAgeSeconds) {
throw new Error('Telegram init data is expired.');
}
const dataCheckString = [...params.entries()] const dataCheckString = [...params.entries()]
.filter(([key]) => key !== 'hash') .filter(([key]) => key !== 'hash')
@@ -56,43 +90,87 @@ function parseTelegramInitData(initData: string) {
const secretKey = hmacSha256('WebAppData', config.telegramMiniAppBotToken); const secretKey = hmacSha256('WebAppData', config.telegramMiniAppBotToken);
const expectedHash = hmacSha256(secretKey, dataCheckString); const expectedHash = hmacSha256(secretKey, dataCheckString);
assertValidHash(receivedHash, expectedHash); assertValidHash(receivedHash, expectedHash, 'init data');
const rawUser = params.get('user'); const rawUser = params.get('user');
if (!rawUser) { if (!rawUser) {
throw new Error('Telegram user is required.'); throw new Error('Telegram user is required.');
} }
return JSON.parse(rawUser) as TelegramInitDataUser; const user = JSON.parse(rawUser) as TelegramInitDataUser;
return {
id: user.id,
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
photoUrl: user.photo_url,
languageCode: user.language_code,
};
} }
export async function getOrCreateTelegramUser(initData: string) { function parseTelegramLoginData(loginData: TelegramLoginData): TelegramUserPayload {
const user = parseTelegramInitData(initData); assertConfiguredBotToken();
assertFreshAuthDate(Number(loginData.auth_date), 'login data');
const dataCheckString = Object.entries(loginData)
.filter(([key, value]) => key !== 'hash' && value !== undefined && value !== null)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const expectedHash = hmacSha256(sha256(config.telegramMiniAppBotToken), dataCheckString);
assertValidHash(loginData.hash, expectedHash, 'login data');
return {
id: Number(loginData.id),
username: loginData.username,
firstName: loginData.first_name,
lastName: loginData.last_name,
photoUrl: loginData.photo_url,
};
}
function parseTelegramLoginJson(loginData: string) {
return parseTelegramLoginData(JSON.parse(loginData) as TelegramLoginData);
}
async function upsertTelegramUser(user: TelegramUserPayload) {
return prisma.user.upsert({ return prisma.user.upsert({
where: { telegramId: String(user.id) }, where: { telegramId: String(user.id) },
create: { create: {
telegramId: String(user.id), telegramId: String(user.id),
username: user.username, username: user.username,
firstName: user.first_name, firstName: user.firstName,
lastName: user.last_name, lastName: user.lastName,
photoUrl: user.photo_url, photoUrl: user.photoUrl,
languageCode: user.language_code, languageCode: user.languageCode,
}, },
update: { update: {
username: user.username, username: user.username,
firstName: user.first_name, firstName: user.firstName,
lastName: user.last_name, lastName: user.lastName,
photoUrl: user.photo_url, photoUrl: user.photoUrl,
languageCode: user.language_code, languageCode: user.languageCode,
}, },
}); });
} }
export async function requireTelegramUser(initData?: string) { export async function getOrCreateTelegramUser(initData: string) {
if (!initData) { return upsertTelegramUser(parseTelegramInitData(initData));
throw new Error('Telegram authorization is required.'); }
export async function getOrCreateTelegramLoginUser(loginData: TelegramLoginData) {
return upsertTelegramUser(parseTelegramLoginData(loginData));
}
export async function requireTelegramUser(authData: TelegramAuthData) {
if (authData.telegramInitData) {
return upsertTelegramUser(parseTelegramInitData(authData.telegramInitData));
} }
return getOrCreateTelegramUser(initData); if (authData.telegramLoginData) {
return upsertTelegramUser(parseTelegramLoginJson(authData.telegramLoginData));
}
throw new Error('Telegram authorization is required.');
} }

View File

@@ -1,10 +1,16 @@
import { GraphQLJSONObject } from './scalars.js'; import { GraphQLJSONObject } from './scalars.js';
import { getOrCreateTelegramUser, requireTelegramUser } from '../auth/telegram.js'; import {
getOrCreateTelegramLoginUser,
getOrCreateTelegramUser,
requireTelegramUser,
type TelegramLoginData,
} from '../auth/telegram.js';
import { listPlaces, listVoiceExperiences } from './places.js'; import { listPlaces, listVoiceExperiences } from './places.js';
import { createVoiceExperience } from './voice-experiences.js'; import { createVoiceExperience } from './voice-experiences.js';
export type GraphqlContext = { export type GraphqlContext = {
telegramInitData?: string; telegramInitData?: string;
telegramLoginData?: string;
}; };
export const schema = /* GraphQL */ ` export const schema = /* GraphQL */ `
@@ -63,6 +69,16 @@ export const schema = /* GraphQL */ `
initData: String! initData: String!
} }
input AuthenticateTelegramLoginInput {
id: Float!
first_name: String
last_name: String
username: String
photo_url: String
auth_date: Float!
hash: String!
}
type AuthPayload { type AuthPayload {
user: User! user: User!
} }
@@ -75,6 +91,7 @@ export const schema = /* GraphQL */ `
type Mutation { type Mutation {
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload! authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload!
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience! createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
} }
`; `;
@@ -85,12 +102,12 @@ export const resolvers = {
health: () => 'ok', health: () => 'ok',
places: async (_: unknown, __: unknown, context: unknown) => { places: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext; const graphqlContext = context as GraphqlContext;
await requireTelegramUser(graphqlContext.telegramInitData); await requireTelegramUser(graphqlContext);
return listPlaces(); return listPlaces();
}, },
voiceExperiences: async (_: unknown, __: unknown, context: unknown) => { voiceExperiences: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext; const graphqlContext = context as GraphqlContext;
await requireTelegramUser(graphqlContext.telegramInitData); await requireTelegramUser(graphqlContext);
return listVoiceExperiences(); return listVoiceExperiences();
}, },
}, },
@@ -99,13 +116,17 @@ export const resolvers = {
_: unknown, _: unknown,
args: { input: { initData: string } }, args: { input: { initData: string } },
) => ({ user: await getOrCreateTelegramUser(args.input.initData) }), ) => ({ user: await getOrCreateTelegramUser(args.input.initData) }),
authenticateTelegramLogin: async (
_: unknown,
args: { input: TelegramLoginData },
) => ({ user: await getOrCreateTelegramLoginUser(args.input) }),
createVoiceExperience: async ( createVoiceExperience: async (
_: unknown, _: unknown,
args: { input: Parameters<typeof createVoiceExperience>[0] }, args: { input: Parameters<typeof createVoiceExperience>[0] },
context: unknown, context: unknown,
) => { ) => {
const graphqlContext = context as GraphqlContext; const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext.telegramInitData); const user = await requireTelegramUser(graphqlContext);
return createVoiceExperience(args.input, user.id); return createVoiceExperience(args.input, user.id);
}, },
}, },

View File

@@ -12,9 +12,11 @@ app.register(mercurius, {
resolvers, resolvers,
graphiql: true, graphiql: true,
context: async (request) => { context: async (request) => {
const header = request.headers['x-telegram-init-data']; const initDataHeader = request.headers['x-telegram-init-data'];
const loginDataHeader = request.headers['x-telegram-login-data'];
return { return {
telegramInitData: Array.isArray(header) ? header[0] : header, telegramInitData: Array.isArray(initDataHeader) ? initDataHeader[0] : initDataHeader,
telegramLoginData: Array.isArray(loginDataHeader) ? loginDataHeader[0] : loginDataHeader,
}; };
}, },
}); });