token.go raw

   1  package cli
   2  
   3  // Copyright 2017 Microsoft Corporation
   4  //
   5  //  Licensed under the Apache License, Version 2.0 (the "License");
   6  //  you may not use this file except in compliance with the License.
   7  //  You may 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  import (
  18  	"bytes"
  19  	"encoding/json"
  20  	"fmt"
  21  	"os"
  22  	"os/exec"
  23  	"path/filepath"
  24  	"regexp"
  25  	"runtime"
  26  	"strconv"
  27  	"time"
  28  
  29  	"github.com/Azure/go-autorest/autorest/adal"
  30  	"github.com/Azure/go-autorest/autorest/date"
  31  	"github.com/mitchellh/go-homedir"
  32  )
  33  
  34  // Token represents an AccessToken from the Azure CLI
  35  type Token struct {
  36  	AccessToken      string `json:"accessToken"`
  37  	Authority        string `json:"_authority"`
  38  	ClientID         string `json:"_clientId"`
  39  	ExpiresOn        string `json:"expiresOn"`
  40  	IdentityProvider string `json:"identityProvider"`
  41  	IsMRRT           bool   `json:"isMRRT"`
  42  	RefreshToken     string `json:"refreshToken"`
  43  	Resource         string `json:"resource"`
  44  	TokenType        string `json:"tokenType"`
  45  	UserID           string `json:"userId"`
  46  }
  47  
  48  const accessTokensJSON = "accessTokens.json"
  49  
  50  // ToADALToken converts an Azure CLI `Token`` to an `adal.Token``
  51  func (t Token) ToADALToken() (converted adal.Token, err error) {
  52  	tokenExpirationDate, err := ParseExpirationDate(t.ExpiresOn)
  53  	if err != nil {
  54  		err = fmt.Errorf("Error parsing Token Expiration Date %q: %+v", t.ExpiresOn, err)
  55  		return
  56  	}
  57  
  58  	difference := tokenExpirationDate.Sub(date.UnixEpoch())
  59  
  60  	converted = adal.Token{
  61  		AccessToken:  t.AccessToken,
  62  		Type:         t.TokenType,
  63  		ExpiresIn:    "3600",
  64  		ExpiresOn:    json.Number(strconv.Itoa(int(difference.Seconds()))),
  65  		RefreshToken: t.RefreshToken,
  66  		Resource:     t.Resource,
  67  	}
  68  	return
  69  }
  70  
  71  // AccessTokensPath returns the path where access tokens are stored from the Azure CLI
  72  // TODO(#199): add unit test.
  73  func AccessTokensPath() (string, error) {
  74  	// Azure-CLI allows user to customize the path of access tokens through environment variable.
  75  	if accessTokenPath := os.Getenv("AZURE_ACCESS_TOKEN_FILE"); accessTokenPath != "" {
  76  		return accessTokenPath, nil
  77  	}
  78  
  79  	// Azure-CLI allows user to customize the path to Azure config directory through environment variable.
  80  	if cfgDir := configDir(); cfgDir != "" {
  81  		return filepath.Join(cfgDir, accessTokensJSON), nil
  82  	}
  83  
  84  	// Fallback logic to default path on non-cloud-shell environment.
  85  	// TODO(#200): remove the dependency on hard-coding path.
  86  	return homedir.Expand("~/.azure/" + accessTokensJSON)
  87  }
  88  
  89  // ParseExpirationDate parses either a Azure CLI or CloudShell date into a time object
  90  func ParseExpirationDate(input string) (*time.Time, error) {
  91  	// CloudShell (and potentially the Azure CLI in future)
  92  	expirationDate, cloudShellErr := time.Parse(time.RFC3339, input)
  93  	if cloudShellErr != nil {
  94  		// Azure CLI (Python) e.g. 2017-08-31 19:48:57.998857 (plus the local timezone)
  95  		const cliFormat = "2006-01-02 15:04:05.999999"
  96  		expirationDate, cliErr := time.ParseInLocation(cliFormat, input, time.Local)
  97  		if cliErr == nil {
  98  			return &expirationDate, nil
  99  		}
 100  
 101  		return nil, fmt.Errorf("Error parsing expiration date %q.\n\nCloudShell Error: \n%+v\n\nCLI Error:\n%+v", input, cloudShellErr, cliErr)
 102  	}
 103  
 104  	return &expirationDate, nil
 105  }
 106  
 107  // LoadTokens restores a set of Token objects from a file located at 'path'.
 108  func LoadTokens(path string) ([]Token, error) {
 109  	file, err := os.Open(path)
 110  	if err != nil {
 111  		return nil, fmt.Errorf("failed to open file (%s) while loading token: %v", path, err)
 112  	}
 113  	defer file.Close()
 114  
 115  	var tokens []Token
 116  
 117  	dec := json.NewDecoder(file)
 118  	if err = dec.Decode(&tokens); err != nil {
 119  		return nil, fmt.Errorf("failed to decode contents of file (%s) into a `cli.Token` representation: %v", path, err)
 120  	}
 121  
 122  	return tokens, nil
 123  }
 124  
 125  // GetTokenFromCLI gets a token using Azure CLI 2.0 for local development scenarios.
 126  func GetTokenFromCLI(resource string) (*Token, error) {
 127  	return GetTokenFromCLIWithParams(GetAccessTokenParams{Resource: resource})
 128  }
 129  
 130  // GetAccessTokenParams is the parameter struct of GetTokenFromCLIWithParams
 131  type GetAccessTokenParams struct {
 132  	Resource     string
 133  	ResourceType string
 134  	Subscription string
 135  	Tenant       string
 136  }
 137  
 138  // GetTokenFromCLIWithParams gets a token using Azure CLI 2.0 for local development scenarios.
 139  func GetTokenFromCLIWithParams(params GetAccessTokenParams) (*Token, error) {
 140  	cliCmd := GetAzureCLICommand()
 141  
 142  	cliCmd.Args = append(cliCmd.Args, "account", "get-access-token", "-o", "json")
 143  	if params.Resource != "" {
 144  		if err := validateParameter(params.Resource); err != nil {
 145  			return nil, err
 146  		}
 147  		cliCmd.Args = append(cliCmd.Args, "--resource", params.Resource)
 148  	}
 149  	if params.ResourceType != "" {
 150  		if err := validateParameter(params.ResourceType); err != nil {
 151  			return nil, err
 152  		}
 153  		cliCmd.Args = append(cliCmd.Args, "--resource-type", params.ResourceType)
 154  	}
 155  	if params.Subscription != "" {
 156  		if err := validateParameter(params.Subscription); err != nil {
 157  			return nil, err
 158  		}
 159  		cliCmd.Args = append(cliCmd.Args, "--subscription", params.Subscription)
 160  	}
 161  	if params.Tenant != "" {
 162  		if err := validateParameter(params.Tenant); err != nil {
 163  			return nil, err
 164  		}
 165  		cliCmd.Args = append(cliCmd.Args, "--tenant", params.Tenant)
 166  	}
 167  
 168  	var stderr bytes.Buffer
 169  	cliCmd.Stderr = &stderr
 170  
 171  	output, err := cliCmd.Output()
 172  	if err != nil {
 173  		if stderr.Len() > 0 {
 174  			return nil, fmt.Errorf("Invoking Azure CLI failed with the following error: %s", stderr.String())
 175  		}
 176  
 177  		return nil, fmt.Errorf("Invoking Azure CLI failed with the following error: %s", err.Error())
 178  	}
 179  
 180  	tokenResponse := Token{}
 181  	err = json.Unmarshal(output, &tokenResponse)
 182  	if err != nil {
 183  		return nil, err
 184  	}
 185  
 186  	return &tokenResponse, err
 187  }
 188  
 189  func validateParameter(param string) error {
 190  	// Validate parameters, since it gets sent as a command line argument to Azure CLI
 191  	const invalidResourceErrorTemplate = "Parameter %s is not in expected format. Only alphanumeric characters, [dot], [colon], [hyphen], and [forward slash] are allowed."
 192  	match, err := regexp.MatchString("^[0-9a-zA-Z-.:/]+$", param)
 193  	if err != nil {
 194  		return err
 195  	}
 196  	if !match {
 197  		return fmt.Errorf(invalidResourceErrorTemplate, param)
 198  	}
 199  	return nil
 200  }
 201  
 202  // GetAzureCLICommand can be used to run arbitrary Azure CLI command
 203  func GetAzureCLICommand() *exec.Cmd {
 204  	// This is the path that a developer can set to tell this class what the install path for Azure CLI is.
 205  	const azureCLIPath = "AzureCLIPath"
 206  
 207  	// The default install paths are used to find Azure CLI. This is for security, so that any path in the calling program's Path environment is not used to execute Azure CLI.
 208  	azureCLIDefaultPathWindows := fmt.Sprintf("%s\\Microsoft SDKs\\Azure\\CLI2\\wbin; %s\\Microsoft SDKs\\Azure\\CLI2\\wbin", os.Getenv("ProgramFiles(x86)"), os.Getenv("ProgramFiles"))
 209  
 210  	// Default path for non-Windows.
 211  	const azureCLIDefaultPath = "/bin:/sbin:/usr/bin:/usr/local/bin"
 212  
 213  	// Execute Azure CLI to get token
 214  	var cliCmd *exec.Cmd
 215  	if runtime.GOOS == "windows" {
 216  		cliCmd = exec.Command(fmt.Sprintf("%s\\system32\\cmd.exe", os.Getenv("windir")))
 217  		cliCmd.Env = os.Environ()
 218  		cliCmd.Env = append(cliCmd.Env, fmt.Sprintf("PATH=%s;%s", os.Getenv(azureCLIPath), azureCLIDefaultPathWindows))
 219  		cliCmd.Args = append(cliCmd.Args, "/c", "az")
 220  	} else {
 221  		cliCmd = exec.Command("az")
 222  		cliCmd.Env = os.Environ()
 223  		cliCmd.Env = append(cliCmd.Env, fmt.Sprintf("PATH=%s:%s", os.Getenv(azureCLIPath), azureCLIDefaultPath))
 224  	}
 225  
 226  	return cliCmd
 227  }
 228