diff --git a/config.nix b/config.nix index ac5c8724..86c52869 100644 --- a/config.nix +++ b/config.nix @@ -3,7 +3,7 @@ exporter-cli = { name = "xpcf"; version = "0.0.1-alpha2"; - vendorHash = "sha256-uvkCb+vg6IznyBZvalPjhAMXMZ1vqcoyAondLdYvQbU="; + vendorHash = "sha256-c5i9v5zDqjkVn8qA9kQQs4bPuw6hELkWFUquGrarXiA="; # vendorHash = lib.fakeHash; meta = { description = "xpcf is a CLI tool for exporting existing resources as Crossplane managed resources"; diff --git a/go.mod b/go.mod index 075a4ad2..05b5c69f 100644 --- a/go.mod +++ b/go.mod @@ -168,8 +168,8 @@ require ( go.uber.org/zap v1.27.1 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.58.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect + golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/internal/clients/cf_auth_cache.go b/internal/clients/cf_auth_cache.go new file mode 100644 index 00000000..29ea875b --- /dev/null +++ b/internal/clients/cf_auth_cache.go @@ -0,0 +1,215 @@ +/* +Copyright 2023 SAP SE +*/ + +package clients + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/cloudfoundry/go-cfclient/v3/config" + "golang.org/x/oauth2" + "golang.org/x/sync/singleflight" +) + +// cfAuthCache reuses one CF UAA login per credential across all reconciles. +// +// GetCredentialConfig runs on every Connect (i.e. every reconcile) for every CF +// resource, and config.New(UserPassword(...)) performs an eager password-grant +// login against CF UAA. Reference resolvers (space/org/domain ResolveByName) build +// a client again within the same reconcile, so a single reconcile can trigger +// several logins. Concurrent per-identity password logins are the documented +// lockout trigger for the shared technical user. +// +// We cache the *login* — a goroutine-safe oauth2.TokenSource — not the +// *config.Config. A config carries a mutable, shared transport: on a 401 +// go-cfclient's retryableAuthTransport re-runs the password grant and reassigns +// oauth2.Transport.Source without synchronization, so sharing one config across +// controllers races and can stampede logins. Instead every reconcile builds its +// own short-lived config from the shared token source, so each has its own +// transport, and token expiry refreshes via the refresh_token grant — never the +// password grant. +// +// The key intentionally omits Passcode: only UserPassword is used, and a passcode +// is a one-time code that must not be cached. If passcode auth is ever wired into +// GetCredentialConfig it must bypass this cache. + +// cfAuthEntry is the cached, reusable login for one credential. +type cfAuthEntry struct { + src oauth2.TokenSource // oauth2.ReuseTokenSource: goroutine-safe, refreshes via refresh_token + loginURL string // discovered CF login endpoint + uaaURL string // discovered CF UAA endpoint +} + +var ( + cfAuthCache sync.Map // map[string]*cfAuthEntry + cfAuthSF singleflight.Group +) + +// bootstrapTimeout bounds the one-time discovery+login per credential. +const bootstrapTimeout = 30 * time.Second + +// cfAuthKey identifies a credential bundle. url+email+password only (see doc above). +func cfAuthKey(url, email, password string) string { + return strings.Join([]string{url, email, password}, "\x00") +} + +// cachedCFConfig returns a go-cfclient config for the credential, reusing a single +// UAA login across reconciles. Each call returns a fresh *config.Config seeded with +// the current token (grant type refresh_token, so config.New performs no login) and +// the pre-discovered auth URLs (so config.New performs no discovery GET). The build +// is therefore fully local. +func cachedCFConfig(ctx context.Context, url, email, password string) (*config.Config, error) { + key := cfAuthKey(url, email, password) + + entry, err := getOrBootstrap(ctx, key, url, email, password) + if err != nil { + return nil, err + } + + tok, err := entry.src.Token() + if err != nil { + // The refresh token is dead/revoked (or bootstrap produced a token that + // can no longer refresh). Drop this wedged entry and bootstrap once more + // with a fresh password login, then retry a single time. + // + // CompareAndDelete (not Delete) so we only evict the exact bad entry we + // observed: when N reconciles share a dying token source they all fail + // here at once, and an unconditional Delete would let a late caller wipe + // the fresh entry an earlier one just re-bootstrapped. We also must not + // Forget the singleflight key — leaving it lets the concurrent + // re-bootstraps coalesce into a single password login instead of each + // starting its own. + cfAuthCache.CompareAndDelete(key, entry) + entry, err = getOrBootstrap(ctx, key, url, email, password) + if err != nil { + return nil, err + } + tok, err = entry.src.Token() + if err != nil { + // Still unusable: drop this entry (again only if it's still the one + // we saw) so the next reconcile starts clean rather than reusing a + // known-bad login. + cfAuthCache.CompareAndDelete(key, entry) + return nil, err + } + } + + return config.New(url, + config.Token(tok.AccessToken, tok.RefreshToken), + config.AuthTokenURL(entry.loginURL, entry.uaaURL), + config.SkipTLSValidation(), + ) +} + +// getOrBootstrap returns the cached entry for key, creating it (once, coalesced +// across concurrent callers) via a single password login if absent. +func getOrBootstrap(ctx context.Context, key, url, email, password string) (*cfAuthEntry, error) { + if e, ok := cfAuthCache.Load(key); ok { + return e.(*cfAuthEntry), nil + } + + v, err, _ := cfAuthSF.Do(key, func() (interface{}, error) { + // Another caller may have populated the cache while we queued. + if e, ok := cfAuthCache.Load(key); ok { + return e.(*cfAuthEntry), nil + } + entry, err := bootstrapCFAuth(ctx, url, email, password) + if err != nil { + // Do not cache a failed login; the next reconcile retries + // (controller-runtime already backs off failing reconciles). + return nil, err + } + cfAuthCache.Store(key, entry) + return entry, nil + }) + if err != nil { + return nil, err + } + return v.(*cfAuthEntry), nil +} + +// bootstrapCFAuth performs the one login per credential: discover the CF auth +// endpoints, then obtain a refreshing token source via the password grant. +func bootstrapCFAuth(ctx context.Context, url, email, password string) (*cfAuthEntry, error) { + // The bootstrap is shared across reconciles (singleflight) and the resulting + // token source lives on to refresh long after this call returns, so it must + // not be tied to the triggering reconcile's cancellation. WithoutCancel keeps + // the request values but drops cancellation/deadline. + ctx = context.WithoutCancel(ctx) + + httpClient := &http.Client{ + Timeout: bootstrapTimeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // mirrors config.SkipTLSValidation(); making TLS verification configurable is tracked in #348 + }, + } + + loginURL, uaaURL, err := discoverCFAuthEndpoints(ctx, httpClient, url) + if err != nil { + return nil, err + } + + oauthCfg := &oauth2.Config{ + ClientID: "cf", + Endpoint: oauth2.Endpoint{ + AuthURL: loginURL + "/oauth/auth", + TokenURL: uaaURL + "/oauth/token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + } + + // oauth2's ReuseTokenSource captures this context for every future refresh. + authCtx := context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + tok, err := oauthCfg.PasswordCredentialsToken(authCtx, email, password) + if err != nil { + return nil, fmt.Errorf("cloudfoundry UAA login failed: %w", err) + } + + return &cfAuthEntry{ + src: oauthCfg.TokenSource(authCtx, tok), + loginURL: loginURL, + uaaURL: uaaURL, + }, nil +} + +// discoverCFAuthEndpoints reads the CF API root and returns the login and UAA +// endpoints — the same discovery go-cfclient performs internally. +func discoverCFAuthEndpoints(ctx context.Context, httpClient *http.Client, url string) (loginURL, uaaURL string, err error) { + root := strings.TrimRight(url, "/") + "/" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, root, nil) + if err != nil { + return "", "", err + } + resp, err := httpClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("error while discovering CF auth endpoints: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + var body struct { + Links struct { + Login struct { + Href string `json:"href"` + } `json:"login"` + Uaa struct { + Href string `json:"href"` + } `json:"uaa"` + } `json:"links"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", "", fmt.Errorf("error decoding CF API root: %w", err) + } + if body.Links.Login.Href == "" || body.Links.Uaa.Href == "" { + return "", "", fmt.Errorf("CF API root did not advertise login/uaa endpoints") + } + return strings.TrimRight(body.Links.Login.Href, "/"), strings.TrimRight(body.Links.Uaa.Href, "/"), nil +} diff --git a/internal/clients/cf_auth_cache_test.go b/internal/clients/cf_auth_cache_test.go new file mode 100644 index 00000000..729f469a --- /dev/null +++ b/internal/clients/cf_auth_cache_test.go @@ -0,0 +1,274 @@ +/* +Copyright 2023 SAP SE +*/ + +package clients + +// Proves the CF login-reuse cache: cachedCFConfig reuses a single UAA password +// login per credential across reconciles, refreshing via the refresh_token grant +// instead of logging in again. Each reconcile still builds its own *config.Config +// from the shared token source (its own transport — no shared state to race on). +// Non-parallel: shares the package-global cfAuthCache / cfAuthSF. + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + cfv3 "github.com/cloudfoundry/go-cfclient/v3/client" +) + +// mintJWT builds a structurally valid JWT access token with the given expiry. +// go-cfclient's jwt.ToOAuth2Token requires three "."-separated segments and +// base64.RawURLEncoding-decodes the payload for its "exp" claim. +func mintJWT(exp time.Time) string { + b64 := base64.RawURLEncoding.EncodeToString + header := b64([]byte(`{"alg":"none","typ":"JWT"}`)) + payload := b64([]byte(`{"exp":` + strconv.FormatInt(exp.Unix(), 10) + `}`)) + return header + "." + payload + ".sig" +} + +// fakeCFAPI serves CF service discovery (GET /) + the UAA token endpoint, +// counting logins by grant type. +type fakeCFAPI struct { + passwordLogins atomic.Int64 + refreshLogins atomic.Int64 + + // accessTokenExpiry is how far in the future issued access tokens expire. + // A negative value issues already-expired tokens (forces a refresh). + accessTokenExpiry time.Duration + // failRefresh makes the token endpoint reject refresh_token grants. + failRefresh bool + // firstPasswordExpired issues an already-expired access token for the first + // password login and a long-lived one for every subsequent login. Combined + // with failRefresh it models a dead shared token source that a fresh + // password re-login recovers. + firstPasswordExpired bool +} + +func (f *fakeCFAPI) start(t *testing.T) string { + t.Helper() + if f.accessTokenExpiry == 0 { + f.accessTokenExpiry = time.Hour + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth/token" { + _ = r.ParseForm() + grant := r.Form.Get("grant_type") + exp := f.accessTokenExpiry + switch grant { + case "refresh_token": + if f.failRefresh { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "invalid_token"}) + return + } + f.refreshLogins.Add(1) + default: // "password" + n := f.passwordLogins.Add(1) + if f.firstPasswordExpired { + if n == 1 { + exp = -time.Minute // first login's token is already expired + } else { + exp = time.Hour // re-login recovers with a long-lived token + } + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + // expires_in drives oauth2's ReuseTokenSource refresh decision; + // the JWT exp drives go-cfclient's config.Token parsing. Keep both + // consistent. + "access_token": mintJWT(time.Now().Add(exp)), + "token_type": "bearer", + "expires_in": int64(exp.Seconds()), + "refresh_token": "rt", + }) + return + } + // discovery: point login + uaa back at this server + _ = json.NewEncoder(w).Encode(map[string]any{ + "links": map[string]any{ + "login": map[string]string{"href": "http://" + r.Host}, + "uaa": map[string]string{"href": "http://" + r.Host}, + "app_ssh": map[string]any{"meta": map[string]string{"oauth_client": "ssh"}}, + }, + }) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +func resetCFCache() { + cfAuthCache.Range(func(k, _ any) bool { + cfAuthCache.Delete(k) + cfAuthSF.Forget(k.(string)) + return true + }) +} + +// buildClient mirrors ClientFnBuilder: build a config from the cache, then a client. +func buildClient(url, email, password string) error { + cfg, err := cachedCFConfig(context.Background(), url, email, password) + if err != nil { + return err + } + _, err = cfv3.New(cfg) + return err +} + +// N reconciles with the same credential reuse one login. +func TestCFCache_ReusesLogin(t *testing.T) { + resetCFCache() + fake := &fakeCFAPI{} + url := fake.start(t) + for i := 0; i < 50; i++ { + if err := buildClient(url, "u@example.com", "pw"); err != nil { + t.Fatalf("buildClient: %v", err) + } + } + if got := fake.passwordLogins.Load(); got != 1 { + t.Errorf("password logins across 50 reconciles = %d, want 1 (login reused)", got) + } + if got := fake.refreshLogins.Load(); got != 0 { + t.Errorf("refresh logins = %d, want 0 (token still valid)", got) + } +} + +// Concurrent reconciles collapse to one login (singleflight). +func TestCFCache_ConcurrentOneLogin(t *testing.T) { + resetCFCache() + fake := &fakeCFAPI{} + url := fake.start(t) + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { defer wg.Done(); _ = buildClient(url, "u@example.com", "pw") }() + } + wg.Wait() + if got := fake.passwordLogins.Load(); got != 1 { + t.Errorf("password logins across 50 concurrent reconciles = %d, want 1", got) + } +} + +// Distinct credentials (different API URLs) get distinct logins. +func TestCFCache_DistinctCredsDistinctLogins(t *testing.T) { + resetCFCache() + f1, f2 := &fakeCFAPI{}, &fakeCFAPI{} + u1, u2 := f1.start(t), f2.start(t) + if err := buildClient(u1, "u@example.com", "pw"); err != nil { + t.Fatalf("buildClient u1: %v", err) + } + if err := buildClient(u2, "u@example.com", "pw"); err != nil { + t.Fatalf("buildClient u2: %v", err) + } + if g1, g2 := f1.passwordLogins.Load(), f2.passwordLogins.Load(); g1 != 1 || g2 != 1 { + t.Errorf("distinct-credential logins = (%d,%d), want (1,1)", g1, g2) + } +} + +// A failed bootstrap is not cached: the next reconcile retries. +func TestCFCache_FailedLoginNotCached(t *testing.T) { + resetCFCache() + // No server: discovery/login fails -> error, must not be cached. + if _, err := cachedCFConfig(context.Background(), "http://127.0.0.1:1", "u@example.com", "pw"); err == nil { + t.Fatal("expected login error") + } + if _, ok := cfAuthCache.Load(cfAuthKey("http://127.0.0.1:1", "u@example.com", "pw")); ok { + t.Error("failed login must not be cached") + } +} + +// An expired access token recovers via the refresh_token grant (not a new +// password login), and concurrent refreshes are race-free (run under -race). +func TestCFCache_ExpiredTokenRefreshesNoPasswordBurst(t *testing.T) { + resetCFCache() + // Password grant issues an already-expired access token so the shared token + // source must refresh; refresh grant issues a long-lived one. + fake := &fakeCFAPI{accessTokenExpiry: -time.Minute} + url := fake.start(t) + + // Prime the cache (one password login) so concurrent callers hit the shared + // ReuseTokenSource refresh path together. + if err := buildClient(url, "u@example.com", "pw"); err != nil { + t.Fatalf("prime buildClient: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { defer wg.Done(); _ = buildClient(url, "u@example.com", "pw") }() + } + wg.Wait() + + if got := fake.passwordLogins.Load(); got != 1 { + t.Errorf("password logins = %d, want 1 (recovery must use refresh grant, not password)", got) + } + if got := fake.refreshLogins.Load(); got == 0 { + t.Errorf("refresh logins = %d, want >=1 (expired token should refresh)", got) + } +} + +// A dead refresh token triggers exactly one re-bootstrap (fresh password login) +// per reconcile and does not wedge or loop. +func TestCFCache_DeadRefreshTokenRebootstraps(t *testing.T) { + resetCFCache() + // Expired access token forces a refresh, and refresh is rejected -> the + // shared source is unusable, so cachedCFConfig re-bootstraps once. + fake := &fakeCFAPI{accessTokenExpiry: -time.Minute, failRefresh: true} + url := fake.start(t) + + _, err := cachedCFConfig(context.Background(), url, "u@example.com", "pw") + if err == nil { + t.Fatal("expected error: refresh is dead and re-bootstrap still yields an expired token") + } + // One initial bootstrap + exactly one re-bootstrap. + if got := fake.passwordLogins.Load(); got != 2 { + t.Errorf("password logins = %d, want 2 (initial + one re-bootstrap)", got) + } + // The known-bad entry must not linger. + if _, ok := cfAuthCache.Load(cfAuthKey(url, "u@example.com", "pw")); ok { + t.Error("unusable entry must be dropped, not wedged") + } +} + +// When the shared token source dies and many reconciles fail at once, recovery +// must coalesce to a single re-login — not one password login per caller — and +// the freshly recovered entry must not be evicted by a straggler still holding +// the dead one. This is the concurrent counterpart to the re-bootstrap path: +// CompareAndDelete (not Delete) and keeping the singleflight key are what make +// it hold. Run under -race. +func TestCFCache_ConcurrentDeadTokenSingleRelogin(t *testing.T) { + resetCFCache() + // First password login issues an already-expired token and the refresh grant + // is dead, so the shared source fails for every concurrent caller at once; + // the second password login recovers with a long-lived token. + fake := &fakeCFAPI{failRefresh: true, firstPasswordExpired: true} + url := fake.start(t) + + var wg sync.WaitGroup + errs := make([]error, 50) + for i := 0; i < 50; i++ { + wg.Add(1) + go func(i int) { defer wg.Done(); errs[i] = buildClient(url, "u@example.com", "pw") }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("reconcile %d failed; recovery should succeed for all callers: %v", i, err) + } + } + // Exactly one dead bootstrap + one coalesced re-login. More would mean a + // straggler clobbered the recovered entry and forced extra password logins. + if got := fake.passwordLogins.Load(); got != 2 { + t.Errorf("password logins = %d, want 2 (one dead bootstrap + one coalesced re-login)", got) + } +} diff --git a/internal/clients/providerconfig.go b/internal/clients/providerconfig.go index f8039cc1..2f7bcfbf 100644 --- a/internal/clients/providerconfig.go +++ b/internal/clients/providerconfig.go @@ -55,7 +55,10 @@ func GetCredentialConfig(ctx context.Context, client client.Client, mg resource. return nil, errors.Wrap(err, errExtractEndpoint) } - return config.New(*url, config.UserPassword(cred.Email, cred.Password), config.SkipTLSValidation()) + // Reuse a single UAA login per credential so we do not log in on every + // reconcile. cachedCFConfig returns a fresh config built from the shared, + // refreshing token source (see cf_auth_cache.go). + return cachedCFConfig(ctx, *url, cred.Email, cred.Password) } func getProviderConfig(ctx context.Context, client client.Client, mg resource.Managed) (*v1beta1.ProviderConfig, error) {