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"]
|
packages = ["diffmatchpatch"]
|
||||||
revision = "2fc9cd33b5f86077aa3e0f442fa0476a9fa9a1dc"
|
revision = "2fc9cd33b5f86077aa3e0f442fa0476a9fa9a1dc"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/skratchdot/open-golang"
|
||||||
|
packages = ["open"]
|
||||||
|
revision = "75fb7ed4208cf72d323d7d02fd1a5964a7a9073c"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
branch = "master"
|
branch = "master"
|
||||||
name = "github.com/spf13/cobra"
|
name = "github.com/spf13/cobra"
|
||||||
|
@ -548,6 +554,6 @@
|
||||||
[solve-meta]
|
[solve-meta]
|
||||||
analyzer-name = "dep"
|
analyzer-name = "dep"
|
||||||
analyzer-version = 1
|
analyzer-version = 1
|
||||||
inputs-digest = "981c1124c73b9ec3faa288afb4482a8376ccf9a3da5f24a4d136f345d6b95beb"
|
inputs-digest = "a3c19b57bc10408ea6f35080bdca4a22257e7ab588c1fe3dfc88cf2b00aeea52"
|
||||||
solver-name = "gps-cdcl"
|
solver-name = "gps-cdcl"
|
||||||
solver-version = 1
|
solver-version = 1
|
||||||
|
|
|
@ -17,11 +17,15 @@ package cloud
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
@ -33,6 +37,7 @@ import (
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
"github.com/opentracing/opentracing-go"
|
"github.com/opentracing/opentracing-go"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"github.com/skratchdot/open-golang/open"
|
||||||
survey "gopkg.in/AlecAivazis/survey.v1"
|
survey "gopkg.in/AlecAivazis/survey.v1"
|
||||||
surveycore "gopkg.in/AlecAivazis/survey.v1/core"
|
surveycore "gopkg.in/AlecAivazis/survey.v1/core"
|
||||||
|
|
||||||
|
@ -58,9 +63,12 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// PulumiCloudURL is the Cloud URL used if no environment or explicit cloud is chosen.
|
// PulumiCloudURL is the Cloud URL used if no environment or explicit cloud is chosen.
|
||||||
PulumiCloudURL = "https://" + defaultAPIURLPrefix + "pulumi.com"
|
PulumiCloudURL = "https://" + defaultAPIDomainPrefix + "pulumi.com"
|
||||||
// defaultAPIURLPrefix is the assumed Cloud URL prefix for typical Pulumi Cloud API endpoints.
|
// defaultAPIDomainPrefix is the assumed Cloud URL prefix for typical Pulumi Cloud API endpoints.
|
||||||
defaultAPIURLPrefix = "api."
|
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.
|
// defaultAPIEnvVar can be set to override the default cloud chosen, if `--cloud` is not present.
|
||||||
defaultURLEnvVar = "PULUMI_API"
|
defaultURLEnvVar = "PULUMI_API"
|
||||||
// AccessTokenEnvVar is the environment variable used to bypass a prompt on login.
|
// 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
|
}, 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.
|
// 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) {
|
func Login(ctx context.Context, d diag.Sink, cloudURL string) (Backend, error) {
|
||||||
cloudURL = ValueOrDefaultURL(cloudURL)
|
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)
|
fmt.Printf("Using access token from %s\n", AccessTokenEnvVar)
|
||||||
} else {
|
} else {
|
||||||
token, readerr := cmdutil.ReadConsoleNoEcho(
|
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 {
|
if readerr != nil {
|
||||||
return nil, readerr
|
return nil, readerr
|
||||||
}
|
}
|
||||||
accessToken = token
|
accessToken = token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if accessToken == "" {
|
||||||
|
return loginWithBrowser(ctx, d, cloudURL)
|
||||||
|
}
|
||||||
|
|
||||||
// Try and use the credentials to see if they are valid.
|
// Try and use the credentials to see if they are valid.
|
||||||
valid, err := IsValidAccessToken(ctx, cloudURL, accessToken)
|
valid, err := IsValidAccessToken(ctx, cloudURL, accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -257,14 +346,24 @@ func (b *cloudBackend) CloudConsoleURL(paths ...string) string {
|
||||||
return cloudConsoleURL(b.CloudURL(), paths...)
|
return cloudConsoleURL(b.CloudURL(), paths...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloudConsoleURL(cloudURL string, paths ...string) string {
|
// serveBrowserLoginServer hosts the server that completes the browser based login flow.
|
||||||
// To produce a cloud console URL, we assume that the URL is of the form `api.xx.yy`, and simply strip off the
|
func serveBrowserLoginServer(l net.Listener, expectedNonce string, destinationURL string, c chan<- string) {
|
||||||
// `api.` part. If that is not the case, we will return an empty string because we don't recognize the pattern.
|
handler := func(res http.ResponseWriter, req *http.Request) {
|
||||||
ix := strings.Index(cloudURL, defaultAPIURLPrefix)
|
tok := req.URL.Query().Get("accessToken")
|
||||||
if ix == -1 {
|
nonce := req.URL.Query().Get("nonce")
|
||||||
return ""
|
|
||||||
|
if tok == "" || nonce != expectedNonce {
|
||||||
|
res.WriteHeader(400)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return cloudURL[:ix] + path.Join(append([]string{cloudURL[ix+len(defaultAPIURLPrefix):]}, paths...)...)
|
|
||||||
|
http.Redirect(res, req, destinationURL, http.StatusTemporaryRedirect)
|
||||||
|
c <- tok
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 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