Вход через AITunnel (OAuth PKCE)
Ваше приложение получает API-ключ пользователя AITunnel без ручного копирования: человек входит в аккаунт, задаёт лимит и нажимает «Разрешить».
https://aitunnel.ru/panel/authorize с callback_url и code_challengePOST https://api.aitunnel.ru/v1/aitunnel/auth/keys, без авторизации, CORS открытsk-aitunnel-… аккаунта пользователя, помеченный адресом приложенияЭто поток OAuth 2.0 authorization code с PKCE (RFC 7636), как у OpenRouter. Регистрировать приложение не нужно, секрета клиента нет. Подходит для ботов, CLI, сайтов и плагинов.
Как это работает
- Приложение создаёт случайный
code_verifier(43–128 символовA–Z a–z 0–9 - . _ ~) и хранит его у себя. Наружу отдаётcode_challenge= base64url(SHA-256(verifier)) без=. - Приложение открывает пользователю страницу согласия с
callback_urlиcode_challenge. - Пользователь входит в AITunnel, видит адрес приложения и нажимает «Разрешить». Браузер возвращается на
callback_url?code=…, а при отказе — наcallback_url?error=access_denied. - Приложение отправляет
codeиcode_verifierвPOST /v1/aitunnel/auth/keysи получает ключ.
Шаг 1. Страница согласия
https://aitunnel.ru/panel/authorize
?callback_url=https://myapp.example.com/aitunnel/callback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256callback_urlhttps:// или http://localhost / 127.0.0.1 (для CLI). Без логина и пароля в адресе и без #фрагмента. Ваши параметры (например, state) сохраняются, code добавляется к нимcode_challengeS256 — base64url(SHA-256(code_verifier)), 43 символа. Для plain — сам verifiercode_challenge_methodS256 (по умолчанию, рекомендуется) или plain// 1. Перед входом: verifier храните у себя, наружу уходит только challenge
const base64url = (bytes) =>
btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
sessionStorage.setItem("aitunnel_verifier", verifier);
// 2. Отправьте пользователя на страницу согласия
const url = new URL("https://aitunnel.ru/panel/authorize");
url.searchParams.set("callback_url", "https://myapp.example.com/aitunnel/callback");
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
location.href = url.href;Шаг 2. Обмен кода на ключ
// 3. На странице callback_url: обменяйте code на ключ
const params = new URLSearchParams(location.search);
if (params.get("error") === "access_denied") {
// пользователь нажал «Отказать»
} else {
const res = await fetch("https://api.aitunnel.ru/v1/aitunnel/auth/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: params.get("code"),
code_verifier: sessionStorage.getItem("aitunnel_verifier"),
code_challenge_method: "S256",
}),
});
const { key } = await res.json(); // sk-aitunnel-…
sessionStorage.removeItem("aitunnel_verifier");
}Тот же запрос из любого языка:
curl https://api.aitunnel.ru/v1/aitunnel/auth/keys \
-H "Content-Type: application/json" \
-d '{
"code": "Qm9vZ2llV29vZ2llLi4uY29kZS1mcm9tLWNhbGxiYWNr",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"code_challenge_method": "S256"
}'{
"key": "sk-aitunnel-AbCdEfGhIjKlMnOpQrStUvWxYz012345",
"user_id": 4242,
"key_id": 917,
"name": "myapp.example.com"
}Пример: сервер бота
Если callback_url ведёт на ваш сервер (Telegram-бот, бэкенд сайта), verifier хранится на сервере, а пользователь приходит по ссылке из чата:
# Сервер приложения (например, Telegram-бота): callback_url ведёт сюда
import base64, hashlib, secrets
import requests
def start_login(state_store, chat_id):
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
state_store[state] = {"verifier": verifier, "chat_id": chat_id}
callback = f"https://bot.example.com/aitunnel/callback?state={state}"
return (
"https://aitunnel.ru/panel/authorize"
f"?callback_url={requests.utils.quote(callback, safe='')}"
f"&code_challenge={challenge}&code_challenge_method=S256"
)
def on_callback(state_store, state, code):
login = state_store.pop(state)
answer = requests.post(
"https://api.aitunnel.ru/v1/aitunnel/auth/keys",
json={"code": code, "code_verifier": login["verifier"]},
timeout=30,
).json()
return login["chat_id"], answer["key"] # сохраните ключ за этим пользователемЧто видит пользователь
- Страница согласия показывает адрес приложения — хост
callback_url— и аккаунт, от имени которого выдаётся ключ. - Пользователь задаёт ключу название (по умолчанию — хост приложения), лимит расходов в рублях и срок: без срока, 30 дней, 90 дней или 1 год.
- В разделе API-ключи у такого ключа метка с адресом приложения. Его можно поставить на паузу, изменить лимит или удалить, как любой другой ключ. Удалённый ключ сразу перестаёт работать (401).
Ошибки
invalid_codecode_verifier не подходит. Код сгорает после первой попытки, даже неудачной — начните вход зановоbalance_requiredkeys_limit_reachedcallback_url не https, содержит #, логин или пароль, или нет code_challengeЕсли у пользователя нулевой баланс или не подтверждена почта, страница согласия скажет об этом сразу и ключ не выдаст: пользователь пополнит баланс и войдёт в приложении снова.
Безопасность
codeиз адреса бесполезен безcode_verifier: перехватить ключ по ссылке нельзя. ИспользуйтеS256— сplainverifier виден в адресе страницы согласия.- Приложение не регистрируется, поэтому пользователь доверяет адресу, который видит. Показывайте в своём интерфейсе, что вход идёт через AITunnel, и не открывайте страницу согласия без действия пользователя.
- Ключ приложения ничем не отличается от обычного: он видит баланс аккаунта (
GET /aitunnel/balance) и свои запросы, но не может управлять аккаунтом, видеть другие ключи или пополнять баланс. Для управления аккаунтом из кода нужен ключ управления. - Выдавать ключ может только сам владелец аккаунта: сотрудник поддержки, вошедший в аккаунт, этого сделать не может.