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:
parent
8e12e739c2
commit
0fcfbf39c3
8
Gopkg.lock
generated
8
Gopkg.lock
generated
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
40
pkg/backend/cloud/console.go
Normal file
40
pkg/backend/cloud/console.go
Normal 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()
|
||||
}
|
38
pkg/backend/cloud/console_test.go
Normal file
38
pkg/backend/cloud/console_test.go
Normal 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"))
|
||||
}
|
Loading…
Reference in a new issue