Инструкция интеграции для портала

Документ для команды портала. Описывает текущий контракт интеграции сервиса Оптимальный маршрут через 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

Сценарий A

Встраивание через iFrame

Сервис отображается внутри страницы портала в iframe.

  • Backend-эндпоинт выдачи ваучера
  • Frontend-обработчик postMessage
  • Протокол IFRAME_READY / SSO_VOUCHER
Сценарий C

Публичный мониторинг (без SSO)

Встраивается только вкладка «Мониторинг АЗС», без авторизации.

  • URL /monitoring?view_mode=embedded
  • Без SSO, ваучеров и провайдера
  • CSP frame-ancestors из ALLOWED_FRAME_ANCESTORS
Сценарий B

Прямой вход по 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

  1. Frontend нашего сервиса запрашивает GET /auth/sso/providers
  2. Пользователь нажимает кнопку Войти через <provider.name>
  3. Браузер открывает GET /auth/sso/start?provider=ciam&return_url=/history
  4. Наш backend проверяет provider и return_url (должен быть относительным), генерирует непрозрачный state и ставит cookies: sso_state, sso_provider, sso_return_url
  5. Наш backend редиректит браузер на согласованный authorization_url, добавляя только query-параметр state
url
GET https://portal.corp.ru/sso/authorize?state=a3f8c2d1...
🔒
state для портала непрозрачен. Его нельзя декодировать, дополнять своими данными, менять формат или генерировать заново. Портал должен вернуть ровно то значение, которое получил.
Что это означает для портала: портальный authorization_url не получает redirect_uri, provider и return_url. Портал должен сам знать фиксированный callback URL на нашу сторону для данного провайдера и окружения.

Шаг 0 — Согласование параметров

Перед стартом интеграции передайте нам:

ПараметрОписаниеПример
providerIdСтабильный идентификатор интеграцииciam
nameОтображаемое имя кнопки в direct loginCIAM Portal
Режим подписиТолько RS256RS256
Публичный ключ порталаМы проверяем им подпись ваучера-----BEGIN PUBLIC KEY-----...
kidИдентификатор ключа в JWT headerportal-main-2026-01
clock_skew_secondsДопустимое расхождение часов между порталом и нашей API при проверке JWT. Поддерживаем диапазон 0..55
issuer (iss)Опционально; если настроен у нас, будет строго проверятьсяhttps://portal.corp.ru
audience (aud)Опционально; если настроен у нас, будет строго проверятьсяoptimal-route
subjectClaimClaim, из которого мы берём внешний ID пользователя. По умолчанию subsub
authorization_urlФиксированный URL портального SSO-endpoint для direct loginhttps://portal.corp.ru/sso/authorize
enabledГлобально включает или выключает провайдера у нас. При false провайдер не участвует ни в iframe flow, ни в direct logintrue
Origin порталаТочный origin родительской страницы для postMessage и frame-ancestorshttps://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Всегда

Генерация ключей

bash
openssl genrsa -out portal-private-key.pem 2048
openssl rsa -in portal-private-key.pem -pubout -out portal-public-key.pem

Единый контракт ваучера

Портал выпускает JWT со следующим payload:

