Skip to content
Closed
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
148 changes: 144 additions & 4 deletions cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,17 @@

const oauthClientID = "26epocf8ss83d7uj8trmr6ktvn"
const oauthTokenURL = "https://auth.mobilenexthq.com/oauth2/token"
const oauthRedirectURI = "https://mobilenexthq.com/oauth/callback/"
const oauthRedirectURI = "https://app.mobilenexthq.com/login/oauth/callback"
const apiTokenURL = "https://api.mobilenexthq.com/auth/token"

const deviceFlowClientID = "ed38b523-56e8-4719-837b-7074fac152b5"
const deviceCodeURL = "https://app.mobilenexthq.com/login/device/code"
const deviceTokenURL = "https://app.mobilenexthq.com/login/device/token"

const authHTTPTimeout = 30 * time.Second

var authHTTPClient = &http.Client{Timeout: authHTTPTimeout}
var webLogin bool

type oauthTokenResponse struct {
AccessToken string `json:"access_token"`
Expand All @@ -59,10 +64,124 @@
Short: "Log in to your account",
Long: `Opens the login page in your default browser to authenticate.`,
RunE: func(cmd *cobra.Command, args []string) error {
if webLogin {
return runAuthLoginWeb()
}
return runAuthLogin()
},
}

type deviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}

type deviceTokenResponse struct {
AccessToken string `json:"access_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
Error string `json:"error,omitempty"`
}

func runAuthLoginWeb() error {
body, err := json.Marshal(map[string]string{
"client_id": deviceFlowClientID,
"scope": "user devices",
})
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}

resp, err := authHTTPClient.Post(deviceCodeURL, "application/json", strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("failed to request device code: %w", err)
}
defer resp.Body.Close()

Check failure on line 101 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `resp.Body.Close` is not checked (errcheck)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle resp.Body.Close() errors (currently failing lint).

Line 102 and Line 162 defer resp.Body.Close() without checking the returned error, which is currently failing errcheck.

🔧 Proposed fix
-	defer resp.Body.Close()
+	defer func() {
+		if closeErr := resp.Body.Close(); closeErr != nil {
+			fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", closeErr)
+		}
+	}()

Apply this pattern in both runAuthLoginWeb and pollDeviceToken.

Also applies to: 162-162

🧰 Tools
🪛 GitHub Check: lint

[failure] 102-102:
Error return value of resp.Body.Close is not checked (errcheck)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/auth.go` at line 102, In runAuthLoginWeb and pollDeviceToken replace the
unchecked defer resp.Body.Close() with a pattern that captures and logs/returns
the Close() error: assign resp.Body.Close() to a variable inside a deferred func
(e.g., defer func() { if err := resp.Body.Close(); err != nil { /* handle: log
with the existing logger or wrap/return the error */ } }()), so the close error
is not ignored and satisfies errcheck; locate the resp variable usages in
runAuthLoginWeb and pollDeviceToken and apply this deferred closure pattern
consistently.


respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("device code endpoint returned %d: %s", resp.StatusCode, string(respBody))
}

var codeResp deviceCodeResponse
if err := json.Unmarshal(respBody, &codeResp); err != nil {
return fmt.Errorf("failed to parse device code response: %w", err)
}

fmt.Printf("-> Your one-time code: %s\nPlease open this URL in your browser: %s\n", codeResp.UserCode, codeResp.VerificationURI)

interval := time.Duration(codeResp.Interval) * time.Second
if interval == 0 {
interval = 5 * time.Second
}
deadline := time.Now().Add(time.Duration(codeResp.ExpiresIn) * time.Second)

