Telegram -> OpenID Connect bridge for Forgejo (deep-link login + notifications)
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alexander Pecheny b11ced2200 Add Forgejo webhook receiver for Telegram notifications
# Why

The bridge already records each user's chat_id at login; this uses it. A
Forgejo system webhook posts to /forgejo-hook and the involved users get a DM,
so notifications need no changes to Forgejo itself (its mailer is disabled).

Routing keys off X-Forgejo-Event-Type rather than X-Forgejo-Event, which
collapses review-comment and PR-comment into one ambiguous value. Recipients
are owner + author + assignees + reviewers minus the actor. Label, milestone,
assign and branch-sync events are dropped as noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:45:27 +00:00
deploy Initial commit: Telegram->OIDC bridge with deep-link login 2026-07-19 18:26:25 +00:00
.gitignore Initial commit: Telegram->OIDC bridge with deep-link login 2026-07-19 18:26:25 +00:00
env.example Add Forgejo webhook receiver for Telegram notifications 2026-07-20 11:45:27 +00:00
go.mod Initial commit: Telegram->OIDC bridge with deep-link login 2026-07-19 18:26:25 +00:00
main.go Add Forgejo webhook receiver for Telegram notifications 2026-07-20 11:45:27 +00:00
notify_test.go Add Forgejo webhook receiver for Telegram notifications 2026-07-20 11:45:27 +00:00
README.md Add Forgejo webhook receiver for Telegram notifications 2026-07-20 11:45:27 +00:00

tg-oidc

Minimal OpenID Connect provider bridging Telegram Login → Forgejo, so code.pecheny.me can offer "Sign in with Telegram". Pure Go standard library, single static binary, no external dependencies.

Telegram's Login Widget is locked to one domain per bot. This instance is also reachable via code.pecheny.kz (a censorship-circumvention alias), so a widget would need a second bot. Instead we use a deep link (t.me/<bot>?start=<sid>), which is domain-agnostic: one bot serves every front-end domain.

As a bonus, deep-link login requires the user to press Start, which is exactly what lets a bot DM them later — so each login records the user's chat_id (OIDC_STORE_PATH), which powers notifications below.

Flow

  1. Forgejo → /authorize → bridge renders a page with a t.me/<bot>?start=<sid> link and a match code; the browser polls /poll?sid=<sid>.
  2. User taps the link → Telegram sends /start <sid> to the bot via webhook.
  3. Bot asks the user to confirm (inline button); the match code lets them verify they're approving their own sign-in (login-CSRF guard).
  4. Confirm → session ready; /poll mints the OAuth code and returns the Forgejo redirect (on whichever domain the user started).
  5. Forgejo /token → RS256 id_token.

Telegram gives no email, so the bridge synthesizes <user>@OIDC_EMAIL_DOMAIN.

Multi-domain

ROOT_URL pins Forgejo's OAuth URLs to code.pecheny.me. The .kz relay rewrites the redirect Location host code.pecheny.mecode.pecheny.kz, so the whole flow stays on .kz. The bridge accepts any redirect host in OIDC_ALT_HOST (allowlisted — prevents open redirects) and returns the user to the host they started on. Forgejo's server-side token call still uses .me; the bridge compares redirect_uri on the canonical host so both legs match.

Notifications

A Forgejo system webhook (type Forgejo, POST /forgejo-hook) turns repo activity into DMs. Forgejo itself needs no patching — only that webhook plus ALLOWED_HOST_LIST = loopback in app.ini.

  • Auth: HMAC-SHA256 of the raw body against FORGEJO_WEBHOOK_SECRET, read from X-Forgejo-Signature. Unset secret ⇒ the endpoint 503s.
  • Events: issues and PRs opened/closed/reopened/merged, comments, and reviews. Routing keys off X-Forgejo-Event-Type, not X-Forgejo-Event — the latter collapses review kinds into an ambiguous pull_request_comment. Label, milestone, assign and branch-sync events are deliberately dropped.
  • Recipients: repo owner, thread author, assignees and requested reviewers, deduped, minus the actor (no self-notifications). Logins with no recorded chat_id are skipped — a user gets DMs only once they've logged in via the bridge.
  • Forgejo's login equals the Telegram @username (it comes from preferred_username), which is what makes the payload→chat_id join work.

Note a merge arrives as action: "closed" with pull_request.merged == true; there is no merged action.

Endpoints

/.well-known/openid-configuration, /jwks.json, /authorize, /poll, /tg-webhook (Telegram, secret-header protected), /forgejo-hook (Forgejo, HMAC-signed), /token, /userinfo, /healthz.

Build & deploy

CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o tg-oidc .
install -o root -g tgoidc -m 750 tg-oidc /opt/telegram-oidc/tg-oidc
# config: see env.example -> /opt/telegram-oidc/tg-oidc.env
# service: see deploy/tg-oidc.service
systemctl restart tg-oidc

Mounted behind Caddy at the issuer path (handle_path /-/telegram-oauth/* strips the prefix so the bridge sees root routes). The webhook is (re)registered with Telegram on every start.

Config

See env.example.