面向开发者的“通过 VISH 登录”
更新于 27.10.2026
“透过VISH” -这是OAuth2.1和OpenID Connect。 一个人登录到您的网站或应用程序与帐户VISH(通过电报或MAX),允许访问,你得到他们的名字,组,角色,如果他们同意,有权阅读他们的时间表。
对于进入的人来说,入口是什么样子的。登入VISH页。
简单介绍一下协议
- Только Authorization Code с PKCE, метод
S256.plain, отсутствие PKCE и implicit-поток не принимаются. state是必需的(从8到512个字符);nonce是必需的,如果scope包含openid.- 令牌收到 只有在响应的主体 到
POST /oauth/token永远不要在地址里。 只有一次code(生命60秒),state和iss在返回地址。 - 返回地址与注册地址以及端口和路径进行逐字符比较。
- 访问令牌持续15分钟,刷新令牌持续30天不使用,总共不超过180天;每次更新时,都会发出新的刷新,并重新使用旧的刷新会撤销整个链。
- 所有地址都列在OpenID Connect设置文档中。 登录库自己读取它,因此指定它而不是单个地址。:
https://d1.vish-app.ru/api/v1/oauth/.well-known/openid-configuration
应用程序注册
- 获取API密钥-请参阅API和访问密钥. 应用程序可以由有效密钥的所有者以及有权颁发密钥或管理的员工注册“透过VISH”. 已关闭的应用程序仅从应用程序注册VISH,而不是一个关键或机器人:一个秘密在注册过程中发出。
- 在申请中VISH,打开“对于开发人员” (
/developers) → “登入VISH -你的申请” → “登记申请”. - 选择类型: “公众人士” -浏览器或移动应用程序中的网站,没有秘密; “私人” -存储客户端秘密的服务器。 类型之后不会改变。
- 只输入回邮地址
https,或http为localhost,127.0.0.1或[::1]为了发展。 没有*和#,最多10个地址。 - 请注意,应用程序可以请求。 保存后,“客户ID” (
vc_…)会出现;关闭的应用程序也有一个秘密(vcs_…)-它显示 一次.
权限(范围)
| 范围 | 应用程序得到什么 |
|---|---|
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它的标签)。 - 人转到登录地址VISH与请求参数。
code_challenge是BASE64URL(SHA-256(code_verifier)). - 如果该人尚未进入VISH然而,该页面会要求您通过Telegram或MAX登录。
- VISH显示哪个应用程序正在请求访问以及它将收到什么。 如果该人之前已经允许所有这些,则会跳过同意屏幕(
prompt=consent无论如何都会显示出来,prompt=none会回来login_required或consent_required没有显示任何东西)。 - VISH将一个人返回到
redirect_uri从code,state,而iss. 在故障或错误的情况下,从error,error_description,state,而iss. - 应用程序检查
state反对得救的人和iss反对issuer从设置文档。 如果不匹配,请求不是你的,停止登录。 - 通过来自服务器的请求或来自同一选项卡的代码更改为令牌:body
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,在RFC7636的引用上检查PKCE,读取配置文档,收集条目地址并显示如何检查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(封闭的应用程序-与一个秘密)。 响应中有一个新的对;旧的刷新不再有效。 如果旧的再来, VISH认为它被盗并撤销整个链-该人将不得不再次登录。 一个新的id_token更新期间不发出。 - 出口;出口:
POST /oauth/revoke从token(刷新撤销整个链,仅访问令牌本身)。 - 验证令牌:
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是不正确的, VISH显示在家里的错误,并没有 不重定向 的人,所以链接不能被用来引导他们离开别人的网站。
这些错误是什么样子的—没有应用程序和令牌?:
// 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个。
- 没有动态客户端注册,通道退出(前/后通道注销),PAR和DPoP。 对于移动应用程序,请使用https返回地址(应用链接,通用链接),而不是您自己的方案。
应用程序方面的安全性是集成安全页。
验证:2026-10,PWA aa337c45,平台bff65fce._