for time.Now().Before(deadline) {
time.Sleep(interval)

token, err := pollDeviceToken(codeResp.DeviceCode)
if err != nil {
return err
Comment on lines +125 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat slow_down as backoff, not a terminal login failure.

Right now any unrecognized device-token error aborts the flow. If the endpoint responds with slow_down, this will fail login instead of increasing the poll interval and continuing.

🔧 Suggested fix
+var errDeviceFlowSlowDown = errors.New("device flow slow_down")
+
 func pollDeviceToken(deviceCode string) (string, error) {
   ...
   switch tokenResp.Error {
   case "authorization_pending":
     return "", nil
+  case "slow_down":
+    return "", errDeviceFlowSlowDown
   case "expired_token":
     return "", fmt.Errorf("device code expired")
   case "":
     return tokenResp.AccessToken, nil
   default:
     return "", fmt.Errorf("device token error: %s", tokenResp.Error)
   }
 }
  for time.Now().Before(deadline) {
    time.Sleep(interval)

    token, err := pollDeviceToken(codeResp.DeviceCode)
    if err != nil {
+     if errors.Is(err, errDeviceFlowSlowDown) {
+       interval += 5 * time.Second
+       continue
+     }
      return err
    }

Also applies to: 173-181

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/auth.go` around lines 125 - 130, The poll loop treating any err from
pollDeviceToken(codeResp.DeviceCode) as terminal is wrong for the "slow_down"
case — instead detect when the error indicates "slow_down" (inspect the error
type/message returned by pollDeviceToken) and, rather than returning err,
increase the poll interval (e.g., back off by multiplying interval up to a cap)
and continue the loop; leave other errors unchanged (still return them). Apply
the same change to the second polling block referenced (the similar loop around
the other pollDeviceToken usage) so both loops handle "slow_down" by backing off
and continuing rather than aborting.

}
if token == "" {
continue
}

if err := keyring.Set(keyringService, keyringUser, token); err != nil {
return fmt.Errorf("failed to store session token: %w", err)
}
Comment on lines +128 to +138
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare how the auth token is produced and consumed across the repo.
rg -n -C3 --type=go 'keyring\.(Set|Get)\(keyringService,\s*keyringUser'
rg -n -C3 --type=go 'exchangeIDTokenForSession|apiTokenURL|deviceTokenResponse|access_token'

Repository: mobile-next/mobilecli

Length of output: 3990


🏁 Script executed:

cat -n cli/auth.go | head -n 200 | tail -n 100

Repository: mobile-next/mobilecli

Length of output: 3714


🏁 Script executed:

sed -n '140,200p' cli/auth.go

Repository: mobile-next/mobilecli

Length of output: 1919


🏁 Script executed:

sed -n '371,390p' cli/auth.go

Repository: mobile-next/mobilecli

Length of output: 697


🏁 Script executed:

sed -n '371,410p' cli/auth.go

Repository: mobile-next/mobilecli

Length of output: 1337


🏁 Script executed:

# Also check what deviceTokenURL constant is
rg -n 'deviceTokenURL\s*=' cli/auth.go

Repository: mobile-next/mobilecli

Length of output: 140


🏁 Script executed:

rg -n 'type apiTokenResponse' cli/auth.go

Repository: mobile-next/mobilecli

Length of output: 98


🏁 Script executed:

sed -n '51,60p' cli/auth.go

Repository: mobile-next/mobilecli

Length of output: 346


Device flow stores a different token type than the browser flow.

The --web flag stores deviceTokenResponse.AccessToken from the device endpoint, but the normal login path stores apiTokenResponse.Token from the API endpoint (after exchanging an IDToken). These are different response structures from different endpoints. Confirm that both token types are interchangeable session tokens, or implement a consistent token exchange flow for both login paths to ensure auth login --web produces credentials compatible with existing consumers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/auth.go` around lines 128 - 138, The device-flow path currently stores
the raw token returned by pollDeviceToken (deviceTokenResponse.AccessToken) into
keyring via keyring.Set, while the browser/normal flow stores
apiTokenResponse.Token after exchanging an ID token; confirm these are
equivalent session tokens and if not, implement a consistent exchange so both
flows persist the same token type. Specifically, inspect pollDeviceToken and the
device endpoint response, compare with the API exchange that produces
apiTokenResponse.Token, and either (a) perform the same token exchange in the
device flow to produce apiTokenResponse.Token before calling
keyring.Set(keyringService, keyringUser, token), or (b) normalize both responses
to a common session token structure and persist that normalized token; update
any consumers expecting apiTokenResponse.Token to accept the normalized form.
Ensure function names referenced (pollDeviceToken, keyring.Set,
apiTokenResponse.Token, deviceTokenResponse.AccessToken) are where the change is
made so auth login --web writes credentials compatible with existing consumers.


fmt.Println("✅ Successfully logged in")
return nil
}

return fmt.Errorf("device code expired, please try again")
}

func pollDeviceToken(deviceCode string) (string, error) {
body, err := json.Marshal(map[string]string{
"client_id": deviceFlowClientID,
"device_code": deviceCode,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
})
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}

resp, err := authHTTPClient.Post(deviceTokenURL, "application/json", strings.NewReader(string(body)))
if err != nil {
return "", fmt.Errorf("failed to poll device token: %w", err)
}
defer resp.Body.Close()

Check failure on line 161 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `resp.Body.Close` is not checked (errcheck)

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}

var tokenResp deviceTokenResponse
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return "", fmt.Errorf("failed to parse token response: %w", err)
}

switch tokenResp.Error {
case "authorization_pending":
return "", nil
case "expired_token":
return "", fmt.Errorf("device code expired")
case "":
return tokenResp.AccessToken, nil
default:
return "", fmt.Errorf("device token error: %s", tokenResp.Error)
}
}

