-
Notifications
You must be signed in to change notification settings - Fork 13
feat: auth login with device flow #184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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"` | ||||||||||||||
|
|
@@ -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() | ||||||||||||||
|
|
||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Treat Right now any unrecognized device-token error aborts the flow. If the endpoint responds with 🔧 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 |
||||||||||||||
| } | ||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 100Repository: mobile-next/mobilecli Length of output: 3714 🏁 Script executed: sed -n '140,200p' cli/auth.goRepository: mobile-next/mobilecli Length of output: 1919 🏁 Script executed: sed -n '371,390p' cli/auth.goRepository: mobile-next/mobilecli Length of output: 697 🏁 Script executed: sed -n '371,410p' cli/auth.goRepository: mobile-next/mobilecli Length of output: 1337 🏁 Script executed: # Also check what deviceTokenURL constant is
rg -n 'deviceTokenURL\s*=' cli/auth.goRepository: mobile-next/mobilecli Length of output: 140 🏁 Script executed: rg -n 'type apiTokenResponse' cli/auth.goRepository: mobile-next/mobilecli Length of output: 98 🏁 Script executed: sed -n '51,60p' cli/auth.goRepository: mobile-next/mobilecli Length of output: 346 Device flow stores a different token type than the browser flow. The 🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| 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() | ||||||||||||||
|
|
||||||||||||||
| 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 { | ||||||||||||||
|
|
@@ -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, | ||||||||||||||
| ) | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -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 | ||||||||||||||
|
|
||||||||||||||
| callbackErr := make(chan error, 1) | ||||||||||||||
| mux := http.NewServeMux() | ||||||||||||||
|
|
@@ -104,13 +223,13 @@ | |||||||||||||
| err := handleOAuthCallback(r, nonce, codeVerifier) | ||||||||||||||
| if err != nil { | ||||||||||||||
| w.WriteHeader(http.StatusBadRequest) | ||||||||||||||
| fmt.Fprintln(w, "Login failed") | ||||||||||||||
| } 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>") | ||||||||||||||
| } | ||||||||||||||
| callbackErr <- err | ||||||||||||||
| go srv.Shutdown(context.Background()) | ||||||||||||||
| }) | ||||||||||||||
|
|
||||||||||||||
| go func() { | ||||||||||||||
|
|
@@ -119,7 +238,7 @@ | |||||||||||||
| } | ||||||||||||||
| }() | ||||||||||||||
|
|
||||||||||||||
| shutdown = func() { srv.Shutdown(context.Background()) } | ||||||||||||||
| return port, callbackErr, shutdown, nil | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -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 | ||||||||||||||
|
|
@@ -230,7 +349,7 @@ | |||||||||||||
| if err != nil { | ||||||||||||||
| return nil, fmt.Errorf("failed to request tokens: %w", err) | ||||||||||||||
| } | ||||||||||||||
| defer resp.Body.Close() | ||||||||||||||
|
|
||||||||||||||
| body, err := io.ReadAll(resp.Body) | ||||||||||||||
| if err != nil { | ||||||||||||||
|
|
@@ -261,7 +380,7 @@ | |||||||||||||
| if err != nil { | ||||||||||||||
| return "", fmt.Errorf("failed to request session token: %w", err) | ||||||||||||||
| } | ||||||||||||||
| defer resp.Body.Close() | ||||||||||||||
|
|
||||||||||||||
| body, err := io.ReadAll(resp.Body) | ||||||||||||||
| if err != nil { | ||||||||||||||
|
|
@@ -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) | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard token masking against short tokens to prevent slice panic.
🔧 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| 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", | ||||||||||||||
|
|
@@ -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) | ||||||||||||||
| } | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 failingerrcheck.🔧 Proposed fix
Apply this pattern in both
runAuthLoginWebandpollDeviceToken.Also applies to: 162-162
🧰 Tools
🪛 GitHub Check: lint
[failure] 102-102:
Error return value of
resp.Body.Closeis not checked (errcheck)🤖 Prompt for AI Agents