Автентифікація ADP: Пояснення танцю OAuth токенів

Victoria Mycolaivna8 хв

Автентифікація ADP: Як не прокидатися вночі від 401 помилок

Знайома картина? Синхронізація зарплат працює ідеально, а потім о пів на третю ночі телефон розривається від дзвінків. Інтеграція лежить. Усі API запити падають з 401.

Токен здох. Знов.

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

П'ять хвилин і готово 🚀

Схема проста: шлете свої дані через OAuth 2.0, отримуєте токен на годину.

Тільки ось прикол: проста реалізація обов'язково зламається. Покажу, що насправді працює в бойовій.

Бойова версія 💪

Після сотні нічних дзвінків зібрав автентифікацію, що не ламається. З 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 - Тепер, коли ви автентифіковані, давайте переконаємося, що вас не заблокують.