La session
Retournons dans notre App.jsx, et regardons la fonction action du chapitre 24 :
async function action(accommodationId) {
const session = await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({ accommodationId, adults: 2, children: 3, from: "...", to: "..." }),
]);
}Un email et un mot de passe, en clair, rejoués à chaque clic.
C'était acceptable pour une démo. Ça ne l'est plus.
Pourquoi le contexte ne suffit pas
Le chapitre 9 avait posé le principe : l'appelant ne déclare pas qui il est, il le prouve une fois, et les commandes suivantes lisent le contexte.
Relisons App.run :
async run(usecases) {
var context = new Context();
// ...
}Le contexte naît au début du run et meurt à la fin. C'est voulu : il représente une exécution.
Or l'utilisateur, lui, reste connecté entre deux exécutions. Il se connecte à 14h, il réserve à 14h07.
Il nous manque donc quelque chose qui survit à un run. Quelque chose que l'utilisateur peut présenter à la prochaine exécution pour dire "c'est encore moi".
Ce quelque chose s'appelle une session.
Trois façons de s'y prendre, dont deux mauvaises
1) Le frontend garde l'email et le mot de passe et les rejoue.
C'est ce que nous faisons. Le mot de passe vit dans une variable JavaScript, transite à chaque action, et se retrouve dans les logs le jour où quelqu'un ajoute un console.log de débogage.
Un mot de passe se saisit une fois. Il ne se stocke jamais, nulle part, sous aucune forme, ni côté client ni côté serveur (nous y revenons plus bas).
2) Le frontend garde l'utilisateur retourné par login et le renvoie.
Séduisant, et catastrophique. Cela revient à laisser l'appelant déclarer qui il est — exactement ce que le chapitre 9 nous a fait supprimer. Il suffit de changer un id dans la console du navigateur pour réserver au nom de quelqu'un d'autre.
Retenez la formule : ce que le client vous renvoie n'est pas une identité, c'est une affirmation. Une identité, c'est une affirmation que le serveur peut vérifier.
3) Le serveur émet un jeton, le client le présente.
Le serveur crée une valeur imprévisible, la range dans son propre état à côté de l'identifiant de l'utilisateur, et la donne au client. Le client la présente. Le serveur la reconnaît, ou pas.
Le client transporte un ticket de vestiaire. Il ne transporte pas le manteau.
Le test
Comme toujours, écrivons d'abord ce que nous voulons lire.
it("A logged in tenant gets a session token, and can reuse it later", async () => {
const app = new App(testDependencies());
// Première exécution : je m'authentifie
const login1 = await app.run([login({ email: "faketenant@mail.com", password: "secret" })]);
expect(login1.error).toBeUndefined();
expect(login1.token).toBeDefined();
// Deuxième exécution, plus tard : je présente mon jeton, pas mon mot de passe
const session = await app.run([
authenticate(login1.token),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-02", to: "2024-06-04" }),
]);
expect(session.error).toBeUndefined();
const bookings = await app.dependencies.bookings.listBookingsForTenantId("tenant-1");
expect(bookings).toHaveLength(1);
});
it("An invalid token books nothing", async () => {
const app = new App(testDependencies());
const session = await app.run([
authenticate("je-suis-un-pirate"),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-02", to: "2024-06-04" }),
]);
expect(session.error).toEqual(InvalidSession());
});Deux choses apparaissent dans ce test : une propriété token sur le contexte, et une commande authenticate.
Notez que le second test s'arrête à la première erreur. C'est le fusible de App.run, écrit au chapitre 13 : book n'est même pas exécutée. Cette ligne de code que nous avions ajoutée "au cas où" est en train de faire de la sécurité.
Le port sessions
Une session est un état du système. Donc un repository, donc un compartiment de plus dans le container.
De quoi a-t-on besoin ? De deux méthodes, pas une de plus :
// infra/MemorySessionRepository.js
export class MemorySessionRepository {
_sessions = new Map();
_counter = 0;
async create(user) {
const token = `session-${++this._counter}`;
this._sessions.set(token, user.id);
return token;
}
async findUserId(token) {
return this._sessions.get(token) ?? null;
}
}Arrêtons-nous sur le générateur de jeton, car il pose exactement la question du chapitre 17.
crypto.randomUUID() retourne une valeur différente à chaque appel. C'est donc, au même titre que l'horloge, une source d'imprévisibilité — et un test qui dépend d'une valeur imprévisible n'est pas un test.
Où la ranger ? La règle du chapitre 21 tranche sans hésiter :
L'horloge est une dépendance : elle donne un résultat différent à chaque appel.
Le générateur d'aléa aussi. Il vit donc dans l'infrastructure, jamais dans le domaine. Notre implémentation de test produit session-1, session-2 : prévisible, lisible dans les messages d'erreur, et parfaitement suffisant.
L'implémentation de production, elle, est la seule à connaître le vrai générateur :
// infra/SessionRepository.js (esquisse)
async create(user) {
const token = crypto.randomUUID(); // 122 bits d'aléa
await this._store.set(token, user.id, { ttl: SEVEN_DAYS });
return token;
}Trois exigences non négociables sur ce jeton, et aucune ne concerne le domaine :
- imprévisible : jamais un compteur, jamais l'id de l'utilisateur, jamais son email. Un jeton devinable est une porte ouverte.
- opaque : il ne contient aucune information. Le serveur seul sait à qui il correspond.
- périssable : une session qui ne meurt jamais est une clé perdue qui ouvre encore.
Notre version de test viole allègrement les deux premières. C'est parfaitement volontaire : elle n'a pas le même travail. La production doit être sûre, le test doit être lisible.
La commande login émet le jeton
export function login(payload) {
const { email, password } = payload;
return async function (dependencies, context) {
const user = await dependencies.users.findByEmail(email);
if (!user) {
return context.withError(InvalidCredentials());
}
if (!(await dependencies.passwords.verify(password, user.hashedPassword))) {
return context.withError(InvalidCredentials());
}
const token = await dependencies.sessions.create(user);
return context.withUser(user).withToken(token);
};
}Vous avez remarqué la disparition de UnknownUserEmail et WrongPassword, remplacées par une unique InvalidCredentials.
Ce n'est pas de la paresse, c'est une correction de faille. Deux messages distincts permettent à n'importe qui de tester si une adresse est inscrite sur la plateforme : il suffit de comparer les réponses. Sur un site de location de vacances, savoir que prenom.nom@entreprise.fr a un compte est déjà une information qui ne vous appartient pas.
Un formulaire de connexion répond une seule chose : email ou mot de passe incorrect.
(Les messages détaillés restent utiles — dans vos logs serveur, pas dans la réponse.)
Et le contexte s'enrichit d'une ligne :
withToken(token) {
this.token = token;
return this;
}La commande authenticate
C'est la symétrique de login : elle remplit le contexte, mais à partir d'un jeton.
export function authenticate(token) {
return async function (dependencies, context) {
if (!token) {
return context.withError(InvalidSession());
}
const userId = await dependencies.sessions.findUserId(token);
if (!userId) {
return context.withError(InvalidSession());
}
const user = await dependencies.users.findById(userId);
if (!user) {
// La session survit à l'utilisateur : compte supprimé, session orpheline.
return context.withError(InvalidSession());
}
return context.withUser(user).withToken(token);
};
}
export function InvalidSession() {
return new Error("Invalid or expired session");
}Le troisième if mérite une seconde d'attention. Il paraît impossible — comment aurait-on une session valide pour un utilisateur inexistant ? Réponse : un compte supprimé, une base restaurée, une migration. Les états impossibles finissent toujours par arriver, et une session orpheline qui plante avec Cannot read property 'id' of null à trois heures du matin, ça n'a rien d'un cas d'école.
Il nous faut au passage une méthode findById sur le repository des utilisateurs. Nous ne l'avions jamais écrite : findByEmail suffisait. Un appelant vient d'apparaître, elle a le droit d'exister.
Et remarquez la forme de nos scénarios, désormais :
await app.run([
authenticate(token),
book({ ... }),
]);authenticate en tête de liste, comme un péage. C'est exactement ce que fera un contrôleur HTTP au chapitre 40 : lire le cookie, et poser authenticate(token) devant la commande demandée.
Le mot de passe, enfin
Nous traînons depuis le chapitre 16 une fonction dont il est temps de parler :
export function encrypt(text) {
return text; // TODO : utiliser une fonction pour chiffrer le texte
}Un TODO de dix chapitres. Réglons-le, car trois erreurs se cachent dans ces deux lignes.
Un mot de passe ne se chiffre pas, il se hache. Chiffrer, c'est réversible — il existe une clé qui redonne le texte. On ne veut pas pouvoir retrouver le mot de passe : on veut seulement pouvoir vérifier qu'on nous représente le bon. Le champ ne s'appelle donc pas encryptedPassword mais hashedPassword. Encore du langage ubiquitaire, chapitre 1.
Le hachage doit être lent et salé. Pas sha256 : une carte graphique en calcule des milliards par seconde. On utilise une fonction conçue pour être coûteuse — bcrypt, scrypt, ou argon2id aujourd'hui recommandée — avec un sel unique par utilisateur, pour qu'un même mot de passe ne produise jamais deux fois la même empreinte.
La comparaison ne se fait pas avec ===. L'égalité de chaînes s'arrête au premier caractère différent : le temps de réponse trahit alors le nombre de caractères corrects. Les bibliothèques de hachage fournissent leur propre compare, à temps constant.
Bonne nouvelle : rien de tout cela ne concerne le domaine.
// infra/argonPasswordHasher.js
import argon2 from "argon2";
export const argonPasswordHasher = {
hash: (plain) => argon2.hash(plain),
verify: (plain, hashed) => argon2.verify(hashed, plain),
};// infra/testPasswordHasher.js — rapide, et volontairement stupide
export const testPasswordHasher = {
hash: async (plain) => `hashed:${plain}`,
verify: async (plain, hashed) => hashed === `hashed:${plain}`,
};Un port de plus dans le container, et la commande login ne sait toujours rien d'argon2.
Pourquoi une implémentation de test si naïve ? Parce qu'argon2 est conçu pour prendre 100 ms. Multipliez par le nombre de tests qui commencent par un login, et votre suite passe de 40 ms à plusieurs secondes. Une suite lente est une suite qu'on cesse de lancer.
C'est la contrepartie assumée : nos tests ne vérifient pas argon2. Ils vérifient que la commande refuse un mauvais mot de passe. Vérifier argon2, c'est le travail des auteurs d'argon2.
Ce qui sort du domaine
Dernier point, et il vaut pour tout ce que nous exposerons ensuite.
Notre contexte contient loggedUser, c'est-à-dire l'entité complète telle qu'elle sort de la base : identifiant, email… et empreinte du mot de passe.
Si le frontend lit context.loggedUser, l'empreinte part dans le navigateur. Puis dans un console.log. Puis dans un outil de monitoring.
Ce qui vit dans le domaine n'est pas ce qui en sort. Ajoutons donc au contexte de quoi produire une vue, et rien d'autre :
session() {
return {
token: this.token ?? null,
error: this.error ? this.error.message : null,
currentUser: this.loggedUser
? { id: this.loggedUser.id, email: this.loggedUser.email }
: null,
};
}Trois champs choisis un par un. C'est cette méthode que l'UI appellera, et c'est elle qui deviendra le corps de la réponse HTTP au chapitre 41.
Une liste blanche, jamais une liste noire : delete user.hashedPassword marche jusqu'au jour où quelqu'un ajoute une colonne.
Le domaine sait maintenant reconnaître un utilisateur d'une exécution à l'autre. Allons construire l'écran qui va avec.