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