Activer l'authentification par clé API demande de lire dist/ : prefixeCle absent du type, aucun outil pour créer une clé, et le non-rebornage des scopes n'est écrit nulle part #36

Closed
opened 2026-09-09 19:09:40 +00:00 by nicolas-fryder · 3 comments

Constaté le 2026-09-09 sur le premier satellite réel monté depuis eqs-satellite-template, en activant l'authentification par clé API pour qu'un agent non humain puisse écrire dans l'API. @eqs/* 0.7.0.

Le socle a tout ce qu'il faut : DepotCles, genererCle, hacherCle, verifierCle, scopesBornes, principalDepuisCle, et jusqu'à la table ApiKey livrée dans auth.prisma. Mais l'assembler demande d'aller lire dist/, sur trois points distincts. Ça a marché, c'est en production chez nous, et les trois se corrigent probablement en une passe de documentation plus un champ de type.

1. prefixeCle n'est pas une option, et rien ne le dit

OptionsAuthPlateforme (@eqs/platform-nest, index.d.ts l. 21-37) expose depotCles, mais aucun champ pour le préfixe. En réalité assemblage.js (l. 149) lit API_KEY_PREFIX directement dans l'environnement validé, sans jamais passer par les options que l'application fournit.

Le résultat fonctionne — API_KEY_PREFIX est dans baseSchema — mais le chemin pour y arriver est une lecture de dist/. Ni le README de @eqs/auth, ni le type ne disent qu'il ne faut pas le passer soi-même. Une surface qui expose depotCles mais tait son compagnon naturel invite à chercher là où il n'y a rien.

Proposition : soit accepter prefixeCle dans les options en repliant sur API_KEY_PREFIX, soit une ligne de documentation sur le type disant que le préfixe vient de l'environnement et pas des options. La seconde suffit.

2. Aucun outil ne crée une clé

Le README documente genererCle, mais ne montre nulle part l'insertion en base, et il n'existe pas de commande équivalente à eqs auth roles --init.

Concrètement, créer une clé pour un agent demande d'écrire à la main l'insertion de la ligne ApiKey avec le bon hash — et de ne pas se tromper, puisque la clé brute ne se revoit jamais. C'est un geste qu'on refera à chaque agent, à chaque satellite.

Proposition : une commande eqs auth cle --creer --proprietaire <id> --scopes <…>, symétrique d'eqs auth roles --init, qui affiche la clé brute une seule fois et n'écrit que le hash. À défaut, un exemple complet dans le README de @eqs/auth — l'insertion en base, pas seulement genererCle.

3. Le non-rebornage des scopes n'est écrit nulle part — et c'est le point de sécurité

scopesBornes(demandes, permissionsProprietaire) existe et fait ce qu'il faut : une clé ne peut pas demander plus que ce que son propriétaire possède. Mais elle ne s'applique qu'à la création de la clé. À chaque requête, principalDepuisCle construit permissions: new Set(cle.scopes) — les scopes figés dans la ligne, sans re-borner par les permissions actuelles du propriétaire.

C'est un choix défendable (moins de requêtes, comportement prévisible), mais il a une conséquence qu'il faut connaître : si les droits d'une personne sont réduits, ses clés existantes gardent les leurs. Retirer un rôle à quelqu'un ne referme pas les clés qu'il a déjà émises ; il faut les révoquer explicitement.

Ce comportement n'est documenté que dans dist/index.js. Sur une question d'autorisation, ça ne devrait pas être le cas — c'est le genre de détail qu'on découvre après un incident.

Proposition : l'écrire dans le README de @eqs/auth, à côté de scopesBornes, avec la conséquence opérationnelle (« révoquer les clés en retirant un rôle »). Et si le rebornage à chaque requête est jugé souhaitable, c'est une décision de sécurité qui mérite son propre ticket.

Ce que ça coûte

