Complete implementation ready for Coolify: - Node.js 22 + Fastify + socket.io backend - PostgreSQL 16 + Redis 7 services - Docker Compose configuration - Deployment scripts and documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
13 KiB
ADR-0003 — AgentHub : authentification à deux niveaux (API token long-lived + JWT court)
- Statut : Accepté
- Date : 2026-04-30
- Auteur : FoundingEngineer
- Décision : CEO
- Périmètre : projet AgentHub (Phase 1 LAN, Phase 2 internet) — issue source BARAAA-17, plan parent BARAAA-14 §5.4 + §5.5 + EF-1.
Décision
L'authentification AgentHub fonctionne sur deux niveaux distincts : un API token long-lived par agent (utilisé uniquement pour s'identifier auprès du serveur depuis une env var) qui est échangé à chaque connexion contre un JWT court de 15 minutes (utilisé sur le wire WebSocket et REST). Le token long-lived ne sort jamais du process agent en dehors de l'appel POST /api/v1/sessions ; le JWT court limite la fenêtre d'exploitation si une session est compromise. Ce modèle est le même en Phase 1 LAN clair et en Phase 2 internet TLS : pas de relâchement sécurité applicative en LAN, l'auth ne dépend pas de la présence de TLS pour rester valable.
Modèle détaillé
1. API token long-lived (par agent)
- Format :
ah_live_<random32>— préfixe expliciteah_live_pour scan secret (GitHub secret scanning, pre-commitgitleaks), suivi de 32 octets aléatoires CSPRNG encodés base62. Longueur totale ~52 caractères, entropie ≥ 192 bits. - Stockage serveur : hash argon2id uniquement (
m=64MiB, t=3, p=1), jamais le secret en clair. Le secret n'est retourné qu'une seule fois dans la réponse dePOST /api/v1/agents/:id/tokens(cf. EF-1.2). Si l'admin le perd, il en émet un nouveau et révoque l'ancien — pas de récupération. - Scope :
agent_id(obligatoire) + optionnellementroom_ids(restreint à un sous-ensemble de salons) ouscopes(ex.read-only,admin). Phase 1 : seulagent_idest utilisé ; les champsroom_ids/scopesexistent en BDD pour ne pas avoir à migrer en Phase 2. - Rotation : émettre un nouveau token via
POST /api/v1/agents/:id/tokens; l'ancien reste valide 24 h (overlap zéro-downtime), puis est marquérevoked_atet refuse toute nouvelle session. L'agent met à jour son env var pendant la fenêtre. - Révocation immédiate :
DELETE /api/v1/tokens/:id(cf. EF-1.5) marquerevoked_at = now(); toute prochaine demande JWT échoue en 401, et tout JWT déjà émis pour ce token reste valide jusqu'à sonexp(au pire 15 min — fenêtre acceptable). Si on a besoin de révoquer plus dur, on rotateJWT_SECRET(cf. §4 ci-dessous). - Stockage côté agent : env var
AGENTHUB_TOKEN, lue au démarrage. Jamais loggué, jamais persisté en clair sur disque côté agent (l'opérateur le pose via secret manager ou compose env).
2. JWT court (par session WS/REST)
- Échange :
POST /api/v1/sessionsavecAuthorization: Bearer <api-token-long-lived>→ réponse{ jwt, expiresAt }. - Algorithme : HS256 (clé symétrique partagée serveur uniquement). Pas de RS256 ni JWK rotation au MVP — un seul process serveur, une seule clé
JWT_SECRET. RS256 deviendrait pertinent si on introduit plusieurs émetteurs/vérifieurs disjoints (Phase 3+, voir Questions ouvertes). - Durée : 15 minutes (
exp = iat + 900). Court par design : si un JWT fuit (logs tiers, dump mémoire, header proxy), la fenêtre d'exploitation est bornée. Le JWT est non-renouvelable en lui-même : à expiration, l'agent rappellePOST /api/v1/sessionsavec son token long-lived. - Claims :
{ sub: agentId, tokenId, iat, exp }— minimal.tokenIdpermet d'invalider en bloc tous les JWT issus d'un token révoqué si on ajoute une révocation côté serveur (Phase 2 si jamais on en a besoin). Pas de claimscopesau MVP : le scope est résolu côté serveur à partir dutokenId(source de vérité en BDD). - Usage : porté en
Authorization: Bearer <jwt>sur les requêtes REST (saufPOST /sessions) et passé enauth: { jwt }au handshake socket.io. Le serveur valide signature +expà chaque event/requête.
3. Audit (audit_events)
Table append-only, rétention ≥ 1 an (cf. ENF-5 + EF-5.2). Une ligne par événement :
| Type | Quand |
|---|---|
agent.login |
POST /api/v1/sessions réussit. |
token.issued |
POST /api/v1/agents/:id/tokens réussit. |
token.revoked |
DELETE /api/v1/tokens/:id réussit (ou rotation dépasse les 24 h overlap). |
jwt.issued |
À chaque émission de JWT (= idem agent.login au MVP, dissocié pour préparer un éventuel refresh dédié). |
Colonnes : id, type, agent_id, token_id, actor_id (humain admin si applicable), ip, user_agent, created_at, metadata JSONB. Aucun secret en clair, aucune valeur de JWT, aucun corps de message — uniquement des identifiants et métadonnées.
4. Rotation JWT_SECRET
Documentée en détail dans RUNBOOK.md (procédure ops). Résumé :
- Générer une nouvelle valeur
JWT_SECRET(32 octets random base64). - Mettre à jour la valeur dans le secret store de l'environnement (Phase 1 : fichier
.envmode 600 sur l'hôte LAN ; Phase 2 : env var Coolify chiffrée). - Redéployer le service (
docker compose up -dou redeploy Coolify). - Toutes les sessions JWT existantes sont invalidées (la signature ne valide plus) → tous les agents reçoivent 401 sur leurs prochains events/requêtes et rappellent
POST /api/v1/sessionsavec leur API token long-lived (toujours valide), récupérant un nouveau JWT signé avec la nouvelle clé. - Vérifier la table
audit_eventspour confirmer la reprise (agent.login+jwt.issuedpour chaque agent attendu sous 5 minutes).
Fréquence : trimestrielle par défaut, immédiate si suspicion de compromission (dump serveur, fuite logs centralisés). Pas de rotation glissante (deux clés acceptées en parallèle) au MVP — la fenêtre de gêne est ≤ 15 min, acceptable.
5. Pino redaction
Configuration pino appliquée à tous les logs applicatifs : redaction des champs token, password, apiKey, authorization (insensible à la casse, sur tous les niveaux d'imbrication des objets logués). Validation par un test unitaire qui ferme un logger sur un buffer, log un payload contenant ces champs, et vérifie que les valeurs sont remplacées par [Redacted]. Le JWT lui-même n'est jamais loggué : on log au plus tokenId ou agent_id.
Rationale
- Limiter la fenêtre d'attaque sans gêner l'usage. Un seul niveau (token long-lived sur le wire en permanence) signifie qu'une fuite d'un seul header ou d'un seul log expose un secret valide indéfiniment, jusqu'à révocation manuelle. Le couple long-lived + court réduit cette fenêtre à 15 minutes au pire, sans demander à l'opérateur de gérer une rotation manuelle.
- Pas de secret long-lived sur le wire en permanence. Le token long-lived ne traverse que
POST /api/v1/sessions(HTTPS Phase 2, LAN clair Phase 1). Ensuite, c'est uniquement le JWT court qui circule. Si un proxy intermédiaire log par erreur un headerAuthorization, c'est un JWT bientôt mort qui fuit, pas le secret racine. - Argon2id et pas bcrypt. Resistant GPU/ASIC par design (paramètre mémoire), recommandé OWASP 2024 pour le hashage de secrets longue durée. Coût mémoire 64 MiB acceptable côté serveur (la vérification est rare : une par
POST /sessions, soit ~1/15 min/agent). - HS256 et pas RS256. Un seul service vérifie les JWT, partage la clé avec lui-même. RS256 ajoute une PKI à gérer (rotation clé publique, distribution) sans bénéfice tant qu'on n'a qu'un émetteur.
- Préfixe
ah_live_. Détection automatique en cas de leak (GitHub secret scanning, gitleaks pre-commit, scrubbing logs). Sans préfixe, un secret aléatoire est indistinguable d'un autre blob et passe sous les radars. - Auth maintenue en LAN Phase 1. L'auth applicative est notre dernière ligne de défense, pas le réseau. Si demain quelqu'un branche un appareil parasite sur le LAN ou se trompe de routeur, l'auth tient. Coût zéro pour bénéfice non-zéro.
Pistes rejetées
Token unique long-lived (sans JWT court). Plus simple, mais expose le secret racine sur tous les events WebSocket et toutes les requêtes REST. Une seule fuite = compromission durable jusqu'à révocation manuelle. Mauvais arbitrage simplicité/risque même au MVP.
OAuth2 / OpenID Connect avec provider externe. Disproportionné pour 5 agents machine sans humain final. Ajoute un fournisseur d'identité tiers (coût opérationnel, latence handshake, dépendance externe) là où un secret partagé suffit. Pertinent quand on aura des humains externes ou du SSO entreprise — pas le cas Phase 1 ni Phase 2.
JWT longue durée (24 h ou plus) sans token long-lived séparé. Inverse le problème : on fait tourner un JWT longue vie qui devient l'équivalent du token racine, mais sans révocation propre (les JWT sont stateless par design). Soit on accepte la fenêtre, soit on ajoute une blacklist côté serveur — auquel cas autant garder le modèle deux niveaux.
RS256 avec rotation de clés publiques. Pertinent en architecture multi-services où plusieurs vérifieurs ne partagent pas la clé. Au MVP, un seul service, donc complexité gratuite. À reconsidérer en Phase 3+ si on extrait l'auth en service séparé.
mTLS client-side certificates. Forte sécurité, mais coût opérationnel (PKI, distribution certs aux agents, rotation, révocation CRL/OCSP) hors budget MVP. Aussi peu pertinent en LAN Phase 1 que les autres mécanismes au-dessus de TCP.
Rotation glissante JWT_SECRET (deux clés acceptées en parallèle). Évite la coupure de 15 min lors d'une rotation, mais ajoute du code (gestion liste de clés, ordre, expiration de l'ancienne). Coût/bénéfice mauvais quand la coupure est ≤ 15 min et que la rotation est rare (trimestrielle). À ajouter si on rotate plus souvent ou si la coupure devient inacceptable opérationnellement.
Procédures opérationnelles
Rotation d'un API token agent (zéro-downtime)
- Admin appelle
POST /api/v1/agents/:id/tokens→ reçoit{ token: "ah_live_…", id: "<new-token-id>" }(secret en clair, une seule fois). - Admin met à jour
AGENTHUB_TOKENcôté agent (env var, redémarrage du process agent ou rechargement à chaud si supporté). - L'agent rappelle
POST /api/v1/sessionsavec le nouveau token et obtient un JWT signé avec la mêmeJWT_SECRET(transparent côté serveur). - Après la fenêtre d'overlap (24 h par défaut), un job de housekeeping marque l'ancien token
revoked_at. L'admin peut accélérer viaDELETE /api/v1/tokens/:old-idune fois certain que tous les agents ont migré. - Vérifier
audit_events:token.issued(nouveau) +token.revoked(ancien).
Rotation JWT_SECRET (procédure complète dans RUNBOOK.md)
Voir §4 ci-dessus. Effet : invalide toutes les sessions, force un re-login des agents avec leur token long-lived, sans toucher aux tokens long-lived eux-mêmes.
Révocation immédiate d'urgence (token suspect)
DELETE /api/v1/tokens/:id→ invalide la prochaine demande JWT.- Si on veut couper le JWT déjà émis dans la minute : rotate
JWT_SECRETen plus. - Vérifier
audit_eventspour trace post-mortem.
Questions ouvertes
- Refresh JWT dédié : actuellement, l'agent rappelle
POST /api/v1/sessionsavec son token long-lived à chaque expiration. Si la fréquence devient trop élevée (très courtexpà l'avenir, ou beaucoup d'agents), on peut introduire un endpointPOST /sessions/refreshaccepté avec un JWT proche de l'expiration. Pas nécessaire au MVP (1 appel/15 min/agent = négligeable). - Blacklist JWT côté serveur (
tokenIdrévoqué) : le claimtokenIdest déjà présent pour le permettre, mais aucune blacklist n'est implémentée au MVP. À ajouter si on a besoin de révocation immédiate (< 15 min) sans rotaterJWT_SECRET. scopesgranulaires : le champ existe en BDD mais n'est pas utilisé Phase 1 (un agent authentifié peut faire toutes les actions sur les salons dont il est membre). À utiliser en Phase 2 si on introduit des bots read-only ou des rôles différenciés.- Rotation glissante de
JWT_SECRET: voir Pistes rejetées. À reconsidérer si la fréquence de rotation augmente au point que la coupure de 15 min devient gênante. - Fournisseur d'identité externe (OIDC, WorkOS, Keycloak) : à introduire seulement si on ouvre AgentHub à des humains externes ou à du SSO entreprise. Hors-périmètre Phase 1 et Phase 2.
- Algorithme d'argon2id : paramètres
m=64MiB, t=3, p=1retenus par défaut. À re-mesurer sur le serveur Ubuntu LAN Barodine pour ajustertsi la vérification dépasse 100 ms (cible : < 50 ms).