openapi: 3.0.3
info:
  title: Delta Auth — API сервера авторизации
  version: "1.0.0"
  description: |
    Сервер авторизации и идентификации пользователей для всех приложений
    компании Delta (мессенджер, видеонаблюдение, CRM и любых внешних сервисов).

    Совместим с **OAuth 2.0** и **OpenID Connect 1.0**.
    Любая стандартная OIDC-библиотека работает через discovery-URL:

        https://auth.delta.online/.well-known/openid-configuration

    ## Два режима использования

    **Режим A — встроенная форма (REST)** — приложение само показывает
    форму логина и регистрации, отправляет данные на наши REST-эндпоинты,
    получает `access_token` + `refresh_token`. Подходит для мобильных
    и доверенных приложений.

    **Режим B — OAuth 2.0 Authorization Code Flow** — приложение редиректит
    пользователя на нашу страницу `/login` (как «Войти через Google»),
    после успешного входа браузер возвращается на `redirect_uri` с
    одноразовым `code`, который обменивается на токены через `/oauth/token`.
    Подходит для веб-приложений и внешних интеграций.

    ## Как подключить приложение

    1. Получить у администратора `client_id` (+ `client_secret`, если backend).
    2. Указать список разрешённых `redirect_uri`.
    3. Реализовать один из двух режимов выше.
    4. Валидировать `access_token` через JWKS (offline) или `/oauth/introspect` (online).

  contact:
    name: Delta Support
    email: info@delta.online

servers:
  - url: https://auth.delta.online
    description: Production

tags:
  - name: discovery
    description: OpenID Connect discovery
  - name: auth
    description: Локальная аутентификация (REST для приложений)
  - name: otp
    description: Одноразовые коды (SMS / Email)
  - name: oauth2
    description: OAuth 2.0 / OIDC сервер (Authorization Code, refresh, introspect)
  - name: social
    description: Вход через Google / Яндекс / Mail.ru
  - name: profile
    description: Профиль пользователя
  - name: admin
    description: Управление клиентами, scope и ролями (только админ)

# ============================ SECURITY =================================
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Access-токен в формате JWT (RS256). Выдаётся `/auth/login` или `/oauth/token`.
    basicAuth:
      type: http
      scheme: basic
      description: Аутентификация OAuth-клиента — `client_id:client_secret`.

  schemas:
    Error:
      type: object
      properties:
        error: { type: string, example: invalid_request }
        message: { type: string, example: "поле X обязательно" }

    TokenResponse:
      type: object
      properties:
        access_token: { type: string, description: "JWT access-токен (RS256)" }
        token_type:   { type: string, enum: [Bearer] }
        expires_in:   { type: integer, description: "Срок действия access-токена в секундах" }
        refresh_token: { type: string, description: "Opaque refresh-токен `{jti}.{secret}`" }
        id_token:     { type: string, description: "JWT с claims пользователя (только если scope=openid)" }
        scope:        { type: string, example: "openid profile email offline_access" }

    User:
      type: object
      properties:
        id:             { type: string, format: uuid }
        email:          { type: string, format: email, nullable: true }
        email_verified: { type: boolean }
        phone:          { type: string, nullable: true, example: "+79991234567" }
        phone_verified: { type: boolean }
        full_name:      { type: string, nullable: true }
        avatar_url:     { type: string, nullable: true }
        locale:         { type: string, example: "ru" }
        is_admin:       { type: boolean }
        created_at:     { type: string, format: date-time }

    RegisterRequest:
      type: object
      required: [password]
      properties:
        email:     { type: string, format: email }
        phone:     { type: string, pattern: '^\+?\d{7,15}$', example: "+79991234567" }
        password:  { type: string, minLength: 8, maxLength: 128 }
        full_name: { type: string, maxLength: 255 }

    LoginRequest:
      type: object
      required: [username, password]
      properties:
        username:  { type: string, description: "Email или телефон" }
        password:  { type: string }
        client_id: { type: string, description: "Опционально: к какому приложению относится сессия" }
        scope:     { type: string, default: "openid profile email offline_access" }

    OtpSendRequest:
      type: object
      required: [channel, target]
      properties:
        channel: { type: string, enum: [sms, email] }
        target:  { type: string, description: "Телефон или email" }
        purpose: { type: string, enum: [email_verify, sms_verify, password_reset, login_otp], default: login_otp }

    OtpVerifyRequest:
      type: object
      required: [purpose, target, code]
      properties:
        purpose: { type: string }
        target:  { type: string }
        code:    { type: string, minLength: 4, maxLength: 10 }

    IntrospectResponse:
      type: object
      properties:
        active:     { type: boolean }
        token_type: { type: string, enum: [access_token, refresh_token] }
        scope:      { type: string }
        client_id:  { type: string, nullable: true }
        sub:        { type: string, format: uuid }
        username:   { type: string, nullable: true }
        email:      { type: string, nullable: true }
        email_verified: { type: boolean, nullable: true }
        exp:        { type: integer }
        iat:        { type: integer }
        iss:        { type: string }
        aud:        { type: string }
        jti:        { type: string }

    ClientCreateRequest:
      type: object
      required: [name, redirect_uris]
      properties:
        name:            { type: string, example: "My App" }
        description:     { type: string }
        redirect_uris:   { type: array, items: { type: string, format: uri }, minItems: 1 }
        scopes:          { type: array, items: { type: string }, example: [openid, profile, email] }
        is_confidential: { type: boolean, default: true, description: "true для backend (с секретом), false для SPA/mobile (PKCE)" }

