Skip to content

Commit

Permalink
feat: world cli login for deployment (#65)
Browse files Browse the repository at this point in the history
Closes: WORLD-1119

## Overview

Adding new login feature to world cli. this feature will integrate world cli to world forge and enable user to do deployment via cli.

## Brief Changelog

add new cmd command flow for login :
- world cli will open browser to do authentication to world forge
- after that world cli woll poll the token from world forge

## Testing and Verifying

- added unit test for the new function
- manual testing

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/YO1Dcg4NByYdZHvKXaTq/943928a1-b536-4a82-8dac-2dd8717073cb.webm">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/YO1Dcg4NByYdZHvKXaTq/943928a1-b536-4a82-8dac-2dd8717073cb.webm">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/YO1Dcg4NByYdZHvKXaTq/943928a1-b536-4a82-8dac-2dd8717073cb.webm">Screencast from 2024-05-17 19-13-14.webm</video>
  • Loading branch information
zulkhair committed May 30, 2024
1 parent ef82596 commit 5fd07ae
Show file tree
Hide file tree
Showing 15 changed files with 854 additions and 79 deletions.
14 changes: 8 additions & 6 deletions cmd/world/cardinal/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,14 @@ var devCmd = &cobra.Command{
}
return eris.Wrap(ErrGracefulExit, "Cardinal terminated")
})
group.Go(func() error {
if err := startCardinalEditor(ctx, cfg.RootDir, cfg.GameDir, port); err != nil {
return eris.Wrap(err, "Encountered an error with Cardinal Editor")
}
return eris.Wrap(ErrGracefulExit, "Cardinal Editor terminated")
})
if editor {
group.Go(func() error {
if err := startCardinalEditor(ctx, cfg.RootDir, cfg.GameDir, port); err != nil {
return eris.Wrap(err, "Encountered an error with Cardinal Editor")
}
return eris.Wrap(ErrGracefulExit, "Cardinal Editor terminated")
})
}

// If any of the group's goroutines is terminated non-gracefully, we want to treat it as an error.
if err := group.Wait(); err != nil && !eris.Is(err, ErrGracefulExit) {
Expand Down
183 changes: 183 additions & 0 deletions cmd/world/root/#.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package root

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/user"
"strconv"
"time"

"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/rotisserie/eris"
"github.com/spf13/cobra"

"pkg.world.dev/world-cli/common/globalconfig"
"pkg.world.dev/world-cli/common/logger"
"pkg.world.dev/world-cli/common/#"
"pkg.world.dev/world-cli/tea/component/program"
"pkg.world.dev/world-cli/tea/style"
)

var (
// token is the credential used to authenticate with the World Forge Service
token string

// world forge base URL
worldForgeBaseURL = "http://localhost:3000"

defaultRetryAfterSeconds = 3
)

// loginCmd logs into the World Forge Service
func getLoginCmd() *cobra.Command {
loginCmd := &cobra.Command{
Use: "login",
Short: "Authenticate using an access token",
RunE: func(cmd *cobra.Command, _ []string) error {
logger.SetDebugMode(cmd)

err := loginOnBrowser(cmd.Context())
if err != nil {
return eris.Wrap(err, "failed to login")
}

return nil
},
}

return loginCmd
}

