OAuth 2.0 + PKCE
Стандартный вход через OAuth для сторонних приложений и MCP-клиентов (Claude Desktop, Cursor, Cline) — без копирования ключа.
Hubris реализует OAuth 2.0 Authorization Code + PKCE (RFC 6749 + 7636, рекомендации RFC 9700) для авторизации сторонних приложений. Это даёт два преимущества:
- Ограниченный доступ: приложение получает отдельный токен только на те действия, которые вы разрешили, и с дневным лимитом расхода. Ваш ключ
sk-gw-…приложение не видит. - Совместимость с MCP-коннекторами: Claude Desktop, Claude.ai, Cursor и любой MCP-клиент со встроенным подключением удалённых серверов («Add custom connector») сами проходят обнаружение, регистрацию и авторизацию — без шима
mcp-remote.
Обнаружение (RFC 8414 + RFC 9728)
Клиент начинает с одного из адресов:
GET https://api.hubris.pw/.well-known/oauth-protected-resource
GET https://api.hubris.pw/.well-known/oauth-authorization-serverЗапрос к https://api.hubris.pw/mcp без токена получает 401 с заголовком WWW-Authenticate: Bearer realm="https://api.hubris.pw", resource_metadata="https://api.hubris.pw/.well-known/oauth-protected-resource" — по нему MCP-клиенты и находят первый адрес.
Второй адрес — основной, в нём все точки входа. Ответ:
{
"issuer": "https://api.hubris.pw",
"authorization_endpoint": "https://api.hubris.pw/oauth/authorize",
"token_endpoint": "https://api.hubris.pw/oauth/token",
"registration_endpoint": "https://api.hubris.pw/oauth/clients",
"revocation_endpoint": "https://api.hubris.pw/oauth/revoke",
"scopes_supported": ["chat:write", "models:read", "balance:read", "videos:write", "videos:read", "offline_access"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"]
}token_endpoint_auth_methods_supported: ["none"] означает, что поддерживаются только публичные клиенты: PKCE обязателен, client_secret не выдаётся.
Права доступа (scopes)
| Scope | Что разрешает |
|---|---|
chat:write | Запросы к моделям: /v1/chat/completions, /v1/responses, /v1/messages, /v1/embeddings, /v1/decisions, /v1/rerank, /v1/images, /v1/audio, /v1/video/transcriptions, /v1/integrations/bitrix24; на /mcp — chat_complete, embeddings_create, rerank, decisions_ask, image_generate, speech_create, transcription_create, youtube_transcribe |
models:read | Ваши цепочки резервных моделей route/<slug> в ответе /v1/models |
balance:read | Баланс аккаунта: MCP-инструмент balance_get |
videos:write | Создание видео: POST /v1/videos; на /mcp — video_create |
videos:read | Статус и файл видео: GET /v1/videos/{id}, GET /v1/videos/{id}/content; на /mcp — video_get и ресурс ролика |
Те же права машиночитаемо — в спецификации https://hubris.pw/openapi.json: схема авторизации oauth2 со списком scopes и нужный scope у каждого описанного там эндпоинта.
Сам каталог моделей за правами не стоит: /v1/models открыт и без ключа, а инструменты каталога на /mcp (models_list, models_search, models_get_pricing, ресурс hubris://catalog/models) отвечают любому токену. models:read добавляет к каталогу только личные цепочки владельца — без него приложение видит ровно публичный список.
Свой расход приложение тоже видит без отдельного права: GET /v1/usage и инструмент usage_get отдают траты всего подключения — по всем его часовым токенам, без ключей и других приложений.
Без нужного права /v1/* отвечает 403 с кодом insufficient_scope и текстом Требуется scope: <право>, а инструмент MCP возвращает ошибку с тем же текстом. Эндпоинты кабинета (/api/internal/*), /v1/key и /v1/credits OAuth-токен не принимает.
Приложение запрашивает подмножество прав в ?scope=... (через пробел). На экране согласия каждый запрошенный доступ — отдельная галочка, включённая по умолчанию: человек может снять лишнее и выдать меньше, чем просили. Токен тогда получит урезанный набор, а фактически выданное придёт в поле scope ответа /oauth/token — сверяйтесь с ним, а не с тем, что запрашивали. Если сняты все галочки, это отказ: redirect_uri получает error=access_denied.
Запрос права, которого нет в регистрации клиента, /oauth/authorize отклоняет с 400 и текстом scope not authorized for this client: <scope>.
Срок доступа и offline_access
Отдельная галочка «Оставаться подключённым 90 дней» решает, будет ли выдан refresh-токен:
- включена (по умолчанию) —
/oauth/tokenвозвращаетrefresh_token, доступ продлевается ротацией; - снята —
refresh_tokenв ответе отсутствует (именно нет ключа, а неnull), access живёт час, дальше приложение проходит авторизацию заново.
offline_access запрашивать не обязательно — это отметка выбора пользователя, а не право доступа: в scope выданного токена он не попадает и в проверках на /v1/* не участвует. Если клиент всё же прислал его в ?scope= (он объявлен в scopes_supported, и клиенты, читающие метаданные по стандарту, так делают), запрос принимается, а решение остаётся за галочкой. Клиент определяет режим по наличию refresh_token в ответе.
Токены
- Access-токен: формат
hbr-at-<32 hex>, живёт 1 час (expires_in: 3600). Передаётся какAuthorization: Bearer ...на/v1/*и/mcp. - Refresh-токен: формат
hbr-rt-<48 hex>, живёт 90 дней. Принимается только наPOST /oauth/token; на других адресах —401с текстомRefresh tokens not accepted on this endpoint — use POST /oauth/token. - Ротация: каждый обмен refresh выдаёт новый refresh и новый access; прежний access продолжает работать до истечения своего часа. Старый refresh ещё 30 секунд принимается повторно (защита от гонки параллельных запросов). После этого окна его повторное использование считается кражей: ответ
400 invalid_grantс текстомRefresh token reuse detected — session revoked, и отзываются все токены этой авторизации. - Код авторизации одноразовый: повторный обмен —
400 invalid_grant,Code not found, expired, or already used.
Поток (пример на Python)
import requests, hashlib, base64, secrets
# 1. Регистрация клиента (один раз на приложение)
reg = requests.post('https://api.hubris.pw/oauth/clients', json={
'client_name': 'My App',
'redirect_uris': ['http://localhost:8765/callback'],
'scope': 'chat:write models:read',
})
client_id = reg.json()['client_id']
# `client_name` и `scope` необязательны (RFC 7591). Без `scope` клиент получает
# все права из списка ниже — сузить их пользователь сможет на экране согласия. Без
# `client_name` имя берётся из хоста первого `redirect_uris`
# (для localhost и 127.0.0.1 — «Local MCP client»).
# 2. PKCE
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b'=').decode()
# 3. Авторизация — откройте этот адрес в браузере
auth_url = (
'https://api.hubris.pw/oauth/authorize'
f'?client_id={client_id}'
'&redirect_uri=http://localhost:8765/callback'
'&response_type=code'
f'&code_challenge={challenge}&code_challenge_method=S256'
'&scope=chat:write+models:read'
'&state=somestate'
)
# Пользователь видит экран согласия, нажимает «Разрешить» →
# редирект на callback?code=...&state=somestate
# 4. Обмен кода на токены
tok = requests.post('https://api.hubris.pw/oauth/token', data={
'grant_type': 'authorization_code',
'code': received_code,
'redirect_uri': 'http://localhost:8765/callback',
'client_id': client_id,
'code_verifier': verifier,
}).json()
access_token = tok['access_token']
# 5. Запрос к модели
resp = requests.post(
'https://api.hubris.pw/v1/chat/completions',
headers={'Authorization': f'Bearer {access_token}'},
json={
'model': 'openai/gpt-4o-mini',
'messages': [{'role': 'user', 'content': 'привет'}],
},
)received_code — значение параметра code из адреса, на который браузер вернулся после согласия; state в нём совпадает с отправленным. Если пользователь не вошёл в Hubris, /oauth/authorize сначала отправит его на страницу входа и вернёт обратно.
Продление и отзыв:
# Новый access по refresh-токену (только если галочка продления была включена)
new = requests.post('https://api.hubris.pw/oauth/token', data={
'grant_type': 'refresh_token',
'refresh_token': tok['refresh_token'],
'client_id': client_id,
}).json()
# Отзыв (RFC 7009): refresh-токен гасит и все access-токены этой авторизации,
# access-токен — только себя. Ответ всегда 200 с пустым объектом.
requests.post('https://api.hubris.pw/oauth/revoke', data={
'token': new['refresh_token'],
'token_type_hint': 'refresh_token',
'client_id': client_id,
})Дневной лимит
Приложение может передать daily_limit_kopecks=50000 в запросе к /oauth/authorize. Пользователь видит запрошенную сумму на экране согласия в поле «Лимит расхода» (₽ в день) и может её изменить перед подтверждением. Если приложение ничего не передало или передало не целое положительное число, лимит по умолчанию — 500 ₽ в день; больше 100 000 ₽ в день лимит не бывает.
Когда расход за сутки достигает лимита, запросы с этим токеном получают 429 с кодом daily_limit_exceeded и заголовком Retry-After; отказ виден строкой в журнале расхода. Видео (POST /v1/videos) запускается, только если удержание под ролик целиком помещается в остаток лимита.
Безопасность
Hubris следует рекомендациям RFC 9700:
- PKCE S256 обязателен — метод
plainне поддерживается (code_challenge_method must be "S256"). - Ротация refresh-токенов и обнаружение повторного использования — повтор старого refresh после 30-секундного окна отзывает все токены авторизации.
- Точное совпадение
redirect_uriпосле нормализации (схема и хост без учёта регистра, путь — с учётом) — без префиксов и масок. Незарегистрированный адрес — страница «Подозрительный запрос» вместо редиректа.http://допускается только дляlocalhostи127.0.0.1. - Запрещённые слова в
client_name(hubris,official,admin,support— в любом месте имени) — базовая защита от фишинга; регистрация получает400 invalid_client_metadata,client_name contains a reserved word. Проверка распространяется и на имя, выведенное изredirect_uris, когдаclient_nameне прислан: хост с запрещённым словом заменяется на нейтральное «MCP client». - Ограничение регистраций: 5 в час с одного IP и 100 в сутки на весь сервис.
- В базе хранятся только sha256-хэши токенов, сами токены — нигде.
Подключение MCP-клиентов
Как это выглядит в Claude Desktop или Claude.ai:
- В клиенте указываете адрес
https://api.hubris.pw/mcp. - Клиент сам проходит обнаружение → регистрацию →
/oauth/authorizeи открывает браузер. - Вы видите экран согласия «Авторизация приложения»: имя клиента, адрес возврата, галочки доступов («Делать запросы к моделям от вашего имени», «Видеть ваши цепочки резервных моделей», «Видеть остаток вашего баланса», «Создавать видео от вашего имени», «Смотреть и скачивать ваши видео»), продление на 90 дней и лимит расхода.
- После «Разрешить» браузер возвращает вас в клиент, токены выпущены, инструменты подключены.
Ключи sk-gw- на /mcp продолжают работать как раньше.
Отзыв доступа пользователем
Выданные приложениям доступы видны владельцу аккаунта в кабинете — Профиль → Приложения: имя приложения, что ему разрешено, дневной лимит, дата подключения и последнего запроса. Доступ без продления помечен «доступ до …, без продления» — он тоже отзывается наравне с остальными. Кнопка «Отозвать доступ» и подтверждение «Отозвать» гасят и refresh-, и все access-токены приложения сразу, не дожидаясь истечения часа: следующий же запрос получает 401. Подключиться снова приложение может обычным путём — пройдя авторизацию заново.
Приложение отзывает собственный доступ само — через POST /oauth/revoke (RFC 7009, пример выше).
Планы
Развитие поставлено на паузу до появления приложений, которым текущего набора мало. Если вам нужно что-то из списка — напишите нам, это ускорит работу:
/oauth/userinfoи интроспекция токенов (RFC 7662)- Отдельное право
embeddings:write(сейчас эмбеддинги входят вchat:write) - Подтверждение домена для значка «проверенное приложение» при регистрации
Ссылки
- RFC 6749 — OAuth 2.0
- RFC 7009 — Token Revocation
- RFC 7591 — Dynamic Client Registration
- RFC 7636 — PKCE
- RFC 8414 — Authorization Server Metadata
- RFC 9700 — OAuth 2.0 Security Best Current Practice
- RFC 9728 — Protected Resource Metadata
- MCP Authorization: https://modelcontextprotocol.io/specification/draft/basic/authorization
Обновлено: