Вход через ВИШ для разработчиков
Обновлено 27.10.2026
«Войти через ВИШ» — это OAuth 2.1 и OpenID Connect. Человек входит в ваш сайт или приложение аккаунтом ВИШ (через Telegram или MAX), разрешает доступ, и вы получаете его имя, группу, роль и, если он согласился, право читать его расписание.
Как вход выглядит для человека, который входит, — на странице Вход через ВИШ.
Коротко о протоколе
- Только Authorization Code с PKCE, метод
S256.plain, отсутствие PKCE и implicit-поток не принимаются. stateобязателен (от 8 до 512 символов);nonceобязателен, если вscopeестьopenid.- Токены приходят только в теле ответа на
POST /oauth/tokenи никогда — в адресе. В адресе возврата бывает только одноразовыйcode(живёт 60 секунд),stateиiss. - Адрес возврата сравнивается посимвольно с зарегистрированным, вместе с портом и путём.
- Токен доступа живёт 15 минут, токен refresh — 30 дней без использования и не больше 180 дней всего; при каждом обновлении выдаётся новый refresh, а повторное использование старого отзывает всю цепочку.
- Все адреса — в документе настройки OpenID Connect. Библиотеки входа читают его сами, поэтому указывайте его, а не отдельные адреса:
https://d1.vish-app.ru/api/v1/oauth/.well-known/openid-configuration
Регистрация приложения
- Получите API-ключ — см. API и ключи доступа. Регистрировать приложения может владелец действующего ключа, а также сотрудник с правом выпускать ключи или управлять приложениями «Входа через ВИШ». Закрытое приложение регистрируют только из приложения ВИШ, не ключом и не ботом: при регистрации выдаётся секрет.
- В приложении ВИШ откройте «Для разработчиков» (
/developers) → «Войти через ВИШ — ваши приложения» → «Зарегистрировать приложение». - Выберите тип: «Публичное» — сайт в браузере или мобильное приложение, секрета нет; «Закрытое» — ваш сервер, который хранит секрет клиента. Тип потом не меняется.
- Впишите адреса возврата — только
https, либоhttpнаlocalhost,127.0.0.1или[::1]для разработки. Без*и#, до 10 адресов. - Отметьте, что приложение может запрашивать. После сохранения появится «Идентификатор клиента» (
vc_…); у закрытого приложения ещё и секрет (vcs_…) — его показывают один раз.
Разрешения (scope)
| scope | Что получает приложение |
|---|---|
openid | ID-токен и sub — постоянный номер человека для вашего приложения; два приложения не могут сопоставить своих пользователей |
profile | name, given_name, family_name, picture, locale |
group | vish_group — учебная группа человека |
role | vish_role — роль, и признаки vish_teacher, vish_student |
schedule:read | Чтение расписания от имени человека: GET /schedule, GET /schedule/subscriptions/mine, GET /schedule/{id}/subscription |
notes:read | Чтение заметок к занятиям: GET /schedule/{id}/notes |
Писать что-либо от имени человека токен не может, какие бы разрешения ни получил. Человек разрешает всё запрошенное сразу или отказывает.
Как проходит вход
Приложение (клиент) ВИШ Человек
| | |
| 1. code_verifier, state, nonce — создаются и сохраняются у себя |
|------------------------------------------------------------------->|
| 2. переход на authorization_endpoint (/oauth/authorize): |
| response_type=code, client_id, redirect_uri, scope, state, |
| nonce, code_challenge, code_challenge_method=S256 |
| |<--- 3. вход через Telegram или MAX ---|
| |<--- 4. «Разрешить» на экране согласия |
|<--- 5. 302 на redirect_uri?code=…&state=…&iss=… --------------------|
| 6. проверка: state совпадает с сохранённым, iss — с issuer |
|---- 7. POST /oauth/token: grant_type=authorization_code, code, |
| redirect_uri, client_id, code_verifier ------------------->|
|<--- 8. JSON: access_token, refresh_token, id_token, expires_in ----|
| 9. проверка id_token: iss, aud, nonce, exp |
|---- 10. GET /oauth/userinfo, Authorization: Bearer access_token -->|
|<--- 11. sub, name, vish_group, vish_role … -------------------------|- Перед переходом приложение создаёт три случайные строки:
code_verifier(43–128 символов),stateиnonce— и сохраняет их у себя (в серверной сессии или вsessionStorageсвоей вкладки). - Человек уходит на адрес входа ВИШ с параметрами запроса.
code_challenge— этоBASE64URL(SHA-256(code_verifier)). - Если человек ещё не вошёл в ВИШ, страница попросит войти через Telegram или MAX.
- ВИШ показывает, какое приложение просит доступ и что получит. Если человек уже разрешал всё это раньше, экран согласия пропускается (
prompt=consentпокажет его всё равно,prompt=noneвернётlogin_requiredилиconsent_required, ничего не показывая). - ВИШ возвращает человека на
redirect_uriсcode,stateиiss. При отказе или ошибке — сerror,error_description,stateиiss. - Приложение сверяет
stateс сохранённым иissсissuerиз документа настройки. Не совпало — запрос не ваш, прервите вход. - Код меняется на токены запросом с сервера или из той же вкладки: тело
application/x-www-form-urlencoded,code_verifier— тот, что создан на шаге 1. Закрытое приложение добавляет секрет (заголовокAuthorization: Basicилиclient_secretв теле). - В ответе —
access_token,token_type: "Bearer",expires_in,refresh_token,scopeи, если просилиopenid,id_token. - В
id_tokenсверьтеiss,aud(вашclient_id),nonce(ваш) и срокexp. Ключи подписи (ES256) — поjwks_uri. - Данные о человеке —
GET /oauth/userinfoс токеном доступа; расписание — обычными методами API с тем же заголовком, но со своего сервера: со страницы другого сайта браузер разрешает читать (CORS) только протокольные адреса/oauth/….
PKCE и адрес входа
Пример создаёт code_verifier, code_challenge, state и nonce, проверяет PKCE на эталоне из RFC 7636, читает документ настройки, собирает адрес входа и показывает, как проверить state и iss в адресе возврата. В браузер он не ходит, поэтому работает без ключа и без зарегистрированного приложения (VISH_CLIENT_ID подставьте свой).
// PKCE (S256), state and nonce with Web Crypto (the same code runs in a browser); the authorize URL
// from discovery; the callback check.
const API = process.env.VISH_API ?? 'https://d1.vish-app.ru/api/v1'
const CLIENT_ID = process.env.VISH_CLIENT_ID ?? 'vc_your_client_id'
const REDIRECT_URI = 'http://127.0.0.1:8080/callback'
const base64url = (bytes) =>
btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const randomString = () => base64url(crypto.getRandomValues(new Uint8Array(32))) // 43 characters
const challengeOf = async (verifier) =>
base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
// RFC 7636, appendix B: the reference verifier and its challenge.
if ((await challengeOf('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')) !== 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') {
throw new Error('S256 is computed wrongly')
}
const response = await fetch(`${API}/oauth/.well-known/openid-configuration`)
if (!response.ok) throw new Error(`discovery: HTTP ${response.status}`)
const discovery = await response.json()
if (!discovery.code_challenge_methods_supported?.includes('S256')) throw new Error('S256 not offered')
// Keep these three until the callback: sessionStorage of this tab, or the server-side session.
const codeVerifier = randomString()
const state = randomString()
const nonce = randomString()
const authorizeUrl = new URL(discovery.authorization_endpoint)
authorizeUrl.search = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile group schedule:read',
state,
nonce,
code_challenge: await challengeOf(codeVerifier),
code_challenge_method: 'S256',
}).toString()
console.log('authorize URL:', authorizeUrl.href)
// What the callback handler does with ?code=…&state=…&iss=…
const acceptCallback = (callbackUrl) => {
const params = new URL(callbackUrl).searchParams
if (params.get('state') !== state) throw new Error('state mismatch: not our request')
if (params.get('iss') !== discovery.issuer) throw new Error('iss mismatch')
if (params.has('error')) throw new Error(`${params.get('error')}: ${params.get('error_description')}`)
return params.get('code')
}
const issuer = encodeURIComponent(discovery.issuer)
if (acceptCallback(`${REDIRECT_URI}?code=abc&state=${state}&iss=${issuer}`) !== 'abc') throw new Error('valid callback refused')
let forgedAccepted = true
try {
acceptCallback(`${REDIRECT_URI}?code=abc&state=forged-state&iss=${issuer}`)
} catch {
forgedAccepted = false
}
if (forgedAccepted) throw new Error('forged state accepted')
console.log('PKCE, state and iss checks work; keep codeVerifier, state and nonce until the callback')Полный вход из локальной программы
Программа ниже проходит вход целиком: поднимает адрес возврата http://127.0.0.1:8080/callback, печатает адрес входа, ждёт возврата, сверяет state и iss, меняет код на токены, проверяет id_token и читает userinfo. Для неё нужно своё публичное приложение с этим адресом возврата и разрешениями openid, profile и group; его идентификатор — в VISH_CLIENT_ID. Откройте напечатанный адрес в браузере и разрешите доступ.
id_token здесь приходит прямо из ответа /oauth/token по TLS, поэтому программа проверяет его поля (iss, aud, nonce, exp); подпись тоже можно проверить — ключами из jwks_uri (ES256).
// Full sign-in with VISH for a public client: loopback redirect, PKCE, token exchange, userinfo.
import { createHash, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'
const API = process.env.VISH_API ?? 'https://d1.vish-app.ru/api/v1'
const CLIENT_ID = process.env.VISH_CLIENT_ID
if (!CLIENT_ID) throw new Error('set VISH_CLIENT_ID to your public app id (vc_...)')
const REDIRECT_URI = 'http://127.0.0.1:8080/callback'
const discovery = await (await fetch(`${API}/oauth/.well-known/openid-configuration`)).json()
const codeVerifier = randomBytes(32).toString('base64url')
const state = randomBytes(32).toString('base64url')
const nonce = randomBytes(32).toString('base64url')
const authorizeUrl = new URL(discovery.authorization_endpoint)
authorizeUrl.search = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile group',
state,
nonce,
code_challenge: createHash('sha256').update(codeVerifier).digest('base64url'),
code_challenge_method: 'S256',
}).toString()
console.log(`Open in a browser:\n${authorizeUrl.href}`)
const params = await new Promise((resolve) => {
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', REDIRECT_URI)
if (url.pathname !== '/callback') return void response.writeHead(404).end()
response.end('Signed in. You can close this tab.')
server.close()
resolve(url.searchParams)
}).listen(8080, '127.0.0.1')
})
if (params.get('state') !== state) throw new Error('state mismatch: not our request')
if (params.get('iss') !== discovery.issuer) throw new Error('iss mismatch')
if (params.has('error')) throw new Error(`${params.get('error')}: ${params.get('error_description')}`)
const tokenResponse = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: params.get('code') ?? '',
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}),
})
const tokens = await tokenResponse.json()
if (!tokenResponse.ok) throw new Error(`${tokens.error}: ${tokens.error_description}`)
// Received straight from the token endpoint over TLS, so the claims are checked; the signature can be
// checked too, with the keys at discovery.jwks_uri (ES256).
const claims = JSON.parse(Buffer.from(tokens.id_token.split('.')[1], 'base64url').toString())
if (claims.iss !== discovery.issuer || claims.aud !== CLIENT_ID || claims.nonce !== nonce || claims.exp * 1000 < Date.now()) {
throw new Error('id_token rejected')
}
const userinfo = await fetch(discovery.userinfo_endpoint, { headers: { Authorization: `Bearer ${tokens.access_token}` } })
if (!userinfo.ok) throw new Error(`userinfo: HTTP ${userinfo.status}`)
console.log(await userinfo.json())Обновление и отзыв токенов
- Обновить:
POST /oauth/tokenсgrant_type=refresh_token,refresh_tokenиclient_id(закрытое приложение — с секретом). В ответе новая пара; старый refresh больше не действует. Если старый пришёл ещё раз, ВИШ считает его украденным и отзывает всю цепочку — человеку придётся войти заново. Новыйid_tokenпри обновлении не выдаётся. - Выйти:
POST /oauth/revokeсtoken(refresh отзывает всю цепочку, токен доступа — только себя). - Проверить токен:
POST /oauth/introspect— только тому приложению, которому токен выдан.
Ошибки протокола
Адреса /oauth/token, /oauth/userinfo, /oauth/revoke и /oauth/introspect отвечают ошибками по стандарту OAuth — { "error": "…", "error_description": "…" }: invalid_request, invalid_client, invalid_grant (код истёк, уже использован или не к тому code_verifier), invalid_token. Если client_id или redirect_uri неверны, ВИШ показывает ошибку у себя и не перенаправляет человека — так ссылку нельзя использовать, чтобы увести его на чужой сайт.
Как выглядят эти ошибки — без приложения и токена:
// Protocol errors are RFC 6749 objects: { error, error_description }.
const API = process.env.VISH_API ?? 'https://d1.vish-app.ru/api/v1'
const token = await fetch(`${API}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'made-up-code',
redirect_uri: 'http://127.0.0.1:8080/callback',
client_id: 'vc_made_up',
code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
}),
})
const tokenError = await token.json()
if (token.status !== 401 || tokenError.error !== 'invalid_client') throw new Error(`token: ${token.status} ${JSON.stringify(tokenError)}`)
const userinfo = await fetch(`${API}/oauth/userinfo`)
const userinfoError = await userinfo.json()
if (userinfo.status !== 401 || userinfoError.error !== 'invalid_token') throw new Error(`userinfo: ${userinfo.status}`)
console.log('POST /oauth/token ->', token.status, tokenError.error)
console.log('GET /oauth/userinfo ->', userinfo.status, userinfoError.error)Документ настройки можно посмотреть и здесь:
Попробовать запрос
Ключ нужен только методам с замком. Он живёт только на этой странице: не сохраняется, уходит лишь заголовком этого запроса и забывается, когда вы её покинете. Где взять ключ
Ограничения
- До 10 приложений на владельца и до 10 адресов возврата на приложение.
- Обмен кода и обновление — не больше 30 запросов в минуту на адрес и приложение; все протокольные запросы с одного адреса — не больше 120 в минуту.
- Нет динамической регистрации клиентов, выхода по каналу (front/back-channel logout), PAR и DPoP. Для мобильных приложений используйте адрес возврата https (App Links, Universal Links), а не собственную схему.
Безопасность на стороне вашего приложения — на странице Безопасность интеграций.
Проверено: 2026-10, PWA aa337c45, platform bff65fce.