Skip to content

feat: auth login with device flow#184

Open
gmegidish wants to merge 3 commits intomainfrom
feat-auth-login-with-deviceflow
Open

feat: auth login with device flow#184
gmegidish wants to merge 3 commits intomainfrom
feat-auth-login-with-deviceflow

Conversation

@gmegidish
Copy link
Member

No description provided.

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

Walkthrough

Adds a device-code web login flow selectable via a new --web flag. Implements deviceCodeResponse and deviceTokenResponse types, runAuthLoginWeb and pollDeviceToken functions, and switches auth login to use the web device flow when --web is set. Updates the login host to app.mobilenexhq. Introduces an auth status command that reads and masks the stored token. Wires the --web flag and registers authStatusCmd alongside existing auth commands.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive No pull request description was provided by the author; unable to evaluate whether description content relates to the changeset. Add a pull request description explaining the device flow login implementation, its purpose, and how to use the new --web flag.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: auth login with device flow' directly and accurately describes the main change—adding device flow authentication to the login command.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat-auth-login-with-deviceflow

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
cli/auth.go (1)

174-183: Handle slow_down responses during device token polling.

pollDeviceToken treats unknown errors as fatal; for device flow, slow_down should increase polling interval and continue instead of aborting login.

♻️ Proposed refactor
+var errDeviceFlowSlowDown = errors.New("device flow requested slower polling")
@@
-		token, err := pollDeviceToken(codeResp.DeviceCode)
-		if err != nil {
-			return err
-		}
+		token, err := pollDeviceToken(codeResp.DeviceCode)
+		if errors.Is(err, errDeviceFlowSlowDown) {
+			interval += 5 * time.Second
+			continue
+		}
+		if err != nil {
+			return err
+		}
@@
 	switch tokenResp.Error {
 	case "authorization_pending":
 		return "", nil
+	case "slow_down":
+		return "", errDeviceFlowSlowDown
 	case "expired_token":
 		return "", fmt.Errorf("device code expired")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/auth.go` around lines 174 - 183, pollDeviceToken currently treats unknown
tokenResp.Error values as fatal; specifically handle the "slow_down" response by
not returning an error but increasing the polling interval and continuing to
poll. In the switch over tokenResp.Error (and in/around the pollDeviceToken
function), add a case for "slow_down" that increments the delay (or adds a
backoff) before the next request and then continue the loop, rather than
returning an error for that value; keep existing behavior for
"authorization_pending", "expired_token", empty string, and the default error
case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cli/auth.go`:
- 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.
- 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.
- Around line 408-411: The current RunE handler in cli/auth.go calls os.Exit
when err != nil || token == "", which prevents Cobra from handling errors and
makes tests brittle; change this to return errors instead and distinguish
keyring.ErrNotFound from other keyring errors: inside the function (the RunE
callback that obtains token and err), if err == keyring.ErrNotFound or token ==
"" return a clear user-facing error (e.g., fmt.Errorf("not logged into
mobilenexthq.com")), if err != nil (and not ErrNotFound) return that underlying
keyring error wrapped with context, and remove any os.Exit calls so Cobra can
handle/report errors and tests can assert on returned errors.

---

Nitpick comments:
In `@cli/auth.go`:
- Around line 174-183: pollDeviceToken currently treats unknown tokenResp.Error
values as fatal; specifically handle the "slow_down" response by not returning
an error but increasing the polling interval and continuing to poll. In the
switch over tokenResp.Error (and in/around the pollDeviceToken function), add a
case for "slow_down" that increments the delay (or adds a backoff) before the
next request and then continue the loop, rather than returning an error for that
value; keep existing behavior for "authorization_pending", "expired_token",
empty string, and the default error case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 75e2bf0d-4cc9-4f82-b4a9-556fd25d92ff

📥 Commits

Reviewing files that changed from the base of the PR and between 5eff5df and 8d9127d.

📒 Files selected for processing (1)
  • cli/auth.go

if err != nil {
return fmt.Errorf("failed to request device code: %w", err)
}
defer resp.Body.Close()
Copy link

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.

os.Exit(1)
}

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

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
cli/auth.go (3)

406-411: ⚠️ Potential issue | 🟠 Major

Check err before treating this as logged-out.

With the current ordering, a real keyring backend failure can still be flattened into "not logged in" whenever token == "". Handle ErrNotFound first, then other errors, then the empty-token case.

🔧 Suggested fix
 		token, err := keyring.Get(keyringService, keyringUser)
-		if errors.Is(err, keyring.ErrNotFound) || token == "" {
+		if errors.Is(err, keyring.ErrNotFound) {
 			return fmt.Errorf("not logged into mobilenexthq.com")
 		}
 		if err != nil {
 			return fmt.Errorf("failed to read keyring: %w", err)
 		}
+		if token == "" {
+			return fmt.Errorf("not logged into mobilenexthq.com")
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/auth.go` around lines 406 - 411, The current logic calls keyring.Get and
treats empty token as "not logged in" before handling actual errors; update the
ordering in the code that uses keyring.Get (the token, err := keyring.Get(...)
block) to first check if errors.Is(err, keyring.ErrNotFound) and return "not
logged into mobilenexthq.com", then check if err != nil and return
fmt.Errorf("failed to read keyring: %w", err), and only after those error checks
validate if token == "" and return "not logged into mobilenexthq.com" if empty;
reference keyring.Get, keyring.ErrNotFound, token and err when making the
changes.

414-414: ⚠️ Potential issue | 🟠 Major

Guard the token slice before masking.

token[:4] still panics when the stored token is shorter than four characters.

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

In `@cli/auth.go` at line 414, The masking line that builds masked := token[:4] +
strings.Repeat("*", 40) can panic if token is shorter than 4 chars; update the
code that produces the mask (where `token` is used) to guard the slice by
checking len(token) and slicing safely (e.g., use the full token when len(token)
< 4 or compute prefix := token[:min(4,len(token))]) before concatenating the
repeated asterisks so `masked` never slices out of bounds in the function
handling authentication/token display.

101-101: ⚠️ Potential issue | 🟠 Major

Handle both resp.Body.Close() errors.

errcheck is still failing on these two defers, so the PR stays red until the close error is consumed explicitly.

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

Also applies to: 161-161

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

In `@cli/auth.go` at line 101, Replace the plain defer resp.Body.Close() calls
with an explicit close-error consumer: wrap the Close() in a deferred func that
captures the returned error (e.g., if cerr := resp.Body.Close(); cerr != nil {
/* log or handle cerr */ }) so the Close error is not ignored; locate both
occurrences where resp.Body.Close() is deferred and update them to use this
pattern, referencing the resp variable and its Body.Close() method.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cli/auth.go`:
- Around line 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.

---

Duplicate comments:
In `@cli/auth.go`:
- Around line 406-411: The current logic calls keyring.Get and treats empty
token as "not logged in" before handling actual errors; update the ordering in
the code that uses keyring.Get (the token, err := keyring.Get(...) block) to
first check if errors.Is(err, keyring.ErrNotFound) and return "not logged into
mobilenexthq.com", then check if err != nil and return fmt.Errorf("failed to
read keyring: %w", err), and only after those error checks validate if token ==
"" and return "not logged into mobilenexthq.com" if empty; reference
keyring.Get, keyring.ErrNotFound, token and err when making the changes.
- Line 414: The masking line that builds masked := token[:4] +
strings.Repeat("*", 40) can panic if token is shorter than 4 chars; update the
code that produces the mask (where `token` is used) to guard the slice by
checking len(token) and slicing safely (e.g., use the full token when len(token)
< 4 or compute prefix := token[:min(4,len(token))]) before concatenating the
repeated asterisks so `masked` never slices out of bounds in the function
handling authentication/token display.
- Line 101: Replace the plain defer resp.Body.Close() calls with an explicit
close-error consumer: wrap the Close() in a deferred func that captures the
returned error (e.g., if cerr := resp.Body.Close(); cerr != nil { /* log or
handle cerr */ }) so the Close error is not ignored; locate both occurrences
where resp.Body.Close() is deferred and update them to use this pattern,
referencing the resp variable and its Body.Close() method.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b9bf46bf-4a97-4b8d-ae33-56639785bf3a

📥 Commits

Reviewing files that changed from the base of the PR and between 8d9127d and a3ed996.

📒 Files selected for processing (1)
  • cli/auth.go

Comment on lines +128 to +138
token, err := pollDeviceToken(codeResp.DeviceCode)
if err != nil {
return err
}
if token == "" {
continue
}

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

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
cli/auth.go (1)

406-411: ⚠️ Potential issue | 🟠 Major

Check err before treating an empty token as logged-out.

With the current order, any non-ErrNotFound keyring failure that also returns an empty string is reported as "not logged into mobilenexthq.com" instead of surfacing the real read error.

🔧 Suggested fix
 		token, err := keyring.Get(keyringService, keyringUser)
-		if errors.Is(err, keyring.ErrNotFound) || token == "" {
-			return fmt.Errorf("not logged into mobilenexthq.com")
-		}
 		if err != nil {
+			if errors.Is(err, keyring.ErrNotFound) {
+				return fmt.Errorf("not logged into mobilenexthq.com")
+			}
 			return fmt.Errorf("failed to read keyring: %w", err)
 		}
+		if token == "" {
+			return fmt.Errorf("not logged into mobilenexthq.com")
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/auth.go` around lines 406 - 411, The current logic calls keyring.Get and
treats an empty token as "not logged into mobilenexthq.com" before checking for
a non-ErrNotFound error; change the order in the keyring.Get handling so you
first inspect err: if err != nil then if errors.Is(err, keyring.ErrNotFound)
return the "not logged into mobilenexthq.com" message, otherwise return
fmt.Errorf("failed to read keyring: %w", err); only after err is nil check if
token == "" and then return the "not logged into mobilenexthq.com" error. This
touches the keyring.Get call and the token/err handling in the same function.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cli/auth.go`:
- Around line 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.

---

Duplicate comments:
In `@cli/auth.go`:
- Around line 406-411: The current logic calls keyring.Get and treats an empty
token as "not logged into mobilenexthq.com" before checking for a
non-ErrNotFound error; change the order in the keyring.Get handling so you first
inspect err: if err != nil then if errors.Is(err, keyring.ErrNotFound) return
the "not logged into mobilenexthq.com" message, otherwise return
fmt.Errorf("failed to read keyring: %w", err); only after err is nil check if
token == "" and then return the "not logged into mobilenexthq.com" error. This
touches the keyring.Get call and the token/err handling in the same function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3f5f255-6467-40a2-98f3-13f117a552d1

📥 Commits

Reviewing files that changed from the base of the PR and between a3ed996 and dd2d5cb.

📒 Files selected for processing (1)
  • cli/auth.go

Comment on lines +125 to +130
for time.Now().Before(deadline) {
time.Sleep(interval)

token, err := pollDeviceToken(codeResp.DeviceCode)
if err != nil {
return err
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant