From d5679f6e0782601552ffbdeb64b052700209a150 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Fri, 20 Mar 2026 09:01:35 +0100
Subject: [PATCH] hotfix msft/google login tokens end to end separated from
connection
---
.../OAuth-Auth-vs-Data-Connection-Konzept.md | 351 ++++++++++++++++++
1 file changed, 351 insertions(+)
create mode 100644 concepts/OAuth-Auth-vs-Data-Connection-Konzept.md
diff --git a/concepts/OAuth-Auth-vs-Data-Connection-Konzept.md b/concepts/OAuth-Auth-vs-Data-Connection-Konzept.md
new file mode 100644
index 0000000..2f06cbe
--- /dev/null
+++ b/concepts/OAuth-Auth-vs-Data-Connection-Konzept.md
@@ -0,0 +1,351 @@
+# OAuth: Login (Auth-only) vs. UserConnection (Data) — Zielzustand & Umsetzungsplan
+
+## Übersicht
+
+Dieses Dokument beschreibt den **ausschließlichen Zielzustand** der Google- und Microsoft-Integration im **PowerOn Gateway** (`poweron/gateway`). Es gibt **keine Übergangsphase** und **keine Rückwärtskompatibilität**.
+
+Der folgende Abschnitt **„Ist (Codebasis)“** belegt den Ausgangspunkt mit konkreten Stellen im Repo. Alles danach ist **Soll**: feste URLs, feste Scope-Listen, feste Env-Keys, feste Verhaltensregeln.
+
+| Ebene | OAuth-Client | Token in DB |
+|--------|----------------|-------------|
+| **Identity (Login)** | Auth-App | `connectionId = null`, `tokenPurpose = authSession`, `tokenAccess` = Gateway-JWT |
+| **Data (UserConnection)** | Data-App | `connectionId` gesetzt, `tokenPurpose = dataConnection`, `tokenAccess` / `tokenRefresh` = Microsoft/Google |
+
+---
+
+## Ist (Codebasis) — Referenz für den Umbau
+
+### Router & URLs (heute)
+
+- **Prefix Google:** `APIRouter(prefix="/api/google", …)` in `gateway/modules/routes/routeSecurityGoogle.py`.
+- **Prefix MSFT:** `APIRouter(prefix="/api/msft", …)` in `gateway/modules/routes/routeSecurityMsft.py`.
+- **Heute sichtbare Endpunkte:**
+ - `GET /api/google/login` — startet OAuth mit **einem** globalen `SCOPES`-Array (enthält Gmail + Drive + Userinfo + `openid`).
+ - `GET /api/google/auth/callback` — gemeinsamer Callback für Login (`state.type == "login"`) und Connect (`state.type == "connect"`).
+ - `GET /api/msft/login` — startet OAuth mit **einem** globalen `SCOPES` (alle Business-Scopes).
+ - `GET /api/msft/auth/callback` — gemeinsamer Callback Login/Connect.
+ - `GET /api/msft/adminconsent` + `GET /api/msft/adminconsent/callback`.
+ - `GET /api/google/config` — Debug-Konfiguration.
+
+### Google — eine Scope-Liste für alles (`routeSecurityGoogle.py`, ca. Zeilen 88–94)
+
+Heute:
+
+```text
+https://www.googleapis.com/auth/gmail.readonly
+https://www.googleapis.com/auth/drive.readonly
+https://www.googleapis.com/auth/userinfo.profile
+https://www.googleapis.com/auth/userinfo.email
+openid
+```
+
+Zusätzlich setzt der Login `access_type=offline`, `include_granted_scopes=true` (Zeilen ~166–169) — auch für den reinen Login-Pfad.
+
+### Microsoft — eine Scope-Liste für alles (`routeSecurityMsft.py`, Zeilen 56–67)
+
+Heute (delegiert, an MSAL übergeben; MSAL ergänzt u. a. `openid`, `profile`, `offline_access`):
+
+```text
+User.Read
+Mail.ReadWrite
+Mail.Send
+Files.ReadWrite.All
+Sites.ReadWrite.All
+Team.ReadBasic.All
+OnlineMeetings.Read
+Chat.ReadWrite
+ChatMessage.Send
+```
+
+Kommentar im Code: Admin Consent unter `GET /api/msft/adminconsent`.
+
+### Microsoft Login speichert zwei Access-Token-Rows (`routeSecurityMsft.py`, ca. 340–389)
+
+1. `Token` mit `tokenAccess = token_response["access_token"]` (Graph) → `saveAccessToken`.
+2. `Token` mit `tokenAccess = jwt_token` (Gateway-JWT) → `saveAccessToken` erneut.
+
+Der Login-Pfad soll im Ziel **nur** Schritt 2 persistieren (ein Row, `authSession`).
+
+### Google Login-Callback postet Provider-Token ans Frontend (`routeSecurityGoogle.py`, ca. 387–391)
+
+Im HTML wird `postMessage` mit `access_token: token_response["access_token"]` (Google Access Token) mitgeschickt. **Ziel:** Im Login-Flow kein Google Access Token an `window.opener` senden — nur noch `token_data` für das Gateway-JWT (oder nur Cookies, je nach UI-Vertrag).
+
+### Connections → OAuth-Start (`routeDataConnections.py`, Zeilen 450–465)
+
+`connect_service` baut:
+
+- MSFT: `auth_url = f"/api/msft/login?state={json.dumps(state_data)}"` mit `type: "connect"`, `connectionId`, `userId`.
+- Google: `auth_url = f"/api/google/login?state=…"` gleiches Muster.
+
+**Ziel:** Diese Strings werden durch die neuen **Connect-URLs** ersetzt (siehe unten). Die fachliche Validierung „Connection gehört `currentUser`“ bleibt: Zeilen 436–440.
+
+### Token-Refresh Microsoft (`gateway/modules/auth/tokenManager.py`, Zeilen 47–53)
+
+`refreshMicrosoftToken` sendet beim Refresh einen **fest eingeschränkten** `scope`:
+
+```text
+Mail.ReadWrite Mail.Send Mail.ReadWrite.Shared User.Read
+```
+
+Das deckt **nicht** die in `routeSecurityMsft.py` verwendeten Scopes ab (`Files.ReadWrite.All`, `Sites.ReadWrite.All`, Teams, …). **Ziel:** Refresh-`scope` ist **zeichenidentisch** zur Data-Scope-Liste aus dem Connect (eine Konstante `MSFT_DATA_SCOPES` im Code, für Authorize + Token + Refresh).
+
+### JWT-Validierung (`gateway/modules/auth/authentication.py`, Zeilen 150–196)
+
+- Datenbankprüfung (`findActiveTokenById`) gilt **nur** für `AuthAuthority.LOCAL`.
+- JWTs mit `authenticationAuthority` `google` / `msft` werden **ohne** diese DB-Zeilenprüfung akzeptiert, sofern Signatur und Claims passen.
+
+**Ziel:** Für `google` und `msft` dieselbe Regel wie LOCAL: aktive Row mit `id = jti`, `tokenPurpose = authSession`, `authority` passend. (Kein „optional“.)
+
+### CSRF (`gateway/modules/auth/csrf.py`, Zeilen 24–31)
+
+`exempt_paths` enthält u. a.:
+
+- `/api/msft/login`, `/api/google/login`
+- `/api/msft/callback`, `/api/google/callback` — diese Pfade entsprechen **nicht** den realen Callback-Routen (`/api/msft/auth/callback`, `/api/google/auth/callback`).
+
+**Ziel:** `exempt_paths` auf die **neuen** OAuth-GET-Start-URLs setzen (siehe unten). Tote Einträge entfernen. (Hinweis: CSRF greift nur für `POST`/`PUT`/`DELETE`/`PATCH`; die Einträge sind trotzdem mit den echten Pfaden zu synchronisieren.)
+
+### Konfiguration (`gateway/env_dev.env`, `env_int.env`, `env_prod.env`, `.env`)
+
+Heute u. a.:
+
+- `Service_GOOGLE_CLIENT_ID`, `Service_GOOGLE_CLIENT_SECRET`, `Service_GOOGLE_REDIRECT_URI` → zeigen auf **eine** App und Callback `…/api/google/auth/callback`.
+- `Service_MSFT_CLIENT_ID`, `Service_MSFT_CLIENT_SECRET`, `Service_MSFT_REDIRECT_URI` → `…/api/msft/auth/callback`.
+
+**Ziel:** Diese Keys werden **ersetzt** (siehe Abschnitt Konfiguration).
+
+### App-Einbindung (`gateway/app.py`, ca. Zeilen 539–543)
+
+`msftRouter` und `googleRouter` werden eingebunden; der Umbau bleibt in denselben Modulen oder splittet intern — **keine** parallelen Legacy-Router.
+
+---
+
+## Soll: Öffentliche HTTP-API (nur diese Routen)
+
+Die heutigen Routen **`GET /api/google/login`**, **`GET /api/msft/login`** und der **gemeinsame** Callback **`GET /api/google/auth/callback`** / **`GET /api/msft/auth/callback`** werden **entfernt**. Stattdessen:
+
+| Aktion | Google | Microsoft |
+|--------|--------|-----------|
+| Login start | `GET /api/google/auth/login` | `GET /api/msft/auth/login` |
+| Login callback | `GET /api/google/auth/login/callback` | `GET /api/msft/auth/login/callback` |
+| Connect start | `GET /api/google/auth/connect` | `GET /api/msft/auth/connect` |
+| Connect callback | `GET /api/google/auth/connect/callback` | `GET /api/msft/auth/connect/callback` |
+| Admin Consent | — | `GET /api/msft/adminconsent` |
+| Admin Consent Callback | — | `GET /api/msft/adminconsent/callback` |
+
+**Connect-Start:** Query-Parameter und Pflichten — **konkret:**
+
+- `connectionId` (UUID der `UserConnection`) ist **Pflicht**.
+- Der Handler ruft `getCurrentUser` (oder gleichwertige Session) auf, lädt die Connection über `getInterface(currentUser).getUserConnections` bzw. verifiziert Besitz wie in `routeDataConnections.connect_service` (heute Zeilen 436–440). Stimmt `connectionId` nicht mit `currentUser` überein → `404` / `403`.
+- **Kein** separater OAuth-State mit `userId`/`connectionId` als alleinige Sicherheit: Session + serverseitige Ownership-Prüfung sind maßgeblich.
+
+**Redirects in Google Cloud / Azure (pro Umgebung):**
+
+- Auth-App: genau die beiden URIs `…/auth/login/callback`.
+- Data-App: genau die beiden URIs `…/auth/connect/callback`.
+- MSFT Admin Consent Redirect: wie heute aus dem Data-Redirect URI abgeleitet, aber mit dem **Data-**`REDIRECT_URI` als Basis:
+
+ Heute: `REDIRECT_URI.replace("/auth/callback", "/adminconsent/callback")`.
+
+ **Ziel:** `Service_MSFT_DATA_REDIRECT_URI.replace("/auth/connect/callback", "/adminconsent/callback")` (oder gleiche relative Ersetzung auf dem neuen Connect-Callback-Pfad).
+
+Debug **`GET /api/google/config`:** entweder entfernen oder auf **Auth-** und **Data-**Client getrennt dokumentieren (ohne Secrets); nicht zwingend für Produktion.
+
+---
+
+## Soll: Scope-Listen (fest im Code)
+
+### Google — Auth (`GOOGLE_AUTH_SCOPES`)
+
+Nur OIDC + Profil, **keine** Gmail-/Drive-API-Scopes:
+
+```text
+openid
+https://www.googleapis.com/auth/userinfo.email
+https://www.googleapis.com/auth/userinfo.profile
+```
+
+- **`access_type`:** `online` (kein Refresh-Token-Zwang für Login).
+- **`include_granted_scopes`:** **`false`** oder Parameter weglassen.
+
+### Google — Data (`GOOGLE_DATA_SCOPES`)
+
+Übernahme der **heutigen** Daten-Scopes aus `routeSecurityGoogle.py`, ohne die Userinfo-Scopes zu duplizieren, wenn sie für den Data-Flow nicht nötig sind; **Minimum** zur Kompatibilität mit `connectorGoogle.py` (Drive + Gmail):
+
+```text
+openid
+https://www.googleapis.com/auth/userinfo.email
+https://www.googleapis.com/auth/userinfo.profile
+https://www.googleapis.com/auth/gmail.readonly
+https://www.googleapis.com/auth/drive.readonly
+```
+
+- **`access_type`:** `offline`.
+- **`include_granted_scopes`:** `true` **nur** hier (Data-App / Connect), nicht im Auth-Login.
+
+### Microsoft — Auth (`MSFT_AUTH_SCOPES`)
+
+Nur Profil für `GET https://graph.microsoft.com/v1.0/me` nach Code-Tausch:
+
+```text
+User.Read
+```
+
+MSAL erhält **ausschließlich** diese Liste für `get_authorization_request_url` / `acquire_token_by_authorization_code` im **Login**-Pfad. Die von MSAL ergänzten Standard-Claims (`openid`, `profile`, `offline_access`, …) bleiben unverändert Provider-seitig.
+
+### Microsoft — Data (`MSFT_DATA_SCOPES`)
+
+**Unverändert** gegenüber der heutigen Liste in `routeSecurityMsft.py` (Zeilen 56–67):
+
+```text
+User.Read
+Mail.ReadWrite
+Mail.Send
+Files.ReadWrite.All
+Sites.ReadWrite.All
+Team.ReadBasic.All
+OnlineMeetings.Read
+Chat.ReadWrite
+ChatMessage.Send
+```
+
+**Regel:** `MSFT_DATA_SCOPES` ist **eine** Konstante; sie wird für Authorize, für `acquire_token_by_authorization_code` Connect und für den **Refresh** (`tokenManager.refreshMicrosoftToken`, Feld `scope` im POST an `…/oauth2/v2.0/token`) als **eine** Space-getrennte Zeichenkette verwendet (Reihenfolge wie oben, konsistent halten).
+
+---
+
+## Soll: Konfiguration (`gateway/env_*.env` / `APP_CONFIG`)
+
+Die bisherigen Einträge werden **entfernt** und **ersetzt** durch:
+
+**Google**
+
+- `Service_GOOGLE_AUTH_CLIENT_ID`
+- `Service_GOOGLE_AUTH_CLIENT_SECRET`
+- `Service_GOOGLE_AUTH_REDIRECT_URI` → muss `…/api/google/auth/login/callback` enden.
+- `Service_GOOGLE_DATA_CLIENT_ID`
+- `Service_GOOGLE_DATA_CLIENT_SECRET`
+- `Service_GOOGLE_DATA_REDIRECT_URI` → muss `…/api/google/auth/connect/callback` enden.
+
+**Microsoft**
+
+- `Service_MSFT_AUTH_CLIENT_ID`
+- `Service_MSFT_AUTH_CLIENT_SECRET`
+- `Service_MSFT_AUTH_REDIRECT_URI` → `…/api/msft/auth/login/callback`
+- `Service_MSFT_DATA_CLIENT_ID`
+- `Service_MSFT_DATA_CLIENT_SECRET`
+- `Service_MSFT_DATA_REDIRECT_URI` → `…/api/msft/auth/connect/callback`
+- `Service_MSFT_TENANT_ID` (unverändert, z. B. `common`)
+
+**Zu aktualisierende Dateien:** `gateway/env_dev.env`, `gateway/env_int.env`, `gateway/env_prod.env`, `gateway/.env`, sowie jede zentrale Konfiguration, die `APP_CONFIG` befüllt.
+
+**`TokenManager`** lädt für Refresh **ausschließlich** Data-Client-Zugangsdaten: `Service_MSFT_DATA_CLIENT_ID`, `Service_MSFT_DATA_CLIENT_SECRET`, `Service_MSFT_TENANT_ID` (heute nutzt die Klasse `Service_MSFT_CLIENT_ID` / `SECRET` — das wird ersetzt).
+
+---
+
+## Soll: Datenmodell & DB
+
+### `Token` (`gateway/modules/datamodels/datamodelSecurity.py`)
+
+Pflichtfeld:
+
+```text
+tokenPurpose: Literal["authSession", "dataConnection"]
+```
+
+| `tokenPurpose` | `connectionId` | `tokenAccess` | `tokenRefresh` |
+|----------------|----------------|---------------|----------------|
+| `authSession` | `null` | Gateway-JWT | leer oder nicht genutzt |
+| `dataConnection` | gesetzt | Provider Access | Provider Refresh (Data-Flow) |
+
+Kein weiteres optionales Metadatenfeld im ersten Schritt.
+
+### Migration
+
+- Spalte `tokenPurpose` **NOT NULL** mit Check-Constraint oder App-Validierung.
+- Cutover: bestehende Datenbankzeilen ohne klare Zuordnung werden **bereinigt**; Nutzer führen Login und Connect **einmal neu** aus.
+
+### `UserConnection.grantedScopes`
+
+Nach Connect-Callback:
+
+- **Google:** aus `token_response["scope"]` (bzw. aus der Antwort des Token-Endpunkts) als Liste splitten.
+- **Microsoft:** aus dem erfolgreichen MSAL-Resultat-Feld für Scope, falls vorhanden; wenn der Provider nur eine Teilmenge zurückgibt, persistieren was zurückkommt — **nicht** blind `MSFT_DATA_SCOPES` in die DB schreiben, wenn die Antwort etwas anderes meldet.
+
+---
+
+## Soll: Verhalten pro Flow
+
+### Login
+
+1. `GET …/auth/login` → Redirect Google/Microsoft **Auth-App**, Scopes = `GOOGLE_AUTH_SCOPES` bzw. `MSFT_AUTH_SCOPES`.
+2. Callback `…/auth/login/callback` → Code tauschen, Userinfo (`oauth2/v2/userinfo` bzw. Graph `/me`), User anlegen/lesen, JWT erzeugen.
+3. Genau **ein** `saveAccessToken`: `tokenPurpose=authSession`, `connectionId=null`, `tokenAccess=jwt`, `id=jti` wie heute bei Google/MSFT-JWT-Row.
+4. Cookies setzen wie heute (`setAccessTokenCookie`, `setRefreshTokenCookie`).
+5. **Microsoft:** kein `saveAccessToken` mit Rohtoken aus Graph.
+6. **Google:** `postMessage` **ohne** `access_token` mit Google-Bearer-Token.
+
+### Connect
+
+1. Client ruft weiter `POST /api/connections/{connectionId}/connect` auf (bestehende Vertragsstelle).
+2. Response `authUrl` ist **relativ** z. B. `/api/google/auth/connect?connectionId={id}` bzw. `/api/msft/auth/connect?connectionId={id}`.
+3. Browser folgt `GET …/auth/connect` mit gültiger Session → Server prüft Ownership → Redirect **Data-App**, Scopes = `GOOGLE_DATA_SCOPES` / `MSFT_DATA_SCOPES`.
+4. Callback `…/auth/connect/callback` → `saveConnectionToken` mit `tokenPurpose=dataConnection`, gleiche `connectionId`; `UserConnection` aktualisieren inkl. `grantedScopes`.
+
+### Admin Consent (MSFT)
+
+- `GET /api/msft/adminconsent` verwendet **`Service_MSFT_DATA_CLIENT_ID`** und `redirect_uri` abgeleitet von **`Service_MSFT_DATA_REDIRECT_URI`**.
+
+---
+
+## Soll: Codeänderungen (Dateien)
+
+| Datei | Konkrete Aufgabe |
+|-------|------------------|
+| `gateway/modules/routes/routeSecurityGoogle.py` | Router neu strukturieren: vier Zielrouten; zwei Client-Paare aus Config; Konstanten `GOOGLE_AUTH_SCOPES` / `GOOGLE_DATA_SCOPES`; Login ohne `include_granted_scopes`; Connect mit `offline` + `include_granted_scopes`; `/login` und `/auth/callback` löschen. |
+| `gateway/modules/routes/routeSecurityMsft.py` | Wie oben; `MSFT_AUTH_SCOPES` / `MSFT_DATA_SCOPES`; Login nur eine `saveAccessToken`-Row; Connect unverändert fachlich, aber nur Data-Client; Admin Consent auf Data-Client; `/login` und `/auth/callback` löschen. |
+| `gateway/modules/routes/routeDataConnections.py` | `auth_url` auf `/api/msft/auth/connect?connectionId=…` und `/api/google/auth/connect?connectionId=…` (kein JSON-`state` mehr nötig für Security — Session + DB-Check). |
+| `gateway/modules/interfaces/interfaceDbApp.py` | `saveAccessToken` / `saveConnectionToken` validieren `tokenPurpose` und `connectionId` wie in der Tabelle oben; Verstoß → `ValueError`. |
+| `gateway/modules/auth/tokenManager.py` | `msft_client_id` / `secret` aus `Service_MSFT_DATA_*`; Refresh-`scope` = space-joined `MSFT_DATA_SCOPES`; Refresh nur sinnvoll aufrufen wenn `oldToken.tokenPurpose == dataConnection` (oder implizit nur solche Tokens in DB). |
+| `gateway/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py` | `getFreshToken`: wenn geladener Token nicht `dataConnection` → `None` zurück + Log. |
+| `gateway/modules/auth/authentication.py` | Erweiterung: für `authenticationAuthority` `google` und `msft` dieselbe aktive-Row-Prüfung wie LOCAL auf `findActiveTokenById` mit passender `authority` und `tokenPurpose=authSession`. |
+| `gateway/modules/auth/csrf.py` | `exempt_paths`: `/api/google/auth/login`, `/api/msft/auth/login` (und keine toten `/api/*/callback` ohne `/auth/`). |
+| `gateway/env_*.env`, `.env` | Keys wie im Abschnitt Konfiguration. |
+| Frontend (PowerOn UI) | Alle Links von `/api/google/login` → `/api/google/auth/login`, von `/api/msft/login` → `/api/msft/auth/login`; `connect` nutzt neue `authUrl`. |
+
+---
+
+## Umsetzungsschritte (ein Schnitt, Reihenfolge)
+
+1. Vier OAuth-Apps in Google Cloud / Zwei in Azure (Auth + Data) anlegen; Redirect-URIs exakt wie oben.
+2. DB-Migration `tokenPurpose` NOT NULL; Datenbereinigung / erzwungener Re-Login und Re-Connect.
+3. Konstanten und Handler in `routeSecurityGoogle.py` / `routeSecurityMsft.py` implementieren; alte Routen entfernen.
+4. `routeDataConnections.py` `authUrl` anpassen.
+5. `interfaceDbApp` + `authentication.py` + `tokenManager` + `mainServiceSecurity.py` anpassen.
+6. `csrf.py` + alle `env_*.env` aktualisieren.
+7. Frontend-URLs ersetzen.
+8. Tests: Login-Tokeninfo ohne Gmail/Drive; Connect mit Daten-Scopes; Refresh MSFT mit vollständigem Scope-String; API-Aufruf mit altem `/api/*/login` muss **404** liefern.
+
+---
+
+## Tests (Akzeptanzkriterien)
+
+- [ ] `GET /api/google/login` und `GET /api/msft/login` existieren **nicht** (404).
+- [ ] Nach Login liefert Google `tokeninfo` (falls mit kurzlebigenm Token geprüft) **keine** `gmail`/`drive` Scope-Strings.
+- [ ] Nach MSFT-Login enthält das Access Token **keine** Ressourcen-Zustimmung für Mail/Files jenseits von `User.Read` (Scope-String prüfen).
+- [ ] Nach Connect: `getConnectionToken` → `dataConnection`; Browse über `ConnectorResolver` funktioniert.
+- [ ] `refreshMicrosoftToken` sendet `scope` = vollständiger `MSFT_DATA_SCOPES`-String.
+- [ ] `postMessage` beim Google-Login enthält **keinen** Google `access_token`.
+
+---
+
+## Changelog (Dokument)
+
+| Datum | Änderung |
+|-------|----------|
+| 2026-03-20 | Erstversion |
+| 2026-03-20 | Nur Zielzustand, ohne Übergang |
+| 2026-03-20 | Ist-Analyse am Code; fest definierte Scopes, URLs, Env-Keys; vage Formulierungen entfernt |
+
+---
+
+*Ende Dokument*