Приложение ВИШ — расписание РУТ (МИИТ)

Расписание Российского университета транспорта для студентов и сотрудников всех институтов. Расписание своей группы и любого преподавателя на день и на неделю, свободные аудитории МИИТ прямо сейчас, загруженность аудиторий, групп и преподавателей, уведомления об изменениях в расписании и ИИ-помощник по расписанию.

Институты

Приложение РУТ (МИИТ) охватывает пять институтов: ВИШ — Высшая инженерная школа, АГА — Академия гражданской авиации, АДХ — Академия дорожного хозяйства, ПИШ — Передовая инженерная школа «Академия ВСМ» и ИМТК — Институт международных транспортных коммуникаций. Расписание группы и расписание преподавателя ищутся одинаково в любом из них.

Что умеет приложение ВИШ
Разделы
Руководство

Вход через ВИШ для разработчиков

Обновлено 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. Библиотеки входа читают его сами, поэтому указывайте его, а не отдельные адреса:
text
https://d1.vish-app.ru/api/v1/oauth/.well-known/openid-configuration

Регистрация приложения

  1. Получите API-ключ — см. API и ключи доступа. Регистрировать приложения может владелец действующего ключа, а также сотрудник с правом выпускать ключи или управлять приложениями «Входа через ВИШ». Закрытое приложение регистрируют только из приложения ВИШ, не ключом и не ботом: при регистрации выдаётся секрет.
  2. В приложении ВИШ откройте «Для разработчиков» (/developers) → «Войти через ВИШ — ваши приложения» → «Зарегистрировать приложение».
  3. Выберите тип: «Публичное» — сайт в браузере или мобильное приложение, секрета нет; «Закрытое» — ваш сервер, который хранит секрет клиента. Тип потом не меняется.
  4. Впишите адреса возврата — только https, либо http на localhost, 127.0.0.1 или [::1] для разработки. Без * и #, до 10 адресов.
  5. Отметьте, что приложение может запрашивать. После сохранения появится «Идентификатор клиента» (vc_…); у закрытого приложения ещё и секрет (vcs_…) — его показывают один раз.

Разрешения (scope)

scopeЧто получает приложение
openidID-токен и sub — постоянный номер человека для вашего приложения; два приложения не могут сопоставить своих пользователей
profilename, given_name, family_name, picture, locale
groupvish_group — учебная группа человека
rolevish_role — роль, и признаки vish_teacher, vish_student
schedule:readЧтение расписания от имени человека: GET /schedule, GET /schedule/subscriptions/mine, GET /schedule/{id}/subscription
notes:readЧтение заметок к занятиям: GET /schedule/{id}/notes

Писать что-либо от имени человека токен не может, какие бы разрешения ни получил. Человек разрешает всё запрошенное сразу или отказывает.

Как проходит вход

text
 Приложение (клиент)                 ВИШ                                  Человек
        |                             |                                       |
        | 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 … -------------------------|
  1. Перед переходом приложение создаёт три случайные строки: code_verifier (43–128 символов), state и nonce — и сохраняет их у себя (в серверной сессии или в sessionStorage своей вкладки).
  2. Человек уходит на адрес входа ВИШ с параметрами запроса. code_challenge — это BASE64URL(SHA-256(code_verifier)).
  3. Если человек ещё не вошёл в ВИШ, страница попросит войти через Telegram или MAX.
  4. ВИШ показывает, какое приложение просит доступ и что получит. Если человек уже разрешал всё это раньше, экран согласия пропускается (prompt=consent покажет его всё равно, prompt=none вернёт login_required или consent_required, ничего не показывая).
  5. ВИШ возвращает человека на redirect_uri с code, state и iss. При отказе или ошибке — с error, error_description, state и iss.
  6. Приложение сверяет state с сохранённым и iss с issuer из документа настройки. Не совпало — запрос не ваш, прервите вход.
  7. Код меняется на токены запросом с сервера или из той же вкладки: тело application/x-www-form-urlencoded, code_verifier — тот, что создан на шаге 1. Закрытое приложение добавляет секрет (заголовок Authorization: Basic или client_secret в теле).
  8. В ответе — access_token, token_type: "Bearer", expires_in, refresh_token, scope и, если просили openid, id_token.
  9. В id_token сверьте iss, aud (ваш client_id), nonce (ваш) и срок exp. Ключи подписи (ES256) — по jwks_uri.
  10. Данные о человеке — 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.