Skip to content

Implementing Telegram "OAuth" in TypeScript

Posted on:September 23, 2023 at 03:17 PM

The objective of this tutorial is to create a custom login button without, distinct from the pre-built option provided by the Telegram Login Widget. Additionally, I encountered challenges while trying to reposition the default button that comes with the script, resulting in a less than optimal Developer Experience (DX). We’ll address this issue in this guide.

All code snippets in this guide will be in TypeScript, and I’ll be using the Zod validation library.

It’s worth noting that Telegram authentication doesn’t follow the traditional OAuth implementation, which is why it’s enclosed in quotes in the title.

Table of contents

Open Table of contents

1. Create a Bot

To begin the process of implementing authorization, it’s imperative to create your own bot. It’s crucial to name the bot in a way that aligns with your application. This is vital because the bot’s name will be visible during the authorization flow, and you definitely don’t want to confuse your users with a generic bot image.

Once you’ve named your bot, you should request a bot token from @BotFather, which will be in the following format:

[NUMBER]:[NUMBERS_WITH_LETTERS]

The first part of this API Token will be your bot_id, a piece of information we’ll need later in the process.

2. Add Script Tag

You should insert the following script into your index.html file, preferably within the head section:

<script src="https://telegram.org/js/telegram-widget.js?22"></script>

This script will introduce the Telegram key into our window object.

It’s important to note that we’re intentionally not including the async attribute on the script tag, contrary to what the Telegram documentation suggests. This choice will have significant implications down the road.

Additionally, we are refraining from defining any other attributes mentioned in the documentation. We’re doing this because we don’t want the default “Login With Telegram” button to be visible; instead, we aim to implement our custom solution.

3. Install the Zod Library

You can install the Zod library using your preferred package manager. In this case, we’ll use npm:

npm i zod

However, feel free to use any package manager that suits your workflow.

4. Define Telegram Class

import { z } from "zod";

const telegramUserSchema = z.object({
  auth_date: z.number(),
  first_name: z.string(),
  hash: z.string(),
  id: z.number(),
  last_name: z.string(),
  photo_url: z.string(),
  username: z.string(),
});

const loginResponseSchema = z.boolean().or(telegramUserSchema);

const loginPropsSchema = z.object({
  bot_id: z.string(),
  request_access: z.boolean(),
});

const clientSchema = z.object({
  Telegram: z.object({
    Login: z.object({
      auth: z
        .function()
        .args(loginPropsSchema, z.function().args(loginResponseSchema)),
    }),
  }),
});

export type TelegramUser = z.infer<typeof telegramUserSchema>;

class Telegram {
  private client;

  constructor() {
    this.client = clientSchema.parse(window).Telegram;
  }

  public login(
    props: z.infer<typeof loginPropsSchema>
  ): Promise<z.infer<typeof loginResponseSchema>> {
    return new Promise(resolve => {
      if (!this.client) {
        return resolve(false);
      }

      this.client.Login.auth(props, response => resolve(response));
    });
  }
}

export const telegram = new Telegram();

In the code snippet above, we’ve defined the Telegram class, which plays a key role in ensuring type safety throughout the integration. Let’s break down what’s happening here:

By establishing these schemas, you can interact with the loaded SDK in a type-safe manner, ensuring input and output variable validation. This enhances the robustness of your integration.

5. Use Telegram Class

To demonstrate how to use the Telegram class, consider the following example:

const login = async () => {
  const res = await telegram.login({
    bot_id: "YOUR_BOT_ID_HERE",
    request_access: true,
  });

  if (typeof res !== "boolean") {
    // successful login, `res` will be the information on user
  } else {
    console.error("Could not login");
  }
};

6. Authenticating on Server

To determine if a user has logged in through Telegram, you need the res variable obtained from the code snippet provided earlier.

You can pass all the fields to the server using your preferred method, whether it’s through a cookie or directly in the request body. Once received, the server can validate this object to establish whether the user has successfully logged in.

The following code snippet demonstrates how to validate the object on the server:

import crypto from "node:crypto";

export class TelegramAuth {
  private secretKey;

  constructor(botToken: string) {
    this.secretKey = crypto.createHash("sha256").update(botToken).digest();
  }

  public isValid(data: Record<string, string | number>) {
    const authData = { ...data };
    const checkHash = authData.hash;
    delete authData.hash;

    const dataCheckString = Object.entries(authData)
      .map(([key, value]) => `${key}=${value}`)
      .sort()
      .join("\n");

    const hash = crypto
      .createHmac("sha256", this.secretKey)
      .update(dataCheckString)
      .digest("hex");

    if (hash !== checkHash) {
      throw new Error("Data is NOT from Telegram");
    }

    if (Date.now() / 1000 - Number(authData.auth_date) > 86400) {
      throw new Error("Outdated auth data");
    }
  }
}

I will not explain the provided code in detail since mostly it was copied from Telegram gist, but it was in PHP, so I just converted it into TypeScript

The sample usage example, integrating this into your codebase, might look like this (using the trpc library):

const telegramUserSchema = z.object({
  auth_date: z.number(),
  first_name: z.string(),
  hash: z.string(),
  id: z.number(),
  last_name: z.string(),
  photo_url: z.string(),
  username: z.string(),
});

const appRouter = router({
  listMessages: publicProcedure
    .input(
      z.object({
        telegramUser: telegramUserSchema,
      })
    )
    .query(async ({ input }) => {
      telegramAuth.isValid(input.telegramUser);

      // now `input.telegramUser` was validated
      // we can trust the provided information inside

      const result = await repo.get({
        userId: input.telegramUser.id.toString(),
      });

      return result;
    }),
});

Conclusion

If you have any remaining questions or need further assistance, please don’t hesitate to reach out to me on Twitter. Your feedback and inquiries are always welcome.

Thank you for reading!