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

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:
Florian 2026-05-26 00:25:54 +02:00
parent a78726076f
commit a849977fcc
4 changed files with 39 additions and 91 deletions

View file

@ -1,89 +1,32 @@
// OAuth2 client_credentials pour l'API Météo France (portail-api.meteofrance.fr).
// Le token retourné dure ~1h, on le cache en mémoire process + Valkey jusqu'à 30 min avant expiration.
// Accès à l'API Météo France (portail-api.meteofrance.fr) via API Key longue durée.
//
// 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 :
// METEOFRANCE_CLIENT_ID + METEOFRANCE_CLIENT_SECRET → mode OAuth2 (refresh auto)
// METEOFRANCE_STATIC_TOKEN → fallback dev/test (1h)
// METEOFRANCE_API_KEY → clé permanente, envoyée en header `Authorization: Bearer <key>`
//
// 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 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;
}
const BASE = 'https://public-api.meteofrance.fr';
export async function fetchMF(path: string, init: RequestInit = {}): Promise<Response> {
const base = 'https://public-api.meteofrance.fr';
let token = await getAccessToken();
let res = await fetch(`${base}${path}`, {
...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' },
});
const key = process.env.METEOFRANCE_API_KEY;
if (!key) {
throw new Error('METEOFRANCE_API_KEY missing in env');
}
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);
}