Il problema: due Firebase, zero condivisione

PanelControl è una PWA interna che gestisce operatori, calendario e chat di un team commerciale. Dal pannello di onboarding, i colleghi devono aprire un sito separato — chiamiamolo Ordini — che gira su un Firebase project completamente diverso.

La richiesta era semplice: quando un operatore clicca "Onboarding", il sito di destinazione deve sapere chi è senza che l'utente faccia un secondo login. Il problema è che le due app non condividono né il database né l'autenticazione Firebase.

Le opzioni erano tre:

Opzione / Option Come funzionaLimite How it worksDrawback
A — Firebase condiviso Scrive un token su un nodo RTDB comune I due progetti hanno DB separati Writes a token to a shared RTDB node The two projects have separate databases
B — URL firmato HMAC Genera un link con token nel parametro ?auth= Segreto nel client (tool interno, accettabile) Generates a link with token in ?auth= param Secret lives in the client (internal tool, acceptable)
C — postMessage Comunicazione iframe/popup Richiede stesso dominio o apertura controllata iframe/popup communication Requires same domain or controlled popup opening

Con DB diversi e navigazione tramite link diretto, l'opzione B è quella giusta. Niente infrastruttura aggiuntiva, funziona subito.

Come funziona HMAC-SHA256 nel browser

HMAC (Hash-based Message Authentication Code) produce una firma crittografica di un messaggio usando una chiave segreta condivisa. Senza quella chiave, la firma non può essere riprodotta — e quindi il ricevente sa che il mittente la conosce.

La Web Crypto API è disponibile in tutti i browser moderni, non richiede librerie e lavora nativamente con ArrayBuffer. Il flusso è questo:

flusso / flow
// LATO MITTENTE (PanelControl)
payload  = { user: "[OPERATORE]", dept: "Sales", ts: Date.now() }
token    = base64url( HMAC-SHA256(JSON.stringify(payload), SECRET) )
url      = "https://[app-ordini].netlify.app/?auth=" + token + "." + base64url(payload)

// LATO RICEVENTE (Ordini)
[sig, data] = url.searchParam("auth").split(".")
expectedSig = HMAC-SHA256(base64url_decode(data), SECRET)
se sig !== expectedSig → token non valido, accesso negato
se Date.now() - payload.ts > 5 min → token scaduto
altrimenti → window.panelUser = payload.user ✓

L'implementazione: lato mittente

In PanelControl, il link "Onboarding" è diventato un pulsante che chiama una funzione asincrona. La funzione legge l'utente corrente dallo state dell'app, costruisce il payload, lo firma e apre il link.

onboarding.js — PanelControl
// Segreto condiviso — identico nel sito destinatario
const SHARED_SECRET = 'TuoSegretoCondiviso123!';
const DEST_URL      = 'https://[app-ordini].netlify.app/';

// Helper: ArrayBuffer → base64url
function buf2b64(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

async function openOnboardingWithToken() {
  const user = state.currentUser?.name || 'Sconosciuto';

  const payload = {
    user,
    dept: state.currentUser?.dept || '',
    ts: Date.now()
  };

  const payloadB64 = buf2b64(
    new TextEncoder().encode(JSON.stringify(payload))
  );

  // Importa la chiave HMAC
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(SHARED_SECRET),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );

  // Firma il payload
  const sigBuffer = await crypto.subtle.sign(
    'HMAC',
    key,
    new TextEncoder().encode(payloadB64)
  );
  const sig = buf2b64(sigBuffer);

  const token = `${sig}.${payloadB64}`;
  window.open(`${DEST_URL}?auth=${token}`, '_blank');
}
💡

Nota sul formato del token: uso sig.payload separati da un punto — come i JWT, ma senza header. Il ricevente splitta su ., ricalcola la firma sul payload e confronta.

Lato ricevente: verifica e pulizia URL

