# Integrating "Sign in with Telegram" (auth.aipika.tech)

Authenticate your users via Telegram on **any domain**. Works **without
registration**: your `redirect_uri` is your identity. The user ends up logged in
**in whatever browser they finish in**.

- **Base URL:** `https://auth.aipika.tech`
- **Bot:** `@aipika_auth_bot` (internal; you don't touch it)
- **Machine-readable spec for LLMs/agents:** `https://auth.aipika.tech/llms.txt`

---

## TL;DR (registration-less, the default)

1. Redirect the browser to:
   `https://auth.aipika.tech/authorize?redirect_uri=<YOUR_HTTPS_CALLBACK>&state=<csrf>`
2. The user confirms in Telegram; we redirect back to
   `<YOUR_CALLBACK>?code=<one-time>&state=<csrf>`.
3. Your backend: `POST https://auth.aipika.tech/token` with `{ "code": "<code>" }`.
   You get `{ aud, telegram_id, first_name, username, photo_url, ... }`.
4. **Verify `aud === your own origin`**, then create your own session.

No `client_id`, no `client_secret`, no registration. The only requirements:
`redirect_uri` must be **https** (http allowed only on `localhost` for dev), and
you **must check `aud`**.

---

## Why no registration is safe here

Your `redirect_uri` (its origin) IS your identity, and the result is **bound to
that origin** via `aud`:

- **Redirect substitution is harmless.** If someone starts a flow with
  `redirect_uri=attacker.com`, the resulting `code`/identity is bound to
  `attacker.com` (`aud=https://attacker.com`) — only good for the attacker's own
  site. Your site checks `aud === your origin` and rejects anything else, so a
  code minted for another origin can't be injected into your site.
- **A separate `client_id` registry is therefore unnecessary** for redirect
  authenticity.

What registration does NOT solve (and neither does anything else, given
cross-browser login): a `code` that **leaks before first use** can be replayed
in its short one-time window. We minimize this with one-time + short TTL (~120 s)
codes that are delivered only to the user inside Telegram. If you need to close
this entirely, use a hardening below.

---

## Optional hardenings

### PKCE (strict, same-browser)
Send `code_challenge` (+ `code_challenge_method=S256`) on `/authorize`, and the
matching `code_verifier` on `/token`. We then require the verifier — a leaked
code is useless without it. **Trade-off:** this binds login to the initiating
browser, so it **disables cross-browser/magic-link completion**. Use it for
high-value sessions where same-browser is acceptable.

```
code_verifier  = random 43–128 chars
code_challenge = base64url(sha256(code_verifier))
```

### Confidential client (registered secret)
Ask the operator to register you:
`npm run client -- add --id myapp --name "My App" --redirect https://myapp.com/cb`.
Then pass `client_id` on `/authorize` and `client_id`+`client_secret` on `/token`.
We enforce your redirect allowlist and require the secret (so a leaked code can't
be exchanged by a third party), and return a verifiable `id_token` (JWT HS256
signed with your secret). Cross-browser still works (the secret lives on your
server, not in the browser).

---

## Reference implementation (Node/Express)

```js
const express = require("express");
const crypto = require("crypto");
const app = express();

const AUTH = "https://auth.aipika.tech";
const PUBLIC_URL = "https://myapp.com";
const REDIRECT_URI = PUBLIC_URL + "/auth/telegram/callback";
const OWN_ORIGIN = new URL(PUBLIC_URL).origin;

app.get("/login", (req, res) => {
  const state = crypto.randomBytes(16).toString("base64url");
  res.cookie("tg_state", state, { httpOnly: true, sameSite: "lax", secure: true, maxAge: 600000 });
  const u = new URL(AUTH + "/authorize");
  u.searchParams.set("redirect_uri", REDIRECT_URI);
  u.searchParams.set("state", state);
  res.redirect(u.toString());
});

app.get("/auth/telegram/callback", async (req, res) => {
  const { code, state } = req.query;
  if (!code) return res.status(400).send("missing code");

  // Verify state ONLY IF present. It's absent when the user finished in another
  // browser (Telegram in-app browser) — accept the one-time code in that case.
  const expected = req.cookies?.tg_state;
  if (expected && state && expected !== state) return res.status(400).send("bad state");

  const r = await fetch(AUTH + "/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code }),
  });
  if (!r.ok) return res.status(401).send("auth failed");
  const t = await r.json();

  // CRITICAL: the result is bound to an origin. Only trust tokens minted for you.
  if (t.aud !== OWN_ORIGIN) return res.status(400).send("bad aud");

  // t = { telegram_id, first_name, last_name, username, photo_url, aud }
  // create YOUR OWN session here (cookie/JWT), then:
  res.redirect("/");
});
```

A complete runnable version is in [`demo/`](../demo).

---

## Embedded mode (iframe — no domain change)

Instead of redirecting away to `auth.aipika.tech`, embed the login UI in an
iframe on your own page. Drop in the widget:

```html
<div id="tg-login"></div>
<script src="https://auth.aipika.tech/widget.js"></script>
<script>
  TelegramLogin.mount("#tg-login", {
    redirectUri: "https://yourapp.com/auth/telegram/callback",
    onSuccess: function (r) {                 // r = { code, state }
      // hand the code to YOUR backend (it exchanges at /token, checks aud, sets your session)
      fetch("/auth/telegram/complete", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ code: r.code })
      }).then(function (res) { if (res.ok) location.reload(); });
    }
  });
</script>
```

Your backend endpoint (`/auth/telegram/complete`) does exactly the same `/token`
exchange + `aud` check as the redirect callback, then sets your session and
returns `200`. See [`demo/`](../demo) `/embed` + `/complete`.

How it works & why it's safe:
- The widget loads `auth.../authorize?...&embed=1` in an iframe. On success the
  auth page **`postMessage`s** `{ code, state }` to your page (targeted at your
  origin), instead of redirecting.
- The widget only accepts messages whose `event.origin` is the auth service, and
  checks that `state` matches what it sent (CSRF).
- The auth page sends `Content-Security-Policy: frame-ancestors` limited to your
  `redirect_uri` origin — only your site can frame that login.
- Clickjacking can't steal an identity: the result is still `aud`-bound (a code
  framed by another site is bound to that site's origin, useless to you), and
  login requires the user to actively confirm in Telegram.
- No cookies are used by the iframe (it polls by request id), so third-party
  cookie blocking doesn't affect it.

Notes: this is mainly a **desktop** convenience (QR scanned by phone, iframe
polls and completes). On mobile the "Open Telegram" button opens Telegram in a
new tab (a tg:// link can't run inside an iframe). The other-browser/magic-link
completion still works as a normal redirect in that other browser.

---

## Cross-browser rule

The user may finish in a **different browser** than they started in (they tap the
"return" link inside Telegram). That browser has no `state` cookie from your
site. So: **verify `state` only when its cookie is present**; otherwise accept
the one-time `code` (it's delivered only to the user in their private Telegram
chat, is single-use and short-lived). Don't hard-require `state` — that would
break cross-browser login. (`aud` + one-time are what protect you instead.)

---

## Endpoint reference

### `GET /authorize` (browser navigates here)
Query: `redirect_uri` (required, https or localhost), `state` (recommended),
`scope` (optional), `code_challenge` + `code_challenge_method` (optional PKCE),
`client_id` (optional, only for registered confidential clients). On success,
302 → `redirect_uri?code=<code>&state=<state>`.

### `POST /token` (server-to-server)
Body (JSON or form): `code` (required); optional `code_verifier` (PKCE),
`client_id` + `client_secret` (confidential). Response `200`:
```json
{
  "token_type": "Bearer",
  "aud": "https://myapp.com",
  "sub": "123456789",
  "telegram_id": "123456789",
  "first_name": "Os",
  "last_name": null,
  "username": "osk",
  "photo_url": null
}
```
(`id_token` is added only in confidential mode.) Errors: `400 invalid_request`,
`400 invalid_grant` (code wrong/expired/used, or PKCE missing/mismatch),
`401 invalid_client` (confidential: bad/missing secret).

### `GET /healthz` → `{"ok":true}`

---

## Security checklist

- [ ] `redirect_uri` is https (localhost only for dev).
- [ ] You verify `aud === your own origin` before trusting the result. **(most important)**
- [ ] You send `state` and verify it when present (CSRF on the same-browser path).
- [ ] You exchange the `code` server-side, immediately (it's single-use, ~120 s).
- [ ] Your own session cookie is `HttpOnly` + `Secure` + `SameSite=Lax`.
- [ ] (Optional) PKCE or confidential mode for high-value sessions.

## Troubleshooting

| Symptom | Cause / fix |
|---|---|
| `/authorize` → "Недопустимый redirect_uri" | redirect_uri is not https (or not localhost). |
| `/token` → `invalid_grant` | code expired (>120 s), already used, or (PKCE) verifier missing/wrong. |
| `/token` → `invalid_client` | confidential mode: missing/wrong `client_secret`. |
| You logged in as the wrong user / got someone else's session | you didn't check `aud`. Always verify `aud === your origin`. |
| Cross-browser login "loses" the user | you hard-required `state`/PKCE — make `state` conditional (see above). |
