package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strings" "sync" "time" ) type keycloakTokenResponse struct { AccessToken string `json:"access_token"` } type keycloakTokenManager struct { cfg Config client *http.Client mu sync.Mutex token string refreshCh chan struct{} lastErr error } func newKeycloakTokenManager(cfg Config, client *http.Client) *keycloakTokenManager { return &keycloakTokenManager{ cfg: cfg, client: client, token: strings.TrimSpace(cfg.AccessToken), } } func (m *keycloakTokenManager) CurrentToken() string { m.mu.Lock() defer m.mu.Unlock() return strings.TrimSpace(m.token) } func (m *keycloakTokenManager) EnsureToken(ctx context.Context, reason string) (string, error) { if token := m.CurrentToken(); token != "" { return token, nil } return m.Refresh(ctx, reason) } func (m *keycloakTokenManager) Refresh(ctx context.Context, reason string) (string, error) { m.mu.Lock() if ch := m.refreshCh; ch != nil { m.mu.Unlock() select { case <-ctx.Done(): return "", ctx.Err() case <-ch: m.mu.Lock() token := strings.TrimSpace(m.token) err := m.lastErr m.mu.Unlock() if err != nil { return "", err } if token == "" { return "", fmt.Errorf("keycloak token refresh completed without access_token") } return token, nil } } ch := make(chan struct{}) m.refreshCh = ch m.mu.Unlock() log.Printf("[auth] keycloak_token_refresh_start reason=%s endpoint=%s", reason, m.cfg.KeycloakTokenEndpoint) start := time.Now() token, err := m.fetchToken(ctx) if err != nil { log.Printf("[auth] keycloak_token_refresh_failed reason=%s endpoint=%s err=%v", reason, m.cfg.KeycloakTokenEndpoint, err) } else { log.Printf( "[auth] keycloak_token_refresh_ok reason=%s endpoint=%s elapsed=%s", reason, m.cfg.KeycloakTokenEndpoint, time.Since(start).Truncate(time.Millisecond), ) } m.mu.Lock() if err == nil { m.token = token } m.lastErr = err close(ch) m.refreshCh = nil currentToken := strings.TrimSpace(m.token) m.mu.Unlock() if err != nil { return "", err } return currentToken, nil } func (m *keycloakTokenManager) fetchToken(ctx context.Context) (string, error) { form := url.Values{} form.Set("grant_type", "password") form.Set("client_id", strings.TrimSpace(m.cfg.KeycloakClientID)) form.Set("username", strings.TrimSpace(m.cfg.KeycloakUsername)) form.Set("password", m.cfg.KeycloakPassword) scope := strings.TrimSpace(m.cfg.KeycloakScope) if scope != "" { form.Set("scope", scope) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(m.cfg.KeycloakTokenEndpoint), strings.NewReader(form.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := m.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("keycloak token request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } var tokenResp keycloakTokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return "", fmt.Errorf("keycloak token parse failed: %w", err) } token := strings.TrimSpace(tokenResp.AccessToken) if token == "" { return "", fmt.Errorf("keycloak token response missing access_token") } return token, nil }