github.com/actions-on-google/gactions@v3.2.0+incompatible/api/apiutils.go (about)

     1  // Copyright 2020 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     https://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package apiutils contains utility functions to simplify working with gRPC libraries.
    16  package apiutils
    17  
    18  import (
    19  	"bytes"
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"io/ioutil"
    25  	"net"
    26  	"net/http"
    27  	"net/url"
    28  	"os"
    29  	"os/exec"
    30  	"os/signal"
    31  	"os/user"
    32  	"path/filepath"
    33  	"runtime"
    34  	"text/template"
    35  	"time"
    36  
    37  	"github.com/actions-on-google/gactions/log"
    38  
    39  	"golang.org/x/oauth2/google"
    40  	"golang.org/x/oauth2"
    41  )
    42  
    43  const (
    44  	builderAPIScope = "https://www.googleapis.com/auth/actions.builder"
    45  	loginPrompt     = `
    46  <!DOCTYPE html>
    47  <html>
    48    <head>
    49      <meta charset="utf-8">
    50      <meta name="viewport" content="width=device-width, initial-scale=1">
    51      <title>gactions CLI</title>
    52  
    53      <style media="screen">
    54        body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
    55        #message { background: white; max-width: 360px; margin: 100px auto 16px; padding: 32px 24px 8px; border-radius: 3px; }
    56        #message h2 { color: #4caf50; font-weight: bold; font-size: 16px; margin: 0 0 8px; }
    57        #message h1 { font-size: 22px; font-weight: 300; color: rgba(0,0,0,0.6); margin: 0 0 16px;}
    58        #message p { line-height: 140%; margin: 16px 0 24px; font-size: 14px; }
    59        #message a { display: block; text-align: center; background: #039be5; text-transform: uppercase; text-decoration: none; color: white; padding: 16px; border-radius: 4px; }
    60        #message, #message a { box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); }
    61        #load { color: rgba(0,0,0,0.4); text-align: center; font-size: 13px; }
    62        @media (max-width: 600px) {
    63          body, #message { margin-top: 0; background: white; box-shadow: none; }
    64          body { border-top: 16px solid #4caf50; }
    65        }
    66        code { font-size: 18px; color: #999; }
    67      </style>
    68    </head>
    69    <body>
    70      <div id="message">
    71        <h2>{{.H2}}</h2>
    72        <h1>{{.H1}}</h1>
    73        <p>{{.P}}</p>
    74      </div>
    75    </body>
    76  </html>
    77  `
    78  )
    79  
    80  // NewHTTPClient returns a *http.Client created with all required scopes and permissions.
    81  // tokenFilepath can be set to "" if not otherwise defined.
    82  func NewHTTPClient(ctx context.Context, clientSecretKeyFile []byte, tokenFilepath string) (*http.Client, error) {
    83  	config, err := google.ConfigFromJSON(clientSecretKeyFile, builderAPIScope)
    84  	if err != nil {
    85  		return nil, err
    86  	}
    87  	tokenCacheFilename := ""
    88  	if tokenFilepath == "" {
    89  		tokenCacheFilename, err = tokenCacheFile()
    90  		if err != nil {
    91  			return nil, err
    92  		}
    93  	} else {
    94  		tokenCacheFilename = tokenFilepath
    95  	}
    96  	if !exists(tokenCacheFilename) {
    97  		log.Infoln("Could not locate OAuth2 token")
    98  		return nil, errors.New(`command requires authentication. try to run "gactions login" first`)
    99  	}
   100  	tok, err := tokenFromFile(tokenCacheFilename)
   101  	if err != nil {
   102  		return nil, err
   103  	}
   104  	return config.Client(ctx, tok), nil
   105  }
   106  
   107  // Auth prompts user for authentication token and writes it to disc.
   108  func Auth(ctx context.Context, clientSecretKeyFile []byte) error {
   109  	config, err := google.ConfigFromJSON(clientSecretKeyFile, []string{builderAPIScope}...)
   110  	if err != nil {
   111  		return err
   112  	}
   113  	// Get OAuth2 token from the user. It will be written into cacheFilename.
   114  	tokenCacheFilename, err := tokenCacheFile()
   115  	if err != nil {
   116  		return err
   117  	}
   118  	// Check the shell is appropriate for use of launched browsers, otherwise present the copy/paste
   119  	// flow.
   120  	nonSSH := checkShell()
   121  	notWindows := runtime.GOOS != "windows"
   122  	tok, err := token(ctx, config, tokenCacheFilename, nonSSH && notWindows)
   123  	if err != nil {
   124  		return err
   125  	}
   126  	if err := saveToken(tokenCacheFilename, tok); err != nil {
   127  		return err
   128  	}
   129  	return nil
   130  }
   131  
   132  // RemoveToken deletes the stored token
   133  func RemoveToken() error {
   134  	s, err := tokenCacheFile()
   135  	if err != nil {
   136  		return err
   137  	}
   138  	return RemoveTokenWithFilename(s)
   139  }
   140  
   141  func RemoveTokenWithFilename(filename string) error {
   142  	if !exists(filename) {
   143  		log.Outf("Already logged out.")
   144  		return errors.New("already logged out")
   145  	}
   146  	b, err := ioutil.ReadFile(filename)
   147  	if err != nil {
   148  		return err
   149  	}
   150  	log.Infof("Removing %s\n", filename)
   151  	if err := os.Remove(filename); err != nil {
   152  		return err
   153  	}
   154  	log.Infof("Successfully removed %s\n", filename)
   155  	return revokeToken(b)
   156  }
   157  
   158  var revokeToken = func(file []byte) error {
   159  	type tokenFile struct {
   160  		AccessToken  string `json:"access_token"`
   161  		RefreshToken string `json:"refresh_token"`
   162  	}
   163  	var out tokenFile
   164  	if err := json.Unmarshal(file, &out); err != nil {
   165  		return err
   166  	}
   167  	// Revokes an access token or if it's expired, revokes the refresh token
   168  	// If the token has expired, been tampered with, or had its permissions revoked,
   169  	// Google's authorization server returns an error message in the JSON object.
   170  	// The error surfaces as a 400 error. Revoking an access token also revokes
   171  	// a refresh token associated with it.
   172  	// Reference: https://developers.google.com/youtube/v3/live/guides/auth/client-side-web-apps
   173  	for i := 0; i < 2; i++ {
   174  		var token string
   175  		if i == 0 {
   176  			token = out.AccessToken
   177  		} else {
   178  			token = out.RefreshToken
   179  		}
   180  		log.Infof("Attempt %v: revoking a token.\n", i)
   181  		url := fmt.Sprintf("https://accounts.google.com/o/oauth2/revoke?token=%s", token)
   182  		resp, err := http.Get(url)
   183  		if err != nil {
   184  			return err
   185  		}
   186  		if resp.StatusCode == 200 {
   187  			log.Infof("Attempt %v: successfully revoked a token.\n", i)
   188  			break
   189  		}
   190  	}
   191  	return nil
   192  }
   193  
   194  // token retrieves OAuth2 token with the given OAuth2 config. It tries looking up in tokenCacheFilename, and
   195  // if token is not found, will prompt the user to get an interactive code to exchange for OAuth2 token.
   196  var token = func(ctx context.Context, config *oauth2.Config, tokenCacheFilename string, launchBrowser bool) (*oauth2.Token, error) {
   197  	var tok *oauth2.Token
   198  	var err error
   199  	tok, err = tokenFromFile(tokenCacheFilename)
   200  	if err == nil {
   201  		return tok, nil
   202  	}
   203  	if launchBrowser {
   204  		tok, err = interactiveTokenWeb(ctx, config)
   205  	} else {
   206  		tok, err = interactiveTokenCopyPaste(ctx, config)
   207  	}
   208  	return tok, err
   209  }
   210  
   211  // Checks if the shell is not SSH.
   212  func checkShell() bool {
   213  	// https://en.wikibooks.org/wiki/OpenSSH/Client_Applications
   214  	return len(os.Getenv("SSH_CLIENT")) == 0
   215  }
   216  
   217  // tokenFromFile retrieves a Token from a given file path.
   218  // It returns the retrieved Token and any read error encountered.
   219  func tokenFromFile(file string) (*oauth2.Token, error) {
   220  	b, err := ioutil.ReadFile(file)
   221  	if err != nil {
   222  		return nil, err
   223  	}
   224  	t := &oauth2.Token{}
   225  	err = json.Unmarshal(b, t)
   226  	if err != nil {
   227  		return nil, err
   228  	}
   229  	return t, err
   230  }
   231  
   232  // interactiveToken gets OAuth2 token from an authorization code received from the user.
   233  var interactiveTokenCopyPaste = func(ctx context.Context, conf *oauth2.Config) (*oauth2.Token, error) {
   234  	requestURL := conf.AuthCodeURL("state", oauth2.AccessTypeOffline)
   235  	log.Outln("Gactions needs access to your Google account. Please copy & paste the URL below into a web browser and follow the instructions there. Then copy and paste the authorization code from the browser back here.")
   236  	log.Outf("Visit this URL: \n%s\n", requestURL)
   237  	log.Out("Enter authorization code: ")
   238  	var code string
   239  	_, err := fmt.Scan(&code)
   240  	if err != nil {
   241  		return nil, err
   242  	}
   243  	tok, err := conf.Exchange(ctx, code)
   244  	if err != nil {
   245  		return nil, err
   246  	}
   247  	return tok, nil
   248  }
   249  
   250  // interactiveToken gets OAuth2 token from an authorization code received from the user.
   251  var interactiveTokenWeb = func(ctx context.Context, configIn *oauth2.Config) (*oauth2.Token, error) {
   252  	// Start server on localhost and let net pick the open port.
   253  	listener, err := net.Listen("tcp", "localhost:0")
   254  	if err != nil {
   255  		return nil, err
   256  	}
   257  	defer listener.Close()
   258  	tcpAddr, err := net.ResolveTCPAddr("tcp", listener.Addr().String())
   259  	if err != nil {
   260  		return nil, err
   261  	}
   262  	redirectPath := "/oauth"
   263  	redirectPort := tcpAddr.Port
   264  	urlPrefix := fmt.Sprintf("http://localhost:%d", redirectPort)
   265  	// Make a copy of the config and patch its RedirectURL member.
   266  	config := *configIn
   267  	config.RedirectURL = urlPrefix + redirectPath
   268  
   269  	// Launch browser (note: this would not work in a SSH session).
   270  	authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
   271  	var cmdName string
   272  	switch runtime.GOOS {
   273  	case "linux":
   274  		cmdName = "xdg-open"
   275  	case "darwin":
   276  		cmdName = "open"
   277  	default:
   278  		return nil, fmt.Errorf("can not automatically open a browser on %v", runtime.GOOS)
   279  	}
   280  	cmd := exec.Command(cmdName, authURL)
   281  	if err := cmd.Start(); err != nil {
   282  		return nil, err
   283  	}
   284  
   285  	// Setup server handle functions.
   286  	errCh := make(chan error)
   287  	codes := make(chan string)
   288  	http.HandleFunc(redirectPath, func(w http.ResponseWriter, request *http.Request) {
   289  		query := request.URL.Query()
   290  		type loginPromptData struct {
   291  			H1 string
   292  			H2 string
   293  			P  string
   294  		}
   295  		var t *template.Template
   296  		var errTemplate error
   297  		t = template.Must(template.New("login").Parse(loginPrompt))
   298  		s := ""
   299  		buf := bytes.NewBufferString(s)
   300  		if err := query.Get("error"); err != "" {
   301  			errCh <- fmt.Errorf("OAuth error response: %v", err)
   302  			errTemplate = t.Execute(buf, loginPromptData{
   303  				H2: "Oops!",
   304  				H1: "gactions CLI Login Failed",
   305  				P:  "The gactions CLI login request was rejected or an error occurred. Please run gactions login again.",
   306  			})
   307  		} else if code := query.Get("code"); code == "" {
   308  			errCh <- fmt.Errorf("OAuth error empty")
   309  			errTemplate = t.Execute(buf, loginPromptData{
   310  				H2: "Oops!",
   311  				H1: "gactions CLI Login Failed",
   312  				P:  "The gactions CLI login request was rejected or an error occurred. Please run gactions login again.",
   313  			})
   314  		} else {
   315  			codes <- code
   316  			errTemplate = t.Execute(buf, loginPromptData{
   317  				H2: "Great!",
   318  				H1: "gactions CLI Login Successful",
   319  				P:  "You are logged in to the gactions Command-Line interface. You can immediately close this window and continue using the CLI.",
   320  			})
   321  		}
   322  		if errTemplate != nil {
   323  			fmt.Fprint(w, "<html><body><h1>gactions login failed. Please try again.</h1></body>")
   324  		} else {
   325  			fmt.Fprint(w, buf.String())
   326  		}
   327  	})
   328  
   329  	// Start server, defer shutdown to end of function.
   330  	server := http.Server{}
   331  	go server.Serve(listener)
   332  
   333  	// Have server running for only 1 minute and then stop.
   334  	ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
   335  	defer cancel()
   336  	defer server.Shutdown(ctx)
   337  
   338  	stop := make(chan os.Signal, 1)
   339  	signal.Notify(stop, os.Interrupt)
   340  
   341  	// Obtain either code or error.
   342  	select {
   343  	case err = <-errCh:
   344  		return nil, err
   345  	case code := <-codes:
   346  		log.Infoln("OAuth key code obtained.")
   347  		return config.Exchange(ctx, code)
   348  	case <-stop:
   349  		return nil, errors.New("caught interrupt signal")
   350  	case <-ctx.Done():
   351  		if ctx.Err() == context.DeadlineExceeded {
   352  			log.Infof("Deadline exceeded: %s", ctx.Err().Error())
   353  			return nil, errors.New("waited for user input for too long")
   354  		}
   355  		return nil, errors.New("unable to retrieve OAuth key code")
   356  	}
   357  }
   358  
   359  // saveToken uses a file path to create a file and store the
   360  // token in it.
   361  func saveToken(file string, token *oauth2.Token) error {
   362  	if exists(file) {
   363  		return nil
   364  	}
   365  	log.Infof("Saving credential file to: %s\n", file)
   366  	tokenJSON, err := json.Marshal(token)
   367  	if err != nil {
   368  		return fmt.Errorf("unable to marshal token into json: %v", err)
   369  	}
   370  	return ioutil.WriteFile(file, tokenJSON, 0644)
   371  }
   372  
   373  // exists returns whether the given file or directory exists or not
   374  func exists(path string) bool {
   375  	if _, err := os.Stat(path); err != nil {
   376  		return os.IsExist(err)
   377  	}
   378  	return true
   379  }
   380  
   381  // tokenCacheFile generates credential file path/filename.
   382  // It returns the generated credential path/filename.
   383  var tokenCacheFile = func() (string, error) {
   384  	usr, err := user.Current()
   385  	if err != nil {
   386  		return "", err
   387  	}
   388  	tokenCacheDir := filepath.Join(usr.HomeDir, ".credentials")
   389  	os.MkdirAll(tokenCacheDir, 0700)
   390  	return filepath.Join(tokenCacheDir,
   391  		url.QueryEscape("gactions-actions.googleapis.com-go.json")), err
   392  }