func loginOnBrowser(ctx context.Context) error {
encryption, err := login.NewEncryption()
if err != nil {
logger.Error("Failed to create login encryption", err)
return err
}

encodedPubKey := encryption.EncodedPublicKey()
sessionID := uuid.NewString()
tokenName := generateTokenNameWithFallback()

loginURL := fmt.Sprintf("%s/cli/#?session_id=%s&token=%s&pub_key=%s",
worldForgeBaseURL, sessionID, tokenName, encodedPubKey)

loginMessage := "In case the browser didn't open, please open the following link in your browser"
fmt.Print(style.CLIHeader("World Forge", style.DoubleRightIcon.Render(loginMessage)), "\n")
fmt.Printf("%s\n\n", loginURL)
if err := login.RunOpenCmd(ctx, loginURL); err != nil {
logger.Error("Failed to open browser", err)
return err
}

// Wait for the token to be generated
if err := program.RunProgram(ctx, func(p program.Program, ctx context.Context) error {
p.Send(program.StatusMsg("Waiting response from world forge service..."))

pollURL := fmt.Sprintf("%s/auth/cli/#/%s", worldForgeBaseURL, sessionID)
accessToken, err := pollForAccessToken(ctx, pollURL)

if err != nil {
return err
}

token, err = encryption.DecryptAccessToken(accessToken.AccessToken, accessToken.PublicKey, accessToken.Nonce)
if err != nil {
return err
}

if err := globalconfig.SetWorldForgeToken(tokenName, token); err != nil {
logger.Error("Failed to set access token", err)
return err
}

return nil
}); err != nil {
logger.Error("Failed to get access token", err)
return err
}

fmt.Println(style.TickIcon.Render("Successfully logged in :"))
// Print the token
credential, err := globalconfig.GetWorldForgeCredential()
if err != nil {
logger.Warn("Failed to get the access token when print", err)
}
stringCredential, err := json.MarshalIndent(credential, "", " ")
if err != nil {
logger.Warn("Failed to marshal the access token when print", err)
}
fmt.Println(style.BoldText.Render(string(stringCredential)))
return nil
}

func generateTokenName() (string, error) {
user, err := user.Current()
if err != nil {
return "", eris.Wrap(err, "cannot retrieve current user")
}

hostname, err := os.Hostname()
if err != nil {
return "", eris.Wrap(err, "cannot retrieve hostname")
}

return fmt.Sprintf("cli_%s@%s_%d", user.Username, hostname, time.Now().Unix()), nil
}

func generateTokenNameWithFallback() string {
name, err := generateTokenName()
if err != nil {
name = fmt.Sprintf("cli_%d", time.Now().Unix())
}
return name
}

func pollForAccessToken(ctx context.Context, url string) (login.AccessTokenResponse, error) {
var accessTokenResponse login.AccessTokenResponse

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return accessTokenResponse, eris.Wrap(err, "cannot fetch access token")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return accessTokenResponse, eris.Wrap(err, "cannot fetch access token")
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
retryAfterSeconds, err := strconv.Atoi(resp.Header.Get("Retry-After"))
if err != nil {
retryAfterSeconds = defaultRetryAfterSeconds
}
t := time.NewTimer(time.Duration(retryAfterSeconds) * time.Second)
select {
case <-ctx.Done():
t.Stop()
case <-t.C:
}
return pollForAccessToken(ctx, url)
}

if resp.StatusCode == http.StatusOK {
body, err := io.ReadAll(resp.Body)

if err != nil {
return accessTokenResponse, eris.Wrap(err, "cannot read access token response body")
}

if err := json.Unmarshal(body, &accessTokenResponse); err != nil {
return accessTokenResponse, eris.Wrap(err, "cannot unmarshal access token response")
}

return accessTokenResponse, nil
}

return accessTokenResponse, errors.Errorf("HTTP %s: cannot retrieve access token", resp.Status)
}
5 changes: 3 additions & 2 deletions cmd/world/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ func init() {
// Register base commands
doctorCmd := getDoctorCmd(os.Stdout)
createCmd := getCreateCmd(os.Stdout)
rootCmd.AddCommand(createCmd, doctorCmd, versionCmd)
loginCmd := getLoginCmd()
rootCmd.AddCommand(createCmd, doctorCmd, versionCmd, loginCmd)

// Register subcommands
rootCmd.AddCommand(cardinal.BaseCmd)
Expand Down Expand Up @@ -131,7 +132,7 @@ func checkLatestVersion() error {
fmt.Println(cmdStyle.Render(updateMessage))

commandMessage := "To install the latest version run:\n\t" +
"'curl https://install.world.dev/cli! | bash'"
"'curl https://install.world.dev/cli! | bash'\n"
fmt.Println(cmdStyle.Render(commandMessage))
}
}
Expand Down
104 changes: 100 additions & 4 deletions cmd/world/root/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"os/user"
"strings"
"testing"
"time"

