github.com/argoproj/argo-cd/v2@v2.10.9/server/logout/logout_test.go (about)

     1  package logout
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"net/http"
     8  	"net/http/httptest"
     9  	"regexp"
    10  	"testing"
    11  
    12  	"github.com/argoproj/argo-cd/v2/common"
    13  	appclientset "github.com/argoproj/argo-cd/v2/pkg/client/clientset/versioned/fake"
    14  	"github.com/argoproj/argo-cd/v2/test"
    15  	"github.com/argoproj/argo-cd/v2/util/session"
    16  	"github.com/argoproj/argo-cd/v2/util/settings"
    17  
    18  	"github.com/golang-jwt/jwt/v4"
    19  	"github.com/stretchr/testify/assert"
    20  	corev1 "k8s.io/api/core/v1"
    21  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    22  	"k8s.io/client-go/kubernetes/fake"
    23  )
    24  
    25  var (
    26  	validJWTPattern                      = regexp.MustCompile(`[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+`)
    27  	baseURL                              = "http://localhost:4000"
    28  	rootPath                             = "argocd"
    29  	baseHRef                             = "argocd"
    30  	baseLogoutURL                        = "http://localhost:4000/logout"
    31  	baseLogoutURLwithToken               = "http://localhost:4000/logout?id_token_hint={{token}}"
    32  	baseLogoutURLwithRedirectURL         = "http://localhost:4000/logout?post_logout_redirect_uri={{logoutRedirectURL}}"
    33  	baseLogoutURLwithTokenAndRedirectURL = "http://localhost:4000/logout?id_token_hint={{token}}&post_logout_redirect_uri={{logoutRedirectURL}}"
    34  	invalidToken                         = "sample-token"
    35  	oidcToken                            = "eyJraWQiOiJYQi1MM3ZFdHhYWXJLcmRSQnVEV0NwdnZsSnk3SEJVb2d5N253M1U1Z1ZZIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHVqNnM1NDVyNU5peVNLcjVkNSIsIm5hbWUiOiJqZCByIiwiZW1haWwiOiJqYWlkZWVwMTdydWx6QGdtYWlsLmNvbSIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9kZXYtNTY5NTA5OC5va3RhLmNvbSIsImF1ZCI6IjBvYWowM2FmSEtqN3laWXJwNWQ1IiwiaWF0IjoxNjA1NTcyMzU5LCJleHAiOjE2MDU1NzU5NTksImp0aSI6IklELl9ORDJxVG5iREFtc3hIZUt2U2ZHeVBqTXRicXFEQXdkdlRQTDZCTnpfR3ciLCJhbXIiOlsicHdkIl0sImlkcCI6IjAwb2lnaGZmdkpRTDYzWjhoNWQ1IiwicHJlZmVycmVkX3VzZXJuYW1lIjoiamFpZGVlcDE3cnVsekBnbWFpbC5jb20iLCJhdXRoX3RpbWUiOjE2MDU1NzIzNTcsImF0X2hhc2giOiJqZVEwRml2ak9nNGI2TUpXRDIxOWxnIn0.GHkqwXgW-lrAhJdypW7SVjW0YdNLFQiRL8iwgT6DHJxP9Nb0OtkH2NKcBYAA5N6bTPLRQUHgYwWcgm5zSXmvqa7ciIgPF3tiQI8UmJA9VFRRDR-x9ExX15nskCbXfiQ67MriLslUrQUyzSCfUrSjXKwnDxbKGQncrtmRsh5asfCzJFb9excn311W9HKbT3KA0Ot7eOMnVS6V7SGfXxnKs6szcXIEMa_FhB4zDAVLr-dnxvSG_uuWcHrAkLTUVhHbdQQXF7hXIEfyr5lkMJN-drjdz-bn40GaYulEmUvO1bjcL9toCVQ3Ismypyr0b8phj4w3uRsLDZQxTxK7jAXlyQ"
    36  	nonOidcToken                         = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MDU1NzQyMTIsImlzcyI6ImFyZ29jZCIsIm5iZiI6MTYwNTU3NDIxMiwic3ViIjoiYWRtaW4ifQ.zDJ4piwWnwsHON-oPusHMXWINlnrRDTQykYogT7afeE"
    37  	expectedNonOIDCLogoutURL             = "http://localhost:4000"
    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, constructedLogoutURL, tt.expectedLogoutURL)
    83  		})
    84  	}
    85  }
    86  
    87  func TestHandlerConstructLogoutURL(t *testing.T) {
    88  	kubeClientWithOIDCConfig := fake.NewSimpleClientset(
    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.NewSimpleClientset(
   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.NewSimpleClientset(
   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  	kubeClientWithoutOIDCConfig := fake.NewSimpleClientset(
   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  			},
   195  		},
   196  		&corev1.Secret{
   197  			ObjectMeta: metav1.ObjectMeta{
   198  				Name:      common.ArgoCDSecretName,
   199  				Namespace: "default",
   200  				Labels: map[string]string{
   201  					"app.kubernetes.io/part-of": "argocd",
   202  				},
   203  			},
   204  			Data: map[string][]byte{
   205  				"admin.password":   nil,
   206  				"server.secretkey": nil,
   207  			},
   208  		},
   209  	)
   210  
   211  	settingsManagerWithOIDCConfig := settings.NewSettingsManager(context.Background(), kubeClientWithOIDCConfig, "default")
   212  	settingsManagerWithoutOIDCConfig := settings.NewSettingsManager(context.Background(), kubeClientWithoutOIDCConfig, "default")
   213  	settingsManagerWithOIDCConfigButNoLogoutURL := settings.NewSettingsManager(context.Background(), kubeClientWithOIDCConfigButNoLogoutURL, "default")
   214  	settingsManagerWithOIDCConfigButNoURL := settings.NewSettingsManager(context.Background(), kubeClientWithOIDCConfigButNoURL, "default")
   215  
   216  	sessionManager := session.NewSessionManager(settingsManagerWithOIDCConfig, test.NewFakeProjLister(), "", nil, session.NewUserStateStorage(nil))
   217  
   218  	oidcHandler := NewHandler(appclientset.NewSimpleClientset(), settingsManagerWithOIDCConfig, sessionManager, rootPath, baseHRef, "default")
   219  	oidcHandler.verifyToken = func(tokenString string) (jwt.Claims, string, error) {
   220  		if !validJWTPattern.MatchString(tokenString) {
   221  			return nil, "", errors.New("invalid jwt")
   222  		}
   223  		return &jwt.RegisteredClaims{Issuer: "okta"}, "", nil
   224  	}
   225  	nonoidcHandler := NewHandler(appclientset.NewSimpleClientset(), settingsManagerWithoutOIDCConfig, sessionManager, "", baseHRef, "default")
   226  	nonoidcHandler.verifyToken = func(tokenString string) (jwt.Claims, string, error) {
   227  		if !validJWTPattern.MatchString(tokenString) {
   228  			return nil, "", errors.New("invalid jwt")
   229  		}
   230  		return &jwt.RegisteredClaims{Issuer: session.SessionManagerClaimsIssuer}, "", nil
   231  	}
   232  	oidcHandlerWithoutLogoutURL := NewHandler(appclientset.NewSimpleClientset(), settingsManagerWithOIDCConfigButNoLogoutURL, sessionManager, "", baseHRef, "default")
   233  	oidcHandlerWithoutLogoutURL.verifyToken = func(tokenString string) (jwt.Claims, string, error) {
   234  		if !validJWTPattern.MatchString(tokenString) {
   235  			return nil, "", errors.New("invalid jwt")
   236  		}
   237  		return &jwt.RegisteredClaims{Issuer: "okta"}, "", nil
   238  	}
   239  
   240  	oidcHandlerWithoutBaseURL := NewHandler(appclientset.NewSimpleClientset(), settingsManagerWithOIDCConfigButNoURL, sessionManager, "argocd", baseHRef, "default")
   241  	oidcHandlerWithoutBaseURL.verifyToken = func(tokenString string) (jwt.Claims, string, error) {
   242  		if !validJWTPattern.MatchString(tokenString) {
   243  			return nil, "", errors.New("invalid jwt")
   244  		}
   245  		return &jwt.RegisteredClaims{Issuer: "okta"}, "", nil
   246  	}
   247  	oidcTokenHeader := make(map[string][]string)
   248  	oidcTokenHeader["Cookie"] = []string{"argocd.token=" + oidcToken}
   249  	nonOidcTokenHeader := make(map[string][]string)
   250  	nonOidcTokenHeader["Cookie"] = []string{"argocd.token=" + nonOidcToken}
   251  	invalidHeader := make(map[string][]string)
   252  	invalidHeader["Cookie"] = []string{"argocd.token=" + invalidToken}
   253  
   254  	oidcRequest, err := http.NewRequest(http.MethodGet, "http://localhost:4000/api/logout", nil)
   255  	assert.NoError(t, err)
   256  	oidcRequest.Header = oidcTokenHeader
   257  	nonoidcRequest, err := http.NewRequest(http.MethodGet, "http://localhost:4000/api/logout", nil)
   258  	assert.NoError(t, err)
   259  	nonoidcRequest.Header = nonOidcTokenHeader
   260  	assert.NoError(t, err)
   261  	requestWithInvalidToken, err := http.NewRequest(http.MethodGet, "http://localhost:4000/api/logout", nil)
   262  	assert.NoError(t, err)
   263  	requestWithInvalidToken.Header = invalidHeader
   264  	invalidRequest, err := http.NewRequest(http.MethodGet, "http://localhost:4000/api/logout", nil)
   265  	assert.NoError(t, err)
   266  
   267  	tests := []struct {
   268  		name              string
   269  		kubeClient        *fake.Clientset
   270  		handler           http.Handler
   271  		request           *http.Request
   272  		responseRecorder  *httptest.ResponseRecorder
   273  		expectedLogoutURL string
   274  		wantErr           bool
   275  	}{
   276  		{
   277  			name:              "Case: OIDC logout request with valid token",
   278  			handler:           oidcHandler,
   279  			request:           oidcRequest,
   280  			responseRecorder:  httptest.NewRecorder(),
   281  			expectedLogoutURL: expectedOIDCLogoutURL,
   282  			wantErr:           false,
   283  		},
   284  		{
   285  			name:              "Case: OIDC logout request with valid token but missing URL",
   286  			handler:           oidcHandlerWithoutBaseURL,
   287  			request:           oidcRequest,
   288  			responseRecorder:  httptest.NewRecorder(),
   289  			expectedLogoutURL: expectedOIDCLogoutURLWithRootPath,
   290  			wantErr:           false,
   291  		},
   292  		{
   293  			name:              "Case: non-OIDC logout request with valid token",
   294  			handler:           nonoidcHandler,
   295  			request:           nonoidcRequest,
   296  			responseRecorder:  httptest.NewRecorder(),
   297  			expectedLogoutURL: expectedNonOIDCLogoutURL,
   298  			wantErr:           false,
   299  		},
   300  		{
   301  			name:              "Case: Logout request with invalid token",
   302  			handler:           nonoidcHandler,
   303  			request:           requestWithInvalidToken,
   304  			responseRecorder:  httptest.NewRecorder(),
   305  			expectedLogoutURL: expectedNonOIDCLogoutURL,
   306  			wantErr:           false,
   307  		},
   308  		{
   309  			name:              "Case: Logout request with missing token",
   310  			handler:           oidcHandler,
   311  			request:           invalidRequest,
   312  			responseRecorder:  httptest.NewRecorder(),
   313  			expectedLogoutURL: expectedNonOIDCLogoutURL,
   314  			wantErr:           true,
   315  		},
   316  		{
   317  			name:              "Case:OIDC Logout request with missing logout URL configuration in config map",
   318  			handler:           oidcHandlerWithoutLogoutURL,
   319  			request:           oidcRequest,
   320  			responseRecorder:  httptest.NewRecorder(),
   321  			expectedLogoutURL: expectedNonOIDCLogoutURL,
   322  			wantErr:           false,
   323  		},
   324  	}
   325  	for _, tt := range tests {
   326  		t.Run(tt.name, func(t *testing.T) {
   327  			tt.handler.ServeHTTP(tt.responseRecorder, tt.request)
   328  			if status := tt.responseRecorder.Code; status != http.StatusSeeOther {
   329  				if !tt.wantErr {
   330  					t.Errorf(tt.responseRecorder.Body.String())
   331  					t.Errorf("handler returned wrong status code: " + fmt.Sprintf("%d", tt.responseRecorder.Code))
   332  				}
   333  			} else {
   334  				if tt.wantErr {
   335  					t.Errorf("expected error but did not get one")
   336  				} else {
   337  					assert.Equal(t, tt.expectedLogoutURL, tt.responseRecorder.Result().Header["Location"][0])
   338  				}
   339  			}
   340  		})
   341  	}
   342  }