Authentification
KOHAMI utilise un schéma d'authentification custom NestJS + JWT (cf. ADR apps/api/docs/decisions/div-005-auth-custom-nest.md). Aucun fournisseur tiers (Auth0, Clerk, Cognito), l'identité est gérée intégralement dans Postgres et Scaleway Secret Manager.
Trois rôles sont définis, chacun avec un périmètre d'endpoints distinct :
| Rôle | Endpoints accessibles | 2FA requis | Durée du JWT |
|---|---|---|---|
senior | /v1/livekit/token, /v1/widgets/*, /v1/consents, /v1/profile | Non | 15 min (refresh 7 j) |
aidant | /v1/alerts/*, /v1/conversations, /v1/seniors/:id/... (RLS) | Oui (TOTP) | 15 min (refresh 24 h) |
admin | Tout, incluant /v1/admin/*, /v1/export/*, /v1/audit/* | Oui (TOTP) | 15 min (refresh 24 h) |
L'invariant INV-P05 (RLS, un senior ne voit que ses propres données) est appliqué côté Postgres : chaque connexion JWT injecte app.current_user_id via SET LOCAL, et chaque table sensible expose une policy USING (user_id = current_setting('app.current_user_id')).
Login, 3 endpoints
type AuthLoginRequest = {
email: string;
password: string;
role: "senior" | "aidant" | "admin";
totpCode?: string;
};
type AuthLoginResponse = {
accessToken: string;
refreshToken: string;
expiresIn: number;
requiresTwoFactor?: true;
};Les types canoniques vivent dans @kohami/shared/types/auth.
Senior, login simple
const response = await fetch(`${apiBaseUrl}/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "senior@example.com",
password: "...",
role: "senior",
}),
});Aidant ou admin, login plus TOTP
Le premier appel sans totpCode retourne requiresTwoFactor: true (HTTP 200, token absent). Le second appel inclut le code TOTP six chiffres généré par l'app Google Authenticator (ou équivalent).
const init = await fetch(`${apiBaseUrl}/v1/auth/login`, {
method: "POST",
body: JSON.stringify({ email, password, role: "aidant" }),
});
const initJson = (await init.json()) as { requiresTwoFactor?: true };
if (initJson.requiresTwoFactor) {
const final = await fetch(`${apiBaseUrl}/v1/auth/login`, {
method: "POST",
body: JSON.stringify({ email, password, role: "aidant", totpCode: "123456" }),
});
const finalJson = (await final.json()) as AuthLoginResponse;
}Le rollout 2FA TOTP est tracé dans l'ADR auth-2fa-totp-rollout.md (PR #466).
Refresh
L'accessToken est volontairement court (15 minutes). Pour le renouveler :
const response = await fetch(`${apiBaseUrl}/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});Le refreshToken est rotaté à chaque refresh, l'ancien est invalidé immédiatement (defense-in-depth contre le replay). Stockez toujours le nouveau token retourné.
Codes d'erreur
| HTTP | Cause | Action |
|---|---|---|
400 Bad Request | Payload mal formé | Vérifier le contrat Zod |
401 Unauthorized | JWT manquant, expiré ou signature invalide | Retenter un refresh, sinon login |
403 Forbidden | JWT valide mais rôle insuffisant pour la route | Pas de retry possible |
409 Conflict | TOTP requis mais non fourni | Voir requiresTwoFactor dans le body |
429 Too Many Requests | Rate limit auth dépassé | Backoff exponentiel, voir Gestion d'erreurs |
Le contrat NestJS standard est uniforme :
{
"statusCode": 401,
"message": "Token expired",
"error": "Unauthorized"
}Sécurité opérationnelle
- Stockage côté tablette :
expo-secure-store(iOS Keychain, Android KeyStore), jamaisAsyncStorage - HTTPS only, aucun appel HTTP plain accepté côté back (CSP + HSTS)
- Idle timeout, la session aidant expire après 30 minutes d'inactivité observable
- Audit log, toute opération sensible est tracée, voir
/v1/audit/...(admin only)