openapi: 3.0.3
info:
  title: Optimal Route SSO Integration API
  version: 1.2.0
  description: |
    Спецификация покрывает:
    - embedded-вход через iframe и `POST /auth/sso/exchange`
    - redirect-flow через `GET /auth/sso/start` и `GET /auth/sso/callback`
    - reference endpoints demo-host для локальной проверки
    - контракт `postMessage` в custom extensions `x-postmessage-contracts`

    В production замените demo-значения:
    - `https://demo.routemax.ru` на origin вашего приложения
    - `routemax-demo` на согласованный `providerId`
    - `http://localhost:4000` на origin портала или mock-host

externalDocs:
  description: HTML integration guide
  url: ./integration-guide.html

servers:
  - url: https://demo.routemax.ru
    description: App origin и API сервиса

tags:
  - name: Embedded entry
    description: Точка входа SPA в embedded-режиме внутри iframe
  - name: App SSO API
    description: Реальные SSO endpoints приложения
  - name: Demo host reference
    description: Локальные reference endpoints mock-host для Scenario A и Scenario B

paths:
  /:
    get:
      tags:
        - Embedded entry
      summary: Открыть приложение в embedded-режиме
      description: |
        SPA entry point для встраивания приложения в iframe.

        Важные условия:
        - `view_mode=embedded` обязателен
        - `provider` должен совпадать с `providerId` в voucher и `postMessage`
        - `parent_origin` должен входить в allowlist `VITE_PARENT_ORIGINS`
        - iframe должен быть разрешён сервером через `ALLOWED_FRAME_ANCESTORS`
      parameters:
        - name: view_mode
          in: query
          required: true
          description: Включает embedded-режим фронтенда
          schema:
            type: string
            enum:
              - embedded
        - name: provider
          in: query
          required: true
          description: Согласованный SSO provider ID
          schema:
            type: string
            minLength: 1
            maxLength: 128
          example: routemax-demo
        - name: parent_origin
          in: query
          required: false
          description: |
            Точный origin родительского портала. Обязателен, если в `VITE_PARENT_ORIGINS`
            настроено больше одного origin.
          schema:
            type: string
            format: uri
          example: http://localhost:4000
      responses:
        '200':
          description: HTML shell приложения, который затем запускает iframe SSO handshake
          content:
            text/html:
              schema:
                type: string

  /auth/sso/exchange:
    post:
      tags:
        - App SSO API
      summary: Обменять SSO voucher на access token
      operationId: exchangeSsoVoucher
      description: |
        Используется только в iframe-flow.

        Сервер:
        - находит провайдера по `providerId`
        - валидирует JWT voucher
        - извлекает внешний идентификатор пользователя из `sub` или `subjectClaim`
        - создаёт или находит локального пользователя
        - atomically помечает `jti` как использованный
        - возвращает in-memory access token без refresh-cookie
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SsoExchangeRequest'
            examples:
              demo:
                summary: Embedded exchange
                value:
                  providerId: routemax-demo
                  voucher: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvdXRlbWF4LWRlbW8tMSJ9...
      responses:
        '200':
          description: Voucher успешно подтверждён
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoExchangeResponse'
        '400':
          description: Некорректный request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorResponse'
        '401':
          description: Voucher или provider невалидны
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                unknownProvider:
                  value:
                    message: Unknown or disabled SSO provider
                invalidVoucher:
                  value:
                    message: Invalid or expired SSO voucher
                missingExternalId:
                  value:
                    message: Missing external identity in voucher
                replay:
                  value:
                    message: Invalid or replayed SSO voucher
        '500':
          description: Внутренняя ошибка на этапе first-time provisioning или auth storage
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: Internal auth error

  /auth/sso/providers:
    get:
      tags:
        - App SSO API
      summary: Получить список SSO-провайдеров для redirect-flow
      operationId: listSsoProviders
      description: |
        Возвращает только активные провайдеры, у которых настроен `authorizationUrl`.
        Обычно используется для кнопок direct login вне iframe.
      responses:
        '200':
          description: Список доступных SSO-провайдеров
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SsoProvidersResponse'
              examples:
                demo:
                  value:
                    providers:
                      - id: routemax-demo
                        name: RouteMax Demo Portal (localhost)
        '500':
          description: Не удалось загрузить провайдеров
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: Failed to load SSO providers

  /auth/sso/start:
    get:
      tags:
        - App SSO API
      summary: Стартовать redirect-flow
      operationId: startRedirectSso
      description: |
        Генерирует short-lived cookies:
        - `sso_state`
        - `sso_provider`
        - `sso_return_url`

        Затем перенаправляет браузер на `authorizationUrl` выбранного провайдера,
        добавляя query parameter `state`.
      parameters:
        - name: provider
          in: query
          required: true
          description: Идентификатор провайдера из `sso_providers.id`
          schema:
            type: string
            minLength: 1
            maxLength: 128
          example: routemax-demo
        - name: return_url
          in: query
          required: false
          description: |
            Только относительный URL внутри приложения. После успешного callback
            будет добавлен marker `?sso=1` или `&sso=1`.
          schema:
            type: string
            pattern: ^/.*$
          example: /history
      responses:
        '302':
          description: Redirect на портал или mock-host
          headers:
            Location:
              description: "`authorizationUrl` провайдера со `state`"
              schema:
                type: string
                format: uri
              example: http://localhost:4000/sso/authorize?state=5f0b2db6e5bc...
            Set-Cookie:
              description: Устанавливает `sso_state`, `sso_provider`, `sso_return_url`
              schema:
                type: string
        '400':
          description: Некорректные параметры старта
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missingProvider:
                  value:
                    message: Missing provider parameter
                invalidReturnUrl:
                  value:
                    message: Invalid return_url
                unsupportedProvider:
                  value:
                    message: Unknown SSO provider or provider does not support redirect login
        '500':
          description: Внутренняя ошибка запуска SSO
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: Internal auth error

  /auth/sso/callback:
    get:
      tags:
        - App SSO API
      summary: Завершить redirect-flow после возврата от портала
      operationId: completeRedirectSso
      description: |
        Проверяет:
        - `voucher` и `state` из query string
        - cookies `sso_state`, `sso_provider`, `sso_return_url`
        - валидность voucher
        - уникальность `jti`

        При успехе:
        - создаёт или находит локального пользователя
        - сохраняет refresh token
        - устанавливает refresh-cookie
        - очищает SSO cookies
        - перенаправляет на `APP_BASE_URL + return_url + ?sso=1`

        В duplicate-callback race может вернуть HTML-страницу, которая опрашивает
        `/auth/refresh`, а затем завершает редирект без 401.
      parameters:
        - name: voucher
          in: query
          required: true
          description: JWT voucher от портала
          schema:
            type: string
            minLength: 1
          example: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvdXRlbWF4LWRlbW8tMSJ9...
        - name: state
          in: query
          required: true
          description: Должен совпасть с cookie `sso_state`
          schema:
            type: string
            minLength: 1
          example: 5f0b2db6e5bc8b39d3d0...
      responses:
        '302':
          description: Redirect в SPA после успешного завершения SSO
          headers:
            Location:
              description: Итоговый URL приложения с `?sso=1`
              schema:
                type: string
                format: uri
              example: https://demo.routemax.ru/history?sso=1
            Set-Cookie:
              description: |
                Устанавливает refresh-cookie и очищает `sso_state`, `sso_provider`,
                `sso_return_url`.
              schema:
                type: string
        '200':
          description: HTML fallback page при duplicate-callback race
          content:
            text/html:
              schema:
                type: string
        '400':
          description: Не пройдены query/cookie проверки
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missingQuery:
                  value:
                    message: Missing voucher or state parameter
                invalidState:
                  value:
                    message: Invalid SSO state
                missingCookieProvider:
                  value:
                    message: Missing SSO provider cookie
        '401':
          description: Voucher или провайдер невалидны
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                unknownProvider:
                  value:
                    message: Unknown or disabled SSO provider
                invalidVoucher:
                  value:
                    message: Invalid or expired SSO voucher
                missingExternalId:
                  value:
                    message: Missing external identity in voucher
        '500':
          description: Внутренняя ошибка завершения SSO
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                message: Internal auth error

  /api/voucher:
    get:
      tags:
        - Demo host reference
      summary: Сгенерировать demo voucher для iframe и тестов
      operationId: generateDemoVoucher
      description: |
        Endpoint mock-host для локального Scenario A.

        Возвращает:
        - `providerId`
        - JWT `voucher`
        - `payload` claims
        - `debug` flags

        Используется страницей `scenario-a.html`.
      servers:
        - url: http://localhost:4000
          description: Локальный iframe-host
      parameters:
        - name: expired
          in: query
          required: false
          description: Передайте `1`, чтобы получить уже истёкший voucher
          schema:
            type: string
            enum:
              - '1'
        - name: bad_signature
          in: query
          required: false
          description: Передайте `1`, чтобы подписать voucher неправильным приватным ключом
          schema:
            type: string
            enum:
              - '1'
      responses:
        '200':
          description: Demo voucher создан
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DemoVoucherResponse'
              examples:
                valid:
                  value:
                    providerId: routemax-demo
                    voucher: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvdXRlbWF4LWRlbW8tMSJ9...
                    payload:
                      sub: TEST_001
                      provider: routemax-demo
                      type: sso_voucher
                      jti: 5d6cf7c4-71ae-4104-9bc9-7d7798698b53
                      iss: http://localhost:4000
                      aud: optimal-route
                      iat: 1744195800
                      exp: 1744195860
                    debug:
                      expired: false
                      badSignature: false
                      issuer: http://localhost:4000
                      kid: routemax-demo-1
        '403':
          description: Origin не разрешён политикой demo-host CORS
          content:
            text/plain:
              schema:
                type: string
              example: Origin is not allowed for the demo voucher API

  /sso/authorize:
    get:
      tags:
        - Demo host reference
      summary: Эмуляция portal-side authorize endpoint для redirect-flow
      operationId: demoPortalAuthorize
      description: |
        Endpoint mock-host для Scenario B.

        Получает `state`, генерирует новый voucher и делает `302` на callback
        приложения: `/auth/sso/callback?voucher=...&state=...`.
      servers:
        - url: http://localhost:4000
          description: Локальный iframe-host
      parameters:
        - name: state
          in: query
          required: true
          description: State, ранее сгенерированный приложением в `/auth/sso/start`
          schema:
            type: string
            minLength: 1
          example: 5f0b2db6e5bc8b39d3d0...
      responses:
        '302':
          description: Redirect назад на callback приложения
          headers:
            Location:
              schema:
                type: string
                format: uri
              example: https://demo.routemax.ru/auth/sso/callback?voucher=eyJ...&state=5f0b2db6...
        '400':
          description: Отсутствует обязательный state
          content:
            text/plain:
              schema:
                type: string
              example: Missing state parameter

