From fe8a69d9b87304876d6a38a8a1f4d2aeac84af58 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 8 May 2026 18:26:49 +0700 Subject: [PATCH] Support Telegram login widget auth --- src/auth/telegram.ts | 138 +++++++++++++++++++++++++++++++++--------- src/graphql/schema.ts | 29 +++++++-- src/server.ts | 6 +- 3 files changed, 137 insertions(+), 36 deletions(-) diff --git a/src/auth/telegram.ts b/src/auth/telegram.ts index 1526b95..c32e84f 100644 --- a/src/auth/telegram.ts +++ b/src/auth/telegram.ts @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; import { config } from '../config.js'; import { prisma } from '../prisma.js'; @@ -12,41 +12,75 @@ type TelegramInitDataUser = { 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) { 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'); 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)) { - 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) { 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 receivedHash = params.get('hash'); if (!receivedHash) { throw new Error('Telegram init data hash is required.'); } - const authDate = Number(params.get('auth_date')); - 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.'); - } + assertFreshAuthDate(Number(params.get('auth_date')), 'init data'); const dataCheckString = [...params.entries()] .filter(([key]) => key !== 'hash') @@ -56,43 +90,87 @@ function parseTelegramInitData(initData: string) { const secretKey = hmacSha256('WebAppData', config.telegramMiniAppBotToken); const expectedHash = hmacSha256(secretKey, dataCheckString); - assertValidHash(receivedHash, expectedHash); + assertValidHash(receivedHash, expectedHash, 'init data'); const rawUser = params.get('user'); if (!rawUser) { 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) { - const user = parseTelegramInitData(initData); +function parseTelegramLoginData(loginData: TelegramLoginData): TelegramUserPayload { + 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({ where: { telegramId: String(user.id) }, create: { telegramId: String(user.id), username: user.username, - firstName: user.first_name, - lastName: user.last_name, - photoUrl: user.photo_url, - languageCode: user.language_code, + firstName: user.firstName, + lastName: user.lastName, + photoUrl: user.photoUrl, + languageCode: user.languageCode, }, update: { username: user.username, - firstName: user.first_name, - lastName: user.last_name, - photoUrl: user.photo_url, - languageCode: user.language_code, + firstName: user.firstName, + lastName: user.lastName, + photoUrl: user.photoUrl, + languageCode: user.languageCode, }, }); } -export async function requireTelegramUser(initData?: string) { - if (!initData) { - throw new Error('Telegram authorization is required.'); +export async function getOrCreateTelegramUser(initData: string) { + return upsertTelegramUser(parseTelegramInitData(initData)); +} + +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.'); } diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts index 21e8eed..cba376a 100644 --- a/src/graphql/schema.ts +++ b/src/graphql/schema.ts @@ -1,10 +1,16 @@ 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 { createVoiceExperience } from './voice-experiences.js'; export type GraphqlContext = { telegramInitData?: string; + telegramLoginData?: string; }; export const schema = /* GraphQL */ ` @@ -63,6 +69,16 @@ export const schema = /* GraphQL */ ` initData: String! } + input AuthenticateTelegramLoginInput { + id: Float! + first_name: String + last_name: String + username: String + photo_url: String + auth_date: Float! + hash: String! + } + type AuthPayload { user: User! } @@ -75,6 +91,7 @@ export const schema = /* GraphQL */ ` type Mutation { authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload! + authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload! createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience! } `; @@ -85,12 +102,12 @@ export const resolvers = { health: () => 'ok', places: async (_: unknown, __: unknown, context: unknown) => { const graphqlContext = context as GraphqlContext; - await requireTelegramUser(graphqlContext.telegramInitData); + await requireTelegramUser(graphqlContext); return listPlaces(); }, voiceExperiences: async (_: unknown, __: unknown, context: unknown) => { const graphqlContext = context as GraphqlContext; - await requireTelegramUser(graphqlContext.telegramInitData); + await requireTelegramUser(graphqlContext); return listVoiceExperiences(); }, }, @@ -99,13 +116,17 @@ export const resolvers = { _: unknown, args: { input: { initData: string } }, ) => ({ user: await getOrCreateTelegramUser(args.input.initData) }), + authenticateTelegramLogin: async ( + _: unknown, + args: { input: TelegramLoginData }, + ) => ({ user: await getOrCreateTelegramLoginUser(args.input) }), createVoiceExperience: async ( _: unknown, args: { input: Parameters[0] }, context: unknown, ) => { const graphqlContext = context as GraphqlContext; - const user = await requireTelegramUser(graphqlContext.telegramInitData); + const user = await requireTelegramUser(graphqlContext); return createVoiceExperience(args.input, user.id); }, }, diff --git a/src/server.ts b/src/server.ts index d1d6fd6..9e14125 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,9 +12,11 @@ app.register(mercurius, { resolvers, graphiql: true, 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 { - telegramInitData: Array.isArray(header) ? header[0] : header, + telegramInitData: Array.isArray(initDataHeader) ? initDataHeader[0] : initDataHeader, + telegramLoginData: Array.isArray(loginDataHeader) ? loginDataHeader[0] : loginDataHeader, }; }, });