github.com/argoproj/argo-cd/v3@v3.2.1/util/oidc/oidc.go (about) 1 package oidc 2 3 import ( 4 "context" 5 "encoding/hex" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "html" 10 "html/template" 11 "io" 12 "net" 13 "net/http" 14 "net/url" 15 "os" 16 "path" 17 "strings" 18 "sync" 19 "time" 20 21 gooidc "github.com/coreos/go-oidc/v3/oidc" 22 "github.com/golang-jwt/jwt/v5" 23 log "github.com/sirupsen/logrus" 24 "golang.org/x/oauth2" 25 26 "github.com/argoproj/argo-cd/v3/common" 27 "github.com/argoproj/argo-cd/v3/server/settings/oidc" 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 32 httputil "github.com/argoproj/argo-cd/v3/util/http" 33 jwtutil "github.com/argoproj/argo-cd/v3/util/jwt" 34 "github.com/argoproj/argo-cd/v3/util/rand" 35 "github.com/argoproj/argo-cd/v3/util/settings" 36 ) 37 38 var ErrInvalidRedirectURL = errors.New("invalid return URL") 39 40 const ( 41 GrantTypeAuthorizationCode = "authorization_code" 42 GrantTypeImplicit = "implicit" 43 ResponseTypeCode = "code" 44 UserInfoResponseCachePrefix = "userinfo_response" 45 AccessTokenCachePrefix = "access_token" 46 ) 47 48 // OIDCConfiguration holds a subset of interested fields from the OIDC configuration spec 49 type OIDCConfiguration struct { 50 Issuer string `json:"issuer"` 51 ScopesSupported []string `json:"scopes_supported"` 52 ResponseTypesSupported []string `json:"response_types_supported"` 53 GrantTypesSupported []string `json:"grant_types_supported,omitempty"` 54 } 55 56 type ClaimsRequest struct { 57 IDToken map[string]*oidc.Claim `json:"id_token"` 58 } 59 60 type ClientApp struct { 61 // OAuth2 client ID of this application (e.g. argo-cd) 62 clientID string 63 // OAuth2 client secret of this application 64 clientSecret string 65 // Use Proof Key for Code Exchange (PKCE) 66 usePKCE bool 67 // Use Azure Workload Identity for clientID auth instead of clientSecret 68 useAzureWorkloadIdentity bool 69 // Callback URL for OAuth2 responses (e.g. https://argocd.example.com/auth/callback) 70 redirectURI string 71 // URL of the issuer (e.g. https://argocd.example.com/api/dex) 72 issuerURL string 73 // The URL endpoint at which the ArgoCD server is accessed. 74 baseHRef string 75 // client is the HTTP client which is used to query the IDp 76 client *http.Client 77 // secureCookie indicates if the cookie should be set with the Secure flag, meaning it should 78 // only ever be sent over HTTPS. This value is inferred by the scheme of the redirectURI. 79 secureCookie bool 80 // settings holds Argo CD settings 81 settings *settings.ArgoCDSettings 82 // encryptionKey holds server encryption key 83 encryptionKey []byte 84 // provider is the OIDC provider 85 provider Provider 86 // clientCache represent a cache of sso artifact 87 clientCache cache.CacheClient 88 // properties for azure workload identity. 89 azure azureApp 90 } 91 92 type azureApp struct { 93 // federated azure token for the service account 94 assertion string 95 // expiry of the token 96 expires time.Time 97 // mutex for parallelism for reading the token 98 mtx *sync.RWMutex 99 } 100 101 func GetScopesOrDefault(scopes []string) []string { 102 if len(scopes) == 0 { 103 return []string{"openid", "profile", "email", "groups"} 104 } 105 return scopes 106 } 107 108 // NewClientApp will register the Argo CD client app (either via Dex or external OIDC) and return an 109 // object which has HTTP handlers for handling the HTTP responses for login and callback 110 func NewClientApp(settings *settings.ArgoCDSettings, dexServerAddr string, dexTLSConfig *dex.DexTLSConfig, baseHRef string, cacheClient cache.CacheClient) (*ClientApp, error) { 111 redirectURL, err := settings.RedirectURL() 112 if err != nil { 113 return nil, err 114 } 115 encryptionKey, err := settings.GetServerEncryptionKey() 116 if err != nil { 117 return nil, err 118 } 119 a := ClientApp{ 120 clientID: settings.OAuth2ClientID(), 121 clientSecret: settings.OAuth2ClientSecret(), 122 usePKCE: settings.OAuth2UsePKCE(), 123 useAzureWorkloadIdentity: settings.UseAzureWorkloadIdentity(), 124 redirectURI: redirectURL, 125 issuerURL: settings.IssuerURL(), 126 baseHRef: baseHRef, 127 encryptionKey: encryptionKey, 128 clientCache: cacheClient, 129 azure: azureApp{mtx: &sync.RWMutex{}}, 130 } 131 log.Infof("Creating client app (%s)", a.clientID) 132 u, err := url.Parse(settings.URL) 133 if err != nil { 134 return nil, fmt.Errorf("parse redirect-uri: %w", err) 135 } 136 137 transport := &http.Transport{ 138 Proxy: http.ProxyFromEnvironment, 139 Dial: (&net.Dialer{ 140 Timeout: 30 * time.Second, 141 KeepAlive: 30 * time.Second, 142 }).Dial, 143 TLSHandshakeTimeout: 10 * time.Second, 144 ExpectContinueTimeout: 1 * time.Second, 145 } 146 a.client = &http.Client{ 147 Transport: transport, 148 } 149 150 if settings.DexConfig != "" && settings.OIDCConfigRAW == "" { 151 transport.TLSClientConfig = dex.TLSConfig(dexTLSConfig) 152 addrWithProto := dex.DexServerAddressWithProtocol(dexServerAddr, dexTLSConfig) 153 a.client.Transport = dex.NewDexRewriteURLRoundTripper(addrWithProto, a.client.Transport) 154 } else { 155 transport.TLSClientConfig = settings.OIDCTLSConfig() 156 } 157 if os.Getenv(common.EnvVarSSODebug) == "1" { 158 a.client.Transport = httputil.DebugTransport{T: a.client.Transport} 159 } 160 161 a.provider = NewOIDCProvider(a.issuerURL, a.client) 162 // NOTE: if we ever have replicas of Argo CD, this needs to switch to Redis cache 163 a.secureCookie = bool(u.Scheme == "https") 164 a.settings = settings 165 return &a, nil 166 } 167 168 func (a *ClientApp) oauth2Config(request *http.Request, scopes []string) (*oauth2.Config, error) { 169 endpoint, err := a.provider.Endpoint() 170 if err != nil { 171 return nil, err 172 } 173 redirectURL, err := a.settings.RedirectURLForRequest(request) 174 if err != nil { 175 log.Warnf("Unable to find ArgoCD URL from request, falling back to configured redirect URI: %v", err) 176 redirectURL = a.redirectURI 177 } 178 179 return &oauth2.Config{ 180 ClientID: a.clientID, 181 ClientSecret: a.clientSecret, 182 Endpoint: *endpoint, 183 Scopes: scopes, 184 RedirectURL: redirectURL, 185 }, nil 186 } 187 188 // generateAppState creates an app state nonce 189 func (a *ClientApp) generateAppState(returnURL string, pkceVerifier string, w http.ResponseWriter) (string, error) { 190 // According to the spec (https://www.rfc-editor.org/rfc/rfc6749#section-10.10), this must be guessable with 191 // probability <= 2^(-128). The following call generates one of 52^24 random strings, ~= 2^136 possibilities. 192 randStr, err := rand.String(24) 193 if err != nil { 194 return "", fmt.Errorf("failed to generate app state: %w", err) 195 } 196 if returnURL == "" { 197 returnURL = a.baseHRef 198 } 199 cookieValue := fmt.Sprintf("%s\n%s\n%s", randStr, returnURL, pkceVerifier) 200 if encrypted, err := crypto.Encrypt([]byte(cookieValue), a.encryptionKey); err != nil { 201 return "", err 202 } else { 203 cookieValue = hex.EncodeToString(encrypted) 204 } 205 206 http.SetCookie(w, &http.Cookie{ 207 Name: common.StateCookieName, 208 Value: cookieValue, 209 Expires: time.Now().Add(common.StateCookieMaxAge), 210 HttpOnly: true, 211 SameSite: http.SameSiteLaxMode, 212 Secure: a.secureCookie, 213 }) 214 return randStr, nil 215 } 216 217 func (a *ClientApp) verifyAppState(r *http.Request, w http.ResponseWriter, state string) (string, string, error) { 218 c, err := r.Cookie(common.StateCookieName) 219 if err != nil { 220 return "", "", err 221 } 222 val, err := hex.DecodeString(c.Value) 223 if err != nil { 224 return "", "", err 225 } 226 val, err = crypto.Decrypt(val, a.encryptionKey) 227 if err != nil { 228 return "", "", err 229 } 230 cookieVal := string(val) 231 redirectURL := a.baseHRef 232 pkceVerifier := "" 233 parts := strings.SplitN(cookieVal, "\n", 3) 234 if len(parts) > 1 && parts[1] != "" { 235 if !isValidRedirectURL(parts[1], 236 append([]string{a.settings.URL, a.baseHRef}, a.settings.AdditionalURLs...)) { 237 sanitizedURL := parts[1] 238 if len(sanitizedURL) > 100 { 239 sanitizedURL = sanitizedURL[:100] 240 } 241 log.Warnf("Failed to verify app state - got invalid redirectURL %q", sanitizedURL) 242 return "", "", fmt.Errorf("failed to verify app state: %w", ErrInvalidRedirectURL) 243 } 244 redirectURL = parts[1] 245 } 246 if len(parts) > 2 { 247 pkceVerifier = parts[2] 248 } 249 if parts[0] != state { 250 return "", "", fmt.Errorf("invalid state in '%s' cookie", common.AuthCookieName) 251 } 252 // set empty cookie to clear it 253 http.SetCookie(w, &http.Cookie{ 254 Name: common.StateCookieName, 255 Value: "", 256 HttpOnly: true, 257 SameSite: http.SameSiteLaxMode, 258 Secure: a.secureCookie, 259 }) 260 return redirectURL, pkceVerifier, nil 261 } 262 263 // isValidRedirectURL checks whether the given redirectURL matches on of the 264 // allowed URLs to redirect to. 265 // 266 // In order to be considered valid,the protocol and host (including port) have 267 // to match and if allowed path is not "/", redirectURL's path must be within 268 // allowed URL's path. 269 func isValidRedirectURL(redirectURL string, allowedURLs []string) bool { 270 if redirectURL == "" { 271 return true 272 } 273 r, err := url.Parse(redirectURL) 274 if err != nil { 275 return false 276 } 277 // We consider empty path the same as "/" for redirect URL 278 if r.Path == "" { 279 r.Path = "/" 280 } 281 // Prevent CRLF in the redirectURL 282 if strings.ContainsAny(r.Path, "\r\n") { 283 return false 284 } 285 for _, baseURL := range allowedURLs { 286 b, err := url.Parse(baseURL) 287 if err != nil { 288 continue 289 } 290 // We consider empty path the same as "/" for allowed URL 291 if b.Path == "" { 292 b.Path = "/" 293 } 294 // scheme and host are mandatory to match. 295 if b.Scheme == r.Scheme && b.Host == r.Host { 296 // If path of redirectURL and allowedURL match, redirectURL is allowed 297 // if b.Path == r.Path { 298 // return true 299 // } 300 // If path of redirectURL is within allowed URL's path, redirectURL is allowed 301 if strings.HasPrefix(path.Clean(r.Path), b.Path) { 302 return true 303 } 304 } 305 } 306 // No match - redirect URL is not allowed 307 return false 308 } 309 310 // HandleLogin formulates the proper OAuth2 URL (auth code or implicit) and redirects the user to 311 // the IDp login & consent page 312 func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) { 313 oidcConf, err := a.provider.ParseConfig() 314 if err != nil { 315 http.Error(w, err.Error(), http.StatusInternalServerError) 316 return 317 } 318 scopes := make([]string, 0) 319 pkceVerifier := "" 320 var opts []oauth2.AuthCodeOption 321 if config := a.settings.OIDCConfig(); config != nil { 322 scopes = GetScopesOrDefault(config.RequestedScopes) 323 opts = AppendClaimsAuthenticationRequestParameter(opts, config.RequestedIDTokenClaims) 324 } else if a.settings.IsDexConfigured() { 325 scopes = append(GetScopesOrDefault(nil), common.DexFederatedScope) 326 } 327 328 oauth2Config, err := a.oauth2Config(r, scopes) 329 if err != nil { 330 http.Error(w, err.Error(), http.StatusInternalServerError) 331 return 332 } 333 returnURL := r.FormValue("return_url") 334 // Check if return_url is valid, otherwise abort processing (see https://github.com/argoproj/argo-cd/pull/4780) 335 if !isValidRedirectURL(returnURL, append([]string{a.settings.URL}, a.settings.AdditionalURLs...)) { 336 http.Error(w, "Invalid redirect URL: the protocol and host (including port) must match and the path must be within allowed URLs if provided", http.StatusBadRequest) 337 return 338 } 339 if a.usePKCE { 340 pkceVerifier = oauth2.GenerateVerifier() 341 opts = append(opts, oauth2.S256ChallengeOption(pkceVerifier)) 342 } 343 stateNonce, err := a.generateAppState(returnURL, pkceVerifier, w) 344 if err != nil { 345 log.Errorf("Failed to initiate login flow: %v", err) 346 http.Error(w, "Failed to initiate login flow", http.StatusInternalServerError) 347 return 348 } 349 grantType := InferGrantType(oidcConf) 350 var url string 351 switch grantType { 352 case GrantTypeAuthorizationCode: 353 url = oauth2Config.AuthCodeURL(stateNonce, opts...) 354 case GrantTypeImplicit: 355 url, err = ImplicitFlowURL(oauth2Config, stateNonce, opts...) 356 if err != nil { 357 log.Errorf("Failed to initiate implicit login flow: %v", err) 358 http.Error(w, "Failed to initiate implicit login flow", http.StatusInternalServerError) 359 return 360 } 361 default: 362 http.Error(w, fmt.Sprintf("Unsupported grant type: %v", grantType), http.StatusInternalServerError) 363 return 364 } 365 log.Infof("Performing %s flow login: %s", grantType, url) 366 http.Redirect(w, r, url, http.StatusSeeOther) 367 } 368 369 // getFederatedServiceAccountToken returns the specified file's content, which is expected to be Federated Kubernetes service account token. 370 // Kubernetes is responsible for updating the file as service account tokens expire. 371 // Azure Workload Identity mutation webhook will set the environment variable AZURE_FEDERATED_TOKEN_FILE 372 // Content of this file will contain a federated token which can be used in assertion with Microsoft Entra Application. 373 func (a *azureApp) getFederatedServiceAccountToken(context.Context) (string, error) { 374 file, ok := os.LookupEnv("AZURE_FEDERATED_TOKEN_FILE") 375 if file == "" || !ok { 376 return "", errors.New("AZURE_FEDERATED_TOKEN_FILE env variable not found, make sure workload identity is enabled on the cluster") 377 } 378 379 if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { 380 return "", errors.New("AZURE_FEDERATED_TOKEN_FILE specified file does not exist") 381 } 382 383 a.mtx.RLock() 384 if a.expires.Before(time.Now()) { 385 // ensure only one goroutine at a time updates the assertion 386 a.mtx.RUnlock() 387 a.mtx.Lock() 388 defer a.mtx.Unlock() 389 // double check because another goroutine may have acquired the write lock first and done the update 390 if now := time.Now(); a.expires.Before(now) { 391 content, err := os.ReadFile(file) 392 if err != nil { 393 return "", err 394 } 395 a.assertion = string(content) 396 // Kubernetes rotates service account tokens when they reach 80% of their total TTL. The shortest TTL 397 // is 1 hour. That implies the token we just read is valid for at least 12 minutes (20% of 1 hour), 398 // but we add some margin for safety. 399 a.expires = now.Add(10 * time.Minute) 400 } 401 } else { 402 defer a.mtx.RUnlock() 403 } 404 return a.assertion, nil 405 } 406 407 // HandleCallback is the callback handler for an OAuth2 login flow 408 func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) { 409 oauth2Config, err := a.oauth2Config(r, nil) 410 if err != nil { 411 http.Error(w, err.Error(), http.StatusInternalServerError) 412 return 413 } 414 log.Infof("Callback: %s", r.URL) 415 if errMsg := r.FormValue("error"); errMsg != "" { 416 errorDesc := r.FormValue("error_description") 417 http.Error(w, html.EscapeString(errMsg)+": "+html.EscapeString(errorDesc), http.StatusBadRequest) 418 return 419 } 420 code := r.FormValue("code") 421 state := r.FormValue("state") 422 if code == "" { 423 // If code was not given, it implies implicit flow 424 a.handleImplicitFlow(r, w, state) 425 return 426 } 427 returnURL, pkceVerifier, err := a.verifyAppState(r, w, state) 428 if err != nil { 429 http.Error(w, err.Error(), http.StatusBadRequest) 430 return 431 } 432 433 ctx := gooidc.ClientContext(r.Context(), a.client) 434 options := []oauth2.AuthCodeOption{} 435 436 if a.useAzureWorkloadIdentity { 437 clientAssertion, err := a.azure.getFederatedServiceAccountToken(ctx) 438 if err != nil { 439 http.Error(w, fmt.Sprintf("failed to generate client assertion: %v", err), http.StatusInternalServerError) 440 return 441 } 442 443 options = []oauth2.AuthCodeOption{ 444 oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), 445 oauth2.SetAuthURLParam("client_assertion", clientAssertion), 446 } 447 } 448 449 if a.usePKCE { 450 options = append(options, oauth2.VerifierOption(pkceVerifier)) 451 } 452 453 token, err := oauth2Config.Exchange(ctx, code, options...) 454 if err != nil { 455 http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError) 456 return 457 } 458 459 idTokenRAW, ok := token.Extra("id_token").(string) 460 if !ok { 461 http.Error(w, "no id_token in token response", http.StatusInternalServerError) 462 return 463 } 464 465 idToken, err := a.provider.Verify(idTokenRAW, a.settings) 466 if err != nil { 467 log.Warnf("Failed to verify token: %s", err) 468 http.Error(w, common.TokenVerificationError, http.StatusInternalServerError) 469 return 470 } 471 path := "/" 472 if a.baseHRef != "" { 473 path = strings.TrimRight(strings.TrimLeft(a.baseHRef, "/"), "/") 474 } 475 cookiePath := "path=/" + path 476 flags := []string{cookiePath, "SameSite=lax", "httpOnly"} 477 if a.secureCookie { 478 flags = append(flags, "Secure") 479 } 480 var claims jwt.MapClaims 481 err = idToken.Claims(&claims) 482 if err != nil { 483 http.Error(w, err.Error(), http.StatusInternalServerError) 484 return 485 } 486 // save the accessToken in memory for later use 487 encToken, err := crypto.Encrypt([]byte(token.AccessToken), a.encryptionKey) 488 if err != nil { 489 claimsJSON, _ := json.Marshal(claims) 490 http.Error(w, "failed encrypting token", http.StatusInternalServerError) 491 log.Errorf("cannot encrypt accessToken: %v (claims=%s)", err, claimsJSON) 492 return 493 } 494 sub := jwtutil.StringField(claims, "sub") 495 err = a.clientCache.Set(&cache.Item{ 496 Key: formatAccessTokenCacheKey(sub), 497 Object: encToken, 498 CacheActionOpts: cache.CacheActionOpts{ 499 Expiration: getTokenExpiration(claims), 500 }, 501 }) 502 if err != nil { 503 claimsJSON, _ := json.Marshal(claims) 504 http.Error(w, fmt.Sprintf("claims=%s, err=%v", claimsJSON, err), http.StatusInternalServerError) 505 return 506 } 507 508 if idTokenRAW != "" { 509 cookies, err := httputil.MakeCookieMetadata(common.AuthCookieName, idTokenRAW, flags...) 510 if err != nil { 511 claimsJSON, _ := json.Marshal(claims) 512 http.Error(w, fmt.Sprintf("claims=%s, err=%v", claimsJSON, err), http.StatusInternalServerError) 513 return 514 } 515 516 for _, cookie := range cookies { 517 w.Header().Add("Set-Cookie", cookie) 518 } 519 } 520 521 claimsJSON, _ := json.Marshal(claims) 522 log.Infof("Web login successful. Claims: %s", claimsJSON) 523 if os.Getenv(common.EnvVarSSODebug) == "1" { 524 claimsJSON, _ := json.MarshalIndent(claims, "", " ") 525 renderToken(w, a.redirectURI, idTokenRAW, token.RefreshToken, claimsJSON) 526 } else { 527 http.Redirect(w, r, returnURL, http.StatusSeeOther) 528 } 529 } 530 531 var implicitFlowTmpl = template.Must(template.New("implicit.html").Parse(`<script> 532 var hash = window.location.hash.substr(1); 533 var result = hash.split('&').reduce(function (result, item) { 534 var parts = item.split('='); 535 result[parts[0]] = parts[1]; 536 return result; 537 }, {}); 538 var idToken = result['id_token']; 539 var state = result['state']; 540 var returnURL = "{{ .ReturnURL }}"; 541 if (state != "" && returnURL == "") { 542 window.location.href = window.location.href.split("#")[0] + "?state=" + result['state'] + window.location.hash; 543 } else if (returnURL != "") { 544 document.cookie = "{{ .CookieName }}=" + idToken + "; path=/"; 545 window.location.href = returnURL; 546 } 547 </script>`)) 548 549 // handleImplicitFlow completes an implicit OAuth2 flow. The id_token and state will be contained 550 // in the URL fragment. The javascript client first redirects to the callback URL, supplying the 551 // state nonce for verification, as well as looking up the return URL. Once verified, the client 552 // stores the id_token from the fragment as a cookie. Finally it performs the final redirect back to 553 // the return URL. 554 func (a *ClientApp) handleImplicitFlow(r *http.Request, w http.ResponseWriter, state string) { 555 type implicitFlowValues struct { 556 CookieName string 557 ReturnURL string 558 } 559 vals := implicitFlowValues{ 560 CookieName: common.AuthCookieName, 561 } 562 if state != "" { 563 // Not using pkceVerifier, since PKCE is not supported in implicit flow. 564 returnURL, _, err := a.verifyAppState(r, w, state) 565 if err != nil { 566 http.Error(w, err.Error(), http.StatusBadRequest) 567 return 568 } 569 vals.ReturnURL = returnURL 570 } 571 renderTemplate(w, implicitFlowTmpl, vals) 572 } 573 574 // ImplicitFlowURL is an adaptation of oauth2.Config::AuthCodeURL() which returns a URL 575 // appropriate for an OAuth2 implicit login flow (as opposed to authorization code flow). 576 func ImplicitFlowURL(c *oauth2.Config, state string, opts ...oauth2.AuthCodeOption) (string, error) { 577 opts = append(opts, oauth2.SetAuthURLParam("response_type", "id_token")) 578 randString, err := rand.String(24) 579 if err != nil { 580 return "", fmt.Errorf("failed to generate nonce for implicit flow URL: %w", err) 581 } 582 opts = append(opts, oauth2.SetAuthURLParam("nonce", randString)) 583 return c.AuthCodeURL(state, opts...), nil 584 } 585 586 // OfflineAccess returns whether or not 'offline_access' is a supported scope 587 func OfflineAccess(scopes []string) bool { 588 if len(scopes) == 0 { 589 // scopes_supported is a "RECOMMENDED" discovery claim, not a required 590 // one. If missing, assume that the provider follows the spec and has 591 // an "offline_access" scope. 592 return true 593 } 594 // See if scopes_supported has the "offline_access" scope. 595 for _, scope := range scopes { 596 if scope == gooidc.ScopeOfflineAccess { 597 return true 598 } 599 } 600 return false 601 } 602 603 // InferGrantType infers the proper grant flow depending on the OAuth2 client config and OIDC configuration. 604 // Returns either: "authorization_code" or "implicit" 605 func InferGrantType(oidcConf *OIDCConfiguration) string { 606 // Check the supported response types. If the list contains the response type 'code', 607 // then grant type is 'authorization_code'. This is preferred over the implicit 608 // grant type since refresh tokens cannot be issued that way. 609 for _, supportedType := range oidcConf.ResponseTypesSupported { 610 if supportedType == ResponseTypeCode { 611 return GrantTypeAuthorizationCode 612 } 613 } 614 615 // Assume implicit otherwise 616 return GrantTypeImplicit 617 } 618 619 // AppendClaimsAuthenticationRequestParameter appends a OIDC claims authentication request parameter 620 // to `opts` with the `requestedClaims` 621 func AppendClaimsAuthenticationRequestParameter(opts []oauth2.AuthCodeOption, requestedClaims map[string]*oidc.Claim) []oauth2.AuthCodeOption { 622 if len(requestedClaims) == 0 { 623 return opts 624 } 625 log.Infof("RequestedClaims: %s\n", requestedClaims) 626 claimsRequestParameter, err := createClaimsAuthenticationRequestParameter(requestedClaims) 627 if err != nil { 628 log.Errorf("Failed to create OIDC claims authentication request parameter from config: %s", err) 629 return opts 630 } 631 return append(opts, claimsRequestParameter) 632 } 633 634 func createClaimsAuthenticationRequestParameter(requestedClaims map[string]*oidc.Claim) (oauth2.AuthCodeOption, error) { 635 claimsRequest := ClaimsRequest{IDToken: requestedClaims} 636 claimsRequestRAW, err := json.Marshal(claimsRequest) 637 if err != nil { 638 return nil, err 639 } 640 return oauth2.SetAuthURLParam("claims", string(claimsRequestRAW)), nil 641 } 642 643 // GetUserInfo queries the IDP userinfo endpoint for claims 644 func (a *ClientApp) GetUserInfo(actualClaims jwt.MapClaims, issuerURL, userInfoPath string) (jwt.MapClaims, bool, error) { 645 sub := jwtutil.StringField(actualClaims, "sub") 646 var claims jwt.MapClaims 647 var encClaims []byte 648 649 // in case we got it in the cache, we just return the item 650 clientCacheKey := formatUserInfoResponseCacheKey(sub) 651 if err := a.clientCache.Get(clientCacheKey, &encClaims); err == nil { 652 claimsRaw, err := crypto.Decrypt(encClaims, a.encryptionKey) 653 if err != nil { 654 log.Errorf("decrypting the cached claims failed (sub=%s): %s", sub, err) 655 } else { 656 err = json.Unmarshal(claimsRaw, &claims) 657 if err == nil { 658 // return the cached claims since they are not yet expired, were successfully decrypted and unmarshaled 659 return claims, false, nil 660 } 661 log.Errorf("cannot unmarshal cached claims structure: %s", err) 662 } 663 } 664 665 // check if the accessToken for the user is still present 666 var encAccessToken []byte 667 err := a.clientCache.Get(formatAccessTokenCacheKey(sub), &encAccessToken) 668 // without an accessToken we can't query the user info endpoint 669 // thus the user needs to reauthenticate for argocd to get a new accessToken 670 if errors.Is(err, cache.ErrCacheMiss) { 671 return claims, true, fmt.Errorf("no accessToken for %s: %w", sub, err) 672 } else if err != nil { 673 return claims, true, fmt.Errorf("could not read accessToken from cache for %s: %w", sub, err) 674 } 675 676 accessToken, err := crypto.Decrypt(encAccessToken, a.encryptionKey) 677 if err != nil { 678 return claims, true, fmt.Errorf("could not decrypt accessToken for %s: %w", sub, err) 679 } 680 681 url := issuerURL + userInfoPath 682 request, err := http.NewRequest(http.MethodGet, url, http.NoBody) 683 if err != nil { 684 err = fmt.Errorf("failed creating new http request: %w", err) 685 return claims, false, err 686 } 687 688 bearer := fmt.Sprintf("Bearer %s", accessToken) 689 request.Header.Set("Authorization", bearer) 690 691 response, err := a.client.Do(request) 692 if err != nil { 693 return claims, false, fmt.Errorf("failed to query userinfo endpoint of IDP: %w", err) 694 } 695 defer response.Body.Close() 696 if response.StatusCode == http.StatusUnauthorized { 697 return claims, true, err 698 } 699 700 // according to https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponseValidation 701 // the response should be validated 702 header := response.Header.Get("content-type") 703 rawBody, err := io.ReadAll(response.Body) 704 if err != nil { 705 return claims, false, fmt.Errorf("got error reading response body: %w", err) 706 } 707 switch header { 708 case "application/jwt": 709 // if body is JWT, first validate it before extracting claims 710 idToken, err := a.provider.Verify(string(rawBody), a.settings) 711 if err != nil { 712 return claims, false, fmt.Errorf("user info response in jwt format not valid: %w", err) 713 } 714 err = idToken.Claims(claims) 715 if err != nil { 716 return claims, false, fmt.Errorf("cannot get claims from userinfo jwt: %w", err) 717 } 718 default: 719 // if body is json, unsigned and unencrypted claims can be deserialized 720 err = json.Unmarshal(rawBody, &claims) 721 if err != nil { 722 return claims, false, fmt.Errorf("failed to decode response body to struct: %w", err) 723 } 724 } 725 726 // in case response was successfully validated and there was no error, put item in cache 727 // but first let's determine the expiry of the cache 728 var cacheExpiry time.Duration 729 settingExpiry := a.settings.UserInfoCacheExpiration() 730 tokenExpiry := getTokenExpiration(claims) 731 732 // only use configured expiry if the token lives longer and the expiry is configured 733 // if the token has no expiry, use the expiry of the actual token 734 // otherwise use the expiry of the token 735 switch { 736 case settingExpiry < tokenExpiry && settingExpiry != 0: 737 cacheExpiry = settingExpiry 738 case tokenExpiry < 0: 739 cacheExpiry = getTokenExpiration(actualClaims) 740 default: 741 cacheExpiry = tokenExpiry 742 } 743 744 rawClaims, err := json.Marshal(claims) 745 if err != nil { 746 return claims, false, fmt.Errorf("could not marshal claim to json: %w", err) 747 } 748 encClaims, err = crypto.Encrypt(rawClaims, a.encryptionKey) 749 if err != nil { 750 return claims, false, fmt.Errorf("could not encrypt user info response: %w", err) 751 } 752 753 err = a.clientCache.Set(&cache.Item{ 754 Key: clientCacheKey, 755 Object: encClaims, 756 CacheActionOpts: cache.CacheActionOpts{ 757 Expiration: cacheExpiry, 758 }, 759 }) 760 if err != nil { 761 return claims, false, fmt.Errorf("could not put item to cache: %w", err) 762 } 763 764 return claims, false, nil 765 } 766 767 // getTokenExpiration returns a time.Duration until the token expires 768 func getTokenExpiration(claims jwt.MapClaims) time.Duration { 769 // get duration until token expires 770 exp := jwtutil.Float64Field(claims, "exp") 771 tm := time.Unix(int64(exp), 0) 772 tokenExpiry := time.Until(tm) 773 return tokenExpiry 774 } 775 776 // formatUserInfoResponseCacheKey returns the key which is used to store userinfo of user in cache 777 func formatUserInfoResponseCacheKey(sub string) string { 778 return fmt.Sprintf("%s_%s", UserInfoResponseCachePrefix, sub) 779 } 780 781 // formatAccessTokenCacheKey returns the key which is used to store the accessToken of a user in cache 782 func formatAccessTokenCacheKey(sub string) string { 783 return fmt.Sprintf("%s_%s", AccessTokenCachePrefix, sub) 784 }