150 lines
3.5 KiB
Go
150 lines
3.5 KiB
Go
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
|
|
}
|