func generateCSRFNonce() (string, error) {
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
Expand All @@ -84,7 +203,7 @@

func buildLoginURL(port int, nonce, codeChallenge string) string {
return fmt.Sprintf(
"https://mobilenexthq.com/oauth/login/?redirectUri=http://localhost:%d/oauth/callback&csrf=%s&agent=mobilecli&agentVersion=%s&code_challenge=%s&code_challenge_method=S256",
"https://app.mobilenexthq.com/login/oauth/?redirectUri=http://localhost:%d/oauth/callback&csrf=%s&agent=mobilecli&agentVersion=%s&code_challenge=%s&code_challenge_method=S256",
port, nonce, server.Version, codeChallenge,
)
}
Expand All @@ -94,7 +213,7 @@
if err != nil {
return 0, nil, nil, fmt.Errorf("failed to start callback server: %w", err)
}
port = listener.Addr().(*net.TCPAddr).Port

Check failure on line 216 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)

callbackErr := make(chan error, 1)
mux := http.NewServeMux()
Expand All @@ -104,13 +223,13 @@
err := handleOAuthCallback(r, nonce, codeVerifier)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, "Login failed")

Check failure on line 226 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintln` is not checked (errcheck)
} else {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, "<html><body><h2>Login successful!</h2><p>You can close this window.</p></body></html>")

Check failure on line 229 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintln` is not checked (errcheck)
}
callbackErr <- err
go srv.Shutdown(context.Background())

Check failure on line 232 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `srv.Shutdown` is not checked (errcheck)
})

go func() {
Expand All @@ -119,7 +238,7 @@
}
}()

shutdown = func() { srv.Shutdown(context.Background()) }

Check failure on line 241 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `srv.Shutdown` is not checked (errcheck)
return port, callbackErr, shutdown, nil
}

Expand All @@ -141,7 +260,7 @@
defer shutdown()

loginURL := buildLoginURL(port, nonce, codeChallenge)
fmt.Printf("Your browser has been opened to visit:\n\n\t%s\n\n", loginURL)
fmt.Printf("Your browser has been opened to visit:\n %s\n\n", loginURL)

if err := openBrowser(loginURL); err != nil {
return err
Expand Down Expand Up @@ -230,7 +349,7 @@
if err != nil {
return nil, fmt.Errorf("failed to request tokens: %w", err)
}
defer resp.Body.Close()

Check failure on line 352 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `resp.Body.Close` is not checked (errcheck)

body, err := io.ReadAll(resp.Body)
if err != nil {
Expand Down Expand Up @@ -261,7 +380,7 @@
if err != nil {
return "", fmt.Errorf("failed to request session token: %w", err)
}
defer resp.Body.Close()

Check failure on line 383 in cli/auth.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `resp.Body.Close` is not checked (errcheck)

body, err := io.ReadAll(resp.Body)
if err != nil {
Expand All @@ -280,6 +399,26 @@
return tokenResp.Token, nil
}

var authStatusCmd = &cobra.Command{
Use: "status",
Short: "Show authentication status",
RunE: func(cmd *cobra.Command, args []string) error {
token, err := keyring.Get(keyringService, keyringUser)
if errors.Is(err, keyring.ErrNotFound) || token == "" {
return fmt.Errorf("not logged into mobilenexthq.com")
}
if err != nil {
return fmt.Errorf("failed to read keyring: %w", err)
}

masked := token[:4] + strings.Repeat("*", 40)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard token masking against short tokens to prevent slice panic.

token[:4] panics when token length is less than 4.

🔧 Proposed fix
-		masked := token[:4] + strings.Repeat("*", 40)
+		visible := 4
+		if len(token) < visible {
+			visible = len(token)
+		}
+		masked := token[:visible] + strings.Repeat("*", len(token)-visible)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
masked := token[:4] + strings.Repeat("*", 40)
visible := 4
if len(token) < visible {
visible = len(token)
}
masked := token[:visible] + strings.Repeat("*", len(token)-visible)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/auth.go` at line 413, The masking code for variable token (masked :=
token[:4] + strings.Repeat("*", 40)) can panic for tokens shorter than 4; update
the logic in the function that builds masked to first compute a safe prefix
length (e.g., n := len(token); if n > 4 { n = 4 }) or use a min helper, then
slice token[:n]; decide how many stars to append (fixed 40 or max(0,
40-(len(token)-n))), and assign masked using that safe prefix and the stars;
change the single expression that creates masked to use these guarded values so
short tokens do not cause a panic.

fmt.Printf("mobilenexthq.com\n")
fmt.Printf(" - You are logged in\n")
fmt.Printf(" - Token: %s\n", masked)
return nil
},
}

var authLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Log out of your account",
Expand Down Expand Up @@ -314,6 +453,7 @@
}

func init() {
authLoginCmd.Flags().BoolVar(&webLogin, "web", false, "use device code flow (no localhost callback needed)")
rootCmd.AddCommand(authCmd)
authCmd.AddCommand(authLoginCmd, authLogoutCmd, authTokenCmd)
authCmd.AddCommand(authLoginCmd, authLogoutCmd, authStatusCmd, authTokenCmd)
}
Loading