github.com/argoproj/argo-cd/v3@v3.2.1/util/oidc/provider.go (about)

     1  package oidc
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"net/http"
     8  	"strings"
     9  
    10  	gooidc "github.com/coreos/go-oidc/v3/oidc"
    11  	log "github.com/sirupsen/logrus"
    12  	"golang.org/x/oauth2"
    13  
    14  	"github.com/argoproj/argo-cd/v3/util/security"
    15  	"github.com/argoproj/argo-cd/v3/util/settings"
    16  )
    17  
    18  // Provider is a wrapper around go-oidc provider to also provide the following features:
    19  // 1. lazy initialization/querying of the provider
    20  // 2. automatic detection of change in signing keys
    21  // 3. convenience function for verifying tokens
    22  // We have to initialize the provider lazily since Argo CD can be an OIDC client to itself (in the
    23  // case of dex reverse proxy), which presents a chicken-and-egg problem of (1) serving dex over
    24  // HTTP, and (2) querying the OIDC provider (ourself) to initialize the OIDC client.
    25  type Provider interface {
    26  	Endpoint() (*oauth2.Endpoint, error)
    27  
    28  	ParseConfig() (*OIDCConfiguration, error)
    29  
    30  	Verify(tokenString string, argoSettings *settings.ArgoCDSettings) (*gooidc.IDToken, error)
    31  }
    32  
    33  type providerImpl struct {
    34  	issuerURL      string
    35  	client         *http.Client
    36  	goOIDCProvider *gooidc.Provider
    37  }
    38  
    39  var _ Provider = &providerImpl{}
    40  
    41  // NewOIDCProvider initializes an OIDC provider
    42  func NewOIDCProvider(issuerURL string, client *http.Client) Provider {
    43  	return &providerImpl{
    44  		issuerURL: issuerURL,
    45  		client:    client,
    46  	}
    47  }
    48  
    49  // oidcProvider lazily initializes, memoizes, and returns the OIDC provider.
    50  func (p *providerImpl) provider() (*gooidc.Provider, error) {
    51  	if p.goOIDCProvider != nil {
    52  		return p.goOIDCProvider, nil
    53  	}
    54  	prov, err := p.newGoOIDCProvider()
    55  	if err != nil {
    56  		return nil, err
    57  	}
    58  	p.goOIDCProvider = prov
    59  	return p.goOIDCProvider, nil
    60  }
    61  
    62  // newGoOIDCProvider creates a new instance of go-oidc.Provider querying the well known oidc
    63  // configuration path (http://example-argocd.com/api/dex/.well-known/openid-configuration)
    64  func (p *providerImpl) newGoOIDCProvider() (*gooidc.Provider, error) {
    65  	log.Infof("Initializing OIDC provider (issuer: %s)", p.issuerURL)
    66  	ctx := gooidc.ClientContext(context.Background(), p.client)
    67  	prov, err := gooidc.NewProvider(ctx, p.issuerURL)
    68  	if err != nil {
    69  		return nil, fmt.Errorf("failed to query provider %q: %w", p.issuerURL, err)
    70  	}
    71  	s, _ := ParseConfig(prov)
    72  	log.Infof("OIDC supported scopes: %v", s.ScopesSupported)
    73  	return prov, nil
    74  }
    75  
    76  type tokenVerificationError struct {
    77  	errorsByAudience map[string]error
    78  }
    79  
    80  func (t tokenVerificationError) Error() string {
    81  	var errorStrings []string
    82  	for aud, err := range t.errorsByAudience {
    83  		errorStrings = append(errorStrings, fmt.Sprintf("error for aud %q: %v", aud, err))
    84  	}
    85  	return "token verification failed for all audiences: " + strings.Join(errorStrings, ", ")
    86  }
    87  
    88  func (p *providerImpl) Verify(tokenString string, argoSettings *settings.ArgoCDSettings) (*gooidc.IDToken, error) {
    89  	// According to the JWT spec, the aud claim is optional. The spec also says (emphasis mine):
    90  	//
    91  	//   If the principal processing the claim does not identify itself with a value in the "aud" claim _when this
    92  	//   claim is present_, then the JWT MUST be rejected.
    93  	//
    94  	//     - https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3
    95  	//
    96  	// If the claim is not present, we can skip the audience claim check (called the "ClientID check" in go-oidc's
    97  	// terminology).
    98  	//
    99  	// The OIDC spec says that the aud claim is required (https://openid.net/specs/openid-connect-core-1_0.html#IDToken).
   100  	// But we cannot assume that all OIDC providers will follow the spec. For Argo CD <2.6.0, we will default to
   101  	// allowing the aud claim to be optional. In Argo CD >=2.6.0, we will default to requiring the aud claim to be
   102  	// present and give users the skipAudienceCheckWhenTokenHasNoAudience setting to revert the behavior if necessary.
   103  	//
   104  	// At this point, we have not verified that the token has not been altered. All code paths below MUST VERIFY
   105  	// THE TOKEN SIGNATURE to confirm that an attacker did not maliciously remove the "aud" claim.
   106  	unverifiedHasAudClaim, err := security.UnverifiedHasAudClaim(tokenString)
   107  	if err != nil {
   108  		return nil, fmt.Errorf("failed to determine whether the token has an aud claim: %w", err)
   109  	}
   110  
   111  	var idToken *gooidc.IDToken
   112  	if !unverifiedHasAudClaim {
   113  		idToken, err = p.verify("", tokenString, argoSettings.SkipAudienceCheckWhenTokenHasNoAudience())
   114  	} else {
   115  		allowedAudiences := argoSettings.OAuth2AllowedAudiences()
   116  		if len(allowedAudiences) == 0 {
   117  			return nil, errors.New("token has an audience claim, but no allowed audiences are configured")
   118  		}
   119  		tokenVerificationErrors := make(map[string]error)
   120  		// Token must be verified for at least one allowed audience
   121  		for _, aud := range allowedAudiences {
   122  			idToken, err = p.verify(aud, tokenString, false)
   123  			tokenExpiredError := &gooidc.TokenExpiredError{}
   124  			if errors.As(err, &tokenExpiredError) {
   125  				// If the token is expired, we won't bother checking other audiences. It's important to return a
   126  				// TokenExpiredError instead of an error related to an incorrect audience, because the caller may
   127  				// have specific behavior to handle expired tokens.
   128  				break
   129  			}
   130  			if err == nil {
   131  				break
   132  			}
   133  			// We store the error for each audience so that we can return a more detailed error message to the user.
   134  			// If this gets merged, we'll be able to detect failures unrelated to audiences and short-circuit this loop
   135  			// to avoid logging irrelevant warnings: https://github.com/coreos/go-oidc/pull/406
   136  			tokenVerificationErrors[aud] = err
   137  		}
   138  		// If the most recent attempt encountered an error, and if we have collected multiple errors, switch to the
   139  		// other error type to gather more context.
   140  		if err != nil && len(tokenVerificationErrors) > 0 {
   141  			err = tokenVerificationError{errorsByAudience: tokenVerificationErrors}
   142  		}
   143  	}
   144  
   145  	if err != nil {
   146  		return nil, fmt.Errorf("failed to verify token: %w", err)
   147  	}
   148  
   149  	return idToken, nil
   150  }
   151  
   152  func (p *providerImpl) verify(clientID, tokenString string, skipClientIDCheck bool) (*gooidc.IDToken, error) {
   153  	ctx := context.Background()
   154  	prov, err := p.provider()
   155  	if err != nil {
   156  		return nil, err
   157  	}
   158  	config := &gooidc.Config{ClientID: clientID, SkipClientIDCheck: skipClientIDCheck}
   159  	verifier := prov.Verifier(config)
   160  	idToken, err := verifier.Verify(ctx, tokenString)
   161  	if err != nil {
   162  		// HACK: if we failed token verification, it's possible the reason was because dex
   163  		// restarted and has new JWKS signing keys (we do not back dex with persistent storage
   164  		// so keys might be regenerated). Detect this by:
   165  		// 1. looking for the specific error message
   166  		// 2. re-initializing the OIDC provider
   167  		// 3. re-attempting token verification
   168  		// NOTE: the error message is sensitive to implementation of verifier.Verify()
   169  		if !strings.Contains(err.Error(), "failed to verify signature") {
   170  			return nil, err
   171  		}
   172  		newProvider, retryErr := p.newGoOIDCProvider()
   173  		if retryErr != nil {
   174  			// return original error if we fail to re-initialize OIDC
   175  			return nil, err
   176  		}
   177  		verifier = newProvider.Verifier(config)
   178  		idToken, err = verifier.Verify(ctx, tokenString)
   179  		if err != nil {
   180  			return nil, err
   181  		}
   182  		// If we get here, we successfully re-initialized OIDC and after re-initialization,
   183  		// the token is now valid.
   184  		log.Info("New OIDC settings detected")
   185  		p.goOIDCProvider = newProvider
   186  	}
   187  	return idToken, nil
   188  }
   189  
   190  func (p *providerImpl) Endpoint() (*oauth2.Endpoint, error) {
   191  	prov, err := p.provider()
   192  	if err != nil {
   193  		return nil, err
   194  	}
   195  	endpoint := prov.Endpoint()
   196  	return &endpoint, nil
   197  }
   198  
   199  // ParseConfig parses the OIDC Config into the concrete datastructure
   200  func (p *providerImpl) ParseConfig() (*OIDCConfiguration, error) {
   201  	prov, err := p.provider()
   202  	if err != nil {
   203  		return nil, err
   204  	}
   205  	return ParseConfig(prov)
   206  }
   207  
   208  // ParseConfig parses the OIDC Config into the concrete datastructure
   209  func ParseConfig(provider *gooidc.Provider) (*OIDCConfiguration, error) {
   210  	var conf OIDCConfiguration
   211  	err := provider.Claims(&conf)
   212  	if err != nil {
   213  		return nil, err
   214  	}
   215  	return &conf, nil
   216  }