Support browser based logins to the CLI

During login, if no access token is provided, use our browser
based login.
This commit is contained in:
Matt Ellis 2018-05-24 18:48:03 -07:00
parent 8e12e739c2
commit 0fcfbf39c3
4 changed files with 195 additions and 12 deletions

8
Gopkg.lock generated
View file

@ -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

View file

@ -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

View file

@ -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()
}

View file

@ -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"))
}