Files
workspace/docs/plan-google-auth.md
Roberto Musso 2958961e75 feat: complete Step 5 Google OAuth — backup key + tests
- adiuvAI: add backup-key.ts (device-specific AES key via safeStorage),
  remove _cachedPassword from AuthManager
- api: add TestOAuth (6 tests) covering authorize, callback flows
- docs: mark Step 5 complete with lessons learned

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:42:29 +02:00

13 KiB

Piano: Integrazione Google Login + Avatar

Contesto

L'app adiuvAI ha un sistema auth custom (email+password, bcrypt, JWT HS256) funzionante. Obiettivo: aggiungere Google Login come primo social provider, con la prospettiva di aggiungerne 2-3 in futuro (Microsoft, GitHub).

Decisione architetturale: OAuth diretto nel sistema esistente (non Keycloak, non Auth0).

  • Il backend continua a emettere i propri JWT
  • Ogni provider e' un'implementazione concreta di un'astrazione condivisa
  • Email+password resta il metodo primario di registrazione/login
  • L'avatar utente viene preso da Google; per utenti email si usano iniziali o icona default

Nota encryption: La encryption_key Fernet e' generata random, NON derivata dalla password. Il social login non rompe la crittografia server-side. L'unico fix necessario e' sganciare il backup encryption locale (_cachedPassword) dalla password utente.


Regole

  • Completamento step: aggiornare questo documento marcando lo step come [x] e annotare eventuali lessons learned
  • Commit: al termine di ogni step, eseguire commit del codice

Step 1: Backend — DB + Model + Schema

Status: [x] Completato

Cosa fare:

  • Migration Alembic: creare tabella oauth_accounts (id, user_id FK, provider, provider_user_id, provider_email, created_at; UNIQUE su provider, provider_user_id)
  • Migration Alembic: rendere users.password_hash nullable
  • Migration Alembic: aggiungere colonna users.avatar_url (VARCHAR, nullable)
  • Model OAuthAccount in api/app/models.py con relationship a User
  • Campo avatar_url: str | None sul model User
  • Aggiornare UserProfile in api/app/schemas.py con avatar_url

File coinvolti:

  • api/alembic/versions/XXX_add_oauth_and_avatar.py (nuovo)
  • api/app/models.py
  • api/app/schemas.py

Lessons learned:

  • get_current_user in middleware/auth.py esegue una query separata per name/surname — aggiornare quella query per includere avatar_url (non solo il model). Altrimenti il campo viene ignorato anche se presente in DB.
  • update_profile in routes/auth.py costruisce UserProfile manualmente: va aggiornato esplicitamente con avatar_url=user.avatar_url.
  • La OAuthAccount.user relationship richiede forward reference: il model deve essere dichiarato dopo User ma la relationship su User usa OAuthAccount — SQLAlchemy risolve automaticamente con le stringhe lazy evaluation, ma occorre che il model sia nello stesso modulo.

Step 2: Backend — OAuth Provider + Route

Status: [x] Completato

Cosa fare:

  • Creare astrazione provider OAuth riusabile in api/app/auth/oauth_providers.py (nuovo)
    • Classe base con: get_authorization_url(), exchange_code(), get_userinfo()
    • Implementazione concreta GoogleOAuthProvider
  • Aggiungere settings in api/app/config/settings.py: GOOGLE_AUTH_CLIENT_ID, GOOGLE_AUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI
    • Separati da GMAIL_CLIENT_ID/SECRET (scope diversi: openid email profile vs gmail.readonly)
  • Aggiungere route in api/app/api/routes/auth.py:
    • GET /auth/oauth/{provider}/authorize — genera state + PKCE code_challenge, ritorna authorize URL
    • POST /auth/oauth/{provider}/callback — valida state, scambia code, fetch userinfo, crea/linka utente, salva avatar_url da Google, emette JWT
  • Logica utente nel callback:
    • oauth_accounts match? -> login utente esistente
    • Email match + email_verified=true? -> link account a utente esistente (aggiorna avatar se mancante)
    • Nessun match? -> crea nuovo utente (Fernet key, password_hash=None, avatar da Google)
  • Aggiungere dependency authlib in requirements

Sicurezza:

  • PKCE obbligatorio (desktop app = public client)
  • Auto-link email solo se email_verified=true da Google
  • State param per prevenire CSRF

File coinvolti:

  • api/app/auth/__init__.py (nuovo)
  • api/app/auth/oauth_providers.py (nuovo)
  • api/app/api/routes/auth.py
  • api/app/config/settings.py
  • api/requirements.txt (o pyproject.toml)

