package keycloak import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" ) // HTTPAdapter implements Adapter against the real Keycloak Admin REST API. // Uses client-credentials grant; an admin-role'd service account on the // realm should be configured. Token is cached and refreshed before expiry. type HTTPAdapter struct { cfg HTTPConfig hc *http.Client // token cache mu sync.Mutex tokenStr string tokenExp time.Time } // HTTPConfig — every value read from env via internal/config. type HTTPConfig struct { BaseURL string // e.g. http://localhost:8080 Realm string // breakpilot-dev | breakpilot-prod ClientID string // service account client id ClientSecret string // service account client secret AdminEmail string // platform admin email — used to gate the BREAKPILOT_ADMIN realm role check Timeout time.Duration } func NewHTTPAdapter(cfg HTTPConfig) *HTTPAdapter { if cfg.Timeout == 0 { cfg.Timeout = 10 * time.Second } return &HTTPAdapter{cfg: cfg, hc: &http.Client{Timeout: cfg.Timeout}} } // ─── auth ──────────────────────────────────────────────────────────────── func (a *HTTPAdapter) token(ctx context.Context) (string, error) { a.mu.Lock() defer a.mu.Unlock() if a.tokenStr != "" && time.Now().Before(a.tokenExp.Add(-30*time.Second)) { return a.tokenStr, nil } form := url.Values{ "grant_type": {"client_credentials"}, "client_id": {a.cfg.ClientID}, "client_secret": {a.cfg.ClientSecret}, } tokenURL := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", a.cfg.BaseURL, a.cfg.Realm) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := a.hc.Do(req) if err != nil { return "", fmt.Errorf("%w: %v", ErrUnavailable, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusUnauthorized { return "", ErrUnauthorized } if resp.StatusCode/100 != 2 { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("keycloak token: %d %s", resp.StatusCode, body) } var tr struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { return "", fmt.Errorf("keycloak token decode: %w", err) } a.tokenStr = tr.AccessToken a.tokenExp = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second) return a.tokenStr, nil } // adminCall is the common request shape against /admin/realms/{realm}/... // On 401/403 it clears the token and tries once more. func (a *HTTPAdapter) adminCall(ctx context.Context, method, path string, body any, into any) (resp *http.Response, err error) { tok, err := a.token(ctx) if err != nil { return nil, err } resp, err = a.doAdmin(ctx, method, path, body, tok) if err != nil { return nil, err } if resp.StatusCode == http.StatusUnauthorized { _ = resp.Body.Close() a.mu.Lock() a.tokenStr = "" // force refresh a.mu.Unlock() if tok, err = a.token(ctx); err != nil { return nil, err } resp, err = a.doAdmin(ctx, method, path, body, tok) if err != nil { return nil, err } } if into != nil && resp.StatusCode/100 == 2 && resp.ContentLength != 0 { defer func() { _ = resp.Body.Close() }() if err := json.NewDecoder(resp.Body).Decode(into); err != nil && !errors.Is(err, io.EOF) { return nil, fmt.Errorf("decode response: %w", err) } } return resp, nil } func (a *HTTPAdapter) doAdmin(ctx context.Context, method, path string, body any, tok string) (*http.Response, error) { u := fmt.Sprintf("%s/admin/realms/%s%s", a.cfg.BaseURL, a.cfg.Realm, path) var bodyR io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { return nil, err } bodyR = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(ctx, method, u, bodyR) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+tok) if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", "application/json") resp, err := a.hc.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) } return resp, nil } // Health pings /admin/serverinfo (cheap, returns 200 on a working install). func (a *HTTPAdapter) Health(ctx context.Context) error { tok, err := a.token(ctx) if err != nil { return err } u := fmt.Sprintf("%s/admin/serverinfo", a.cfg.BaseURL) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := a.hc.Do(req) if err != nil { return fmt.Errorf("%w: %v", ErrUnavailable, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode/100 != 2 { return fmt.Errorf("keycloak health: %d", resp.StatusCode) } return nil }