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  }