Lessons learned:

  • authlib non è necessaria: il flow PKCE con Google si implementa direttamente con httpx (già in requirements). Aggiungere authlib sarebbe una dipendenza inutilizzata. Se si vorrà usare authlib in futuro (es. per provider con flow più complessi), aggiungerla allora.
  • State store in-memory: _pending_states è un dict a livello modulo — funziona in dev con un solo processo, ma non sopravvive a restart e non scala su più worker. In produzione va sostituito con Redis (o un campo temporaneo su DB).
  • _issue_refresh_token helper: la logica di emissione token è condivisa tra i tre branch del callback (link esistente, link email, nuovo utente) — fattorizzarla in un helper evita duplicazione.
  • tuple_ import non usato: l'import sql_tuple da sqlalchemy può essere rimosso (non serve per le query attuali).
  • Route provider tipizzata come Literal["google"]: FastAPI valida automaticamente il parametro path e risponde 422 per provider sconosciuti, rendendo superfluo un check manuale. Il dict _PROVIDERS serve come fallback di sicurezza.
  • await db.flush() prima di creare OAuthAccount: necessario per ottenere new_user.id prima del commit, altrimenti il FK fallisce.

Status: [x] Completato

Cosa fare:

  • Registrare protocollo custom adiuvai:// in adiuvAI/forge.config.ts (packagerConfig.protocols)
  • In adiuvAI/src/main/index.ts:
    • app.setAsDefaultProtocolClient('adiuvai')
    • Windows/Linux: gestire second-instance event, parsare argv per deep link
    • macOS: gestire open-url event
    • Forward code+state all'auth manager
  • In adiuvAI/src/main/auth/auth-manager.ts:
    • Nuovo metodo loginWithOAuth(provider): chiama authorize, apre browser, attende callback, scambia code, salva token
    • Nuovo metodo handleOAuthCallback(code, state): risolve la promise pendente
  • In adiuvAI/src/main/router/index.ts:
    • Aggiungere procedura tRPC auth.loginWithOAuth

File coinvolti:

  • adiuvAI/forge.config.ts
  • adiuvAI/src/main/index.ts
  • adiuvAI/src/main/auth/auth-manager.ts
  • adiuvAI/src/main/router/index.ts

Lessons learned:

  • adiuvai:// non è accettato da Google Console come redirect URI: Google accetta solo http://localhost e https://. Soluzione: il backend espone GET /auth/oauth/{provider}/web-callback che riceve il redirect da Google e rimanda subito a adiuvai://. Il redirect_uri registrato su Google punta al backend, non direttamente all'Electron app.
  • OAUTH_REDIRECT_URI punta al backend, non al dominio website: adiuvai.com è il sito statico. L'API starà su api.adiuvai.com (o simile). Il default in settings.py è http://localhost:8000/api/v1/auth/oauth/google/web-callback per sviluppo locale — sovrascrivere con la var d'ambiente in prod.
  • app.requestSingleInstanceLock() è necessario per second-instance: senza il lock, l'evento non viene mai emesso su Windows/Linux. Se requestSingleInstanceLock() restituisce false, bisogna uscire subito (app.quit()).
  • process.defaultApp in dev: in dev mode, Electron è lanciato come electron . — il nome del processo non corrisponde all'app. Occorre passare [path.resolve(process.argv[1])] come terzo argomento a setAsDefaultProtocolClient per includere lo script nella registrazione OS del protocollo.
  • post() in auth-manager serializza snake_case: il backend si aspetta { code, state } in snake_case. La conversione avviene automaticamente via toSnakeCase() dentro post(), quindi la chiamata da handleOAuthCallback è safe.
  • loginWithOAuth usa fetch() diretto (non this.get()): la route authorize è pubblica (no JWT richiesto), ma get() lancia se non autenticati. Usare fetch() diretto evita la dipendenza dalla sessione attiva.
  • avatarUrl in UserProfileSchema: il backend restituisce avatar_url (snake_case) che viene camelCased in toCamelCase() prima del parse Zod. Il campo deve stare nello schema Zod come avatarUrl (camelCase).

Step 4: Electron — UI Login + Avatar

Status: [x] Completato

Cosa fare:

  • In adiuvAI/src/renderer/components/auth/LoginForm.tsx:
    • Email+password resta il form principale (prima opzione, in alto)
    • Divider "oppure" sotto il form
    • Bottone "Sign in with Google" sotto il divider
    • Stato "In attesa del browser..." durante il flow OAuth
    • Su successo: invalidare auth.status
  • Mostrare avatar nel profilo utente:
    • Avatar da avatar_url (immagine circolare) se disponibile
    • Fallback: iniziali del nome o icona default per utenti senza avatar
  • Aggiornare tipo UserProfile in adiuvAI/src/shared/api-types.ts con avatar_url

