Dal “perché” al “come”: cosa costruiamo
Nel precedente articolo abbiamo chiuso la questione principale: il token JWT non va in AsyncStorage, va nel Keychain. Bene, e adesso? Qui entriamo nella parte che i tutorial tendono a saltare — non tanto dove mettere il token, quanto come organizzare il codice che lo salva, lo legge, lo rinnova e lo cancella quando serve.
L’obiettivo di questa guida è costruire un piccolo package auth che racchiuda tutta la logica di sessione dietro un’API minima e stabile: tre funzioni pubbliche, un provider React, un paio di regole chiare per il refresh. Niente di più, e soprattutto niente di meno.
Perché un package vero e proprio, e non un semplice file auth.js buttato dentro src/services/? Perché l’autenticazione è uno di quei pezzi di codice che tocchi raramente, ma che tutti gli altri moduli toccano di continuo. Se la logica resta isolata dietro un contratto chiaro, puoi cambiare implementazione — passare da cookie WordPress a JWT puro, aggiungere la biometria, cambiare endpoint — senza dover rincorrere ogni singola chiamata sparsa nell’app. Se invece è sparsa ovunque, ogni modifica si trasforma in una caccia al tesoro tra i file.
Le tre funzioni che ci diamo come contratto pubblico sono performLogin(credentials), che autentica l’utente e persiste il token; performLogout(), che cancella token, refresh e stato di sessione; e getAuthToken(), che restituisce il token valido corrente rinnovandolo, se serve, in modo trasparente. Tutto il resto — Keychain, fetch, parsing della risposta, gestione degli errori — vive dentro il package e non è visibile dall’esterno.
La struttura del package auth
La struttura che adottiamo è volutamente semplice: niente over-engineering, ma abbastanza separazione da poter testare e sostituire i singoli pezzi senza toccare il resto.
src/packages/auth/
├── src/
│ ├── tokenStorage.js // wrapper su react-native-keychain
│ ├── authApi.js // chiamate HTTP al backend
│ ├── authService.js // orchestrazione: login, logout, refresh
│ ├── AuthContext.js // provider React + useAuth hook
│ └── index.js // export pubblico (solo le 3 funzioni + provider)
├── package.json // finto, documenta il contratto
└── README.md
Il principio guida è quello della funzione a imbuto: i componenti esterni vedono solo index.js, che re-esporta performLogin, performLogout, getAuthToken, l’hook useAuth e il provider AuthProvider. Tutto ciò che sta sotto src/ è implementazione privata, e nessuno al di fuori del package dovrebbe mai doverlo conoscere.
Vediamo i pezzi uno per uno, nell’ordine in cui conviene costruirli.
1. tokenStorage.js: il wrapper su Keychain
Il primo file da scrivere è quello che parla direttamente con react-native-keychain. L’idea è nascondere i dettagli della libreria dietro funzioni con nomi che dicono esattamente cosa fanno, così che chi legge il codice più avanti non debba andare a rileggersi la documentazione della libreria:
import * as Keychain from 'react-native-keychain';
const JWT_SERVICE = 'jwt_token';
const REFRESH_SERVICE = 'jwt_refresh_token';
// Salva il token di accesso
export async function setAccessToken(token) {
await Keychain.setInternetCredentials(JWT_SERVICE, 'user', token);
}
// Restituisce il token di accesso, o null se non c'è
export async function getAccessToken() {
const creds = await Keychain.getInternetCredentials(JWT_SERVICE);
return creds ? creds.password : null;
}
// Salva il refresh token (separato dall'access token)
export async function setRefreshToken(token) {
await Keychain.setInternetCredentials(REFRESH_SERVICE, 'user', token);
}
export async function getRefreshToken() {
const creds = await Keychain.getInternetCredentials(REFRESH_SERVICE);
return creds ? creds.password : null;
}
// Cancella entrambi
export async function clearTokens() {
await Keychain.resetInternetCredentials(JWT_SERVICE);
await Keychain.resetInternetCredentials(REFRESH_SERVICE);
}
Ci sono due scelte in questo file che meritano una spiegazione, perché non sono ovvie a prima vista.
La prima è perché usare due service separati per access e refresh token. La ragione è che i due token hanno requisiti di sicurezza molto diversi tra loro: il refresh token vive più a lungo ed è, di fatto, il pezzo più prezioso dei due — con un refresh token valido, un attaccante può generare un numero pressoché infinito di access token. Ha senso allora tenerlo in una voce Keychain separata, magari con un accessControl più restrittivo che richieda biometria, mentre l’access token — che scade in pochi minuti — può restare accessibile senza prompt a ogni singola lettura.
La seconda riguarda la scelta di setInternetCredentials invece di setGenericPassword. Funzionano entrambi, ma setInternetCredentials è pensato per credenziali associate a un dominio, e in un’app che parla con my-genius.it questa semantica è semplicemente più corretta. Su iOS, in più, permette di sfruttare in modo più pulito l’accessibilità THIS_DEVICE_ONLY. È più una scelta di coerenza che di correttezza tecnica, ma vale comunque la pena rispettarla.
2. authApi.js: le chiamate HTTP
Qui vivono le uniche due chiamate di rete di tutto il package: il login e il refresh. Tutto il resto — profilo utente, permessi, e via dicendo — sta in altri moduli, perché semplicemente non è responsabilità del package auth occuparsene.
const BASE_URL = 'https://my-genius.it/api';
export async function loginRequest(email, password) {
const res = await fetch(`${BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
throw new Error(res.status === 401 ? 'Credenziali non valide' : 'Login fallito');
}
const data = await res.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: data.user,
};
}
export async function refreshRequest(refreshToken) {
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
throw new Error('Refresh fallito');
}
const data = await res.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token, // alcuni backend ruotano anche il refresh
};
}
Non c’è nulla di particolarmente furbo qui: fetch nativo, gestione errori minimale, nessuna politica di retry. E non è una dimenticanza — la retry va gestita un livello più su, nel authService, perché è lì che si decide davvero se ha senso riprovare una chiamata fallita.
3. authService.js: l’orchestrazione
Arriviamo così al cuore del package: il file che implementa le tre funzioni pubbliche e che contiene la parte più delicata di tutta la logica, cioè il refresh del token.
import * as tokenStorage from './tokenStorage';
import { loginRequest, refreshRequest } from './authApi';
let cachedAccessToken = null; // cache in-memory per evitare letture Keychain ad ogni chiamata
// Inizializza la cache all'avvio dell'app
export async function initAuth() {
cachedAccessToken = await tokenStorage.getAccessToken();
}
export async function performLogin(email, password) {
const { accessToken, refreshToken, user } = await loginRequest(email, password);
await tokenStorage.setAccessToken(accessToken);
await tokenStorage.setRefreshToken(refreshToken);
cachedAccessToken = accessToken;
return { user };
}
export async function performLogout() {
await tokenStorage.clearTokens();
cachedAccessToken = null;
}
export async function getAuthToken() {
// 1. Se abbiamo un token in cache, restituiamolo subito
if (cachedAccessToken) {
return cachedAccessToken;
}
// 2. Altrimenti leggiamo dal Keychain
const token = await tokenStorage.getAccessToken();
if (token) {
cachedAccessToken = token;
return token;
}
// 3. Niente access token: proviamo il refresh
const refreshed = await tryRefresh();
return refreshed;
}
async function tryRefresh() {
const refreshToken = await tokenStorage.getRefreshToken();
if (!refreshToken) {
return null; // nessuna sessione persistente
}
try {
const { accessToken, refreshToken: newRefresh } = await refreshRequest(refreshToken);
await tokenStorage.setAccessToken(accessToken);
if (newRefresh) {
await tokenStorage.setRefreshToken(newRefresh);
}
cachedAccessToken = accessToken;
return accessToken;
} catch (e) {
// Refresh fallito: sessione morta, pulizia totale
await performLogout();
return null;
}
}
Ci sono tre dettagli, in questo file, che fanno tutta la differenza tra un’implementazione “da tutorial” e una che regge davvero in produzione.
Il primo è la cache in-memory. Senza di essa, ogni singola chiamata API dell’app finirebbe per passare da getAuthToken a Keychain.getInternetCredentials, che è un’operazione asincrona con un bridge nativo — non gratuita. Su un’app che fa decine di richieste, quel costo si accumula in fretta. La cache risolve il problema dividendo le responsabilità: il Keychain resta la fonte di verità per la persistenza, mentre la memoria diventa la fonte di verità per la sessione corrente, molto più veloce da consultare.
Il secondo è il refresh “lazy”. Non c’è nessun timer che rinnova il token ogni tot minuti: il refresh avviene solo quando getAuthToken non trova un access token valido, né in cache né in Keychain. È un pattern più semplice da ragionare, più robusto — niente race condition tra timer e richieste in corso — e si sposa naturalmente con il fatto che l’access token, di solito, scade dopo 15-30 minuti, un intervallo molto più ampio della frequenza media di chiamate in una sessione attiva.
Il terzo è la pulizia totale quando il refresh fallisce. Se il refresh non va a buon fine, significa che il refresh token è scaduto o è stato revocato, e tenerlo comunque nel Keychain non porta alcun beneficio — anzi, rischia solo di creare confusione più avanti. Meglio cancellare tutto in un colpo solo e lasciare che sia l’app, più in alto, a rimandare l’utente al login.
E se il token scade mentre una chiamata è in corso?
Questo è il caso limite che i tutorial saltano quasi sempre. L’access token è valido nel momento in cui lo leggi, ma scade prima che la tua fetch raggiunga il server, che a quel punto risponde con un 401. La soluzione più pulita è gestire questo caso nel client HTTP globale, non dentro il package auth:
// src/packages/api-client/httpClient.js (semplificato)
import { getAuthToken, performLogout } from '@mygenius/auth';
export async function authedFetch(url, options = {}) {
const token = await getAuthToken();
const res = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
// Forza un refresh e riprova una volta sola
const newToken = await getAuthToken({ forceRefresh: true });
if (newToken && newToken !== token) {
return fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${newToken}` },
});
}
// Refresh non possibile: sessione scaduta
await performLogout();
throw new Error('Sessione scaduta');
}
return res;
}
Il package auth espone getAuthToken con un parametro opzionale, forceRefresh, che invalida la cache e forza un giro di tryRefresh. In questo modo l’API resta semplice per chi la usa nel modo consueto, ma abbastanza flessibile da coprire anche il caso limite gestito dal client HTTP.
4. AuthContext.js: il provider React
Fin qui abbiamo parlato solo di funzioni imperative. In un’app React Native, però, la maggior parte della UI ha bisogno di reagire ai cambiamenti dello stato di autenticazione: mostrare la schermata di login quando l’utente si slogga, nascondere il loader quando il login va a buon fine, e così via. Serve quindi uno stato reattivo, e il modo più diretto per ottenerlo in React è un Context.
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import * as authService from './authService';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'authenticated' | 'unauthenticated'
const [user, setUser] = useState(null);
const login = useCallback(async (email, password) => {
setStatus('loading');
try {
const { user } = await authService.performLogin(email, password);
setUser(user);
setStatus('authenticated');
return true;
} catch (e) {
setStatus('unauthenticated');
throw e;
}
}, []);
const logout = useCallback(async () => {
await authService.performLogout();
setUser(null);
setStatus('unauthenticated');
}, []);
// Bootstrap: all'avvio, prova a recuperare una sessione persistente
useEffect(() => {
(async () => {
setStatus('loading');
await authService.initAuth();
const token = await authService.getAuthToken();
if (token) {
// In un'app reale qui si recupererebbe anche il profilo utente
setStatus('authenticated');
} else {
setStatus('unauthenticated');
}
})();
}, []);
const value = { status, user, login, logout };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth deve essere usato dentro <AuthProvider>');
return ctx;
}
Il provider fa, in sostanza, una cosa sola: tiene lo stato — status e user — e lo espone a chi lo consuma. Tutta la logica di Keychain, fetch e refresh resta chiusa dentro il authService, e questa separazione è più importante di quanto sembri: se domani decidessi di sostituire Context con Zustand, Redux o uno store custom, sarebbe il provider a cambiare, non il authService.
Vale la pena notare che lo stato status ha quattro valori, non due come si potrebbe immaginare di primo acchito. Gli stati idle iniziale e loading servono a evitare il classico bug dello “sfarfallio”, in cui l’app mostra per un istante la schermata di login e subito dopo scatta su quella autenticata, semplicemente perché non ha ancora finito di leggere il Keychain. Con status === 'idle' la UI può mostrare uno splash screen o uno schermo vuoto, e decidere cosa mostrare solo a bootstrap concluso.
5. Integrazione con Redux: cosa tenere e cosa no
Una domanda che sorge naturale a questo punto è: se ho già un Context per l’autenticazione, mi serve anche Redux? Nel progetto myGenius la risposta è stata sì, ma per una parte diversa del problema.
Il Context si occupa dello stato operativo dell’autenticazione — sono loggato? chi è l’utente? sto facendo login proprio ora? — ed è pensato per essere consumato dai componenti React che devono reagire visivamente a questi cambiamenti.
Redux, invece, si occupa dello stato descrittivo della sessione, quello che anche parti dell’app non strettamente React devono poter leggere: il metodo di login scelto (email, telefono, biometria), l’identificatore normalizzato dell’utente, il verificationId di Firebase usato nel flusso via SMS. Sono dati che sopravvivono al di fuori del ciclo di vita dei singoli componenti e che vengono letti da saghe, middleware e servizi.
Una slice minimale per questo scopo assomiglia a questa:
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
method: null, // 'email' | 'phone' | 'biometric'
identifier: '', // email o telefono normalizzato
session: { firebaseUid: null },
verificationId: null, // per il flusso OTP
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setMethod(state, action) { state.method = action.payload; },
setIdentifier(state, action) { state.identifier = action.payload; },
setSession(state, action) { state.session = { ...state.session, ...action.payload }; },
setVerificationId(state, action) { state.verificationId = action.payload; },
resetAuth() { return initialState; },
},
});
export const { setMethod, setIdentifier, setSession, setVerificationId, resetAuth } = authSlice.actions;
export default authSlice.reducer;
La regola pratica che ci siamo dati, e su cui vale la pena essere netti, è che il token JWT non entra mai in Redux. Il token vive nel Keychain e nella cache del authService; il suo valore non serve ai componenti React, serve solo al client HTTP. Metterlo anche in Redux significherebbe duplicare la fonte di verità, e ogni duplicazione di questo tipo apre la porta a inconsistenze — Redux che dice una cosa e Keychain che ne dice un’altra. Se un domani serve il token dentro un componente, lo si chiede con getAuthToken(), non lo si legge da uno store.
L’azione resetAuth, in questo schema, è quella che il provider invoca dentro logout, subito dopo aver chiamato authService.performLogout(). L’ordine, qui, conta parecchio: prima si pulisce il persistente — il Keychain — e solo dopo il volatile, cioè Redux e Context, in modo che non esista mai una finestra temporale in cui l’app crede ancora di essere autenticata mentre il token è già sparito.
6. Il refresh del token: tre pattern a confronto
Il refresh è probabilmente la parte in cui le implementazioni divergono di più tra un progetto e l’altro. Vale la pena guardare i tre pattern principali che si incontrano più spesso, e capire perché in questo caso abbiamo scelto quello lazy.
Il primo è il timer proattivo: un setInterval rinnova il token ogni tot minuti, un intervallo leggermente inferiore alla scadenza reale. Il vantaggio è che il token in cache è quasi sempre valido; gli svantaggi sono rinnovi inutili quando l’app è in background, possibili race condition se l’utente fa una chiamata proprio nell’istante in cui scatta il timer, e la complessità aggiuntiva di dover gestire il lifecycle del timer stesso — sospenderlo quando l’app va in background, riavviarlo al resume.
Il secondo è il refresh on 401: nessuna logica preventiva, ogni chiamata usa semplicemente il token in cache, e se il server risponde con un 401 si fa un refresh e si riprova. Il vantaggio è la semplicità assoluta; lo svantaggio è che ogni token scaduto costa un round-trip extra, e che il 401 può significare sia “token scaduto” sia “token revocato” — distinguere i due casi in modo affidabile diventa fragile in fretta.
Il terzo è il refresh lazy, quello che abbiamo scelto: il token viene rinnovato solo quando getAuthToken non trova un access token valido né in cache né in Keychain. Il 401 resta comunque gestito come fallback nel client HTTP, ma diventa un evento raro, non la via principale. I vantaggi sono l’assenza di timer, di rinnovi inutili e di race condition; lo svantaggio è che serve una cache in-memory ben gestita, perché senza di essa il pattern collassa semplicemente nel secondo.
Il pattern lazy funziona particolarmente bene quando l’access token ha una scadenza breve — 15-30 minuti — e l’app fa chiamate con una frequenza alta rispetto a quella scadenza. Se l’access token durasse 24 ore, il pattern proattivo perderebbe di senso e quello lazy diventerebbe ancora più conveniente. Se invece l’access token durasse solo 5 minuti e l’app facesse chiamate sporadiche, il pattern proattivo potrebbe risultare più efficiente — ma è un caso piuttosto raro nella pratica.
7. index.js: il contratto pubblico
L’ultimo file è anche il più corto, ma è quello che conta di più: è lui a dire al resto del mondo cosa può effettivamente usare.
export { performLogin, performLogout, getAuthToken, initAuth } from './authService';
export { AuthProvider, useAuth } from './AuthContext';
Tutto il resto — tokenStorage, authApi, i dettagli interni del authService — resta fuori da questo export. Ed è proprio questo il contratto del package: se un domani decidessi di sostituire react-native-keychain con un’altra libreria, o di spostare il refresh su un worker separato, l’interfaccia pubblica non cambierebbe di una virgola, e nessun consumer del package se ne accorgerebbe.
È una disciplina che, ammettiamolo, può sembrare noiosa. Ma è esattamente quella che distingue un semplice “file di utility” da un vero modulo — ed è la disciplina che permette, in un refactor progressivo come quello che stiamo portando avanti su myGenius, di muoversi un pezzo alla volta senza che il resto dell’app se ne accorga nemmeno.
Errori comuni da evitare
Chiudiamo con i tre errori che abbiamo visto ripetersi più spesso in giro — e che, va detto onestamente, abbiamo commesso anche noi prima di arrivare a questa struttura.
Il primo è salvare il token in Redux “per comodità”. Sembra una buona idea finché non capita di dover fare logout da un punto dell’app che non ha accesso allo store, o finché un hot reload non resetta lo store lasciando però il token ancora nel Keychain. Due fonti di verità significano, prima o poi, due modi per desincronizzarsi.
Il secondo è fare il refresh dentro il componente. Capita di trovare useEffect che chiamano refreshToken al mount di una schermata: funziona finché hai una sola schermata, ma appena ne hai dieci ti ritrovi a lanciare dieci refresh contemporanei al login. Il refresh è responsabilità del authService, non della UI, e va tenuto lì.
Il terzo è dimenticare initAuth all’avvio. Se la cache in-memory non viene inizializzata, la prima chiamata a getAuthToken va comunque dritta al Keychain — il che va bene, ma è più lento del necessario. Peggio ancora, se il bootstrap del provider non attende initAuth, l’app può arrivare a decidere “utente non autenticato” prima ancora di aver letto il token persistente. Il flusso corretto resta sempre: splash, poi initAuth più getAuthToken, e solo alla fine la decisione.
Domande frequenti
Il refresh token va salvato con la biometria?
Non necessariamente. Se lo si fa, ogni rinnovo silenzioso finisce per richiedere un prompt biometrico, il che vanifica proprio lo scopo di un refresh pensato per essere invisibile. Una configurazione più sensata è: access token senza biometria, perché scade in fretta e va letto spesso, e refresh token con ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY — cifrato a riposo, escluso dai backup, ma leggibile senza prompt. La biometria conviene richiederla piuttosto sull’azione di login, non sulla semplice lettura del token.
Come si testa il package auth?
I pezzi testabili in isolamento sono tokenStorage, mockando react-native-keychain, e authService, mockando a sua volta tokenStorage e authApi. Il provider React si testa invece con @testing-library/react-native, renderizzandolo insieme a un consumer e verificando i passaggi di stato. Vale la pena evitare di testare l’integrazione reale con il Keychain in CI: è lenta, fragile, dipendente dal simulatore. Meglio verificare che il wrapper chiami le API giuste, dando per scontato che il Keychain, quello, funzioni.
Posso usare questo pattern con un backend che non emette refresh token?
Sì, ma si perde la capacità di rinnovare la sessione senza richiedere di nuovo le credenziali. In quel caso tryRefresh non fa semplicemente nulla, e getAuthToken restituisce null non appena l’access token scade, costringendo l’utente a rifare il login. È una scelta valida per app con requisiti di sicurezza molto alti — sessioni brevi, niente persistenza — ma per un’app aziendale di uso quotidiano il refresh token resta quasi un obbligo di UX.
Redux Toolkit o Context per l’autenticazione?
Non è davvero un aut-aut: come visto sopra, possono convivere senza problemi. Il Context copre lo stato operativo che la UI consuma direttamente, Redux copre lo stato descrittivo che middleware e servizi non-React devono poter leggere. Se l’app non usa già Redux, il Context da solo può bastare tranquillamente. Se invece l’app usa già Redux per altro, ha senso tenere anche la slice auth per coerenza con il resto — a patto di non metterci mai dentro il token.
Conclusione
Implementare un login JWT in React Native non è difficile nel senso di “codice complicato” — è difficile nel senso di “decisioni che si pagano nel tempo”. Dove salvi il token, come lo rinnovi, cosa esponi agli altri moduli dell’app: sono tutte scelte che sembrano dettagli irrilevanti finché, sei mesi dopo, non ti trovi a dover cambiare backend o aggiungere la biometria, e scopri che la logica è sparsa in quindici file diversi.
Il package auth con tre funzioni pubbliche, un provider e una separazione netta tra persistenza, orchestrazione e stato React è la struttura più semplice che abbiamo trovato per tenere insieme sicurezza, testabilità e flessibilità. Non è l’unica possibile, di sicuro, ma è quella che ha retto meglio al nostro refactor.
Nel prossimo articolo usciamo dal codice e torniamo al prodotto: perché il login con email e password, per quanto tecnicamente ineccepibile, è spesso un muro per gli utenti meno a loro agio con la tecnologia — e perché l’OTP via email o SMS è un compromesso che vale davvero la pena considerare.
Questo articolo fa parte della serie di sviluppo di myGenius Mobile. Il codice mostrato è semplificato rispetto a quello in produzione per chiarezza espositiva, ma l’architettura e i pattern descritti riflettono le scelte reali del progetto.




