github.com/argoproj/argo-cd/v3@v3.2.1/server/logout/logout_test.go (about) 1 package logout 2 3 import ( 4 "errors" 5 "net/http" 6 "net/http/httptest" 7 "regexp" 8 "strconv" 9 "testing" 10 11 "github.com/argoproj/argo-cd/v3/common" 12 "github.com/argoproj/argo-cd/v3/test" 13 "github.com/argoproj/argo-cd/v3/util/session" 14 "github.com/argoproj/argo-cd/v3/util/settings" 15 16 "github.com/golang-jwt/jwt/v5" 17 "github.com/stretchr/testify/assert" 18 "github.com/stretchr/testify/require" 19 corev1 "k8s.io/api/core/v1" 20 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 "k8s.io/client-go/kubernetes/fake" 22 ) 23 24 var ( 25 validJWTPattern = regexp.MustCompile(`[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+`) 26 baseURL = "http://localhost:4000" 27 rootPath = "argocd" 28 baseHRef = "argocd" 29 baseLogoutURL = "http://localhost:4000/logout" 30 baseLogoutURLwithToken = "http://localhost:4000/logout?id_token_hint={{token}}" 31 baseLogoutURLwithRedirectURL = "http://localhost:4000/logout?post_logout_redirect_uri={{logoutRedirectURL}}" 32 baseLogoutURLwithTokenAndRedirectURL = "http://localhost:4000/logout?id_token_hint={{token}}&post_logout_redirect_uri={{logoutRedirectURL}}" 33 invalidToken = "sample-token" 34 oidcToken = "eyJraWQiOiJYQi1MM3ZFdHhYWXJLcmRSQnVEV0NwdnZsSnk3SEJVb2d5N253M1U1Z1ZZIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHVqNnM1NDVyNU5peVNLcjVkNSIsIm5hbWUiOiJqZCByIiwiZW1haWwiOiJqYWlkZWVwMTdydWx6QGdtYWlsLmNvbSIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9kZXYtNTY5NTA5OC5va3RhLmNvbSIsImF1ZCI6IjBvYWowM2FmSEtqN3laWXJwNWQ1IiwiaWF0IjoxNjA1NTcyMzU5LCJleHAiOjE2MDU1NzU5NTksImp0aSI6IklELl9ORDJxVG5iREFtc3hIZUt2U2ZHeVBqTXRicXFEQXdkdlRQTDZCTnpfR3ciLCJhbXIiOlsicHdkIl0sImlkcCI6IjAwb2lnaGZmdkpRTDYzWjhoNWQ1IiwicHJlZmVycmVkX3VzZXJuYW1lIjoiamFpZGVlcDE3cnVsekBnbWFpbC5jb20iLCJhdXRoX3RpbWUiOjE2MDU1NzIzNTcsImF0X2hhc2giOiJqZVEwRml2ak9nNGI2TUpXRDIxOWxnIn0.GHkqwXgW-lrAhJdypW7SVjW0YdNLFQiRL8iwgT6DHJxP9Nb0OtkH2NKcBYAA5N6bTPLRQUHgYwWcgm5zSXmvqa7ciIgPF3tiQI8UmJA9VFRRDR-x9ExX15nskCbXfiQ67MriLslUrQUyzSCfUrSjXKwnDxbKGQncrtmRsh5asfCzJFb9excn311W9HKbT3KA0Ot7eOMnVS6V7SGfXxnKs6szcXIEMa_FhB4zDAVLr-dnxvSG_uuWcHrAkLTUVhHbdQQXF7hXIEfyr5lkMJN-drjdz-bn40GaYulEmUvO1bjcL9toCVQ3Ismypyr0b8phj4w3uRsLDZQxTxK7jAXlyQ" 35 nonOidcToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MDU1NzQyMTIsImlzcyI6ImFyZ29jZCIsIm5iZiI6MTYwNTU3NDIxMiwic3ViIjoiYWRtaW4ifQ.zDJ4piwWnwsHON-oPusHMXWINlnrRDTQykYogT7afeE" 36 expectedNonOIDCLogoutURL = "http://localhost:4000" 37 expectedNonOIDCLogoutURLOnSecondHost = "http://argocd.my-corp.tld" 38 expectedOIDCLogoutURL = "https://dev-5695098.okta.com/oauth2/v1/logout?id_token_hint=" + oidcToken + "&post_logout_redirect_uri=" + baseURL 39 expectedOIDCLogoutURLWithRootPath = "https://dev-5695098.okta.com/oauth2/v1/logout?id_token_hint=" + oidcToken + "&post_logout_redirect_uri=" + baseURL + "/" + rootPath 40 ) 41 42 func TestConstructLogoutURL(t *testing.T) { 43 tests := []struct { 44 name string 45 logoutURL string 46 token string 47 logoutRedirectURL string 48 expectedLogoutURL string 49 }{ 50 { 51 name: "Case: No additional parameters passed to logout URL", 52 logoutURL: baseLogoutURL, 53 token: oidcToken, 54 logoutRedirectURL: baseURL, 55 expectedLogoutURL: baseLogoutURL, 56 }, 57 { 58 name: "Case: ID token passed to logout URL", 59 logoutURL: baseLogoutURLwithToken, 60 token: oidcToken, 61 logoutRedirectURL: baseURL, 62 expectedLogoutURL: "http://localhost:4000/logout?id_token_hint=" + oidcToken, 63 }, 64 { 65 name: "Case: Redirect required", 66 logoutURL: baseLogoutURLwithRedirectURL, 67 token: oidcToken, 68 logoutRedirectURL: baseURL, 69 expectedLogoutURL: "http://localhost:4000/logout?post_logout_redirect_uri=" + baseURL, 70 }, 71 { 72 name: "Case: ID token and redirect URL passed to logout URL", 73 logoutURL: baseLogoutURLwithTokenAndRedirectURL, 74 token: oidcToken, 75 logoutRedirectURL: baseURL, 76 expectedLogoutURL: "http://localhost:4000/logout?id_token_hint=" + oidcToken + "&post_logout_redirect_uri=" + baseURL, 77 }, 78 } 79 for _, tt := range tests { 80 t.Run(tt.name, func(t *testing.T) { 81 constructedLogoutURL := constructLogoutURL(tt.logoutURL, tt.token, tt.logoutRedirectURL) 82 assert.Equal(t, tt.expectedLogoutURL, constructedLogoutURL) 83 }) 84 } 85 } 86 87 func TestHandlerConstructLogoutURL(t *testing.T) { 88 kubeClientWithOIDCConfig := fake.NewClientset( 89 &corev1.ConfigMap{ 90 ObjectMeta: metav1.ObjectMeta{ 91 Name: common.ArgoCDConfigMapName, 92 Namespace: "default", 93 Labels: map[string]string{ 94 "app.kubernetes.io/part-of": "argocd", 95 }, 96 }, 97 Data: map[string]string{ 98 "oidc.config": "name: Okta \n" + 99 "issuer: https://dev-5695098.okta.com \n" + 100 "requestedScopes: [\"openid\", \"profile\", \"email\", \"groups\"] \n" + 101 "requestedIDTokenClaims: {\"groups\": {\"essential\": true}} \n" + 102 "logoutURL: https://dev-5695098.okta.com/oauth2/v1/logout?id_token_hint={{token}}&post_logout_redirect_uri={{logoutRedirectURL}}", 103 "url": "http://localhost:4000", 104 }, 105 }, 106 &corev1.Secret{ 107 ObjectMeta: metav1.ObjectMeta{ 108 Name: common.ArgoCDSecretName, 109 Namespace: "default", 110 Labels: map[string]string{ 111 "app.kubernetes.io/part-of": "argocd", 112 }, 113 }, 114 Data: map[string][]byte{ 115 "admin.password": nil, 116 "server.secretkey": nil, 117 }, 118 }, 119 ) 120 kubeClientWithOIDCConfigButNoURL := fake.NewClientset( 121 &corev1.ConfigMap{ 122 ObjectMeta: metav1.ObjectMeta{ 123 Name: common.ArgoCDConfigMapName, 124 Namespace: "default", 125 Labels: map[string]string{ 126 "app.kubernetes.io/part-of": "argocd", 127 }, 128 }, 129 Data: map[string]string{ 130 "oidc.config": "name: Okta \n" + 131 "issuer: https://dev-5695098.okta.com \n" + 132 "requestedScopes: [\"openid\", \"profile\", \"email\", \"groups\"] \n" + 133 "requestedIDTokenClaims: {\"groups\": {\"essential\": true}} \n" + 134 "logoutURL: https://dev-5695098.okta.com/oauth2/v1/logout?id_token_hint={{token}}&post_logout_redirect_uri={{logoutRedirectURL}}", 135 "url": "", 136 }, 137 }, 138 &corev1.Secret{ 139 ObjectMeta: metav1.ObjectMeta{ 140 Name: common.ArgoCDSecretName, 141 Namespace: "default", 142 Labels: map[string]string{ 143 "app.kubernetes.io/part-of": "argocd", 144 }, 145 }, 146 Data: map[string][]byte{ 147 "admin.password": nil, 148 "server.secretkey": nil, 149 }, 150 }, 151 ) 152 kubeClientWithOIDCConfigButNoLogoutURL := fake.NewClientset( 153 &corev1.ConfigMap{ 154 ObjectMeta: metav1.ObjectMeta{ 155 Name: common.ArgoCDConfigMapName, 156 Namespace: "default", 157 Labels: map[string]string{ 158 "app.kubernetes.io/part-of": "argocd", 159 }, 160 }, 161 Data: map[string]string{ 162 "oidc.config": "name: Okta \n" + 163 "issuer: https://dev-5695098.okta.com \n" + 164 "requestedScopes: [\"openid\", \"profile\", \"email\", \"groups\"] \n" + 165 "requestedIDTokenClaims: {\"groups\": {\"essential\": true}} \n", 166 "url": "http://localhost:4000", 167 }, 168 }, 169 &corev1.Secret{ 170 ObjectMeta: metav1.ObjectMeta{ 171 Name: common.ArgoCDSecretName, 172 Namespace: "default", 173 Labels: map[string]string{ 174 "app.kubernetes.io/part-of": "argocd", 175 }, 176 }, 177 Data: map[string][]byte{ 178 "admin.password": nil, 179 "server.secretkey": nil, 180 }, 181 }, 182 ) 183 kubeClientWithoutOIDCAndMultipleURLs := fake.NewClientset( 184 &corev1.ConfigMap{ 185 ObjectMeta: metav1.ObjectMeta{ 186 Name: common.ArgoCDConfigMapName, 187 Namespace: "default", 188 Labels: map[string]string{ 189 "app.kubernetes.io/part-of": "argocd", 190 }, 191 }, 192 Data: map[string]string{ 193 "url": "http://localhost:4000", 194 "additionalUrls": "- http://argocd.my-corp.tld", 195 }, 196 }, 197 &corev1.Secret{ 198 ObjectMeta: metav1.ObjectMeta{ 199 Name: common.ArgoCDSecretName, 200 Namespace: "default", 201 Labels: map[string]string{ 202 "app.kubernetes.io/part-of": "argocd", 203 }, 204 }, 205 Data: map[string][]byte{ 206 "admin.password": nil, 207 "server.secretkey": nil, 208 }, 209 }, 210 ) 211 kubeClientWithoutOIDCConfig := fake.NewClientset( 212 &corev1.ConfigMap{ 213 ObjectMeta: metav1.ObjectMeta{ 214 Name: common.ArgoCDConfigMapName, 215 Namespace: "default", 216 Labels: map[string]string{ 217 "app.kubernetes.io/part-of": "argocd", 218 }, 219 }, 220 Data: map[string]string{ 221 "url": "http://localhost:4000", 222 }, 223 }, 224 &corev1.Secret{ 225 ObjectMeta: metav1.ObjectMeta{ 226 Name: common.ArgoCDSecretName, 227 Namespace: "default", 228 Labels: map[string]string{ 229 "app.kubernetes.io/part-of": "argocd", 230 }, 231 }, 232 Data: map[string][]byte{ 233 "admin.password": nil, 234 "server.secretkey": nil, 235 }, 236 }, 237 ) 238 239 settingsManagerWithOIDCConfig := settings.NewSettingsManager(t.Context(), kubeClientWithOIDCConfig, "default") 240 settingsManagerWithoutOIDCConfig := settings.NewSettingsManager(t.Context(), kubeClientWithoutOIDCConfig, "default") 241 settingsManagerWithOIDCConfigButNoLogoutURL := settings.NewSettingsManager(t.Context(), kubeClientWithOIDCConfigButNoLogoutURL, "default") 242 settingsManagerWithoutOIDCAndMultipleURLs := settings.NewSettingsManager(t.Context(), kubeClientWithoutOIDCAndMultipleURLs, "default") 243 settingsManagerWithOIDCConfigButNoURL := settings.NewSettingsManager(t.Context(), kubeClientWithOIDCConfigButNoURL, "default") 244 245 sessionManager := session.NewSessionManager(settingsManagerWithOIDCConfig, test.NewFakeProjLister(), "", nil, session.NewUserStateStorage(nil)) 246 247 oidcHandler := NewHandler(settingsManagerWithOIDCConfig, sessionManager, rootPath, baseHRef) 248 oidcHandler.verifyToken = func(tokenString string) (jwt.Claims, string, error) { 249 if !validJWTPattern.MatchString(tokenString) { 250 return nil, "", errors.New("invalid jwt") 251 } 252 return &jwt.RegisteredClaims{Issuer: "okta"}, "", nil 253 } 254 nonoidcHandler := NewHandler(settingsManagerWithoutOIDCConfig, sessionManager, "", baseHRef) 255 nonoidcHandler.verifyToken = func(tokenString string) (jwt.Claims, string, error) { 256 if !validJWTPattern.MatchString(tokenString) { 257 return nil, "", errors.New("invalid jwt") 258 } 259 return &jwt.RegisteredClaims{Issuer: session.SessionManagerClaimsIssuer}, "", nil 260 } 261 oidcHandlerWithoutLogoutURL := NewHandler(settingsManagerWithOIDCConfigButNoLogoutURL, sessionManager, "", baseHRef) 262 oidcHandlerWithoutLogoutURL.verifyToken = func(tokenString string) (jwt.Claims, string, error) { 263 if !validJWTPattern.MatchString(tokenString) { 264 return nil, "", errors.New("invalid jwt") 265 } 266 return &jwt.RegisteredClaims{Issuer: "okta"}, "", nil 267 } 268 nonoidcHandlerWithMultipleURLs := NewHandler(settingsManagerWithoutOIDCAndMultipleURLs, sessionManager, "", baseHRef) 269 nonoidcHandlerWithMultipleURLs.verifyToken = func(tokenString string) (jwt.Claims, string, error) { 270 if !validJWTPattern.MatchString(tokenString) { 271 return nil, "", errors.New("invalid jwt") 272 } 273 return &jwt.RegisteredClaims{Issuer: "okta"}, "", nil 274 } 275 276 oidcHandlerWithoutBaseURL := NewHandler(settingsManagerWithOIDCConfigButNoURL, sessionManager, "argocd", baseHRef) 277 oidcHandlerWithoutBaseURL.verifyToken = func(tokenString string) (jwt.Claims, string, error) { 278 if !validJWTPattern.MatchString(tokenString) { 279 return nil, "", errors.New("invalid jwt") 280 } 281 return &jwt.RegisteredClaims{Issuer: "okta"}, "", nil 282 } 283 oidcTokenHeader := make(map[string][]string) 284 oidcTokenHeader["Cookie"] = []string{"argocd.token=" + oidcToken} 285 nonOidcTokenHeader := make(map[string][]string) 286 nonOidcTokenHeader["Cookie"] = []string{"argocd.token=" + nonOidcToken} 287 invalidHeader := make(map[string][]string) 288 invalidHeader["Cookie"] = []string{"argocd.token=" + invalidToken} 289 290 oidcRequest, err := http.NewRequest(http.MethodGet, "http://localhost:4000/api/logout", http.NoBody) 291 require.NoError(t, err) 292 oidcRequest.Header = oidcTokenHeader 293 nonoidcRequest, err := http.NewRequest(http.MethodGet, "http://localhost:4000/api/logout", http.NoBody) 294 require.NoError(t, err) 295 nonoidcRequest.Header = nonOidcTokenHeader 296 nonoidcRequestOnSecondHost, err := http.NewRequest(http.MethodGet, "http://argocd.my-corp.tld/api/logout", http.NoBody) 297 assert.NoError(t, err) 298 nonoidcRequestOnSecondHost.Header = nonOidcTokenHeader 299 assert.NoError(t, err) 300 requestWithInvalidToken, err := http.NewRequest(http.MethodGet, "http://localhost:4000/api/logout", http.NoBody) 301 require.NoError(t, err) 302 requestWithInvalidToken.Header = invalidHeader 303 invalidRequest, err := http.NewRequest(http.MethodGet, "http://localhost:4000/api/logout", http.NoBody) 304 require.NoError(t, err) 305 306 tests := []struct { 307 name string 308 kubeClient *fake.Clientset 309 handler http.Handler 310 request *http.Request 311 responseRecorder *httptest.ResponseRecorder 312 expectedLogoutURL string 313 wantErr bool 314 }{ 315 { 316 name: "Case: OIDC logout request with valid token", 317 handler: oidcHandler, 318 request: oidcRequest, 319 responseRecorder: httptest.NewRecorder(), 320 expectedLogoutURL: expectedOIDCLogoutURL, 321 wantErr: false, 322 }, 323 { 324 name: "Case: OIDC logout request with valid token but missing URL", 325 handler: oidcHandlerWithoutBaseURL, 326 request: oidcRequest, 327 responseRecorder: httptest.NewRecorder(), 328 expectedLogoutURL: expectedOIDCLogoutURLWithRootPath, 329 wantErr: false, 330 }, 331 { 332 name: "Case: non-OIDC logout request with valid token", 333 handler: nonoidcHandler, 334 request: nonoidcRequest, 335 responseRecorder: httptest.NewRecorder(), 336 expectedLogoutURL: expectedNonOIDCLogoutURL, 337 wantErr: false, 338 }, 339 { 340 name: "Case: Logout request with invalid token", 341 handler: nonoidcHandler, 342 request: requestWithInvalidToken, 343 responseRecorder: httptest.NewRecorder(), 344 expectedLogoutURL: expectedNonOIDCLogoutURL, 345 wantErr: false, 346 }, 347 { 348 name: "Case: Logout request with missing token", 349 handler: oidcHandler, 350 request: invalidRequest, 351 responseRecorder: httptest.NewRecorder(), 352 expectedLogoutURL: expectedNonOIDCLogoutURL, 353 wantErr: true, 354 }, 355 { 356 name: "Case:OIDC Logout request with missing logout URL configuration in config map", 357 handler: oidcHandlerWithoutLogoutURL, 358 request: oidcRequest, 359 responseRecorder: httptest.NewRecorder(), 360 expectedLogoutURL: expectedNonOIDCLogoutURL, 361 wantErr: false, 362 }, 363 { 364 name: "Case:non-OIDC Logout request on the first supported URL", 365 handler: nonoidcHandlerWithMultipleURLs, 366 request: nonoidcRequest, 367 responseRecorder: httptest.NewRecorder(), 368 expectedLogoutURL: expectedNonOIDCLogoutURL, 369 wantErr: false, 370 }, 371 { 372 name: "Case:non-OIDC Logout request on the second supported URL", 373 handler: nonoidcHandlerWithMultipleURLs, 374 request: nonoidcRequestOnSecondHost, 375 responseRecorder: httptest.NewRecorder(), 376 expectedLogoutURL: expectedNonOIDCLogoutURLOnSecondHost, 377 wantErr: false, 378 }, 379 } 380 for _, tt := range tests { 381 t.Run(tt.name, func(t *testing.T) { 382 tt.handler.ServeHTTP(tt.responseRecorder, tt.request) 383 if status := tt.responseRecorder.Code; status != http.StatusSeeOther { 384 if !tt.wantErr { 385 t.Error(tt.responseRecorder.Body.String()) 386 t.Error("handler returned wrong status code: " + strconv.Itoa(tt.responseRecorder.Code)) 387 } 388 } else { 389 if tt.wantErr { 390 t.Errorf("expected error but did not get one") 391 } else { 392 assert.Equal(t, tt.expectedLogoutURL, tt.responseRecorder.Result().Header["Location"][0]) 393 } 394 } 395 }) 396 } 397 }