Rien de bloquant : tout se contourne en lisant dist/. Le coût est en temps — nous avons passé un moment à assembler ce qui aurait dû être une recette — et en risque sur le point 3, qui touche à l'autorisation et se découvre au mauvais moment.

Ce qui est en place chez nous

apps/api/src/auth/depot-cles.prisma.ts implémente DepotCles sur ApiKey, câblé dans plateforme.ts, avec un test d'intégration monté sans le simulacre d'authentification qui prouve qu'une vraie clé authentifie, qu'une clé révoquée ou expirée est refusée, et qu'un porteur de clé reçoit bien 403 sur une opération réservée aux personnes. Je peux coller le dépôt ici s'il peut servir de base à une implémentation de référence — il fait une vingtaine de lignes.

Constaté le 2026-09-09 sur le premier satellite réel monté depuis `eqs-satellite-template`, en activant l'authentification par clé API pour qu'un agent non humain puisse écrire dans l'API. `@eqs/*` 0.7.0. Le socle a tout ce qu'il faut : `DepotCles`, `genererCle`, `hacherCle`, `verifierCle`, `scopesBornes`, `principalDepuisCle`, et jusqu'à la table `ApiKey` livrée dans `auth.prisma`. **Mais l'assembler demande d'aller lire `dist/`**, sur trois points distincts. Ça a marché, c'est en production chez nous, et les trois se corrigent probablement en une passe de documentation plus un champ de type. ## 1. `prefixeCle` n'est pas une option, et rien ne le dit `OptionsAuthPlateforme` (`@eqs/platform-nest`, `index.d.ts` l. 21-37) expose `depotCles`, mais **aucun champ pour le préfixe**. En réalité `assemblage.js` (l. 149) lit `API_KEY_PREFIX` directement dans l'environnement validé, sans jamais passer par les options que l'application fournit. Le résultat fonctionne — `API_KEY_PREFIX` est dans `baseSchema` — mais le chemin pour y arriver est une lecture de `dist/`. Ni le README de `@eqs/auth`, ni le type ne disent qu'il **ne faut pas** le passer soi-même. Une surface qui expose `depotCles` mais tait son compagnon naturel invite à chercher là où il n'y a rien. **Proposition** : soit accepter `prefixeCle` dans les options en repliant sur `API_KEY_PREFIX`, soit une ligne de documentation sur le type disant que le préfixe vient de l'environnement et pas des options. La seconde suffit. ## 2. Aucun outil ne crée une clé Le README documente `genererCle`, mais **ne montre nulle part l'insertion en base**, et il n'existe pas de commande équivalente à `eqs auth roles --init`. Concrètement, créer une clé pour un agent demande d'écrire à la main l'insertion de la ligne `ApiKey` avec le bon hash — et de ne pas se tromper, puisque la clé brute ne se revoit jamais. C'est un geste qu'on refera à chaque agent, à chaque satellite. **Proposition** : une commande `eqs auth cle --creer --proprietaire <id> --scopes <…>`, symétrique d'`eqs auth roles --init`, qui affiche la clé brute une seule fois et n'écrit que le hash. À défaut, un exemple complet dans le README de `@eqs/auth` — l'insertion en base, pas seulement `genererCle`. ## 3. Le non-rebornage des scopes n'est écrit nulle part — et c'est le point de sécurité `scopesBornes(demandes, permissionsProprietaire)` existe et fait ce qu'il faut : une clé ne peut pas demander plus que ce que son propriétaire possède. **Mais elle ne s'applique qu'à la création de la clé.** À chaque requête, `principalDepuisCle` construit `permissions: new Set(cle.scopes)` — les scopes figés dans la ligne, sans re-borner par les permissions actuelles du propriétaire. C'est un choix défendable (moins de requêtes, comportement prévisible), mais il a une conséquence qu'il faut connaître : **si les droits d'une personne sont réduits, ses clés existantes gardent les leurs.** Retirer un rôle à quelqu'un ne referme pas les clés qu'il a déjà émises ; il faut les révoquer explicitement. Ce comportement n'est documenté que dans `dist/index.js`. Sur une question d'autorisation, ça ne devrait pas être le cas — c'est le genre de détail qu'on découvre après un incident. **Proposition** : l'écrire dans le README de `@eqs/auth`, à côté de `scopesBornes`, avec la conséquence opérationnelle (« révoquer les clés en retirant un rôle »). Et si le rebornage à chaque requête est jugé souhaitable, c'est une décision de sécurité qui mérite son propre ticket. ## Ce que ça coûte Rien de bloquant : tout se contourne en lisant `dist/`. Le coût est en temps — nous avons passé un moment à assembler ce qui aurait dû être une recette — et en risque sur le point 3, qui touche à l'autorisation et se découvre au mauvais moment. ## Ce qui est en place chez nous `apps/api/src/auth/depot-cles.prisma.ts` implémente `DepotCles` sur `ApiKey`, câblé dans `plateforme.ts`, avec un test d'intégration monté **sans** le simulacre d'authentification qui prouve qu'une vraie clé authentifie, qu'une clé révoquée ou expirée est refusée, et qu'un porteur de clé reçoit bien 403 sur une opération réservée aux personnes. Je peux coller le dépôt ici s'il peut servir de base à une implémentation de référence — il fait une vingtaine de lignes.
Author
Owner

