feat: sync HTTP callback OIDC setup

This commit is contained in:
A 2026-07-21 18:18:33 +08:00
parent bf72c246b6
commit f53579416e
26 changed files with 557 additions and 133 deletions

View file

@ -164,7 +164,9 @@ TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
# have been generated with `go run ./cmd/telegramloginkeygen -mode init`.
TELESRV_TELEGRAM_LOGIN_ENABLE=false
TELESRV_TELEGRAM_LOGIN_ISSUER=https://telesrv.net
TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP=false
# Set true to permit an HTTP issuer and HTTP registered origins/redirect URIs
# on any hostname or IP address. HTTPS remains the default when false.
TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false
TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json
TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json
TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper

View file

@ -56,10 +56,10 @@ python .\cmd\bots\aiogramecho\echo.py `
## Webhook 模式
telesrv 现在会持久化 webhook 配置,通过跨实例租约投递,并且只在目标返回 2xx
后推进 `update_id`。aiogram 可监听本机 HTTP由 Caddy/Nginx/Tunnel 提供公网 HTTPS
后推进 `update_id`。aiogram 可直接登记 HTTP/HTTPS 域名或 IP也可以由 Caddy/Nginx/Tunnel 提供公网 HTTPS
```powershell
$env:TELESRV_BOT_WEBHOOK_URL = "https://bot.example.com/webhook"
$env:TELESRV_BOT_WEBHOOK_URL = "http://192.0.2.25:8080/webhook"
$env:TELESRV_BOT_WEBHOOK_SECRET = "replace_with_a_random_secret"
python .\cmd\bots\aiogramecho\echo.py `
--mode webhook `
@ -69,8 +69,8 @@ python .\cmd\bots\aiogramecho\echo.py `
--drop-pending
```
公网 URL 必须是 HTTPS端口限 Telegram 标准的 443/80/88/8443;本机监听地址
可以是 HTTP因为 TLS 通常在反向代理终止`secret_token` 会由 telesrv 放入
Webhook URL 可使用任意合法 HTTP/HTTPS 域名或 IP 及 `1..65535` 端口;本机监听地址
也可以直接使用 HTTP`secret_token` 会由 telesrv 放入
`X-Telegram-Bot-Api-Secret-Token`aiogram 会自动校验。若希望进程退出时删除配置,
再加 `--delete-webhook-on-exit`;默认保留配置,以免普通重启造成更新丢窗。

View file

@ -52,7 +52,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--webhook-url",
default=os.getenv("TELESRV_BOT_WEBHOOK_URL", ""),
help="Public HTTPS URL including the webhook path",
help="Public HTTP(S) URL including the webhook path",
)
parser.add_argument(
"--webhook-path",

View file

@ -89,8 +89,8 @@ enable
`/setlogin` 首次创建 client 时只展示一次 OIDC Client Secret不要写进仓库。可用
`/logininfo` 查看 Client ID 和登记结果,或用 `/resetloginsecret` 轮换 secret。
loopback HTTP 仅应配合 telesrv 的显式开发开关使用;测试部署/生产必须换成精确
HTTPS origin
使用 HTTP 域名/IP 时,在 telesrv 配置 `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true`
demo 会接受任意合法 HTTP(S) issuer/public origin不再限制为 loopback
把一次性 secret 和 Client ID 放入进程环境,再启动:

View file

@ -68,10 +68,6 @@ class PendingFlow:
code_verifier: str = ""
def _is_loopback(host: str | None) -> bool:
return host in {"127.0.0.1", "::1", "localhost"}
def normalize_web_base(value: str, *, name: str) -> str:
raw = value.strip().rstrip("/")
parsed = urlsplit(raw)
@ -85,8 +81,6 @@ def normalize_web_base(value: str, *, name: str) -> str:
or parsed.path not in {"", "/"}
):
raise ValueError(f"{name} must be an absolute origin without path, query, or fragment")
if parsed.scheme != "https" and not _is_loopback(parsed.hostname):
raise ValueError(f"{name} must use HTTPS except on loopback")
return f"{parsed.scheme}://{parsed.netloc}"

View file

@ -49,8 +49,14 @@ class LoginDemoHelpersTest(unittest.TestCase):
demo.normalize_web_base("http://127.0.0.1:3000", name="RP"),
"http://127.0.0.1:3000",
)
with self.assertRaises(ValueError):
demo.normalize_web_base("http://rp.example", name="RP")
self.assertEqual(
demo.normalize_web_base("http://192.0.2.25:3000", name="RP"),
"http://192.0.2.25:3000",
)
self.assertEqual(
demo.normalize_web_base("http://rp.example:18080", name="RP"),
"http://rp.example:18080",
)
with self.assertRaises(ValueError):
demo.normalize_web_base("https://rp.example/callback", name="RP")
self.assertEqual(demo.parse_listen("127.0.0.1:3000"), ("127.0.0.1", 3000))

View file

