- 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>
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 suprovider, provider_user_id) - Migration Alembic: rendere
users.password_hashnullable - Migration Alembic: aggiungere colonna
users.avatar_url(VARCHAR, nullable) - Model
OAuthAccountinapi/app/models.pycon relationship aUser - Campo
avatar_url: str | Nonesul modelUser - Aggiornare
UserProfileinapi/app/schemas.pyconavatar_url
File coinvolti:
api/alembic/versions/XXX_add_oauth_and_avatar.py(nuovo)api/app/models.pyapi/app/schemas.py
Lessons learned:
get_current_userinmiddleware/auth.pyesegue una query separata pername/surname— aggiornare quella query per includereavatar_url(non solo il model). Altrimenti il campo viene ignorato anche se presente in DB.update_profileinroutes/auth.pycostruisceUserProfilemanualmente: va aggiornato esplicitamente conavatar_url=user.avatar_url.- La
OAuthAccount.userrelationship richiede forward reference: il model deve essere dichiarato dopoUserma la relationship suUserusaOAuthAccount— 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
- Classe base con:
- 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 profilevsgmail.readonly)
- Separati da
- Aggiungere route in
api/app/api/routes/auth.py:GET /auth/oauth/{provider}/authorize— genera state + PKCE code_challenge, ritorna authorize URLPOST /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_accountsmatch? -> 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
authlibin requirements
Sicurezza:
- PKCE obbligatorio (desktop app = public client)
- Auto-link email solo se
email_verified=trueda 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.pyapi/app/config/settings.pyapi/requirements.txt(o pyproject.toml)
Lessons learned:
authlibnon è necessaria: il flow PKCE con Google si implementa direttamente conhttpx(già in requirements). Aggiungereauthlibsarebbe 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_tokenhelper: 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'importsql_tupleda sqlalchemy può essere rimosso (non serve per le query attuali).- Route
providertipizzata comeLiteral["google"]: FastAPI valida automaticamente il parametro path e risponde 422 per provider sconosciuti, rendendo superfluo un check manuale. Il dict_PROVIDERSserve come fallback di sicurezza. await db.flush()prima di creareOAuthAccount: necessario per ottenerenew_user.idprima del commit, altrimenti il FK fallisce.
Step 3: Electron — Deep Link + Auth Manager
Status: [x] Completato
Cosa fare:
- Registrare protocollo custom
adiuvai://inadiuvAI/forge.config.ts(packagerConfig.protocols) - In
adiuvAI/src/main/index.ts:app.setAsDefaultProtocolClient('adiuvai')- Windows/Linux: gestire
second-instanceevent, parsare argv per deep link - macOS: gestire
open-urlevent - 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
- Nuovo metodo
- In
adiuvAI/src/main/router/index.ts:- Aggiungere procedura tRPC
auth.loginWithOAuth
- Aggiungere procedura tRPC
File coinvolti:
adiuvAI/forge.config.tsadiuvAI/src/main/index.tsadiuvAI/src/main/auth/auth-manager.tsadiuvAI/src/main/router/index.ts
Lessons learned:
adiuvai://non è accettato da Google Console come redirect URI: Google accetta solohttp://localhostehttps://. Soluzione: il backend esponeGET /auth/oauth/{provider}/web-callbackche riceve il redirect da Google e rimanda subito aadiuvai://. Il redirect_uri registrato su Google punta al backend, non direttamente all'Electron app.OAUTH_REDIRECT_URIpunta al backend, non al dominio website:adiuvai.comè il sito statico. L'API starà suapi.adiuvai.com(o simile). Il default insettings.pyèhttp://localhost:8000/api/v1/auth/oauth/google/web-callbackper sviluppo locale — sovrascrivere con la var d'ambiente in prod.app.requestSingleInstanceLock()è necessario persecond-instance: senza il lock, l'evento non viene mai emesso su Windows/Linux. SerequestSingleInstanceLock()restituiscefalse, bisogna uscire subito (app.quit()).process.defaultAppin dev: in dev mode, Electron è lanciato comeelectron .— il nome del processo non corrisponde all'app. Occorre passare[path.resolve(process.argv[1])]come terzo argomento asetAsDefaultProtocolClientper 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 viatoSnakeCase()dentropost(), quindi la chiamata dahandleOAuthCallbackè safe.loginWithOAuthusafetch()diretto (nonthis.get()): la route authorize è pubblica (no JWT richiesto), maget()lancia se non autenticati. Usarefetch()diretto evita la dipendenza dalla sessione attiva.avatarUrlinUserProfileSchema: il backend restituisceavatar_url(snake_case) che viene camelCased intoCamelCase()prima del parse Zod. Il campo deve stare nello schema Zod comeavatarUrl(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
- Avatar da
- Aggiornare tipo
UserProfileinadiuvAI/src/shared/api-types.tsconavatar_url
File coinvolti:
adiuvAI/src/renderer/components/auth/LoginForm.tsxadiuvAI/src/shared/api-types.ts- Componenti profilo utente (da identificare)
Lessons learned:
oauthMutation.isPendingdura fino a 5 min: la mutation rimane inisPendingmentre Electron attende il deep-link. Il bottone mostra "Waiting for browser…" e tutti gli input vengono disabilitati tramiteisBusy = loginMutation.isPending || oauthMutation.isPendingper evitare azioni concorrenti.- Google icon inline SVG: il progetto usa solo
lucide-reactper 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 dopotoCamelCase(): il campo sul tipoUserProfile(Electron) si chiamaavatarUrl. La proprietà ènullable().optional()nello schema Zod — verificare sempre il null prima di passarla aAvatarImage.AvatarImagecome fallback graceful: Radix UIAvatarImagemostraAvatarFallbackautomaticamente se l'immagine non carica (CORS, URL scaduto, etc). Non serve gestire l'errore manualmente.AccountSectionusa 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.AppSidebarPropstype non usaUserProfileimportato: il tipo è definito inline per evitare dipendenze circolari tra renderer e shared. SeUserProfiledovesse 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
_cachedPasswordper 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.tsadiuvAI/src/main/auth/backup-key.ts(nuovo)api/tests/test_auth.py
Lessons learned:
BackupManagernon esiste ancora nell'Electron app:_cachedPasswordera dichiarato ma non utilizzato da nessun consumer. La pulizia è stata semplice: rimuovere il campo, i setter nei metodilogin/register/logout, e il gettergetCachedPassword(). Se si implementerà un BackupManager in futuro, usaregetBackupKey()dabackup-key.ts.backup-key.tsriusagetToken/setTokendatoken.ts: la chiave backup è salvata nell'encryptedTokensdict sotto la chiavebackup_key, esattamente come i JWT auth. Nessun nuovo meccanismo di storage necessario.deleteTokenimportato dinamicamente indeleteBackupKey(): 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_codeeget_userinfocomeAsyncMockdirettamente 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_matchverifica chesubJWT 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=Falsecon 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.