javascript
{
  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Уникален для каждого нового ваучера
TTLexp - 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

html
<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 генерации ваучера

Добавьте закрытый endpoint, который выдаёт короткоживущий ваучер для текущего авторизованного пользователя портала. Доступен только авторизованным пользователям. Вызывается frontend с credentials: 'include'. Возвращает JSON, а не HTML логина. Ставит Cache-Control: no-store. На каждый вызов создаёт новый jti.

Node.js / Express — RS256

javascript
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

html
<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

Сообщения от нашего приложения к порталу

Сервис → Портал
IFRAME_READYiframe готов принять первый ваучер. Payload: { type: 'IFRAME_READY', providerId }
Сервис → Портал
REQUEST_TOKEN_REFRESHНаш accessToken истёк и нужен новый ваучер. Payload: { type: 'REQUEST_TOKEN_REFRESH', providerId }
Сервис → Портал
IFRAME_RESIZEИзменилась высота интерфейса внутри iframe. Payload: { type: 'IFRAME_RESIZE', height }
IFRAME_RESIZE не содержит providerId. Это ожидаемое поведение текущей реализации.

Сообщения от портала в iframe

Портал → Сервис
SSO_VOUCHERПосле IFRAME_READY. Payload: { type: 'SSO_VOUCHER', providerId, voucher }
Портал → Сервис
SSO_VOUCHER_REFRESHEDПосле REQUEST_TOKEN_REFRESH. Payload: { type: 'SSO_VOUCHER_REFRESHED', providerId, voucher }
🔒
Никогда не используйте '*' как targetOrigin. Всегда проверяйте event.origin и event.source.

Что происходит внутри iframe

После получения SSO_VOUCHER или SSO_VOUCHER_REFRESHED наш frontend:

  1. Проверяет event.origin === parent_origin после валидации parent_origin по allowlist VITE_PARENT_ORIGINS
  2. Проверяет event.source === window.parent
  3. Проверяет совпадение providerId с URL
  4. Вызывает POST /auth/sso/exchange
  5. Сохраняет accessToken только в памяти
В embedded-режиме мы не ставим refresh-cookie. Повторный выпуск токена делается только через новый ваучер от портала.
Диаграмма 1 — Первый вход через iFrame
Loading...
Загрузка схемы… Раскройте исходник PlantUML ниже и вставьте в plantuml.com
@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
Диаграмма 2 — Обновление токена в iFrame
Loading...
Загрузка схемы…
@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:

url
GET https://portal.corp.ru/sso/authorize?state=a3f8c2d1...
Портальный authorization_url:
  • не получает redirect_uri
  • не получает provider
  • не получает return_url
  • должен сам знать фиксированный callback URL на нашу сторону для данного провайдера и окружения

Минимальный контракт

  1. Принять GET <authorization_url>?state=...
  2. Проверить, залогинен ли пользователь на портале
  3. Если нет, сохранить state в серверной сессии и после логина продолжить flow
  4. Выпустить новый ваучер по тому же контракту, что и для iFrame
  5. Вернуть пользователя на наш callback: https://demo.routemax.ru/auth/sso/callback?voucher=...&state=...

Node.js / Express

javascript
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:

  1. Проверяет state по cookie sso_state
  2. Берёт ожидаемый providerId из cookie sso_provider
  3. Валидирует ваучер
  4. Создаёт или находит SSO-пользователя
  5. Ставит refresh-cookie
  6. Очищает SSO-cookie
  7. Редиректит на frontend с безопасным маркером ?sso=1

Пример итогового URL:

url
https://demo.routemax.ru/history?sso=1

Затем наш frontend:

  1. Видит ?sso=1
  2. Удаляет его из адресной строки
  3. Выполняет обычный POST /auth/refresh
  4. Получает accessToken и профиль пользователя
Access token не передаётся в URL. ?sso=1 не является секретом. return_url хранится на нашей стороне и не уходит на портал.

Неаутентифицированный пользователь

Если пользователь не залогинен на портале — сохраните state в серверной сессии и продолжите после логина:

javascript
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)
})
🔒
Минимальные требования к session cookie: Secure, HttpOnly, SameSite=Lax или строже.

Повторный callback с тем же ваучером

Если браузер, прокси или пользователь случайно вызовут callback дважды с одним и тем же ваучером:

  • Один запрос успешно завершит вход
  • Повторный запрос не должен считаться новой авторизацией
  • В direct login мы можем вернуть промежуточную страницу Completing sign-in…, которая короткое время ждёт готовности сессии и затем редиректит на итоговый ?sso=1
