Sign in with VISH for developers
Updated 27.10.2026
“Log in via VISH” — this is OAuth 2.1 and OpenID Connect. A person logs into your website or app with account VISH (via Telegram or MAX), allows access, and you get their name, group, role, and, if they agree, the right to read their schedule.
What the entrance looks like for the person who enters is on the Login through VISH page.
Briefly about the protocol
- Только Authorization Code с PKCE, метод
S256.plain, отсутствие PKCE и implicit-поток не принимаются. stateis required (from 8 to 512 characters);nonceis required ifscopecontainsopenid.- Tokens are received only in the body of the response to
POST /oauth/tokenand never in the address. There is only a one-timecode(lives 60 seconds),stateandissin the return address. - The return address is compared character-by-character with the registered one, along with the port and path.
- The access token lasts for 15 minutes, the refresh token lasts for 30 days without use and no more than 180 days in total; with each update, a new refresh is issued, and reuse of the old one revokes the entire chain.
- All addresses are listed in the OpenID Connect settings document. The login libraries read it themselves, so specify it rather than individual addresses.:
https://d1.vish-app.ru/api/v1/oauth/.well-known/openid-configuration
App Registration
- Get an API key — see API and Access keys. Applications can be registered by the owner of a valid key, as well as an employee with the right to issue keys or manage “Login applications through VISH”. A closed application is registered only from application VISH, not with a key or a bot: a secret is issued during registration.
- In Application VISH, open “For developers” (
/developers) → “Log in through VISH — your applications” → “Register the application”. - Choose the type: “Public” — a website in a browser or a mobile application, there is no secret; “Private” — your server that stores the client's secret. The type doesn't change afterwards.
- Enter the return addresses — only
https, orhttpforlocalhost,127.0.0.1or[::1]for development. Without*and#, up to 10 addresses. - Note that the application can request. After saving, the “Client ID” (
vc_…) will appear; the closed application also has a secret (vcs_…) — it is shown once.
Permissions (scope)
| scope | What the application gets |
|---|---|
openid | is the ID token and sub is the permanent number of the person for your application; two applications cannot match their users |
profile | name, given_name, family_name, picture, locale |
group | vish_group — учебная группа человека |
role | vish_role — role, and signs vish_teacher, vish_student |
schedule:read | Reading the schedule on behalf of a person: GET /schedule, GET /schedule/subscriptions/mine, GET /schedule/{id}/subscription |
notes:read | Reading class notes: GET /schedule/{id}/notes |
The token cannot write anything on behalf of a person, no matter what permissions it receives. The person allows everything requested at once or refuses.
How does the entrance work?
Приложение (клиент) ВИШ Человек
| | |
| 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 … -------------------------|- Before switching, the application creates three random strings:
code_verifier(43-128 characters),stateandnonce, and saves them to itself (in the server session or insessionStorageof its tabs). - The person goes to the login address VISH with the request parameters.
code_challengeisBASE64URL(SHA-256(code_verifier)). - If the person has not entered VISH yet, the page will ask you to log in via Telegram or MAX.
- VISH shows which application is requesting access and what it will receive. If the person has already allowed all this before, the consent screen is skipped (
prompt=consentwill show it anyway,prompt=nonewill returnlogin_requiredorconsent_requiredwithout showing anything). - VISH returns the person to
redirect_urifromcode,state, andiss. In case of failure or error, fromerror,error_description,state, andiss. - The application checks
stateagainst the saved andissagainstissuerfrom the settings document. If it doesn't match, the request is not yours, stop logging in. - The code is changed to tokens by a request from the server or from the same tab: body
application/x-www-form-urlencoded,code_verifieris the one created in step 1. The closed application adds a secret (headerAuthorization: Basicorclient_secretin the body). - In the response —
access_token,token_type: "Bearer",expires_in,refresh_token,scopeand, if you asked foropenid,id_token. - At
id_token, compareiss,aud(yourclient_id),nonce(your), and termexp. The signature keys (ES256) arejwks_urieach. - Data about a person is
GET /oauth/userinfowith an access token; the schedule is provided using the usual API methods with the same header, but from its own server: from a page on another site, the browser allows you to read (CORS) only protocol addresses/oauth/….
PKCE and login address
The example creates code_verifier, code_challenge, state and nonce, checks PKCE on the reference from RFC 7636, reads the configuration document, collects the entry address and shows how to check state and iss in the return address. It doesn't access the browser, so it works without a key and without a registered application (VISH_CLIENT_ID insert your own).
// 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')Full login from the local program
The program below goes through the entire input: it picks up the return address http://127.0.0.1:8080/callback, prints the input address, waits for the return, checks state and iss, changes the code to tokens, checks id_token and reads userinfo. For it, you need your own public application with this return address and permissions openid, profile and group; its identifier is VISH_CLIENT_ID. Open the printed address in the browser and allow access.
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())Updating and revoking tokens
- Update:
POST /oauth/tokenfromgrant_type=refresh_token,refresh_tokenandclient_id(closed application — with a secret). There is a new pair in the response; the old refresh is no longer valid. If the old one arrives again, VISH considers it stolen and revokes the entire chain — the person will have to log in again. A newid_tokenis not issued during the update. - Exit:
POST /oauth/revokefromtoken(refresh revokes the entire chain, access token — only itself). - Verify the token:
POST /oauth/introspect— only to the application that the token was issued to.
Protocol errors
Addresses /oauth/token, /oauth/userinfo, /oauth/revoke, and /oauth/introspect respond with errors according to the OAuth — { "error": "…", "error_description": "…" } standard: invalid_request, invalid_client, invalid_grant (the code has expired, has already been used, or is incorrect code_verifier), invalid_token. If client_id or redirect_uri are incorrect, VISH shows the error at home and does not redirect the person, so the link cannot be used to lead them away to someone else's website.
What do these errors look like — without the app and the token?:
// 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)The settings document can also be viewed here.:
Try the request
Only methods with a lock need a key. It lives on this page only: it is never stored, goes out only as a header of this request and is forgotten when you leave. Where to get a key
Limitations
- Up to 10 applications per owner and up to 10 return addresses per application.
- Code exchange and updating — no more than 30 requests per minute to the address and the application; all protocol requests from one address — no more than 120 per minute.
- There is no dynamic client registration, channel exit (front/back-channel logout), PAR and DPoP. For mobile applications, use the https return address (App Links, Universal Links), rather than your own scheme.
Security on the side of your application is on the Integration Security page.
Verified: 2026-10, PWA aa337c45, platform bff65fce._