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 }