import { readFileSync } from 'node:fs'; import { createHash, randomUUID } from 'node:crypto'; import jwt from 'jsonwebtoken'; import { config } from './config'; interface CachedToken { accessToken: string; expiresAt: number; } interface TokenResponse { access_token: string; expires_in: number; } const TOKEN_ENDPOINT = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`; // Обновляем токен заранее, а не строго по истечении — иначе запрос, уже летящий // к Graph с почти протухшим токеном, может получить 401. const REFRESH_MARGIN_MS = 60_000; let cached: CachedToken | null = null; function readSecret(path: string): string { try { return readFileSync(path, 'utf8'); } catch (cause) { // Самая частая причина на свежем клоне — файлов просто нет: в git они не // хранятся. Ошибка ENOENT сама по себе не подсказывает, что делать. throw new Error( `Не удалось прочитать ${path}. Сертификат и ключ в git не хранятся — ` + 'положите их в каталог secrets/ (см. secrets/README.md) или укажите ' + 'другой каталог через SECRETS_DIR.', { cause } ); } } function buildClientAssertion(): string { const certificatePem = readSecret(config.certPath); const privateKey = readSecret(config.keyPath); // x5t — SHA-1 отпечаток DER-сертификата, base64url. По нему Azure AD находит // публичный ключ приложения и проверяет подпись JWT. const der = certificatePem .replace(/-----BEGIN CERTIFICATE-----/, '') .replace(/-----END CERTIFICATE-----/, '') .replace(/\s+/g, ''); const thumbprint = createHash('sha1').update(Buffer.from(der, 'base64')).digest('base64url'); const nowSeconds = Math.floor(Date.now() / 1000); return jwt.sign( { aud: TOKEN_ENDPOINT, iss: config.clientId, sub: config.clientId, jti: randomUUID(), nbf: nowSeconds, exp: nowSeconds + 5 * 60 }, privateKey, { algorithm: 'RS256', header: { alg: 'RS256', x5t: thumbprint } } ); } export async function getAppOnlyToken(): Promise { if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) { return cached.accessToken; } const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: config.clientId, client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: buildClientAssertion(), scope: config.scope }); const response = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }); if (!response.ok) { throw new Error(`Не удалось получить app-only токен: ${response.status} ${await response.text()}`); } const data = (await response.json()) as TokenResponse; cached = { accessToken: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 }; return cached.accessToken; }