components:
  schemas:
    AuthUser:
      type: object
      required:
        - id
        - email
        - role
      properties:
        id:
          type: string
          format: uuid
          example: 63e95117-5bb1-4a4e-a7f0-230a2306fb58
        email:
          type: string
          format: email
          example: sso:routemax-demo:TEST_001@internal
        role:
          type: string
          enum:
            - user
            - admin
          example: user

    SsoExchangeRequest:
      type: object
      required:
        - providerId
        - voucher
      properties:
        providerId:
          type: string
          minLength: 1
          maxLength: 128
          example: routemax-demo
        voucher:
          type: string
          minLength: 1
          description: Короткоживущий JWT voucher с TTL не более 60 секунд
          example: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvdXRlbWF4LWRlbW8tMSJ9...

    SsoExchangeResponse:
      type: object
      required:
        - accessToken
        - expiresInSeconds
        - user
      properties:
        accessToken:
          type: string
          description: Bearer access token приложения
          example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
        expiresInSeconds:
          type: integer
          minimum: 1
          example: 900
        user:
          $ref: '#/components/schemas/AuthUser'

    SsoProviderPublic:
      type: object
      required:
        - id
        - name
      properties:
        id:
          type: string
          example: routemax-demo
        name:
          type: string
          example: RouteMax Demo Portal (localhost)

    SsoProvidersResponse:
      type: object
      required:
        - providers
      properties:
        providers:
          type: array
          items:
            $ref: '#/components/schemas/SsoProviderPublic'

    ErrorResponse:
      type: object
      required:
        - message
      properties:
        message:
          type: string
          example: Internal auth error

    ValidationErrorResponse:
      type: object
      required:
        - message
        - issues
      properties:
        message:
          type: string
          example: Invalid request payload
        issues:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          example:
            providerId:
              - String must contain at least 1 character(s)

    SsoVoucherPayload:
      type: object
      required:
        - sub
        - provider
        - type
        - jti
        - iat
        - exp
      properties:
        sub:
          type: string
          description: Внешний идентификатор пользователя на стороне портала
          example: TEST_001
        provider:
          type: string
          description: Должен совпадать с согласованным providerId
          example: routemax-demo
        type:
          type: string
          enum:
            - sso_voucher
        jti:
          type: string
          description: Одноразовый идентификатор voucher для replay-защиты
          example: 5d6cf7c4-71ae-4104-9bc9-7d7798698b53
        iss:
          type: string
          nullable: true
          description: Issuer claim, если настроен у провайдера
          example: http://localhost:4000
        aud:
          type: string
          nullable: true
          description: Audience claim, если настроен у провайдера
          example: optimal-route
        iat:
          type: integer
          format: int64
          description: Unix timestamp выпуска
          example: 1744195800
        exp:
          type: integer
          format: int64
          description: Unix timestamp истечения
          example: 1744195860

    DemoVoucherDebug:
      type: object
      required:
        - expired
        - badSignature
        - issuer
        - kid
      properties:
        expired:
          type: boolean
          example: false
        badSignature:
          type: boolean
          example: false
        issuer:
          type: string
          example: http://localhost:4000
        kid:
          type: string
          example: routemax-demo-1

    DemoVoucherResponse:
      type: object
      required:
        - providerId
        - voucher
        - payload
        - debug
      properties:
        providerId:
          type: string
          example: routemax-demo
        voucher:
          type: string
          example: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvdXRlbWF4LWRlbW8tMSJ9...
        payload:
          $ref: '#/components/schemas/SsoVoucherPayload'
        debug:
          $ref: '#/components/schemas/DemoVoucherDebug'

    IframeReadyMessage:
      type: object
      required:
        - type
        - providerId
      properties:
        type:
          type: string
          enum:
            - IFRAME_READY
        providerId:
          type: string
          example: routemax-demo

    RequestTokenRefreshMessage:
      type: object
      required:
        - type
        - providerId
      properties:
        type:
          type: string
          enum:
            - REQUEST_TOKEN_REFRESH
        providerId:
          type: string
          example: routemax-demo

    IframeResizeMessage:
      type: object
      required:
        - type
        - height
      properties:
        type:
          type: string
          enum:
            - IFRAME_RESIZE
        height:
          type: number
          format: double
          minimum: 0
          example: 1240

    SsoVoucherMessage:
      type: object
      required:
        - type
        - providerId
        - voucher
      properties:
        type:
          type: string
          enum:
            - SSO_VOUCHER
        providerId:
          type: string
          example: routemax-demo
        voucher:
          type: string
          example: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvdXRlbWF4LWRlbW8tMSJ9...

    SsoVoucherRefreshedMessage:
      type: object
      required:
        - type
        - providerId
        - voucher
      properties:
        type:
          type: string
          enum:
            - SSO_VOUCHER_REFRESHED
        providerId:
          type: string
          example: routemax-demo
        voucher:
          type: string
          example: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvdXRlbWF4LWRlbW8tMSJ9...