Nel sito Ordini, uno script nell'<head> si esegue prima di qualsiasi altro codice. Fa tre cose: verifica la firma, controlla la scadenza (5 minuti), rimuove ?auth= dall'URL per non lasciare token visibili nella barra del browser.

[brand]-onboarding.html — <head>
const SHARED_SECRET = 'TuoSegretoCondiviso123!';  // deve essere identico
const TOKEN_TTL_MS  = 5 * 60 * 1000;       // 5 minuti

(async () => {
  const params = new URLSearchParams(location.search);
  const auth   = params.get('auth');
  if (!auth) return;

  // Pulisci l'URL subito
  history.replaceState({}, '', location.pathname);

  const [sig, payloadB64] = auth.split('.');
  if (!sig || !payloadB64) return;

  // Importa la chiave in modalità verify
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(SHARED_SECRET),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify']
  );

  // Decodifica la firma da base64url a ArrayBuffer
  const sigBytes = Uint8Array.from(
    atob(sig.replace(/-/g, '+').replace(/_/g, '/')),
    c => c.charCodeAt(0)
  );

  const valid = await crypto.subtle.verify(
    'HMAC', key, sigBytes,
    new TextEncoder().encode(payloadB64)
  );

  if (!valid) { console.warn('[auth] firma non valida'); return; }

  // Decodifica il payload
  const payload = JSON.parse(
    decodeURIComponent(escape(atob(
      payloadB64.replace(/-/g, '+').replace(/_/g, '/')
    )))
  );

  // Controlla scadenza
  if (Date.now() - payload.ts > TOKEN_TTL_MS) {
    console.warn('[auth] token scaduto'); return;
  }

  // Tutto ok — espone l'utente al resto del sito
  window.panelUser = payload.user;
  sessionStorage.setItem('panelUser', payload.user);
  document.dispatchEvent(new CustomEvent('panelUserReady', { detail: payload }));
  console.log('[auth] ✓', payload.user);
})();

Mostrare l'utente nell'header

Una volta che window.panelUser è disponibile, il sito Ordini lo mostra con un badge ambra nell'header — così l'operatore ha la conferma visiva che l'identità è stata trasmessa correttamente.

[brand]-onboarding.html — badge utente
// Nel DOMContentLoaded, dopo lo script di verifica
document.addEventListener('panelUserReady', (e) => {
  const badge = document.getElementById('user-badge');
  if (badge) badge.textContent = '👤 ' + e.detail.user;
});

// HTML nell'header
<span id="user-badge" style="
  background: rgba(251,191,36,.15);
  border: 1px solid rgba(251,191,36,.3);
  color: #fbbf24; border-radius: 20px;
  padding: .2rem .75rem; font-size: .8rem;
"></span>

Considerazioni sulla sicurezza

Questa soluzione ha un limite esplicito: il segreto è nel codice client di entrambi i siti. Chiunque apra i DevTools lo vede. Per un tool interno aziendale questo è accettabile — non è un sito pubblico e il token contiene solo il nome dell'operatore, nessun dato sensibile.

⚠️

Se cambi il segreto condiviso, tutti i token generati in precedenza smettono di funzionare immediatamente — ogni link aperto oltre 5 minuti prima è già scaduto comunque, ma è bene saperlo.

Per un'app pubblica o con dati sensibili si userebbe un token firmato lato server (es. Firebase Custom Token o un endpoint Node.js), così il segreto non esce mai dal backend. Ma per questo contesto, la soluzione client-only funziona perfettamente.

Il risultato

L'operatore clicca "Onboarding [BRAND_POS]" in PanelControl. Il browser apre il sito Ordini con ?auth=TOKEN nell'URL. Lo script verifica la firma in meno di un millisecondo, pulisce l'URL, e il badge 👤 [OPERATORE] compare nell'header. L'utente è disponibile con sessionStorage.getItem('panelUser') in tutto il resto del codice del sito.

Nessun database intermedio, nessun backend aggiuntivo, nessuna dipendenza esterna. Solo crittografia nativa del browser e un segreto condiviso.