Skip to content

Commit

Permalink
feat: start impl of cloud link
Browse files Browse the repository at this point in the history
  • Loading branch information
markphelps committed Apr 30, 2024
1 parent 73178c7 commit e50e857
Show file tree
Hide file tree
Showing 8 changed files with 406 additions and 6 deletions.
131 changes: 131 additions & 0 deletions cmd/flipt/cloud.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"

"github.com/spf13/cobra"
"go.flipt.io/flipt/internal/cmd/cloud"
"go.flipt.io/flipt/internal/cmd/util"
"golang.org/x/sync/errgroup"
)

type cloudCommand struct {
url string
}

type cloudAuth struct {
Token string `json:"token"`
}

func newCloudCommand() *cobra.Command {
cloud := &cloudCommand{}

cmd := &cobra.Command{
Use: "cloud",
Short: "Interact with Flipt Cloud",
}

cmd.PersistentFlags().StringVarP(&cloud.url, "url", "u", "https://flipt.cloud", "Flipt Cloud URL")

authCmd := &cobra.Command{
Use: "auth [flags]",
Short: "Authenticate with Flipt Cloud",
RunE: cloud.auth,
Args: cobra.NoArgs,
}

cmd.AddCommand(authCmd)
return cmd
}

func (c *cloudCommand) auth(cmd *cobra.Command, args []string) error {
done := make(chan struct{})
const callbackURL = "http://localhost:8080/cloud/auth/callback"

ok, err := util.PromptConfirm("Open browser to authenticate with Flipt Cloud?", false)
if err != nil {
return err
}

if !ok {
return nil
}

flow, err := cloud.InitFlow()
if err != nil {
return fmt.Errorf("initializing flow: %w", err)
}

var (
g errgroup.Group
ctx, cancel = context.WithCancel(cmd.Context())
)

defer cancel()

cloudAuthFile := filepath.Join(userConfigDir, "cloud.json")

g.Go(func() error {
tok, err := flow.Wait(ctx)
if err != nil {
return fmt.Errorf("waiting for token: %w", err)
}

cloudAuth := cloudAuth{
Token: tok,
}

cloudAuthBytes, err := json.Marshal(cloudAuth)
if err != nil {
return fmt.Errorf("marshalling cloud auth token: %w", err)
}

if err := os.WriteFile(cloudAuthFile, cloudAuthBytes, 0600); err != nil {
return fmt.Errorf("writing cloud auth token: %w", err)
}

fmt.Println("\n✅ Authenticated with Flipt Cloud!\nYou can now run commands that require cloud authentication.")

return nil
})

g.Go(func() error {
if err := flow.StartServer(nil); !errors.Is(err, net.ErrClosed) {
return fmt.Errorf("starting server: %w", err)
}
close(done)
return nil
})

g.Go(func() error {
select {
case <-done:
cancel()
case <-ctx.Done():
if err := flow.Close(); !errors.Is(err, context.Canceled) {
return err
}
}
return nil
})

browserParams := cloud.BrowserParams{
RedirectURL: callbackURL,
}

g.Go(func() error {
url, err := flow.BrowserURL(fmt.Sprintf("%s/login/device", c.url), browserParams)
if err != nil {
return fmt.Errorf("creating browser URL: %w", err)
}
return util.OpenBrowser(url)
})

return g.Wait()
}
9 changes: 4 additions & 5 deletions cmd/flipt/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/AlecAivazis/survey/v2"
"github.com/spf13/cobra"
"go.flipt.io/flipt/internal/cmd/util"
"go.flipt.io/flipt/internal/config"
"gopkg.in/yaml.v2"
)
Expand Down Expand Up @@ -45,13 +46,11 @@ func (c *initCommand) run(cmd *cobra.Command, args []string) error {
// check if file exists
if _, err := os.Stat(file); err == nil {
// file exists
prompt := &survey.Confirm{
Message: "File exists. Overwrite?",
}
if err := survey.AskOne(prompt, &ack); err != nil {
overwrite, err := util.PromptConfirm("File exists. Overwrite?", false)
if err != nil {
return err
}
if !ack {
if !overwrite {
return nil
}
} else if !os.IsNotExist(err) {
Expand Down
1 change: 1 addition & 0 deletions cmd/flipt/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ func exec() error {
rootCmd.AddCommand(newDocCommand())
rootCmd.AddCommand(newBundleCommand())
rootCmd.AddCommand(newEvaluateCommand())
rootCmd.AddCommand(newCloudCommand())

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand Down
7 changes: 6 additions & 1 deletion go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
Expand Down Expand Up @@ -268,6 +269,7 @@ github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6
github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw=
github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M=
github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E=
Expand Down Expand Up @@ -674,8 +676,10 @@ github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw=
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.18.1 h1:YP7G1KABtKpB5IHrO9vYwSrCOhs7p3uqhvhhQBptya0=
github.com/jackc/pgx/v4 v4.18.1/go.mod h1:FydWkUyadDmdNH/mHnGob881GawxeEm7TcMCzkb+qQE=
github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
github.com/jackc/puddle v1.1.3 h1:JnPg/5Q9xVJGfjsO5CPUOjnJps1JaRUm8I9FXVCFK94=
github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
Expand Down Expand Up @@ -923,6 +927,7 @@ github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiB
github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
github.com/safchain/ethtool v0.2.0/go.mod h1:WkKB1DnNtvsMlDmQ50sgwowDJV/hGbJSOvJoEXs1AJQ=
github.com/sagikazarmark/crypt v0.17.0/go.mod h1:SMtHTvdmsZMuY/bpZoqokSoChIrcJ/epOxZN58PbZDg=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
Expand Down
85 changes: 85 additions & 0 deletions internal/cmd/cloud/local_server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cloud

import (
"context"
"fmt"
"io"
"net"
"net/http"
)

// TokenResponse represents the token received by the local server's callback handler.
type TokenResponse struct {
Token string
State string
}

// bindLocalServer initializes a LocalServer that will listen on a randomly available TCP port.
func bindLocalServer() (*localServer, error) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return nil, err
}

return &localServer{
listener: listener,
resultChan: make(chan TokenResponse, 1),
}, nil
}

type localServer struct {
CallbackPath string
WriteSuccessHTML func(w io.Writer)

resultChan chan (TokenResponse)
listener net.Listener
}

func (s *localServer) Port() int {
return s.listener.Addr().(*net.TCPAddr).Port
}

func (s *localServer) Close() error {
return s.listener.Close()
}

func (s *localServer) Serve() error {
return http.Serve(s.listener, s)
}

func (s *localServer) Wait(ctx context.Context) (TokenResponse, error) {
select {
case <-ctx.Done():
return TokenResponse{}, ctx.Err()
case code := <-s.resultChan:
return code, nil
}
}

// ServeHTTP implements http.Handler.
func (s *localServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.CallbackPath != "" && r.URL.Path != s.CallbackPath {
w.WriteHeader(404)
return
}
defer func() {
_ = s.Close()
}()

params := r.URL.Query()
s.resultChan <- TokenResponse{
Token: params.Get("token"),
State: params.Get("state"),
}

w.Header().Add("content-type", "text/html")
if s.WriteSuccessHTML != nil {
s.WriteSuccessHTML(w)
} else {
defaultSuccessHTML(w)
}
}

func defaultSuccessHTML(w io.Writer) {
fmt.Fprintf(w, "<p>You may now close this page and return to the client app.</p>")
}
94 changes: 94 additions & 0 deletions internal/cmd/cloud/web_flow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package cloud

import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
)

// Flow holds the state for the steps of our OAuth-like Web Application flow.
type Flow struct {
server *localServer
state string
}

// InitFlow creates a new Flow instance by detecting a locally available port number.
func InitFlow() (*Flow, error) {
server, err := bindLocalServer()
if err != nil {
return nil, err
}

state, _ := randomString(20)

return &Flow{
server: server,
state: state,
}, nil
}

// BrowserParams are GET query parameters for initiating the web flow.
type BrowserParams struct {
RedirectURL string
}

// BrowserURL appends GET query parameters to baseURL and returns the url that the user should
// navigate to in their web browser.
func (f *Flow) BrowserURL(baseURL string, params BrowserParams) (string, error) {
ru, err := url.Parse(params.RedirectURL)
if err != nil {
return "", err
}

ru.Host = fmt.Sprintf("%s:%d", ru.Hostname(), f.server.Port())
f.server.CallbackPath = ru.Path

q := url.Values{}
q.Set("redirect_url", ru.String())
q.Set("state", f.state)

return fmt.Sprintf("%s?%s", baseURL, q.Encode()), nil
}

// StartServer starts the localhost server and blocks until it has received the web redirect. The
// writeSuccess function can be used to render a HTML page to the user upon completion.
func (f *Flow) StartServer(writeSuccess func(io.Writer)) error {
f.server.WriteSuccessHTML = writeSuccess
return f.server.Serve()
}

func (f *Flow) Close() error {
return f.server.Close()
}

// WaitOptions specifies parameters to exchange the access token for.
type WaitOptions struct {
// ClientSecret is the app client secret value.
ClientSecret string
}

// Wait blocks until the browser flow has completed and returns the access token.
func (f *Flow) Wait(ctx context.Context) (string, error) {
resp, err := f.server.Wait(ctx)
if err != nil {
return "", err
}
if resp.State != f.state {
return "", errors.New("state mismatch")
}

return resp.Token, nil
}

func randomString(length int) (string, error) {
b := make([]byte, length/2)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
Loading

0 comments on commit e50e857

Please sign in to comment.