feat: technisches Grundgerüst für Instagram-/TikTok-OAuth (Plattform-Verbindung)
Vorbereitung für automatische Beweissicherung statt manuellem
Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram-
oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur
das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine
einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe
CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von
dieser Codeänderung läuft.
- internal/socialconnect: Connector-Interface + InstagramConnector/
TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff).
Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token),
TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden
gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte
API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im
Paket- und CLAUDE.md-Kommentar.
- Migration 0006: platform_connection (NICHT append-only, anders als
finding/extraction/asset — ein Token wird ersetzt, keine Korrektur-
Zeile), höchstens eine Verbindung pro Account+Plattform.
- internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/
nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start
(State-Cookie gegen CSRF, Redirect zum Consent-Screen),
GET /oauth/{platform}/callback (State prüfen, Code tauschen,
Verbindung speichern), POST /verbindungen/{platform}/trennen.
- Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die
Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt
"nicht konfiguriert", kein Absturz) — main.go loggt das beim Start.
Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen
Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung
durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test
gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten
Client-Credentials existieren.
This commit is contained in:
82
internal/socialconnect/connector.go
Normal file
82
internal/socialconnect/connector.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package socialconnect implementiert den OAuth-Authorization-Code-Flow,
|
||||
// mit dem ein Kunde seinen eigenen Instagram- oder TikTok-Account mit
|
||||
// Deklarix verbindet — Ziel ist, dass die Beweissicherung einen
|
||||
// veröffentlichten Beitrag später direkt abrufen kann, statt ihn manuell
|
||||
// hochladen zu müssen. Reine HTTP-Logik gegen die jeweilige Plattform-
|
||||
// API, keine Datenbankzugriffe — Persistenz der Verbindung liegt in
|
||||
// internal/store (platform_connection).
|
||||
//
|
||||
// WICHTIG: Instagram- und TikTok-Endpunkte, Scopes und Token-Formate
|
||||
// ändern sich häufiger als andere APIs. Vor dem ersten echten
|
||||
// Verbindungsversuch mit realen Client-Credentials die Konstanten hier
|
||||
// gegen die aktuelle Meta-/TikTok-Entwicklerdokumentation prüfen —
|
||||
// dieser Code wurde ohne echte Zugangsdaten gebaut und gegen die
|
||||
// Dokumentation, nicht gegen die echte API, verifiziert.
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token ist das Ergebnis eines erfolgreichen Code-Tauschs.
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time // Nullwert, wenn die Plattform keine Ablaufzeit liefert
|
||||
PlatformUserID string
|
||||
}
|
||||
|
||||
// Connector kapselt den OAuth-Flow einer einzelnen Plattform. *InstagramConnector
|
||||
// und *TikTokConnector erfüllen dieses Interface; internal/web hält eine
|
||||
// Menge konfigurierter Connectors (nur die, für die echte Client-
|
||||
// Credentials gesetzt sind — siehe cmd/deklarix/main.go).
|
||||
type Connector interface {
|
||||
// Platform ist der interne Bezeichner ("instagram" | "tiktok"), wie
|
||||
// er auch in platform_connection.platform gespeichert wird.
|
||||
Platform() string
|
||||
AuthorizationURL(state string) string
|
||||
Exchange(ctx context.Context, code string) (Token, error)
|
||||
}
|
||||
|
||||
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return doJSON(client, req, out)
|
||||
}
|
||||
|
||||
func getJSON(ctx context.Context, client *http.Client, endpoint string, query url.Values, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doJSON(client, req, out)
|
||||
}
|
||||
|
||||
func doJSON(client *http.Client, req *http.Request, out any) error {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("antwort lesen: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("unerwarteter Status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("antwort parsen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
103
internal/socialconnect/instagram.go
Normal file
103
internal/socialconnect/instagram.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
instagramDefaultAuthorizeURL = "https://api.instagram.com/oauth/authorize"
|
||||
instagramDefaultTokenURL = "https://api.instagram.com/oauth/access_token"
|
||||
instagramDefaultLongLivedTokenURL = "https://graph.instagram.com/access_token"
|
||||
|
||||
// instagram_business_basic ist die einzige Berechtigung, die wir
|
||||
// brauchen (Profil + Medien lesen) — mehr zu verlangen verzögert nur
|
||||
// den App-Review, siehe Paket-Kommentar.
|
||||
instagramScope = "instagram_business_basic"
|
||||
)
|
||||
|
||||
// InstagramConnector implementiert Connector für Instagram (Instagram
|
||||
// API with Instagram Login). Der Flow läuft zweistufig: der
|
||||
// Autorisierungscode wird zuerst gegen ein 1 Stunde gültiges Token
|
||||
// getauscht, das anschließend gegen ein 60 Tage gültiges langlebiges
|
||||
// Token getauscht wird — ein einzelner API-Aufruf reicht dafür nicht.
|
||||
type InstagramConnector struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
|
||||
// Überschreibbar für Tests (Default: die echten Instagram-Endpunkte).
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
LongLivedTokenURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewInstagramConnector erstellt einen InstagramConnector mit den
|
||||
// echten Instagram-Endpunkten.
|
||||
func NewInstagramConnector(clientID, clientSecret, redirectURL string) *InstagramConnector {
|
||||
return &InstagramConnector{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
AuthorizeURL: instagramDefaultAuthorizeURL,
|
||||
TokenURL: instagramDefaultTokenURL,
|
||||
LongLivedTokenURL: instagramDefaultLongLivedTokenURL,
|
||||
HTTPClient: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *InstagramConnector) Platform() string { return "instagram" }
|
||||
|
||||
func (c *InstagramConnector) AuthorizationURL(state string) string {
|
||||
v := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
"scope": {instagramScope},
|
||||
"response_type": {"code"},
|
||||
"state": {state},
|
||||
}
|
||||
return c.AuthorizeURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
func (c *InstagramConnector) Exchange(ctx context.Context, code string) (Token, error) {
|
||||
form := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
"code": {code},
|
||||
}
|
||||
var short struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
UserID any `json:"user_id"` // liefert Instagram mal als Zahl, mal als String
|
||||
}
|
||||
if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &short); err != nil {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram code exchange: %w", err)
|
||||
}
|
||||
if short.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram code exchange: kein access_token in der Antwort")
|
||||
}
|
||||
|
||||
long := url.Values{
|
||||
"grant_type": {"ig_exchange_token"},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"access_token": {short.AccessToken},
|
||||
}
|
||||
var longResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := getJSON(ctx, c.HTTPClient, c.LongLivedTokenURL, long, &longResp); err != nil {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram long-lived token exchange: %w", err)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: longResp.AccessToken,
|
||||
ExpiresAt: time.Now().Add(time.Duration(longResp.ExpiresIn) * time.Second),
|
||||
PlatformUserID: fmt.Sprint(short.UserID),
|
||||
}, nil
|
||||
}
|
||||
82
internal/socialconnect/instagram_test.go
Normal file
82
internal/socialconnect/instagram_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstagramAuthorizationURL(t *testing.T) {
|
||||
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||
u := c.AuthorizationURL("state-abc")
|
||||
|
||||
for _, want := range []string{
|
||||
"https://api.instagram.com/oauth/authorize?",
|
||||
"client_id=client-123",
|
||||
"state=state-abc",
|
||||
"scope=instagram_business_basic",
|
||||
"response_type=code",
|
||||
} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstagramExchangeSuccess(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm: %v", err)
|
||||
}
|
||||
if r.FormValue("code") != "der-code" {
|
||||
t.Errorf("code = %q, want der-code", r.FormValue("code"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"short-lived-token","user_id":"17841400000000000"}`))
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
longLivedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("access_token") != "short-lived-token" {
|
||||
t.Errorf("access_token query = %q, want short-lived-token", r.URL.Query().Get("access_token"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"long-lived-token","expires_in":5184000}`))
|
||||
}))
|
||||
defer longLivedServer.Close()
|
||||
|
||||
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||
c.TokenURL = tokenServer.URL
|
||||
c.LongLivedTokenURL = longLivedServer.URL
|
||||
|
||||
tok, err := c.Exchange(context.Background(), "der-code")
|
||||
if err != nil {
|
||||
t.Fatalf("Exchange: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "long-lived-token" {
|
||||
t.Errorf("AccessToken = %q, want long-lived-token", tok.AccessToken)
|
||||
}
|
||||
if tok.PlatformUserID != "17841400000000000" {
|
||||
t.Errorf("PlatformUserID = %q, want 17841400000000000", tok.PlatformUserID)
|
||||
}
|
||||
if tok.ExpiresAt.IsZero() {
|
||||
t.Error("expected a non-zero ExpiresAt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstagramExchangePropagatesTokenEndpointError(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error_message":"ungueltiger code"}`))
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||
c.TokenURL = tokenServer.URL
|
||||
|
||||
if _, err := c.Exchange(context.Background(), "falscher-code"); err == nil {
|
||||
t.Fatal("expected an error when the token endpoint returns 400")
|
||||
}
|
||||
}
|
||||
95
internal/socialconnect/tiktok.go
Normal file
95
internal/socialconnect/tiktok.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tiktokDefaultAuthorizeURL = "https://www.tiktok.com/v2/auth/authorize/"
|
||||
tiktokDefaultTokenURL = "https://open.tiktokapis.com/v2/oauth/token/"
|
||||
|
||||
// user.info.basic reicht für Profil-Grunddaten; video.list für den
|
||||
// späteren Abruf veröffentlichter Videos (Beweissicherung). Mehr
|
||||
// Scopes verlangen als nötig verzögert nur den Audit, siehe
|
||||
// Paket-Kommentar.
|
||||
tiktokScope = "user.info.basic,video.list"
|
||||
)
|
||||
|
||||
// TikTokConnector implementiert Connector für TikTok Login Kit v2 (Web-
|
||||
// Flow — PKCE ist bei TikTok nur für Desktop/Mobile-Apps Pflicht, beim
|
||||
// Web-Flow schützt allein der state-Parameter gegen CSRF, siehe
|
||||
// TikTok-Dokumentation "Web").
|
||||
type TikTokConnector struct {
|
||||
ClientKey string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
|
||||
// Überschreibbar für Tests (Default: die echten TikTok-Endpunkte).
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewTikTokConnector erstellt einen TikTokConnector mit den echten
|
||||
// TikTok-Endpunkten.
|
||||
func NewTikTokConnector(clientKey, clientSecret, redirectURL string) *TikTokConnector {
|
||||
return &TikTokConnector{
|
||||
ClientKey: clientKey,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
AuthorizeURL: tiktokDefaultAuthorizeURL,
|
||||
TokenURL: tiktokDefaultTokenURL,
|
||||
HTTPClient: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TikTokConnector) Platform() string { return "tiktok" }
|
||||
|
||||
func (c *TikTokConnector) AuthorizationURL(state string) string {
|
||||
v := url.Values{
|
||||
"client_key": {c.ClientKey},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
"scope": {tiktokScope},
|
||||
"response_type": {"code"},
|
||||
"state": {state},
|
||||
}
|
||||
return c.AuthorizeURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
func (c *TikTokConnector) Exchange(ctx context.Context, code string) (Token, error) {
|
||||
form := url.Values{
|
||||
"client_key": {c.ClientKey},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"code": {code},
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
}
|
||||
var resp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
OpenID string `json:"open_id"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &resp); err != nil {
|
||||
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %w", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %s: %s", resp.Error, resp.ErrorDescription)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: kein access_token in der Antwort")
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: resp.AccessToken,
|
||||
RefreshToken: resp.RefreshToken,
|
||||
ExpiresAt: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second),
|
||||
PlatformUserID: resp.OpenID,
|
||||
}, nil
|
||||
}
|
||||
70
internal/socialconnect/tiktok_test.go
Normal file
70
internal/socialconnect/tiktok_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTikTokAuthorizationURL(t *testing.T) {
|
||||
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||
u := c.AuthorizationURL("state-xyz")
|
||||
|
||||
for _, want := range []string{
|
||||
"https://www.tiktok.com/v2/auth/authorize/?",
|
||||
"client_key=client-key-123",
|
||||
"state=state-xyz",
|
||||
"response_type=code",
|
||||
} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokExchangeSuccess(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm: %v", err)
|
||||
}
|
||||
if r.FormValue("client_key") != "client-key-123" {
|
||||
t.Errorf("client_key = %q, want client-key-123", r.FormValue("client_key"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"tt-access","refresh_token":"tt-refresh","expires_in":86400,"open_id":"tt-open-id-1"}`))
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||
c.TokenURL = tokenServer.URL
|
||||
|
||||
tok, err := c.Exchange(context.Background(), "der-code")
|
||||
if err != nil {
|
||||
t.Fatalf("Exchange: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "tt-access" || tok.RefreshToken != "tt-refresh" || tok.PlatformUserID != "tt-open-id-1" {
|
||||
t.Errorf("unexpected token: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokExchangePropagatesPlatformError(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TikTok liefert Fehler oft mit Status 200, Fehlerfeldern im Body.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"error":"invalid_grant","error_description":"code abgelaufen"}`))
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||
c.TokenURL = tokenServer.URL
|
||||
|
||||
_, err := c.Exchange(context.Background(), "abgelaufener-code")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the platform response contains an error field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_grant") {
|
||||
t.Errorf("error = %v, want it to mention invalid_grant", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user