diff --git a/.env.example b/.env.example index 1c045cb..81fe4b4 100644 --- a/.env.example +++ b/.env.example @@ -17,10 +17,6 @@ VIGILANCE_CACHE_TTL=900 UMAMI_WEBSITE_ID= UMAMI_SRC=https://analytics.nocleus.com/script.js -# Météo France API (OAuth2 sur portail-api.meteofrance.fr) -# Choisir l'un OU l'autre : -# OAuth2 (recommandé prod, refresh auto) : -METEOFRANCE_CLIENT_ID= -METEOFRANCE_CLIENT_SECRET= -# Static token (test seulement, expire 1h) : -METEOFRANCE_STATIC_TOKEN= +# Météo France API (portail-api.meteofrance.fr) +# API Key longue durée — Authorization: Bearer +METEOFRANCE_API_KEY= diff --git a/.env.tmpl b/.env.tmpl index b17dd2a..abe6386 100644 --- a/.env.tmpl +++ b/.env.tmpl @@ -15,6 +15,6 @@ UMAMI_SRC=https://analytics.nocleus.com/script.js # GlitchTip (optionnel — si vide, pas d'envoi Sentry) SENTRY_DSN={{ pass://Infra/Info Canicule — secrets/SENTRY_DSN }} -# Météo France API (portail-api.meteofrance.fr) — OAuth2 client_credentials pour hourly observations -METEOFRANCE_CLIENT_ID={{ pass://Infra/Météo France API/client_id }} -METEOFRANCE_CLIENT_SECRET={{ pass://Infra/Météo France API/client_secret }} +# Météo France API (portail-api.meteofrance.fr) — API Key longue durée +# Header `Authorization: Bearer `. Rotation : recréer sur portail, vault, make env, redeploy. +METEOFRANCE_API_KEY={{ pass://Infra/Météo France API/api_key }} diff --git a/CLAUDE.md b/CLAUDE.md index fe4a9cc..5b06f0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. 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. diff --git a/src/lib/meteofrance-auth.ts b/src/lib/meteofrance-auth.ts index 7688837..49d44d4 100644 --- a/src/lib/meteofrance-auth.ts +++ b/src/lib/meteofrance-auth.ts @@ -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 ` // -// 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 { - 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 { - if (!forceRefresh && inMemory && Date.now() < inMemory.expiresAtMs) { - return inMemory.token; - } - if (!forceRefresh) { - const cached = await cacheGet(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 { - 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); }