github.com/argoproj/argo-cd/v3@v3.2.1/util/oidc/oidc_test.go (about) 1 package oidc 2 3 import ( 4 "crypto/tls" 5 "encoding/hex" 6 "encoding/json" 7 "fmt" 8 "net/http" 9 "net/http/httptest" 10 "net/url" 11 "os" 12 "path/filepath" 13 "strings" 14 "sync" 15 "testing" 16 "time" 17 18 gooidc "github.com/coreos/go-oidc/v3/oidc" 19 "github.com/golang-jwt/jwt/v5" 20 "github.com/stretchr/testify/assert" 21 "github.com/stretchr/testify/require" 22 "golang.org/x/oauth2" 23 "sigs.k8s.io/yaml" 24 25 "github.com/argoproj/argo-cd/v3/common" 26 "github.com/argoproj/argo-cd/v3/server/settings/oidc" 27 "github.com/argoproj/argo-cd/v3/util" 28 "github.com/argoproj/argo-cd/v3/util/cache" 29 "github.com/argoproj/argo-cd/v3/util/crypto" 30 "github.com/argoproj/argo-cd/v3/util/dex" 31 "github.com/argoproj/argo-cd/v3/util/settings" 32 "github.com/argoproj/argo-cd/v3/util/test" 33 ) 34 35 func setupAzureIdentity(t *testing.T) { 36 t.Helper() 37 38 tempDir := t.TempDir() 39 tokenFilePath := filepath.Join(tempDir, "token.txt") 40 tempFile, err := os.Create(tokenFilePath) 41 require.NoError(t, err) 42 _, err = tempFile.WriteString("serviceAccountToken") 43 require.NoError(t, err) 44 t.Setenv("AZURE_FEDERATED_TOKEN_FILE", tokenFilePath) 45 } 46 47 func TestInferGrantType(t *testing.T) { 48 for _, path := range []string{"dex", "okta", "auth0", "onelogin"} { 49 t.Run(path, func(t *testing.T) { 50 rawConfig, err := os.ReadFile("testdata/" + path + ".json") 51 require.NoError(t, err) 52 var config OIDCConfiguration 53 err = json.Unmarshal(rawConfig, &config) 54 require.NoError(t, err) 55 grantType := InferGrantType(&config) 56 assert.Equal(t, GrantTypeAuthorizationCode, grantType) 57 58 var noCodeResponseTypes []string 59 for _, supportedResponseType := range config.ResponseTypesSupported { 60 if supportedResponseType != ResponseTypeCode { 61 noCodeResponseTypes = append(noCodeResponseTypes, supportedResponseType) 62 } 63 } 64 65 config.ResponseTypesSupported = noCodeResponseTypes 66 grantType = InferGrantType(&config) 67 assert.Equal(t, GrantTypeImplicit, grantType) 68 }) 69 } 70 } 71 72 func TestIDTokenClaims(t *testing.T) { 73 oauth2Config := &oauth2.Config{ 74 ClientID: "DUMMY_OIDC_PROVIDER", 75 ClientSecret: "0987654321", 76 Endpoint: oauth2.Endpoint{AuthURL: "https://argocd-dev.onelogin.com/oidc/auth", TokenURL: "https://argocd-dev.onelogin.com/oidc/token"}, 77 Scopes: []string{"oidc", "profile", "groups"}, 78 RedirectURL: "https://argocd-dev.io/redirect_url", 79 } 80 81 var opts []oauth2.AuthCodeOption 82 requestedClaims := make(map[string]*oidc.Claim) 83 84 opts = AppendClaimsAuthenticationRequestParameter(opts, requestedClaims) 85 assert.Empty(t, opts) 86 87 requestedClaims["groups"] = &oidc.Claim{Essential: true} 88 opts = AppendClaimsAuthenticationRequestParameter(opts, requestedClaims) 89 assert.Len(t, opts, 1) 90 91 authCodeURL, err := url.Parse(oauth2Config.AuthCodeURL("TEST", opts...)) 92 require.NoError(t, err) 93 94 values, err := url.ParseQuery(authCodeURL.RawQuery) 95 require.NoError(t, err) 96 97 assert.JSONEq(t, "{\"id_token\":{\"groups\":{\"essential\":true}}}", values.Get("claims")) 98 } 99 100 type fakeProvider struct{} 101 102 func (p *fakeProvider) Endpoint() (*oauth2.Endpoint, error) { 103 return &oauth2.Endpoint{}, nil 104 } 105 106 func (p *fakeProvider) ParseConfig() (*OIDCConfiguration, error) { 107 return nil, nil 108 } 109 110 func (p *fakeProvider) Verify(_ string, _ *settings.ArgoCDSettings) (*gooidc.IDToken, error) { 111 return nil, nil 112 } 113 114 func TestHandleCallback(t *testing.T) { 115 app := ClientApp{provider: &fakeProvider{}, settings: &settings.ArgoCDSettings{}} 116 117 req := httptest.NewRequest(http.MethodGet, "http://example.com/foo", http.NoBody) 118 req.Form = url.Values{ 119 "error": []string{"login-failed"}, 120 "error_description": []string{"<script>alert('hello')</script>"}, 121 } 122 w := httptest.NewRecorder() 123 124 app.HandleCallback(w, req) 125 126 assert.Equal(t, "login-failed: <script>alert('hello')</script>\n", w.Body.String()) 127 } 128 129 func TestClientApp_HandleLogin(t *testing.T) { 130 oidcTestServer := test.GetOIDCTestServer(t, nil) 131 t.Cleanup(oidcTestServer.Close) 132 133 dexTestServer := test.GetDexTestServer(t) 134 t.Cleanup(dexTestServer.Close) 135 136 t.Run("oidc certificate checking during login should toggle on config", func(t *testing.T) { 137 cdSettings := &settings.ArgoCDSettings{ 138 URL: "https://argocd.example.com", 139 OIDCConfigRAW: fmt.Sprintf(` 140 name: Test 141 issuer: %s 142 clientID: xxx 143 clientSecret: yyy 144 requestedScopes: ["oidc"]`, oidcTestServer.URL), 145 } 146 app, err := NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 147 require.NoError(t, err) 148 149 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody) 150 151 w := httptest.NewRecorder() 152 153 app.HandleLogin(w, req) 154 155 assert.Contains(t, w.Body.String(), "certificate signed by unknown authority") 156 157 cdSettings.OIDCTLSInsecureSkipVerify = true 158 159 app, err = NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 160 require.NoError(t, err) 161 162 w = httptest.NewRecorder() 163 164 app.HandleLogin(w, req) 165 166 assert.NotContains(t, w.Body.String(), "certificate is not trusted") 167 assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority") 168 }) 169 170 t.Run("dex certificate checking during login should toggle on config", func(t *testing.T) { 171 cdSettings := &settings.ArgoCDSettings{ 172 URL: "https://argocd.example.com", 173 DexConfig: `connectors: 174 - type: github 175 name: GitHub 176 config: 177 clientID: aabbccddeeff00112233 178 clientSecret: aabbccddeeff00112233`, 179 } 180 cert, err := tls.X509KeyPair(test.Cert, test.PrivateKey) 181 require.NoError(t, err) 182 cdSettings.Certificate = &cert 183 app, err := NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 184 require.NoError(t, err) 185 186 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody) 187 188 w := httptest.NewRecorder() 189 190 app.HandleLogin(w, req) 191 192 if !strings.Contains(w.Body.String(), "certificate signed by unknown authority") && !strings.Contains(w.Body.String(), "certificate is not trusted") { 193 t.Fatal("did not receive expected certificate verification failure error") 194 } 195 196 app, err = NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 197 require.NoError(t, err) 198 199 w = httptest.NewRecorder() 200 201 app.HandleLogin(w, req) 202 203 assert.NotContains(t, w.Body.String(), "certificate is not trusted") 204 assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority") 205 }) 206 207 t.Run("OIDC auth", func(t *testing.T) { 208 cdSettings := &settings.ArgoCDSettings{ 209 URL: "https://argocd.example.com", 210 OIDCTLSInsecureSkipVerify: true, 211 } 212 oidcConfig := settings.OIDCConfig{ 213 Name: "Test", 214 Issuer: oidcTestServer.URL, 215 ClientID: "xxx", 216 ClientSecret: "yyy", 217 } 218 oidcConfigRaw, err := yaml.Marshal(oidcConfig) 219 require.NoError(t, err) 220 cdSettings.OIDCConfigRAW = string(oidcConfigRaw) 221 222 app, err := NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 223 require.NoError(t, err) 224 225 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody) 226 w := httptest.NewRecorder() 227 app.HandleLogin(w, req) 228 229 assert.Equal(t, http.StatusSeeOther, w.Code) 230 location, err := url.Parse(w.Header().Get("Location")) 231 require.NoError(t, err) 232 values, err := url.ParseQuery(location.RawQuery) 233 require.NoError(t, err) 234 assert.Equal(t, []string{"openid", "profile", "email", "groups"}, strings.Split(values.Get("scope"), " ")) 235 assert.Equal(t, "xxx", values.Get("client_id")) 236 assert.Equal(t, "code", values.Get("response_type")) 237 }) 238 239 t.Run("OIDC auth with custom scopes", func(t *testing.T) { 240 cdSettings := &settings.ArgoCDSettings{ 241 URL: "https://argocd.example.com", 242 OIDCTLSInsecureSkipVerify: true, 243 } 244 oidcConfig := settings.OIDCConfig{ 245 Name: "Test", 246 Issuer: oidcTestServer.URL, 247 ClientID: "xxx", 248 ClientSecret: "yyy", 249 RequestedScopes: []string{"oidc"}, 250 } 251 oidcConfigRaw, err := yaml.Marshal(oidcConfig) 252 require.NoError(t, err) 253 cdSettings.OIDCConfigRAW = string(oidcConfigRaw) 254 255 app, err := NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 256 require.NoError(t, err) 257 258 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody) 259 w := httptest.NewRecorder() 260 app.HandleLogin(w, req) 261 262 assert.Equal(t, http.StatusSeeOther, w.Code) 263 location, err := url.Parse(w.Header().Get("Location")) 264 require.NoError(t, err) 265 values, err := url.ParseQuery(location.RawQuery) 266 require.NoError(t, err) 267 assert.Equal(t, []string{"oidc"}, strings.Split(values.Get("scope"), " ")) 268 assert.Equal(t, "xxx", values.Get("client_id")) 269 assert.Equal(t, "code", values.Get("response_type")) 270 }) 271 272 t.Run("Dex auth", func(t *testing.T) { 273 cdSettings := &settings.ArgoCDSettings{ 274 URL: dexTestServer.URL, 275 } 276 dexConfig := map[string]any{ 277 "connectors": []map[string]any{ 278 { 279 "type": "github", 280 "name": "GitHub", 281 "config": map[string]any{ 282 "clientId": "aabbccddeeff00112233", 283 "clientSecret": "aabbccddeeff00112233", 284 }, 285 }, 286 }, 287 } 288 dexConfigRaw, err := yaml.Marshal(dexConfig) 289 require.NoError(t, err) 290 cdSettings.DexConfig = string(dexConfigRaw) 291 292 app, err := NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 293 require.NoError(t, err) 294 295 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody) 296 w := httptest.NewRecorder() 297 app.HandleLogin(w, req) 298 299 assert.Equal(t, http.StatusSeeOther, w.Code) 300 location, err := url.Parse(w.Header().Get("Location")) 301 require.NoError(t, err) 302 values, err := url.ParseQuery(location.RawQuery) 303 require.NoError(t, err) 304 assert.Equal(t, []string{"openid", "profile", "email", "groups", common.DexFederatedScope}, strings.Split(values.Get("scope"), " ")) 305 assert.Equal(t, common.ArgoCDClientAppID, values.Get("client_id")) 306 assert.Equal(t, "code", values.Get("response_type")) 307 }) 308 309 t.Run("with additional base URL", func(t *testing.T) { 310 cdSettings := &settings.ArgoCDSettings{ 311 URL: "https://argocd.example.com", 312 AdditionalURLs: []string{"https://localhost:8080", "https://other.argocd.example.com"}, 313 OIDCTLSInsecureSkipVerify: true, 314 DexConfig: `connectors: 315 - type: github 316 name: GitHub 317 config: 318 clientID: aabbccddeeff00112233 319 clientSecret: aabbccddeeff00112233`, 320 OIDCConfigRAW: fmt.Sprintf(` 321 name: Test 322 issuer: %s 323 clientID: xxx 324 clientSecret: yyy 325 requestedScopes: ["oidc"]`, oidcTestServer.URL), 326 } 327 cert, err := tls.X509KeyPair(test.Cert, test.PrivateKey) 328 require.NoError(t, err) 329 cdSettings.Certificate = &cert 330 app, err := NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 331 require.NoError(t, err) 332 333 t.Run("should accept login redirecting on the main domain", func(t *testing.T) { 334 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody) 335 336 req.URL.RawQuery = url.Values{ 337 "return_url": []string{"https://argocd.example.com/applications"}, 338 }.Encode() 339 340 w := httptest.NewRecorder() 341 342 app.HandleLogin(w, req) 343 344 assert.Equal(t, http.StatusSeeOther, w.Code) 345 location, err := url.Parse(w.Header().Get("Location")) 346 require.NoError(t, err) 347 assert.Equal(t, fmt.Sprintf("%s://%s", location.Scheme, location.Host), oidcTestServer.URL) 348 assert.Equal(t, "/auth", location.Path) 349 assert.Equal(t, "https://argocd.example.com/auth/callback", location.Query().Get("redirect_uri")) 350 }) 351 352 t.Run("should accept login redirecting on the alternative domains", func(t *testing.T) { 353 req := httptest.NewRequest(http.MethodGet, "https://localhost:8080/auth/login", http.NoBody) 354 355 req.URL.RawQuery = url.Values{ 356 "return_url": []string{"https://localhost:8080/applications"}, 357 }.Encode() 358 359 w := httptest.NewRecorder() 360 361 app.HandleLogin(w, req) 362 363 assert.Equal(t, http.StatusSeeOther, w.Code) 364 location, err := url.Parse(w.Header().Get("Location")) 365 require.NoError(t, err) 366 assert.Equal(t, fmt.Sprintf("%s://%s", location.Scheme, location.Host), oidcTestServer.URL) 367 assert.Equal(t, "/auth", location.Path) 368 assert.Equal(t, "https://localhost:8080/auth/callback", location.Query().Get("redirect_uri")) 369 }) 370 371 t.Run("should accept login redirecting on the alternative domains", func(t *testing.T) { 372 req := httptest.NewRequest(http.MethodGet, "https://other.argocd.example.com/auth/login", http.NoBody) 373 374 req.URL.RawQuery = url.Values{ 375 "return_url": []string{"https://other.argocd.example.com/applications"}, 376 }.Encode() 377 378 w := httptest.NewRecorder() 379 380 app.HandleLogin(w, req) 381 382 assert.Equal(t, http.StatusSeeOther, w.Code) 383 location, err := url.Parse(w.Header().Get("Location")) 384 require.NoError(t, err) 385 assert.Equal(t, fmt.Sprintf("%s://%s", location.Scheme, location.Host), oidcTestServer.URL) 386 assert.Equal(t, "/auth", location.Path) 387 assert.Equal(t, "https://other.argocd.example.com/auth/callback", location.Query().Get("redirect_uri")) 388 }) 389 390 t.Run("should deny login redirecting on the alternative domains", func(t *testing.T) { 391 req := httptest.NewRequest(http.MethodGet, "https://not-argocd.example.com/auth/login", http.NoBody) 392 393 req.URL.RawQuery = url.Values{ 394 "return_url": []string{"https://not-argocd.example.com/applications"}, 395 }.Encode() 396 397 w := httptest.NewRecorder() 398 399 app.HandleLogin(w, req) 400 401 assert.Equal(t, http.StatusBadRequest, w.Code) 402 assert.Empty(t, w.Header().Get("Location")) 403 }) 404 }) 405 } 406 407 func Test_Login_Flow(t *testing.T) { 408 // Show that SSO login works when no redirect URL is provided, and we fall back to the configured base href for the 409 // Argo CD instance. 410 411 oidcTestServer := test.GetOIDCTestServer(t, nil) 412 t.Cleanup(oidcTestServer.Close) 413 414 cdSettings := &settings.ArgoCDSettings{ 415 URL: "https://argocd.example.com", 416 OIDCConfigRAW: fmt.Sprintf(` 417 name: Test 418 issuer: %s 419 clientID: test-client-id 420 clientSecret: test-client-secret 421 requestedScopes: ["oidc"]`, oidcTestServer.URL), 422 OIDCTLSInsecureSkipVerify: true, 423 } 424 // The base href (the last argument for NewClientApp) is what HandleLogin will fall back to when no explicit 425 // redirect URL is given. 426 app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour)) 427 require.NoError(t, err) 428 429 w := httptest.NewRecorder() 430 431 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/login", http.NoBody) 432 433 app.HandleLogin(w, req) 434 435 redirectURL, err := w.Result().Location() 436 require.NoError(t, err) 437 438 state := redirectURL.Query().Get("state") 439 440 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("https://argocd.example.com/auth/callback?state=%s&code=abc", state), http.NoBody) 441 for _, cookie := range w.Result().Cookies() { 442 req.AddCookie(cookie) 443 } 444 445 w = httptest.NewRecorder() 446 447 app.HandleCallback(w, req) 448 449 assert.Equal(t, 303, w.Code) 450 assert.NotContains(t, w.Body.String(), ErrInvalidRedirectURL.Error()) 451 } 452 453 func Test_Login_Flow_With_PKCE(t *testing.T) { 454 var codeChallenge string 455 456 oidcTestServer := test.GetOIDCTestServer(t, func(r *http.Request) { 457 codeVerifier := r.FormValue("code_verifier") 458 assert.NotEmpty(t, codeVerifier) 459 assert.Equal(t, oauth2.S256ChallengeFromVerifier(codeVerifier), codeChallenge) 460 }) 461 t.Cleanup(oidcTestServer.Close) 462 463 cdSettings := &settings.ArgoCDSettings{ 464 URL: "https://example.com/argocd", 465 OIDCConfigRAW: fmt.Sprintf(` 466 name: Test 467 issuer: %s 468 clientID: test-client-id 469 clientSecret: test-client-secret 470 requestedScopes: ["oidc"] 471 enablePKCEAuthentication: true`, oidcTestServer.URL), 472 OIDCTLSInsecureSkipVerify: true, 473 } 474 app, err := NewClientApp(cdSettings, "", nil, "/", cache.NewInMemoryCache(24*time.Hour)) 475 require.NoError(t, err) 476 477 w := httptest.NewRecorder() 478 479 req := httptest.NewRequest(http.MethodGet, "https://example.com/argocd/auth/login", http.NoBody) 480 481 app.HandleLogin(w, req) 482 483 redirectURL, err := w.Result().Location() 484 require.NoError(t, err) 485 486 codeChallenge = redirectURL.Query().Get("code_challenge") 487 488 assert.NotEmpty(t, codeChallenge) 489 assert.Equal(t, "S256", redirectURL.Query().Get("code_challenge_method")) 490 491 state := redirectURL.Query().Get("state") 492 493 req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("https://example.com/argocd/auth/callback?state=%s&code=abc", state), http.NoBody) 494 for _, cookie := range w.Result().Cookies() { 495 req.AddCookie(cookie) 496 } 497 498 w = httptest.NewRecorder() 499 500 app.HandleCallback(w, req) 501 502 assert.Equal(t, 303, w.Code) 503 assert.NotContains(t, w.Body.String(), ErrInvalidRedirectURL.Error()) 504 } 505 506 func TestClientApp_HandleCallback(t *testing.T) { 507 oidcTestServer := test.GetOIDCTestServer(t, nil) 508 t.Cleanup(oidcTestServer.Close) 509 510 dexTestServer := test.GetDexTestServer(t) 511 t.Cleanup(dexTestServer.Close) 512 513 t.Run("oidc certificate checking during oidc callback should toggle on config", func(t *testing.T) { 514 cdSettings := &settings.ArgoCDSettings{ 515 URL: "https://argocd.example.com", 516 OIDCConfigRAW: fmt.Sprintf(` 517 name: Test 518 issuer: %s 519 clientID: xxx 520 clientSecret: yyy 521 requestedScopes: ["oidc"]`, oidcTestServer.URL), 522 } 523 app, err := NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 524 require.NoError(t, err) 525 526 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/callback", http.NoBody) 527 528 w := httptest.NewRecorder() 529 530 app.HandleCallback(w, req) 531 532 if !strings.Contains(w.Body.String(), "certificate signed by unknown authority") && !strings.Contains(w.Body.String(), "certificate is not trusted") { 533 t.Fatal("did not receive expected certificate verification failure error") 534 } 535 536 cdSettings.OIDCTLSInsecureSkipVerify = true 537 538 app, err = NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 539 require.NoError(t, err) 540 541 w = httptest.NewRecorder() 542 543 app.HandleCallback(w, req) 544 545 assert.NotContains(t, w.Body.String(), "certificate is not trusted") 546 assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority") 547 }) 548 549 t.Run("dex certificate checking during oidc callback should toggle on config", func(t *testing.T) { 550 cdSettings := &settings.ArgoCDSettings{ 551 URL: "https://argocd.example.com", 552 DexConfig: `connectors: 553 - type: github 554 name: GitHub 555 config: 556 clientID: aabbccddeeff00112233 557 clientSecret: aabbccddeeff00112233`, 558 } 559 cert, err := tls.X509KeyPair(test.Cert, test.PrivateKey) 560 require.NoError(t, err) 561 cdSettings.Certificate = &cert 562 app, err := NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 563 require.NoError(t, err) 564 565 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/callback", http.NoBody) 566 567 w := httptest.NewRecorder() 568 569 app.HandleCallback(w, req) 570 571 if !strings.Contains(w.Body.String(), "certificate signed by unknown authority") && !strings.Contains(w.Body.String(), "certificate is not trusted") { 572 t.Fatal("did not receive expected certificate verification failure error") 573 } 574 575 app, err = NewClientApp(cdSettings, dexTestServer.URL, &dex.DexTLSConfig{StrictValidation: false}, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 576 require.NoError(t, err) 577 578 w = httptest.NewRecorder() 579 580 app.HandleCallback(w, req) 581 582 assert.NotContains(t, w.Body.String(), "certificate is not trusted") 583 assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority") 584 }) 585 } 586 587 func Test_azureApp_getFederatedServiceAccountToken(t *testing.T) { 588 app := azureApp{mtx: &sync.RWMutex{}} 589 590 setupAzureIdentity(t) 591 592 t.Run("before the method call assertion should be empty.", func(t *testing.T) { 593 assert.Empty(t, app.assertion) 594 }) 595 596 t.Run("Fetch the token value from the file", func(t *testing.T) { 597 _, err := app.getFederatedServiceAccountToken(t.Context()) 598 require.NoError(t, err) 599 assert.Equal(t, "serviceAccountToken", app.assertion) 600 }) 601 602 t.Run("Workload Identity Not enabled.", func(t *testing.T) { 603 t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "") 604 _, err := app.getFederatedServiceAccountToken(t.Context()) 605 assert.ErrorContains(t, err, "AZURE_FEDERATED_TOKEN_FILE env variable not found, make sure workload identity is enabled on the cluster") 606 }) 607 608 t.Run("Workload Identity invalid file", func(t *testing.T) { 609 t.Setenv("AZURE_FEDERATED_TOKEN_FILE", filepath.Join(t.TempDir(), "invalid.txt")) 610 _, err := app.getFederatedServiceAccountToken(t.Context()) 611 assert.ErrorContains(t, err, "AZURE_FEDERATED_TOKEN_FILE specified file does not exist") 612 }) 613 614 t.Run("Concurrent access to the function", func(t *testing.T) { 615 currentExpiryTime := app.expires 616 617 var wg sync.WaitGroup 618 numGoroutines := 10 619 wg.Add(numGoroutines) 620 for i := 0; i < numGoroutines; i++ { 621 go func() { 622 defer wg.Done() 623 _, err := app.getFederatedServiceAccountToken(t.Context()) 624 require.NoError(t, err) 625 assert.Equal(t, "serviceAccountToken", app.assertion) 626 }() 627 } 628 wg.Wait() 629 630 // Event with multiple concurrent calls the expiry time should not change untile it passes. 631 assert.Equal(t, currentExpiryTime, app.expires) 632 }) 633 634 t.Run("Concurrent access to the function when the current token expires", func(t *testing.T) { 635 var wg sync.WaitGroup 636 currentExpiryTime := app.expires 637 app.expires = time.Now() 638 numGoroutines := 10 639 wg.Add(numGoroutines) 640 for i := 0; i < numGoroutines; i++ { 641 go func() { 642 defer wg.Done() 643 _, err := app.getFederatedServiceAccountToken(t.Context()) 644 require.NoError(t, err) 645 assert.Equal(t, "serviceAccountToken", app.assertion) 646 }() 647 } 648 wg.Wait() 649 650 assert.NotEqual(t, currentExpiryTime, app.expires) 651 }) 652 } 653 654 func TestClientAppWithAzureWorkloadIdentity_HandleCallback(t *testing.T) { 655 tokenRequestAssertions := func(r *http.Request) { 656 err := r.ParseForm() 657 require.NoError(t, err) 658 659 formData := r.Form 660 clientAssertion := formData.Get("client_assertion") 661 clientAssertionType := formData.Get("client_assertion_type") 662 assert.Equal(t, "serviceAccountToken", clientAssertion) 663 assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", clientAssertionType) 664 } 665 666 oidcTestServer := test.GetAzureOIDCTestServer(t, tokenRequestAssertions) 667 t.Cleanup(oidcTestServer.Close) 668 669 dexTestServer := test.GetDexTestServer(t) 670 t.Cleanup(dexTestServer.Close) 671 signature, err := util.MakeSignature(32) 672 require.NoError(t, err) 673 674 setupAzureIdentity(t) 675 676 t.Run("oidc certificate checking during oidc callback should toggle on config", func(t *testing.T) { 677 cdSettings := &settings.ArgoCDSettings{ 678 URL: "https://argocd.example.com", 679 ServerSignature: signature, 680 OIDCConfigRAW: fmt.Sprintf(` 681 name: Test 682 issuer: %s 683 clientID: xxx 684 azure: 685 useWorkloadIdentity: true 686 skipAudienceCheckWhenTokenHasNoAudience: true 687 requestedScopes: ["oidc"]`, oidcTestServer.URL), 688 } 689 690 app, err := NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 691 require.NoError(t, err) 692 693 req := httptest.NewRequest(http.MethodGet, "https://argocd.example.com/auth/callback", http.NoBody) 694 req.Form = url.Values{ 695 "code": {"abc"}, 696 "state": {"123"}, 697 } 698 w := httptest.NewRecorder() 699 700 app.HandleCallback(w, req) 701 702 if !strings.Contains(w.Body.String(), "certificate signed by unknown authority") && !strings.Contains(w.Body.String(), "certificate is not trusted") { 703 t.Fatal("did not receive expected certificate verification failure error") 704 } 705 706 cdSettings.OIDCTLSInsecureSkipVerify = true 707 708 app, err = NewClientApp(cdSettings, dexTestServer.URL, nil, "https://argocd.example.com", cache.NewInMemoryCache(24*time.Hour)) 709 require.NoError(t, err) 710 711 w = httptest.NewRecorder() 712 713 key, err := cdSettings.GetServerEncryptionKey() 714 require.NoError(t, err) 715 encrypted, _ := crypto.Encrypt([]byte("123"), key) 716 req.AddCookie(&http.Cookie{Name: common.StateCookieName, Value: hex.EncodeToString(encrypted)}) 717 718 app.HandleCallback(w, req) 719 720 assert.NotContains(t, w.Body.String(), "certificate is not trusted") 721 assert.NotContains(t, w.Body.String(), "certificate signed by unknown authority") 722 }) 723 } 724 725 func TestIsValidRedirect(t *testing.T) { 726 tests := []struct { 727 name string 728 valid bool 729 redirectURL string 730 allowedURLs []string 731 }{ 732 { 733 name: "Single allowed valid URL", 734 valid: true, 735 redirectURL: "https://localhost:4000", 736 allowedURLs: []string{"https://localhost:4000/"}, 737 }, 738 { 739 name: "Empty URL", 740 valid: true, 741 redirectURL: "", 742 allowedURLs: []string{"https://localhost:4000/"}, 743 }, 744 { 745 name: "Trailing single slash and empty suffix are handled the same", 746 valid: true, 747 redirectURL: "https://localhost:4000/", 748 allowedURLs: []string{"https://localhost:4000"}, 749 }, 750 { 751 name: "Multiple valid URLs with one allowed", 752 valid: true, 753 redirectURL: "https://localhost:4000", 754 allowedURLs: []string{"https://wherever:4000", "https://localhost:4000"}, 755 }, 756 { 757 name: "Multiple valid URLs with none allowed", 758 valid: false, 759 redirectURL: "https://localhost:4000", 760 allowedURLs: []string{"https://wherever:4000", "https://invalid:4000"}, 761 }, 762 { 763 name: "Invalid redirect URL because path prefix does not match", 764 valid: false, 765 redirectURL: "https://localhost:4000/applications", 766 allowedURLs: []string{"https://localhost:4000/argocd"}, 767 }, 768 { 769 name: "Valid redirect URL because prefix matches", 770 valid: true, 771 redirectURL: "https://localhost:4000/argocd/applications", 772 allowedURLs: []string{"https://localhost:4000/argocd"}, 773 }, 774 { 775 name: "Invalid redirect URL because resolved path does not match prefix", 776 valid: false, 777 redirectURL: "https://localhost:4000/argocd/../applications", 778 allowedURLs: []string{"https://localhost:4000/argocd"}, 779 }, 780 { 781 name: "Invalid redirect URL because scheme mismatch", 782 valid: false, 783 redirectURL: "http://localhost:4000", 784 allowedURLs: []string{"https://localhost:4000"}, 785 }, 786 { 787 name: "Invalid redirect URL because port mismatch", 788 valid: false, 789 redirectURL: "https://localhost", 790 allowedURLs: []string{"https://localhost:80"}, 791 }, 792 { 793 name: "Invalid redirect URL because of CRLF in path", 794 valid: false, 795 redirectURL: "https://localhost:80/argocd\r\n", 796 allowedURLs: []string{"https://localhost:80/argocd\r\n"}, 797 }, 798 } 799 800 for _, tt := range tests { 801 t.Run(tt.name, func(t *testing.T) { 802 res := isValidRedirectURL(tt.redirectURL, tt.allowedURLs) 803 assert.Equal(t, res, tt.valid) 804 }) 805 } 806 } 807 808 func TestGenerateAppState(t *testing.T) { 809 signature, err := util.MakeSignature(32) 810 require.NoError(t, err) 811 expectedReturnURL := "http://argocd.example.com/" 812 app, err := NewClientApp(&settings.ArgoCDSettings{ServerSignature: signature, URL: expectedReturnURL}, "", nil, "", cache.NewInMemoryCache(24*time.Hour)) 813 require.NoError(t, err) 814 generateResponse := httptest.NewRecorder() 815 expectedPKCEVerifier := oauth2.GenerateVerifier() 816 state, err := app.generateAppState(expectedReturnURL, expectedPKCEVerifier, generateResponse) 817 require.NoError(t, err) 818 819 t.Run("VerifyAppState_Successful", func(t *testing.T) { 820 req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) 821 for _, cookie := range generateResponse.Result().Cookies() { 822 req.AddCookie(cookie) 823 } 824 825 returnURL, pkceVerifier, err := app.verifyAppState(req, httptest.NewRecorder(), state) 826 require.NoError(t, err) 827 assert.Equal(t, expectedReturnURL, returnURL) 828 assert.Equal(t, expectedPKCEVerifier, pkceVerifier) 829 }) 830 831 t.Run("VerifyAppState_Failed", func(t *testing.T) { 832 req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) 833 for _, cookie := range generateResponse.Result().Cookies() { 834 req.AddCookie(cookie) 835 } 836 837 _, _, err := app.verifyAppState(req, httptest.NewRecorder(), "wrong state") 838 require.Error(t, err) 839 }) 840 } 841 842 func TestGenerateAppState_XSS(t *testing.T) { 843 signature, err := util.MakeSignature(32) 844 require.NoError(t, err) 845 app, err := NewClientApp( 846 &settings.ArgoCDSettings{ 847 // Only return URLs starting with this base should be allowed. 848 URL: "https://argocd.example.com", 849 ServerSignature: signature, 850 }, 851 "", nil, "", cache.NewInMemoryCache(24*time.Hour), 852 ) 853 require.NoError(t, err) 854 855 t.Run("XSS fails", func(t *testing.T) { 856 // This attack assumes the attacker has compromised the server's secret key. We use `generateAppState` here for 857 // convenience, but an attacker with access to the server secret could write their own code to generate the 858 // malicious cookie. 859 860 expectedReturnURL := "javascript: alert('hi')" 861 generateResponse := httptest.NewRecorder() 862 state, err := app.generateAppState(expectedReturnURL, "", generateResponse) 863 require.NoError(t, err) 864 865 req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) 866 for _, cookie := range generateResponse.Result().Cookies() { 867 req.AddCookie(cookie) 868 } 869 870 returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), state) 871 require.ErrorIs(t, err, ErrInvalidRedirectURL) 872 assert.Empty(t, returnURL) 873 }) 874 875 t.Run("valid return URL succeeds", func(t *testing.T) { 876 expectedReturnURL := "https://argocd.example.com/some/path" 877 generateResponse := httptest.NewRecorder() 878 state, err := app.generateAppState(expectedReturnURL, "", generateResponse) 879 require.NoError(t, err) 880 881 req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) 882 for _, cookie := range generateResponse.Result().Cookies() { 883 req.AddCookie(cookie) 884 } 885 886 returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), state) 887 require.NoError(t, err) 888 assert.Equal(t, expectedReturnURL, returnURL) 889 }) 890 } 891 892 func TestGenerateAppState_NoReturnURL(t *testing.T) { 893 signature, err := util.MakeSignature(32) 894 require.NoError(t, err) 895 cdSettings := &settings.ArgoCDSettings{ServerSignature: signature} 896 key, err := cdSettings.GetServerEncryptionKey() 897 require.NoError(t, err) 898 899 req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) 900 encrypted, err := crypto.Encrypt([]byte("123"), key) 901 require.NoError(t, err) 902 app, err := NewClientApp(cdSettings, "", nil, "/argo-cd", cache.NewInMemoryCache(24*time.Hour)) 903 require.NoError(t, err) 904 905 req.AddCookie(&http.Cookie{Name: common.StateCookieName, Value: hex.EncodeToString(encrypted)}) 906 returnURL, _, err := app.verifyAppState(req, httptest.NewRecorder(), "123") 907 require.NoError(t, err) 908 assert.Equal(t, "/argo-cd", returnURL) 909 } 910 911 func TestGetUserInfo(t *testing.T) { 912 tests := []struct { 913 name string 914 userInfoPath string 915 expectedOutput any 916 expectError bool 917 expectUnauthenticated bool 918 expectedCacheItems []struct { // items to check in cache after function call 919 key string 920 value string 921 expectEncrypted bool 922 expectError bool 923 } 924 idpHandler func(w http.ResponseWriter, r *http.Request) 925 idpClaims jwt.MapClaims // as per specification sub and exp are REQUIRED fields 926 cache cache.CacheClient 927 cacheItems []struct { // items to put in cache before execution 928 key string 929 value string 930 encrypt bool 931 } 932 }{ 933 { 934 name: "call UserInfo with wrong userInfoPath", 935 userInfoPath: "/user", 936 expectedOutput: jwt.MapClaims(nil), 937 expectError: true, 938 expectUnauthenticated: false, 939 expectedCacheItems: []struct { 940 key string 941 value string 942 expectEncrypted bool 943 expectError bool 944 }{ 945 { 946 key: formatUserInfoResponseCacheKey("randomUser"), 947 expectError: true, 948 }, 949 }, 950 idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())}, 951 idpHandler: func(w http.ResponseWriter, _ *http.Request) { 952 w.WriteHeader(http.StatusNotFound) 953 }, 954 cache: cache.NewInMemoryCache(24 * time.Hour), 955 cacheItems: []struct { 956 key string 957 value string 958 encrypt bool 959 }{ 960 { 961 key: formatAccessTokenCacheKey("randomUser"), 962 value: "FakeAccessToken", 963 encrypt: true, 964 }, 965 }, 966 }, 967 { 968 name: "call UserInfo with bad accessToken", 969 userInfoPath: "/user-info", 970 expectedOutput: jwt.MapClaims(nil), 971 expectError: false, 972 expectUnauthenticated: true, 973 expectedCacheItems: []struct { 974 key string 975 value string 976 expectEncrypted bool 977 expectError bool 978 }{ 979 { 980 key: formatUserInfoResponseCacheKey("randomUser"), 981 expectError: true, 982 }, 983 }, 984 idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())}, 985 idpHandler: func(w http.ResponseWriter, _ *http.Request) { 986 w.WriteHeader(http.StatusUnauthorized) 987 }, 988 cache: cache.NewInMemoryCache(24 * time.Hour), 989 cacheItems: []struct { 990 key string 991 value string 992 encrypt bool 993 }{ 994 { 995 key: formatAccessTokenCacheKey("randomUser"), 996 value: "FakeAccessToken", 997 encrypt: true, 998 }, 999 }, 1000 }, 1001 { 1002 name: "call UserInfo with garbage returned", 1003 userInfoPath: "/user-info", 1004 expectedOutput: jwt.MapClaims(nil), 1005 expectError: true, 1006 expectUnauthenticated: false, 1007 expectedCacheItems: []struct { 1008 key string 1009 value string 1010 expectEncrypted bool 1011 expectError bool 1012 }{ 1013 { 1014 key: formatUserInfoResponseCacheKey("randomUser"), 1015 expectError: true, 1016 }, 1017 }, 1018 idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())}, 1019 idpHandler: func(w http.ResponseWriter, _ *http.Request) { 1020 userInfoBytes := ` 1021 notevenJsongarbage 1022 ` 1023 _, err := w.Write([]byte(userInfoBytes)) 1024 if err != nil { 1025 w.WriteHeader(http.StatusInternalServerError) 1026 return 1027 } 1028 w.WriteHeader(http.StatusTeapot) 1029 }, 1030 cache: cache.NewInMemoryCache(24 * time.Hour), 1031 cacheItems: []struct { 1032 key string 1033 value string 1034 encrypt bool 1035 }{ 1036 { 1037 key: formatAccessTokenCacheKey("randomUser"), 1038 value: "FakeAccessToken", 1039 encrypt: true, 1040 }, 1041 }, 1042 }, 1043 { 1044 name: "call UserInfo without accessToken in cache", 1045 userInfoPath: "/user-info", 1046 expectedOutput: jwt.MapClaims(nil), 1047 expectError: true, 1048 expectUnauthenticated: true, 1049 expectedCacheItems: []struct { 1050 key string 1051 value string 1052 expectEncrypted bool 1053 expectError bool 1054 }{ 1055 { 1056 key: formatUserInfoResponseCacheKey("randomUser"), 1057 expectError: true, 1058 }, 1059 }, 1060 idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())}, 1061 idpHandler: func(w http.ResponseWriter, _ *http.Request) { 1062 userInfoBytes := ` 1063 { 1064 "groups":["githubOrg:engineers"] 1065 }` 1066 w.Header().Set("content-type", "application/json") 1067 _, err := w.Write([]byte(userInfoBytes)) 1068 if err != nil { 1069 w.WriteHeader(http.StatusInternalServerError) 1070 return 1071 } 1072 w.WriteHeader(http.StatusOK) 1073 }, 1074 cache: cache.NewInMemoryCache(24 * time.Hour), 1075 }, 1076 { 1077 name: "call UserInfo with valid accessToken in cache", 1078 userInfoPath: "/user-info", 1079 expectedOutput: jwt.MapClaims{"groups": []any{"githubOrg:engineers"}}, 1080 expectError: false, 1081 expectUnauthenticated: false, 1082 expectedCacheItems: []struct { 1083 key string 1084 value string 1085 expectEncrypted bool 1086 expectError bool 1087 }{ 1088 { 1089 key: formatUserInfoResponseCacheKey("randomUser"), 1090 value: "{\"groups\":[\"githubOrg:engineers\"]}", 1091 expectEncrypted: true, 1092 expectError: false, 1093 }, 1094 }, 1095 idpClaims: jwt.MapClaims{"sub": "randomUser", "exp": float64(time.Now().Add(5 * time.Minute).Unix())}, 1096 idpHandler: func(w http.ResponseWriter, _ *http.Request) { 1097 userInfoBytes := ` 1098 { 1099 "groups":["githubOrg:engineers"] 1100 }` 1101 w.Header().Set("content-type", "application/json") 1102 _, err := w.Write([]byte(userInfoBytes)) 1103 if err != nil { 1104 w.WriteHeader(http.StatusInternalServerError) 1105 return 1106 } 1107 w.WriteHeader(http.StatusOK) 1108 }, 1109 cache: cache.NewInMemoryCache(24 * time.Hour), 1110 cacheItems: []struct { 1111 key string 1112 value string 1113 encrypt bool 1114 }{ 1115 { 1116 key: formatAccessTokenCacheKey("randomUser"), 1117 value: "FakeAccessToken", 1118 encrypt: true, 1119 }, 1120 }, 1121 }, 1122 } 1123 1124 for _, tt := range tests { 1125 t.Run(tt.name, func(t *testing.T) { 1126 ts := httptest.NewServer(http.HandlerFunc(tt.idpHandler)) 1127 defer ts.Close() 1128 1129 signature, err := util.MakeSignature(32) 1130 require.NoError(t, err) 1131 cdSettings := &settings.ArgoCDSettings{ServerSignature: signature} 1132 encryptionKey, err := cdSettings.GetServerEncryptionKey() 1133 require.NoError(t, err) 1134 a, _ := NewClientApp(cdSettings, "", nil, "/argo-cd", tt.cache) 1135 1136 for _, item := range tt.cacheItems { 1137 var newValue []byte 1138 newValue = []byte(item.value) 1139 if item.encrypt { 1140 newValue, err = crypto.Encrypt([]byte(item.value), encryptionKey) 1141 require.NoError(t, err) 1142 } 1143 err := a.clientCache.Set(&cache.Item{ 1144 Key: item.key, 1145 Object: newValue, 1146 }) 1147 require.NoError(t, err) 1148 } 1149 1150 got, unauthenticated, err := a.GetUserInfo(tt.idpClaims, ts.URL, tt.userInfoPath) 1151 assert.Equal(t, tt.expectedOutput, got) 1152 assert.Equal(t, tt.expectUnauthenticated, unauthenticated) 1153 if tt.expectError { 1154 require.Error(t, err) 1155 } else { 1156 require.NoError(t, err) 1157 } 1158 for _, item := range tt.expectedCacheItems { 1159 var tmpValue []byte 1160 err := a.clientCache.Get(item.key, &tmpValue) 1161 if item.expectError { 1162 require.Error(t, err) 1163 } else { 1164 require.NoError(t, err) 1165 if item.expectEncrypted { 1166 tmpValue, err = crypto.Decrypt(tmpValue, encryptionKey) 1167 require.NoError(t, err) 1168 } 1169 assert.Equal(t, item.value, string(tmpValue)) 1170 } 1171 } 1172 }) 1173 } 1174 }