dea024f3a9
build-and-deploy / prepare (push) Successful in 0s
build-and-deploy / build-and-push (., Dockerfile, hr-appeals-web) (push) Successful in 8s
Tests / frontend-test (push) Successful in 11s
build-and-deploy / build-and-push (server, server/Dockerfile, hr-appeals-auth) (push) Successful in 4s
build-and-deploy / deploy (push) Successful in 15s
Co-authored-by: Mariya Trishina <trishina.mariya@tnsu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/hr-appeals/pulls/2
97 lines
3.2 KiB
TypeScript
97 lines
3.2 KiB
TypeScript
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<string> {
|
|
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;
|
|
}
|