Skip to content

Commit ef05bd7

Browse files
authored
feat: add OAuth2 password grant support for event generator log-cache access (#1265)
* feat: add OAuth2 password grant support for event generator log-cache access - Add password grant support to CF client configuration - New cf_oauth2_client for event generator to authenticate via resource owner password grant - Prevent thundering herd in token refresh with sync.Once pattern - Update fetcher factory to support password grant credentials - Add UaaCreds model for password grant configuration * refactor: simplify oauth2 password grant code - Inline validateAPI() into Validate() — single call site, no reuse benefit - Precompute tokenURL and basicAuthHeader in CFOauth2HTTPClient constructor - Use models.GrantTypePassword constant instead of hardcoded "password" string - Add missing tests: UAACreds.IsPasswordGrant and introspect Basic auth header * fix: resolve reviewdog CI failure - Remove extra blank line at cf/cfclient_wrapper_test.go:281 that caused gofmt formatting violation * fix: validate cached token in race condition path In forceRefreshToken, when another goroutine has already changed the token, validate it (non-empty and not expired) before returning. If invalid, refresh instead of returning a potentially stale/empty token. * fix: remove sensitive data from token error messages UAA error responses may contain access tokens, client IDs, or other sensitive information. Only log the HTTP status code, not the response body. * docs: add UAA password grant fields to eventgenerator default config Adds uaa section with grant_type, username, and password fields for discoverability. Shows the available configuration options without requiring code inspection. * fix: prevent negative token expiry for short-lived tokens When expires_in <= 30, subtracting the 30s buffer creates a negative duration causing immediate re-fetch loops. Now uses half the token lifetime as buffer for short-lived tokens instead. * docs: add eventgenerator README with password grant documentation Documents both client_credentials and password grant authentication modes for Log Cache access, including example configs and field descriptions. * refactor: harden OAuth2 client based on review feedback - Validate empty access_token and invalid expires_in from token response - Add lager.Logger for observability (token refresh, 401, race condition) - Parse UAA error/error_description in failure responses (non-sensitive) - Wrap retry error with context - Add startup validation for password grant credentials - Fix README inaccuracies (CAS→mutex, buffer logic, URL field) - Default config uses empty grant_type to avoid accidental activation * fix: pass logger to NewCFOauth2HTTPClient in fetcher_factory_test * chore: update devbox.lock plugin versions to 0.0.5 * fix: resolve unit test failures - fetcher_factory_test: avoid deep-comparing logger instances, verify client creation through call count and non-nil assertions instead - cf_oauth2_client_test: use expires_in=31 (effective 1s after 30s buffer) with 1.1s sleep to properly test token expiry refresh
1 parent df59e0a commit ef05bd7

16 files changed

Lines changed: 1029 additions & 43 deletions

api/config/config_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,10 @@ var _ = Describe("Config", func() {
209209
Expect(conf.InfoFilePath).To(Equal("/var/vcap/jobs/autoscaer/config/info-file.json"))
210210
Expect(conf.CF).To(Equal(
211211
cf.Config{
212-
API: "https://api.example.com",
213-
ClientID: "client-id",
214-
Secret: "client-secret",
212+
API: "https://api.example.com",
213+
ClientID: "client-id",
214+
Secret: "client-secret",
215+
GrantType: "client_credentials",
215216
ClientConfig: cf.ClientConfig{
216217
SkipSSLValidation: false,
217218
MaxRetries: 3,

cf/cfclient_wrapper.go

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cf
33
import (
44
"context"
55
"crypto/tls"
6+
"encoding/base64"
67
"encoding/json"
78
"errors"
89
"fmt"
@@ -59,11 +60,16 @@ func NewCFClientWrapper(conf *Config, logger lager.Logger, opts ...WrapperOption
5960
}
6061

6162
options := []config.Option{
62-
config.ClientCredentials(conf.ClientID, conf.Secret),
6363
config.UserAgent(GetUserAgent()),
6464
config.HttpClient(httpClient),
6565
}
6666

67+
if conf.IsPasswordGrant() {
68+
options = append(options, config.UserPassword(conf.Username, conf.Password))
69+
} else {
70+
options = append(options, config.ClientCredentials(conf.ClientID, conf.Secret))
71+
}
72+
6773
cfg, err := config.New(conf.API, options...)
6874
if err != nil {
6975
return nil, fmt.Errorf("failed to create cfclient config: %w", err)
@@ -82,7 +88,6 @@ func NewCFClientWrapper(conf *Config, logger lager.Logger, opts ...WrapperOption
8288
}, nil
8389
}
8490

85-
// createConfiguredHTTPClient creates an HTTP client with retry logic and connection pool settings.
8691
func createConfiguredHTTPClient(conf *Config, logger lager.Logger) *http.Client {
8792
transport := &http.Transport{
8893
DialContext: (&net.Dialer{Timeout: defaultDialTimeout}).DialContext,
@@ -100,7 +105,6 @@ func createConfiguredHTTPClient(conf *Config, logger lager.Logger) *http.Client
100105
}
101106

102107
func (w *CFClientWrapper) Login(ctx context.Context) error {
103-
// Verify credentials by making a test API call
104108
// go-cfclient handles token management internally
105109
_, err := w.cfClient.Root.Get(ctx)
106110
if err != nil {
@@ -169,31 +173,14 @@ func (w *CFClientWrapper) getUaaURL(ctx context.Context) (string, error) {
169173
return strings.TrimSuffix(endpoints.Uaa.Url, "/"), nil
170174
}
171175

172-
// doAuthRequest executes an HTTP request using go-cfclient's authenticated HTTP client,
173-
// which automatically adds a Bearer token via the oauth2 transport.
174-
func (w *CFClientWrapper) doAuthRequest(req *http.Request, result any) error {
175-
req.Header.Set("User-Agent", GetUserAgent())
176-
// #nosec G704 -- UAA URL is fetched from trusted CF API endpoints
177-
resp, err := w.cfClient.HTTPAuthClient().Do(req)
178-
if err != nil {
179-
return err
180-
}
181-
return parseUaaResponse(resp, result)
182-
}
183-
184176
// doUaaRequest executes an HTTP request using the wrapper's HTTP client directly.
185-
// Used for requests that provide their own authorization (e.g. user Bearer tokens).
186177
func (w *CFClientWrapper) doUaaRequest(req *http.Request, result any) error {
187178
req.Header.Set("User-Agent", GetUserAgent())
188179
// #nosec G704 -- UAA URL is fetched from trusted CF API endpoints
189180
resp, err := w.httpClient.Do(req)
190181
if err != nil {
191182
return err
192183
}
193-
return parseUaaResponse(resp, result)
194-
}
195-
196-
func parseUaaResponse(resp *http.Response, result any) error {
197184
defer resp.Body.Close()
198185

199186
if resp.StatusCode == http.StatusUnauthorized {
@@ -229,8 +216,11 @@ func (w *CFClientWrapper) introspectToken(ctx context.Context, token string) (*I
229216

230217
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
231218

219+
credentials := base64.StdEncoding.EncodeToString([]byte(w.conf.ClientID + ":" + w.conf.Secret))
220+
req.Header.Set("Authorization", "Basic "+credentials)
221+
232222
var result IntrospectionResponse
233-
if err := w.doAuthRequest(req, &result); err != nil {
223+
if err := w.doUaaRequest(req, &result); err != nil {
234224
return nil, fmt.Errorf("introspect token failed: %w", err)
235225
}
236226
return &result, nil

cf/cfclient_wrapper_test.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cf_test
22

33
import (
44
"context"
5+
"encoding/base64"
56
"encoding/json"
67
"net/http"
78

@@ -62,7 +63,7 @@ var _ = Describe("CFClientWrapper", func() {
6263
createClient = false
6364
})
6465

65-
It("creates a client successfully", func() {
66+
It("creates a client successfully with client credentials", func() {
6667
mockServer.Add().OauthToken("test-access-token")
6768
mockServer.Add().Info(mockServer.URL())
6869

@@ -72,6 +73,26 @@ var _ = Describe("CFClientWrapper", func() {
7273
Expect(client).NotTo(BeNil())
7374
})
7475

76+
It("creates a client successfully with password grant", func() {
77+
conf.GrantType = cf.GrantTypePassword
78+
conf.Username = "test-user"
79+
conf.Password = "test-password"
80+
conf.ClientID = "cf"
81+
conf.Secret = ""
82+
83+
mockServer.Add().OauthToken("test-password-grant-token")
84+
mockServer.Add().Info(mockServer.URL())
85+
86+
var err error
87+
client, err = cf.NewCFClient(conf, logger, cf.WithHTTPClient(mockServer.HTTPTestServer.Client()))
88+
Expect(err).NotTo(HaveOccurred())
89+
Expect(client).NotTo(BeNil())
90+
91+
// Verify the client can login (which validates the token was obtained)
92+
err = client.Login(ctx)
93+
Expect(err).NotTo(HaveOccurred())
94+
})
95+
7596
It("returns error for invalid API URL", func() {
7697
conf.API = "://invalid-url"
7798
_, err := cf.NewCFClient(conf, logger, cf.WithHTTPClient(mockServer.HTTPTestServer.Client()))
@@ -240,6 +261,24 @@ var _ = Describe("CFClientWrapper", func() {
240261
Expect(isAuthorized).To(BeFalse())
241262
})
242263

264+
It("sends Basic auth header with client credentials to introspect", func() {
265+
var capturedAuth string
266+
mockServer.RouteToHandler(http.MethodPost, "/introspect",
267+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268+
capturedAuth = r.Header.Get("Authorization")
269+
RespondWithJSON(http.StatusOK, map[string]any{
270+
"active": true,
271+
"client_id": "expected-client",
272+
})(w, r)
273+
}))
274+
275+
_, err := client.IsTokenAuthorized(ctx, "some-token", "expected-client")
276+
Expect(err).NotTo(HaveOccurred())
277+
278+
expectedCreds := base64.StdEncoding.EncodeToString([]byte(conf.ClientID + ":" + conf.Secret))
279+
Expect(capturedAuth).To(Equal("Basic " + expectedCreds))
280+
})
281+
243282
It("returns false when token is inactive", func() {
244283
mockServer.RouteToHandler(http.MethodPost, "/introspect",
245284
RespondWithJSON(http.StatusOK, map[string]any{

cf/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import (
1010
"github.com/hashicorp/go-retryablehttp"
1111
)
1212

13+
const (
14+
GrantTypeClientCredentials = "client_credentials"
15+
GrantTypePassword = "password"
16+
)
17+
1318
type (
1419
Guid = models.GUID
1520

cf/config.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ type Config struct {
1919
API string `yaml:"api" json:"api"`
2020
ClientID string `yaml:"client_id" json:"client_id"`
2121
Secret string `yaml:"secret" json:"secret"`
22+
GrantType string `yaml:"grant_type" json:"grant_type"`
23+
Username string `yaml:"username" json:"username"`
24+
Password string `yaml:"password" json:"password"`
25+
}
26+
27+
func (conf *Config) IsPasswordGrant() bool {
28+
return conf.GrantType == GrantTypePassword
2229
}
2330

2431
func (conf *Config) Validate() error {
@@ -43,9 +50,31 @@ func (conf *Config) Validate() error {
4350
apiURL.Path = strings.TrimSuffix(apiURL.Path, "/")
4451
conf.API = apiURL.String()
4552

53+
if conf.IsPasswordGrant() {
54+
return conf.validatePasswordGrant()
55+
}
56+
return conf.validateClientCredentials()
57+
}
58+
59+
func (conf *Config) validatePasswordGrant() error {
60+
if conf.Username == "" {
61+
return fmt.Errorf("Configuration error: username is empty for password grant")
62+
}
63+
if conf.Password == "" {
64+
return fmt.Errorf("Configuration error: password is empty for password grant")
65+
}
66+
if conf.ClientID == "" {
67+
conf.ClientID = "cf"
68+
}
69+
return nil
70+
}
71+
72+
func (conf *Config) validateClientCredentials() error {
73+
if conf.GrantType == "" {
74+
conf.GrantType = GrantTypeClientCredentials
75+
}
4676
if conf.ClientID == "" {
4777
return fmt.Errorf("Configuration error: client_id is empty")
4878
}
49-
5079
return nil
5180
}

cf/config_test.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ var _ = Describe("Config", func() {
1919
objectbytes string
2020
)
2121
JustBeforeEach(func() {
22-
err = yaml.Unmarshal([]byte(objectbytes), &conf)
22+
conf = &cf.Config{}
23+
err = yaml.Unmarshal([]byte(objectbytes), conf)
2324
})
2425
Context("Given a valid configuration", func() {
2526
BeforeEach(func() {
@@ -144,5 +145,40 @@ idle_connection_timeout_ms: 200
144145
})
145146
})
146147

148+
DescribeTable("password grant validation",
149+
func(username, password, clientID, expectedClientID, expectedErr string) {
150+
conf.GrantType = cf.GrantTypePassword
151+
conf.Username = username
152+
conf.Password = password
153+
conf.ClientID = clientID
154+
err = conf.Validate()
155+
if expectedErr != "" {
156+
Expect(err).To(MatchError(expectedErr))
157+
} else {
158+
Expect(err).NotTo(HaveOccurred())
159+
Expect(conf.ClientID).To(Equal(expectedClientID))
160+
}
161+
},
162+
Entry("sets default client_id to 'cf' when empty",
163+
"test-user", "test-password", "", "cf", ""),
164+
Entry("preserves explicit client_id",
165+
"test-user", "test-password", "custom-client", "custom-client", ""),
166+
Entry("errors when username is empty",
167+
"", "test-password", "", "", "Configuration error: username is empty for password grant"),
168+
Entry("errors when password is empty",
169+
"test-user", "", "", "", "Configuration error: password is empty for password grant"),
170+
)
171+
172+
Context("when grant_type is not set", func() {
173+
BeforeEach(func() {
174+
conf.GrantType = ""
175+
})
176+
177+
It("should default to client_credentials", func() {
178+
Expect(err).NotTo(HaveOccurred())
179+
Expect(conf.GrantType).To(Equal(cf.GrantTypeClientCredentials))
180+
})
181+
})
182+
147183
})
148184
})

devbox.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,7 +1531,7 @@
15311531
},
15321532
"mysql@10.6.12": {
15331533
"last_modified": "2023-02-24T09:01:09Z",
1534-
"plugin_version": "0.0.4",
1534+
"plugin_version": "0.0.5",
15351535
"resolved": "github:NixOS/nixpkgs/7d0ed7f2e5aea07ab22ccb338d27fbe347ed2f11#mysql",
15361536
"source": "devbox-search",
15371537
"version": "10.6.12"
@@ -1915,7 +1915,7 @@
19151915
},
19161916
"python@3.14.4": {
19171917
"last_modified": "2026-05-21T08:15:18Z",
1918-
"plugin_version": "0.0.4",
1918+
"plugin_version": "0.0.5",
19191919
"resolved": "github:NixOS/nixpkgs/4a29d733e8a7d5b824c3d8c958a946a9867b3eb2#python314",
19201920
"source": "devbox-search",
19211921
"version": "3.14.4",

eventgenerator/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Event Generator
2+
3+
The Event Generator polls metrics from CF Log Cache, aggregates them, evaluates scaling rules, and triggers scaling events when thresholds are breached.
4+
5+
## Log Cache Authentication
6+
7+
The Event Generator needs to authenticate with CF's Log Cache to read application metrics. Two authentication modes are supported:
8+
9+
### Client Credentials (default)
10+
11+
Uses a UAA client with `client_credentials` grant. The client needs the `logs.admin` authority. This mode uses the standard go-log-cache OAuth2 client internally.
12+
13+
```yaml
14+
uaa:
15+
url: https://uaa.sys.example.com
16+
client_id: autoscaler_client
17+
client_secret: my-secret
18+
skip_ssl_validation: false
19+
```
20+
21+
### Password Grant
22+
23+
Uses the `password` grant type with CF user credentials. This is useful when a dedicated UAA client with `logs.admin` is not available — instead, an org manager user with Log Cache access can be used.
24+
25+
The default client ID is `cf` (CF's built-in public UAA client with an empty secret), matching `cf login` behavior.
26+
27+
```yaml
28+
uaa:
29+
url: https://uaa.sys.example.com
30+
client_id: cf
31+
client_secret: ""
32+
grant_type: password
33+
username: org-manager@example.com
34+
password: my-password
35+
skip_ssl_validation: false
36+
```
37+
38+
**Required fields for password grant:**
39+
- `url` — UAA base URL (e.g., `https://uaa.sys.example.com`); `/oauth/token` is appended automatically
40+
- `grant_type` — must be `password`
41+
- `username` — CF user with access to app metrics via Log Cache
42+
- `password` — user password
43+
44+
**Optional fields:**
45+
- `client_id` — defaults to `cf` if empty
46+
- `client_secret` — empty for the `cf` client (public client)
47+
- `skip_ssl_validation` — defaults to `false`
48+
49+
### How it works
50+
51+
The password grant OAuth2 client:
52+
1. Authenticates using HTTP Basic auth header (`client_id:client_secret`) with username/password in the request body
53+
2. Caches the access token until shortly before expiry (30-second buffer, or half the token lifetime for short-lived tokens)
54+
3. Automatically refreshes on 401 responses with stale-token detection under lock to prevent thundering herd
55+
4. Retries once after a forced token refresh
56+
57+
## Configuration
58+
59+
See [`default_config.json`](./default_config.json) for all available configuration options.

eventgenerator/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ func (c *Config) validateMetricCollector() error {
201201
if c.MetricCollector.MetricCollectorURL == "" {
202202
return fmt.Errorf("Configuration error: metricCollector.metric_collector_url is empty")
203203
}
204+
uaa := c.MetricCollector.UAACreds
205+
if uaa.IsNotEmpty() && uaa.IsPasswordGrant() {
206+
if uaa.Username == "" {
207+
return fmt.Errorf("Configuration error: metricCollector.uaa.username is empty for password grant")
208+
}
209+
if uaa.Password == "" {
210+
return fmt.Errorf("Configuration error: metricCollector.uaa.password is empty for password grant")
211+
}
212+
}
204213
return nil
205214
}
206215

eventgenerator/default_config.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@
4141
"back_off_initial_interval": "5m",
4242
"back_off_max_interval": "120m",
4343
"consecutive_failure_count": 3
44+
},
45+
"uaa": {
46+
"url": "",
47+
"client_id": "",
48+
"client_secret": "",
49+
"grant_type": "",
50+
"username": "",
51+
"password": "",
52+
"skip_ssl_validation": false
4453
}
4554
}
4655
}

0 commit comments

Comments
 (0)