Проверка всех полей перед доверием данным

Проверка всех полей перед доверием данным в JWT/JWS критична, поскольку криптографическая подпись гарантирует лишь неизменность токена, но не его семантическую корректность в рамках конкретного приложения. Библиотека Jsrsasign предоставляет низкоуровневые и среднеуровневые средства для работы с JWS, однако ответственность за строгую валидацию структуры и содержимого полностью ложится на разработчика.

Любая работа с JWT должна быть разложена на два независимых этапа:

  • криптографическая проверка подписи
  • семантическая проверка полей (claims и header)

Важно исключить использование данных до завершения первого этапа.

import { KJUR } from 'jsrsasign';

const jwt = "header.payload.signature";
const publicKey = `-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----`;

// Проверка подписи
const isValid = KJUR.jws.JWS.verify(jwt, publicKey, ["RS256"]);

if (!isValid) {
  throw new Error("Invalid signature");
}

// Только после этого можно извлекать данные
const parsed = KJUR.jws.JWS.parse(jwt);
const header = parsed.headerObj;
const payload = parsed.payloadObj;

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

Одной из наиболее частых уязвимостей является подмена алгоритма (alg). Даже при проверке подписи необходимо явно ограничивать допустимые алгоритмы.

const allowedAlgs = ["RS256"];

if (!allowedAlgs.includes(header.alg)) {
  throw new Error("Disallowed signing algorithm");
}

Игнорирование этого шага может привести к атакам вида “algorithm confusion”, когда токен, подписанный симметричным алгоритмом, принимается как асимметричный.

Проверка обязательных полей payload

После успешной проверки подписи необходимо убедиться, что все критические поля присутствуют и имеют корректный формат.

Наиболее часто проверяемые claims:

  • iss (issuer)
  • sub (subject)
  • aud (audience)
  • exp (expiration time)
  • nbf (not before)
  • iat (issued at)
  • jti (JWT ID)

Пример строгой проверки:

function validatePayload(payload) {
  if (!payload.iss || typeof payload.iss !== "string") {
    throw new Error("Invalid iss");
  }

  if (!payload.sub || typeof payload.sub !== "string") {
    throw new Error("Invalid sub");
  }

  if (!payload.aud) {
    throw new Error("Missing aud");
  }

  if (typeof payload.exp !== "number") {
    throw new Error("Invalid exp");
  }
}

Проверка временных ограничений

Jsrsasign не навязывает автоматическую проверку времени, поэтому её необходимо реализовать явно.

const now = Math.floor(Date.now() / 1000);

if (payload.nbf && now < payload.nbf) {
  throw new Error("Token not active yet");
}

if (payload.exp && now >= payload.exp) {
  throw new Error("Token expired");
}

if (payload.iat && payload.iat > now + 60) {
  throw new Error("Invalid issued-at time");
}

Допускается небольшая временная дельта (clock skew), но она должна быть ограниченной и явно заданной.

Проверка аудитории и издателя

Поле aud может быть строкой или массивом. Ошибка обработки этого поля часто приводит к приёму токенов, предназначенных для другого сервиса.

function validateAudience(aud, expectedAud) {
  if (Array.isArray(aud)) {
    if (!aud.includes(expectedAud)) {
      throw new Error("Audience mismatch");
    }
  } else {
    if (aud !== expectedAud) {
      throw new Error("Audience mismatch");
    }
  }
}

Аналогично проверяется iss:

if (payload.iss !== "https://auth.example.com") {
  throw new Error("Invalid issuer");
}

Работа с header и параметрами защиты

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

  • alg — алгоритм подписи
  • kid — идентификатор ключа
  • typ — тип токена
  • crit — критические параметры

Проверка kid

Нельзя напрямую доверять ключу, выбранному по kid, без валидации:

const keyMap = {
  "key1": publicKey1,
  "key2": publicKey2
};

if (!header.kid || !keyMap[header.kid]) {
  throw new Error("Unknown key id");
}

const keyToUse = keyMap[header.kid];

Контроль typ

if (header.typ && header.typ !== "JWT") {
  throw new Error("Unexpected token type");
}

Игнорирование crit

Параметр crit обозначает обязательные расширения. Если приложение не поддерживает указанные параметры — токен должен быть отклонён.

if (header.crit && header.crit.length > 0) {
  throw new Error("Unsupported critical headers");
}

Полный цикл безопасной обработки

Jsrsasign предоставляет parse и verify, но не выполняет бизнес-валидацию:

function verifyToken(jwt, key) {
  const allowedAlgs = ["RS256"];

  const isValid = KJUR.jws.JWS.verify(jwt, key, allowedAlgs);
  if (!isValid) {
    throw new Error("Signature invalid");
  }

  const { headerObj, payloadObj } = KJUR.jws.JWS.parse(jwt);

  if (!allowedAlgs.includes(headerObj.alg)) {
    throw new Error("Algorithm not allowed");
  }

  if (headerObj.kid && typeof headerObj.kid !== "string") {
    throw new Error("Invalid kid");
  }

  validatePayload(payloadObj);

  const now = Math.floor(Date.now() / 1000);

  if (payloadObj.exp && now >= payloadObj.exp) {
    throw new Error("Expired token");
  }

  if (payloadObj.nbf && now < payloadObj.nbf) {
    throw new Error("Token not active");
  }

  validateAudience(payloadObj.aud, "my-service");

  if (payloadObj.iss !== "https://auth.example.com") {
    throw new Error("Invalid issuer");
  }

  return payloadObj;
}

Проверка типов значений claims

Даже при наличии подписи данные могут быть некорректными по типу:

  • exp, iat, nbf должны быть числами
  • aud — строка или массив строк
  • пользовательские поля должны иметь ожидаемую структуру
if (payload.role && typeof payload.role !== "string") {
  throw new Error("Invalid role type");
}

if (payload.permissions && !Array.isArray(payload.permissions)) {
  throw new Error("Invalid permissions format");
}

Защита от логических подмен

Подписанный токен может оставаться валидным, но быть логически неприемлемым:

  • роль пользователя изменена на admin
  • увеличен баланс
  • изменён tenant или namespace

Поэтому любая бизнес-логика должна строиться на строгой проверке всех значимых полей:

if (payload.role === "admin" && !isAdminContextAllowed()) {
  throw new Error("Privilege escalation blocked");
}

Извлечение и нормализация данных

Jsrsasign возвращает payload как объект, но он требует нормализации:

function normalizePayload(payload) {
  return {
    sub: String(payload.sub),
    iss: String(payload.iss),
    aud: payload.aud,
    exp: Number(payload.exp),
    iat: Number(payload.iat),
    roles: Array.isArray(payload.roles) ? payload.roles : []
  };
}

Принцип недоверия к любым полям до финальной проверки

Даже при успешной верификации подписи:

  • header может содержать неожиданные параметры
  • payload может быть структурно корректным, но семантически недопустимым
  • время может быть подделано в рамках допустимого диапазона
  • kid может указывать на неожиданный ключ при неправильной конфигурации

Любая точка доступа к данным токена должна быть защищена многоуровневой проверкой, где Jsrsasign используется только как криптографический слой, а вся логика доверия реализуется поверх него.