Quatrième point, trouvé en implémentant : scopesBornes est tout-ou-rien, et son nom dit le contraire

En écrivant l'outil de création de clé, nous avons découvert que scopesBornes lève dès qu'un seul scope demandé dépasse les permissions du propriétaire — elle ne rend jamais le sous-ensemble autorisé.

Le nom dit pourtant l'inverse. « Borner » un ensemble, c'est le réduire à ce qui est permis ; on s'attend à recevoir l'intersection. Nous avons écrit le code en supposant cette sémantique, et c'est l'exception qui nous a détrompés. Le comportement n'est documenté ni dans le README de @eqs/auth, ni sur la signature — il se lit dans dist/.

Pourquoi ça compte à l'usage

Le geste courant est : « donne à cet agent tout ce que je peux lui donner parmi ceci ». Avec un tout-ou-rien, la personne doit deviner d'avance l'intersection exacte, sous peine de se faire refuser l'appel entier — et elle n'a aucun moyen simple de la calculer, puisque c'est précisément le travail de la fonction.

Notre outil a donc dû reproduire le test de la fonction avec les primitives publiques (estPermissionValide, permet) pour décider quoi lui soumettre, avant de la laisser rendre le résultat inséré. Autrement dit : on réimplémente la moitié de scopesBornes pour pouvoir l'appeler sans qu'elle lève. C'est le signe assez net qu'il manque quelque chose dans la surface publique.

Ce que ça donne côté personne

Notre script affiche désormais :

Demande : noeud.*, decision.ratifier, *.lire
Accorde : noeud.*
RETIRE PAR LE BORNAGE : decision.ratifier, *.lire — hors des permissions reelles de <proprietaire>

C'est ce que nous voulions : quelqu'un qui croit accorder un droit apprend immédiatement que non. Mais obtenir ces trois lignes a demandé de contourner la fonction censée les produire.

Ce que je proposerais

Une des trois, par ordre de préférence :

  1. scopesBornes rend l'intersection et ne lève plus. C'est ce que son nom promet, et ça rend l'appel utilisable directement. Ceux qui veulent un refus strict comparent la longueur avant/après.
  2. Garder le tout-ou-rien mais exposer la fonction de calcul (scopesAutorises(demandes, permissions) par exemple), pour qu'on n'ait pas à la réécrire.
  3. À défaut, documenter le comportement sur la signature et dans le README — au minimum, que la fonction lève et ne filtre pas.

