From e8790f7c6161f3806e457a663686507bfcef134f Mon Sep 17 00:00:00 2001 From: "Christian G. Warden" Date: Wed, 25 Feb 2026 06:48:07 -0600 Subject: [PATCH] Support Username/Password OAuth Flow Add an OAuth Username/Password grant type alongside the existing SOAP login since SOAP login will no longer be supported after Summer '27. When --user and --password are provided, the login command now selects between two flows: - OAuth Username/Password flow when --connected-app-client-secret is set. The client secret is included in the token request alongside the client id, username, and password. - SOAP login otherwise (preserving prior behavior). A deprecation warning is written to stderr indicating SOAP login will be removed after Summer '27 and pointing users to --connected-app-client-id / --connected-app-client-secret for the OAuth flow. When the OAuth password flow returns invalid_grant / "authentication failure", append a hint to the error noting that only Connected Apps support the username/password flow; External Client Apps do not. --- README.md | 4 +- command/login.go | 31 ++++++-- lib/force.go | 2 +- lib/jwt.go | 60 +++++++++++++++ lib/jwt_test.go | 193 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 lib/jwt_test.go diff --git a/README.md b/README.md index 0cc91dc1..03085c5c 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,8 @@ When you login using the CLI a record of the login is saved. Eventually your tok force login -i=login # log in to production or developer org force login -i=test # log in to sandbox org force login -i=pre # log in to prerelease org - force login -u=un [-p=pw] # log in using SOAP. Password is optional - force login -i=test -u=un -p=pw # log in using SOAP to sandbox org. Password is optional + force login -u=un [-p=pw] # log in using OAuth Username-Password flow + force login -i=test -u=un -p=pw # log in to sandbox org using OAuth Username-Password flow force login -i= -u=un -p=pw # internal only ### logout diff --git a/command/login.go b/command/login.go index 300f0c97..5ca94852 100644 --- a/command/login.go +++ b/command/login.go @@ -268,8 +268,8 @@ var featuresRequiringQuantity = map[string]bool{ const defaultFeatureQuantity = "10" func init() { - loginCmd.Flags().StringP("user", "u", "", "username for SOAP login") - loginCmd.Flags().StringP("password", "p", "", "password for SOAP login") + loginCmd.Flags().StringP("user", "u", "", "username for SOAP or OAuth Username-Password login") + loginCmd.Flags().StringP("password", "p", "", "password for SOAP or OAuth Username-Password login") loginCmd.Flags().StringP("api-version", "v", "", "API version to use") loginCmd.Flags().String("connected-app-client-id", "", "Client Id (aka Consumer Key) to use instead of default") loginCmd.Flags().StringP("key", "k", "", "JWT signing key filename") @@ -476,7 +476,8 @@ to get a new session token automatically when needed.`, `, Args: cobra.MaximumNArgs(0), Run: func(cmd *cobra.Command, args []string) { - if connectedAppClientId, _ := cmd.Flags().GetString("connected-app-client-id"); connectedAppClientId != "" { + connectedAppClientId, _ := cmd.Flags().GetString("connected-app-client-id") + if connectedAppClientId != "" { ClientId = connectedAppClientId } endpoint := getEndpoint(cmd) @@ -485,7 +486,7 @@ to get a new session token automatically when needed.`, keyFile, _ := cmd.Flags().GetString("key") clientSecret, _ := cmd.Flags().GetString("connected-app-client-secret") switch { - case clientSecret != "": + case username == "" && clientSecret != "": clientCredentialsLogin(endpoint, ClientId, clientSecret) case username == "": deviceFlow, _ := cmd.Flags().GetBool("device-flow") @@ -498,9 +499,12 @@ to get a new session token automatically when needed.`, } case keyFile != "": jwtLogin(endpoint, username, keyFile) + case clientSecret != "": + password, _ := cmd.Flags().GetString("password") + passwordLogin(endpoint, ClientId, clientSecret, username, password) default: password, _ := cmd.Flags().GetString("password") - passwordLogin(endpoint, username, password) + soapLogin(endpoint, username, password) } }, } @@ -676,7 +680,21 @@ func clientCredentialsLogin(endpoint, clientId, clientSecret string) { } } -func passwordLogin(endpoint, username, password string) { +func passwordLogin(endpoint, clientId, clientSecret, username, password string) { + if len(password) == 0 { + var err error + password, err = speakeasy.Ask("Password: ") + if err != nil { + ErrorAndExit(err.Error()) + } + } + _, err := ForceLoginAtEndpointAndSavePasswordFlow(endpoint, clientId, clientSecret, username, password, os.Stdout) + if err != nil { + ErrorAndExit(err.Error()) + } +} + +func soapLogin(endpoint, username, password string) { if len(password) == 0 { var err error password, err = speakeasy.Ask("Password: ") @@ -684,6 +702,7 @@ func passwordLogin(endpoint, username, password string) { ErrorAndExit(err.Error()) } } + fmt.Fprintln(os.Stderr, "Warning: SOAP login will no longer be supported after Summer '27. Pass --connected-app-client-id and --connected-app-client-secret to use the OAuth Username/Password flow instead.") _, err := ForceLoginAtEndpointAndSaveSoap(endpoint, username, password, os.Stdout) if err != nil { ErrorAndExit(err.Error()) diff --git a/lib/force.go b/lib/force.go index fa464816..0eaa7726 100644 --- a/lib/force.go +++ b/lib/force.go @@ -100,7 +100,7 @@ type ForceSession struct { Scope string `json:"scope"` Id string `json:"id"` ClientId string - RefreshToken string + RefreshToken string `json:"refresh_token,omitempty"` ForceEndpoint ForceEndpoint EndpointUrl string `json:"endpoint_url"` UserInfo *UserInfo diff --git a/lib/jwt.go b/lib/jwt.go index afc09031..a97b789a 100644 --- a/lib/jwt.go +++ b/lib/jwt.go @@ -153,3 +153,63 @@ func ForceLoginAtEndpointAndSaveClientCredentials(endpoint string, clientId stri username, err = ForceSaveLogin(creds, output) return } + +func PasswordFlowLoginAtEndpoint(endpoint string, clientId string, clientSecret string, username string, password string) (creds ForceSession, err error) { + attrs := url.Values{} + attrs.Set("grant_type", "password") + attrs.Set("client_id", clientId) + if clientSecret != "" { + attrs.Set("client_secret", clientSecret) + } + attrs.Set("username", username) + attrs.Set("password", password) + + postVars := attrs.Encode() + tokenURL := tokenURL(endpoint) + req, err := httpRequest("POST", tokenURL, bytes.NewReader([]byte(postVars))) + if err != nil { + return + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + res, err := doRequest(req) + if err != nil { + return + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return + } + + if res.StatusCode != 200 { + var oauthError *OAuthError + err = json.Unmarshal(body, &oauthError) + if err != nil { + return + } + if oauthError.Error == "invalid_grant" && oauthError.ErrorDescription == "authentication failure" { + err = errors.New(oauthError.ErrorDescription + ": Note that only Connected Apps support the username/password flow; External Client Apps do not") + return + } + err = errors.New(oauthError.ErrorDescription) + return + } + + err = json.Unmarshal(body, &creds) + creds.SessionOptions = &SessionOptions{} + if creds.RefreshToken != "" { + creds.SessionOptions.RefreshMethod = RefreshOauth + } + creds.EndpointUrl = endpoint + creds.ClientId = clientId + return +} + +func ForceLoginAtEndpointAndSavePasswordFlow(endpoint string, clientId string, clientSecret string, username string, password string, output *os.File) (name string, err error) { + creds, err := PasswordFlowLoginAtEndpoint(endpoint, clientId, clientSecret, username, password) + if err != nil { + return + } + name, err = ForceSaveLogin(creds, output) + return +} diff --git a/lib/jwt_test.go b/lib/jwt_test.go new file mode 100644 index 00000000..41beab72 --- /dev/null +++ b/lib/jwt_test.go @@ -0,0 +1,193 @@ +package lib + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPasswordFlowLoginAtEndpoint_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + expectedPath := "/services/oauth2/token" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("Expected Content-Type application/x-www-form-urlencoded, got %s", r.Header.Get("Content-Type")) + } + + err := r.ParseForm() + if err != nil { + t.Fatalf("Failed to parse form: %v", err) + } + + if r.Form.Get("grant_type") != "password" { + t.Errorf("Expected grant_type=password, got %s", r.Form.Get("grant_type")) + } + if r.Form.Get("client_id") != "test-client-id" { + t.Errorf("Expected client_id=test-client-id, got %s", r.Form.Get("client_id")) + } + if r.Form.Get("username") != "user@example.com" { + t.Errorf("Expected username=user@example.com, got %s", r.Form.Get("username")) + } + if r.Form.Get("password") != "secretpassword" { + t.Errorf("Expected password=secretpassword, got %s", r.Form.Get("password")) + } + if _, ok := r.Form["client_secret"]; ok { + t.Errorf("Expected no client_secret in form, got %s", r.Form.Get("client_secret")) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "instance_url": "https://na1.salesforce.com", + "issued_at": "1234567890", + "scope": "full", + }) + })) + defer server.Close() + + creds, err := PasswordFlowLoginAtEndpoint(server.URL, "test-client-id", "", "user@example.com", "secretpassword") + + if err != nil { + t.Fatalf("PasswordFlowLoginAtEndpoint returned error: %v", err) + } + + if creds.AccessToken != "test-access-token" { + t.Errorf("Expected AccessToken test-access-token, got %s", creds.AccessToken) + } + + if creds.RefreshToken != "test-refresh-token" { + t.Errorf("Expected RefreshToken test-refresh-token, got %s", creds.RefreshToken) + } + + if creds.InstanceUrl != "https://na1.salesforce.com" { + t.Errorf("Expected InstanceUrl https://na1.salesforce.com, got %s", creds.InstanceUrl) + } + + if creds.SessionOptions == nil { + t.Fatal("Expected SessionOptions to be set") + } + + if creds.SessionOptions.RefreshMethod != RefreshOauth { + t.Errorf("Expected RefreshMethod RefreshOauth, got %d", creds.SessionOptions.RefreshMethod) + } + + if creds.EndpointUrl != server.URL { + t.Errorf("Expected EndpointUrl %s, got %s", server.URL, creds.EndpointUrl) + } + + if creds.ClientId != "test-client-id" { + t.Errorf("Expected ClientId test-client-id, got %s", creds.ClientId) + } +} + +func TestPasswordFlowLoginAtEndpoint_NoRefreshToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-access-token", + "instance_url": "https://na1.salesforce.com", + "issued_at": "1234567890", + }) + })) + defer server.Close() + + creds, err := PasswordFlowLoginAtEndpoint(server.URL, "test-client-id", "", "user@example.com", "secretpassword") + + if err != nil { + t.Fatalf("PasswordFlowLoginAtEndpoint returned error: %v", err) + } + + if creds.SessionOptions.RefreshMethod != RefreshUnavailable { + t.Errorf("Expected RefreshMethod RefreshUnavailable when no refresh token, got %d", creds.SessionOptions.RefreshMethod) + } +} + +func TestPasswordFlowLoginAtEndpoint_Error(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "invalid_client_id", + "error_description": "client identifier invalid", + }) + })) + defer server.Close() + + _, err := PasswordFlowLoginAtEndpoint(server.URL, "test-client-id", "", "user@example.com", "wrongpassword") + + if err == nil { + t.Fatal("Expected error for invalid credentials, got nil") + } + + if err.Error() != "client identifier invalid" { + t.Errorf("Expected error message 'client identifier invalid', got %q", err.Error()) + } +} + +func TestPasswordFlowLoginAtEndpoint_AuthenticationFailureMentionsExternalClientApp(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "invalid_grant", + "error_description": "authentication failure", + }) + })) + defer server.Close() + + _, err := PasswordFlowLoginAtEndpoint(server.URL, "test-client-id", "test-client-secret", "user@example.com", "wrongpassword") + + if err == nil { + t.Fatal("Expected error for invalid credentials, got nil") + } + + expected := "authentication failure: Note that only Connected Apps support the username/password flow; External Client Apps do not" + if err.Error() != expected { + t.Errorf("Expected error message %q, got %q", expected, err.Error()) + } +} + +func TestPasswordFlowLoginAtEndpoint_WithClientSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + t.Fatalf("Failed to parse form: %v", err) + } + + if r.Form.Get("grant_type") != "password" { + t.Errorf("Expected grant_type=password, got %s", r.Form.Get("grant_type")) + } + if r.Form.Get("client_id") != "test-client-id" { + t.Errorf("Expected client_id=test-client-id, got %s", r.Form.Get("client_id")) + } + if r.Form.Get("client_secret") != "test-client-secret" { + t.Errorf("Expected client_secret=test-client-secret, got %s", r.Form.Get("client_secret")) + } + if r.Form.Get("username") != "user@example.com" { + t.Errorf("Expected username=user@example.com, got %s", r.Form.Get("username")) + } + if r.Form.Get("password") != "secretpassword" { + t.Errorf("Expected password=secretpassword, got %s", r.Form.Get("password")) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "test-access-token", + "instance_url": "https://na1.salesforce.com", + "issued_at": "1234567890", + }) + })) + defer server.Close() + + _, err := PasswordFlowLoginAtEndpoint(server.URL, "test-client-id", "test-client-secret", "user@example.com", "secretpassword") + if err != nil { + t.Fatalf("PasswordFlowLoginAtEndpoint returned error: %v", err) + } +}