Skip to content

Authentification ​

Le principe en trois phrases ​

  1. À 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).
  2. Elle renvoie aussi un jeton CSRF, que l'app range dans SecureStore et ajoute à chaque requête.
  3. Quand le jeton d'accès expire, l'app le renouvelle toute seule, sans rien demander à l'utilisateur.
ÉlémentDuréeOù il est gardéQui l'envoie
Cookie d'accès (JWT)1 hLe stockage de cookies natif du téléphoneLe système, automatiquement
Cookie de renouvellement30 jours, limité au chemin /api/authPareilLe système, uniquement vers /api/auth/...
Jeton CSRFChange à chaque login, refresh et changement de mot de passeMémoire + SecureStoreclient.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 :

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 401 veut 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 user qui 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 :

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 ​

tsx
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 :

ÉcranLienCe qu'il fait
set-password.tsxInvitationChoisir son nom et son mot de passe pour activer le compte
reset-password.tsxMot de passe oubliéChoisir un nouveau mot de passe
confirm-email.tsxChangement d'email (GM)Confirmer la nouvelle adresse
account-deleted.tsxConfirmation après suppression du compte

Les deux écrans de mot de passe utilisent le hook usePasswordLink :

ts
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.