x-voucher-requirements:
  summary: Что сервер валидирует в voucher
  rules:
    - "Алгоритм подписи должен быть `RS256`"
    - "`payload.provider` должен совпадать с ожидаемым `providerId`"
    - "`type` должен быть равен `sso_voucher`"
    - "`exp - iat` не может превышать 60 секунд"
    - "`jti` должен быть уникальным для данного `providerId`"
    - "`iss` и `aud` проверяются, если они настроены в `sso_providers`"
    - "внешний идентификатор берётся из `sub` или из `subjectClaim`"

x-postmessage-contracts:
  description: |
    Эти сообщения не являются HTTP API, поэтому вынесены в vendor extension.
    Используйте точный `targetOrigin`, проверяйте `event.origin` и `event.source`.
  serviceToPortal:
    - name: IFRAME_READY
      when: iframe полностью инициализирован и готов получить первый voucher
      schema:
        $ref: '#/components/schemas/IframeReadyMessage'
    - name: REQUEST_TOKEN_REFRESH
      when: access token приложения истёк и нужен новый voucher
      schema:
        $ref: '#/components/schemas/RequestTokenRefreshMessage'
    - name: IFRAME_RESIZE
      when: изменилась внутренняя высота интерфейса
      schema:
        $ref: '#/components/schemas/IframeResizeMessage'
  portalToService:
    - name: SSO_VOUCHER
      when: ответ на первый `IFRAME_READY`
      schema:
        $ref: '#/components/schemas/SsoVoucherMessage'
    - name: SSO_VOUCHER_REFRESHED
      when: ответ на `REQUEST_TOKEN_REFRESH`
      schema:
        $ref: '#/components/schemas/SsoVoucherRefreshedMessage'
