From 0fcfbf39c30e4e1fc9bf99f73137bc5ca8f9de8e Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Thu, 24 May 2018 18:48:03 -0700 Subject: [PATCH] Support browser based logins to the CLI During login, if no access token is provided, use our browser based login. --- Gopkg.lock | 8 +- pkg/backend/cloud/backend.go | 121 +++++++++++++++++++++++++++--- pkg/backend/cloud/console.go | 40 ++++++++++ pkg/backend/cloud/console_test.go | 38 ++++++++++ 4 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 pkg/backend/cloud/console.go create mode 100644 pkg/backend/cloud/console_test.go diff --git a/Gopkg.lock b/Gopkg.lock index b6888a5b6..36eba18b9 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -300,6 +300,12 @@ packages = ["diffmatchpatch"] revision = "2fc9cd33b5f86077aa3e0f442fa0476a9fa9a1dc" +[[projects]] + branch = "master" + name = "github.com/skratchdot/open-golang" + packages = ["open"] + revision = "75fb7ed4208cf72d323d7d02fd1a5964a7a9073c" + [[projects]] branch = "master" name = "github.com/spf13/cobra" @@ -548,6 +554,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "981c1124c73b9ec3faa288afb4482a8376ccf9a3da5f24a4d136f345d6b95beb" + inputs-digest = "a3c19b57bc10408ea6f35080bdca4a22257e7ab588c1fe3dfc88cf2b00aeea52" solver-name = "gps-cdcl" solver-version = 1 diff --git a/pkg/backend/cloud/backend.go b/pkg/backend/cloud/backend.go index 4d35232ed..9dccd04b3 100644 --- a/pkg/backend/cloud/backend.go +++ b/pkg/backend/cloud/backend.go @@ -17,11 +17,15 @@ package cloud import ( "bytes" "context" + cryptorand "crypto/rand" "encoding/base64" + "encoding/hex" "fmt" "io" "io/ioutil" + "net" "net/http" + "net/url" "os" "path" "runtime" @@ -33,6 +37,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/opentracing/opentracing-go" "github.com/pkg/errors" + "github.com/skratchdot/open-golang/open" survey "gopkg.in/AlecAivazis/survey.v1" surveycore "gopkg.in/AlecAivazis/survey.v1/core" @@ -58,9 +63,12 @@ import ( const ( // PulumiCloudURL is the Cloud URL used if no environment or explicit cloud is chosen. - PulumiCloudURL = "https://" + defaultAPIURLPrefix + "pulumi.com" - // defaultAPIURLPrefix is the assumed Cloud URL prefix for typical Pulumi Cloud API endpoints. - defaultAPIURLPrefix = "api." + PulumiCloudURL = "https://" + defaultAPIDomainPrefix + "pulumi.com" + // defaultAPIDomainPrefix is the assumed Cloud URL prefix for typical Pulumi Cloud API endpoints. + defaultAPIDomainPrefix = "api." + // defaultConsoleDomainPrefix is the assumed Cloud URL prefix typically used for the Pulumi Console. + defaultConsoleDomainPrefix = "app." + // defaultAPIEnvVar can be set to override the default cloud chosen, if `--cloud` is not present. defaultURLEnvVar = "PULUMI_API" // AccessTokenEnvVar is the environment variable used to bypass a prompt on login. @@ -156,6 +164,82 @@ func New(d diag.Sink, cloudURL string) (Backend, error) { }, nil } +// loginWithBrowser uses a web-browser to log into the cloud and returns the cloud backend for it. +func loginWithBrowser(ctx context.Context, d diag.Sink, cloudURL string) (Backend, error) { + // Locally, we generate a nonce and spin up a web server listening on a random port on localhost. We then open a + // browser to a special endpoint on the Pulumi.com console, passing the generated nonce as well as the port of the + // webserver we launched. This endpoint does the OAuth flow and when it completes, redirects to localhost passing + // the nonce and the pulumi access token we created as part of the OAuth flow. If the nonces match, we set the + // access token that was passed to us and the redirect to a special welcome page on Pulumi.com + + loginURL := cloudConsoleURL(cloudURL, "cli-login") + finalWelcomeURL := cloudConsoleURL(cloudURL, "welcome", "cli") + + if loginURL == "" || finalWelcomeURL == "" { + return nil, errors.New("could not determine login url") + } + + // Listen on localhost, have the kernel pick a random port for us + c := make(chan string) + l, err := net.Listen("tcp", "127.0.0.1:") + if err != nil { + return nil, errors.Wrap(err, "could not start listener") + } + + // Extract the port + _, port, err := net.SplitHostPort(l.Addr().String()) + if err != nil { + return nil, errors.Wrap(err, "could not determine port") + } + + // Generate a nonce we'll send with the request. + nonceBytes := make([]byte, 32) + _, err = cryptorand.Read(nonceBytes) + contract.AssertNoErrorf(err, "could not get random bytes") + nonce := hex.EncodeToString(nonceBytes) + + u, err := url.Parse(loginURL) + contract.AssertNoError(err) + + // Generate a description to associate with the access token we'll generate, for display on the Account Settings + // page. + var tokenDescription string + if host, hostErr := os.Hostname(); hostErr == nil { + tokenDescription = fmt.Sprintf("Generated by pulumi login on %s at %s", host, time.Now().Format(time.RFC822)) + } else { + tokenDescription = fmt.Sprintf("Generated by pulumi login at %s", time.Now().Format(time.RFC822)) + } + + // Pass our state around as query parameters on the URL we'll open the user's preferred browser to + q := u.Query() + q.Add("cliSessionPort", port) + q.Add("cliSessionNonce", nonce) + q.Add("cliSessionDescription", tokenDescription) + u.RawQuery = q.Encode() + + // Start the webserver to listen to handle the response + go serveBrowserLoginServer(l, nonce, finalWelcomeURL, c) + + // Launch the web browser and navigate to the login URL. + if openErr := open.Run(u.String()); openErr != nil { + fmt.Printf("We couldn't launch your browser for some reason.\n\nPlease visit %s "+ + "to finish the login process.\n", u) + } else { + fmt.Println("We've launched your web browser to complete the login process.") + } + + fmt.Println("\nWaiting for login to complete...") + + accessToken := <-c + + // Save the token and return the backend + if err = workspace.StoreAccessToken(cloudURL, accessToken, true); err != nil { + return nil, err + } + + return New(d, cloudURL) +} + // Login logs into the target cloud URL and returns the cloud backend for it. func Login(ctx context.Context, d diag.Sink, cloudURL string) (Backend, error) { cloudURL = ValueOrDefaultURL(cloudURL) @@ -180,13 +264,18 @@ func Login(ctx context.Context, d diag.Sink, cloudURL string) (Backend, error) { fmt.Printf("Using access token from %s\n", AccessTokenEnvVar) } else { token, readerr := cmdutil.ReadConsoleNoEcho( - fmt.Sprintf("Enter your Pulumi access token from %s", cloudConsoleURL(cloudURL, "account"))) + fmt.Sprintf("Enter your Pulumi access token from %s (or hit enter to log in with your browser)", + cloudConsoleURL(cloudURL, "account"))) if readerr != nil { return nil, readerr } accessToken = token } + if accessToken == "" { + return loginWithBrowser(ctx, d, cloudURL) + } + // Try and use the credentials to see if they are valid. valid, err := IsValidAccessToken(ctx, cloudURL, accessToken) if err != nil { @@ -257,14 +346,24 @@ func (b *cloudBackend) CloudConsoleURL(paths ...string) string { return cloudConsoleURL(b.CloudURL(), paths...) } -func cloudConsoleURL(cloudURL string, paths ...string) string { - // To produce a cloud console URL, we assume that the URL is of the form `api.xx.yy`, and simply strip off the - // `api.` part. If that is not the case, we will return an empty string because we don't recognize the pattern. - ix := strings.Index(cloudURL, defaultAPIURLPrefix) - if ix == -1 { - return "" +// serveBrowserLoginServer hosts the server that completes the browser based login flow. +func serveBrowserLoginServer(l net.Listener, expectedNonce string, destinationURL string, c chan<- string) { + handler := func(res http.ResponseWriter, req *http.Request) { + tok := req.URL.Query().Get("accessToken") + nonce := req.URL.Query().Get("nonce") + + if tok == "" || nonce != expectedNonce { + res.WriteHeader(400) + return + } + + http.Redirect(res, req, destinationURL, http.StatusTemporaryRedirect) + c <- tok } - return cloudURL[:ix] + path.Join(append([]string{cloudURL[ix+len(defaultAPIURLPrefix):]}, paths...)...) + + mux := &http.ServeMux{} + mux.HandleFunc("/", handler) + contract.IgnoreError(http.Serve(l, mux)) } // CloudConsoleStackPath returns the stack path components for getting to a stack in the cloud console. This path diff --git a/pkg/backend/cloud/console.go b/pkg/backend/cloud/console.go new file mode 100644 index 000000000..03d9eb80b --- /dev/null +++ b/pkg/backend/cloud/console.go @@ -0,0 +1,40 @@ +// Copyright 2016-2018, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloud + +import ( + "net/url" + "path" + "strings" +) + +func cloudConsoleURL(cloudURL string, paths ...string) string { + u, err := url.Parse(cloudURL) + if err != nil { + return "" + } + + switch { + case strings.HasPrefix(u.Host, defaultAPIDomainPrefix): + u.Host = defaultConsoleDomainPrefix + u.Host[len(defaultAPIDomainPrefix):] + case u.Host == "localhost:8080": + u.Host = "localhost:3000" + default: + return "" // We couldn't figure out how to convert the api hostname into a console hostname + } + + u.Path = path.Join(paths...) + return u.String() +} diff --git a/pkg/backend/cloud/console_test.go b/pkg/backend/cloud/console_test.go new file mode 100644 index 000000000..c2fc16250 --- /dev/null +++ b/pkg/backend/cloud/console_test.go @@ -0,0 +1,38 @@ +// Copyright 2016-2018, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package cloud + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConsoleURL(t *testing.T) { + assert.Equal(t, + "https://app.pulumi.com/pulumi-bot/my-stack", + cloudConsoleURL("https://api.pulumi.com", "pulumi-bot", "my-stack")) + + assert.Equal(t, + "http://app.pulumi.example.com/pulumi-bot/my-stack", + cloudConsoleURL("http://api.pulumi.example.com", "pulumi-bot", "my-stack")) + + assert.Equal(t, + "http://localhost:3000/pulumi-bot/my-stack", + cloudConsoleURL("http://localhost:8080", "pulumi-bot", "my-stack")) + + assert.Equal(t, "", cloudConsoleURL("https://example.com", "pulumi-bot", "my-stack")) + + assert.Equal(t, "", cloudConsoleURL("not-even-a-rea-url", "pulumi-bot", "my-stack")) +}