C'est le quatrième point de ce ticket ; les trois autres sont dans le corps ci-dessus. Ils se recoupent tous : la surface d'authentification par clé fonctionne bien, mais s'assemble en lisant dist/.

## Quatrième point, trouvé en implémentant : `scopesBornes` est tout-ou-rien, et son nom dit le contraire En écrivant l'outil de création de clé, nous avons découvert que **`scopesBornes` lève dès qu'un seul scope demandé dépasse les permissions du propriétaire** — elle ne rend jamais le sous-ensemble autorisé. Le nom dit pourtant l'inverse. « Borner » un ensemble, c'est le réduire à ce qui est permis ; on s'attend à recevoir l'intersection. Nous avons écrit le code en supposant cette sémantique, et c'est l'exception qui nous a détrompés. Le comportement n'est documenté ni dans le README de `@eqs/auth`, ni sur la signature — il se lit dans `dist/`. ### Pourquoi ça compte à l'usage Le geste courant est : « donne à cet agent tout ce que je peux lui donner parmi ceci ». Avec un tout-ou-rien, la personne doit deviner d'avance l'intersection exacte, sous peine de se faire refuser l'appel entier — et elle n'a aucun moyen simple de la calculer, puisque c'est précisément le travail de la fonction. Notre outil a donc dû **reproduire le test de la fonction avec les primitives publiques** (`estPermissionValide`, `permet`) pour décider quoi lui soumettre, avant de la laisser rendre le résultat inséré. Autrement dit : on réimplémente la moitié de `scopesBornes` pour pouvoir l'appeler sans qu'elle lève. C'est le signe assez net qu'il manque quelque chose dans la surface publique. ### Ce que ça donne côté personne Notre script affiche désormais : ``` Demande : noeud.*, decision.ratifier, *.lire Accorde : noeud.* RETIRE PAR LE BORNAGE : decision.ratifier, *.lire — hors des permissions reelles de <proprietaire> ``` C'est ce que nous voulions : quelqu'un qui croit accorder un droit apprend immédiatement que non. Mais obtenir ces trois lignes a demandé de contourner la fonction censée les produire. ### Ce que je proposerais Une des trois, par ordre de préférence : 1. **`scopesBornes` rend l'intersection** et ne lève plus. C'est ce que son nom promet, et ça rend l'appel utilisable directement. Ceux qui veulent un refus strict comparent la longueur avant/après. 2. Garder le tout-ou-rien mais **exposer la fonction de calcul** (`scopesAutorises(demandes, permissions)` par exemple), pour qu'on n'ait pas à la réécrire. 3. À défaut, **documenter le comportement** sur la signature et dans le README — au minimum, que la fonction lève et ne filtre pas. C'est le quatrième point de ce ticket ; les trois autres sont dans le corps ci-dessus. Ils se recoupent tous : la surface d'authentification par clé fonctionne bien, mais s'assemble en lisant `dist/`.
Author
Owner

Les quatre points sont vrais, vérifiés dans le code, et corrigés.

1. prefixeCle. Confirmé : le champ n'existe pas dans OptionsAuthPlateforme, et assemblage.ts lit env.API_KEY_PREFIX. J'ai retenu votre seconde proposition — la documentation — et pas la première, pour une raison qui mérite d'être écrite : deux sources se contrediraient en silence, et c'est celle qui n'a pas servi à créer les clés qui ferait échouer l'authentification. Le type le dit maintenant, avec le pourquoi.

2. Aucun outil ne crée une clé. Vrai. preparerCle rend d'un coup la valeur à montrer une fois et la ligne à écrire, portées bornées comprises :

const { brute, ligne } = preparerCle({
  nom: 'agent de saisie',
  proprietaireId: utilisateur.id,
  scopes: ['affaires.lire', 'affaires.creer'],
  permissionsProprietaire: permissionsDe(utilisateur),
  prefixe: env.API_KEY_PREFIX,
})
await prisma.apiKey.create({ data: ligne })
console.log(brute) // une fois, et jamais plus

