Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Browserless login #33

Merged
merged 5 commits into from
Oct 20, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import (
"fmt"

"github.com/railwayapp/cli/entity"
"github.com/railwayapp/cli/ui"
)

func (h *Handler) Login(ctx context.Context, req *entity.CommandRequest) error {
ui.StartSpinner(&ui.SpinnerCfg{
Message: "Logging in...",
})
user, err := h.ctrl.Login(ctx)
isBrowserless, err := req.Cmd.Flags().GetBool("browserless")
if err != nil {
return err
}
ui.StopSpinner(fmt.Sprintf("🎉 Logged in as %s (%s)", user.Name, user.Email))

user, err := h.ctrl.Login(ctx, isBrowserless)
if err != nil {
return err
}

fmt.Printf("🎉 Logged in as %s (%s)", user.Name, user.Email)
return nil
}
117 changes: 110 additions & 7 deletions controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"errors"
"fmt"
"net/http"
"os"
"strconv"
"sync"
"time"

"github.com/pkg/browser"
configs "github.com/railwayapp/cli/configs"
Expand All @@ -30,6 +32,9 @@ type LoginResponse struct {
Error string `json:"error,omitempty"`
}

const maxAttempts = 2 * 60
const pollInterval = 1 * time.Second

func (c *Controller) GetUser(ctx context.Context) (*entity.User, error) {
userCfg, err := c.cfg.GetUserConfigs()
if err != nil {
Expand All @@ -41,21 +46,25 @@ func (c *Controller) GetUser(ctx context.Context) (*entity.User, error) {
return c.gtwy.GetUser(ctx)
}

func (c *Controller) Login(ctx context.Context) (*entity.User, error) {
func (c *Controller) browserBasedLogin(ctx context.Context) (*entity.User, error) {
var token string
var returnedCode string
port, err := c.randomizer.Port()

if err != nil {
return nil, err
}

code := c.randomizer.Code()

wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
ctx := context.Background()
srv := &http.Server{Addr: strconv.Itoa(port)}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", getAPIURL())

if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
token = r.URL.Query().Get("token")
Expand All @@ -71,8 +80,10 @@ func (c *Controller) Login(ctx context.Context) (*entity.User, error) {
w.Write(byteRes)
return
}

res := LoginResponse{Status: loginSuccessResponse}
byteRes, err := json.Marshal(&res)

if err != nil {
fmt.Println(err)
}
Expand All @@ -85,26 +96,104 @@ func (c *Controller) Login(ctx context.Context) (*entity.User, error) {
w.WriteHeader(204)
return
}

wg.Done()

if err := srv.Shutdown(ctx); err != nil {
fmt.Println(err)
}
})

http.ListenAndServe(fmt.Sprintf("localhost:%d", port), nil)
}()
url := getLoginURL(port, code)
browser.OpenURL(url)

url := getBrowserBasedLoginURL(port, code)
err = browser.OpenURL(url)

if err != nil {
// Opening the browser failed. Try browserless login
return c.browserlessLogin(ctx)
}

fmt.Printf("Redirecting you to %s\n", url)
wg.Wait()

if code != returnedCode {
return nil, errors.New("Login failed")
}

err = c.cfg.SetUserConfigs(&entity.UserConfig{
Token: token,
})
if err != nil {
return nil, err
}

user, err := c.gtwy.GetUser(ctx)
if err != nil {
return nil, err
}

return user, nil
}

func (c *Controller) pollForToken(ctx context.Context, code string) (string, error) {
var count = 0
for count < maxAttempts {
token, err := c.gtwy.ConsumeLoginSession(ctx, code)

if err != nil {
return "", errors.New("Login failed")
}

if token != "" {
return token, nil
}

count++
time.Sleep(pollInterval)
}

return "", errors.New("Login timeout")
}

func (c *Controller) browserlessLogin(ctx context.Context) (*entity.User, error) {
wordCode, err := c.gtwy.CreateLoginSession(ctx)
if err != nil {
return nil, err
}

url := getBrowserlessLoginURL(wordCode)

fmt.Printf("Your pairing code is: %s\n", wordCode)
fmt.Printf("To authenticate with Railway, please go to \n %s\n", url)

token, err := c.pollForToken(ctx, wordCode)
if err != nil {
return nil, err
}

err = c.cfg.SetUserConfigs(&entity.UserConfig{
Token: token,
})
if err != nil {
return nil, err
}
if code == returnedCode {
return c.gtwy.GetUser(ctx)

user, err := c.gtwy.GetUser(ctx)
if err != nil {
return nil, err
}

return user, nil
}

func (c *Controller) Login(ctx context.Context, isBrowserless bool) (*entity.User, error) {
if isBrowserless || isSSH() {
return c.browserlessLogin(ctx)
}
return nil, nil

return c.browserBasedLogin(ctx)
}

func (c *Controller) Logout(ctx context.Context) error {
Expand Down Expand Up @@ -141,8 +230,22 @@ func getAPIURL() string {
return baseRailwayURL
}

func getLoginURL(port int, code string) string {
func getBrowserBasedLoginURL(port int, code string) string {
buffer := b64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("port=%d&code=%s", port, code)))
url := fmt.Sprintf("%s/cli-login?d=%s", getAPIURL(), buffer)
return url
}

func getBrowserlessLoginURL(wordCode string) string {
buffer := b64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("wordCode=%s", wordCode)))
url := fmt.Sprintf("%s/cli-login?d=%s", getAPIURL(), buffer)
return url
}

func isSSH() bool {
if os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" {
return true
}

return false
}
33 changes: 33 additions & 0 deletions gateway/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,36 @@ func (g *Gateway) GetUser(ctx context.Context) (*entity.User, error) {
}
return resp.User, nil
}

func (g *Gateway) CreateLoginSession(ctx context.Context) (string, error) {
gqlReq := gql.NewRequest(`mutation { createLoginSession } `)

var resp struct {
Code string `json:"createLoginSession"`
}

if err := g.gqlClient.Run(ctx, gqlReq, &resp); err != nil {
return "", err
}

return resp.Code, nil
}

func (g *Gateway) ConsumeLoginSession(ctx context.Context, code string) (string, error) {
gqlReq := gql.NewRequest(`
mutation($code: String!) {
consumeLoginSession(code: $code)
}
`)
gqlReq.Var("code", code)

var resp struct {
Token string `json:"consumeLoginSession"`
}

if err := g.gqlClient.Run(ctx, gqlReq, &resp); err != nil {
return "", err
}

return resp.Token, nil
}
8 changes: 6 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@ func contextualize(fn entity.HandlerFunction) entity.CobraFunction {
func init() {
// Initializes all commands
handler := cmd.New()
rootCmd.AddCommand(&cobra.Command{

loginCmd := &cobra.Command{
Use: "login",
Short: "Login to Railway",
RunE: contextualize(handler.Login),
})
}
loginCmd.Flags().Bool("browserless", false, "--browserless")

rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(&cobra.Command{
Use: "logout",
Short: "Logout of Railway",
Expand Down