// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton- // Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten // OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes // Cookie im Handler), daher hält dieses Paket keinen Request-State. package oidc import ( "context" "errors" "github.com/jackc/pgx/v5/pgxpool" "git.netcell-it.de/projekte/edgeguard-native/internal/models" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" ) // ErrDisabled signalisiert, dass OIDC nicht aktiviert/konfiguriert ist. var ErrDisabled = errors.New("oidc: not enabled") // Repo liest/schreibt die oidc_settings-Singleton-Row und ver-/entschlüsselt // das Client-Secret via secrets.Box. type Repo struct { pool *pgxpool.Pool box *secrets.Box } func New(pool *pgxpool.Pool, box *secrets.Box) *Repo { return &Repo{pool: pool, box: box} } // Get liefert die Settings (client_secret_enc als Bytes, NULL → nil). func (r *Repo) Get(ctx context.Context) (*models.OIDCSettings, error) { var s models.OIDCSettings if err := r.pool.QueryRow(ctx, ` SELECT id, enabled, issuer_url, client_id, client_secret_enc, scopes, email_claim, button_label, created_at, updated_at FROM oidc_settings WHERE id = 1`).Scan( &s.ID, &s.Enabled, &s.IssuerURL, &s.ClientID, &s.ClientSecretEnc, &s.Scopes, &s.EmailClaim, &s.ButtonLabel, &s.CreatedAt, &s.UpdatedAt, ); err != nil { return nil, err } return &s, nil } // ClientSecret entschlüsselt das gespeicherte Client-Secret ("" wenn keins). func (r *Repo) ClientSecret(ctx context.Context) (string, error) { s, err := r.Get(ctx) if err != nil { return "", err } if len(s.ClientSecretEnc) == 0 { return "", nil } pt, err := r.box.Open(s.ClientSecretEnc) if err != nil { return "", err } return string(pt), nil } // HasSecret meldet, ob ein Client-Secret hinterlegt ist (für die Admin-UI, // ohne das Secret selbst preiszugeben). func (r *Repo) HasSecret(ctx context.Context) (bool, error) { var present bool err := r.pool.QueryRow(ctx, `SELECT client_secret_enc IS NOT NULL FROM oidc_settings WHERE id = 1`).Scan(&present) return present, err } // UpdateInput beschreibt eine Settings-Änderung. ClientSecret nutzt // write-only-Semantik: nil = unverändert, "" = löschen, sonst neu sealen. type UpdateInput struct { Enabled bool IssuerURL string ClientID string Scopes string EmailClaim string ButtonLabel string ClientSecret *string } // Update schreibt die Settings. Das Secret wird nur angefasst, wenn // ClientSecret != nil. func (r *Repo) Update(ctx context.Context, in UpdateInput) error { if in.ClientSecret == nil { _, err := r.pool.Exec(ctx, ` UPDATE oidc_settings SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4, email_claim=$5, button_label=$6, updated_at=NOW() WHERE id=1`, in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel) return err } var enc []byte if *in.ClientSecret != "" { sealed, err := r.box.Seal([]byte(*in.ClientSecret)) if err != nil { return err } enc = sealed } _, err := r.pool.Exec(ctx, ` UPDATE oidc_settings SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4, email_claim=$5, button_label=$6, client_secret_enc=$7, updated_at=NOW() WHERE id=1`, in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel, enc) return err }