# ============================ PATHS =================================
paths:
  # ----------- DISCOVERY -----------
  /.well-known/openid-configuration:
    get:
      tags: [discovery]
      summary: OpenID Connect discovery
      description: |
        Стандартный документ метаданных OIDC. Любая совместимая библиотека
        (Spring Security, MSAL, openid-client, AppAuth) автоматически
        прочитает его и сконфигурируется.
      responses:
        "200":
          description: OK
          content:
            application/json:
              example:
                issuer: "https://auth.delta.online"
                authorization_endpoint: "https://auth.delta.online/oauth/authorize"
                token_endpoint: "https://auth.delta.online/oauth/token"
                userinfo_endpoint: "https://auth.delta.online/oauth/userinfo"
                revocation_endpoint: "https://auth.delta.online/oauth/revoke"
                introspection_endpoint: "https://auth.delta.online/oauth/introspect"
                jwks_uri: "https://auth.delta.online/.well-known/jwks.json"
                response_types_supported: [code]
                grant_types_supported: [authorization_code, refresh_token, password, client_credentials]
                scopes_supported: [openid, profile, email, phone, offline_access, social_tokens]
                id_token_signing_alg_values_supported: [RS256]
                code_challenge_methods_supported: [S256, plain]

  /.well-known/jwks.json:
    get:
      tags: [discovery]
      summary: Публичные ключи для проверки JWT
      description: |
        JSON Web Key Set с публичным ключом RSA. Используйте для **offline**
        валидации access-токенов (без обращения к нашему серверу).
      responses:
        "200":
          description: OK
          content:
            application/json:
              example:
                keys:
                  - kty: RSA
                    use: sig
                    alg: RS256
                    kid: auth-key-1
                    n: "pNLFDi…"
                    e: AQAB

  # ----------- AUTH (REST) -----------
  /auth/register:
    post:
      tags: [auth]
      summary: Регистрация пользователя
      description: |
        Создаёт пользователя по email и/или телефону. Если задан email — отправляется письмо с кодом подтверждения.
        Если задан телефон — SMS-код. Регистрация не блокируется при недоступности SMTP/SMS-провайдера.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterRequest" }
      responses:
        "201":
          description: Создан
          content:
            application/json:
              example:
                user_id: "c4c767dd-7aa0-48f3-9f9a-fd755c62d07e"
                verify_required: true
                email_sent: true
                sms_sent: false
        "400": { description: "invalid_request" }
        "409": { description: "email_taken / phone_taken" }

  /auth/login:
    post:
      tags: [auth]
      summary: Вход по логину и паролю
      description: |
        Возвращает пару `access_token` + `refresh_token` (если scope содержит `offline_access`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "401": { description: "invalid_credentials" }

  /auth/refresh:
    post:
      tags: [auth]
      summary: Обновить пару токенов
      description: |
        Refresh-токен **ротируется**: после успешного запроса старый токен отзывается,
        выдаётся новая пара. При попытке повторно использовать старый — отзывается
        вся цепочка токенов (anti-replay).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400": { description: invalid_grant }

  /auth/logout:
    post:
      tags: [auth]
      summary: Выход (отзыв refresh-токена и сессии)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token: { type: string }
      responses:
        "200": { description: OK }

  /auth/me:
    get:
      tags: [auth]
      summary: Профиль текущего пользователя
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { description: unauthorized }

  # ----------- OTP -----------
  /auth/otp/send:
    post:
      tags: [otp]
      summary: Отправить одноразовый код
      description: |
        Канал `sms` отправляет через MainSMS (с Telegram-fallback). Канал `email` —
        через наш Postfix с DKIM. Коды живут 5 минут, имеют ограничение по попыткам.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OtpSendRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              example:
                ok: true
                expires_at: "2026-05-13T10:22:11Z"
                ttl: 300
                target: "+79991234567"

  /auth/otp/verify:
    post:
      tags: [otp]
      summary: Проверить код
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OtpVerifyRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              example:
                ok: true
                user_id: "c4c767dd-7aa0-48f3-9f9a-fd755c62d07e"
        "400": { description: "expired / invalid_code / too_many_attempts" }

  # ----------- OAUTH2 -----------
  /oauth/authorize:
    get:
      tags: [oauth2]
      summary: Authorization Code Flow (с PKCE)
      description: |
        Точка входа для OAuth flow. Если пользователь не залогинен — редиректит на `/login`.
        Если не дано согласие — редиректит на `/consent`. В случае успеха возвращает
        одноразовый `code` на `redirect_uri`.
      parameters:
        - { in: query, name: response_type, required: true, schema: { type: string, enum: [code] } }
        - { in: query, name: client_id,     required: true, schema: { type: string } }
        - { in: query, name: redirect_uri,  required: true, schema: { type: string, format: uri } }
        - { in: query, name: scope,         schema: { type: string, default: "openid profile email" } }
        - { in: query, name: state,         schema: { type: string, description: "Антифорджери токен. Обязательно для безопасности." } }
        - { in: query, name: code_challenge,        schema: { type: string, description: "PKCE" } }
        - { in: query, name: code_challenge_method, schema: { type: string, enum: [S256, plain] } }
        - { in: query, name: nonce,         schema: { type: string, description: "Для id_token (OIDC)" } }
      responses:
        "302": { description: "Редирект на redirect_uri?code=...&state=..." }
        "400": { description: "invalid_client / invalid_redirect_uri / invalid_scope" }

  /oauth/token:
    post:
      tags: [oauth2]
      summary: Обмен code / refresh на токены
      description: |
        Поддерживает grant_type: `authorization_code`, `refresh_token`, `password`, `client_credentials`.
        Клиент аутентифицируется одним из способов:
          - Basic Auth (`client_id:client_secret`)
          - `client_secret_post` (поля в теле)
          - PKCE без секрета (для public клиентов)
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type]
              properties:
                grant_type:    { type: string, enum: [authorization_code, refresh_token, password, client_credentials] }
                code:          { type: string }
                redirect_uri:  { type: string }
                code_verifier: { type: string, description: "PKCE" }
                refresh_token: { type: string }
                username:      { type: string, description: "Для grant_type=password" }
                password:      { type: string, description: "Для grant_type=password" }
                client_id:     { type: string }
                client_secret: { type: string }
                scope:         { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400": { description: "invalid_grant / unsupported_grant_type" }
        "401": { description: "invalid_client" }

  /oauth/userinfo:
    get:
      tags: [oauth2]
      summary: UserInfo (OIDC)
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              example:
                sub: "c4c767dd-7aa0-48f3-9f9a-fd755c62d07e"
                email: "info@delta.online"
                email_verified: true
                phone_number: "+79991234567"
                phone_number_verified: true
                name: "Иван Иванов"
                picture: "https://..."
                locale: "ru"

  /oauth/introspect:
    post:
      tags: [oauth2]
      summary: Проверка токена (RFC 7662)
      description: |
        Backend-сервисы (мессенджер, CRM, VMS) используют этот эндпоинт для **онлайн** проверки токенов.
        Для offline-проверки используйте JWKS.
      security: [{ basicAuth: [] }]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                token_type_hint: { type: string, enum: [access_token, refresh_token] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IntrospectResponse" }

  /oauth/revoke:
    post:
      tags: [oauth2]
      summary: Отзыв токена (RFC 7009)
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
                token_type_hint: { type: string, enum: [access_token, refresh_token] }
      responses:
        "200": { description: OK }

  # ----------- SOCIAL -----------
  /auth/{provider}:
    get:
      tags: [social]
      summary: Войти через внешнего провайдера
      description: Редиректит пользователя на Google / Яндекс / Mail.ru.
      parameters:
        - { in: path,  name: provider, required: true, schema: { type: string, enum: [google, yandex, mailru] } }
        - { in: query, name: return_to, schema: { type: string, description: "URL для возврата с токенами в URL-fragment" } }
      responses:
        "302": { description: Редирект к провайдеру }

  /auth/{provider}/callback:
    get:
      tags: [social]
      summary: Колбэк от провайдера
      parameters:
        - { in: path, name: provider, required: true, schema: { type: string, enum: [google, yandex, mailru] } }
        - { in: query, name: code,  required: true, schema: { type: string } }
        - { in: query, name: state, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK (или редирект на return_to с токенами в hash)

  /auth/{provider}/tokens:
    get:
      tags: [social]
      summary: Получить access-токен внешнего провайдера
      description: |
        Требует у токена scope `social_tokens`. Если access-токен провайдера протух
        и есть refresh-токен, сервер автоматически его обновит.
      security: [{ bearerAuth: [social_tokens] }]
      parameters:
        - { in: path, name: provider, required: true, schema: { type: string, enum: [google, yandex, mailru] } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              example:
                provider: google
                access_token: "ya29.a0..."
                expires_at: "2026-05-13T12:00:00Z"
                scope: "openid email profile"
        "403": { description: insufficient_scope }
        "404": { description: not_linked }

  # ----------- ADMIN -----------
  /clients:
    get:
      tags: [admin]
      summary: Список OAuth-клиентов
      security: [{ bearerAuth: [] }]
      responses:
        "200": { description: OK }
        "403": { description: forbidden }

    post:
      tags: [admin]
      summary: Зарегистрировать новое приложение
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientCreateRequest" }
      responses:
        "201":
          description: Создан
          content:
            application/json:
              example:
                client_id: "3f5cc230709b4d4d692020be9cf8b630"
                client_secret: "a2545996..."
                name: "My App"
                redirect_uris: ["https://app.example.com/callback"]
                scopes: [openid, profile, email]

  /clients/{clientId}:
    delete:
      tags: [admin]
      summary: Отключить приложение
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
      responses:
        "200": { description: OK }

  /clients/{clientId}/scopes:
    get:
      tags: [admin]
      summary: Каталог scope приложения
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
      responses:
        "200": { description: OK }
    post:
      tags: [admin]
      summary: Добавить scope в каталог
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope]
              properties:
                scope:       { type: string, pattern: '^[a-z0-9_:.-]+$', example: "crm:write" }
                description: { type: string }
                is_default:  { type: boolean, default: false, description: "Выдаётся всем пользователям по умолчанию" }
      responses:
        "201": { description: Создан }

  /clients/{clientId}/roles:
    get:
      tags: [admin]
      summary: Роли (пакеты scope) приложения
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
      responses: { "200": { description: OK } }
    post:
      tags: [admin]
      summary: Создать роль
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:        { type: string, example: admin }
                description: { type: string }
                scopes:      { type: array, items: { type: string }, example: [crm:read, crm:write] }
                is_default:  { type: boolean, default: false }
      responses: { "201": { description: Создан } }

  /clients/{clientId}/users/{userId}/scopes:
    post:
      tags: [admin]
      summary: Выдать scope пользователю в этом приложении
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
        - { in: path, name: userId,   required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope]
              properties:
                scope: { type: string }
      responses: { "200": { description: OK } }

  /clients/{clientId}/users/{userId}/scopes/{scope}:
    delete:
      tags: [admin]
      summary: Отозвать scope у пользователя
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
        - { in: path, name: userId,   required: true, schema: { type: string, format: uuid } }
        - { in: path, name: scope,    required: true, schema: { type: string } }
      responses: { "200": { description: OK } }

  /clients/{clientId}/users/{userId}/roles:
    post:
      tags: [admin]
      summary: Назначить роль пользователю
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
        - { in: path, name: userId,   required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role_id]
              properties:
                role_id: { type: string, format: uuid }
      responses: { "200": { description: OK } }

  /clients/{clientId}/users/{userId}/permissions:
    get:
      tags: [admin]
      summary: Эффективные scope пользователя в приложении
      security: [{ bearerAuth: [] }]
      parameters:
        - { in: path, name: clientId, required: true, schema: { type: string } }
        - { in: path, name: userId,   required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              example:
                scopes: [crm:read, crm:write]