Это нормальное поведение текущей реализации.
Диаграмма 3 — Redirect flow
Loading...
Загрузка схемы…
@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
Диаграмма 4 — CSRF-защита на callback
Loading...
Загрузка схемы…
@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
Диаграмма 5 — Сравнение потоков A и B
Loading...
Загрузка схемы…
@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

Что пошло не такОжидаемый ответ
Неизвестный или выключенный providerId401 Unknown or disabled SSO provider
Некорректная подпись / истёкший ваучер / lifetime > 60 сек401 Invalid or expired SSO voucher
Нет внешнего идентификатора в ожидаемом claim401 Missing external identity in voucher
Повторный jti401 Invalid or replayed SSO voucher
Невалидный body400 Invalid request payload

GET /auth/sso/callback

Что пошло не такОжидаемый ответ
Нет voucher или state400 Missing voucher or state parameter
state не совпал с cookie400 Invalid SSO state
Нет sso_provider cookie400 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-storeJWT не должен кэшироваться
Параметр voucher нужно маскировать в proxy/application logsВ redirect-flow JWT идёт через query string
Session cookie портала: Secure, HttpOnly, SameSiteИначе ослабляется защита flow
Origin портала передан нам точно, без wildcardОн используется в frame-ancestors и postMessage-валидации
Дополнительно на нашей стороне:
  • ALLOWED_FRAME_ANCESTORS задаёт, кому браузер вообще разрешит встраивать наше приложение в iframe через CSP frame-ancestors
  • VITE_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, ни на postMessage
  • APP_BASE_URL используется для построения финального redirect после callback и должен указывать на origin frontend-приложения
  • Для всех этих переменных передаются только точные origin без path, query string, hash и wildcard

Тестирование интеграции

Быстрая проверка ваучера без iframe

bash
curl -X POST https://demo.routemax.ru/auth/sso/exchange \
  -H 'Content-Type: application/json' \
  -d '{"providerId":"ciam","voucher":"eyJhbGci..."}'

Ожидаемый ответ нашего сервера

json
{
  "accessToken": "eyJ...",
  "expiresInSeconds": 86400,
  "user": {
    "id": "uuid-123",
    "email": "sso:ciam:CONT_001@internal",
    "role": "user"
  }
}
Это ответ нашей стороны. Порталу не нужно реализовывать такой ответ у себя. На стороне портала нужно только выпустить ваучер и передать его нам.

Проверка сценария A — iframe

  1. Открыть страницу портала со встроенным iframe
  2. Убедиться, что iframe загрузился с ?view_mode=embedded&provider=<providerId>&parent_origin=<portalOrigin>; при одном разрешённом parent origin параметр parent_origin можно не передавать, но рекомендуем передавать всегда
  3. Убедиться, что портал дождался IFRAME_READY
  4. Убедиться, что первый ваучер ушёл как SSO_VOUCHER
  5. Убедиться, что при истечении нашего access token iframe отправляет REQUEST_TOKEN_REFRESH
  6. Убедиться, что портал отвечает SSO_VOUCHER_REFRESHED с новым jti
  7. Убедиться, что IFRAME_RESIZE корректно меняет высоту контейнера

Проверка сценария B — direct login

  1. Открыть https://demo.routemax.ru
  2. Нажать кнопку Войти через <provider.name>
  3. Убедиться, что браузер сначала ушёл на /auth/sso/start?provider=...&return_url=...
  4. Убедиться, что затем был redirect на согласованный authorization_url портала с единственным query state
  5. Убедиться, что портал вернул пользователя на /auth/sso/callback?voucher=...&state=...
  6. Убедиться, что итоговый redirect пошёл на frontend с ?sso=1
  7. Убедиться, что пользователь оказался залогинен

Чеклист готовности

Сценарий 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
🔒
Не присылайте: общий секрет, приватный ключ портала, полный JWT в открытом виде в чате или тикете.

API

🔗
Общая документация API Оптимальный маршрут доступна по внешней ссылке ниже.
Открыть API-документацию