ADR-013 - Authentification M2M OAuth pour les services internes
- Statut : Accepté
- Date : 2026-06-04
Contexte
Cerops doit permettre à des services non interactifs, comme apps/sentinel, d’appeler l’API interne sans session utilisateur.
Ces appels ne doivent pas réutiliser les cookies Better-Auth des applications web, ni dépendre d’un token statique partagé entre services.
Le besoin initial est l’accès de Sentinel aux procédures internes liées aux images satellite. Le mécanisme doit rester extensible à d’autres services internes avec des permissions distinctes.
Décision
Nous utilisons le grant OAuth 2 client_credentials fourni par @better-auth/oauth-provider.
Chaque service machine est provisionné comme client OAuth confidentiel avec token_endpoint_auth_method = client_secret_basic.
Le serveur d’autorisation est exposé par Better-Auth sous /api/auth.
Le endpoint de token utilisé par les machines est :
POST /api/auth/oauth2/token
Les clients demandent un token avec :
grant_type=client_credentials
resource=<CEROPS_API_AUDIENCE>
scope=<scopes demandes>
L’audience protégée par défaut est http://localhost:3000/rpc.
En production, CEROPS_API_AUDIENCE doit correspondre à l’URL publique de l’API RPC.
Provisioning et rotation
Les clients machine sont créés par le script d’administration :
bun run oauth:provision:prod -- --name=sentinel-prod --scopes=satellite:read,satellite:write --admin-session-token=<admin_session_token>
Le script appelle auth.api.adminCreateOAuthClient(...) et stocke les scopes autorisés dans les métadonnées du client.
Le client_secret est affiché une seule fois et doit être placé dans le gestionnaire de secrets de l’environnement cible.
La rotation d’urgence utilise :
bun run oauth:rotate:prod -- --client-id=<client_id> --admin-session-token=<admin_session_token>
Cette rotation invalide immédiatement l’ancien secret client. Les JWT déjà émis restent valides jusqu’à leur expiration. Pour une rotation sans interruption, il faut provisionner un second client, déployer ses identifiants, vérifier le renouvellement des JWT, puis désactiver l’ancien client.
Scopes de service
Les scopes M2M supportés sont centralisés dans packages/auth/src/service-scopes.ts :
satellite:readprocessing:writeanalysis:readanalysis:write
Un client ne peut demander que les scopes présents dans ses métadonnées allowedScopes.
La fonction assertClientScopesAllowed(...) bloque une demande de token si le client demande un scope non autorisé.
Les procédures ORPC internes déclarent ensuite leurs scopes requis via createServiceProcedure(...).
Par exemple, internal.auth.checkM2M exige satellite:read.
Validation côté API
Le contexte API extrait le header HTTP :
Authorization: Bearer <access_token>
Les procédures de service utilisent createServiceProcedure(requiredScopes).
Ce middleware :
- vérifie le JWT avec les clés JWKS exposées par Better-Auth sous
/api/auth/jwks; - impose l’issuer
BETTER_AUTH_URL + /api/auth; - impose l’audience
CEROPS_API_AUDIENCE; - extrait les scopes depuis la claim
scope; - rejette la requête en
UNAUTHORIZEDsi le token est absent ou invalide ; - rejette la requête en
FORBIDDENsi un scope requis manque ; - expose
context.serviceavecclientId,subjectetscopes.
Les procédures M2M ne dépendent pas de protectedProcedure et ne nécessitent donc pas de session utilisateur.
Elles doivent passer par createServiceProcedure(...) pour éviter de mélanger authentification utilisateur et authentification machine.
Fonctionnement du client Sentinel
apps/sentinel utilise CeropsOAuthClient.
Au démarrage, Sentinel attend quelques secondes puis appelle :
POST /rpc/internal/auth/checkM2M
Cette sonde échoue le processus si l’authentification M2M n’est pas opérationnelle.
Le client Sentinel :
- demande un token à
/api/auth/oauth2/tokenavecclient_secret_basic; - demande actuellement le scope
satellite:read; - met le JWT en cache mémoire ;
- renouvelle le token avant expiration avec une marge de 60 secondes et un jitter de 15 secondes ;
- déduplique les renouvellements concurrents ;
- invalide le cache et retente une fois en cas de réponse
401; - retente les réponses
429avec backoff exponentiel borné.
Les variables requises côté Sentinel sont :
CEROPS_API_URLCEROPS_API_AUDIENCECEROPS_OAUTH_CLIENT_IDCEROPS_OAUTH_CLIENT_SECRET
Métadonnées OAuth
Le serveur expose les métadonnées OAuth nécessaires à la découverte :
- authorization server metadata :
/.well-known/oauth-authorization-server/api/auth - protected resource metadata pour l’audience RPC
- JWKS :
/api/auth/jwks
Le chemin legacy Better-Auth /api/auth/token est désactivé.
Les clients machine doivent utiliser /api/auth/oauth2/token.
Conséquences
- Les secrets machine sont propres à chaque environnement et ne sont pas codés en dur.
- Les permissions sont exprimées en scopes, ce qui permet de restreindre un service à son besoin réel.
- La révocation d’un secret client stoppe les nouveaux tokens, mais pas les JWT déjà émis.
- Les appels M2M sont indépendants des sessions utilisateur et des cookies httpOnly.
- L’ajout d’un nouveau service interne nécessite un client OAuth dédié et des scopes explicites.
- Les erreurs opérationnelles à surveiller sont les
401,403et429sur les routes internes.
Alternatives considérées
- Token statique interne : simple à implémenter, mais rotation difficile, absence de scopes fins et risque élevé en cas de fuite.
- Session utilisateur technique : réutilise l’auth web, mais mélange identité humaine et identité machine, et dépend des cookies.
- mTLS uniquement : forte garantie d’identité transport, mais plus complexe à opérer et insuffisant pour exprimer des permissions applicatives.
- Clés API par service : opérationnellement simple, mais moins standard qu’OAuth et moins intégré à Better-Auth/JWKS.