Инструкция интеграции для портала
Документ для команды портала. Описывает текущий контракт интеграции сервиса Оптимальный маршрут через iFrame и SSO по состоянию на текущую реализацию приложения.
Статус v1.2
| Параметр | Значение |
|---|---|
| Версия документа | v1.2 |
| Последнее обновление | 2026-04-03 |
| Ответственная сторона | Команда Оптимальный маршрут |
POST /auth/sso/exchange, GET /auth/sso/providers, GET /auth/sso/start, GET /auth/sso/callback, embedded-режим ?view_mode=embedded&provider=..., обмен сообщениями через postMessage, replay-защита ваучеров по jti.Что поддерживается в v1.2
Встраивание через iFrame
Сервис отображается внутри страницы портала в iframe.
- Backend-эндпоинт выдачи ваучера
- Frontend-обработчик
postMessage - Протокол IFRAME_READY / SSO_VOUCHER
Публичный мониторинг (без SSO)
Встраивается только вкладка «Мониторинг АЗС», без авторизации.
- URL
/monitoring?view_mode=embedded - Без SSO, ваучеров и провайдера
- CSP
frame-ancestorsизALLOWED_FRAME_ANCESTORS
Прямой вход по URL
Пользователь открывает сервис напрямую из браузера.
- Фиксированный
authorization_urlна портале - Принимает только
state - Возвращает на наш callback с
voucherиstate
Важные ограничения версии v1
- В
embedded-режиме сервис открывается какhttps://demo.routemax.ru/?view_mode=embedded&provider=<providerId>&parent_origin=<portalOrigin>; если вVITE_PARENT_ORIGINSнастроено несколько origin, параметрparent_originобязателен - Для
postMessageиспользуется allowlist черезVITE_PARENT_ORIGINSи точныйparent_originв URL iframe - В
redirect-flow портал не получаетredirect_uri,providerилиreturn_urlв query — на портал уходит толькоstate - URL портального SSO-эндпоинта хранится у нас в конфигурации провайдера как
authorization_url - Один и тот же ваучер можно использовать только один раз — в payload обязателен
jti - Максимальный срок жизни ваучера: 60 секунд
- Нативные OIDC ID Token и JWKS не входят в контракт — поддерживаются только подписанные порталом JWT-ваучеры
Как начинается redirect-flow
- Frontend нашего сервиса запрашивает
GET /auth/sso/providers - Пользователь нажимает кнопку
Войти через <provider.name> - Браузер открывает
GET /auth/sso/start?provider=ciam&return_url=/history - Наш backend проверяет
providerиreturn_url(должен быть относительным), генерирует непрозрачныйstateи ставит cookies:sso_state,sso_provider,sso_return_url - Наш backend редиректит браузер на согласованный
authorization_url, добавляя только query-параметрstate
GET https://portal.corp.ru/sso/authorize?state=a3f8c2d1...
state для портала непрозрачен. Его нельзя декодировать, дополнять своими данными, менять формат или генерировать заново. Портал должен вернуть ровно то значение, которое получил.authorization_url не получает redirect_uri, provider и return_url. Портал должен сам знать фиксированный callback URL на нашу сторону для данного провайдера и окружения.Шаг 0 — Согласование параметров
Перед стартом интеграции передайте нам:
| Параметр | Описание | Пример |
|---|---|---|
providerId | Стабильный идентификатор интеграции | ciam |
name | Отображаемое имя кнопки в direct login | CIAM Portal |
| Режим подписи | Только RS256 | RS256 |
| Публичный ключ портала | Мы проверяем им подпись ваучера | -----BEGIN PUBLIC KEY-----... |
kid | Идентификатор ключа в JWT header | portal-main-2026-01 |
clock_skew_seconds | Допустимое расхождение часов между порталом и нашей API при проверке JWT. Поддерживаем диапазон 0..5 | 5 |
issuer (iss) | Опционально; если настроен у нас, будет строго проверяться | https://portal.corp.ru |
audience (aud) | Опционально; если настроен у нас, будет строго проверяться | optimal-route |
subjectClaim | Claim, из которого мы берём внешний ID пользователя. По умолчанию sub | sub |
authorization_url | Фиксированный URL портального SSO-endpoint для direct login | https://portal.corp.ru/sso/authorize |
enabled | Глобально включает или выключает провайдера у нас. При false провайдер не участвует ни в iframe flow, ни в direct login | true |
| Origin портала | Точный origin родительской страницы для postMessage и frame-ancestors | https://portal.corp.ru |
- Для новых интеграций рекомендуем
subjectClaim = sub. providerIdв бизнес-терминах соответствуетsso_providers.idв нашей БД. Он должен совпадать сproviderв URL iframe,payload.providerв voucher иproviderIdвpostMessage.- Если нужен режим совместимости с другим claim, например
contId, согласуем его отдельно. Соответствующий claim должен быть в payload, ноsubвсё равно остаётся обязательным. - Для
redirect-flow callback URL на нашу сторону согласуется отдельно для каждого окружения, например:https://demo.routemax.ru/auth/sso/callback. authorization_urlнужен только дляredirect/direct login. Для чистого iframe-сценария он может отсутствовать.- Origin-значения передаются нам только как точные origin без path, query и wildcard, например
https://portal.corp.ru. - Если у портала несколько провайдеров, рекомендуем отдельный
authorization_urlна каждыйproviderId, чтобы не передаватьproviderв query.
Как поля хранятся у нас в sso_providers
| Колонка | На что влияет | Когда обязательна |
|---|---|---|
id | Ключ провайдера. По нему API ищет конфигурацию, связывает пользователя, хранит использованные jti и проверяет совпадение payload.provider | Всегда |
name | Текст кнопки Войти через <provider.name> в direct login | Всегда |
method | Алгоритм проверки подписи. В текущем контракте поддерживается только rs256 | Всегда |
public_key | Публичный ключ, которым наша API проверяет подпись voucher | Всегда |
kid | Если задан, сверяется с kid в JWT header | Рекомендуется при ротации ключей |
clock_skew_seconds | Допуск по времени при проверке iat/exp | Всегда; обычно 5 |
subject_claim | Определяет, из какого claim мы извлекаем внешний ID пользователя | Всегда; обычно sub |
issuer | Если заполнен, строго проверяется claim iss | Опционально |
audience | Если заполнен, строго проверяется claim aud | Опционально |
authorization_url | Используется только в redirect/direct login: по нему строится редирект с параметром state | Обязательно для redirect-flow; не нужно для iframe-only |
enabled | Глобальный флаг доступности провайдера. При false он исчезает из /auth/sso/providers и не будет найден в iframe/callback flow | Всегда |
Генерация ключей
openssl genrsa -out portal-private-key.pem 2048
openssl rsa -in portal-private-key.pem -pubout -out portal-public-key.pem
Единый контракт ваучера
Портал выпускает JWT со следующим payload:
{
sub: 'CONT_001',
// Опционально, если заранее согласован кастомный subjectClaim:
// contId: 'CONT_001',
provider: 'ciam',
type: 'sso_voucher',
jti: '4d3f6f1a-5d52-4ee9-a19e-3f62f0fd2c1b',
iss: 'https://portal.corp.ru',
aud: 'optimal-route',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60
}
Обязательные правила
| Поле | Требование |
|---|---|
type | Всегда sso_voucher |
provider | Всегда равен согласованному providerId |
sub | Обязателен всегда |
jti | Уникален для каждого нового ваучера |
| TTL | exp - iat не должен превышать 60 |
| Приватный ключ | Никогда не попадает в браузер |
Что проверяет наш сервер
- Корректность подписи по
RS256 - Совпадение
kidиз JWT header сsso_providers.kid, если он задан - Совпадение
payload.providerс ожидаемымproviderId type === 'sso_voucher'- Срок действия
- Ограничение
exp - iat ≤ 60 - Уникальность
jti issиaud, если они настроены для провайдера- Наличие внешнего идентификатора: из
sub(по умолчанию) или из согласованного claim, еслиsubjectClaimпереопределён
Сценарий A — встраивание через iFrame
URL iframe
<iframe
id="optimal-route-frame"
src="https://demo.routemax.ru/?view_mode=embedded&provider=ciam"
style="width: 100%; border: 0; height: 720px;"
allow="clipboard-write"
></iframe>
Параметры:
view_mode=embeddedвключает embedded-режим на фронтендеprovider=<providerId>нужен для проверки входящих сообщений и ваучеров
A1. Backend: endpoint генерации ваучера
credentials: 'include'. Возвращает JSON, а не HTML логина. Ставит Cache-Control: no-store. На каждый вызов создаёт новый jti.Node.js / Express — RS256
const crypto = require('crypto')
const fs = require('fs')
const jwt = require('jsonwebtoken')
const privateKey = fs.readFileSync('/path/to/portal-private-key.pem')
app.get('/api/sso-voucher', requirePortalAuth, (req, res) => {
const providerId = 'ciam'
const voucher = jwt.sign(
{
sub: String(req.user.contId),
provider: providerId,
type: 'sso_voucher',
jti: crypto.randomUUID(),
iss: 'https://portal.corp.ru',
aud: 'optimal-route'
},
privateKey,
{
algorithm: 'RS256',
keyid: 'portal-main-2026-01',
expiresIn: '60s'
}
)
res.set('Cache-Control', 'no-store')
res.json({ providerId, voucher })
})
A2. Frontend: iframe + обработчик postMessage
<script>
const providerId = 'ciam'
const appOrigin = 'https://demo.routemax.ru'
const frame = document.getElementById('optimal-route-frame')
async function fetchVoucher() {
const response = await fetch('/api/sso-voucher', {
credentials: 'include',
headers: { Accept: 'application/json' }
})
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
window.location.href = '/login'
return null
}
throw new Error(`Voucher endpoint failed with status ${response.status}`)
}
const data = await response.json()
if (!data || data.providerId !== providerId || typeof data.voucher !== 'string') {
throw new Error('Invalid voucher response payload')
}
return data.voucher
}
async function sendVoucher(messageType) {
const voucher = await fetchVoucher()
if (!voucher || !frame.contentWindow) return
frame.contentWindow.postMessage(
{ type: messageType, providerId, voucher },
appOrigin
)
}
window.addEventListener('message', async (event) => {
if (event.origin !== appOrigin) return
if (event.source !== frame.contentWindow) return
const data = event.data
if (!data || typeof data !== 'object' || typeof data.type !== 'string') return
if (data.type === 'IFRAME_READY') {
if (data.providerId !== providerId) return
await sendVoucher('SSO_VOUCHER')
return
}
if (data.type === 'REQUEST_TOKEN_REFRESH') {
if (data.providerId !== providerId) return
await sendVoucher('SSO_VOUCHER_REFRESHED')
return
}
if (data.type === 'IFRAME_RESIZE') {
const nextHeight = Number(data.height)
if (!Number.isFinite(nextHeight)) return
frame.style.height = `${Math.max(400, Math.min(6000, Math.ceil(nextHeight)))}px`
}
})
</script>
- Не отправляйте ваучер до
IFRAME_READY - Всегда используйте точный
targetOrigin, а не'*' - Обязательно проверяйте
event.originиevent.source - При каждом refresh выдавайте новый ваучер с новым
jti
A3. Контракт postMessage
Сообщения от нашего приложения к порталу
{ type: 'IFRAME_READY', providerId }accessToken истёк и нужен новый ваучер. Payload: { type: 'REQUEST_TOKEN_REFRESH', providerId }{ type: 'IFRAME_RESIZE', height }IFRAME_RESIZE не содержит providerId. Это ожидаемое поведение текущей реализации.Сообщения от портала в iframe
IFRAME_READY. Payload: { type: 'SSO_VOUCHER', providerId, voucher }REQUEST_TOKEN_REFRESH. Payload: { type: 'SSO_VOUCHER_REFRESHED', providerId, voucher }'*' как targetOrigin. Всегда проверяйте event.origin и event.source.Что происходит внутри iframe
После получения SSO_VOUCHER или SSO_VOUCHER_REFRESHED наш frontend:
- Проверяет
event.origin === parent_originпосле валидацииparent_originпо allowlistVITE_PARENT_ORIGINS - Проверяет
event.source === window.parent - Проверяет совпадение
providerIdс URL - Вызывает
POST /auth/sso/exchange - Сохраняет
accessTokenтолько в памяти
@startuml Scenario_A_First_Login
title Сценарий A — iFrame + postMessage: первый вход
skinparam sequenceArrowThickness 1.5
skinparam participantPadding 12
skinparam boxPadding 10
skinparam roundcorner 6
actor "Пользователь" as User
box "Браузер" #f5f5f5
participant "Портал\n(Frontend)" as ParentFE
participant "iFrame\n(App Frontend)" as AppFE
end box
participant "Бэкенд\nпортала" as ParentBE
participant "App API\n(Express)" as AppAPI
database "Supabase\n(PostgreSQL)" as DB
autonumber
== Загрузка ==
User -> ParentFE: Открывает страницу портала
ParentFE -> AppFE: <iframe src="...?view_mode=embedded&provider=ciam">
activate AppFE
AppFE -> AppFE: EMBEDDED_MODE = true\nproviderId = 'ciam'
AppFE -> AppFE: Регистрирует listener message
== Handshake ==
AppFE -> ParentFE: postMessage({type:'IFRAME_READY', providerId:'ciam'}, portalOrigin)
note right of ParentFE
Портал не должен отправлять
ваучер до IFRAME_READY.
end note
== Выпуск ваучера ==
ParentFE -> ParentBE: GET /api/sso-voucher
activate ParentBE
ParentBE -> ParentBE: jwt.sign({sub, provider, type, jti, iss, aud})\nTTL = 60s
ParentBE --> ParentFE: {providerId:'ciam', voucher:'eyJ...'}
deactivate ParentBE
ParentFE -> AppFE: postMessage({type:'SSO_VOUCHER', providerId, voucher}, appOrigin)
== Обмен ваучера ==
AppFE -> AppFE: Проверяет event.origin\nПроверяет event.source\nПроверяет providerId
AppFE -> AppAPI: POST /auth/sso/exchange\n{providerId:'ciam', voucher:'eyJ...'}
activate AppAPI
AppAPI -> DB: SELECT * FROM sso_providers WHERE id='ciam'
DB --> AppAPI: {method:'rs256', public_key, kid, issuer, audience}
AppAPI -> AppAPI: verify signature, check claims
AppAPI -> DB: INSERT INTO used_sso_voucher_jtis
DB --> AppAPI: OK
AppAPI -> DB: Найти или создать SSO-пользователя
DB --> AppAPI: {id, email, role}
AppAPI --> AppFE: {accessToken, user}
deactivate AppAPI
== Инициализация ==
AppFE -> AppFE: setAccessToken(token, memoryOnly=true)\nsetUser(user)
AppFE --> User: Приложение загружено внутри iframe
deactivate AppFE
@enduml
@startuml Scenario_A_Refresh
title Сценарий A — iFrame: обновление токена через новый ваучер
skinparam sequenceArrowThickness 1.5
skinparam participantPadding 12
skinparam roundcorner 6
actor "Пользователь" as User
box "Браузер" #f5f5f5
participant "Портал\n(Frontend)" as ParentFE
participant "iFrame\n(App Frontend)" as AppFE
end box
participant "Бэкенд\nпортала" as ParentBE
participant "App API\n(Express)" as AppAPI
database "Supabase" as DB
autonumber
User -> AppFE: Делает действие
AppFE -> AppAPI: REST запрос\nAuthorization: Bearer <expired>
AppAPI --> AppFE: 401 Unauthorized
AppFE -> ParentFE: postMessage({type:'REQUEST_TOKEN_REFRESH', providerId:'ciam'}, portalOrigin)
ParentFE -> ParentBE: GET /api/sso-voucher
activate ParentBE
ParentBE -> ParentBE: Новый ваучер с новым jti
ParentBE --> ParentFE: {providerId, voucher:'eyJ...new...'}
deactivate ParentBE
ParentFE -> AppFE: postMessage({type:'SSO_VOUCHER_REFRESHED', providerId, voucher}, appOrigin)
AppFE -> AppAPI: POST /auth/sso/exchange\n{providerId, voucher}
activate AppAPI
AppAPI -> DB: Проверка подписи, jti, claims
DB --> AppAPI: OK
AppAPI --> AppFE: {accessToken:'renewed', user}
deactivate AppAPI
AppFE -> AppFE: setAccessToken(newToken, memoryOnly=true)
AppFE -> AppAPI: Повтор исходного запроса
AppAPI --> AppFE: 200 OK
AppFE --> User: Данные без перезагрузки
@enduml
Сценарий B — прямой вход по URL
B1. Портальный SSO-endpoint
Наш сервис редиректит пользователя на ваш authorization_url с единственным query-параметром state:
GET https://portal.corp.ru/sso/authorize?state=a3f8c2d1...
authorization_url:
- не получает
redirect_uri - не получает
provider - не получает
return_url - должен сам знать фиксированный callback URL на нашу сторону для данного провайдера и окружения
Минимальный контракт
- Принять
GET <authorization_url>?state=... - Проверить, залогинен ли пользователь на портале
- Если нет, сохранить
stateв серверной сессии и после логина продолжить flow - Выпустить новый ваучер по тому же контракту, что и для iFrame
- Вернуть пользователя на наш callback:
https://demo.routemax.ru/auth/sso/callback?voucher=...&state=...
Node.js / Express
const crypto = require('crypto')
const fs = require('fs')
const jwt = require('jsonwebtoken')
const CALLBACK_URL = 'https://demo.routemax.ru/auth/sso/callback'
const privateKey = fs.readFileSync('/path/to/portal-private-key.pem')
app.get('/sso/authorize', requirePortalAuth, (req, res) => {
const state = String(req.query.state || '')
if (!state) {
return res.status(400).send('Missing state')
}
const voucher = jwt.sign(
{
sub: String(req.user.contId),
provider: 'ciam',
type: 'sso_voucher',
jti: crypto.randomUUID(),
iss: 'https://portal.corp.ru',
aud: 'optimal-route'
},
privateKey,
{
algorithm: 'RS256',
keyid: 'portal-main-2026-01',
expiresIn: '60s'
}
)
const callbackUrl = new URL(CALLBACK_URL)
callbackUrl.searchParams.set('voucher', voucher)
callbackUrl.searchParams.set('state', state)
res.redirect(callbackUrl.toString())
})
B2. Что происходит после callback
После возврата пользователя на /auth/sso/callback?voucher=...&state=... наш backend:
- Проверяет
stateпо cookiesso_state - Берёт ожидаемый
providerIdиз cookiesso_provider - Валидирует ваучер
- Создаёт или находит SSO-пользователя
- Ставит
refresh-cookie - Очищает SSO-cookie
- Редиректит на frontend с безопасным маркером
?sso=1
Пример итогового URL:
https://demo.routemax.ru/history?sso=1
Затем наш frontend:
- Видит
?sso=1 - Удаляет его из адресной строки
- Выполняет обычный
POST /auth/refresh - Получает
accessTokenи профиль пользователя
?sso=1 не является секретом. return_url хранится на нашей стороне и не уходит на портал.Неаутентифицированный пользователь
Если пользователь не залогинен на портале — сохраните state в серверной сессии и продолжите после логина:
app.get('/sso/authorize', (req, res) => {
const state = String(req.query.state || '')
if (!state) {
return res.status(400).send('Missing state')
}
if (!req.isAuthenticated()) {
req.session.pendingSsoState = state
return res.redirect('/login')
}
return issueVoucherAndRedirect(req, res, state)
})
Secure, HttpOnly, SameSite=Lax или строже.Повторный callback с тем же ваучером
Если браузер, прокси или пользователь случайно вызовут callback дважды с одним и тем же ваучером:
- Один запрос успешно завершит вход
- Повторный запрос не должен считаться новой авторизацией
- В direct login мы можем вернуть промежуточную страницу
Completing sign-in…, которая короткое время ждёт готовности сессии и затем редиректит на итоговый?sso=1
@startuml Scenario_B_Redirect
title Сценарий B — Прямой URL: вход через redirect-flow
skinparam sequenceArrowThickness 1.5
skinparam participantPadding 12
skinparam boxPadding 10
skinparam roundcorner 6
actor "Пользователь" as User
participant "Браузер\n(App Frontend)" as AppFE
participant "App API\n(Express)" as AppAPI
participant "Бэкенд\nпортала" as PortalBE
database "Supabase" as DB
autonumber
== Начальная загрузка ==
User -> AppFE: Открывает https://demo.routemax.ru
AppFE -> AppAPI: GET /auth/sso/providers
AppAPI -> DB: SELECT id, name FROM sso_providers\nWHERE enabled=true AND authorization_url IS NOT NULL
DB --> AppAPI: [{id:'ciam', name:'CIAM Portal'}]
AppAPI --> AppFE: {providers:[{id:'ciam', name:'CIAM Portal'}]}
AppFE --> User: Показывает кнопку\n"Войти через CIAM Portal"
== Инициация SSO ==
User -> AppFE: Клик "Войти через CIAM Portal"
AppFE -> AppAPI: GET /auth/sso/start?provider=ciam&return_url=/history
activate AppAPI
AppAPI -> DB: SELECT authorization_url FROM sso_providers\nWHERE id='ciam' AND enabled=true
DB --> AppAPI: {authorization_url:'https://portal.corp.ru/sso/authorize'}
AppAPI -> AppAPI: validate return_url => '/history'\ngenerate opaque state
AppAPI --> AppFE: Set-Cookie sso_state=...\nSet-Cookie sso_provider=ciam\nSet-Cookie sso_return_url=/history\n302 -> portal /sso/authorize?state=...
deactivate AppAPI
== Портал ==
AppFE -> PortalBE: GET /sso/authorize?state=...
activate PortalBE
PortalBE -> PortalBE: Пользователь уже залогинен\nна портале
PortalBE -> PortalBE: Не интерпретирует state\nВыпускает новый JWT-ваучер\n{sub, provider:'ciam', type, jti, iss, aud}
PortalBE --> AppFE: 302 -> /auth/sso/callback?voucher=eyJ...&state=...
deactivate PortalBE
note right of PortalBE
Для v1 параметр voucher в query string
должен маскироваться в proxy/application logs.
end note
== Callback ==
AppFE -> AppAPI: GET /auth/sso/callback?voucher=eyJ...&state=...
activate AppAPI
AppAPI -> AppAPI: Проверяет state cookie\nБерёт providerId из sso_provider cookie
AppAPI -> DB: Загружает конфиг provider='ciam'
DB --> AppAPI: {method:'rs256', public_key:'...', kid:'portal-main-2026-01', issuer, audience}
AppAPI -> AppAPI: Проверяет подпись и claims\nСравнивает payload.provider === 'ciam'
AppAPI -> DB: INSERT used_sso_voucher_jtis(provider_id,jti,expires_at)
DB --> AppAPI: OK
AppAPI -> DB: Найти или создать SSO-пользователя
DB --> AppAPI: {id:'uuid-123', email:'sso:ciam:CONT_001@internal'}
AppAPI --> AppFE: Set-Cookie refreshToken=...\n302 -> https://demo.routemax.ru/history?sso=1
deactivate AppAPI
== Bootstrap через refresh-cookie ==
AppFE -> AppFE: Видит ?sso=1\nsetHasSession()\nhistory.replaceState('/history')
AppFE -> AppAPI: POST /auth/refresh\nCookie: refreshToken=...
activate AppAPI
AppAPI --> AppFE: {accessToken:'eyJ...', user:{id,email,role}}
deactivate AppAPI
AppFE -> AppFE: setAccessToken(accessToken)\nsetUser(user)
AppFE --> User: Пользователь залогинен\nи видит /history
@enduml
@startuml Scenario_B_CSRF_Attack
title Негативный сценарий: CSRF на callback
skinparam sequenceArrowThickness 1.5
skinparam roundcorner 6
actor "Атакующий" as Attacker
participant "Браузер жертвы" as Victim
participant "App API" as AppAPI
autonumber
note over Attacker, AppAPI
Атакующий заставляет браузер жертвы
выполнить callback с чужим ваучером.
end note
Attacker -> Victim: /auth/sso/callback?voucher=X&state=ATTACKER_STATE
Victim -> AppAPI: GET callback\nCookie: sso_state=VICTIM_STATE
activate AppAPI
AppAPI -> AppAPI: ATTACKER_STATE != VICTIM_STATE
AppAPI --> Victim: 400 Invalid SSO state
deactivate AppAPI
note over Victim, AppAPI
Callback отклонён. Жертва не залогинена.
end note
@enduml
@startuml Flow_Comparison
title Сравнение потоков: embedded vs redirect
skinparam activityBorderThickness 1.5
skinparam roundcorner 8
|Сценарий A — iFrame|
start
:Портал загружает iframe\n?view_mode=embedded&provider=ciam;
:App -> IFRAME_READY;
:Портал -> SSO_VOUCHER\nproviderId + voucher;
:POST /auth/sso/exchange;
:Проверка подписи и jti;
:Токен -> память;
:Сессией управляет портал;
stop
|Сценарий B — direct URL|
start
:Пользователь открывает app напрямую;
:GET /auth/sso/providers;
:Клик -> GET /auth/sso/start;
:Cookies state/provider/return_url;
:Redirect на портал (только state);
:Портал -> callback c voucher + state;
:Проверка state/provider/jti;
:Refresh-cookie + ?sso=1;
:Frontend -> POST /auth/refresh;
:Полноценная сессия;
stop
@enduml
Ошибки и ожидаемые ответы
POST /auth/sso/exchange
| Что пошло не так | Ожидаемый ответ |
|---|---|
Неизвестный или выключенный providerId | 401 Unknown or disabled SSO provider |
| Некорректная подпись / истёкший ваучер / lifetime > 60 сек | 401 Invalid or expired SSO voucher |
| Нет внешнего идентификатора в ожидаемом claim | 401 Missing external identity in voucher |
Повторный jti | 401 Invalid or replayed SSO voucher |
| Невалидный body | 400 Invalid request payload |
GET /auth/sso/callback
| Что пошло не так | Ожидаемый ответ |
|---|---|
Нет voucher или state | 400 Missing voucher or state parameter |
state не совпал с cookie | 400 Invalid SSO state |
Нет sso_provider cookie | 400 Missing SSO provider cookie |
| Неизвестный провайдер | 401 Unknown or disabled SSO provider |
| Некорректный ваучер | 401 Invalid or expired SSO voucher |
Требования безопасности
| Требование | Почему это важно |
|---|---|
| TTL ваучера не более 60 секунд | Снижает окно перехвата |
Каждый новый ваучер выпускается с новым jti | Один и тот же ваучер нельзя переиспользовать |
| Секрет или приватный ключ только на backend портала | Иначе можно выпускать валидные ваучеры от имени любого пользователя |
Портал не интерпретирует state | Это CSRF-защита на нашей стороне |
В postMessage используется точный targetOrigin | '*' небезопасен |
Проверяются event.origin и event.source | Защита от посторонних окон и iframe |
Ответ ваучерного endpoint: Cache-Control: no-store | JWT не должен кэшироваться |
Параметр voucher нужно маскировать в proxy/application logs | В redirect-flow JWT идёт через query string |
Session cookie портала: Secure, HttpOnly, SameSite | Иначе ослабляется защита flow |
| Origin портала передан нам точно, без wildcard | Он используется в frame-ancestors и postMessage-валидации |
ALLOWED_FRAME_ANCESTORSзадаёт, кому браузер вообще разрешит встраивать наше приложение в iframe через CSPframe-ancestorsVITE_PARENT_ORIGINSзадаёт allowlist доверенных parent origin для embedded SSO. Это отдельная клиентская проверка дляpostMessage; она не заменяетALLOWED_FRAME_ANCESTORS- Если в
VITE_PARENT_ORIGINSнастроено несколько origin, iframe должен передать активный origin через query-параметрparent_origin CORS_ORIGINуправляет только browser XHR/fetch-запросами в нашу API и не влияет ни на embedding iframe, ни наpostMessageAPP_BASE_URLиспользуется для построения финального redirect после callback и должен указывать на origin frontend-приложения- Для всех этих переменных передаются только точные origin без path, query string, hash и wildcard
Тестирование интеграции
Быстрая проверка ваучера без iframe
curl -X POST https://demo.routemax.ru/auth/sso/exchange \
-H 'Content-Type: application/json' \
-d '{"providerId":"ciam","voucher":"eyJhbGci..."}'
Ожидаемый ответ нашего сервера
{
"accessToken": "eyJ...",
"expiresInSeconds": 86400,
"user": {
"id": "uuid-123",
"email": "sso:ciam:CONT_001@internal",
"role": "user"
}
}
Проверка сценария A — iframe
- Открыть страницу портала со встроенным iframe
- Убедиться, что iframe загрузился с
?view_mode=embedded&provider=<providerId>&parent_origin=<portalOrigin>; при одном разрешённом parent origin параметрparent_originможно не передавать, но рекомендуем передавать всегда - Убедиться, что портал дождался
IFRAME_READY - Убедиться, что первый ваучер ушёл как
SSO_VOUCHER - Убедиться, что при истечении нашего access token iframe отправляет
REQUEST_TOKEN_REFRESH - Убедиться, что портал отвечает
SSO_VOUCHER_REFRESHEDс новымjti - Убедиться, что
IFRAME_RESIZEкорректно меняет высоту контейнера
Проверка сценария B — direct login
- Открыть
https://demo.routemax.ru - Нажать кнопку
Войти через <provider.name> - Убедиться, что браузер сначала ушёл на
/auth/sso/start?provider=...&return_url=... - Убедиться, что затем был redirect на согласованный
authorization_urlпортала с единственным querystate - Убедиться, что портал вернул пользователя на
/auth/sso/callback?voucher=...&state=... - Убедиться, что итоговый redirect пошёл на frontend с
?sso=1 - Убедиться, что пользователь оказался залогинен
Чеклист готовности
Сценарий A — iFrame
- Согласован
providerId - Согласованы
issиaud, если они используются - Подпись ваучера настроена только через
RS256 - Согласован и зарегистрирован
kid - Реализован backend endpoint выдачи ваучера
- Ваучер содержит
sub,provider,type,jti,iat,exp - iframe открывается с
?view_mode=embedded&provider=<providerId> - Портал ждёт
IFRAME_READYперед отправкой первого ваучера - При refresh портал выдаёт новый ваучер с новым
jti - Проверяются
event.originиevent.source - Используется точный
targetOrigin - Высота iframe обновляется по
IFRAME_RESIZE
Сценарий B — direct login
- Нам передан
authorization_url - Портал принимает
stateи возвращает его без изменений - Callback URL на нашу сторону согласован заранее
- Портал маскирует
voucherв proxy/application logs - Если пользователь не залогинен,
stateпереживает login-flow - Session cookie портала для продолжения flow защищены
Что прислать при проблемах
- Дата и время запроса в UTC
- Какой это сценарий:
iframeилиdirect login providerId- HTTP-статус и тело ответа проблемного запроса
- Декодированный payload JWT без подписи
- URL проблемного callback или redirect без полного
voucher - Логи браузерной консоли по
postMessage, если проблема в iframe - Было ли это первым входом или refresh внутри iframe
API
Открыть API-документацию