Pas la commande CLI que vous proposiez, et je préfère l'argumenter : eqs n'a ni client Prisma ni schéma de votre base, et DepotCles est en lecture seule par conception — l'écriture appartient à l'application. Une commande qui insérerait devrait deviner votre modèle. Le README porte la recette complète, insertion comprise.

3. Le non-rebornage. Confirmé : principalDepuisCle construit permissions: new Set(cle.scopes), sans rien reborner. C'est écrit maintenant sur la signature de scopesBornes et dans le README, avec la conséquence en clair :

Retirer un rôle à quelqu'un ne retire rien à ses clés existantes. Il faut les révoquer.

Vous avez raison que ça n'avait rien à faire dans dist/. J'ajoute une chose que la relecture a rendue visible : auth.cle-revoquee vérifie qu'une clé révoquée est refusée ; aucun contrôle ne vérifie qu'on a pensé à la révoquer. C'est écrit à côté, pour que personne ne prenne le premier pour le second.

Je n'ai pas changé le comportement — c'est bien une décision de sécurité, et elle mérite son propre ticket si vous voulez la rouvrir.

4. scopesBornes tout-ou-rien. Vrai, et votre analyse du coût est exacte : pour lui soumettre quelque chose qu'elle accepte, il fallait recalculer l'intersection soi-même, c'est-à-dire réécrire la moitié de la fonction.

J'ai pris votre option 2 plutôt que la 1. scopesAutorises(demandes, permissions) rend l'intersection sans lever. scopesBornes garde son refus strict, et c'est délibéré pour la création d'une clé : accorder silencieusement moins que demandé produit une clé qui échouera plus tard, sur une route, sans que personne se souvienne pourquoi. Vos trois lignes d'affichage — demandé / accordé / retiré par le bornage — sont exactement le bon geste, et le README le recommande.

Renommer ou changer le comportement de scopesBornes aurait cassé silencieusement les appels existants, qui comptent sur le refus.

