github.com/pelicanplatform/pelican@v1.0.5/client/acquire_token.go (about)

     1  /***************************************************************
     2   *
     3   * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research
     4   *
     5   * Licensed under the Apache License, Version 2.0 (the "License"); you
     6   * may not use this file except in compliance with the License.  You may
     7   * obtain a copy of the License at
     8   *
     9   *    http://www.apache.org/licenses/LICENSE-2.0
    10   *
    11   * Unless required by applicable law or agreed to in writing, software
    12   * distributed under the License is distributed on an "AS IS" BASIS,
    13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    14   * See the License for the specific language governing permissions and
    15   * limitations under the License.
    16   *
    17   ***************************************************************/
    18  
    19  package client
    20  
    21  import (
    22  	"context"
    23  	"fmt"
    24  	"net/url"
    25  	"path"
    26  	"strings"
    27  	"time"
    28  
    29  	jwt "github.com/golang-jwt/jwt"
    30  	config "github.com/pelicanplatform/pelican/config"
    31  	namespaces "github.com/pelicanplatform/pelican/namespaces"
    32  	oauth2 "github.com/pelicanplatform/pelican/oauth2"
    33  	log "github.com/sirupsen/logrus"
    34  	oauth2_upstream "golang.org/x/oauth2"
    35  )
    36  
    37  func TokenIsAcceptable(jwtSerialized string, osdfPath string, namespace namespaces.Namespace, opts config.TokenGenerationOpts) bool {
    38  	parser := jwt.Parser{SkipClaimsValidation: true}
    39  	token, _, err := parser.ParseUnverified(jwtSerialized, &jwt.MapClaims{})
    40  	if err != nil {
    41  		log.Warningln("Failed to parse token:", err)
    42  		return false
    43  	}
    44  
    45  	// For now, we'll accept any WLCG token
    46  	wlcg_ver := (*token.Claims.(*jwt.MapClaims))["wlcg.ver"]
    47  	if wlcg_ver == nil {
    48  		return false
    49  	}
    50  
    51  	osdfPathCleaned := path.Clean(osdfPath)
    52  	if !strings.HasPrefix(osdfPathCleaned, namespace.Path) {
    53  		return false
    54  	}
    55  
    56  	// For some issuers, the token base path is distinct from the OSDF base path.
    57  	// Example:
    58  	// - Issuer base path: `/chtc`
    59  	// - Namespace path: `/chtc/PROTECTED`
    60  	// In this case, we want to strip out the issuer base path, not the
    61  	// namespace one, in order to see if the token has the right privs.
    62  
    63  	targetResource := path.Clean("/" + osdfPathCleaned[len(namespace.Path):])
    64  	if namespace.CredentialGen != nil && namespace.CredentialGen.BasePath != nil && len(*namespace.CredentialGen.BasePath) > 0 {
    65  		targetResource = path.Clean("/" + osdfPathCleaned[len(*namespace.CredentialGen.BasePath):])
    66  	}
    67  
    68  	scopes_iface := (*token.Claims.(*jwt.MapClaims))["scope"]
    69  	if scopes, ok := scopes_iface.(string); ok {
    70  		acceptableScope := false
    71  		for _, scope := range strings.Split(scopes, " ") {
    72  			scope_info := strings.Split(scope, ":")
    73  			scopeOK := false
    74  			if (opts.Operation == config.TokenWrite || opts.Operation == config.TokenSharedWrite) && (scope_info[0] == "storage.modify" || scope_info[0] == "storage.create") {
    75  				scopeOK = true
    76  			} else if scope_info[0] == "storage.read" {
    77  				scopeOK = true
    78  			}
    79  			if !scopeOK {
    80  				continue
    81  			}
    82  
    83  			if len(scope_info) == 1 {
    84  				acceptableScope = true
    85  				break
    86  			}
    87  			// Shared URLs must have exact matches; otherwise, prefix matching is acceptable.
    88  			if ((opts.Operation == config.TokenSharedWrite || opts.Operation == config.TokenSharedRead) && (targetResource == scope_info[1])) ||
    89  				strings.HasPrefix(targetResource, scope_info[1]) {
    90  				acceptableScope = true
    91  				break
    92  			}
    93  		}
    94  		if acceptableScope {
    95  			return true
    96  		}
    97  	}
    98  	return false
    99  }
   100  
   101  func TokenIsExpired(jwtSerialized string) bool {
   102  	parser := jwt.Parser{SkipClaimsValidation: true}
   103  	token, _, err := parser.ParseUnverified(jwtSerialized, &jwt.StandardClaims{})
   104  	if err != nil {
   105  		log.Warningln("Failed to parse token:", err)
   106  		return true
   107  	}
   108  
   109  	if claims, ok := token.Claims.(*jwt.StandardClaims); ok {
   110  		return claims.Valid() != nil
   111  	}
   112  	return true
   113  }
   114  
   115  func RegisterClient(namespace namespaces.Namespace) (*config.PrefixEntry, error) {
   116  	issuer, err := config.GetIssuerMetadata(*namespace.CredentialGen.Issuer)
   117  	if err != nil {
   118  		return nil, err
   119  	}
   120  	if issuer.RegistrationURL == "" {
   121  		return nil, fmt.Errorf("Issuer %s does not support dynamic client registration", *namespace.CredentialGen.Issuer)
   122  	}
   123  
   124  	drcp := oauth2.DCRPConfig{ClientRegistrationEndpointURL: issuer.RegistrationURL, Metadata: oauth2.Metadata{
   125  		RedirectURIs:            []string{"https://localhost/osdf-client"},
   126  		TokenEndpointAuthMethod: "client_secret_basic",
   127  		GrantTypes:              []string{"refresh_token", "urn:ietf:params:oauth:grant-type:device_code"},
   128  		ResponseTypes:           []string{"code"},
   129  		ClientName:              "OSDF Command Line Client",
   130  		Scopes:                  []string{"offline_access", "wlcg", "storage.read:/", "storage.modify:/", "storage.create:/"},
   131  	}}
   132  
   133  	resp, err := drcp.Register()
   134  	if err != nil {
   135  		return nil, err
   136  	}
   137  	newEntry := config.PrefixEntry{
   138  		Prefix:       namespace.Path,
   139  		ClientID:     resp.ClientID,
   140  		ClientSecret: resp.ClientSecret,
   141  	}
   142  	return &newEntry, nil
   143  }
   144  
   145  // Given a URL and a piece of the namespace, attempt to acquire a valid
   146  // token for that URL.
   147  func AcquireToken(destination *url.URL, namespace namespaces.Namespace, opts config.TokenGenerationOpts) (string, error) {
   148  	log.Debugln("Acquiring a token from configuration and OAuth2")
   149  
   150  	if namespace.CredentialGen == nil || namespace.CredentialGen.Strategy == nil {
   151  		return "", fmt.Errorf("Credential generation scheme unknown for prefix %s", namespace.Path)
   152  	}
   153  	switch strategy := *namespace.CredentialGen.Strategy; strategy {
   154  	case "OAuth2":
   155  	case "Vault":
   156  		return "", fmt.Errorf("Vault credential generation strategy is not supported")
   157  	default:
   158  		return "", fmt.Errorf("Unknown credential generation strategy (%s) for prefix %s",
   159  			strategy, namespace.Path)
   160  	}
   161  	issuer := *namespace.CredentialGen.Issuer
   162  	if len(issuer) == 0 {
   163  		return "", fmt.Errorf("Issuer for prefix %s is unknown", namespace.Path)
   164  	}
   165  
   166  	osdfConfig, err := config.GetConfigContents()
   167  	if err != nil {
   168  		return "", err
   169  	}
   170  
   171  	prefixIdx := -1
   172  	for idx, entry := range osdfConfig.OSDF.OauthClient {
   173  		if entry.Prefix == namespace.Path {
   174  			prefixIdx = idx
   175  			break
   176  		}
   177  	}
   178  	var prefixEntry *config.PrefixEntry
   179  	newEntry := false
   180  	if prefixIdx < 0 {
   181  		log.Infof("Prefix configuration for %s not in configuration file; will request new client", namespace.Path)
   182  		prefixEntry, err = RegisterClient(namespace)
   183  		if err != nil {
   184  			return "", err
   185  		}
   186  		osdfConfig.OSDF.OauthClient = append(osdfConfig.OSDF.OauthClient, *prefixEntry)
   187  		prefixEntry = &osdfConfig.OSDF.OauthClient[len(osdfConfig.OSDF.OauthClient)-1]
   188  		newEntry = true
   189  	} else {
   190  		prefixEntry = &osdfConfig.OSDF.OauthClient[prefixIdx]
   191  		if len(prefixEntry.ClientID) == 0 || len(prefixEntry.ClientSecret) == 0 {
   192  			log.Infof("Prefix configuration for %s missing OAuth2 client information", namespace.Path)
   193  			prefixEntry, err = RegisterClient(namespace)
   194  			if err != nil {
   195  				return "", err
   196  			}
   197  			osdfConfig.OSDF.OauthClient[prefixIdx] = *prefixEntry
   198  			newEntry = true
   199  		}
   200  	}
   201  	if newEntry {
   202  		if err = config.SaveConfigContents(&osdfConfig); err != nil {
   203  			log.Warningln("Failed to save new token to configuration file:", err)
   204  		}
   205  	}
   206  
   207  	// For now, a fairly useless token-selection algorithm - take the first in the list.
   208  	// In the future, we should:
   209  	// - Check scopes
   210  	var acceptableToken *config.TokenEntry = nil
   211  	acceptableUnexpiredToken := ""
   212  	for idx, token := range prefixEntry.Tokens {
   213  		if !TokenIsAcceptable(token.AccessToken, destination.Path, namespace, opts) {
   214  			continue
   215  		}
   216  		if acceptableToken == nil {
   217  			acceptableToken = &prefixEntry.Tokens[idx]
   218  		} else if acceptableUnexpiredToken != "" {
   219  			// Both tokens are non-empty; let's use them
   220  			break
   221  		}
   222  		if !TokenIsExpired(token.AccessToken) {
   223  			acceptableUnexpiredToken = token.AccessToken
   224  		}
   225  	}
   226  	if len(acceptableUnexpiredToken) > 0 {
   227  		log.Debugln("Returning an unexpired token from cache")
   228  		return acceptableUnexpiredToken, nil
   229  	}
   230  
   231  	if acceptableToken != nil && len(acceptableToken.RefreshToken) > 0 {
   232  
   233  		// We have a reasonable token; let's try refreshing it.
   234  		upstreamToken := oauth2_upstream.Token{
   235  			AccessToken:  acceptableToken.AccessToken,
   236  			RefreshToken: acceptableToken.RefreshToken,
   237  			Expiry:       time.Unix(0, 0),
   238  		}
   239  		issuerInfo, err := config.GetIssuerMetadata(issuer)
   240  		if err == nil {
   241  			upstreamConfig := oauth2_upstream.Config{
   242  				ClientID:     prefixEntry.ClientID,
   243  				ClientSecret: prefixEntry.ClientSecret,
   244  				Endpoint: oauth2_upstream.Endpoint{
   245  					AuthURL:  issuerInfo.AuthURL,
   246  					TokenURL: issuerInfo.TokenURL,
   247  				}}
   248  			ctx := context.Background()
   249  			source := upstreamConfig.TokenSource(ctx, &upstreamToken)
   250  			newToken, err := source.Token()
   251  			if err != nil {
   252  				log.Warningln("Failed to renew an expired token:", err)
   253  			} else {
   254  				acceptableToken.AccessToken = newToken.AccessToken
   255  				acceptableToken.Expiration = newToken.Expiry.Unix()
   256  				if len(newToken.RefreshToken) != 0 {
   257  					acceptableToken.RefreshToken = newToken.RefreshToken
   258  				}
   259  				if err = config.SaveConfigContents(&osdfConfig); err != nil {
   260  					log.Warningln("Failed to save new token to configuration file:", err)
   261  				}
   262  				return newToken.AccessToken, nil
   263  			}
   264  		}
   265  	}
   266  
   267  	token, err := oauth2.AcquireToken(issuer, prefixEntry, namespace.CredentialGen, destination.Path, opts)
   268  	if err != nil {
   269  		return "", err
   270  	}
   271  
   272  	Tokens := &prefixEntry.Tokens
   273  	*Tokens = append(*Tokens, *token)
   274  
   275  	if err = config.SaveConfigContents(&osdfConfig); err != nil {
   276  		log.Warningln("Failed to save new token to configuration file:", err)
   277  	}
   278  
   279  	return token.AccessToken, nil
   280  }