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  }