Аутентификация запроса Yandex Pay

В теле запроса Yandex Pay присылает JWT-токен, подписанный по алгоритму ES256. Токен состоит из трех частей: заголовка (header), полезной нагрузки (payload) и подписи, конкатенированных через точку: <base64-encoded headers JSON>.<base64-encoded payload JSON>.<signature>. Раскодированный из base64 заголовок представляет собой JSON документ со следующей структурой:

{
  "alg": "ES256",         // алгоритм подписи
  "kid": "key-id",        // ID ключа, используется при определении публичного ключа
  "iat": "1639408318",    // UNIX время, когда токен был выпущен
  "exp": "1639429918",    // UNIX время, когда токен истекает, токен признается не валидным после наступления этого времени
  "typ": "JWT"            // тип токена
}

Полезная нагрузка также представляет собой JSON, конкретная структура полей которого определяется вызываемым методом. Подпись необходима для проверки валидности JWT токена.

Важно

Перед десериализацией токена необходимо проверить его валидность.

Алгоритм проверки валидности токена

Для проверки валидности JWT-токена рекомендуем воспользоваться одной из стандартных библиотек из списка: https://jwt.io/libraries. По возможности воздержитесь от ручной реализации данных проверок.

В общем случае алгоритм проверки JWT-токена с помощью библиотеки включает в себя:

  1. Проверку валидности подписи JWT-токена с помощью публичных JWK-ключей, размещенных по адресам: https://sandbox.pay.yandex.ru/api/jwks для тестового окружения и https://pay.yandex.ru/api/jwks для продуктивного окружения. Публичный ключ, используемый для валидации подписи конкретного JWT-токена, выбирается на основе требований alg и kid в заголовке самого токена.
  2. Проверку стандартных требований в заголовках JWT-токена: alg, typ, iat, exp и т.д.

Только в случае, если обе проверки прошли успешно, получатель может десериализовать JSON payload JWT токена. После этого получатель должен дополнительно проверить, что merchantId в payload токена совпадает с ID продавца в Yandex Pay. В противном случае токен не может быть использован.

Если любая из проверок завершилась неудачей, токен считается невалидным, а запрос — неаутентифицированным. Такой запрос должен быть отклонен. В этом случае ожидается ответ 403 Forbidden с reasonCode, равным FORBIDDEN.
Пример ответа:

{
    "status": "fail",
    "reasonCode": "FORBIDDEN",
    "reason": "Invalid merchantId"
}

Кеширование публичных JWK-ключей

Допускается кеширование JWK-ключей, используемых для валидации JWT-токенов, на бэкенде продавца. Время жизни такого кеша необходимо устанавливать таким образом, чтобы это не приводило к ошибкам валидации токенов в случае ротации JWK-ключей на стороне Yandex Pay. На практике некоторые библиотеки по умолчанию устанавливают время жизни такого кеша в 10 минут. В случае кеширования JWK-ключей, необходимо реализовать следующие сценарии проверок.

Сценарий А: Успешная валидация токена кешированными JWK-ключами.

  1. Приходит запрос с токеном, чей kid найден среди кешированных JWK-ключей, и время жизни кеша не истекло.
  2. Валидация подписи токена завершается успешно.
  3. Токен считается валидным.

Сценарий B: Неуспешная валидация токена кешированными JWK-ключами.

  1. Приходит запрос с токеном, чей kid найден среди кешированных JWK-ключей, и время жизни кеша не истекло.
  2. Валидация подписи токена завершается неудачей.
  3. Токен считается невалидным, запрос отклоняется.

Сценарий C: Валидация токена со сбросом кеша JWK-ключей.

  1. Приходит запрос с токеном, чей kid не найден среди кешированных JWK-ключей или время жизни кеша истекло.
  2. Продавец должен сбросить кеш JWK-ключей и запросить весь список активных JWK-ключей заново с соответствующего окружению адреса.
  3. Валидация продолжается по сценарию А или B.

Как и в случае с валидацией токена, рекомендуется использовать стандартные библиотеки и воздержаться от ручной реализации данных сценариев.

Пример валидации JWT токена на Python

Ниже приведен пример успешной валидации JWT токена, выпущенного в sandbox окружении.
Для валидации токенов в продакшен окружении должны использоваться публичные ключи, доступные по адресу https://pay.yandex.ru/api/jwks.

import json
from urllib.request import urlopen

from jose import jwt

YANDEX_JWK_ENDPOINT = "https://sandbox.pay.yandex.ru/api/jwks"

JWT_TOKEN = (
"eyJhbGciOiJFUzI1NiIsImlhdCI6MTY1MDE5Njc1OCwia2lkIjoidGVzdC1rZXkiLCJ0eXAiOiJKV1QifQ.eyJjYXJ0Ijp7Iml0ZW1zIjpbeyJwcm9kdWN"
"0SWQiOiJwMSIsInF1YW50aXR5Ijp7ImNvdW50IjoiMSJ9fV19LCJjdXJyZW5jeUNvZGUiOiJSVUIiLCJtZXJjaGFudElkIjoiMjc2Y2YxZjEtZjhlZC00N"
"GZlLTg5ZTMtNWU0MTEzNDZkYThkIn0.YmQjHlh3ddLWgBexQ3QrwtbgAA3u1TVnBl1qnfMIvToBwinH3uH92KGB15m4NAQXdz5nhkjPZZu7RUStJt40PQ"
)

with urlopen(YANDEX_JWK_ENDPOINT) as response:
    public_jwks = json.load(response)

payload = jwt.decode(JWT_TOKEN, public_jwks, algorithms=["ES256"])
print(json.dumps(payload, indent=2))

# {
#   "cart": {
#     "items": [
#       {
#         "productId": "p1",
#         "quantity": {
#           "count": "1"
#         }
#       }
#     ]
#   },
#   "currencyCode": "RUB",
#   "merchantId": "276cf1f1-f8ed-44fe-89e3-5e411346da8d"
# }