@ -366,7 +366,7 @@ func run(logger *zap.Logger) error {
}
telegramLoginService, err = telegramloginapp.NewService(postgres.NewTelegramLoginStore(pool), codeSealer, telegramloginapp.Config{
Issuer: cfg.TelegramLoginIssuer, AppScheme: cfg.PublicAppScheme,
AllowLoopbackHTTP: cfg.TelegramLoginAllowLoopbackHTTP,
AllowHTTP: cfg.TelegramLoginAllowHTTP,
ClientSecretPepper: clientSecretPepper,
SupportedSigningAlgorithms: signingKeys.ActiveAlgorithms(),
RequestTTL: cfg.TelegramLoginRequestTTL, CodeTTL: cfg.TelegramLoginCodeTTL,
@ -375,7 +375,7 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("initialize telegram login service: %w", err)
}
telegramLoginIDTokens, err = telegramloginapp.NewIDTokenIssuer(signingKeys, telegramloginapp.IDTokenIssuerConfig{
Issuer: cfg.TelegramLoginIssuer, TTL: cfg.TelegramLoginIDTokenTTL,
Issuer: cfg.TelegramLoginIssuer, TTL: cfg.TelegramLoginIDTokenTTL, AllowHTTP: cfg.TelegramLoginAllowHTTP,
})
if err != nil {
return fmt.Errorf("initialize telegram login ID-token issuer: %w", err)
@ -393,7 +393,7 @@ func run(logger *zap.Logger) error {
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
AllowLoopbackHTTP: cfg.TelegramLoginAllowLoopbackHTTP,
AllowHTTP: cfg.TelegramLoginAllowHTTP,
})
if err != nil {
return fmt.Errorf("initialize telegram login HTTP provider: %w", err)

View file

@ -51,7 +51,7 @@ This document describes every setting loaded by `internal/config`. Defaults and
| Setting | Type / code default | Description and constraints |
|---|---|---|
| `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug listener. Empty disables it. Keep loopback-only; use an SSH tunnel for production profiling. |
| `TELESRV_BOT_API_ADDR` | nullable address / empty | Minimal HTTP Bot API listener. Empty disables it. It shares MTProto app/store facts. |
| `TELESRV_BOT_API_ADDR` | nullable address / empty | Minimal HTTP Bot API listener. Empty disables it. It shares MTProto app/store facts. `setWebhook` accepts any valid `http://` or `https://` host/IP and port in `1..65535`. |
| `TELESRV_ADMIN_API_ADDR` | nullable address / empty | In-process Admin write API listener. Empty disables it; production should bind loopback. |
| `TELESRV_ADMIN_API_TOKEN` | secret string / empty | Admin API bearer token. Required when the Admin API is enabled and must match the Admin UI token configuration. |
| `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | Standalone `cmd/telesrv-admin` listen address. |
@ -62,7 +62,189 @@ This document describes every setting loaded by `internal/config`. Defaults and
| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | Automatic app-open scheme on landing pages. Must match patched client registration. `tg`, `http`, and `https` are rejected. |
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. |
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. |
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. |
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist/collectible-gift landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. |
| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | Mount the self-hosted Telegram Login/OIDC provider on `TELESRV_PUBLIC_LINK_WEB_ADDR`. Enabling it requires that listener and all key files below. |
| `TELESRV_TELEGRAM_LOGIN_ISSUER` | absolute origin URL / `TELESRV_PUBLIC_BASE_URL` | Exact public issuer used in discovery and tokens. HTTPS is required by default; paths, credentials, query, and fragment are rejected. The next setting permits any HTTP host/IP. |
| `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP` | bool / `false` | When enabled, permits any valid HTTP issuer, BotFather Web origin, redirect URI, and native HTTP callback, without loopback, subnet, or port restrictions. When disabled, those Web URLs still require HTTPS. |
| `TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE` | path / `data/telegram-login/signing-keys.json` | JOSE private-key ring generated by `cmd/telegramloginkeygen`; active plus retiring public keys are published through JWKS. |
| `TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE` | path / `data/telegram-login/code-keys.json` | AES-256-GCM envelope-key ring for recoverable, one-time authorization codes. |
| `TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE` | path / `data/telegram-login/client-secret-pepper` | Deployment pepper for HMAC-SHA-256 client-secret hashes. The file must contain a base64 encoding of exactly 32 random bytes. |
| `TELESRV_TELEGRAM_LOGIN_REQUEST_TTL` | duration / `5m` | Pending authorization lifetime; bounded to `1m..15m`. |
| `TELESRV_TELEGRAM_LOGIN_CODE_TTL` | duration / `2m` | One-time code lifetime; bounded to `30s..10m`. |
| `TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL` | duration / `1h` | Signed ID-token lifetime; bounded to `1m..24h`. Retiring signing keys must cover this window. |
| `TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS` | comma-separated CIDRs / empty | Only requests whose direct peer is in this list may supply `Forwarded`/`X-Forwarded-*` client metadata. The documented nginx deployment uses `127.0.0.1/32,::1/128`. |
| `TELESRV_TELEGRAM_LOGIN_RETENTION` | duration / `168h` | Retention after terminal request/code/revocation state; bounded to `1h..90d`. |
| `TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL` | duration / `5m` | Retention worker interval; bounded to `10s..1h`. |
| `TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH` | int / `500` | Maximum rows per retention pass; bounded to `1..1000`. |
### 3.1 Complete Telegram Login / OIDC setup
#### 1. Generate `data/telegram-login` once
Run this from the `telesrv` repository root:
```powershell
go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login
Get-ChildItem .\data\telegram-login
```
The same command works on Linux; restrict the generated directory afterward:
```bash
go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login
chmod 0700 data/telegram-login
chmod 0600 data/telegram-login/*
```
Initialization creates the following private files. It never prints key material and refuses to
overwrite an existing `signing-keys.json`, `code-keys.json`, or `client-secret-pepper`:
- `signing-keys.json` plus three `signing-*.pem` files: the manifest and private keys for RS256,
ES256, and EdDSA ID-token signatures;
- `code-keys.json`: the AES-256-GCM envelope-key ring for one-time authorization codes;
- `client-secret-pepper`: a 32-byte deployment pepper used to store and verify OIDC Client Secret
digests.
The repository ignores `data/*` by default. Never put this directory in Git, release archives,
logs, or ordinary backups. All instances must mount the same protected files and restart together
after rotation. Losing the pepper invalidates existing Client Secret verification. Losing a signing
key that is still in its publication window invalidates otherwise-live ID tokens against JWKS.
#### 2. Configure and start the Provider
This example exposes OIDC directly at `http://192.0.2.25:2401`; replace it with the server address
that clients can actually reach. Bind `0.0.0.0:2401` for direct LAN/public access, or keep
`127.0.0.1:2401` when an on-host reverse proxy is the only caller:
```env
TELESRV_PUBLIC_BASE_URL=http://192.0.2.25:2401
TELESRV_PUBLIC_LINK_WEB_ADDR=0.0.0.0:2401
TELESRV_PUBLIC_APP_SCHEME=telesrv
TELESRV_TELEGRAM_LOGIN_ENABLE=true
TELESRV_TELEGRAM_LOGIN_ISSUER=http://192.0.2.25:2401
TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true
TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json
TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json
TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper
```
For HTTPS, set the issuer and public base to the exact HTTPS origin and leave
`TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false`. The issuer becomes the token `iss` and the root of all
discovery endpoints, so its scheme, host, and port must exactly match the address used by relying
parties. Start or restart `telesrv`, then verify the public endpoints:
```powershell
curl.exe http://192.0.2.25:2401/.well-known/openid-configuration
curl.exe http://192.0.2.25:2401/.well-known/jwks.json
curl.exe -I http://192.0.2.25:2401/js/telegram-login.js
```
The discovery `issuer` must equal the configured value, and its `authorization_endpoint`,
`token_endpoint`, and `jwks_uri` must be reachable by the relying party. A reverse proxy must pass
through `/.well-known/openid-configuration`, `/.well-known/jwks.json`, `/auth`, `/auth/status`,
`/token`, `/crossapp`, `/inapp`, `/telegram-login.js`, and `/js/telegram-login.js` unchanged.
#### 3. Create an OIDC Client with the local `@BotFather`
Create a bot with `/newbot` or select an existing bot. In the local `@BotFather`, run `/setlogin`
and choose that bot. Initial setup returns:
- `Client ID`: the bot user ID as a decimal string;
- `Client Secret`: shown once, separate from the Bot API token, and meant to be saved immediately
in a secret manager.
Send each configuration command separately. This example runs the relying party at
`http://192.0.2.30:3000`:
```text
add origin http://192.0.2.30:3000
add redirect http://192.0.2.30:3000/oauth/callback
algorithm RS256
enable
```
An `origin` is an exact Web origin without a path, query, or fragment; it authorizes the JS SDK,
popup CORS, and legacy `login_url`. A `redirect` is the exact full URI that receives an
Authorization Code. Wildcards and prefix matching are not supported. Use `/logininfo` to inspect
status and registrations; use `/setlogin` to add/remove URLs, change the algorithm, or disable the
client; use `/resetloginsecret` to rotate the Client Secret. Available algorithms are RS256,
ES256, EdDSA, and ES256K only when its build/key ring is present. EdDSA and ES256K accept only the
`openid` scope.
#### 4. Integrate a relying party with standard OIDC
Start by loading:
```text
http://192.0.2.25:2401/.well-known/openid-configuration
```
The standard flow is Authorization Code with PKCE S256:
1. Generate random `state`, `nonce`, and PKCE `code_verifier`; derive the S256 `code_challenge`.
2. Open the discovery `authorization_endpoint` with `client_id`, the exact `redirect_uri`,
`response_type=code`, a `scope` containing `openid`, `state`, `nonce`, `code_challenge`, and
`code_challenge_method=S256`.
3. After the user approves in TDesktop/Android, verify `state` at the relying-party callback and
read the one-time code.
4. Server-side, POST `grant_type=authorization_code`, the code, the same `redirect_uri`, and
`code_verifier` to the discovery `token_endpoint`. Confidential clients authenticate with HTTP
Basic or `client_secret_post`.
5. Verify the ID-token signature with the discovery `jwks_uri`, then strictly validate `iss`,
`aud`, `exp`, `nonce`, and a non-empty `sub`. Decoding without signature verification is not
sufficient.
Supported scopes are `openid`, `profile`, `phone`, and `telegram:bot_access`. The provider does not
currently expose UserInfo, refresh tokens, or an introspection endpoint. Browser applications may
load `<issuer>/js/telegram-login.js` for the local JS SDK. A Client Secret must remain server-side.
#### 5. Verify the complete path with the Bedolaga demo
Install the demo dependencies:
```powershell
python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv"
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install `
-r .\cmd\bots\bedolagaformat\requirements.txt
```
Put the Client ID/Secret from step 3 and the same bot's Bot API token only in process environment:
```powershell
$env:TELESRV_BOT_TOKEN = "<bot_id>:<bot_api_secret>"
$env:TELESRV_BOT_API_SERVER = "http://192.0.2.25:8081"
$env:TELESRV_BOT_LOGIN_DEMO = "1"
$env:TELESRV_BOT_LOGIN_ISSUER = "http://192.0.2.25:2401"
$env:TELESRV_BOT_LOGIN_CLIENT_ID = "<Client ID returned by BotFather>"
$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "<one-time OIDC Client Secret>"
$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://192.0.2.30:3000"
$env:TELESRV_BOT_LOGIN_LISTEN = "0.0.0.0:3000"
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo
```
The BotFather origin must equal `TELESRV_BOT_LOGIN_PUBLIC_URL`, and the redirect must equal
`<TELESRV_BOT_LOGIN_PUBLIC_URL>/oauth/callback`. Send `/logindemo` to the bot. The first button
tests Bot API `login_url` plus the HMAC callback; the second page tests the local JS SDK popup and
Authorization Code + PKCE/JWKS. Omitting the Client Secret leaves JS popup verification available
but explicitly disables the server-side code flow.
#### 6. Rotate keys
When rotating a signing key, retain the old public key for at least the configured ID-token TTL
plus ten minutes. Restart all instances together after the operation:
```powershell
go run ./cmd/telegramloginkeygen -mode rotate-signing -algorithm RS256 `
-id-token-ttl 1h -publish-for 2h -dir data/telegram-login
go run ./cmd/telegramloginkeygen -mode rotate-code -dir data/telegram-login
```
Run `rotate-signing` separately for RS256, ES256, or EdDSA. `rotate-code` retains old code keys and
adds a new active key. Do not edit manifests or PEM files manually, and never generate divergent
key rings independently on different instances.
## 4. PostgreSQL, Redis, files, and seed data
@ -95,7 +277,7 @@ The language-pack file manifest is authoritative. To add a language, place `data
| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | Shared window for phone and auth-key issuance limits. |
| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` uses fixed codes; `webhook` generates random SMS codes for login, registration, and phone changes. Both modes first commit the same code to the durable 777000 dialog for existing accounts; Webhook is additive. |
| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | Delivery implementation for login-email and email setup/change codes: `smtp` or `webhook`. Existing-account login-email codes are first mirrored to 777000; setup/change remains provider-only. |
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Must use `http`/`https` and contain no userinfo. |
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / empty | Required when any provider selects `webhook`; see [otp-delivery.md](otp-delivery.md) for the fixed v1 contract. Any valid `http://` or `https://` host/IP and port is accepted; userinfo is rejected. |
| `TELESRV_OTP_WEBHOOK_SECRET` | secret string / empty | Optional HMAC-SHA256 signing secret; enables `X-Telesrv-Signature` when non-empty. |
| `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP timeout; must be positive when Webhook delivery is enabled. |
| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | Enables login-email verification. SMTP settings are required only when the email provider is `smtp`. |
@ -207,6 +389,19 @@ The following fallback keys are accepted from the **process environment only**.
| `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | Idempotent lazy starting Stars balance for all accounts; `0` disables automatic grant. |
| `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | Expired-premium cleanup/push interval. Read paths derive expiry independently. |
| `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | Maximum expired premium rows processed per sweep. |
| `TELESRV_STARGIFT_SWEEP_INTERVAL` | duration / `15s` | Local Star Gift offer/auction lifecycle sweep interval; no blockchain connection is made. |
| `TELESRV_STARGIFT_SWEEP_BATCH` | int / `1000` | Maximum offer/auction/outbox work claimed per lifecycle sweep. |
| `TELESRV_STARGIFT_TON_STARTING_GRANT` | int64 / `10000000000` | Nanoton granted idempotently on a user's first access to the internal telesrv TON ledger; `0` disables it. This is not an on-chain asset. |
| `TELESRV_STARGIFT_TRANSFER_STARS` | int64 / `25` | Stars charged for a collectible transfer; `0` enables the free-transfer RPC. |
| `TELESRV_STARGIFT_DROP_DETAILS_STARS` | int64 / `25` | Stars charged to remove a collectible's original sender/message details. |
| `TELESRV_STARGIFT_OFFER_MIN_STARS` | int / `1` | Minimum Stars offer snapshotted for user-owned collectibles; `0` disables the offer entry point. |
| `TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE` | int / `1000` | Seller share in Stars sales, in permille; the remainder is recorded as platform commission. |
| `TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE` | int / `1000` | Seller share in internal-TON sales, in permille; this affects only the local ledger. |
| `TELESRV_STARGIFT_EXPORT_DELAY` | duration / `0s` | Delay snapshotted into `can_export_at` when a collectible is issued. |
| `TELESRV_STARGIFT_TRANSFER_DELAY` | duration / `0s` | Delay snapshotted into `can_transfer_at`. |
| `TELESRV_STARGIFT_RESELL_DELAY` | duration / `0s` | Delay snapshotted into `can_resell_at`. |
| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | Delay snapshotted into `can_craft_at`. |
| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | Per-input local craft success contribution, capped at 1000 permille. |
## 11. Private calls, group calls, TURN, SFU, and livestream

View file

@ -51,7 +51,7 @@
| 参数 | 类型 / 代码默认值 | 说明与约束 |
|---|---|---|
| `TELESRV_DEBUG_ADDR` | nullable address / `127.0.0.1:6060` | pprof/debug 监听;空值关闭。生产必须保持 loopback通过 SSH 隧道抓取。 |
| `TELESRV_BOT_API_ADDR` | nullable address / 空 | 最小 HTTP Bot API 监听;空值关闭,与 MTProto 共用 app/store 事实。 |
| `TELESRV_BOT_API_ADDR` | nullable address / 空 | 最小 HTTP Bot API 监听;空值关闭,与 MTProto 共用 app/store 事实。`setWebhook` 接受任意合法 `http://``https://` 主机/IP 与 `1..65535` 端口。 |
| `TELESRV_ADMIN_API_ADDR` | nullable address / 空 | 进程内 Admin 写 API空值关闭生产应只监听 loopback。 |
| `TELESRV_ADMIN_API_TOKEN` | secret string / 空 | Admin API bearer token启用 Admin API 时必须显式配置,并与 Admin UI 使用的 token 一致。 |
| `TELESRV_ADMIN_UI_ADDR` | address / `127.0.0.1:2600` | 独立 `cmd/telesrv-admin` 监听地址。 |
@ -62,7 +62,181 @@
| `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | 落地页自动唤起客户端的 scheme必须与 patched 客户端注册值一致;禁止 `tg``http``https`。 |
| `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | username 页面 Web 客户端入口,校验规则同 `TELESRV_PUBLIC_BASE_URL`。 |
| `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | 公开落地页产品名trim 后非空、无控制字符、最多 64 个 Unicode 字符。 |
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 |
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist/collectible gift 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 |
| `TELESRV_TELEGRAM_LOGIN_ENABLE` | bool / `false` | 在 `TELESRV_PUBLIC_LINK_WEB_ADDR` 上挂载自建 Telegram Login/OIDC Provider启用时必须同时配置该 listener 与下列全部密钥文件。 |
| `TELESRV_TELEGRAM_LOGIN_ISSUER` | 绝对 origin URL / `TELESRV_PUBLIC_BASE_URL` | discovery 与 token 使用的精确公开 issuer默认必须 HTTPS禁止 path、credentials、query、fragment。开启下一项后可直接配置任意 HTTP 域名/IP。 |
| `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP` | bool / `false` | 开启后允许任意合法 HTTP issuer、BotFather Web origin、redirect URI 和 native HTTP callback不限制为 loopback也不限制 IP 网段或端口。关闭时这些 Web URL 仍必须 HTTPS。 |
| `TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE` | path / `data/telegram-login/signing-keys.json` | 由 `cmd/telegramloginkeygen` 生成的 JOSE 私钥环JWKS 会发布 active 和仍在退役窗口内的公钥。 |
| `TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE` | path / `data/telegram-login/code-keys.json` | 用于可恢复一次性 authorization code 的 AES-256-GCM envelope key ring。 |
| `TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE` | path / `data/telegram-login/client-secret-pepper` | HMAC-SHA-256 Client Secret 摘要的部署 pepper 文件,内容必须是恰好 32 个随机字节的 base64 编码。 |
| `TELESRV_TELEGRAM_LOGIN_REQUEST_TTL` | duration / `5m` | pending authorization 生命周期,限定 `1m..15m`。 |
| `TELESRV_TELEGRAM_LOGIN_CODE_TTL` | duration / `2m` | 一次性 code 生命周期,限定 `30s..10m`。 |
| `TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL` | duration / `1h` | ID token 生命周期,限定 `1m..24h`;退役签名公钥必须覆盖该窗口。 |
| `TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS` | 逗号分隔 CIDR / 空 | 只有直连 peer 落在该列表时才信任 `Forwarded`/`X-Forwarded-*` 客户端元数据;文档中的单机 nginx 部署使用 `127.0.0.1/32,::1/128`。 |
| `TELESRV_TELEGRAM_LOGIN_RETENTION` | duration / `168h` | terminal request/code/revocation 后的保留期,限定 `1h..90d`。 |
| `TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL` | duration / `5m` | retention worker 周期,限定 `10s..1h`。 |
| `TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH` | int / `500` | 每轮最大清理行数,限定 `1..1000`。 |
### 3.1 Telegram Login / OIDC 完整启用流程
#### 1. 一次性生成 `data/telegram-login`
`telesrv` 仓库根目录执行:
```powershell
go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login
Get-ChildItem .\data\telegram-login
```
Linux 部署也可使用同一命令;生成后应限制目录权限:
```bash
go run ./cmd/telegramloginkeygen -mode init -dir data/telegram-login
chmod 0700 data/telegram-login
chmod 0600 data/telegram-login/*
```
初始化会生成以下私密文件,命令不会把密钥内容输出到终端,并会拒绝覆盖已经存在的
`signing-keys.json``code-keys.json``client-secret-pepper`
- `signing-keys.json` 和三个 `signing-*.pem`RS256、ES256、EdDSA ID token 签名私钥及清单;
- `code-keys.json`:一次性 authorization code 使用的 AES-256-GCM envelope key ring
- `client-secret-pepper`:保存和校验 OIDC Client Secret 摘要时使用的 32 字节部署 pepper。
`data/*` 默认已被仓库 `.gitignore` 排除。不要把该目录放入 Git、发布压缩包、日志或
普通备份;多实例必须挂载同一份受保护的文件,并在轮换后一起重启。丢失 pepper 会让
现有 Client Secret 无法验证,丢失仍在发布窗口内的签名私钥会让尚未过期的 ID token
无法继续通过 JWKS 验证。
#### 2. 配置并启动 Provider
以下示例直接通过 `http://192.0.2.25:2401` 对外提供 OIDC请替换成客户端实际可达的
服务器 IP。直接监听局域网/公网网卡时使用 `0.0.0.0:2401`,仅由同机反向代理转发时
应改回 `127.0.0.1:2401`
```env
TELESRV_PUBLIC_BASE_URL=http://192.0.2.25:2401
TELESRV_PUBLIC_LINK_WEB_ADDR=0.0.0.0:2401
TELESRV_PUBLIC_APP_SCHEME=telesrv
TELESRV_TELEGRAM_LOGIN_ENABLE=true
TELESRV_TELEGRAM_LOGIN_ISSUER=http://192.0.2.25:2401
TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true
TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE=data/telegram-login/signing-keys.json
TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE=data/telegram-login/code-keys.json
TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE=data/telegram-login/client-secret-pepper
```
使用 HTTPS 时,把 `TELESRV_TELEGRAM_LOGIN_ISSUER` 和公开根地址改成精确 HTTPS
origin并保持 `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=false`。issuer 是 token 的 `iss`
以及 discovery 中所有端点的根地址scheme、host 和 port 必须与依赖方访问的地址完全
一致。启动或重启 `telesrv` 后,先验证公开端点:
```powershell
curl.exe http://192.0.2.25:2401/.well-known/openid-configuration
curl.exe http://192.0.2.25:2401/.well-known/jwks.json
curl.exe -I http://192.0.2.25:2401/js/telegram-login.js
```
discovery 返回的 `issuer` 必须等于配置值,`authorization_endpoint``token_endpoint`
`jwks_uri` 必须可从依赖方访问。使用反向代理时需原样转发
`/.well-known/openid-configuration``/.well-known/jwks.json``/auth``/auth/status`
`/token``/crossapp``/inapp``/telegram-login.js``/js/telegram-login.js`
#### 3. 用本服 `@BotFather` 创建 OIDC Client
先用 `/newbot` 创建或选择已有 bot然后在本服 `@BotFather` 中执行 `/setlogin` 并选择
该 bot。首次配置会返回
- `Client ID`bot user ID 的十进制字符串;
- `Client Secret`:只显示一次,与 Bot API token 不同,必须立即保存到密钥管理系统。
接着逐条发送配置命令。下面假设依赖方页面运行在 `http://192.0.2.30:3000`
```text
add origin http://192.0.2.30:3000
add redirect http://192.0.2.30:3000/oauth/callback
algorithm RS256
enable
```
`origin` 只能是无 path/query/fragment 的精确 Web origin用于 JS SDK、popup CORS 和
legacy `login_url``redirect` 是 Authorization Code Flow 返回 code 的精确完整 URI。
不支持 wildcard 或 prefix 匹配。用 `/logininfo` 检查状态和登记值;用 `/setlogin`
增删 URL、切换签名算法或 disable`/resetloginsecret` 轮换 Client Secret。可用的
签名算法为 RS256、ES256、EdDSA以及仅在对应构建和 key ring 已提供时可选的 ES256K
EdDSA/ES256K 只允许 `openid` scope。
#### 4. 依赖方接入标准 OIDC
依赖方应首先读取:
```text
http://192.0.2.25:2401/.well-known/openid-configuration
```
标准流程为 Authorization Code + PKCE S256
1. 生成随机 `state``nonce` 和 PKCE `code_verifier`,计算 S256 `code_challenge`
2. 浏览器打开 discovery 中的 `authorization_endpoint`,携带 `client_id`、精确
`redirect_uri``response_type=code`、包含 `openid``scope``state``nonce`
`code_challenge``code_challenge_method=S256`
3. 用户在 TDesktop/Android 中确认后,依赖方 callback 校验 `state` 并取得一次性 code
4. 服务端向 discovery 中的 `token_endpoint` POST `grant_type=authorization_code`、code、
同一 `redirect_uri``code_verifier`,机密 client 使用 HTTP Basic 或
`client_secret_post` 提交 Client Secret
5. 用 discovery 的 `jwks_uri` 验证 ID token 签名,并严格校验 `iss``aud``exp`
`nonce` 和非空 `sub`。不要只解码而不验签。
支持的 scope 为 `openid``profile``phone``telegram:bot_access`。当前不提供
UserInfo、refresh token 或 introspection endpoint。浏览器前端可以加载
`<issuer>/js/telegram-login.js` 使用本地 JS SDKClient Secret 只能留在服务端。
#### 5. 使用 Bedolaga demo 验证完整链路
安装 demo 依赖:
```powershell
python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv"
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install `
-r .\cmd\bots\bedolagaformat\requirements.txt
```
将第 3 步得到的 Client ID/Secret 和同一个 Bot API token 仅放入进程环境:
```powershell
$env:TELESRV_BOT_TOKEN = "<bot_id>:<bot_api_secret>"
$env:TELESRV_BOT_API_SERVER = "http://192.0.2.25:8081"
$env:TELESRV_BOT_LOGIN_DEMO = "1"
$env:TELESRV_BOT_LOGIN_ISSUER = "http://192.0.2.25:2401"
$env:TELESRV_BOT_LOGIN_CLIENT_ID = "<BotFather 返回的 Client ID>"
$env:TELESRV_BOT_LOGIN_CLIENT_SECRET = "<只显示一次的 OIDC Client Secret>"
$env:TELESRV_BOT_LOGIN_PUBLIC_URL = "http://192.0.2.30:3000"
$env:TELESRV_BOT_LOGIN_LISTEN = "0.0.0.0:3000"
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py --drop-pending --login-demo
```
确保 BotFather 登记的 origin 等于 `TELESRV_BOT_LOGIN_PUBLIC_URL`redirect 等于
`<TELESRV_BOT_LOGIN_PUBLIC_URL>/oauth/callback`。在客户端向 bot 发送 `/logindemo`:第一颗
按钮验证 Bot API `login_url` 和 HMAC 回调,第二颗按钮页面分别验证本地 JS SDK popup
以及 Authorization Code + PKCE/JWKS。省略 Client Secret 时只能验证 JS popup服务端
code flow 会明确禁用。
#### 6. 密钥轮换
签名 key 轮换时,旧公钥发布窗口必须至少覆盖配置的 ID token TTL 再加 10 分钟;操作
完成后所有实例一起重启:
```powershell
go run ./cmd/telegramloginkeygen -mode rotate-signing -algorithm RS256 `
-id-token-ttl 1h -publish-for 2h -dir data/telegram-login
go run ./cmd/telegramloginkeygen -mode rotate-code -dir data/telegram-login
```
`rotate-signing` 可分别用于 RS256、ES256、EdDSA`rotate-code` 保留旧 code key 并新增
active key。不要手工编辑 manifest 或 PEM不要在各实例上分别生成不一致的 key ring。
## 4. PostgreSQL、Redis、文件与 seed
@ -95,7 +269,7 @@
| `TELESRV_AUTH_CODE_RATE_WINDOW` | duration / `10m` | 手机号与 auth-key 发码限流共用窗口。 |
| `TELESRV_PHONE_CODE_DELIVERY_PROVIDER` | enum / `development` | `development` 使用固定码;`webhook` 为登录、注册、改号生成随机 SMS code 并调用 OTP Webhook。已有账号在两种模式下都先 durable 写入同码 777000 消息Webhook 只是附加渠道。 |
| `TELESRV_EMAIL_CODE_DELIVERY_PROVIDER` | enum / `smtp` | 登录邮箱、邮箱 setup/change 的投递实现:`smtp``webhook`。已有账号的登录邮箱码会先同码镜像到 777000邮箱 setup/change 仍只走 provider。 |
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。只允许 `http`/`https`不得含 userinfo。 |
| `TELESRV_OTP_WEBHOOK_URL` | absolute URL / 空 | 任一 provider 选择 `webhook` 时必填;固定 v1 协议见 [otp-delivery.md](otp-delivery.md)。允许任意合法 `http://``https://` 主机/IP 与端口,不得含 userinfo。 |
| `TELESRV_OTP_WEBHOOK_SECRET` | secret string / 空 | 可选 HMAC-SHA256 签名密钥;非空时发送 `X-Telesrv-Signature`。 |
| `TELESRV_OTP_WEBHOOK_TIMEOUT` | duration / `5s` | Webhook HTTP 请求超时,启用 Webhook 时必须为正数。 |
| `TELESRV_LOGIN_EMAIL_ENABLE` | bool / `false` | 启用登录邮箱验证码email provider 为 `smtp` 时要求 SMTP 配置,`webhook` 时不依赖 SMTP。 |
@ -207,6 +381,19 @@
| `TELESRV_STARS_STARTING_GRANT` | int64 / `1000` | 对所有账号幂等惰性授予的 Stars 起始余额;`0` 关闭自动赠送。 |
| `TELESRV_PREMIUM_SWEEP_INTERVAL` | duration / `1m` | 过期 Premium 清理/推送周期;读取路径独立即时派生到期状态。 |
| `TELESRV_PREMIUM_SWEEP_BATCH` | int / `500` | 单次 sweep 最大处理行数。 |
| `TELESRV_STARGIFT_SWEEP_INTERVAL` | duration / `15s` | Star Gift 报价/竞拍本地生命周期清扫周期;不会连接区块链。 |
| `TELESRV_STARGIFT_SWEEP_BATCH` | int / `1000` | 单次礼物生命周期清扫最多处理的报价、竞拍与 outbox 工作量。 |
| `TELESRV_STARGIFT_TON_STARTING_GRANT` | int64 / `10000000000` | 每个用户首次访问 telesrv 内部 TON 账本时幂等授予的 nanoton`0` 关闭赠送。它不是链上资产。 |
| `TELESRV_STARGIFT_TRANSFER_STARS` | int64 / `25` | collectible 转赠费用;设为 `0` 时使用免费转赠 RPC。 |
| `TELESRV_STARGIFT_DROP_DETAILS_STARS` | int64 / `25` | 移除 collectible 原始发送者/附言信息所需 Stars。 |
| `TELESRV_STARGIFT_OFFER_MIN_STARS` | int / `1` | collectible 签发时固化的用户持有礼物最低 Stars 报价;`0` 不开放报价入口。 |
| `TELESRV_STARGIFT_STARS_PROCEEDS_PERMILLE` | int / `1000` | Stars 成交时卖方实收比例(千分比);差额作为平台佣金写入成交记录。 |
| `TELESRV_STARGIFT_TON_PROCEEDS_PERMILLE` | int / `1000` | 内部 TON 成交时卖方实收比例(千分比);只影响本地账本。 |
| `TELESRV_STARGIFT_EXPORT_DELAY` | duration / `0s` | collectible 签发时固化到 `can_export_at` 的等待期。 |
| `TELESRV_STARGIFT_TRANSFER_DELAY` | duration / `0s` | 签发时固化到 `can_transfer_at` 的等待期。 |
| `TELESRV_STARGIFT_RESELL_DELAY` | duration / `0s` | 签发时固化到 `can_resell_at` 的等待期。 |
| `TELESRV_STARGIFT_CRAFT_DELAY` | duration / `0s` | 签发时固化到 `can_craft_at` 的等待期;可 Craft 礼物即使为 `0s` 也写升级时间这一正数能力边界0 只表示不具备 Craft 能力或已终结。 |
| `TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE` | int / `250` | 每份输入礼物贡献的本地合成成功概率,累计上限 1000‰。 |
## 11. 私聊通话、群通话、TURN、SFU 与直播

View file

@ -22,7 +22,7 @@ func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
pepper := make([]byte, 32)
pepper[0] = 2
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
Issuer: "http://localhost:2404", AppScheme: "telesrv", AllowLoopbackHTTP: true,
Issuer: "http://192.0.2.25:2404", AppScheme: "telesrv", AllowHTTP: true,
ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
})
if err != nil {
@ -52,13 +52,13 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
if len(secret) < 32 {
t.Fatalf("client secret is unexpectedly short: %q", secret)
}
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://localhost:3000"); !strings.Contains(reply, "Success!") {
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test:3000"); !strings.Contains(reply, "Success!") {
t.Fatalf("add origin reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://localhost:3000/auth/callback"); !strings.Contains(reply, "Success!") {
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") {
t.Fatalf("add redirect reply = %q", reply)
}
@ -83,7 +83,7 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
sendToBotFather(t, svc, messages, owner, "/logininfo")
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://localhost:3000", "redirect_uri http://localhost:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://rp.example.test:3000", "redirect_uri http://192.0.2.26:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
if !strings.Contains(info, want) {
t.Fatalf("login info = %q, missing %q", info, want)
}

View file

@ -296,6 +296,7 @@ type IDTokenIssuerConfig struct {
Issuer string
TTL time.Duration
Now func() time.Time
AllowHTTP bool
}
type IDTokenIssuer struct {
@ -337,7 +338,7 @@ func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIs
if keys == nil {
return nil, errors.New("telegram login signing key ring is required")
}
issuer, err := NormalizeWebOrigin(cfg.Issuer, true)
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP)
if err != nil {
return nil, fmt.Errorf("telegram login ID token issuer: %w", err)
}

View file

@ -167,6 +167,21 @@ func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) {
}
}
func TestIDTokenIssuerAcceptsHTTPIPOnlyWhenEnabled(t *testing.T) {
now := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC)
ring := telegramLoginTestSigningKeys(t, &now)
if _, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401"}); err == nil {
t.Fatal("HTTP issuer was accepted while AllowHTTP was false")
}
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401", AllowHTTP: true})
if err != nil {
t.Fatal(err)
}
if issuer.Issuer() != "http://192.0.2.25:2401" {
t.Fatalf("issuer=%q", issuer.Issuer())
}
}
func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) {
p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {

View file

@ -45,7 +45,7 @@ func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform,
// non-web custom scheme registered for a native application. Query and
// fragment components are forbidden because OAuth response fields are
// appended by the provider and must not collide with application input.
func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, error) {
func NormalizeNativeCallbackURI(raw string, allowHTTP bool) (string, error) {
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", domain.ErrTelegramLoginURLInvalid
}
@ -54,7 +54,7 @@ func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, err
return "", domain.ErrTelegramLoginURLInvalid
}
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
normalized, _, err := NormalizeRedirectURI(raw, allowLoopbackHTTP)
normalized, _, err := NormalizeRedirectURI(raw, allowHTTP)
return normalized, err
}
scheme := strings.ToLower(u.Scheme)

View file

@ -36,7 +36,7 @@ var telegramLoginMatchCodePool = []string{
type Config struct {
Issuer string
AppScheme string
AllowLoopbackHTTP bool
AllowHTTP bool
ClientSecretPepper []byte
SupportedSigningAlgorithms []domain.TelegramLoginSigningAlgorithm
RequestTTL time.Duration
@ -49,7 +49,7 @@ type Service struct {
sealer *CodeSealer
issuer string
appScheme string
allowLoopbackHTTP bool
allowHTTP bool
clientSecretPepper []byte
signingAlgorithms []domain.TelegramLoginSigningAlgorithm
signingAlgorithmSet map[domain.TelegramLoginSigningAlgorithm]struct{}
@ -62,7 +62,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con
if loginStore == nil || sealer == nil || len(cfg.ClientSecretPepper) < 32 {
return nil, fmt.Errorf("telegram login dependencies are incomplete")
}
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowLoopbackHTTP)
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP)
if err != nil {
return nil, fmt.Errorf("telegram login issuer: %w", err)
}
@ -93,7 +93,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con
}
return &Service{
store: loginStore, sealer: sealer, issuer: issuer, appScheme: strings.ToLower(cfg.AppScheme),
allowLoopbackHTTP: cfg.AllowLoopbackHTTP,
allowHTTP: cfg.AllowHTTP,
clientSecretPepper: append([]byte(nil), cfg.ClientSecretPepper...),
signingAlgorithms: append([]domain.TelegramLoginSigningAlgorithm(nil), cfg.SupportedSigningAlgorithms...),
signingAlgorithmSet: signingAlgorithmSet,
@ -248,9 +248,9 @@ func (s *Service) AddAllowedURL(ctx context.Context, botUserID int64, kind domai
var err error
switch kind {
case domain.TelegramLoginAllowedWebOrigin:
normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP)
normalized, err = NormalizeWebOrigin(raw, s.allowHTTP)
case domain.TelegramLoginAllowedRedirectURI:
normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP)
normalized, _, err = NormalizeRedirectURI(raw, s.allowHTTP)
default:
err = domain.ErrTelegramLoginURLInvalid
}
@ -267,9 +267,9 @@ func (s *Service) DeleteAllowedURL(ctx context.Context, botUserID int64, kind do
var err error
switch kind {
case domain.TelegramLoginAllowedWebOrigin:
normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP)
normalized, err = NormalizeWebOrigin(raw, s.allowHTTP)
case domain.TelegramLoginAllowedRedirectURI:
normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP)
normalized, _, err = NormalizeRedirectURI(raw, s.allowHTTP)
default:
err = domain.ErrTelegramLoginURLInvalid
}
@ -311,7 +311,7 @@ func (s *Service) AddNativeApp(ctx context.Context, botUserID int64, platform do
if err != nil {
return domain.TelegramLoginNativeApp{}, err
}
callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowLoopbackHTTP)
callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowHTTP)
if err != nil {
return domain.TelegramLoginNativeApp{}, err
}
@ -335,7 +335,7 @@ func (s *Service) DeleteNativeApp(ctx context.Context, botUserID, appID int64) (
}
func (s *Service) matchNativeApp(ctx context.Context, botUserID int64, platform domain.TelegramLoginNativePlatform, rawCallbackURI string) (domain.TelegramLoginNativeApp, string, bool, error) {
callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowLoopbackHTTP)
callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowHTTP)
if err != nil {
return domain.TelegramLoginNativeApp{}, "", false, nil
}
@ -362,7 +362,7 @@ func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, ra
if !found || !client.Enabled {
return "", "", domain.ErrTelegramLoginClientDisabled
}
normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowLoopbackHTTP)
normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowHTTP)
if err != nil {
return "", "", err
}
@ -370,7 +370,7 @@ func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, ra
if err != nil {
return "", "", domain.ErrTelegramLoginURLInvalid
}
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowHTTP)
if err != nil {
return "", "", err
}
@ -415,7 +415,7 @@ func (s *Service) AuthorizeMessageButton(ctx context.Context, params domain.Tele
if err != nil {
return domain.TelegramLoginMessageButtonResult{}, domain.ErrTelegramLoginURLInvalid
}
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowHTTP)
if err != nil {
return domain.TelegramLoginMessageButtonResult{}, err
}
@ -552,7 +552,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
}
var allowed, isApp bool
var nativeApp domain.TelegramLoginNativeApp
redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP)
redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowHTTP)
if params.ResponseType == "code" && redirectErr == nil && !params.NativePlatform.Valid() {
allowed, err = s.store.IsTelegramLoginURLAllowed(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
if err != nil {
@ -603,14 +603,14 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
u, _ := url.Parse(redirectURI)
origin = u.Scheme + "://" + u.Host
}
origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP)
origin, err = NormalizeWebOrigin(origin, s.allowHTTP)
if err != nil {
return CreatedAuthorization{}, err
}
}
if params.ResponseType == "post_message" {
redirectURL, _ := url.Parse(redirectURI)
redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowLoopbackHTTP)
redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowHTTP)
if redirectOriginErr != nil || redirectOrigin != origin {
return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
@ -627,7 +627,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
if isApp {
return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowLoopbackHTTP)
inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowHTTP)
if err != nil {
return CreatedAuthorization{}, err
}
@ -693,7 +693,7 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
redirectURI, safe, err := s.safeCodeRedirect(ctx, client.BotUserID, rawRedirectURI)
return AuthorizationErrorTarget{ResponseType: responseType, RedirectURI: redirectURI}, safe, err
case "post_message":
redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowLoopbackHTTP)
redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowHTTP)
if err != nil {
return AuthorizationErrorTarget{}, false, nil
}
@ -702,12 +702,12 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
redirect, _ := url.Parse(redirectURI)
origin = redirect.Scheme + "://" + redirect.Host
}
origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP)
origin, err = NormalizeWebOrigin(origin, s.allowHTTP)
if err != nil {
return AuthorizationErrorTarget{}, false, nil
}
redirect, _ := url.Parse(redirectURI)
redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowLoopbackHTTP)
redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowHTTP)
if err != nil || redirectOrigin != origin {
return AuthorizationErrorTarget{}, false, nil
}
@ -725,7 +725,7 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
}
func (s *Service) safeCodeRedirect(ctx context.Context, botUserID int64, raw string) (string, bool, error) {
if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowLoopbackHTTP); err == nil {
if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowHTTP); err == nil {
allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, botUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
if err != nil || allowed {
return redirectURI, allowed, err
@ -856,7 +856,7 @@ func (s *Service) RequestByDeepLinkForOrigin(ctx context.Context, rawURL, rawOri
if rawOrigin == "" || request.InAppOrigin == "" {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginOriginNotAllowed
}
origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP)
if err != nil {
return domain.TelegramLoginRequest{}, err
}
@ -1067,7 +1067,7 @@ func (s *Service) ExchangeInAppTokenAndIssue(ctx context.Context, token, rawOrig
if issuer == nil || len(token) < 16 || len(token) > 1024 || strings.IndexFunc(token, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 {
return IssuedAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP)
if err != nil {
return IssuedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
@ -1294,7 +1294,7 @@ func (s *Service) exchangeAuthorizationCode(ctx context.Context, params Exchange
return ExchangedAuthorization{}, "", domain.ErrTelegramLoginCodeInvalid
}
} else {
redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP)
redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowHTTP)
if err != nil {
return ExchangedAuthorization{}, "", err
}

View file

@ -94,7 +94,7 @@ func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, alg
pepper[0] = 9
service, err := NewService(loginStore, sealer, Config{
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv",
AllowLoopbackHTTP: true, ClientSecretPepper: pepper,
AllowHTTP: true, ClientSecretPepper: pepper,
SupportedSigningAlgorithms: algorithms,
Now: func() time.Time { return *now },
})

View file

@ -14,8 +14,8 @@ import (
const maxTelegramLoginURLLength = 4096
func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domainName string, err error) {
u, err := parseWebURL(raw, allowLoopbackHTTP)
func NormalizeRedirectURI(raw string, allowHTTP bool) (normalized, domainName string, err error) {
u, err := parseWebURL(raw, allowHTTP)
if err != nil {
return "", "", err
}
@ -34,8 +34,8 @@ func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domai
return u.String(), u.Hostname(), nil
}
func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) {
u, err := parseWebURL(raw, allowLoopbackHTTP)
func NormalizeWebOrigin(raw string, allowHTTP bool) (string, error) {
u, err := parseWebURL(raw, allowHTTP)
if err != nil {
return "", err
}
@ -46,7 +46,7 @@ func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) {
return u.String(), nil
}
func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
func parseWebURL(raw string, allowHTTP bool) (*url.URL, error) {
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return nil, domain.ErrTelegramLoginURLInvalid
}
@ -78,7 +78,7 @@ func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
port = ""
}
case "http":
if !allowLoopbackHTTP || !isLoopbackHost(host) {
if !allowHTTP {
return nil, domain.ErrTelegramLoginURLInvalid
}
if port == "80" {
@ -99,14 +99,6 @@ func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
return u, nil
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func AppendAuthorizationResult(redirectURI, code, state string) (string, error) {
u, err := url.Parse(redirectURI)
if err != nil || !u.IsAbs() || code == "" {

View file

@ -18,8 +18,9 @@ func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) {
}{
{name: "https canonical", raw: "https://EXAMPLE.com:443/callback?tenant=one", want: "https://example.com/callback?tenant=one", valid: true},
{name: "idna", raw: "https://例子.测试/callback", want: "https://xn--fsqu00a.xn--0zwm56d/callback", valid: true},
{name: "loopback dev", raw: "http://127.0.0.1:8080/callback", allowHTTP: true, want: "http://127.0.0.1:8080/callback", valid: true},
{name: "http production", raw: "http://example.com/callback"},
{name: "http hostname enabled", raw: "http://example.com:8080/callback", allowHTTP: true, want: "http://example.com:8080/callback", valid: true},
{name: "http ipv4 enabled", raw: "http://192.0.2.25:3000/callback", allowHTTP: true, want: "http://192.0.2.25:3000/callback", valid: true},
{name: "http disabled", raw: "http://example.com/callback"},
{name: "userinfo", raw: "https://user@example.com/callback"},
{name: "fragment", raw: "https://example.com/callback#token"},
{name: "reserved code", raw: "https://example.com/callback?code=attacker"},
@ -64,19 +65,19 @@ func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) {
}
}
func TestNormalizeLoopbackIPv6PreservesURLBrackets(t *testing.T) {
origin, err := NormalizeWebOrigin("http://[0:0:0:0:0:0:0:1]:80/", true)
func TestNormalizeHTTPIPv6PreservesURLBrackets(t *testing.T) {
origin, err := NormalizeWebOrigin("http://[2001:db8::25]:80/", true)
if err != nil {
t.Fatal(err)
}
if origin != "http://[0:0:0:0:0:0:0:1]" {
if origin != "http://[2001:db8::25]" {
t.Fatalf("origin=%q", origin)
}
redirect, domainName, err := NormalizeRedirectURI("http://[::1]/callback", true)
redirect, domainName, err := NormalizeRedirectURI("http://[2001:db8::26]:3000/callback", true)
if err != nil {
t.Fatal(err)
}
if redirect != "http://[::1]/callback" || domainName != "::1" {
if redirect != "http://[2001:db8::26]:3000/callback" || domainName != "2001:db8::26" {
t.Fatalf("redirect=%q domain=%q", redirect, domainName)
}
}

View file

@ -1077,11 +1077,18 @@ func validateWebhookURL(raw string) error {
return errors.New("WEBHOOK_URL_INVALID")
}
u, err := neturl.ParseRequestURI(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
if err != nil {
return errors.New("WEBHOOK_URL_INVALID")
}
scheme := strings.ToLower(u.Scheme)
if (scheme != "http" && scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
return errors.New("WEBHOOK_URL_INVALID")
}
if port := u.Port(); port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return errors.New("WEBHOOK_URL_INVALID")
}
if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" {
return errors.New("WEBHOOK_PORT_NOT_ALLOWED")
}
return nil
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
@ -977,6 +978,23 @@ func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T
}
}
func TestSetWebhookAcceptsHTTPHostIPAndArbitraryPort(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
for _, rawURL := range []string{
"http://bot.example.test:3000/hook",
"http://192.0.2.25:18080/hook",
"http://[2001:db8::25]:28080/hook",
"HTTP://bot.example.test:3100/hook",
} {
gateway := &fakeBotAPIGateway{}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", fmt.Sprintf(`{"url":%q}`, rawURL))
if rec.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != rawURL {
t.Fatalf("setWebhook url=%q status=%d body=%s config=%#v", rawURL, rec.Code, rec.Body.String(), gateway.webhook)
}
}
}
func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
@ -984,8 +1002,9 @@ func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
body string
want string
}{
{`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"},
{`{"url":"ftp://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"http://user@example.test/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"http://example.test:0/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"},
{`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"},
}

View file

@ -4,7 +4,6 @@ package config
import (
"bufio"
"fmt"
"net"
"net/netip"
"net/url"
"os"
@ -95,7 +94,9 @@ type Config struct {
// process listings or accidentally copied into tracked .env templates.
TelegramLoginEnabled bool
TelegramLoginIssuer string
TelegramLoginAllowLoopbackHTTP bool
// TelegramLoginAllowHTTP permits HTTP issuers and registered Login URLs on
// any valid host/IP and port. HTTPS remains mandatory when false.
TelegramLoginAllowHTTP bool
TelegramLoginSigningKeysFile string
TelegramLoginCodeKeysFile string
TelegramLoginSecretPepperFile string
@ -491,7 +492,7 @@ func Load() (Config, error) {
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
TelegramLoginAllowLoopbackHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", false),
TelegramLoginAllowHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", false),
TelegramLoginSigningKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "data/telegram-login/signing-keys.json"),
TelegramLoginCodeKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "data/telegram-login/code-keys.json"),
TelegramLoginSecretPepperFile: envOr("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "data/telegram-login/client-secret-pepper"),
@ -681,10 +682,8 @@ func validateTelegramLoginConfig(cfg Config) error {
switch issuer.Scheme {
case "https":
case "http":
host := issuer.Hostname()
ip := net.ParseIP(host)
if !cfg.TelegramLoginAllowLoopbackHTTP || (host != "localhost" && (ip == nil || !ip.IsLoopback())) {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http is allowed only for explicit loopback development")
if !cfg.TelegramLoginAllowHTTP {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http requires TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true")
}
default:
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must use https")

View file

@ -435,8 +435,8 @@ func TestLoadTelegramLoginConfig(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "127.0.0.1:2401")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ENABLE", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://127.0.0.1:2401/")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://192.0.2.25:2401/")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "secrets/signing.json")
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "secrets/codes.json")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "secrets/pepper")
@ -452,8 +452,8 @@ func TestLoadTelegramLoginConfig(t *testing.T) {
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://127.0.0.1:2401" || !cfg.TelegramLoginAllowLoopbackHTTP {
t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q loopback:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowLoopbackHTTP)
if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://192.0.2.25:2401" || !cfg.TelegramLoginAllowHTTP {
t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q allow_http:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowHTTP)
}
if cfg.TelegramLoginSigningKeysFile != "secrets/signing.json" || cfg.TelegramLoginCodeKeysFile != "secrets/codes.json" || cfg.TelegramLoginSecretPepperFile != "secrets/pepper" {
t.Fatalf("telegram login secret files = %q / %q / %q", cfg.TelegramLoginSigningKeysFile, cfg.TelegramLoginCodeKeysFile, cfg.TelegramLoginSecretPepperFile)
@ -484,11 +484,7 @@ func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.
}{
{name: "missing listener", mutate: func(c *Config) { c.PublicLinkWebAddr = "" }},
{name: "issuer path", mutate: func(c *Config) { c.TelegramLoginIssuer = "https://login.example.test/oauth" }},
{name: "public http", mutate: func(c *Config) {
c.TelegramLoginIssuer = "http://login.example.test"
c.TelegramLoginAllowLoopbackHTTP = true
}},
{name: "loopback http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://127.0.0.1:2401" }},
{name: "http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://192.0.2.25:2401" }},
{name: "missing key file", mutate: func(c *Config) { c.TelegramLoginSigningKeysFile = "" }},
{name: "request ttl too long", mutate: func(c *Config) { c.TelegramLoginRequestTTL = 16 * time.Minute }},
{name: "code ttl too short", mutate: func(c *Config) { c.TelegramLoginCodeTTL = 29 * time.Second }},
@ -508,6 +504,22 @@ func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.
}
}
func TestValidateTelegramLoginConfigAcceptsHTTPHostAndIPWhenEnabled(t *testing.T) {
valid := Config{
TelegramLoginEnabled: true, TelegramLoginAllowHTTP: true, PublicLinkWebAddr: "127.0.0.1:2401",
TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper",
TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour,
TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500,
}
for _, issuer := range []string{"http://login.example.test:3000", "http://192.0.2.25:2401", "http://[2001:db8::25]:2401"} {
cfg := valid
cfg.TelegramLoginIssuer = issuer
if err := validateTelegramLoginConfig(cfg); err != nil {
t.Fatalf("issuer %q was rejected: %v", issuer, err)
}
}
}
func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one")

View file

@ -2,7 +2,6 @@ package domain
import (
"errors"
"net"
"net/url"
"strings"
"unicode/utf8"
@ -397,8 +396,9 @@ func validateButtonURL(raw string) error {
// validateLoginButtonURL performs only the protocol-shape validation shared by
// Bot API and MTProto input buttons. The Telegram Login service remains the
// authority for the deployment policy: it rejects loopback HTTP unless the
// explicit development switch is enabled and the exact origin is registered.
// authority for the deployment policy: HTTP is accepted here as a protocol
// shape, then allowed only when the Login HTTP switch is enabled and the exact
// origin is registered.
func validateLoginButtonURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
@ -408,15 +408,8 @@ func validateLoginButtonURL(raw string) error {
if err != nil || u.Host == "" || u.User != nil {
return ErrButtonURLInvalid
}
if u.Scheme == "https" {
return nil
}
if u.Scheme != "http" {
return ErrButtonURLInvalid
}
host := strings.ToLower(u.Hostname())
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return ErrButtonURLInvalid
}
return nil

View file

@ -28,7 +28,8 @@ func TestValidateReplyMarkup(t *testing.T) {
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
{"login url loopback http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://127.0.0.1:8080/login"}}}}, nil},
{"login url localhost http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://localhost:8080/login"}}}}, nil},
{"login url public http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com/login"}}}}, ErrButtonURLInvalid},
{"login url public http host ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com:3000/login"}}}}, nil},
{"login url public http ip ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://192.0.2.25:18080/login"}}}}, nil},
{"login url credentials bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "https://user@example.com/login"}}}}, ErrButtonURLInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},

View file

@ -261,7 +261,7 @@ func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T)
pepper[0] = 8
loginStore := memory.NewTelegramLoginStore(telegramLoginBotPermissionAdapter{bots: f.router.deps.Bots})
login, err := telegramloginapp.NewService(loginStore, sealer, telegramloginapp.Config{
Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
Issuer: "http://192.0.2.25:2401", AppScheme: "telesrv", AllowHTTP: true, ClientSecretPepper: pepper,
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
})
if err != nil {
@ -270,13 +270,13 @@ func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T)
if _, err := login.CreateClient(f.ctx, f.bot.ID, domain.TelegramLoginSigningRS256); err != nil {
t.Fatal(err)
}
if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil {
if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "http://rp.test:3000"); err != nil {
t.Fatal(err)
}
f.router.deps.TelegramLogin = login
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login?next=%2Fhome", RequestWriteAccess: true,
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "http://rp.test:3000/login?next=%2Fhome", RequestWriteAccess: true,
}}}}
if _, err := f.router.BotAPISendMessage(f.ctx, f.bot.ID, f.owner.ID, "Authorize", nil, markup, false, false, 0); err != nil {
t.Fatalf("BotAPISendMessage: %v", err)

View file

@ -40,7 +40,7 @@ type Config struct {
AppName string
Logger *zap.Logger
TrustedProxyCIDRs []string
AllowLoopbackHTTP bool
AllowHTTP bool
}
type Handler struct {
@ -50,7 +50,7 @@ type Handler struct {
logger *zap.Logger
limiter RateLimiter
trustedProxies []netip.Prefix
allowLoopbackHTTP bool
allowHTTP bool
mux *http.ServeMux
}
@ -76,7 +76,7 @@ func NewHandler(cfg Config) (*Handler, error) {
}
trustedProxies = append(trustedProxies, prefix.Masked())
}
h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowLoopbackHTTP: cfg.AllowLoopbackHTTP}
h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowHTTP: cfg.AllowHTTP}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", h.discovery)
mux.HandleFunc("GET /.well-known/jwks.json", h.jwks)
@ -333,7 +333,7 @@ func (h *Handler) inApp(w http.ResponseWriter, r *http.Request) {
writeOAuthError(w, http.StatusBadRequest, "unsupported_response_type", "only id_token is supported")
return
}
origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowLoopbackHTTP)
origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowHTTP)
if err != nil || r.Header.Get("Origin") != origin {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app origin is invalid")
return
@ -511,7 +511,7 @@ func (h *Handler) authorizeStatusOrigin(w http.ResponseWriter, r *http.Request,
if origin == "" {
return true
}
issuerOrigin, err := loginapp.NormalizeWebOrigin(h.tokens.Issuer(), h.allowLoopbackHTTP)
issuerOrigin, err := loginapp.NormalizeWebOrigin(h.tokens.Issuer(), h.allowHTTP)
if err == nil && origin == issuerOrigin {
return true
}

View file

@ -109,7 +109,7 @@ func newTelegramLoginHTTPFixture(t *testing.T) telegramLoginHTTPFixture {
if err != nil {
t.Fatal(err)
}
handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowLoopbackHTTP: true})
handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowHTTP: true})
if err != nil {
t.Fatal(err)
}