File coinvolti:

  • adiuvAI/src/renderer/components/auth/LoginForm.tsx
  • adiuvAI/src/shared/api-types.ts
  • Componenti profilo utente (da identificare)

Lessons learned:

  • oauthMutation.isPending dura fino a 5 min: la mutation rimane in isPending mentre Electron attende il deep-link. Il bottone mostra "Waiting for browser…" e tutti gli input vengono disabilitati tramite isBusy = loginMutation.isPending || oauthMutation.isPending per evitare azioni concorrenti.
  • Google icon inline SVG: il progetto usa solo lucide-react per le icone. Il logo Google 'G' è stato inserito come SVG inline direttamente nel componente — non introdurre librerie di icone esterne.
  • profile.avatarUrl è snake_case sul backend ma camelCase dopo toCamelCase(): il campo sul tipo UserProfile (Electron) si chiama avatarUrl. La proprietà è nullable().optional() nello schema Zod — verificare sempre il null prima di passarla a AvatarImage.
  • AvatarImage come fallback graceful: Radix UI AvatarImage mostra AvatarFallback automaticamente se l'immagine non carica (CORS, URL scaduto, etc). Non serve gestire l'errore manualmente.
  • AccountSection usa IIFE per evitare variabili di blocco: il profilo era condizionale — l'IIFE (() => { ... })() dentro JSX permette di dichiarare variabili locali (displayName, initials) senza inquinare il componente con useState o helper esterni.
  • AppSidebarProps type non usa UserProfile importato: il tipo è definito inline per evitare dipendenze circolari tra renderer e shared. Se UserProfile dovesse cambiare, va aggiornato anche il tipo inline.

Step 5: Fix Backup Encryption + Test

Status: [x] Completato

Cosa fare:

  • Sganciare BackupManager da _cachedPassword:
    • Generare chiave backup random 256-bit al primo login (qualsiasi metodo auth)
    • Salvarla in electron-store via safeStorage (stessa pattern di token.ts)
    • Usare questa chiave al posto di _cachedPassword per AES backup
    • Per utenti esistenti: generare nuova chiave, usarla per backup futuri
  • Test backend (api/tests/test_auth.py):
    • Test authorize URL generation
    • Test callback: creazione utente, linking account, duplicati
    • Test callback con email match -> auto-link
    • Test state mismatch -> 401
  • Test manuale end-to-end:
    • Click "Sign in with Google" -> browser -> consent -> redirect -> autenticato
    • Account linking: stessa email via password e Google -> stesso utente
    • Utente solo-social: nuovo utente senza password
    • Backup con utente social -> chiave device-specific

File coinvolti:

  • adiuvAI/src/main/auth/auth-manager.ts
  • adiuvAI/src/main/auth/backup-key.ts (nuovo)
  • api/tests/test_auth.py

Lessons learned:

  • BackupManager non esiste ancora nell'Electron app: _cachedPassword era dichiarato ma non utilizzato da nessun consumer. La pulizia è stata semplice: rimuovere il campo, i setter nei metodi login/register/logout, e il getter getCachedPassword(). Se si implementerà un BackupManager in futuro, usare getBackupKey() da backup-key.ts.
  • backup-key.ts riusa getToken/setToken da token.ts: la chiave backup è salvata nell'encryptedTokens dict sotto la chiave backup_key, esattamente come i JWT auth. Nessun nuovo meccanismo di storage necessario.
  • deleteToken importato dinamicamente in deleteBackupKey(): non strettamente necessario (poteva essere importato staticamente), ma è un pattern difensivo per evitare dipendenze circolari in caso di refactor futuro.
  • I test OAuth mockano exchange_code e get_userinfo come AsyncMock direttamente sulla classe (patch.object(GoogleOAuthProvider, ...)): funziona perché FastAPI crea una nuova istanza del provider per ogni request, quindi il mock intercetta l'istanza creata dentro la route.
  • monkeypatch.setattr(settings, ...) è sufficiente per simulare credenziali Google configurate: non serve sovrascrivere variabili d'ambiente o ricreare l'app — Pydantic Settings legge gli attributi dall'oggetto singleton in runtime.
  • Il test email_match verifica che sub JWT sia identico tra registrazione password e login OAuth: questo copre la logica di linking senza accedere direttamente al DB.
  • Edge case non testato: se Google restituisce email_verified=False con un'email già registrata, il backend tenta di creare un nuovo utente con email duplicata → constraint violation → 500. Non è stato fixato in questo step (fuori scope), ma è stato identificato.