Compare commits

..

4 Commits

Author SHA1 Message Date
Roberto Musso
92648472d7 fix: handle unverified OAuth email conflict (api submodule)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:49:36 +02:00
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
Roberto Musso
d068edc77e feat: Google OAuth Steps 2-4 — backend web-callback, Electron deep link, login UI, avatar
Backend (api @ feature/batch-agent-v2):
- GET /auth/oauth/{provider}/web-callback: bounces Google redirect to adiuvai://
- OAUTH_REDIRECT_URI default: http://localhost:8000/api/v1/.../web-callback

Electron (adiuvAI @ develop):
- adiuvai:// protocol registered in forge.config.ts and via setAsDefaultProtocolClient
- Single-instance lock + second-instance/open-url deep-link handlers
- AuthManager.loginWithOAuth() + handleOAuthCallback()
- auth.loginWithOAuth tRPC mutation
- LoginForm: Google button, divider, pending state
- AppShell + AccountSection: avatar photo with initials fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:04:14 +02:00
Roberto Musso
37d7e65b35 feat: implement Step 2 Google OAuth backend — provider abstraction, PKCE routes, user linking
Adds api/app/auth/oauth_providers.py with GoogleOAuthProvider (httpx-based,
no authlib needed) and generate_pkce_pair(). New routes:
GET /auth/oauth/{provider}/authorize and POST /auth/oauth/{provider}/callback
with state/PKCE validation and three-way user resolution (existing OAuth link,
email auto-link, new social-only user). Updates settings.py with
GOOGLE_AUTH_CLIENT_ID/SECRET and OAUTH_REDIRECT_URI.

Also includes Step 1 backend changes (already marked complete in plan):
oauth_accounts table migration, nullable password_hash, avatar_url on User.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:21:14 +02:00
3 changed files with 37 additions and 13 deletions

Submodule adiuvAI updated: 27bc9d90af...20bc28e59b

2
api

Submodule api updated: 3cf067faea...90500a3462

View File

@@ -26,7 +26,7 @@ il backup encryption locale (`_cachedPassword`) dalla password utente.
## Step 1: Backend — DB + Model + Schema ## Step 1: Backend — DB + Model + Schema
**Status:** [ ] Da fare **Status:** [x] Completato
**Cosa fare:** **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: creare tabella `oauth_accounts` (`id`, `user_id FK`, `provider`, `provider_user_id`, `provider_email`, `created_at`; UNIQUE su `provider, provider_user_id`)
@@ -42,13 +42,15 @@ il backup encryption locale (`_cachedPassword`) dalla password utente.
- `api/app/schemas.py` - `api/app/schemas.py`
**Lessons learned:** **Lessons learned:**
- _(da compilare a step completato)_ - `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 ## Step 2: Backend — OAuth Provider + Route
**Status:** [ ] Da fare **Status:** [x] Completato
**Cosa fare:** **Cosa fare:**
- Creare astrazione provider OAuth riusabile in `api/app/auth/oauth_providers.py` (nuovo) - Creare astrazione provider OAuth riusabile in `api/app/auth/oauth_providers.py` (nuovo)
@@ -78,13 +80,18 @@ il backup encryption locale (`_cachedPassword`) dalla password utente.
- `api/requirements.txt` (o pyproject.toml) - `api/requirements.txt` (o pyproject.toml)
**Lessons learned:** **Lessons learned:**
- _(da compilare a step completato)_ - **`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.
--- ---
## Step 3: Electron — Deep Link + Auth Manager ## Step 3: Electron — Deep Link + Auth Manager
**Status:** [ ] Da fare **Status:** [x] Completato
**Cosa fare:** **Cosa fare:**
- Registrare protocollo custom `adiuvai://` in `adiuvAI/forge.config.ts` (packagerConfig.protocols) - Registrare protocollo custom `adiuvai://` in `adiuvAI/forge.config.ts` (packagerConfig.protocols)
@@ -106,13 +113,19 @@ il backup encryption locale (`_cachedPassword`) dalla password utente.
- `adiuvAI/src/main/router/index.ts` - `adiuvAI/src/main/router/index.ts`
**Lessons learned:** **Lessons learned:**
- _(da compilare a step completato)_ - **`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 ## Step 4: Electron — UI Login + Avatar
**Status:** [ ] Da fare **Status:** [x] Completato
**Cosa fare:** **Cosa fare:**
- In `adiuvAI/src/renderer/components/auth/LoginForm.tsx`: - In `adiuvAI/src/renderer/components/auth/LoginForm.tsx`:
@@ -132,13 +145,18 @@ il backup encryption locale (`_cachedPassword`) dalla password utente.
- Componenti profilo utente (da identificare) - Componenti profilo utente (da identificare)
**Lessons learned:** **Lessons learned:**
- _(da compilare a step completato)_ - **`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 ## Step 5: Fix Backup Encryption + Test
**Status:** [ ] Da fare **Status:** [x] Completato
**Cosa fare:** **Cosa fare:**
- Sganciare BackupManager da `_cachedPassword`: - Sganciare BackupManager da `_cachedPassword`:
@@ -159,8 +177,14 @@ il backup encryption locale (`_cachedPassword`) dalla password utente.
**File coinvolti:** **File coinvolti:**
- `adiuvAI/src/main/auth/auth-manager.ts` - `adiuvAI/src/main/auth/auth-manager.ts`
- BackupManager (da identificare path esatto) - `adiuvAI/src/main/auth/backup-key.ts` (nuovo)
- `api/tests/test_auth.py` - `api/tests/test_auth.py`
**Lessons learned:** **Lessons learned:**
- _(da compilare a step completato)_ - **`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.