Skip to content

Commit 90f92d7

Browse files
Merge pull request #705 from jetstack/handle-auth-failures
[VC-43403] CyberArk(identity): remove backoff library and simplify error handling
2 parents 26d6e59 + 55c660e commit 90f92d7

File tree

5 files changed

+216
-39
lines changed

5 files changed

+216
-39
lines changed

pkg/internal/cyberark/identity/identity.go

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ import (
99
"net/http"
1010
"net/url"
1111
"sync"
12-
"time"
1312

14-
"github.com/cenkalti/backoff/v5"
1513
"k8s.io/klog/v2"
1614

1715
"github.com/jetstack/preflight/pkg/logs"
@@ -209,25 +207,17 @@ func (c *Client) LoginUsernamePassword(ctx context.Context, username string, pas
209207
}
210208
}()
211209

212-
operation := func() (any, error) {
213-
advanceRequestBody, err := c.doStartAuthentication(ctx, username)
214-
if err != nil {
215-
return struct{}{}, err
216-
}
217-
218-
// NB: We explicitly pass advanceRequestBody by value here so that when we add the password
219-
// in doAdvanceAuthentication we don't create a copy of the password slice elsewhere.
220-
err = c.doAdvanceAuthentication(ctx, username, &password, advanceRequestBody)
221-
if err != nil {
222-
return struct{}{}, err
223-
}
224-
225-
return struct{}{}, nil
210+
advanceRequestBody, err := c.doStartAuthentication(ctx, username)
211+
if err != nil {
212+
return err
226213
}
227214

228-
backoffPolicy := backoff.NewConstantBackOff(10 * time.Second)
229-
230-
_, err := backoff.Retry(ctx, operation, backoff.WithBackOff(backoffPolicy))
215+
// NB: We explicitly pass advanceRequestBody by value here so that when we add the password
216+
// in doAdvanceAuthentication we don't create a copy of the password slice elsewhere.
217+
err = c.doAdvanceAuthentication(ctx, username, &password, advanceRequestBody)
218+
if err != nil {
219+
return err
220+
}
231221

232222
return err
233223
}
@@ -281,8 +271,7 @@ func (c *Client) doStartAuthentication(ctx context.Context, username string) (ad
281271
}
282272

283273
// If we got a 4xx error, we shouldn't retry
284-
return response, backoff.Permanent(err)
285-
274+
return response, err
286275
}
287276

