package kcpolicy import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) type Keycloak struct { cfg *Config hc *http.Client } func NewKeycloak(cfg *Config) *Keycloak { return &Keycloak{ cfg: cfg, hc: &http.Client{Timeout: 5 * time.Second}, } } type tokenResp struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } func (k *Keycloak) token(ctx context.Context) (string, error) { form := url.Values{} form.Set("grant_type", "client_credentials") form.Set("client_id", k.cfg.Keycloak.ClientID) form.Set("client_secret", k.cfg.Keycloak.ClientSecret) u := strings.TrimRight(k.cfg.Keycloak.BaseURL, "/") + "/realms/" + url.PathEscape(k.cfg.Keycloak.Realm) + "/protocol/openid-connect/token" req, _ := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := k.hc.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return "", fmt.Errorf("token http %d: %s", resp.StatusCode, string(b)) } var tr tokenResp if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { return "", err } if tr.AccessToken == "" { return "", fmt.Errorf("empty access_token") } return tr.AccessToken, nil } type kcUser struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` Enabled bool `json:"enabled"` Attrs map[string][]string `json:"attributes"` } func (k *Keycloak) adminGet(ctx context.Context, bearer, path string, q url.Values) ([]kcUser, error) { base := strings.TrimRight(k.cfg.Keycloak.BaseURL, "/") + "/admin/realms/" + url.PathEscape(k.cfg.Keycloak.Realm) + path u := base if q != nil { u += "?" + q.Encode() } req, _ := http.NewRequestWithContext(ctx, "GET", u, nil) req.Header.Set("Authorization", "Bearer "+bearer) resp, err := k.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return nil, fmt.Errorf("admin http %d: %s", resp.StatusCode, string(b)) } var users []kcUser if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { return nil, err } return users, nil } // Find primary email by username (exact if supported) func (k *Keycloak) EmailByUsername(ctx context.Context, username string) (string, bool, error) { bearer, err := k.token(ctx) if err != nil { return "", false, err } q := url.Values{} q.Set("username", username) q.Set("exact", "true") users, err := k.adminGet(ctx, bearer, "/users", q) if err != nil { // fallback: search q2 := url.Values{} q2.Set("search", username) users, err = k.adminGet(ctx, bearer, "/users", q2) if err != nil { return "", false, err } } for _, u := range users { if strings.EqualFold(u.Username, username) && u.Enabled && u.Email != "" { return strings.ToLower(u.Email), true, nil } } return "", false, nil } // Check if an email exists as primary user email func (k *Keycloak) EmailExists(ctx context.Context, email string) (bool, error) { bearer, err := k.token(ctx) if err != nil { return false, err } q := url.Values{} q.Set("email", email) q.Set("exact", "true") users, err := k.adminGet(ctx, bearer, "/users", q) if err != nil { // fallback: search q2 := url.Values{} q2.Set("search", email) users, err = k.adminGet(ctx, bearer, "/users", q2) if err != nil { return false, err } } for _, u := range users { if u.Enabled && strings.EqualFold(u.Email, email) { return true, nil } } return false, nil }