github.com/argoproj/argo-cd@v1.8.7/util/oidc/oidc.go (about)

     1  package oidc
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"html"
     7  	"html/template"
     8  	"net"
     9  	"net/http"
    10  	"net/url"
    11  	"os"
    12  	"path"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/argoproj/pkg/jwt/zjwt"
    17  	gooidc "github.com/coreos/go-oidc"
    18  	"github.com/dgrijalva/jwt-go/v4"
    19  	log "github.com/sirupsen/logrus"
    20  	"golang.org/x/oauth2"
    21  
    22  	"github.com/argoproj/argo-cd/common"
    23  	"github.com/argoproj/argo-cd/server/settings/oidc"
    24  	appstatecache "github.com/argoproj/argo-cd/util/cache/appstate"
    25  	"github.com/argoproj/argo-cd/util/dex"
    26  	httputil "github.com/argoproj/argo-cd/util/http"
    27  	"github.com/argoproj/argo-cd/util/rand"
    28  	"github.com/argoproj/argo-cd/util/settings"
    29  )
    30  
    31  const (
    32  	GrantTypeAuthorizationCode = "authorization_code"
    33  	GrantTypeImplicit          = "implicit"
    34  	ResponseTypeCode           = "code"
    35  )
    36  
    37  // OIDCConfiguration holds a subset of interested fields from the OIDC configuration spec
    38  type OIDCConfiguration struct {
    39  	Issuer                 string   `json:"issuer"`
    40  	ScopesSupported        []string `json:"scopes_supported"`
    41  	ResponseTypesSupported []string `json:"response_types_supported"`
    42  	GrantTypesSupported    []string `json:"grant_types_supported,omitempty"`
    43  }
    44  
    45  type ClaimsRequest struct {
    46  	IDToken map[string]*oidc.Claim `json:"id_token"`
    47  }
    48  
    49  type OIDCState struct {
    50  	// ReturnURL is the URL in which to redirect a user back to after completing an OAuth2 login
    51  	ReturnURL string `json:"returnURL"`
    52  }
    53  
    54  type OIDCStateStorage interface {
    55  	GetOIDCState(key string) (*OIDCState, error)
    56  	SetOIDCState(key string, state *OIDCState) error
    57  }
    58  
    59  type ClientApp struct {
    60  	// OAuth2 client ID of this application (e.g. argo-cd)
    61  	clientID string
    62  	// OAuth2 client secret of this application
    63  	clientSecret string
    64  	// Callback URL for OAuth2 responses (e.g. https://argocd.example.com/auth/callback)
    65  	redirectURI string
    66  	// URL of the issuer (e.g. https://argocd.example.com/api/dex)
    67  	issuerURL string
    68  	// The URL endpoint at which the ArgoCD server is accessed.
    69  	baseHRef string
    70  	// client is the HTTP client which is used to query the IDp
    71  	client *http.Client
    72  	// secureCookie indicates if the cookie should be set with the Secure flag, meaning it should
    73  	// only ever be sent over HTTPS. This value is inferred by the scheme of the redirectURI.
    74  	secureCookie bool
    75  	// settings holds Argo CD settings
    76  	settings *settings.ArgoCDSettings
    77  	// provider is the OIDC provider
    78  	provider Provider
    79  	// cache holds temporary nonce tokens to which hold application state values
    80  	// See http://tools.ietf.org/html/rfc6749#section-10.12 for more info.
    81  	cache OIDCStateStorage
    82  }
    83  
    84  func GetScopesOrDefault(scopes []string) []string {
    85  	if len(scopes) == 0 {
    86  		return []string{"openid", "profile", "email", "groups"}
    87  	}
    88  	return scopes
    89  }
    90  
    91  // NewClientApp will register the Argo CD client app (either via Dex or external OIDC) and return an
    92  // object which has HTTP handlers for handling the HTTP responses for login and callback
    93  func NewClientApp(settings *settings.ArgoCDSettings, cache OIDCStateStorage, dexServerAddr, baseHRef string) (*ClientApp, error) {
    94  	redirectURL, err := settings.RedirectURL()
    95  	if err != nil {
    96  		return nil, err
    97  	}
    98  	a := ClientApp{
    99  		clientID:     settings.OAuth2ClientID(),
   100  		clientSecret: settings.OAuth2ClientSecret(),
   101  		redirectURI:  redirectURL,
   102  		issuerURL:    settings.IssuerURL(),
   103  		baseHRef:     baseHRef,
   104  		cache:        cache,
   105  	}
   106  	log.Infof("Creating client app (%s)", a.clientID)
   107  	u, err := url.Parse(settings.URL)
   108  	if err != nil {
   109  		return nil, fmt.Errorf("parse redirect-uri: %v", err)
   110  	}
   111  	tlsConfig := settings.TLSConfig()
   112  	if tlsConfig != nil {
   113  		tlsConfig.InsecureSkipVerify = true
   114  	}
   115  	a.client = &http.Client{
   116  		Transport: &http.Transport{
   117  			TLSClientConfig: tlsConfig,
   118  			Proxy:           http.ProxyFromEnvironment,
   119  			Dial: (&net.Dialer{
   120  				Timeout:   30 * time.Second,
   121  				KeepAlive: 30 * time.Second,
   122  			}).Dial,
   123  			TLSHandshakeTimeout:   10 * time.Second,
   124  			ExpectContinueTimeout: 1 * time.Second,
   125  		},
   126  	}
   127  	if settings.DexConfig != "" && settings.OIDCConfigRAW == "" {
   128  		a.client.Transport = dex.NewDexRewriteURLRoundTripper(dexServerAddr, a.client.Transport)
   129  	}
   130  	if os.Getenv(common.EnvVarSSODebug) == "1" {
   131  		a.client.Transport = httputil.DebugTransport{T: a.client.Transport}
   132  	}
   133  
   134  	a.provider = NewOIDCProvider(a.issuerURL, a.client)
   135  	// NOTE: if we ever have replicas of Argo CD, this needs to switch to Redis cache
   136  	a.secureCookie = bool(u.Scheme == "https")
   137  	a.settings = settings
   138  	return &a, nil
   139  }
   140  
   141  func (a *ClientApp) oauth2Config(scopes []string) (*oauth2.Config, error) {
   142  	endpoint, err := a.provider.Endpoint()
   143  	if err != nil {
   144  		return nil, err
   145  	}
   146  	return &oauth2.Config{
   147  		ClientID:     a.clientID,
   148  		ClientSecret: a.clientSecret,
   149  		Endpoint:     *endpoint,
   150  		Scopes:       scopes,
   151  		RedirectURL:  a.redirectURI,
   152  	}, nil
   153  }
   154  
   155  // generateAppState creates an app state nonce
   156  func (a *ClientApp) generateAppState(returnURL string) string {
   157  	randStr := rand.RandString(10)
   158  	if returnURL == "" {
   159  		returnURL = a.baseHRef
   160  	}
   161  	err := a.cache.SetOIDCState(randStr, &OIDCState{ReturnURL: returnURL})
   162  	if err != nil {
   163  		// This should never happen with the in-memory cache
   164  		log.Errorf("Failed to set app state: %v", err)
   165  	}
   166  	return randStr
   167  }
   168  
   169  func (a *ClientApp) verifyAppState(state string) (*OIDCState, error) {
   170  	res, err := a.cache.GetOIDCState(state)
   171  	if err != nil {
   172  		if err == appstatecache.ErrCacheMiss {
   173  			return nil, fmt.Errorf("unknown app state %s", state)
   174  		} else {
   175  			return nil, fmt.Errorf("failed to verify app state %s: %v", state, err)
   176  		}
   177  	}
   178  
   179  	_ = a.cache.SetOIDCState(state, nil)
   180  	return res, nil
   181  }
   182  
   183  // isValidRedirectURL checks whether the given redirectURL matches on of the
   184  // allowed URLs to redirect to.
   185  //
   186  // In order to be considered valid,the protocol and host (including port) have
   187  // to match and if allowed path is not "/", redirectURL's path must be within
   188  // allowed URL's path.
   189  func isValidRedirectURL(redirectURL string, allowedURLs []string) bool {
   190  	if redirectURL == "" {
   191  		return true
   192  	}
   193  	r, err := url.Parse(redirectURL)
   194  	if err != nil {
   195  		return false
   196  	}
   197  	// We consider empty path the same as "/" for redirect URL
   198  	if r.Path == "" {
   199  		r.Path = "/"
   200  	}
   201  	// Prevent CLRF in the redirectURL
   202  	if strings.ContainsAny(r.Path, "\r\n") {
   203  		return false
   204  	}
   205  	for _, baseURL := range allowedURLs {
   206  		b, err := url.Parse(baseURL)
   207  		if err != nil {
   208  			continue
   209  		}
   210  		// We consider empty path the same as "/" for allowed URL
   211  		if b.Path == "" {
   212  			b.Path = "/"
   213  		}
   214  		// scheme and host are mandatory to match.
   215  		if b.Scheme == r.Scheme && b.Host == r.Host {
   216  			// If path of redirectURL and allowedURL match, redirectURL is allowed
   217  			//if b.Path == r.Path {
   218  			//	return true
   219  			//}
   220  			// If path of redirectURL is within allowed URL's path, redirectURL is allowed
   221  			if strings.HasPrefix(path.Clean(r.Path), b.Path) {
   222  				return true
   223  			}
   224  		}
   225  	}
   226  	// No match - redirect URL is not allowed
   227  	return false
   228  }
   229  
   230  // HandleLogin formulates the proper OAuth2 URL (auth code or implicit) and redirects the user to
   231  // the IDp login & consent page
   232  func (a *ClientApp) HandleLogin(w http.ResponseWriter, r *http.Request) {
   233  	oidcConf, err := a.provider.ParseConfig()
   234  	if err != nil {
   235  		http.Error(w, err.Error(), http.StatusInternalServerError)
   236  		return
   237  	}
   238  	scopes := make([]string, 0)
   239  	var opts []oauth2.AuthCodeOption
   240  	if config := a.settings.OIDCConfig(); config != nil {
   241  		scopes = config.RequestedScopes
   242  		opts = AppendClaimsAuthenticationRequestParameter(opts, config.RequestedIDTokenClaims)
   243  	}
   244  	oauth2Config, err := a.oauth2Config(GetScopesOrDefault(scopes))
   245  	if err != nil {
   246  		http.Error(w, err.Error(), http.StatusInternalServerError)
   247  		return
   248  	}
   249  	returnURL := r.FormValue("return_url")
   250  	// Check if return_url is valid, otherwise abort processing (see #2707)
   251  	if !isValidRedirectURL(returnURL, []string{a.settings.URL}) {
   252  		http.Error(w, "Invalid return_url", http.StatusBadRequest)
   253  		return
   254  	}
   255  	stateNonce := a.generateAppState(returnURL)
   256  	grantType := InferGrantType(oidcConf)
   257  	var url string
   258  	switch grantType {
   259  	case GrantTypeAuthorizationCode:
   260  		url = oauth2Config.AuthCodeURL(stateNonce, opts...)
   261  	case GrantTypeImplicit:
   262  		url = ImplicitFlowURL(oauth2Config, stateNonce, opts...)
   263  	default:
   264  		http.Error(w, fmt.Sprintf("Unsupported grant type: %v", grantType), http.StatusInternalServerError)
   265  		return
   266  	}
   267  	log.Infof("Performing %s flow login: %s", grantType, url)
   268  	http.Redirect(w, r, url, http.StatusSeeOther)
   269  }
   270  
   271  // HandleCallback is the callback handler for an OAuth2 login flow
   272  func (a *ClientApp) HandleCallback(w http.ResponseWriter, r *http.Request) {
   273  	oauth2Config, err := a.oauth2Config(nil)
   274  	if err != nil {
   275  		http.Error(w, err.Error(), http.StatusInternalServerError)
   276  		return
   277  	}
   278  	log.Infof("Callback: %s", r.URL)
   279  	if errMsg := r.FormValue("error"); errMsg != "" {
   280  		errorDesc := r.FormValue("error_description")
   281  		http.Error(w, html.EscapeString(errMsg)+": "+html.EscapeString(errorDesc), http.StatusBadRequest)
   282  		return
   283  	}
   284  	code := r.FormValue("code")
   285  	state := r.FormValue("state")
   286  	if code == "" {
   287  		// If code was not given, it implies implicit flow
   288  		a.handleImplicitFlow(w, state)
   289  		return
   290  	}
   291  	appState, err := a.verifyAppState(state)
   292  	if err != nil {
   293  		http.Error(w, err.Error(), http.StatusBadRequest)
   294  		return
   295  	}
   296  	ctx := gooidc.ClientContext(r.Context(), a.client)
   297  	token, err := oauth2Config.Exchange(ctx, code)
   298  	if err != nil {
   299  		http.Error(w, fmt.Sprintf("failed to get token: %v", err), http.StatusInternalServerError)
   300  		return
   301  	}
   302  	idTokenRAW, ok := token.Extra("id_token").(string)
   303  	if !ok {
   304  		http.Error(w, "no id_token in token response", http.StatusInternalServerError)
   305  		return
   306  	}
   307  	idToken, err := a.provider.Verify(a.clientID, idTokenRAW)
   308  	if err != nil {
   309  		http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusInternalServerError)
   310  		return
   311  	}
   312  	path := "/"
   313  	if a.baseHRef != "" {
   314  		path = strings.TrimRight(strings.TrimLeft(a.baseHRef, "/"), "/")
   315  	}
   316  	cookiePath := fmt.Sprintf("path=/%s", path)
   317  	flags := []string{cookiePath, "SameSite=lax", "httpOnly"}
   318  	if a.secureCookie {
   319  		flags = append(flags, "Secure")
   320  	}
   321  	var claims jwt.MapClaims
   322  	err = idToken.Claims(&claims)
   323  	if err != nil {
   324  		http.Error(w, err.Error(), http.StatusInternalServerError)
   325  		return
   326  	}
   327  	if idTokenRAW != "" {
   328  		idTokenRAW, err = zjwt.ZJWT(idTokenRAW)
   329  		if err != nil {
   330  			http.Error(w, err.Error(), http.StatusInternalServerError)
   331  			return
   332  		}
   333  		cookie, err := httputil.MakeCookieMetadata(common.AuthCookieName, idTokenRAW, flags...)
   334  		if err != nil {
   335  			claimsJSON, _ := json.Marshal(claims)
   336  			http.Error(w, fmt.Sprintf("claims=%s, err=%v", claimsJSON, err), http.StatusInternalServerError)
   337  			return
   338  		}
   339  		w.Header().Set("Set-Cookie", cookie)
   340  	}
   341  
   342  	claimsJSON, _ := json.Marshal(claims)
   343  	log.Infof("Web login successful. Claims: %s", claimsJSON)
   344  	if os.Getenv(common.EnvVarSSODebug) == "1" {
   345  		claimsJSON, _ := json.MarshalIndent(claims, "", "  ")
   346  		renderToken(w, a.redirectURI, idTokenRAW, token.RefreshToken, claimsJSON)
   347  	} else {
   348  		http.Redirect(w, r, appState.ReturnURL, http.StatusSeeOther)
   349  	}
   350  }
   351  
   352  var implicitFlowTmpl = template.Must(template.New("implicit.html").Parse(`<script>
   353  var hash = window.location.hash.substr(1);
   354  var result = hash.split('&').reduce(function (result, item) {
   355  	var parts = item.split('=');
   356  	result[parts[0]] = parts[1];
   357  	return result;
   358  }, {});
   359  var idToken = result['id_token'];
   360  var state = result['state'];
   361  var returnURL = "{{ .ReturnURL }}";
   362  if (state != "" && returnURL == "") {
   363  	window.location.href = window.location.href.split("#")[0] + "?state=" + result['state'] + window.location.hash;
   364  } else if (returnURL != "") {
   365  	document.cookie = "{{ .CookieName }}=" + idToken + "; path=/";
   366  	window.location.href = returnURL;
   367  }
   368  </script>`))
   369  
   370  // handleImplicitFlow completes an implicit OAuth2 flow. The id_token and state will be contained
   371  // in the URL fragment. The javascript client first redirects to the callback URL, supplying the
   372  // state nonce for verification, as well as looking up the return URL. Once verified, the client
   373  // stores the id_token from the fragment as a cookie. Finally it performs the final redirect back to
   374  // the return URL.
   375  func (a *ClientApp) handleImplicitFlow(w http.ResponseWriter, state string) {
   376  	type implicitFlowValues struct {
   377  		CookieName string
   378  		ReturnURL  string
   379  	}
   380  	vals := implicitFlowValues{
   381  		CookieName: common.AuthCookieName,
   382  	}
   383  	if state != "" {
   384  		appState, err := a.verifyAppState(state)
   385  		if err != nil {
   386  			http.Error(w, err.Error(), http.StatusBadRequest)
   387  			return
   388  		}
   389  		vals.ReturnURL = appState.ReturnURL
   390  	}
   391  	renderTemplate(w, implicitFlowTmpl, vals)
   392  }
   393  
   394  // ImplicitFlowURL is an adaptation of oauth2.Config::AuthCodeURL() which returns a URL
   395  // appropriate for an OAuth2 implicit login flow (as opposed to authorization code flow).
   396  func ImplicitFlowURL(c *oauth2.Config, state string, opts ...oauth2.AuthCodeOption) string {
   397  	opts = append(opts, oauth2.SetAuthURLParam("response_type", "id_token"))
   398  	opts = append(opts, oauth2.SetAuthURLParam("nonce", rand.RandString(10)))
   399  	return c.AuthCodeURL(state, opts...)
   400  }
   401  
   402  // OfflineAccess returns whether or not 'offline_access' is a supported scope
   403  func OfflineAccess(scopes []string) bool {
   404  	if len(scopes) == 0 {
   405  		// scopes_supported is a "RECOMMENDED" discovery claim, not a required
   406  		// one. If missing, assume that the provider follows the spec and has
   407  		// an "offline_access" scope.
   408  		return true
   409  	}
   410  	// See if scopes_supported has the "offline_access" scope.
   411  	for _, scope := range scopes {
   412  		if scope == gooidc.ScopeOfflineAccess {
   413  			return true
   414  		}
   415  	}
   416  	return false
   417  }
   418  
   419  // InferGrantType infers the proper grant flow depending on the OAuth2 client config and OIDC configuration.
   420  // Returns either: "authorization_code" or "implicit"
   421  func InferGrantType(oidcConf *OIDCConfiguration) string {
   422  	// Check the supported response types. If the list contains the response type 'code',
   423  	// then grant type is 'authorization_code'. This is preferred over the implicit
   424  	// grant type since refresh tokens cannot be issued that way.
   425  	for _, supportedType := range oidcConf.ResponseTypesSupported {
   426  		if supportedType == ResponseTypeCode {
   427  			return GrantTypeAuthorizationCode
   428  		}
   429  	}
   430  
   431  	// Assume implicit otherwise
   432  	return GrantTypeImplicit
   433  }
   434  
   435  // AppendClaimsAuthenticationRequestParameter appends a OIDC claims authentication request parameter
   436  // to `opts` with the `requestedClaims`
   437  func AppendClaimsAuthenticationRequestParameter(opts []oauth2.AuthCodeOption, requestedClaims map[string]*oidc.Claim) []oauth2.AuthCodeOption {
   438  	if len(requestedClaims) == 0 {
   439  		return opts
   440  	}
   441  	log.Infof("RequestedClaims: %s\n", requestedClaims)
   442  	claimsRequestParameter, err := createClaimsAuthenticationRequestParameter(requestedClaims)
   443  	if err != nil {
   444  		log.Errorf("Failed to create OIDC claims authentication request parameter from config: %s", err)
   445  		return opts
   446  	}
   447  	return append(opts, claimsRequestParameter)
   448  }
   449  
   450  func createClaimsAuthenticationRequestParameter(requestedClaims map[string]*oidc.Claim) (oauth2.AuthCodeOption, error) {
   451  	claimsRequest := ClaimsRequest{IDToken: requestedClaims}
   452  	claimsRequestRAW, err := json.Marshal(claimsRequest)
   453  	if err != nil {
   454  		return nil, err
   455  	}
   456  	return oauth2.SetAuthURLParam("claims", string(claimsRequestRAW)), nil
   457  }