Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<instance> -u=un -p=pw # internal only

### logout
Expand Down
31 changes: 25 additions & 6 deletions command/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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)
}
},
}
Expand Down Expand Up @@ -676,14 +680,29 @@ 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: ")
if err != nil {
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())
Expand Down
2 changes: 1 addition & 1 deletion lib/force.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions lib/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
193 changes: 193 additions & 0 deletions lib/jwt_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading