Аутентификация ADP: Объяснение танца OAuth токенов

Victoria Mycolaivna8 мин

Аутентификация ADP: Как перестать просыпаться от 401 ошибок

Знакомая ситуация? Синхронизация зарплат работает отлично, а потом в половине третьего ночи звонит телефон. Интеграция лежит. Все API запросы валятся с 401.

Токен сдох. Снова.

После 17 ночных аварий из-за токенов я наконец-то собрала аутентификацию, которая не подводит. Расскажу, как избежать этого кошмара.

Быстрый старт за 5 минут 🚀

Схема простая: отправляете свои данные через OAuth 2.0, получаете bearer токен на час.

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

Боевая версия 💪

После сотни ночных звонков собрал аутентификацию, которая не ломается. С retry логикой, нормальной обработкой ошибок и TypeScript поддержкой:

import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import * as https from 'https';
import * as fs from 'fs';
import pRetry from 'p-retry';

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  scope?: string;
}

interface ADPTokenManagerConfig {
  clientId: string;
  clientSecret: string;
  certPath?: string;
  keyPath?: string;
  environment?: 'production' | 'sandbox';
  retryAttempts?: number;
  tokenBufferSeconds?: number;
}

class ADPTokenManager {
  private clientId: string;
  private clientSecret: string;
  private certPath?: string;
  private keyPath?: string;
  private token: string | null = null;
  private tokenExpiry: number | null = null;
  private refreshPromise: Promise<void> | null = null;
  private axiosInstance: AxiosInstance;
  private tokenBufferMs: number;
  private retryAttempts: number;
  private tokenEndpoint: string;

  constructor(config: ADPTokenManagerConfig) {
    this.clientId = config.clientId;
    this.clientSecret = config.clientSecret;
    this.certPath = config.certPath;
    this.keyPath = config.keyPath;
    this.retryAttempts = config.retryAttempts || 3;
    this.tokenBufferMs = (config.tokenBufferSeconds || 300) * 1000; // 5 минут по умолчанию

    // Установить конечную точку на основе среды
    const baseUrl =
      config.environment === 'sandbox'
        ? 'https://accounts.adp.com'
        : 'https://accounts.adp.com';
    this.tokenEndpoint = `${baseUrl}/auth/oauth/v2/token`;

    // Настроить axios с SSL при необходимости
    const axiosConfig: AxiosRequestConfig = {
      timeout: 30000,
      headers: {
        'User-Agent': 'ADP-Integration/1.0',
      },
    };

    if(this.certPath && this.keyPath) {
      axiosConfig.httpsAgent = new https.Agent({
        cert: fs.readFileSync(this.certPath),
        key: fs.readFileSync(this.keyPath),
        rejectUnauthorized: true,
      });
    }

    this.axiosInstance = axios.create(axiosConfig);
  }

  async getToken(): Promise<string> {
    // Если мы уже обновляем, ждем этого
    if(this.refreshPromise) {
      await this.refreshPromise;
      if (!this.token) throw new Error('Обновление токена не удалось');
      return this.token;
    }

    // Проверить, действителен ли токен еще (с буфером)
    if (
      this.token &&
      this.tokenExpiry &&
      this.tokenExpiry > Date.now() + this.tokenBufferMs
    ) {
      return this.token;
    }

    // Обновить токен
    this.refreshPromise = this.refreshToken();
    try {
      await this.refreshPromise;
      if (!this.token) throw new Error('Обновление токена не удалось');
      return this.token;
    } finally {
      this.refreshPromise = null;
    }
  }

  private async refreshToken(): Promise<void> {
    const operation = async () => {
      console.log('Обновление токена ADP...');

      const response = await this.axiosInstance.post<TokenResponse>(
        this.tokenEndpoint,
        new URLSearchParams({
          grant_type: 'client_credentials',
          client_id: this.clientId,
          client_secret: this.clientSecret,
        }),
        {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
        }
      );

      this.token = response.data.access_token;
      // Установить истечение с буфером
      this.tokenExpiry =
        Date.now() + response.data.expires_in * 1000 - this.tokenBufferMs;

      console.log(
        'Токен успешно обновлен, истекает:',
        new Date(this.tokenExpiry)
      );
    };

    try {
      await pRetry(operation, {
        retries: this.retryAttempts,
        onFailedAttempt: error => {
          console.warn(
            `Попытка обновления токена ${error.attemptNumber} не удалась. Осталось ${error.retriesLeft} попыток.`,
            error.message
          );
        },
        minTimeout: 1000,
        maxTimeout: 5000,
        randomize: true,
      });
    } catch(error: any) {
      console.error('Обновление токена не удалось после всех попыток:', error.message);

      // Логировать подробную информацию об ошибке для отладки
      if(error.response) {
        console.error('Статус ответа:', error.response.status);
        console.error('Данные ответа:', error.response.data);
      }

      // Не обнулять существующий токен - может еще работать кратковременно
      throw new Error(`Обновление токена ADP не удалось: ${error.message}`);
    }
  }

  // Используйте это для всех вызовов API
  async apiCall<T = any>(
    url: string,
    options: AxiosRequestConfig = {}
  ): Promise<T> {
    const token = await this.getToken();

    const response = await pRetry(
      async () => {
        return await this.axiosInstance.request<T>({
          ...options,
          url,
          headers: {
            ...options.headers,
            Authorization: `Bearer ${token}`,
          },
        });
      },
      {
        retries: this.retryAttempts,
        onFailedAttempt: async error => {
          // Если получаем 401, пытаемся обновить токен
          if(error.response?.status <mark>= 401 && error.attemptNumber </mark>= 1) {
            console.log('Получен 401, принудительное обновление токена...');
            this.tokenExpiry = 0; // Принудительное обновление
            await this.getToken();
          }
        },
      }
    );

    return response.data;
  }

  // Утилитарный метод для проверки валидности токена
  isTokenValid(): boolean {
    return !!(
      this.token &&
      this.tokenExpiry &&
      this.tokenExpiry > Date.now() + this.tokenBufferMs
    );
  }

  // Принудительное обновление токена (полезно для тестирования)
  async forceRefresh(): Promise<void> {
    this.tokenExpiry = 0;
    await this.getToken();
  }
}

Как пользоваться

Создаете менеджер с вашими данными и настройками окружения. Дальше он сам следит за токенами и дает простой метод apiCall для всех обращений к ADP.

Безопасность без паранойи 🔒

1. Где хранить секреты

В продакшене только менеджеры секретов (AWS Secrets Manager, HashiCorp Vault). Никаких паролей в коде! Локально используйте .env файлы с нормальными правами доступа.

2. Сертификаты

Сертификаты лучше хранить в base64 в переменных окружения, не файлами. В продакшене обязательно проверяйте SSL и используйте TLS 1.2+. Не забывайте про промежуточные сертификаты в цепочке.

3. Токены

На сервере храните токены только в памяти. В кластере используйте зашифрованный Redis с нормальными ключами. Токены в логах и БД - табу.

SSL сертификаты и как с ними жить 🔐

Next Gen хочет ПОЛНУЮ цепочку сертификатов в PEM формате - ваш + промежуточные + корневой, все склеенное.

Дорогая ошибка на $10K

Один клиент 2 недели бился с "недействительным сертификатом". Оказалось, скопировал из PDF и получил "умные" кавычки вместо обычных. Используйте только ASCII кавычки, никаких фигурных из Word'а!

Реальная история. $10K за отладку кавычек. 🤦‍♂️

Профессиональная обработка истечения токенов

Ошибка новичка: Получение токена в начале длительной операции. После 45 минут обработки тысяч записей токен истекает, и каждый последующий вызов завершается ошибкой 401.

Проверенный в боевых условиях подход: Используйте менеджер токенов для каждого вызова API. Он автоматически обновляет токены до их истечения и обрабатывает логику повторных попыток. Обрабатывайте записи пакетами с правильным ограничением скорости между запросами.

Состояние гонки, о котором никто не говорит

Несколько запросов запускаются одновременно? Без защиты вы будете обновлять токен 10 раз одновременно. Наш паттерн refreshPromise предотвращает это - все запросы ждут завершения одной операции обновления.

Ограничение скорости и мониторинг в продакшене 📋

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

Настройка переменных среды

Настройте эти переменные среды: ADP_CLIENT_ID, ADP_CLIENT_SECRET, ADP_ENVIRONMENT (production/sandbox). Для Next Gen добавьте ADP_CERT_BASE64 и ADP_KEY_BASE64 для сертификатов. Установите TOKEN_BUFFER_SECONDS=300 и RATE_LIMIT_REQUESTS_PER_SECOND=50 для оптимальной производительности.

Распространенные ошибки аутентификации (и исправления)

Ошибка 1: "Недействительный клиент"

{
  "error": "invalid_client",
  "error_description": "Аутентификация клиента не удалась"
}

Причины и исправления:

  • Использование производственных учетных данных против URL песочницы (или наоборот)
  • Неправильный ID/секрет клиента (проверьте опечатки, пробелы)
  • Учетные данные не закодированы правильно в URL

Ошибка 2: "Ошибка проверки сертификата"

Error: unable to verify the first certificate
Error: self signed certificate in certificate chain

Причины и исправления:

  • Отсутствующие промежуточные сертификаты в цепи
  • Неправильный формат сертификата (нужен PEM, не DER)
  • Несоответствие сертификата/ключа
  • Истекшие сертификаты

Ошибка 3: "Токен работает, потом перестает"

// Работает 55 минут, потом все ломается

Исправление: Токены истекают ТОЧНО через 1 час. Реализуйте правильную логику обновления.

Ошибка 4: "Превышен лимит скорости"

{
  "error": "rate_limit_exceeded",
  "message": "Слишком много запросов"
}

Исправление: Реализуйте экспоненциальную задержку и соблюдайте лимиты скорости (максимум 50 запросов/сек).

Ошибка 5: "Ошибка SSL handshake"

Error: Client network socket disconnected before secure TLS connection was established

Причины и исправления:

  • Firewall блокирует исходящий HTTPS (порт 443)
  • Корпоративный прокси перехватывает SSL
  • Устаревшая версия TLS (используйте TLSv1.2+)

Ошибка 6: "Таймаут сети"

Error: timeout of 30000ms exceeded

Исправление: Увеличьте таймаут для запросов токенов, реализуйте логику повторных попыток.

С этой проверенной в боевых условиях реализацией ваша интеграция будет надежно обрабатывать производственный трафик без тех страшных инцидентов в 2 утра.

Дополнительные ресурсы


Далее: [СКОРО] Лимиты скорости ADP: Урок за $50K - Теперь, когда вы аутентифицированы, давайте убедимся, что вас не заблокируют.