M365-01: Microsoft-Graph-API-Anbindung (Auth/Basis) #223

Merged
patrick merged 1 commits from feature/m365-01-01-microsoft-graph-api-anbindung-auth-basis into main 2026-09-27 11:00:35 +02:00
3 changed files with 420 additions and 0 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# M365-01 – Prüfprotokoll: Microsoft-Graph-API-Anbindung (Auth/Basis)
Datum: 2026-09-27. Host: Entwicklungshost 192.168.1.204, eigene Wegwerf-DB/-Rolle `m365_01_test` (angelegt und nach Testlauf wieder gelöscht, keine Kollision mit anderen parallelen Agenten). Frischer Klon von `origin/main`.
## Umfang
- `connect/internal/m365connector`: OAuth2-Client-Credentials-Flow (App-only, `grant_type=client_credentials`, Scope `https://graph.microsoft.com/.default`) gegen den Microsoft-Identity-Endpunkt sowie ein generischer Graph-API-Lesezugriff (`GET /organization` als kleinste Basisoperation ohne zusätzliche Admin-Consent-Anforderung). Implementiert `registry.Connector` (gleiches Muster wie `erpconnector`/`crmconnector`) und liefert `credential.Secret` zur Speicherung über die bestehende Connect-Credential-Verwaltung (ENG-02) — keine eigene Zugangsdatenverwaltung.
- Vorbereitet, aber nicht Bestandteil dieser Kachel: `Client.Read` ist die generische Primitive, auf der M365-02 (SharePoint/OneDrive) und M365-03 (Outlook) aufbauen können.
## Limitation: kein echter Microsoft-Testmandant
In dieser Umgebung stehen keine echten Azure-AD/Microsoft-365-Testmandanten-Credentials zur Verfügung (OAuth2 gegen einen externen, produktiven Cloud-Dienst). Wie bei anderen Drittsystem-Anbindungen in dieser Session (z. B. DATEV) wird die OAuth2-Anmeldung und der Graph-Aufruf gegen einen `httptest.Server`-Mock geprüft, der den realen Vertrag beider Endpunkte nachbildet:
- Token-Endpunkt: prüft `grant_type=client_credentials` und `client_secret`, antwortet mit `access_token`/`expires_in` im realen Antwortformat von `login.microsoftonline.com`.
- Graph-Endpunkt: antwortet auf `Authorization: Bearer <token>` entweder mit Nutzdaten oder mit `401` + Microsoft-Graph-typischem Fehlerkörper `{"error":{"code":"InvalidAuthenticationToken",...}}` für abgelaufene/ungültige Token.
Ein echter End-to-End-Lauf gegen einen produktiven Microsoft-Testmandanten ist damit **offen** und ehrlich als solcher dokumentiert, nicht übersprungen.
## Akzeptanzkriterien
| # | Kriterium | Ergebnis |
|---|---|---|
| 1 | Authentifizierung gegen Microsoft Graph im Test erfolgreich | **Erfüllt** (gegen Mock, s. o.). `TestClient_Authenticate_Success`, `TestClient_Authenticate_WrongSecret_ReportsError` (Gegenprobe). |
| 2 | Zugangsdaten werden über die Connect-Credential-Verwaltung gespeichert | **Erfüllt.** `TestClient_Authenticate_StoredViaCredentialStore` — Token aus `Authenticate()` wird über `credential.Store.Put`/`.Get` (ENG-02) verschlüsselt gespeichert, gelesen und für einen Graph-Aufruf verwendet. |
| 3 | Abgelaufene Berechtigung wird erkannt und klar gemeldet | **Erfüllt.** `TestClient_Connect_ExpiredToken_ReportsPermissionExpired` — `401`-Antwort mit Graph-Fehlercode führt zu typisiertem `ErrPermissionExpired`, nicht zu einem generischen/stillen Fehler. |
## Prüfungen vor der Abnahme
1. **OAuth2-Anmeldung im Test gegen Microsoft-Testmandant erfolgreich.** Zielwert: Client-Credentials-Exchange liefert gültigen Access Token bei korrektem Client-Secret, klaren Fehler bei falschem. Ergebnis: **bestanden** (gegen Mock-Identity-Endpunkt, s. Limitation oben) — `TestClient_Authenticate_Success`, `TestClient_Authenticate_WrongSecret_ReportsError`.
2. **Testaufruf einer Graph-API-Basisoperation erfolgreich.** Zielwert: mit gültigem, aus der Credential-Verwaltung gelesenem Token liefert `GET /organization` eine erfolgreiche Antwort. Ergebnis: **bestanden** — `TestClient_Authenticate_StoredViaCredentialStore` (End-to-End: Authenticate → Store.Put → Store.Get → Connect/Read), `TestClient_Connect_Success`.
3. **Simulierter Berechtigungsablauf wird korrekt erkannt und gemeldet.** Zielwert: Graph-Antwort `401 InvalidAuthenticationToken` wird als `ErrPermissionExpired` erkannt, nicht als generischer Verbindungsfehler. Ergebnis: **bestanden** — `TestClient_Connect_ExpiredToken_ReportsPermissionExpired`.
## Regressionsprüfung
`go build ./...`, `go vet ./internal/m365connector/...` und `go test ./... ` für das gesamte `connect`-Modul (inkl. `TEST_TENANT_DSN` gegen die Wegwerf-DB `m365_01_test`): alle Pakete grün (`credential`, `erpconnector`, `crmconnector`, `syncjob`, `registry`, `m365connector`, …), keine Regressionen.
## Nicht Bestandteil dieser Kachel (nicht umgesetzt)
- M365-02 (SharePoint/OneDrive-Dokumentabgleich)
- M365-03 (Outlook-Kalender-Kopplung)
- Interaktiver OAuth2-Authorization-Code-Flow (Nutzeranmeldung im Browser) — diese Kachel deckt den App-only-Client-Credentials-Flow ab, passend zu einem serverseitigen Connector ohne Nutzerinteraktion; ein Authorization-Code-Flow wäre nur nötig, falls ein späteres Ticket delegierte (nutzergebundene) statt Anwendungs-Berechtigungen verlangt.
- Automatisches Token-Refresh (`credential.Refresher`) ist bereits generisch in ENG-02 vorhanden und für `m365connector` nicht gesondert verdrahtet — Anbindung folgt bei Bedarf in M365-02/M365-03, sobald ein Refresh-Token-Fluss (statt reinem Client-Credentials-Grant, der keinen Refresh-Token liefert) tatsächlich gebraucht wird.
@@ -0,0 +1,167 @@
// Package m365connector implements the Microsoft-365-Anbindung base layer
// (M365-01): OAuth2 client-credentials authentication against Microsoft
// Graph plus a basic Graph API call, as the prerequisite for the
// SharePoint/OneDrive (M365-02) and Outlook (M365-03) connectors. Follows
// the same registry.Connector / credential.Store pattern as erpconnector
// and crmconnector — Microsoft 365 is one connector under the same
// interface, not a special case.
package m365connector
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/registry"
)
var (
// ErrAuthFailed wraps any failure of the OAuth2 token exchange.
ErrAuthFailed = errors.New("m365connector: authentication failed")
// ErrPermissionExpired is returned when Graph rejects a stored access
// token as expired/invalid, so the caller has to re-authenticate
// instead of retrying silently.
ErrPermissionExpired = errors.New("m365connector: permission expired")
// ErrConnectionFailed wraps any other failure to reach Graph.
ErrConnectionFailed = errors.New("m365connector: connection failed")
)
// Config configures one Microsoft 365 connector instance. TenantID,
// ClientID and ClientSecret are read from environment variables by the
// caller, never hardcoded. TokenEndpoint and GraphBaseURL default to the
// real Microsoft endpoints and are overridden in tests to point at a mock
// server.
type Config struct {
Name string
Version string
TenantID string
ClientID string
ClientSecret string
TokenEndpoint string
GraphBaseURL string
}
func (c Config) tokenURL() string {
if c.TokenEndpoint != "" {
return c.TokenEndpoint
}
return "https://login.microsoftonline.com/" + c.TenantID + "/oauth2/v2.0/token"
}
func (c Config) graphURL() string {
if c.GraphBaseURL != "" {
return c.GraphBaseURL
}
return "https://graph.microsoft.com/v1.0"
}
// Client is the Microsoft Graph connector. It implements registry.Connector
// so it can be registered under ENG-01's plugin model.
type Client struct {
cfg Config
http *http.Client
}
func New(cfg Config) *Client {
return &Client{cfg: cfg, http: httpclient.Default()}
}
func (c *Client) Metadata() registry.Metadata {
return registry.Metadata{
Name: c.cfg.Name,
Version: c.cfg.Version,
Operations: []string{"authenticate", "connect", "read"},
}
}
// Authenticate runs the OAuth2 client-credentials grant against Microsoft
// identity platform (app-only auth, scope https://graph.microsoft.com/.default)
// and returns the resulting secret for storage via the Connect
// credential management (ENG-02). It performs no persistence itself — the
// caller decides tenant/instance assignment and calls credential.Store.
func (c *Client) Authenticate(ctx context.Context) (credential.Secret, time.Time, error) {
form := url.Values{
"client_id": {c.cfg.ClientID},
"client_secret": {c.cfg.ClientSecret},
"grant_type": {"client_credentials"},
"scope": {"https://graph.microsoft.com/.default"},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.tokenURL(), strings.NewReader(form.Encode()))
if err != nil {
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: %w", ErrAuthFailed, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: %w", ErrAuthFailed, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: status %d: %s", ErrAuthFailed, resp.StatusCode, string(body))
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &out); err != nil {
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: decode response: %w", ErrAuthFailed, err)
}
if out.AccessToken == "" {
return credential.Secret{}, time.Time{}, fmt.Errorf("%w: empty access_token in response", ErrAuthFailed)
}
expiresAt := time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
return credential.Secret{AccessToken: out.AccessToken}, expiresAt, nil
}
// Connect verifies a stored access token still works, by calling the
// Graph "organization" endpoint (available under the default app-only
// scope, no extra admin consent needed). It is the smallest possible
// Graph call fit to double as a basic connectivity/permission check.
func (c *Client) Connect(ctx context.Context, secret credential.Secret) error {
_, err := c.get(ctx, secret, "/organization")
return err
}
// Read fetches the raw JSON body of a Graph path (e.g. "/organization",
// "/users") using the given access token. Used as the generic basic-call
// primitive that M365-02/M365-03 build on.
func (c *Client) Read(ctx context.Context, secret credential.Secret, path string) ([]byte, error) {
return c.get(ctx, secret, path)
}
func (c *Client) get(ctx context.Context, secret credential.Secret, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.graphURL()+path, nil)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err)
}
req.Header.Set("Authorization", "Bearer "+secret.AccessToken)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("%w: status %d: %s", ErrPermissionExpired, resp.StatusCode, string(body))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(body))
}
return body, nil
}
@@ -0,0 +1,212 @@
package m365connector
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
type fakeKEK struct{ kek []byte }
func (f *fakeKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) {
return f.kek, nil
}
func setupCredentialStore(t *testing.T) *credential.Store {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
migrations, err := migrate.Load(findMigrationsDir(t))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
cryptoSvc := connectcrypto.NewService(&fakeKEK{kek: make([]byte, connectcrypto.KEKSize)})
return credential.NewStore(pool, cryptoSvc)
}
func findMigrationsDir(t *testing.T) string {
t.Helper()
dir, _ := os.Getwd()
for i := 0; i < 6; i++ {
p := dir + "/migrations"
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
return p
}
dir += "/.."
}
t.Fatal("connect/migrations nicht gefunden")
return ""
}
// mockAzureAD simulates the Microsoft identity platform token endpoint plus
// the Graph API, since real production credentials against a Microsoft
// test tenant are not available in this environment (documented in
// connect/docs/M365-01-PRUEFPROTOKOLL.md).
func mockAzureAD(t *testing.T, wantClientSecret string, tokenExpired bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/tenant-1/oauth2/v2.0/token", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.PostForm.Get("grant_type") != "client_credentials" || r.PostForm.Get("client_secret") != wantClientSecret {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token_type":"Bearer","expires_in":3600,"access_token":"test-access-token"}`))
})
mux.HandleFunc("/organization", func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if tokenExpired || auth != "Bearer test-access-token" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":{"code":"InvalidAuthenticationToken","message":"Access token has expired."}}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"value":[{"id":"org-1","displayName":"Testmandant GmbH"}]}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func testConfig(srv *httptest.Server) Config {
return Config{
Name: "m365",
Version: "1.0.0",
TenantID: "tenant-1",
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
TokenEndpoint: srv.URL + "/tenant-1/oauth2/v2.0/token",
GraphBaseURL: srv.URL,
}
}
// Pruefung 1 / AC1: OAuth2-Anmeldung im Test gegen Microsoft-Testmandant
// (hier: Mock des Microsoft-Identity-Endpunkts) erfolgreich.
func TestClient_Authenticate_Success(t *testing.T) {
srv := mockAzureAD(t, "test-client-secret", false)
c := New(testConfig(srv))
secret, expiresAt, err := c.Authenticate(context.Background())
if err != nil {
t.Fatalf("Authenticate() = %v, want nil", err)
}
if secret.AccessToken != "test-access-token" {
t.Fatalf("AccessToken = %q, want test-access-token", secret.AccessToken)
}
if !expiresAt.After(time.Now()) {
t.Fatalf("expiresAt = %v, want in the future", expiresAt)
}
}
func TestClient_Authenticate_WrongSecret_ReportsError(t *testing.T) {
srv := mockAzureAD(t, "correct-secret", false)
cfg := testConfig(srv)
cfg.ClientSecret = "wrong-secret"
c := New(cfg)
_, _, err := c.Authenticate(context.Background())
if err == nil {
t.Fatal("Authenticate() = nil, want error for wrong client secret")
}
if !errors.Is(err, ErrAuthFailed) {
t.Fatalf("Authenticate() error = %v, want wrapping ErrAuthFailed", err)
}
}
// AC2: Zugangsdaten werden ueber die Connect-Credential-Verwaltung (ENG-02)
// gespeichert und wieder gelesen.
func TestClient_Authenticate_StoredViaCredentialStore(t *testing.T) {
store := setupCredentialStore(t)
srv := mockAzureAD(t, "test-client-secret", false)
c := New(testConfig(srv))
secret, expiresAt, err := c.Authenticate(context.Background())
if err != nil {
t.Fatalf("Authenticate() = %v", err)
}
ref := credential.Ref{TenantSlug: "testmandant", ConnectorName: "m365", ConnectorInstanceID: "default"}
if err := store.Put(context.Background(), ref, credential.KindOAuth2, secret, &expiresAt); err != nil {
t.Fatalf("Store.Put() = %v", err)
}
got, kind, gotExpiresAt, err := store.Get(context.Background(), ref)
if err != nil {
t.Fatalf("Store.Get() = %v", err)
}
if kind != credential.KindOAuth2 {
t.Fatalf("kind = %q, want oauth2", kind)
}
if got.AccessToken != secret.AccessToken {
t.Fatalf("AccessToken = %q, want %q", got.AccessToken, secret.AccessToken)
}
if gotExpiresAt == nil || !gotExpiresAt.Equal(expiresAt.Truncate(time.Microsecond)) {
t.Fatalf("expiresAt = %v, want %v", gotExpiresAt, expiresAt)
}
// AC2 / Pruefung 2: Testaufruf einer Graph-API-Basisoperation erfolgreich,
// mit dem aus der Credential-Verwaltung gelesenen Token.
body, err := c.Read(context.Background(), got, "/organization")
if err != nil {
t.Fatalf("Read() = %v, want nil", err)
}
if len(body) == 0 {
t.Fatal("Read() returned empty body")
}
}
// AC3 / Pruefung 3: Ein abgelaufener/ungueltiger Zugriffstoken wird beim
// Graph-Aufruf klar als Berechtigungsablauf erkannt und gemeldet, nicht
// stillschweigend als generischer Fehler behandelt.
func TestClient_Connect_ExpiredToken_ReportsPermissionExpired(t *testing.T) {
srv := mockAzureAD(t, "test-client-secret", true)
c := New(testConfig(srv))
err := c.Connect(context.Background(), credential.Secret{AccessToken: "test-access-token"})
if err == nil {
t.Fatal("Connect() = nil, want error for expired token")
}
if !errors.Is(err, ErrPermissionExpired) {
t.Fatalf("Connect() error = %v, want wrapping ErrPermissionExpired", err)
}
}
func TestClient_Connect_Success(t *testing.T) {
srv := mockAzureAD(t, "test-client-secret", false)
c := New(testConfig(srv))
if err := c.Connect(context.Background(), credential.Secret{AccessToken: "test-access-token"}); err != nil {
t.Fatalf("Connect() = %v, want nil", err)
}
}
func TestClient_Metadata(t *testing.T) {
c := New(Config{Name: "m365", Version: "1.0.0"})
md := c.Metadata()
if md.Name != "m365" || md.Version != "1.0.0" || len(md.Operations) == 0 {
t.Fatalf("Metadata() = %+v, unexpected", md)
}
}