diff --git a/cmd/scw/testdata/test-all-usage-login-usage.golden b/cmd/scw/testdata/test-all-usage-login-usage.golden new file mode 100644 index 0000000000..df426a3079 --- /dev/null +++ b/cmd/scw/testdata/test-all-usage-login-usage.golden @@ -0,0 +1,24 @@ +🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 +πŸŸ₯πŸŸ₯πŸŸ₯ STDERR️️ πŸŸ₯πŸŸ₯πŸŸ₯️ +Start an interactive connection to scaleway to initialize the active profile of the config +A webpage will open while the CLI will wait for a response. +Once you connected to Scaleway, the profile should be configured. + +USAGE: + scw login [arg=value ...] + +ARGS: + [port] The port number used to wait for browser's response + +FLAGS: + -h, --help help for login + +GLOBAL FLAGS: + -c, --config string The path to the config file + -D, --debug Enable debug mode + -o, --output string Output format: json or human, see 'scw help output' for more info (default "human") + -p, --profile string The config profile to use + +SEE ALSO: + # Init profile manually + scw init diff --git a/cmd/scw/testdata/test-main-usage-usage.golden b/cmd/scw/testdata/test-main-usage-usage.golden index c34cb983b9..ce2de18651 100644 --- a/cmd/scw/testdata/test-main-usage-usage.golden +++ b/cmd/scw/testdata/test-main-usage-usage.golden @@ -49,6 +49,7 @@ CONFIGURATION COMMANDS: config Config file management info Get info about current settings init Initialize the config + login Login to scaleway UTILITY COMMANDS: feedback Send feedback to the Scaleway CLI Team! diff --git a/docs/commands/login.md b/docs/commands/login.md new file mode 100644 index 0000000000..e966fc1694 --- /dev/null +++ b/docs/commands/login.md @@ -0,0 +1,9 @@ + +# Documentation for `scw login` +Start an interactive connection to scaleway to initialize the active profile of the config +A webpage will open while the CLI will wait for a response. +Once you connected to Scaleway, the profile should be configured. + + + + diff --git a/internal/namespaces/get_commands.go b/internal/namespaces/get_commands.go index 0dccadbcd5..9a29091983 100644 --- a/internal/namespaces/get_commands.go +++ b/internal/namespaces/get_commands.go @@ -34,6 +34,7 @@ import ( "github.com/scaleway/scaleway-cli/v2/internal/namespaces/k8s/v1" keymanager "github.com/scaleway/scaleway-cli/v2/internal/namespaces/key_manager/v1alpha1" "github.com/scaleway/scaleway-cli/v2/internal/namespaces/lb/v1" + "github.com/scaleway/scaleway-cli/v2/internal/namespaces/login" "github.com/scaleway/scaleway-cli/v2/internal/namespaces/marketplace/v2" mnq "github.com/scaleway/scaleway-cli/v2/internal/namespaces/mnq/v1beta1" "github.com/scaleway/scaleway-cli/v2/internal/namespaces/object/v1" @@ -104,6 +105,7 @@ func GetCommands() *core.Commands { jobs.GetCommands(), serverless_sqldb.GetCommands(), edgeservices.GetCommands(), + login.GetCommands(), ) if beta { diff --git a/internal/namespaces/init/init.go b/internal/namespaces/init/init.go index a554a48ec0..aa909e6c0e 100644 --- a/internal/namespaces/init/init.go +++ b/internal/namespaces/init/init.go @@ -54,10 +54,10 @@ See below the schema `scw init` follows to ask for default config: */ func GetCommands() *core.Commands { - return core.NewCommands(initCommand()) + return core.NewCommands(Command()) } -type initArgs struct { +type Args struct { AccessKey string SecretKey string ProjectID string @@ -70,7 +70,7 @@ type initArgs struct { InstallAutocomplete *bool } -func initCommand() *core.Command { +func Command() *core.Command { return &core.Command{ Groups: []string{"config"}, Short: `Initialize the config`, @@ -83,7 +83,7 @@ Default path for configuration file is based on the following priority order: - $USERPROFILE/.config/scw/config.yaml`, Namespace: "init", AllowAnonymousClient: true, - ArgsType: reflect.TypeOf(initArgs{}), + ArgsType: reflect.TypeOf(Args{}), ArgSpecs: core.ArgSpecs{ { Name: "secret-key", @@ -126,9 +126,13 @@ Default path for configuration file is based on the following priority order: Short: "Config management help", Command: "scw config", }, + { + Short: "Login through a web page", + Command: "scw login", + }, }, Run: func(ctx context.Context, argsI interface{}) (i interface{}, e error) { - args := argsI.(*initArgs) + args := argsI.(*Args) profileName := core.ExtractProfileName(ctx) configPath := core.ExtractConfigPath(ctx) @@ -243,7 +247,7 @@ Default path for configuration file is based on the following priority order: successDetails := []string(nil) // Install autocomplete - if *args.InstallAutocomplete { + if args.InstallAutocomplete != nil && *args.InstallAutocomplete { _, _ = interactive.Println() _, err := autocomplete.InstallCommandRun(ctx, &autocomplete.InstallArgs{ Basename: "scw", @@ -254,7 +258,7 @@ Default path for configuration file is based on the following priority order: } // Init SSH Key - if *args.WithSSHKey { + if args.WithSSHKey != nil && *args.WithSSHKey { _, _ = interactive.Println() _, err := iamcommands.InitWithSSHKeyRun(ctx, nil) if err != nil { diff --git a/internal/namespaces/login/login.go b/internal/namespaces/login/login.go new file mode 100644 index 0000000000..839e918a2b --- /dev/null +++ b/internal/namespaces/login/login.go @@ -0,0 +1,153 @@ +package login + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + "time" + + "github.com/scaleway/scaleway-cli/v2/internal/core" + initCommand "github.com/scaleway/scaleway-cli/v2/internal/namespaces/init" + "github.com/scaleway/scaleway-cli/v2/internal/namespaces/login/webcallback" + iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1" + "github.com/scaleway/scaleway-sdk-go/logger" + "github.com/scaleway/scaleway-sdk-go/scw" + "github.com/skratchdot/open-golang/open" +) + +func GetCommands() *core.Commands { + return core.NewCommands(loginCommand()) +} + +type loginArgs struct { + Port int `json:"port"` + // PrintURL will print the account url instead of trying to open it with a browser + PrintURL bool `json:"print_url"` +} + +func loginCommand() *core.Command { + return &core.Command{ + Groups: []string{"config"}, + Short: `Login to scaleway`, + Long: `Start an interactive connection to scaleway to initialize the active profile of the config +A webpage will open while the CLI will wait for a response. +Once you connected to Scaleway, the profile should be configured. +`, + Namespace: "login", + AllowAnonymousClient: true, + ArgsType: reflect.TypeOf(loginArgs{}), + ArgSpecs: core.ArgSpecs{ + { + Name: "port", + Short: "The port number used to wait for browser's response", + }, + }, + SeeAlsos: []*core.SeeAlso{ + { + Short: "Init profile manually", + Command: "scw init", + }, + }, + Run: func(ctx context.Context, argsI interface{}) (interface{}, error) { + args := argsI.(*loginArgs) + + opts := []webcallback.Options(nil) + if args.Port > 0 { + opts = append(opts, webcallback.WithPort(args.Port)) + } + + wb := webcallback.New(opts...) + err := wb.Start() + if err != nil { + return nil, err + } + + callbackURL := fmt.Sprintf("http://localhost:%d/callback", wb.Port()) + + accountURL := "https://account.scaleway.com/authenticate?redirectToUrl=" + callbackURL + + logger.Debugf("Web server started, waiting for callback on %s\n", callbackURL) + + if args.PrintURL { + fmt.Println(accountURL) + } else { + err = open.Start(accountURL) + if err != nil { + logger.Warningf("Failed to open web url, you may not have a default browser configured") + logger.Warningf("You can open it: " + accountURL) + } + } + + fmt.Println("waiting for callback from browser...") + token, err := wb.Wait(ctx) + if err != nil { + return nil, err + } + + rawToken, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return nil, err + } + + tt := Token{} + + err = json.Unmarshal(rawToken, &tt) + if err != nil { + return nil, err + } + + client, err := scw.NewClient(scw.WithJWT(tt.Token)) + if err != nil { + return nil, err + } + + api := iam.NewAPI(client) + apiKey, err := api.CreateAPIKey(&iam.CreateAPIKeyRequest{ + UserID: &tt.Jwt.AudienceID, + Description: "Generated by the Scaleway CLI", + }) + if err != nil { + return nil, err + } + + resp, err := initCommand.Command().Run(ctx, &initCommand.Args{ + AccessKey: apiKey.AccessKey, + SecretKey: *apiKey.SecretKey, + ProjectID: apiKey.DefaultProjectID, + OrganizationID: apiKey.DefaultProjectID, + Region: scw.RegionFrPar, + Zone: scw.ZoneFrPar1, + }) + if err != nil { + // Cleanup API Key if init failed + logger.Warningf("Init failed, cleaning API key.\n") + cleanErr := api.DeleteAPIKey(&iam.DeleteAPIKeyRequest{ + AccessKey: apiKey.AccessKey, + }) + if cleanErr != nil { + logger.Warningf("Failed to clean API key: %s\n", err.Error()) + } + return nil, err + } + + return resp, nil + }, + } +} + +type Token struct { + Jwt struct { + AudienceID string `json:"audienceId"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + IP string `json:"ip"` + IssuerID string `json:"issuerId"` + Jti string `json:"jti"` + UpdatedAt time.Time `json:"updatedAt"` + UserAgent string `json:"userAgent"` + } `json:"jwt"` + RenewToken string `json:"renewToken"` + Token string `json:"token"` +} diff --git a/internal/namespaces/login/webcallback/options.go b/internal/namespaces/login/webcallback/options.go new file mode 100644 index 0000000000..033d225e77 --- /dev/null +++ b/internal/namespaces/login/webcallback/options.go @@ -0,0 +1,9 @@ +package webcallback + +type Options func(*WebCallback) + +func WithPort(port int) Options { + return func(callback *WebCallback) { + callback.port = port + } +} diff --git a/internal/namespaces/login/webcallback/webcallback.go b/internal/namespaces/login/webcallback/webcallback.go new file mode 100644 index 0000000000..8c21d37cf4 --- /dev/null +++ b/internal/namespaces/login/webcallback/webcallback.go @@ -0,0 +1,134 @@ +package webcallback + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/scaleway/scaleway-sdk-go/logger" +) + +// WebCallback is a web server that will wait for a callback +type WebCallback struct { + port int + + tokenChan chan string + errChan chan error + srv *http.Server + listener net.Listener +} + +func New(opts ...Options) *WebCallback { + wb := new(WebCallback) + for _, opt := range opts { + opt(wb) + } + + return wb +} + +func (wb *WebCallback) Start() error { + wb.tokenChan = make(chan string, 1) + wb.errChan = make(chan error, 1) + + listener, err := net.Listen("tcp", ":"+strconv.Itoa(wb.port)) + if err != nil { + return err + } + wb.listener = listener + wb.port = listener.Addr().(*net.TCPAddr).Port + wb.srv = &http.Server{ + Addr: ":" + strconv.Itoa(wb.port), + ReadHeaderTimeout: time.Second * 5, + ReadTimeout: time.Second * 5, + } + + wb.srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "callback") { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(webpageString("Invalid URL"))) + } + token := r.URL.Query().Get("token") + if token != "" { + wb.tokenChan <- r.URL.Query().Get("token") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(webpageString("You can close this page."))) + } else { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(webpageString("Invalid Token."))) + } + }) + + go func() { + err = wb.srv.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + wb.errChan <- err + } + }() + + return nil +} + +// Trigger will trigger currently waiting callback. Made for tests +func (wb *WebCallback) Trigger(token string, timeout time.Duration) error { + req, err := http.NewRequest(http.MethodGet, "http://localhost:"+strconv.Itoa(wb.port)+"/callback", nil) + if err != nil { + return err + } + + q := req.URL.Query() + q.Add("token", token) + req.URL.RawQuery = q.Encode() + + client := http.Client{Timeout: timeout} + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +func (wb *WebCallback) Wait(ctx context.Context) (string, error) { + defer wb.Close() + select { + case err := <-wb.errChan: + return "", err + case token := <-wb.tokenChan: + return token, nil + case <-ctx.Done(): + logger.Warningf("context canceled, closing web server") + return "", ctx.Err() + } +} + +func (wb *WebCallback) Close() { + err := wb.srv.Close() + if err != nil { + logger.Warningf("failed to close web server: %v", err) + } +} + +// Port returns the port used by the web server. It may be chosen randomly if let as default when starting server. +func (wb *WebCallback) Port() int { + return wb.port +} + +func webpageString(msg string) string { + return fmt.Sprintf(` + + + + +%s + + +`, msg) +} diff --git a/internal/namespaces/login/webcallback/webcallback_test.go b/internal/namespaces/login/webcallback/webcallback_test.go new file mode 100644 index 0000000000..42d1223a7b --- /dev/null +++ b/internal/namespaces/login/webcallback/webcallback_test.go @@ -0,0 +1,30 @@ +package webcallback_test + +import ( + "context" + "testing" + "time" + + "github.com/scaleway/scaleway-cli/v2/internal/namespaces/login/webcallback" + "github.com/stretchr/testify/assert" +) + +func TestWebCallback(t *testing.T) { + wb := webcallback.New() + + t.Cleanup(func() { + wb.Close() + }) + assert.NoError(t, wb.Start()) + assert.NoError(t, wb.Trigger("test-token", time.Second)) + + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second) + t.Cleanup(cancelFunc) + + resp, err := wb.Wait(ctx) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "test-token", resp) +}