Authentification
Le principe en trois phrases
- À la connexion, l'API pose deux cookies HttpOnly sur le téléphone : un jeton d'accès (1 h) et un jeton de renouvellement (30 jours).
- Elle renvoie aussi un jeton CSRF, que l'app range dans SecureStore et ajoute à chaque requête.
- Quand le jeton d'accès expire, l'app le renouvelle toute seule, sans rien demander à l'utilisateur.
| Élément | Durée | Où il est gardé | Qui l'envoie |
|---|---|---|---|
| Cookie d'accès (JWT) | 1 h | Le stockage de cookies natif du téléphone | Le système, automatiquement |
| Cookie de renouvellement | 30 jours, limité au chemin /api/auth | Pareil | Le système, uniquement vers /api/auth/... |
| Jeton CSRF | Change à chaque login, refresh et changement de mot de passe | Mémoire + SecureStore | client.ts, dans l'en-tête x-csrf-token |
Pourquoi pas un jeton dans le stockage ?
Les cookies HttpOnly ne sont pas lisibles par le JavaScript de l'app. Même en cas de faille dans le code, la session ne peut pas être volée. C'est le même mécanisme que le site web, donc l'API n'a rien de spécial à faire pour le mobile.
La connexion
Dans src/app/sign-in.tsx :
const submit = async () => {
if (sending || !email.trim() || !password) return;
setSending(true);
setFailure(null);
try {
await signIn(email.trim(), password);
} catch (error) {
setFailure(errorStatus(error) === 401 ? 'wrong' : 'unreachable');
setSending(false);
}
};- Un
401veut dire « email ou mot de passe incorrect ». - Toute autre erreur veut dire « serveur injoignable » (pas de réseau par exemple).
- En cas de succès, l'écran ne fait rien : c'est le changement de
userqui fait changer d'écran.
Retrouver la session au démarrage
Quand on rouvre l'app, il ne faut pas redemander le mot de passe. Dans src/state/session.tsx :
useEffect(() => {
whenSessionExpires(() => {
forget();
});
(async () => {
if (await loadCsrfToken()) {
try {
start(await fetchMe());
} catch (error) {
if (errorStatus(error) === 401) await clearCsrfToken();
}
}
setReady(true);
})();
}, [forget, start]);Si le jeton d'accès a expiré pendant la nuit, GET /auth/me reçoit un 401, client.ts appelle /auth/refresh (le cookie de 30 jours est encore valide), puis rejoue /auth/me. L'utilisateur ne voit rien.
La déconnexion
signOut: async () => {
try {
await logout(); // POST /auth/logout : l'API efface les cookies
} finally {
await forget(); // efface le jeton CSRF, user = null
}
},Le finally garantit qu'on oublie la session localement même si la requête échoue (pas de réseau). L'utilisateur qui appuie sur « Se déconnecter » est toujours déconnecté.
La session expirée
Si le renouvellement échoue avec un 401, client.ts appelle onSessionExpired(). SessionProvider l'a branché sur forget() au démarrage avec whenSessionExpires. Résultat : user redevient null et l'app revient à la connexion, depuis n'importe quel écran.
Cela arrive quand :
- l'app n'a pas été ouverte depuis plus de 30 jours ;
- le mot de passe a été changé ou réinitialisé sur un autre appareil (l'API révoque les autres sessions) ;
- le compte a été supprimé.
Changer de mot de passe
changePassword dans src/api/users.ts reçoit un nouveau jeton CSRF dans la réponse, et l'enregistre aussitôt. Les autres appareils, eux, sont déconnectés par l'API.
Les liens reçus par email
Certains écrans sont accessibles sans être connecté, parce qu'on y arrive depuis un email :
| Écran | Lien | Ce qu'il fait |
|---|---|---|
set-password.tsx | Invitation | Choisir son nom et son mot de passe pour activer le compte |
reset-password.tsx | Mot de passe oublié | Choisir un nouveau mot de passe |
confirm-email.tsx | Changement d'email (GM) | Confirmer la nouvelle adresse |
account-deleted.tsx | Confirmation après suppression du compte |
Les deux écrans de mot de passe utilisent le hook usePasswordLink :
export function usePasswordLink() {
const { token } = useLocalSearchParams<{ token?: string }>();
const [state, setState] = useState<LinkState>({ status: 'checking' });
useEffect(() => {
if (!token) return setState({ status: 'invalid' });
fetchPasswordLink(token) // GET /password-tokens/:token
.then((link) => setState({ status: 'valid', link }))
.catch(() => setState({ status: 'invalid' }));
}, [token]);
return { token: token ?? '', state };
}Le jeton est lu dans l'adresse (?token=...). L'écran affiche « vérification... », puis le formulaire si le lien est valide, ou une explication s'il a expiré ou déjà servi.
Ouvrir ces écrans depuis un lien
Grâce au scheme de app.json, un lien timemanagermobile://reset-password?token=... ouvre directement l'écran dans l'app. Aujourd'hui, les emails envoyés par l'API pointent vers le site web (WEB_URL) : les écrans mobiles sont prêts si l'équipe décide un jour d'envoyer des liens vers l'app.
Les règles de mot de passe
src/components/auth/password-rules.tsx affiche les règles et vérifie le mot de passe en direct, comme le web :
- au moins 12 caractères ;
- au moins une majuscule ;
- au moins un caractère spécial ;
- les deux saisies identiques.
Le bouton reste désactivé tant que passwordIsValid(password, confirm) est faux. L'API vérifie de toute façon les mêmes règles.