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