pnpm check vert, 49 tests dans @eqs/auth (deux de plus, dont un qui vérifie que la clé en clair n'apparaît nulle part dans la ligne à écrire).

Votre offre de coller votre DepotCles Prisma nous intéresse — une implémentation de référence dans le gabarit éviterait à chacun de la réécrire. Postez-la, je la reprendrai.

Branche fix/cles-api. Disponible à la prochaine version de @eqs/*.

Les quatre points sont vrais, vérifiés dans le code, et corrigés. **1. `prefixeCle`.** Confirmé : le champ n'existe pas dans `OptionsAuthPlateforme`, et `assemblage.ts` lit `env.API_KEY_PREFIX`. J'ai retenu votre seconde proposition — la documentation — et pas la première, pour une raison qui mérite d'être écrite : deux sources se contrediraient en silence, et c'est celle qui **n'a pas servi à créer les clés** qui ferait échouer l'authentification. Le type le dit maintenant, avec le pourquoi. **2. Aucun outil ne crée une clé.** Vrai. `preparerCle` rend d'un coup la valeur à montrer une fois et la ligne à écrire, portées bornées comprises : ```ts const { brute, ligne } = preparerCle({ nom: 'agent de saisie', proprietaireId: utilisateur.id, scopes: ['affaires.lire', 'affaires.creer'], permissionsProprietaire: permissionsDe(utilisateur), prefixe: env.API_KEY_PREFIX, }) await prisma.apiKey.create({ data: ligne }) console.log(brute) // une fois, et jamais plus ``` **Pas la commande CLI que vous proposiez**, et je préfère l'argumenter : `eqs` n'a ni client Prisma ni schéma de votre base, et `DepotCles` est en lecture seule par conception — l'écriture appartient à l'application. Une commande qui insérerait devrait deviner votre modèle. Le README porte la recette complète, insertion comprise. **3. Le non-rebornage.** Confirmé : `principalDepuisCle` construit `permissions: new Set(cle.scopes)`, sans rien reborner. C'est écrit maintenant sur la signature de `scopesBornes` et dans le README, avec la conséquence en clair : > **Retirer un rôle à quelqu'un ne retire rien à ses clés existantes.** Il faut les révoquer. Vous avez raison que ça n'avait rien à faire dans `dist/`. J'ajoute une chose que la relecture a rendue visible : `auth.cle-revoquee` vérifie qu'une clé révoquée est refusée ; **aucun contrôle ne vérifie qu'on a pensé à la révoquer**. C'est écrit à côté, pour que personne ne prenne le premier pour le second. Je n'ai pas changé le comportement — c'est bien une décision de sécurité, et elle mérite son propre ticket si vous voulez la rouvrir. **4. `scopesBornes` tout-ou-rien.** Vrai, et votre analyse du coût est exacte : pour lui soumettre quelque chose qu'elle accepte, il fallait recalculer l'intersection soi-même, c'est-à-dire réécrire la moitié de la fonction. J'ai pris votre **option 2** plutôt que la 1. `scopesAutorises(demandes, permissions)` rend l'intersection sans lever. `scopesBornes` garde son refus strict, et c'est délibéré pour la création d'une clé : accorder silencieusement moins que demandé produit une clé qui échouera plus tard, sur une route, sans que personne se souvienne pourquoi. Vos trois lignes d'affichage — demandé / accordé / retiré par le bornage — sont exactement le bon geste, et le README le recommande. Renommer ou changer le comportement de `scopesBornes` aurait cassé silencieusement les appels existants, qui comptent sur le refus. `pnpm check` vert, 49 tests dans `@eqs/auth` (deux de plus, dont un qui vérifie que la clé en clair n'apparaît nulle part dans la ligne à écrire). **Votre offre de coller votre `DepotCles` Prisma nous intéresse** — une implémentation de référence dans le gabarit éviterait à chacun de la réécrire. Postez-la, je la reprendrai. Branche `fix/cles-api`. Disponible à la prochaine version de `@eqs/*`.
Author
Owner

Le DepotCles Prisma, comme demandé

Voici l'implémentation en place chez nous. Elle tourne, elle est couverte par un test d'intégration qui monte l'application sans le simulacre d'authentification.

import type { PrismaClient } from '@prisma/client'
import type { CleStockee, DepotCles } from '@eqs/auth'
import { log } from '@eqs/telemetry'

/**
 * Le `DepotCles` de `@eqs/auth` sur la table `api_key` (générée par
 * `auth.prisma`, `@eqs/auth`). Aucune règle métier : une ligne lue, une ligne
 * rendue dans la forme attendue. `@eqs/auth` fait le reste (hash, expiration,
 * révocation, `marquerUtilisee`).
 *
 * La clé BRUTE ne passe jamais ici : seul son hash SHA-256 sert de clé de
 * recherche (`hash` est `@unique` dans `auth.prisma`), et seul le hash est
 * stocké.
 */
export class DepotClesPrisma implements DepotCles {
  constructor(private readonly prisma: PrismaClient) {}

  async trouverParHash(hash: string): Promise<CleStockee | null> {
    const cle = await this.prisma.apiKey.findUnique({ where: { hash } })
    if (cle === null) return null
    return {
      id: cle.id,
      nom: cle.nom,
      hash: cle.hash,
      prefixe: cle.prefixe,
      scopes: cle.scopes,
      proprietaireId: cle.proprietaireId,
      revoqueeLe: cle.revoqueeLe,
      expireLe: cle.expireLe,
    }
  }

  async marquerUtilisee(id: string, quand: Date): Promise<void> {
    // Le socle appelle ceci APRÈS avoir validé la clé, AVANT de rendre le
    // principal : c'est de la télémétrie (la date de dernier usage), pas une
    // condition d'authentification. Ailleurs dans ce dépôt une écriture qui
    // échoue remonte (fidèle à l'erreur réelle) ; ici, une base
    // momentanément indisponible ou un conflit ne doit JAMAIS casser
    // l'authentification d'une clé par ailleurs valide — on journalise et on
    // laisse passer.
    try {
      await this.prisma.apiKey.update({ where: { id }, data: { utiliseeLe: quand } })
    } catch (erreur) {
      log.error('auth.cle.marquer-utilisee.echec', {
        cleId: id,
        erreur: erreur instanceof Error ? erreur.message : String(erreur),
      })
    }
  }
}

Le câblage, dans optionsApp() :

auth: {
  database: prismaAdapter(prisma(), { provider: 'postgresql' }),
  depotCles: new DepotClesPrisma(prisma()),
},

Deux points qui méritent peut-être de voyager avec le code

1. marquerUtilisee doit avaler ses échecs, et ce n'est pas évident. C'est le seul endroit de notre dépôt où une écriture qui rate ne remonte pas — partout ailleurs on est fidèle à l'erreur réelle. La raison est que le socle l'appelle après avoir validé la clé et avant de rendre le principal : sans ce try/catch, une base momentanément indisponible fait échouer l'authentification d'une clé parfaitement valide. C'est notre relecture de sécurité qui l'a attrapé, pas nous.

Si vous reprenez cette implémentation dans le gabarit, la garder avec son commentaire évitera qu'un lecteur bien intentionné « corrige » le catch en le faisant remonter.

Cela dit, ça pose une question qui vous appartient : la méthode étant optionnelle dans l'interface, faudrait-il que principalDepuisCle protège lui-même son appel ? Aujourd'hui, chaque implémentation doit y penser — et la nôtre n'y avait pas pensé du premier coup.

2. import type { PrismaClient } casse l'injection Nest. Nous nous sommes fait avoir ailleurs dans le même dépôt : un import type efface la métadonnée de constructeur que Nest lit à l'exécution, et le service ne démarre plus. Le message de Nest est explicite une fois qu'on l'a vu, mais on le voit tard. Ici la classe n'est pas @Injectable() — elle est instanciée à la main dans optionsApp() — donc le piège ne joue pas. Si vous en faites un provider Nest dans le gabarit, l'import doit être de valeur.

Ce qu'on fera de notre côté

preparerCle et scopesAutorises remplaceront directement la partie la plus laide de notre script de création de clé — celle où l'on recalcule l'intersection avec estPermissionValide et permet pour pouvoir soumettre à scopesBornes quelque chose qu'elle accepte. On reprendra ça dès que la version sera publiée, et je viendrai dire ici si la reprise se passe bien : c'est le meilleur test de la correction.

Merci pour les réponses argumentées, en particulier sur prefixeCle — l'argument des deux sources qui se contredisent en silence, avec celle qui n'a pas servi à créer les clés qui fait échouer l'authentification, est plus convaincant que ma proposition.

## Le `DepotCles` Prisma, comme demandé Voici l'implémentation en place chez nous. Elle tourne, elle est couverte par un test d'intégration qui monte l'application **sans** le simulacre d'authentification. ```ts import type { PrismaClient } from '@prisma/client' import type { CleStockee, DepotCles } from '@eqs/auth' import { log } from '@eqs/telemetry' /** * Le `DepotCles` de `@eqs/auth` sur la table `api_key` (générée par * `auth.prisma`, `@eqs/auth`). Aucune règle métier : une ligne lue, une ligne * rendue dans la forme attendue. `@eqs/auth` fait le reste (hash, expiration, * révocation, `marquerUtilisee`). * * La clé BRUTE ne passe jamais ici : seul son hash SHA-256 sert de clé de * recherche (`hash` est `@unique` dans `auth.prisma`), et seul le hash est * stocké. */ export class DepotClesPrisma implements DepotCles { constructor(private readonly prisma: PrismaClient) {} async trouverParHash(hash: string): Promise<CleStockee | null> { const cle = await this.prisma.apiKey.findUnique({ where: { hash } }) if (cle === null) return null return { id: cle.id, nom: cle.nom, hash: cle.hash, prefixe: cle.prefixe, scopes: cle.scopes, proprietaireId: cle.proprietaireId, revoqueeLe: cle.revoqueeLe, expireLe: cle.expireLe, } } async marquerUtilisee(id: string, quand: Date): Promise<void> { // Le socle appelle ceci APRÈS avoir validé la clé, AVANT de rendre le // principal : c'est de la télémétrie (la date de dernier usage), pas une // condition d'authentification. Ailleurs dans ce dépôt une écriture qui // échoue remonte (fidèle à l'erreur réelle) ; ici, une base // momentanément indisponible ou un conflit ne doit JAMAIS casser // l'authentification d'une clé par ailleurs valide — on journalise et on // laisse passer. try { await this.prisma.apiKey.update({ where: { id }, data: { utiliseeLe: quand } }) } catch (erreur) { log.error('auth.cle.marquer-utilisee.echec', { cleId: id, erreur: erreur instanceof Error ? erreur.message : String(erreur), }) } } } ``` Le câblage, dans `optionsApp()` : ```ts auth: { database: prismaAdapter(prisma(), { provider: 'postgresql' }), depotCles: new DepotClesPrisma(prisma()), }, ``` ### Deux points qui méritent peut-être de voyager avec le code **1. `marquerUtilisee` doit avaler ses échecs, et ce n'est pas évident.** C'est le seul endroit de notre dépôt où une écriture qui rate ne remonte pas — partout ailleurs on est fidèle à l'erreur réelle. La raison est que le socle l'appelle *après* avoir validé la clé et *avant* de rendre le principal : sans ce `try/catch`, une base momentanément indisponible fait échouer l'authentification d'une clé parfaitement valide. C'est notre relecture de sécurité qui l'a attrapé, pas nous. Si vous reprenez cette implémentation dans le gabarit, la garder **avec son commentaire** évitera qu'un lecteur bien intentionné « corrige » le `catch` en le faisant remonter. Cela dit, ça pose une question qui vous appartient : la méthode étant optionnelle dans l'interface, faudrait-il que `principalDepuisCle` protège lui-même son appel ? Aujourd'hui, chaque implémentation doit y penser — et la nôtre n'y avait pas pensé du premier coup. **2. `import type { PrismaClient }` casse l'injection Nest.** Nous nous sommes fait avoir ailleurs dans le même dépôt : un `import type` efface la métadonnée de constructeur que Nest lit à l'exécution, et le service ne démarre plus. Le message de Nest est explicite une fois qu'on l'a vu, mais on le voit tard. Ici la classe n'est pas `@Injectable()` — elle est instanciée à la main dans `optionsApp()` — donc le piège ne joue pas. Si vous en faites un provider Nest dans le gabarit, l'import doit être **de valeur**. ### Ce qu'on fera de notre côté `preparerCle` et `scopesAutorises` remplaceront directement la partie la plus laide de notre script de création de clé — celle où l'on recalcule l'intersection avec `estPermissionValide` et `permet` pour pouvoir soumettre à `scopesBornes` quelque chose qu'elle accepte. On reprendra ça dès que la version sera publiée, et je viendrai dire ici si la reprise se passe bien : c'est le meilleur test de la correction. Merci pour les réponses argumentées, en particulier sur `prefixeCle` — l'argument des deux sources qui se contredisent en silence, avec celle qui n'a pas servi à créer les clés qui fait échouer l'authentification, est plus convaincant que ma proposition.
nicolas-fryder referenced this issue from a commit 2026-09-09 21:51:55 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Infrastructure/eqs-platform#36
No description provided.