hubris

OAuth 2.0 + PKCE

Стандартный вход через OAuth для сторонних приложений и MCP-клиентов (Claude Desktop, Cursor, Cline) — без копирования ключа.

Hubris реализует OAuth 2.0 Authorization Code + PKCE (RFC 6749 + 7636, рекомендации RFC 9700) для авторизации сторонних приложений. Это даёт два преимущества:

  1. Ограниченный доступ: приложение получает отдельный токен только на те действия, которые вы разрешили, и с дневным лимитом расхода. Ваш ключ sk-gw-… приложение не видит.
  2. Совместимость с 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:

  1. В клиенте указываете адрес https://api.hubris.pw/mcp.
  2. Клиент сам проходит обнаружение → регистрацию → /oauth/authorize и открывает браузер.
  3. Вы видите экран согласия «Авторизация приложения»: имя клиента, адрес возврата, галочки доступов («Делать запросы к моделям от вашего имени», «Видеть ваши цепочки резервных моделей», «Видеть остаток вашего баланса», «Создавать видео от вашего имени», «Смотреть и скачивать ваши видео»), продление на 90 дней и лимит расхода.
  4. После «Разрешить» браузер возвращает вас в клиент, токены выпущены, инструменты подключены.

Ключи sk-gw- на /mcp продолжают работать как раньше.

Отзыв доступа пользователем

Выданные приложениям доступы видны владельцу аккаунта в кабинете — Профиль → Приложения: имя приложения, что ему разрешено, дневной лимит, дата подключения и последнего запроса. Доступ без продления помечен «доступ до …, без продления» — он тоже отзывается наравне с остальными. Кнопка «Отозвать доступ» и подтверждение «Отозвать» гасят и refresh-, и все access-токены приложения сразу, не дожидаясь истечения часа: следующий же запрос получает 401. Подключиться снова приложение может обычным путём — пройдя авторизацию заново.

Приложение отзывает собственный доступ само — через POST /oauth/revoke (RFC 7009, пример выше).

Планы

Развитие поставлено на паузу до появления приложений, которым текущего набора мало. Если вам нужно что-то из списка — напишите нам, это ускорит работу:

  • /oauth/userinfo и интроспекция токенов (RFC 7662)
  • Отдельное право embeddings:write (сейчас эмбеддинги входят в chat:write)
  • Подтверждение домена для значка «проверенное приложение» при регистрации
Проверено на Python 3.12 + requests и Chromium (Playwright 1.63), 17 сентября 2026.

Ссылки

Обновлено: