diff --git a/templates/cli/internal/cmd/login.go b/templates/cli/internal/cmd/login.go index f133f11ff0..5cf1c3766d 100644 --- a/templates/cli/internal/cmd/login.go +++ b/templates/cli/internal/cmd/login.go @@ -31,11 +31,12 @@ import ( // loginOptions are the flags `login` was given. type loginOptions struct { - Endpoint string - Email string - Password string - MFA string - Code string + Endpoint string + Email string + Password string + MFA string + Code string + SelfSigned *string // Switch changes which signed-in account is current, without signing in. Switch bool // New signs in again even when an account is already signed in. @@ -44,11 +45,16 @@ type loginOptions struct { func newLoginCommand() *cobra.Command { options := loginOptions{} + selfSigned := "" command := &cobra.Command{ Use: "login", Short: "Sign in to an Appwrite endpoint", RunE: func(command *cobra.Command, args []string) error { + if command.Flags().Changed("self-signed") { + options.SelfSigned = &selfSigned + } + return runLogin(command, options) }, } @@ -64,6 +70,8 @@ func newLoginCommand() *cobra.Command { strings.Join(mfaFactors, ", ")+". Pass the code itself with --code") flags.StringVar(&options.Code, "code", "", "MFA code from the factor chosen with --mfa, on self hosted instances") + flags.StringVar(&selfSigned, "self-signed", "", + "Accept a self-signed certificate on self hosted instances ( true or false )") flags.BoolVar(&options.Switch, "switch", false, "Switch to another signed-in account") flags.BoolVar(&options.New, "new", false, "Sign in to another account") @@ -84,8 +92,9 @@ func runLogin(command *cobra.Command, options loginOptions) error { } previous := global.CurrentSessionID() - if options.Switch && (options.Password != "" || options.MFA != "" || options.Code != "") { - return errors.New("--password, --mfa and --code cannot be used with --switch. " + + if options.Switch && (options.Password != "" || options.MFA != "" || + options.Code != "" || options.SelfSigned != nil) { + return errors.New("--password, --mfa, --code and --self-signed cannot be used with --switch. " + "Use --endpoint and/or --email to select a stored account") } @@ -97,6 +106,14 @@ func runLogin(command *cobra.Command, options loginOptions) error { return err } + selfSigned := global.CurrentBool(config.PreferenceSelfSigned) + if options.SelfSigned != nil { + selfSigned, err = strconv.ParseBool(*options.SelfSigned) + if err != nil { + return fmt.Errorf("--self-signed must be true or false: %w", err) + } + } + configEndpoint := resolveLoginEndpoint(options.Endpoint) if options.Endpoint != "" && config.IsRegionalCloudEndpoint(options.Endpoint) { @@ -110,16 +127,15 @@ func runLogin(command *cobra.Command, options loginOptions) error { // Checked BEFORE anything is prompted for, so a wrong endpoint fails // immediately rather than after the email and password are typed. if options.Endpoint != "" && !cloud { - if err := verifyEndpoint(configEndpoint, - global.CurrentBool(config.PreferenceSelfSigned)); err != nil { + if err := verifyEndpoint(configEndpoint, selfSigned); err != nil { return err } } if cloud && (options.Email != "" || options.Password != "" || - options.MFA != "" || options.Code != "") { + options.MFA != "" || options.Code != "" || options.SelfSigned != nil) { return fmt.Errorf("cloud sign-in happens in your browser. Run '%s login' "+ - "without --email, --password, --mfa or --code -- those options are "+ + "without --email, --password, --mfa, --code or --self-signed -- those options are "+ "for self-hosted instances", app.ExecutableName) } @@ -142,7 +158,7 @@ func runLogin(command *cobra.Command, options loginOptions) error { } if !cloud { - return loginWithPassword(command, global, configEndpoint, previous, options) + return loginWithPassword(command, global, configEndpoint, previous, selfSigned, options) } return loginWithDevice(command, configEndpoint) @@ -335,6 +351,7 @@ func loginWithPassword( command *cobra.Command, global *config.Global, endpoint, previous string, + selfSigned bool, options loginOptions, ) error { out := command.OutOrStdout() @@ -362,6 +379,9 @@ func loginWithPassword( session := config.NewObject() session.Set(config.PreferenceEndpoint, endpoint) session.Set(config.PreferenceEmail, email) + if selfSigned { + session.Set(config.PreferenceSelfSigned, true) + } global.AddSession(sessionID, session) global.SetCurrentSessionID(sessionID) if err := global.Write(); err != nil { @@ -376,6 +396,7 @@ func loginWithPassword( api := client.New(endpoint, app.Version). SetProject(config.ProjectConsole). + SetSelfSigned(selfSigned). SetLocale("en-US") credentials := jsonx.NewObject() diff --git a/templates/cli/internal/cmd/login_test.go b/templates/cli/internal/cmd/login_test.go index 207550c718..c91fd5ed94 100644 --- a/templates/cli/internal/cmd/login_test.go +++ b/templates/cli/internal/cmd/login_test.go @@ -18,7 +18,7 @@ func TestLoginOffersEveryOption(t *testing.T) { command := newLoginCommand() for _, name := range []string{ - "endpoint", "email", "password", "mfa", "code", "switch", "new", + "endpoint", "email", "password", "mfa", "code", "self-signed", "switch", "new", } { if command.Flags().Lookup(name) == nil { t.Errorf("--%s is missing", name) @@ -383,7 +383,7 @@ func TestLoginCompletesMFAWhenTheAccountAsksForMoreFactors(t *testing.T) { defer server.Close() command, _ := captureCommand() - err := loginWithPassword(command, global, server.URL, "", loginOptions{ + err := loginWithPassword(command, global, server.URL, "", false, loginOptions{ Email: "someone@example.com", Password: "password", MFA: "totp", @@ -435,7 +435,7 @@ func TestLoginAbandonsTheSessionWhenMFAFails(t *testing.T) { defer server.Close() command, _ := captureCommand() - err := loginWithPassword(command, global, server.URL, "", loginOptions{ + err := loginWithPassword(command, global, server.URL, "", false, loginOptions{ Email: "someone@example.com", Password: "password", MFA: "totp", @@ -478,3 +478,184 @@ func TestLoginRejectsAnUnknownFactor(t *testing.T) { t.Errorf("err = %v, want the accepted factors listed", err) } } + +func selfSignedAppwrite(t *testing.T) *httptest.Server { + t.Helper() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /health/version": + w.Write([]byte(`{"version":"1.8.0"}`)) + case "POST /account/sessions/email": + w.Header().Add("Set-Cookie", "a_session_console=secret") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{}`)) + case "GET /account": + w.Write([]byte(`{"email":"someone@example.com"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + return server +} + +func TestLoginSignsInWithTheStoredSelfSignedPreference(t *testing.T) { + preferencesWith(t, `{ + "current": "default", + "default": {"selfSigned": true} +}`) + server := selfSignedAppwrite(t) + command, _ := captureCommand() + + err := runLogin(command, loginOptions{ + Endpoint: server.URL, + Email: "someone@example.com", + Password: "password", + }) + if err != nil { + t.Fatalf("err = %v, want the sign-in to accept the self-signed certificate", err) + } + + stored, err := preferences() + if err != nil { + t.Fatal(err) + } + if !stored.CurrentBool(config.PreferenceSelfSigned) { + t.Error("the new session does not trust the self-signed certificate it signed in with") + } +} + +func TestLoginSelfSignedFlagWorksWithoutAPriorClientCall(t *testing.T) { + preferencesWith(t, `{}`) + server := selfSignedAppwrite(t) + command, _ := captureCommand() + + err := runLogin(command, loginOptions{ + Endpoint: server.URL, + Email: "someone@example.com", + Password: "password", + SelfSigned: new("true"), + }) + if err != nil { + t.Fatalf("err = %v, want --self-signed true to accept the certificate", err) + } + + stored, err := preferences() + if err != nil { + t.Fatal(err) + } + if !stored.CurrentBool(config.PreferenceSelfSigned) { + t.Error("the new session does not trust the self-signed certificate it signed in with") + } +} + +func TestLoginSelfSignedFlagOverridesTheStoredPreference(t *testing.T) { + preferencesWith(t, `{ + "current": "default", + "default": {"selfSigned": true} +}`) + server := selfSignedAppwrite(t) + command, _ := captureCommand() + + err := runLogin(command, loginOptions{ + Endpoint: server.URL, + Email: "someone@example.com", + Password: "password", + SelfSigned: new("false"), + }) + if err == nil { + t.Fatal("--self-signed false accepted an untrusted certificate") + } +} + +func TestLoginCompletesMFAOverASelfSignedCertificate(t *testing.T) { + global := preferencesWith(t, `{}`) + verified := false + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "POST /account/sessions/email": + w.Header().Add("Set-Cookie", "a_session_console=partial") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{}`)) + case "GET /account": + if !verified { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"More factors are required.","code":401,"type":"user_more_factors_required"}`)) + + return + } + w.Write([]byte(`{"email":"someone@example.com"}`)) + case "POST /account/mfa/challenges": + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"$id":"challenge"}`)) + case "PUT /account/mfa/challenges": + verified = true + w.Write([]byte(`{}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + command, _ := captureCommand() + + err := loginWithPassword(command, global, server.URL, "", true, loginOptions{ + Email: "someone@example.com", + Password: "password", + MFA: "totp", + Code: "123456", + }) + if err != nil { + t.Fatalf("err = %v, want MFA completed over the self-signed certificate", err) + } +} + +func TestLoginRejectsAnInvalidSelfSignedValue(t *testing.T) { + preferencesWith(t, `{}`) + + err := runLogin(newLoginCommand(), loginOptions{ + Endpoint: "http://127.0.0.1:1/v1", + SelfSigned: new("maybe"), + }) + if err == nil || !strings.Contains(err.Error(), "--self-signed must be true or false") { + t.Errorf("err = %v, want the invalid value rejected", err) + } +} + +func TestLoginRejectsSelfSignedAgainstCloud(t *testing.T) { + preferencesWith(t, `{}`) + + err := runLogin(newLoginCommand(), loginOptions{ + Endpoint: "https://cloud.appwrite.io/v1", + SelfSigned: new("true"), + }) + if err == nil || !strings.Contains(err.Error(), "--self-signed") { + t.Errorf("err = %v, want --self-signed rejected for Cloud", err) + } +} + +func TestLoginRejectsSelfSignedWithSwitch(t *testing.T) { + preferencesWith(t, `{}`) + + err := runLogin(newLoginCommand(), loginOptions{Switch: true, SelfSigned: new("true")}) + if err == nil || !strings.Contains(err.Error(), "cannot be used with --switch") { + t.Errorf("err = %v, want --self-signed rejected with --switch", err) + } +} + +func TestLoginRejectsAnExplicitlyEmptySelfSigned(t *testing.T) { + preferencesWith(t, `{ + "current": "default", + "default": {"selfSigned": true} +}`) + command := newLoginCommand() + command.SetArgs([]string{"--endpoint", "http://127.0.0.1:1/v1", "--self-signed="}) + command.SilenceUsage = true + command.SilenceErrors = true + + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "--self-signed must be true or false") { + t.Errorf("err = %v, want an empty --self-signed rejected rather than inheriting the stored preference", err) + } +}