refactor: сертификат и ключ в каталоге secrets/, один параметр на каталог
Tests / frontend-test (pull_request) Successful in 3m14s

Локально путь к сертификату вёл в ~/Downloads у каждого свой, а в контейнере
задавался двумя отдельными переменными. Теперь каталог один и подменяется
целиком через SECRETS_DIR: по умолчанию secrets/ в корне репозитория, в
контейнере /certs (смонтированный каталог с хоста, GRAPH_CERT_DIR не меняется).

Имена файлов внутри фиксированы, поэтому GRAPH_CERT_PATH и GRAPH_KEY_PATH
больше не нужны. Содержимое secrets/ в .gitignore, кроме README.

Отсутствие файлов теперь даёт понятную ошибку вместо ENOENT — на свежем
клоне это основной сценарий отказа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 13:34:37 +05:00
parent b4a10510e1
commit f1caa62f28
8 changed files with 88 additions and 19 deletions
+5
View File
@@ -15,3 +15,8 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
.env .env
# Сертификаты и ключи. Каталог подменяется целиком: локально это secrets/,
# в контейнере — примонтированный /certs (см. docker-compose.yml).
secrets/*
!secrets/README.md
+4 -4
View File
@@ -32,10 +32,10 @@ npm run dev
так же, как nginx в проде (`nginx.conf`). Без запущенного `[auth]` отправка так же, как nginx в проде (`nginx.conf`). Без запущенного `[auth]` отправка
формы отдаёт 500 от прокси. формы отдаёт 500 от прокси.
Сервису нужен `server/.env` с сертификатом Azure AD — скопируйте Сервису нужны две вещи: `server/.env` (скопируйте
[server/.env.example](server/.env.example) и подставьте свои пути. Сами [server/.env.example](server/.env.example)) и сертификат Azure AD в каталоге
`.pem`-файлы в git не хранятся, их надо взять у администратора. Подробнее — [`secrets/`](secrets/README.md). Сами `.pem`-файлы в git не хранятся — их надо
в [server/README.md](server/README.md). взять у администратора. Подробнее — в [server/README.md](server/README.md).
Прочие команды: Прочие команды:
+3 -2
View File
@@ -13,8 +13,9 @@ services:
environment: environment:
GRAPH_TENANT_ID: ${GRAPH_TENANT_ID:?в .env на сервере не задан GRAPH_TENANT_ID} GRAPH_TENANT_ID: ${GRAPH_TENANT_ID:?в .env на сервере не задан GRAPH_TENANT_ID}
GRAPH_CLIENT_ID: ${GRAPH_CLIENT_ID:?в .env на сервере не задан GRAPH_CLIENT_ID} GRAPH_CLIENT_ID: ${GRAPH_CLIENT_ID:?в .env на сервере не задан GRAPH_CLIENT_ID}
GRAPH_CERT_PATH: /certs/SPFxDeploy_cert.pem # Один параметр на весь каталог с секретами — имена файлов внутри
GRAPH_KEY_PATH: /certs/SPFxDeploy_key.pem # фиксированы (см. server/src/config.ts).
SECRETS_DIR: /certs
volumes: volumes:
# Каталог с сертификатом/ключом на сервере — путь задаётся в .env # Каталог с сертификатом/ключом на сервере — путь задаётся в .env
# (GRAPH_CERT_DIR), сами файлы в git не попадают. # (GRAPH_CERT_DIR), сами файлы в git не попадают.
+34
View File
@@ -0,0 +1,34 @@
# secrets
Сертификат и приватный ключ приложения Azure AD, под которым сервис
`hr-appeals-auth` получает app-only токен. **В git не попадают** — всё
содержимое каталога, кроме этого файла, в `.gitignore`.
Положите сюда два файла (взять у администратора):
```
secrets/
SPFxDeploy_cert.pem
SPFxDeploy_key.pem
```
Имена фиксированные — их ждёт [server/src/config.ts](../server/src/config.ts).
## Подменить каталог целиком
Путь задаётся одной переменной `SECRETS_DIR`, поэтому каталог можно заменить
не трогая код:
```bash
SECRETS_DIR=/path/to/other/secrets npm run dev --prefix server
```
По умолчанию — этот каталог. В продовом контейнере `SECRETS_DIR=/certs`, куда
смонтирован каталог с хоста: путь на хосте задаёт `GRAPH_CERT_DIR` в `.env`
рядом с `docker-compose.yml` на сервере.
## Если ключ утёк
Сертификат перевыпускается в Azure AD (App registrations → нужное приложение →
Certificates & secrets), старый оттуда удаляется. Замена файлов здесь без этого
ничего не даёт: Azure AD продолжит принимать подпись старым ключом.
+6 -6
View File
@@ -1,13 +1,13 @@
# Локальный запуск: скопируйте в server/.env и подставьте свои пути к сертификату. # Локальный запуск: скопируйте в server/.env.
# На проде эти переменные задаёт docker-compose (см. docker-compose.yml), .env там свой. # На проде эти переменные задаёт docker-compose (см. docker-compose.yml).
GRAPH_TENANT_ID=b4c2af35-bfd6-4cff-9af9-556efb53e65a GRAPH_TENANT_ID=b4c2af35-bfd6-4cff-9af9-556efb53e65a
GRAPH_CLIENT_ID=26d2e058-0b27-4a00-b49f-1f17c4eecd1d GRAPH_CLIENT_ID=26d2e058-0b27-4a00-b49f-1f17c4eecd1d
# Сертификат и приватный ключ приложения — в git не хранятся, взять у администратора.
GRAPH_CERT_PATH=C:/path/to/SPFxDeploy_cert.pem
GRAPH_KEY_PATH=C:/path/to/SPFxDeploy_key.pem
# 3000 занят vite (см. vite.config.ts), поэтому локально сервис слушает 3100 — # 3000 занят vite (см. vite.config.ts), поэтому локально сервис слушает 3100 —
# именно туда проксирует /api из dev-сервера. # именно туда проксирует /api из dev-сервера.
PORT=3100 PORT=3100
# Каталог с сертификатом и ключом. По умолчанию — secrets/ в корне репозитория,
# задавать нужно только чтобы подменить его на другой (см. secrets/README.md).
# SECRETS_DIR=/path/to/secrets
+5 -3
View File
@@ -13,8 +13,7 @@ assertion) и используется для авторизованных за
|---|---| |---|---|
| `GRAPH_TENANT_ID` | Tenant ID Azure AD | | `GRAPH_TENANT_ID` | Tenant ID Azure AD |
| `GRAPH_CLIENT_ID` | Client ID App Registration, к которому привязан сертификат `SPFxDeploy` | | `GRAPH_CLIENT_ID` | Client ID App Registration, к которому привязан сертификат `SPFxDeploy` |
| `GRAPH_CERT_PATH` | Путь к `SPFxDeploy_cert.pem` внутри контейнера (см. `docker-compose.yml` в корне репозитория) | | `SECRETS_DIR` | Каталог с `SPFxDeploy_cert.pem` и `SPFxDeploy_key.pem` (имена файлов фиксированы). По умолчанию — [`secrets/`](../secrets/README.md) в корне репозитория; в контейнере `/certs`, куда смонтирован каталог с хоста. Задавать нужно только чтобы подменить каталог целиком |
| `GRAPH_KEY_PATH` | Путь к `SPFxDeploy_key.pem` внутри контейнера |
| `GRAPH_SCOPE` | OAuth-scope запрашиваемого токена. По умолчанию — App ID URI самого `api.tnsu.kz` (`api://9232d9e2-.../.default`), задавать не нужно. Токен для `https://graph.microsoft.com/.default` тут не подойдёт: `api.tnsu.kz` проверяет `aud` входящего JWT и отвечает 401 | | `GRAPH_SCOPE` | OAuth-scope запрашиваемого токена. По умолчанию — App ID URI самого `api.tnsu.kz` (`api://9232d9e2-.../.default`), задавать не нужно. Токен для `https://graph.microsoft.com/.default` тут не подойдёт: `api.tnsu.kz` проверяет `aud` входящего JWT и отвечает 401 |
| `PORT` | Порт HTTP-сервера сервиса, по умолчанию `3000` (локально — `3100`, см. `.env.example`) | | `PORT` | Порт HTTP-сервера сервиса, по умолчанию `3000` (локально — `3100`, см. `.env.example`) |
@@ -23,10 +22,13 @@ assertion) и используется для авторизованных за
Из корня репозитория `npm run dev` поднимает сервис вместе с фронтендом. Отдельно: Из корня репозитория `npm run dev` поднимает сервис вместе с фронтендом. Отдельно:
```bash ```bash
cp .env.example .env # подставить свои пути к SPFxDeploy_cert.pem / _key.pem cp .env.example .env # tenant/client id и порт
npm run dev # tsc + запуск с чтением .env (--env-file, Node 20+) npm run dev # tsc + запуск с чтением .env (--env-file, Node 20+)
``` ```
Сертификат и ключ при этом должны лежать в [`secrets/`](../secrets/README.md) —
в git они не хранятся.
Проверить, что авторизация работает: `curl http://localhost:3100/diag/token` → Проверить, что авторизация работает: `curl http://localhost:3100/diag/token` →
`{"ok":true}`. Если вернулась ошибка — в ней будет ответ Azure AD целиком. `{"ok":true}`. Если вернулась ошибка — в ней будет ответ Azure AD целиком.
+14 -2
View File
@@ -1,3 +1,5 @@
import { join, resolve } from 'node:path';
function requireEnv(name: string): string { function requireEnv(name: string): string {
const value = process.env[name]; const value = process.env[name];
if (!value) { if (!value) {
@@ -6,11 +8,21 @@ function requireEnv(name: string): string {
return value; return value;
} }
/**
* Каталог с сертификатом и приватным ключом приложения. Одна переменная на весь
* каталог, а не путь к каждому файлу: так его можно подменить централизованно —
* локально это `secrets/` в корне репозитория (в git не попадает), в контейнере
* SECRETS_DIR=/certs, куда смонтирован каталог с хоста (см. docker-compose.yml).
*
* __dirname здесь — server/dist, поэтому до корня репозитория два уровня вверх.
*/
const secretsDir = process.env.SECRETS_DIR ?? resolve(__dirname, '../../secrets');
export const config = { export const config = {
tenantId: requireEnv('GRAPH_TENANT_ID'), tenantId: requireEnv('GRAPH_TENANT_ID'),
clientId: requireEnv('GRAPH_CLIENT_ID'), clientId: requireEnv('GRAPH_CLIENT_ID'),
certPath: requireEnv('GRAPH_CERT_PATH'), certPath: join(secretsDir, 'SPFxDeploy_cert.pem'),
keyPath: requireEnv('GRAPH_KEY_PATH'), keyPath: join(secretsDir, 'SPFxDeploy_key.pem'),
// Токен нужен не для Graph напрямую, а для api.tnsu.kz — он сам ходит в // Токен нужен не для Graph напрямую, а для api.tnsu.kz — он сам ходит в
// SharePoint. Это его App ID URI: с graph.microsoft.com/.default api вернёт // SharePoint. Это его App ID URI: с graph.microsoft.com/.default api вернёт
// 401, потому что проверяет aud во входящем JWT. // 401, потому что проверяет aud во входящем JWT.
+17 -2
View File
@@ -21,9 +21,24 @@ const REFRESH_MARGIN_MS = 60_000;
let cached: CachedToken | null = null; 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 { function buildClientAssertion(): string {
const certificatePem = readFileSync(config.certPath, 'utf8'); const certificatePem = readSecret(config.certPath);
const privateKey = readFileSync(config.keyPath, 'utf8'); const privateKey = readSecret(config.keyPath);
// x5t — SHA-1 отпечаток DER-сертификата, base64url. По нему Azure AD находит // x5t — SHA-1 отпечаток DER-сертификата, base64url. По нему Azure AD находит
// публичный ключ приложения и проверяет подпись JWT. // публичный ключ приложения и проверяет подпись JWT.