name: google-oauth-callback-impl-spec-2026-05-22 description: Spec near-executable per implementare il flusso OAuth Google (start + callback) in api/reach/, post-MVP. Riusa oauth_connections. Security-sensitive (token cifrati + state CSRF). Pass dedicato. last_update: 2026-05-22 owner: gregorio (decisione schema/cifratura) · Claude (implementazione, pass dedicato) ref: frontend/multiverbe-app/src/lib/reach/adapters/ · database/migrations/2026-05-15h_reach_seo_foundation.sql · memorie feedback_endpoint_auth_robust_getuser + project_api_esbuild_local_validation status: SPEC pronta · NON implementato · non blocca gate 10 Giu (Reach C1b/E1 post-MVP)

Spec · flusso OAuth Google (Search Console E1 + Business Profile C1b)

Stato attuale (verificato 2026-05-22)

  • api/reach/google-oauth-callback.ts NON esiste. Nessun endpoint OAuth "start" Google.
  • oauth_connections esiste già (usata oggi per i social) con colonne adatte: provider, provider_account_id/name, access_token_enc, refresh_token_enc, scopes string[], status, token_expires_at, workspace_id, user_id, metadata. → riusare questa (la tabella reach_google_connections dedicata nella migration è commentata/opzionale).
  • Gli adapter (src/lib/reach/adapters/) sono stub finché non ricevono un GoogleConnectionProvider reale (oggi nullConnectionProvider). Contratto: getConnection(workspaceId, service, siteId) → GoogleConnection | null.
  • Env già deployate: GOOGLE_OAUTH_CLIENT_ID/SECRET/REDIRECT_URI (apex https://multiverbe.app/api/reach/google-oauth-callback), GOOGLE_*_ENABLED flags.

🔴 Decisioni founder prima di implementare

  1. Storage: riusare oauth_connections (RACCOMANDATO, zero nuove migration) — 1 riga provider='google', scopes=['business.manage','webmasters.readonly']. ✅/❌
  2. Cifratura token (*_enc): AES-256-GCM app-side con chiave da env OAUTH_TOKEN_ENC_KEY (32 byte) — VS pgsodium server-side. Decisione di postura sicurezza. Verificare se esiste già un helper crypto in api/_lib/; se no, crearlo.

File da creare (pass dedicato)

  1. api/_lib/crypto.tsencrypt(plaintext)/decrypt(ciphertext) AES-256-GCM (iv+tag prefissati), chiave OAUTH_TOKEN_ENC_KEY. (Solo se non esiste già.)
  2. api/_lib/google-oauth.tsbuildAuthUrl(state), exchangeCode(code)→{access,refresh,expiry,scope}, refreshAccessToken(refresh). Endpoint Google standard, access_type=offline + prompt=consent (per ottenere il refresh token), scopes business.manage + webmasters.readonly.
  3. api/reach/google-oauth-start.tsauth robusta (memoria): getUser() da Supabase + verifica membership via team_members(workspace_id, user_id=auth.uid()). Genera state firmato HMAC {workspace_id, nonce, exp} → cookie httpOnly+SameSite=Lax+Secure → redirect 302 a buildAuthUrl(state). (Evita IDOR: il workspace_id viene dalla sessione, non dal client.)
  4. api/reach/google-oauth-callback.ts — valida state (cookie vs query, HMAC, non scaduto), exchangeCode(code), ricava account id/email (userinfo o tokeninfo), cifra i token, upsert oauth_connections (provider='google', workspace_id dallo state, scopes, status='connected', token_expires_at). Redirect 302 → pagina Reach dashboard con ?google=connected. Gestire error query (consenso negato) → redirect con ?google=error.
  5. api/_lib/google-connection-provider.ts — implementa GoogleConnectionProvider: legge oauth_connections (provider='google', workspace) → decifra → se token_expires_at scaduto, refreshAccessToken + update riga → ritorna GoogleConnection. Passare a getReachAdapters({ connectionProvider }).

Sicurezza (non negoziabile)

  • Token mai in chiaro nel DB: cifrare PRIMA dell'INSERT (colonne *_enc).
  • state CSRF firmato + cookie httpOnly; confronto costante-tempo sull'HMAC.
  • Auth pattern getUser + team_members (no workspace_id dal client). RLS resta attiva; gli endpoint usano service-role solo dopo il check di membership.
  • Pseudonimizzazione N/A (no PII verso Anthropic qui).

Validazione (CI non typecheck-a api/)

esbuild api/reach/google-oauth-callback.ts --bundle --packages=external --platform=node --format=esm > NUL
esbuild api/reach/google-oauth-start.ts   --bundle --packages=external --platform=node --format=esm > NUL

Eseguire dal checkout principale (memoria project_api_esbuild_local_validation).

Definition of Done

  • Start → consent Google → callback → riga oauth_connections provider='google' con token cifrati + scopes.
  • getReachAdapters con il provider reale → envStatus.businessProfile/searchConsole = 'connected' quando il workspace ha la connessione.
  • esbuild EXIT 0 su entrambi gli entrypoint. NON blocca il gate 10 Giu.