@@ -2,12 +2,21 @@ package login
22
33import (
44 "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/http/httputil"
10+ "net/url"
11+ "strings"
512 "testing"
613 "time"
714
15+ "github.com/golang-jwt/jwt/v5"
816 "github.com/google/go-cmp/cmp"
917 "github.com/google/go-cmp/cmp/cmpopts"
1018 "github.com/google/uuid"
19+ "github.com/stackitcloud/stackit-cli/internal/pkg/cache"
1120 "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
1221 "github.com/stackitcloud/stackit-sdk-go/services/ske"
1322 v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -21,6 +30,10 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
2130var testClient = & ske.APIClient {}
2231var testProjectId = uuid .NewString ()
2332var testClusterName = "cluster"
33+ 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
2437
2538const testRegion = "eu01"
2639
@@ -30,14 +43,15 @@ func fixtureClusterConfig(mods ...func(clusterConfig *clusterConfig)) *clusterCo
3043 ClusterName : testClusterName ,
3144 cacheKey : "" ,
3245 Region : testRegion ,
46+ OrganizationID : testOrganization ,
3347 }
3448 for _ , mod := range mods {
3549 mod (clusterConfig )
3650 }
3751 return clusterConfig
3852}
3953
40- func fixtureRequest (mods ... func (request * ske.ApiCreateKubeconfigRequest )) ske.ApiCreateKubeconfigRequest {
54+ func fixtureLoginRequest (mods ... func (request * ske.ApiCreateKubeconfigRequest )) ske.ApiCreateKubeconfigRequest {
4155 request := testClient .CreateKubeconfig (testCtx , testProjectId , testRegion , testClusterName )
4256 request = request .CreateKubeconfigPayload (ske.CreateKubeconfigPayload {})
4357 for _ , mod := range mods {
@@ -46,6 +60,40 @@ func fixtureRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) ske.A
4660 return request
4761}
4862
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+
4997func TestBuildRequest (t * testing.T ) {
5098 tests := []struct {
5199 description string
@@ -55,7 +103,7 @@ func TestBuildRequest(t *testing.T) {
55103 {
56104 description : "expiration time" ,
57105 clusterConfig : fixtureClusterConfig (),
58- expectedRequest : fixtureRequest ().CreateKubeconfigPayload (ske.CreateKubeconfigPayload {
106+ expectedRequest : fixtureLoginRequest ().CreateKubeconfigPayload (ske.CreateKubeconfigPayload {
59107 ExpirationSeconds : utils .Ptr ("1800" )}),
60108 },
61109 }
@@ -134,7 +182,187 @@ zbRjZmli7cnenEnfnNoFIGbgkbjGXRUCIC5zFtWXFK7kA+B2vDxD0DlLcQodNwi4
134182 if execCredential == nil {
135183 t .Fatal ("execCredential is nil" )
136184 }
137- diff := cmp .Diff (execCredential , tt .expectedExecCredentialRequest )
185+ expected , _ := json .Marshal (tt .expectedExecCredentialRequest )
186+ diff := cmp .Diff (execCredential , expected )
187+ if diff != "" {
188+ t .Fatalf ("Data does not match: %s" , diff )
189+ }
190+ })
191+ }
192+ }
193+
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+
323+ func TestParseTokenToExecCredential (t * testing.T ) {
324+ expirationTime := time .Now ().Add (30 * time .Minute )
325+ expectedTime := expirationTime .Add (- 5 * time .Minute )
326+ token , err := jwt .NewWithClaims (jwt .SigningMethodRS256 , jwt.RegisteredClaims {
327+ ExpiresAt : jwt .NewNumericDate (expirationTime ),
328+ }).SigningString ()
329+ if err != nil {
330+ t .Fatalf ("token generation failed: %v" , err )
331+ }
332+ token += ".signatureAAA"
333+
334+ tests := []struct {
335+ description string
336+ token string
337+ expectedExecCredentialRequest * clientauthenticationv1.ExecCredential
338+ }{
339+ {
340+ description : "expiration time" ,
341+ token : token ,
342+ expectedExecCredentialRequest : & clientauthenticationv1.ExecCredential {
343+ TypeMeta : v1.TypeMeta {
344+ APIVersion : clientauthenticationv1 .SchemeGroupVersion .String (),
345+ Kind : "ExecCredential" ,
346+ },
347+ Status : & clientauthenticationv1.ExecCredentialStatus {
348+ ExpirationTimestamp : & v1.Time {Time : expectedTime },
349+ Token : token ,
350+ },
351+ },
352+ },
353+ }
354+
355+ for _ , tt := range tests {
356+ t .Run (tt .description , func (t * testing.T ) {
357+ execCredential , err := parseTokenToExecCredential (tt .token )
358+ if err != nil {
359+ t .Fatalf ("func returned error: %s" , err )
360+ }
361+ if execCredential == nil {
362+ t .Fatal ("execCredential is nil" )
363+ }
364+ expected , _ := json .Marshal (tt .expectedExecCredentialRequest )
365+ diff := cmp .Diff (execCredential , expected )
138366 if diff != "" {
139367 t .Fatalf ("Data does not match: %s" , diff )
140368 }
0 commit comments