Skip to content

Commit 650ba2c

Browse files
refactor token exchange into auth package
1 parent 70e18f4 commit 650ba2c

File tree

4 files changed

+302
-260
lines changed

4 files changed

+302
-260
lines changed

internal/cmd/ske/kubeconfig/login/login.go

Lines changed: 15 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,9 @@ import (
88
"encoding/pem"
99
"errors"
1010
"fmt"
11-
"io"
1211
"net/http"
13-
"net/url"
1412
"os"
1513
"strconv"
16-
"strings"
1714
"time"
1815

1916
"github.com/spf13/cobra"
@@ -326,24 +323,21 @@ func getAccessToken(params *types.CmdParams) (string, error) {
326323
}
327324

328325
func retrieveTokenFromIDP(ctx context.Context, idpClient *http.Client, accessToken string, clusterConfig *clusterConfig) (string, error) {
329-
tokenEndpoint, err := auth.GetAuthField(auth.IDP_TOKEN_ENDPOINT)
330-
if err != nil {
331-
return "", fmt.Errorf("get idp token endpoint: %w", err)
332-
}
326+
resource := resourceForCluster(clusterConfig)
333327

334328
cachedToken := getCachedToken(clusterConfig.cacheKey)
335329
if cachedToken == "" {
336-
return exchangeToken(ctx, idpClient, tokenEndpoint, accessToken, clusterConfig)
330+
return exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey)
337331
}
338332

339333
expiry, err := auth.TokenExpirationTime(cachedToken)
340334
if err != nil {
341335
// token is expired or invalid, request new
342336
_ = cache.DeleteObject(clusterConfig.cacheKey)
343-
return exchangeToken(ctx, idpClient, tokenEndpoint, accessToken, clusterConfig)
337+
return exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey)
344338
} else if time.Now().Add(refreshTokenBeforeDuration).After(expiry) {
345339
// token expires soon -> refresh
346-
token, err := exchangeToken(ctx, idpClient, tokenEndpoint, accessToken, clusterConfig)
340+
token, err := exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey)
347341
// try to get a new one but use cache on failure
348342
if err != nil {
349343
return cachedToken, nil
@@ -354,69 +348,6 @@ func retrieveTokenFromIDP(ctx context.Context, idpClient *http.Client, accessTok
354348
return cachedToken, nil
355349
}
356350

357-
func getCachedToken(key string) string {
358-
token, err := cache.GetObject(key)
359-
if err != nil {
360-
return ""
361-
}
362-
return string(token)
363-
}
364-
365-
func exchangeToken(ctx context.Context, idpClient *http.Client, tokenEndpoint, accessToken string, config *clusterConfig) (string, error) {
366-
req, err := buildRequestToExchangeTokens(ctx, tokenEndpoint, accessToken, config)
367-
if err != nil {
368-
return "", fmt.Errorf("build request: %w", err)
369-
}
370-
resp, err := idpClient.Do(req)
371-
if err != nil {
372-
return "", fmt.Errorf("call API: %w", err)
373-
}
374-
defer func() {
375-
tempErr := resp.Body.Close()
376-
if tempErr != nil {
377-
err = fmt.Errorf("close response body: %w", tempErr)
378-
}
379-
}()
380-
381-
clusterToken, err := parseTokenExchangeResponse(resp)
382-
if err != nil {
383-
return "", fmt.Errorf("parse API response: %w", err)
384-
}
385-
if err = cache.PutObject(config.cacheKey, []byte(clusterToken)); err != nil {
386-
return "", fmt.Errorf("cache token: %w", err)
387-
}
388-
return clusterToken, err
389-
}
390-
391-
func buildRequestToExchangeTokens(ctx context.Context, tokenEndpoint, accessToken string, config *clusterConfig) (*http.Request, error) {
392-
idpClientID, err := auth.GetIDPClientID()
393-
if err != nil {
394-
return nil, err
395-
}
396-
397-
form := url.Values{}
398-
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
399-
form.Set("client_id", idpClientID)
400-
form.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
401-
form.Set("requested_token_type", "urn:ietf:params:oauth:token-type:id_token")
402-
form.Set("scope", "openid profile email groups")
403-
form.Set("subject_token", accessToken)
404-
form.Set("resource", resourceForCluster(config))
405-
406-
req, err := http.NewRequestWithContext(
407-
ctx,
408-
http.MethodPost,
409-
tokenEndpoint,
410-
strings.NewReader(form.Encode()),
411-
)
412-
if err != nil {
413-
return nil, fmt.Errorf("build exchange request: %w", err)
414-
}
415-
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
416-
417-
return req, nil
418-
}
419-
420351
func resourceForCluster(config *clusterConfig) string {
421352
return fmt.Sprintf(
422353
"resource://organizations/%s/projects/%s/regions/%s/ske/%s",
@@ -427,26 +358,23 @@ func resourceForCluster(config *clusterConfig) string {
427358
)
428359
}
429360

430-
func parseTokenExchangeResponse(resp *http.Response) (accessToken string, err error) {
431-
respBody, err := io.ReadAll(resp.Body)
361+
func getCachedToken(key string) string {
362+
token, err := cache.GetObject(key)
432363
if err != nil {
433-
return "", fmt.Errorf("read body: %w", err)
434-
}
435-
if resp.StatusCode != http.StatusOK {
436-
return "", fmt.Errorf("non-OK %d status: %s", resp.StatusCode, string(respBody))
364+
return ""
437365
}
366+
return string(token)
367+
}
438368

439-
respContent := struct {
440-
AccessToken string `json:"access_token"`
441-
}{}
442-
err = json.Unmarshal(respBody, &respContent)
369+
func exchangeAndCacheToken(ctx context.Context, idpClient *http.Client, accessToken, resource, cacheKey string) (string, error) {
370+
clusterToken, err := auth.ExchangeToken(ctx, idpClient, accessToken, resource)
443371
if err != nil {
444-
return "", fmt.Errorf("unmarshal body: %w", err)
372+
return "", err
445373
}
446-
if respContent.AccessToken == "" {
447-
return "", fmt.Errorf("no access token found")
374+
if err = cache.PutObject(cacheKey, []byte(clusterToken)); err != nil {
375+
return "", fmt.Errorf("cache token: %w", err)
448376
}
449-
return respContent.AccessToken, nil
377+
return clusterToken, err
450378
}
451379

452380
func outputTokenKubeconfig(p *print.Printer, cacheKey, token string) error {

internal/cmd/ske/kubeconfig/login/login_test.go

Lines changed: 10 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,13 @@ package login
33
import (
44
"context"
55
"encoding/json"
6-
"io"
7-
"net/http"
8-
"net/http/httptest"
9-
"net/http/httputil"
10-
"net/url"
11-
"strings"
126
"testing"
137
"time"
148

159
"github.com/golang-jwt/jwt/v5"
1610
"github.com/google/go-cmp/cmp"
1711
"github.com/google/go-cmp/cmp/cmpopts"
1812
"github.com/google/uuid"
19-
"github.com/stackitcloud/stackit-cli/internal/pkg/cache"
2013
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
2114
"github.com/stackitcloud/stackit-sdk-go/services/ske"
2215
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -31,9 +24,6 @@ var testClient = &ske.APIClient{}
3124
var testProjectId = uuid.NewString()
3225
var testClusterName = "cluster"
3326
var testOrganization = uuid.NewString()
34-
var testAccessToken = "access-token-test-" + uuid.NewString()
35-
var testExchangedToken = "access-token-exchanged-" + uuid.NewString()
36-
var testTokenEndpoint = "https://accounts.stackit.cloud/test/endpoint" //nolint:gosec // Actually just a URL
3727

3828
const testRegion = "eu01"
3929

@@ -60,40 +50,6 @@ func fixtureLoginRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest))
6050
return request
6151
}
6252

63-
func fixtureTokenExchangeRequest(tokenEndpoint string) *http.Request {
64-
form := url.Values{}
65-
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
66-
form.Set("client_id", "stackit-cli-0000-0000-000000000001")
67-
form.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
68-
form.Set("requested_token_type", "urn:ietf:params:oauth:token-type:id_token")
69-
form.Set("scope", "openid profile email groups")
70-
form.Set("subject_token", testAccessToken)
71-
form.Set("resource", "resource://organizations/"+testOrganization+"/projects/"+testProjectId+"/regions/"+testRegion+"/ske/"+testClusterName)
72-
73-
req, _ := http.NewRequestWithContext(
74-
testCtx,
75-
http.MethodPost,
76-
tokenEndpoint,
77-
strings.NewReader(form.Encode()),
78-
)
79-
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
80-
return req
81-
}
82-
83-
func fixtureTokenExchangeResponse() string {
84-
type exchangeReponse struct {
85-
AccessToken string `json:"access_token"`
86-
IssuedTokeType string `json:"issued_token_type"`
87-
TokenType string `json:"token_type"`
88-
}
89-
response, _ := json.Marshal(exchangeReponse{
90-
AccessToken: testExchangedToken,
91-
IssuedTokeType: "urn:ietf:params:oauth:token-type:id_token",
92-
TokenType: "Bearer",
93-
})
94-
return string(response)
95-
}
96-
9753
func TestBuildRequest(t *testing.T) {
9854
tests := []struct {
9955
description string
@@ -191,135 +147,6 @@ zbRjZmli7cnenEnfnNoFIGbgkbjGXRUCIC5zFtWXFK7kA+B2vDxD0DlLcQodNwi4
191147
}
192148
}
193149

194-
func TestBuildTokenExchangeRequest(t *testing.T) {
195-
cfg := fixtureClusterConfig()
196-
expectedRequest := fixtureTokenExchangeRequest(testTokenEndpoint)
197-
req, err := buildRequestToExchangeTokens(testCtx, testTokenEndpoint, testAccessToken, cfg)
198-
if err != nil {
199-
t.Fatalf("func returned error: %s", err)
200-
}
201-
// directly using cmp.Diff is not possible, so dump the requests first
202-
expected, err := httputil.DumpRequest(expectedRequest, true)
203-
if err != nil {
204-
t.Fatalf("fail to dump expected: %s", err)
205-
}
206-
actual, err := httputil.DumpRequest(req, true)
207-
if err != nil {
208-
t.Fatalf("fail to dump actual: %s", err)
209-
}
210-
diff := cmp.Diff(actual, expected)
211-
if diff != "" {
212-
t.Fatalf("Data does not match: %s", diff)
213-
}
214-
}
215-
216-
func TestParseTokenExchangeResponse(t *testing.T) {
217-
response := fixtureTokenExchangeResponse()
218-
219-
tests := []struct {
220-
description string
221-
response string
222-
status int
223-
expectError bool
224-
}{
225-
{
226-
description: "valid response",
227-
response: response,
228-
status: http.StatusOK,
229-
},
230-
{
231-
description: "error status",
232-
response: response, // valid response to make sure the status code is checked
233-
status: http.StatusForbidden,
234-
expectError: true,
235-
},
236-
{
237-
description: "error content",
238-
response: "{}",
239-
status: http.StatusOK,
240-
expectError: true,
241-
},
242-
}
243-
244-
for _, tt := range tests {
245-
t.Run(tt.description, func(t *testing.T) {
246-
w := httptest.NewRecorder()
247-
w.WriteHeader(tt.status)
248-
_, _ = w.WriteString(tt.response)
249-
resp := w.Result()
250-
251-
defer func() {
252-
tempErr := resp.Body.Close()
253-
if tempErr != nil {
254-
t.Fatalf("failed to close response body: %v", tempErr)
255-
}
256-
}()
257-
accessToken, err := parseTokenExchangeResponse(resp)
258-
if tt.expectError {
259-
if err == nil {
260-
t.Fatal("expected error got nil")
261-
}
262-
} else {
263-
if err != nil {
264-
t.Fatalf("func returned error: %s", err)
265-
}
266-
diff := cmp.Diff(accessToken, testExchangedToken)
267-
if diff != "" {
268-
t.Fatalf("Token does not match: %s", diff)
269-
}
270-
}
271-
})
272-
}
273-
}
274-
275-
func TestExchangeToken(t *testing.T) {
276-
config := fixtureClusterConfig(func(clusterConfig *clusterConfig) {
277-
clusterConfig.cacheKey = "test-exchange-token-" + uuid.NewString()
278-
})
279-
var request *http.Request
280-
response := fixtureTokenExchangeResponse()
281-
defer cache.OverwriteCacheDir(t)()
282-
if err := cache.Init(); err != nil {
283-
t.Fatalf("cache init failed: %s", err)
284-
}
285-
286-
handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
287-
// only compare body as the headers will differ
288-
expected, err := io.ReadAll(request.Body)
289-
if err != nil {
290-
t.Errorf("fail to dump expected: %s", err)
291-
}
292-
actual, err := io.ReadAll(req.Body)
293-
if err != nil {
294-
t.Errorf("fail to dump actual: %s", err)
295-
}
296-
diff := cmp.Diff(actual, expected)
297-
if diff != "" {
298-
w.WriteHeader(http.StatusBadRequest)
299-
t.Errorf("request mismatch: %v", diff)
300-
return
301-
}
302-
303-
w.Header().Set("Content-Type", "application/json")
304-
_, err = w.Write([]byte(response))
305-
if err != nil {
306-
t.Errorf("Failed to write response: %v", err)
307-
}
308-
})
309-
server := httptest.NewServer(handler)
310-
defer server.Close()
311-
312-
request = fixtureTokenExchangeRequest(server.URL)
313-
idToken, err := exchangeToken(testCtx, server.Client(), server.URL, testAccessToken, config)
314-
if err != nil {
315-
t.Fatalf("func returned error: %s", err)
316-
}
317-
diff := cmp.Diff(idToken, testExchangedToken)
318-
if diff != "" {
319-
t.Fatalf("Exchanged token does not match: %s", diff)
320-
}
321-
}
322-
323150
func TestParseTokenToExecCredential(t *testing.T) {
324151
expirationTime := time.Now().Add(30 * time.Minute)
325152
expectedTime := expirationTime.Add(-5 * time.Minute)
@@ -369,3 +196,13 @@ func TestParseTokenToExecCredential(t *testing.T) {
369196
})
370197
}
371198
}
199+
200+
func TestResourceForCluster(t *testing.T) {
201+
cc := fixtureClusterConfig()
202+
resource := resourceForCluster(cc)
203+
// somewhat redundant, but the resource string must not change unexpectedly
204+
expectedResource := "resource://organizations/" + testOrganization + "/projects/" + testProjectId + "/regions/" + testRegion + "/ske/" + testClusterName
205+
if resource != expectedResource {
206+
t.Fatalf("unexpected resource, got %v expected %v", resource, expectedResource)
207+
}
208+
}

0 commit comments

Comments
 (0)