"github.com/spf13/cobra"
tassert "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gotest.tools/v3/assert"

"pkg.world.dev/world-cli/common/#"
)

// outputFromCmd runs the rootCmd with the given cmd arguments and returns the output of the command along with
Expand Down Expand Up @@ -103,7 +110,7 @@ func TestExecuteDoctorCommand(t *testing.T) {

func TestCreateStartStopRestartPurge(t *testing.T) {
// Create Cardinal
gameDir, err := os.MkdirTemp("", "game-template")
gameDir, err := os.MkdirTemp("", "game-template-start")
assert.NilError(t, err)

// Remove dir
Expand Down Expand Up @@ -158,7 +165,7 @@ func TestCreateStartStopRestartPurge(t *testing.T) {

func TestDev(t *testing.T) {
// Create Cardinal
gameDir, err := os.MkdirTemp("", "game-template")
gameDir, err := os.MkdirTemp("", "game-template-dev")
assert.NilError(t, err)

// Remove dir
Expand Down Expand Up @@ -213,7 +220,7 @@ func TestCheckLatestVersion(t *testing.T) {

func cardinalIsUp(t *testing.T) bool {
up := false
for i := 0; i < 10; i++ {
for i := 0; i < 60; i++ {
conn, err := net.DialTimeout("tcp", "localhost:4040", time.Second)
if err != nil {
time.Sleep(time.Second)
Expand All @@ -229,7 +236,7 @@ func cardinalIsUp(t *testing.T) bool {

func cardinalIsDown(t *testing.T) bool {
down := false
for i := 0; i < 10; i++ {
for i := 0; i < 60; i++ {
conn, err := net.DialTimeout("tcp", "localhost:4040", time.Second)
if err != nil {
down = true
Expand All @@ -242,3 +249,92 @@ func cardinalIsDown(t *testing.T) bool {
}
return down
}

func TestGenerateTokenNameWithFallback(t *testing.T) {
// Attempt to generate a token name
name := generateTokenNameWithFallback()

// Ensure the name follows the expected pattern
tassert.Contains(t, name, "cli_")

// Additional checks if user and hostname can be retrieved in the environment
currentUser, userErr := user.Current()
hostname, hostErr := os.Hostname()
if userErr == nil && hostErr == nil {
expectedPrefix := fmt.Sprintf("cli_%s@%s_", currentUser.Username, hostname)
tassert.Contains(t, name, expectedPrefix)
}
}

func TestPollForAccessToken(t *testing.T) {
tests := []struct {
name string
statusCode int
retryAfterHeader string
responseBody string
expectError bool
expectedResponse login.AccessTokenResponse
}{
{
name: "Successful token retrieval",
statusCode: http.StatusOK,
responseBody: `{"access_token": "test_token", "pub_key": "test_pub_key", "nonce": "test_nonce"}`,
expectedResponse: login.AccessTokenResponse{
AccessToken: "test_token",
PublicKey: "test_pub_key",
Nonce: "test_nonce",
},
expectError: false,
},
{
name: "Retry on 404 with Retry-After header",
statusCode: http.StatusNotFound,
retryAfterHeader: "1",
expectError: true,
},
{
name: "Retry on 404 without Retry-After header",
statusCode: http.StatusNotFound,
retryAfterHeader: "",
expectError: true,
},
{
name: "Error on invalid JSON response",
statusCode: http.StatusOK,
responseBody: `invalid_json`,
expectError: true,
},
{
name: "Error on non-200/404 status",
statusCode: http.StatusInternalServerError,
expectError: true,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if test.retryAfterHeader != "" {
w.Header().Set("Retry-After", test.retryAfterHeader)
}
w.WriteHeader(test.statusCode)
w.Write([]byte(test.responseBody)) //nolint:errcheck // Ignore error for test
})

server := httptest.NewServer(handler)
defer server.Close()

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

response, err := pollForAccessToken(ctx, server.URL)

if test.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
tassert.Equal(t, test.expectedResponse, response)
}
})
}
}
Loading

0 comments on commit 5fd07ae

Please # to comment.