288277
startAuthResponse := startAuthenticationResponseBody{}
@@ -354,19 +343,19 @@ func (c *Client) doStartAuthentication(ctx context.Context, username string) (ad
354343
// and receiving a token in response.
355344
func (c *Client) doAdvanceAuthentication(ctx context.Context, username string, password *[]byte, requestBody advanceAuthenticationRequestBody) error {
356345
if password == nil {
357-
return backoff.Permanent(fmt.Errorf("password must not be nil; this is a programming error"))
346+
return fmt.Errorf("password must not be nil; this is a programming error")
358347
}
359348

360349
requestBody.Answer = string(*password)
361350

362351
bodyJSON, err := json.Marshal(requestBody)
363352
if err != nil {
364-
return backoff.Permanent(fmt.Errorf("failed to marshal JSON for request to AdvanceAuthentication endpoint: %s", err))
353+
return fmt.Errorf("failed to marshal JSON for request to AdvanceAuthentication endpoint: %s", err)
365354
}
366355

367356
endpoint, err := url.JoinPath(c.baseURL, "Security", "AdvanceAuthentication")
368357
if err != nil {
369-
return backoff.Permanent(fmt.Errorf("failed to create URL for request to CyberArk Identity AdvanceAuthentication: %s", err))
358+
return fmt.Errorf("failed to create URL for request to CyberArk Identity AdvanceAuthentication: %s", err)
370359
}
371360

372361
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON))
@@ -386,13 +375,7 @@ func (c *Client) doAdvanceAuthentication(ctx context.Context, username string, p
386375
// Important: Even login failures can produce a 200 status code, so this
387376
// check won't catch all failures
388377
if httpResponse.StatusCode != http.StatusOK {
389-
err := fmt.Errorf("got unexpected status code %s from request to advance authentication in CyberArk Identity API", httpResponse.Status)
390-
if httpResponse.StatusCode >= 500 || httpResponse.StatusCode < 400 {
391-
return err
392-
}
393-
394-
// If we got a 4xx error, we shouldn't retry
395-
return backoff.Permanent(err)
378+
return fmt.Errorf("got unexpected status code %s from request to advance authentication in CyberArk Identity API", httpResponse.Status)
396379
}
397380

398381
advanceAuthResponse := advanceAuthenticationResponseBody{}
@@ -413,7 +396,7 @@ func (c *Client) doAdvanceAuthentication(ctx context.Context, username string, p
413396
if advanceAuthResponse.Result.Summary != SummaryLoginSuccess {
414397
// IF MFA was enabled and we got here, there's probably nothing to be gained from a retry
415398
// and the best thing to do is fail now so the user can fix MFA settings.
416-
return backoff.Permanent(fmt.Errorf("got a %s response from AdvanceAuthentication; this implies that the user account %s requires MFA, which is not supported. Try unlocking MFA for this user", advanceAuthResponse.Result.Summary, username))
399+
return fmt.Errorf("got a %s response from AdvanceAuthentication; this implies that the user account %s requires MFA, which is not supported. Try unlocking MFA for this user", advanceAuthResponse.Result.Summary, username)
417400
}
418401

419402
klog.FromContext(ctx).Info("successfully completed AdvanceAuthentication request to CyberArk Identity; login complete", "username", username)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package identity
2+
3+
// This file contains tests for the LoginUsernamePassword function in the
4+
// identity package. The tests cover both a mock API server and the real API,
5+
// depending on the environment variables set. The tests are intended to
6+
// demonstrate that the mock API behaves the same as the real API
7+
8+
import (
9+
"net/http"
10+
"os"
11+
"testing"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
"k8s.io/klog/v2"
16+
"k8s.io/klog/v2/ktesting"
17+
18+
"github.com/jetstack/preflight/pkg/internal/cyberark/servicediscovery"
19+
arktesting "github.com/jetstack/preflight/pkg/internal/cyberark/testing"
20+
21+
_ "k8s.io/klog/v2/ktesting/init"
22+
)
23+
24+
// inputs holds the various input values for the tests.
25+
type inputs struct {
26+
httpClient *http.Client
27+
baseURL string
28+
subdomain string
29+
username string
30+
password string
31+
}
32+
33+
// TestLoginUsernamePassword_MockAPI tests the LoginUsernamePassword function
34+
// against a mock API server. The mock server is configured to return different
35+
// responses based on the username and password used in the request.
36+
func TestLoginUsernamePassword_MockAPI(t *testing.T) {
37+
loginUsernamePasswordTests(t, func(t testing.TB) inputs {
38+
baseURL, httpClient := MockIdentityServer(t)
39+
return inputs{
40+
httpClient: httpClient,
41+
baseURL: baseURL,
42+
subdomain: "subdomain-ignored-by-mock",
43+
username: successUser,
44+
password: successPassword,
45+
}
46+
})
47+
}
48+
49+
// TestLoginUsernamePassword_RealAPI tests the LoginUsernamePassword function
50+
// against the real API. The environment variables are used to configure the
51+
// client.
52+
func TestLoginUsernamePassword_RealAPI(t *testing.T) {
53+
arktesting.SkipIfNoEnv(t)
54+
subdomain := os.Getenv("ARK_SUBDOMAIN")
55+
httpClient := http.DefaultClient
56+
services, err := servicediscovery.New(httpClient).DiscoverServices(t.Context(), subdomain)
57+
require.NoError(t, err)
58+
59+
loginUsernamePasswordTests(t, func(t testing.TB) inputs {
60+
return inputs{
61+
httpClient: httpClient,
62+
baseURL: services.Identity.API,
63+
subdomain: subdomain,
64+
username: os.Getenv("ARK_USERNAME"),
65+
password: os.Getenv("ARK_SECRET"),
66+
}
67+
})
68+
}
69+
70+
// loginUsernamePasswordTests runs tests which are expected to pass regardless of
71+
// whether the mock or real API is used.
72+
func loginUsernamePasswordTests(t *testing.T, inputsGenerator func(t testing.TB) inputs) {
73+
type testCase struct {
74+
name string
75+
modifier func(in *inputs)
76+
expectedError string
77+
}
78+
tests := []testCase{
79+
{
80+
name: "success",
81+
},
82+
{
83+
name: "bad-username",
84+
modifier: func(in *inputs) {
85+
in.username = failureUser
86+
},
87+
expectedError: `^got a failure response from request to advance authentication: ` +
88+
`message="Authentication \(login or challenge\) has failed\. ` +
89+
`Please try again or contact your system administrator\."`,
90+
},
91+
{
92+
name: "empty-username",
93+
modifier: func(in *inputs) {
94+
in.username = ""
95+
},
96+
expectedError: `^got a failure response from request to start authentication: ` +
97+
`message="Authentication \(login or challenge\) has failed\. ` +
98+
`Please try again or contact your system administrator\."`,
99+
},
100+
{
101+
name: "bad-password",
102+
modifier: func(in *inputs) {
103+
in.password = "bad-password"
104+
},
105+
expectedError: `^got a failure response from request to advance authentication: ` +
106+
`message="Authentication \(login or challenge\) has failed\. ` +
107+
`Please try again or contact your system administrator\."`,
108+
},
109+
}
110+
for _, test := range tests {
111+
t.Run(test.name, func(t *testing.T) {
112+
logger := ktesting.NewLogger(t, ktesting.DefaultConfig)
113+
ctx := klog.NewContext(t.Context(), logger)
114+
115+
in := inputsGenerator(t)
116+
if test.modifier != nil {
117+
test.modifier(&in)
118+
}
119+
cl := New(in.httpClient, in.baseURL, in.subdomain)
120+
err := cl.LoginUsernamePassword(ctx, in.username, []byte(in.password))
121+
if test.expectedError != "" {
122+
if assert.Error(t, err) {
123+
assert.Regexp(t, test.expectedError, err.Error())
124+
}
125+
return
126+
}
127+
require.NoError(t, err)
128+
})
129+
}
130+
}

pkg/internal/cyberark/identity/mock.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
const (
1919
successUser = "test@example.com"
20+
failureUser = "test-fail@example.com"
2021
successUserMultipleChallenges = "test-multiple-challenges@example.com"
2122
successUserMultipleMechanisms = "test-multiple-mechanisms@example.com"
2223
noUPMechanism = "noup@example.com"
@@ -35,6 +36,9 @@ var (
3536
//go:embed testdata/start_authentication_success.json
3637
startAuthenticationSuccessResponse string
3738

39+
//go:embed testdata/start_authentication_bad_user_session_id.json
40+
startAuthenticationBadUserResponse string
41+
3842
//go:embed testdata/start_authentication_success_multiple_challenges.json
3943
startAuthenticationSuccessMultipleChallengesResponse string
4044

@@ -133,11 +137,6 @@ func (mis *mockIdentityServer) handleStartAuthentication(w http.ResponseWriter,
133137
return
134138
}
135139

136-
// Important: Experimentally, the Identity server we generated the testdata from seems to accept
137-
// any user and provide a success response for StartAuthentication, so filtering on the user here
138-
// doesn't match the server's behavior; however, it's helpful to do so since it lets us test different
139-
// error handling capabilities in the client.
140-
141140
switch reqBody.User {
142141
case successUser:
143142
w.WriteHeader(http.StatusOK)
@@ -160,9 +159,16 @@ func (mis *mockIdentityServer) handleStartAuthentication(w http.ResponseWriter,
160159
w.WriteHeader(http.StatusOK)
161160
_, _ = w.Write([]byte(startAuthenticationFailureResponse))
162161

162+
case failureUser:
163+
// Experimentally, the real API produces a 200 response and what looks
164+
// like a success response body. but the login is rejected later by the
165+
// AdvanceAuthentication stage, perhaps by virtue of the sessionID which
166+
// is returned here and supplied to AdvanceAuthentication.
167+
w.WriteHeader(http.StatusOK)
168+
_, _ = w.Write([]byte(startAuthenticationBadUserResponse))
169+
163170
default:
164-
w.WriteHeader(http.StatusForbidden)
165-
_, _ = w.Write([]byte(`{"message":"MOCK SERVER: invalid user"}`))
171+
panic("programmer error: should not be reached")
166172
}
167173
}
168174

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"success": true,
3+
"Result": {
4+
"ClientHints": {
5+
"PersistDefault": false,
6+
"AllowPersist": true,
7+
"AllowForgotPassword": true,
8+
"EndpointAuthenticationEnabled": false
9+
},
10+
"Version": "1.0",
11+
"SessionId": "bad-user-session-id",
12+
"EventDescription": null,
13+
"RetryWaitingTime": 0,
14+
"SecurityImageName": null,
15+
"AllowLoginMfaCache": false,
16+
"Challenges": [
17+
{
18+
"Mechanisms": [
19+
{
20+
"AnswerType": "Text",
21+
"Name": "UP",
22+
"PromptMechChosen": "Enter Password",
23+
"PromptSelectMech": "Password",
24+
"MechanismId": "aaaaaaa_AAAAAAAAAAAAAAAAAAAAAAAAAAAA-1111111",
25+
"Enrolled": true
26+
}
27+
]
28+
}
29+
],
30+
"Summary": "NewPackage",
31+
"TenantId": "TENANTID"
32+
},
33+
"Message": null,
34+
"MessageID": null,
35+
"Exception": null,
36+
"ErrorID": null,
37+
"ErrorCode": null,
38+
"IsSoftError": false,
39+
"InnerExceptions": null
40+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package testing
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
// SkipIfNoEnv skips the test if the required CyberArk environment variables are not set.
9+
func SkipIfNoEnv(t testing.TB) {
10+
t.Helper()
11+
12+
if os.Getenv("ARK_SUBDOMAIN") == "" ||
13+
os.Getenv("ARK_USERNAME") == "" ||
14+
os.Getenv("ARK_SECRET") == "" {
15+
t.Skip("Skipping test because one of ARK_SUBDOMAIN, ARK_USERNAME or ARK_SECRET isn't set")
16+
}
17+
18+
}

0 commit comments

Comments
 (0)