Перейти к основному содержимому

Step-Up Token

Step-up токен -- опциональный подписанный JWT, возвращаемый после успешной MFA верификации. Он предоставляет криптографическое доказательство того, что пользователь прошёл step-up верификацию для конкретного действия.

Когда использовать

АрхитектураНужен step-up токен?Почему
Серверное приложение (бэкенд вызывает VoxKey напрямую)НетВаш бэкенд уже получил verified: true -- он доверяет ответу VoxKey
SPA (фронтенд вызывает VoxKey)ДаВаш бэкенд не может доверять фронтенду утверждающему "MFA пройден" -- токен предоставляет доказательство
Микросервисы (Сервис A верифицирует, Сервис B действует)ДаСервис B валидирует токен независимо без обращения к VoxKey

Запрос step-up токена

Установите issue_step_up_token: true при создании challenge:

const challenge = await fetch('/api/v1/{realm}/mfa/challenges', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
factor_type: 'totp',
purpose: 'account.delete',
issue_step_up_token: true,
}),
}).then(r => r.json());

После успешной верификации ответ включает step_up_token:

{
"verified": true,
"factor_type": "totp",
"verified_at": "2026-04-03T10:04:30Z",
"step_up_token": "eyJhbGciOiJSUzI1NiIs..."
}

Передача токена

Отправляйте его в заголовке X-Step-Up-Token при вызове вашего бэкенда:

await fetch('/api/delete-account', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Step-Up-Token': stepUpToken,
},
});

Справочник claims

ClaimТипОписание
typstringВсегда step_up+jwt -- отличает от access/id токенов
issstringURL издателя realm'а (такой же как в access tokens)
substringID пользователя
realm_idstringID realm'а
client_idstringOAuth client ID
token_jtistringJTI оригинального access token (для аудита)
challenge_idstringID MFA challenge
purposestringВерифицированное назначение (напр. account.delete)
acrstringurn:voxkey:aal2
amrarrayМетоды аутентификации, напр. ["pwd", "otp"]
auth_timenumberВременная метка верификации
iatnumberВремя выпуска
expnumberВремя истечения (iat + 300 секунд)
jtistringУникальный ID токена

TTL: 5 минут.

Валидация на вашем бэкенде

1. Получите JWKS

curl https://app.voxkey.io/oauth2/{realmUUID}/oidc/jwks

2. Валидируйте токен

Проверьте эти поля:

  1. typ должен быть step_up+jwt (предотвращает принятие обычных access tokens как step-up proof)
  2. iss должен совпадать с URL издателя вашего realm'а
  3. exp должен быть в будущем
  4. sub должен совпадать с аутентифицированным пользователем
  5. purpose должен совпадать с выполняемым действием
  6. client_id должен совпадать с вашим приложением
  7. Подпись должна верифицироваться через JWKS по kid из заголовка JWT

Пример на Node.js (jose)

import * as jose from 'jose';

const JWKS = jose.createRemoteJWKSet(
new URL('https://app.voxkey.io/oauth2/{realmUUID}/oidc/jwks')
);

async function validateStepUpToken(token, expectedPurpose, expectedUserId) {
const { payload, protectedHeader } = await jose.jwtVerify(token, JWKS, {
issuer: 'https://app.voxkey.io/oauth2/{realmUUID}',
});

if (protectedHeader.typ !== 'step_up+jwt') {
throw new Error('Это не step-up токен');
}

if (payload.purpose !== expectedPurpose) {
throw new Error(`Несоответствие purpose: ожидался ${expectedPurpose}, получен ${payload.purpose}`);
}

if (payload.sub !== expectedUserId) {
throw new Error('Несоответствие пользователя');
}

return payload;
}

Пример на PHP (firebase/php-jwt)

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

$jwksJson = file_get_contents('https://app.voxkey.io/oauth2/{realmUUID}/oidc/jwks');
$jwks = json_decode($jwksJson, true);
$keys = JWK::parseKeySet($jwks);

$payload = JWT::decode($stepUpToken, $keys);

if (($payload->typ ?? null) !== 'step_up+jwt') {
throw new Exception('Это не step-up токен');
}

if ($payload->purpose !== $expectedPurpose) {
throw new Exception('Несоответствие purpose');
}

if ($payload->sub !== $expectedUserId) {
throw new Exception('Несоответствие пользователя');
}

Мягкая привязка к access token

Step-up токен содержит token_jti -- JTI access token, который использовался при создании challenge. Однако ваш бэкенд не должен проверять строгое совпадение. Пользователь может обновить свой access token между созданием challenge и использованием step-up токена.

Обязательные проверки: sub + client_id + purpose + exp

token_jti -- только для аудита и расследований.