refactor(mf): API Key longue durée au lieu d OAuth2 client_credentials
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Le portail Météo France ne propose pas le flow client_credentials gratuit (seulement token OAuth2 court 1h, pas viable, ou API Key longue durée). On simplifie : un seul env METEOFRANCE_API_KEY → Authorization: Bearer. - lib/meteofrance-auth.ts : suppression du cache token + flow refresh - .env.tmpl : ref unique vers vault Infra/Météo France API/api_key - vault item recréé avec api_key (hidden) + created_at + expires_at - CLAUDE.md projet : section rotation API key L onglet 24 h n apparaît qu une fois api_key non vide dans le vault. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a78726076f
commit
a849977fcc
4 changed files with 39 additions and 91 deletions
10
.env.example
10
.env.example
|
|
@ -17,10 +17,6 @@ VIGILANCE_CACHE_TTL=900
|
||||||
UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
UMAMI_SRC=https://analytics.nocleus.com/script.js
|
UMAMI_SRC=https://analytics.nocleus.com/script.js
|
||||||
|
|
||||||
# Météo France API (OAuth2 sur portail-api.meteofrance.fr)
|
# Météo France API (portail-api.meteofrance.fr)
|
||||||
# Choisir l'un OU l'autre :
|
# API Key longue durée — Authorization: Bearer <key>
|
||||||
# OAuth2 (recommandé prod, refresh auto) :
|
METEOFRANCE_API_KEY=
|
||||||
METEOFRANCE_CLIENT_ID=
|
|
||||||
METEOFRANCE_CLIENT_SECRET=
|
|
||||||
# Static token (test seulement, expire 1h) :
|
|
||||||
METEOFRANCE_STATIC_TOKEN=
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,6 @@ UMAMI_SRC=https://analytics.nocleus.com/script.js
|
||||||
# GlitchTip (optionnel — si vide, pas d'envoi Sentry)
|
# GlitchTip (optionnel — si vide, pas d'envoi Sentry)
|
||||||
SENTRY_DSN={{ pass://Infra/Info Canicule — secrets/SENTRY_DSN }}
|
SENTRY_DSN={{ pass://Infra/Info Canicule — secrets/SENTRY_DSN }}
|
||||||
|
|
||||||
# Météo France API (portail-api.meteofrance.fr) — OAuth2 client_credentials pour hourly observations
|
# Météo France API (portail-api.meteofrance.fr) — API Key longue durée
|
||||||
METEOFRANCE_CLIENT_ID={{ pass://Infra/Météo France API/client_id }}
|
# Header `Authorization: Bearer <key>`. Rotation : recréer sur portail, vault, make env, redeploy.
|
||||||
METEOFRANCE_CLIENT_SECRET={{ pass://Infra/Météo France API/client_secret }}
|
METEOFRANCE_API_KEY={{ pass://Infra/Météo France API/api_key }}
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,12 @@ Le prefix `info-canicule:` est injecté par ioredis (`keyPrefix`), donc la clé
|
||||||
Pattern Reteno : push main → CI Forgejo SSH au VPS → `git fetch && reset --hard && make env && docker compose up -d --build --wait`.
|
Pattern Reteno : push main → CI Forgejo SSH au VPS → `git fetch && reset --hard && make env && docker compose up -d --build --wait`.
|
||||||
|
|
||||||
Le `.env.tmpl` est commit, le `.env` réel est matérialisé par `make env` (pass-cli, vault `Infra`).
|
Le `.env.tmpl` est commit, le `.env` réel est matérialisé par `make env` (pass-cli, vault `Infra`).
|
||||||
|
|
||||||
|
## Météo France API — rotation API Key
|
||||||
|
|
||||||
|
- Vault item : `Infra/Météo France API`, field `api_key` (hidden) + `created_at` + `expires_at`.
|
||||||
|
- Le portail (portail-api.meteofrance.fr) propose une **API Key longue durée** (durée choisie à la création, max ~10 ans). Pas de flow OAuth2 client_credentials dispo gratuitement → on n'utilise QUE l'API Key, pas de refresh automatique.
|
||||||
|
- Code : `src/lib/meteofrance-auth.ts` — header `Authorization: Bearer ${METEOFRANCE_API_KEY}`.
|
||||||
|
- Rotation : avant l'expiration notée dans le vault, regénérer côté portail, copier dans le vault, `make env` côté VPS, `docker compose up -d --force-recreate app`.
|
||||||
|
- Si la clé expire / est révoquée : l'onglet « 24 h » sur les pages dept ne s'affiche plus (skip silencieux), pas d'impact sur le reste du site. Erreur loggée côté serveur uniquement.
|
||||||
|
- Si on veut être alerté : prévoir un cron HC.io qui curl `/api/vigilance` non, qui curl un endpoint test `MF` (à coder). Pour l'instant : se reposer sur la date `expires_at` du vault item.
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,32 @@
|
||||||
// OAuth2 client_credentials pour l'API Météo France (portail-api.meteofrance.fr).
|
// Accès à l'API Météo France (portail-api.meteofrance.fr) via API Key longue durée.
|
||||||
// Le token retourné dure ~1h, on le cache en mémoire process + Valkey jusqu'à 30 min avant expiration.
|
//
|
||||||
|
// Le portail propose 2 modes : (a) token OAuth2 court 1h (pas adapté en prod
|
||||||
|
// sans flow refresh) et (b) API Key permanente / longue durée. On utilise (b).
|
||||||
//
|
//
|
||||||
// Configuration via env :
|
// Configuration via env :
|
||||||
// METEOFRANCE_CLIENT_ID + METEOFRANCE_CLIENT_SECRET → mode OAuth2 (refresh auto)
|
// METEOFRANCE_API_KEY → clé permanente, envoyée en header `Authorization: Bearer <key>`
|
||||||
// METEOFRANCE_STATIC_TOKEN → fallback dev/test (1h)
|
|
||||||
//
|
//
|
||||||
// Le mode OAuth2 est obligatoire en prod ; le static_token est pour debug local.
|
// Quand la clé approche de l'expiration (cf. duration choisie à la création),
|
||||||
|
// régénérer côté portail puis mettre à jour le vault `Infra/Météo France API`,
|
||||||
|
// puis `make env` + redeploy. Voir CLAUDE.md projet info-canicule.
|
||||||
|
|
||||||
import { cacheGet, cacheSet } from './cache';
|
const BASE = 'https://public-api.meteofrance.fr';
|
||||||
|
|
||||||
const TOKEN_URL = 'https://portail-api.meteofrance.fr/token';
|
|
||||||
const CACHE_KEY = 'mf:access_token';
|
|
||||||
|
|
||||||
interface CachedToken {
|
|
||||||
token: string;
|
|
||||||
expiresAtMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let inMemory: CachedToken | null = null;
|
|
||||||
|
|
||||||
async function fetchFreshToken(): Promise<CachedToken> {
|
|
||||||
const clientId = process.env.METEOFRANCE_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.METEOFRANCE_CLIENT_SECRET;
|
|
||||||
const staticToken = process.env.METEOFRANCE_STATIC_TOKEN;
|
|
||||||
|
|
||||||
if (clientId && clientSecret) {
|
|
||||||
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
|
||||||
const body = new URLSearchParams({ grant_type: 'client_credentials' });
|
|
||||||
const res = await fetch(TOKEN_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Basic ${basic}`,
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text();
|
|
||||||
throw new Error(`MF token fetch failed: ${res.status} ${txt.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
const json = (await res.json()) as { access_token: string; expires_in: number };
|
|
||||||
const expiresAtMs = Date.now() + (json.expires_in - 120) * 1000; // marge 2 min
|
|
||||||
return { token: json.access_token, expiresAtMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (staticToken) {
|
|
||||||
// Pas de moyen de décoder l'expiration sans parser le JWT ; on suppose 1h (à durcir si besoin).
|
|
||||||
// Si le token est expiré, l'API retournera 401 et le caller pourra invalidate().
|
|
||||||
return { token: staticToken, expiresAtMs: Date.now() + 50 * 60 * 1000 };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('No Météo France credentials in env (need METEOFRANCE_CLIENT_ID+SECRET or METEOFRANCE_STATIC_TOKEN)');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAccessToken(forceRefresh = false): Promise<string> {
|
|
||||||
if (!forceRefresh && inMemory && Date.now() < inMemory.expiresAtMs) {
|
|
||||||
return inMemory.token;
|
|
||||||
}
|
|
||||||
if (!forceRefresh) {
|
|
||||||
const cached = await cacheGet<CachedToken>(CACHE_KEY);
|
|
||||||
if (cached && Date.now() < cached.expiresAtMs) {
|
|
||||||
inMemory = cached;
|
|
||||||
return cached.token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fresh = await fetchFreshToken();
|
|
||||||
inMemory = fresh;
|
|
||||||
const ttl = Math.max(60, Math.floor((fresh.expiresAtMs - Date.now()) / 1000));
|
|
||||||
await cacheSet(CACHE_KEY, fresh, ttl);
|
|
||||||
return fresh.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMF(path: string, init: RequestInit = {}): Promise<Response> {
|
export async function fetchMF(path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
const base = 'https://public-api.meteofrance.fr';
|
const key = process.env.METEOFRANCE_API_KEY;
|
||||||
let token = await getAccessToken();
|
if (!key) {
|
||||||
let res = await fetch(`${base}${path}`, {
|
throw new Error('METEOFRANCE_API_KEY missing in env');
|
||||||
...init,
|
|
||||||
headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
|
||||||
token = await getAccessToken(true);
|
|
||||||
res = await fetch(`${base}${path}`, {
|
|
||||||
...init,
|
|
||||||
headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return res;
|
return fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...init.headers,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMeteoFranceCredentials(): boolean {
|
||||||
|
return Boolean(process.env.METEOFRANCE_API_KEY);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue