From 4f4a58c35b2213a4ca0e322ec55a738a9b87e836 Mon Sep 17 00:00:00 2001 From: nathabonfim59 Date: Sat, 8 Feb 2025 20:12:08 -0300 Subject: [PATCH 1/8] feat(copilot): add new auth flow with device code --- copilot.go | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 123 insertions(+), 7 deletions(-) diff --git a/copilot.go b/copilot.go index 4dc79c40..8b60a13b 100644 --- a/copilot.go +++ b/copilot.go @@ -6,17 +6,22 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "runtime" + "strconv" "strings" "time" ) const ( - copilotChatAuthURL = "https://api.github.com/copilot_internal/v2/token" - copilotEditorVersion = "vscode/1.95.3" - copilotUserAgent = "curl/7.81.0" // Necessay to bypass the user-agent check + copilotAuthDeviceCodeURL = "https://github.com/login/device/code" + copilotAuthTokenURL = "https://api.github.com/login/oauth/access_token" + copilotChatAuthURL = "https://api.github.com/copilot_internal/v2/token" + copilotEditorVersion = "vscode/1.95.3" + copilotUserAgent = "curl/7.81.0" // Necessay to bypass the user-agent check + copilotClientID = "Iv1.b507a08c87ecfe98" // Copilot Editor ) // Authentication response from GitHub Copilot's token endpoint. @@ -37,6 +42,21 @@ type CopilotAccessToken struct { } `json:"error_details,omitempty"` } +type CopilotDeviceCodeResponse 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 CopilotDeviceTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error,omitempty"` +} + type copilotHTTPClient struct { client *http.Client AccessToken *CopilotAccessToken @@ -75,16 +95,100 @@ func (c *copilotHTTPClient) Do(req *http.Request) (*http.Response, error) { return httpResp, nil } -func getCopilotRefreshToken() (string, error) { +func copilotLogin(client *http.Client, configPath string) (string, error) { + resp, err := client.Post( + copilotAuthDeviceCodeURL, + "application/x-www-form-urlencoded", + strings.NewReader(fmt.Sprintf("client_id=%s&scope=copilot", copilotClientID)), + ) + if err != nil { + return "", fmt.Errorf("failed to get device code: %w", err) + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to decode device code response: %w", err) + } + + var deviceCodeResp CopilotDeviceCodeResponse = CopilotDeviceCodeResponse{} + if err != nil { + return "", fmt.Errorf("failed to parse device code response: %w", err) + } + + data, err := url.ParseQuery((string(responseBody))) + + deviceCodeResp.UserCode = data.Get("user_code") + deviceCodeResp.ExpiresIn, _ = strconv.Atoi(data.Get("expires_in")) + deviceCodeResp.DeviceCode = data.Get("device_code") + deviceCodeResp.VerificationUri = data.Get("") + + fmt.Printf("Please go to %s and enter the code %s\n", deviceCodeResp.VerificationUri, deviceCodeResp.UserCode) + saveCopilotRefreshToken(client, deviceCodeResp.DeviceCode, deviceCodeResp.Interval, deviceCodeResp.ExpiresIn) + + return "", fmt.Errorf("not implemented") +} + +func saveCopilotRefreshToken(client *http.Client, deviceCode string, interval int, expiresIn int) (CopilotDeviceTokenResponse, error) { + var accessTokenResp CopilotDeviceTokenResponse + endTime := time.Now().Add(time.Duration(expiresIn) * time.Second) + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for range ticker.C { + if time.Now().After(endTime) { + return CopilotDeviceTokenResponse{}, fmt.Errorf("authorization polling timeout") + } + + data := strings.NewReader( + fmt.Sprintf( + "client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code", + copilotClientID, + deviceCode, + ), + ) + req, err := http.NewRequest("POST", copilotAuthTokenURL, data) + if err != nil { + return CopilotDeviceTokenResponse{}, err + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return CopilotDeviceTokenResponse{}, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&accessTokenResp); err != nil { + return CopilotDeviceTokenResponse{}, err + } + if accessTokenResp.AccessToken != "" { + return accessTokenResp, nil // Successfully authenticated + } + if accessTokenResp.Error != "" { + // Handle errors like "authorization_pending" or "expired_token" appropriately + if accessTokenResp.Error != "authorization_pending" { + return CopilotDeviceTokenResponse{}, fmt.Errorf("token error: %s", accessTokenResp.Error) + } + } + } + + return CopilotDeviceTokenResponse{}, fmt.Errorf("authorization polling failed or timed out") +} + +func getCopilotRefreshToken(client *http.Client) (string, error) { configPath := filepath.Join(os.Getenv("HOME"), ".config/github-copilot") if runtime.GOOS == "windows" { configPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot") } + // Support both legacy and current config file locations + legacyConfigPath := filepath.Join(configPath, "hosts.json") + currentConfigPath := filepath.Join(configPath, "apps.json") + // Check both possible config file locations configFiles := []string{ - filepath.Join(configPath, "hosts.json"), - filepath.Join(configPath, "apps.json"), + legacyConfigPath, + currentConfigPath, } // Try to get token from config files @@ -95,6 +199,18 @@ func getCopilotRefreshToken() (string, error) { } } + // Try to login in into Copilot + token, err := copilotLogin(client, currentConfigPath) + if err != nil { + return "", fmt.Errorf("failed to login into Copilot: %w", err) + } + + if token != "" { + return token, nil + } + + return "", fmt.Errorf(token) + return "", fmt.Errorf("no token found in %s", strings.Join(configFiles, ", ")) } @@ -136,7 +252,7 @@ func getCopilotAccessToken(client *http.Client) (CopilotAccessToken, error) { } } - refreshToken, err := getCopilotRefreshToken() + refreshToken, err := getCopilotRefreshToken(client) if err != nil { return CopilotAccessToken{}, fmt.Errorf("failed to get refresh token: %w", err) } From 6d166d77aadc14096c5df48f2665798910eff059 Mon Sep 17 00:00:00 2001 From: nathabonfim59 Date: Tue, 1 Apr 2025 13:00:58 -0300 Subject: [PATCH 2/8] fix(copilot): correct verification URI and interval parsing in device flow --- copilot.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/copilot.go b/copilot.go index 8b60a13b..c7dff279 100644 --- a/copilot.go +++ b/copilot.go @@ -119,8 +119,9 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { deviceCodeResp.UserCode = data.Get("user_code") deviceCodeResp.ExpiresIn, _ = strconv.Atoi(data.Get("expires_in")) + deviceCodeResp.Interval, _ = strconv.Atoi(data.Get("interval")) deviceCodeResp.DeviceCode = data.Get("device_code") - deviceCodeResp.VerificationUri = data.Get("") + deviceCodeResp.VerificationUri = data.Get("verification_uri") fmt.Printf("Please go to %s and enter the code %s\n", deviceCodeResp.VerificationUri, deviceCodeResp.UserCode) saveCopilotRefreshToken(client, deviceCodeResp.DeviceCode, deviceCodeResp.Interval, deviceCodeResp.ExpiresIn) From 68d226c68ea52f55bb047e6039cade63d0518226 Mon Sep 17 00:00:00 2001 From: nathabonfim59 Date: Tue, 1 Apr 2025 13:01:03 -0300 Subject: [PATCH 3/8] feat(copilot): implement device flow authentication - Add full token exchange flow for GitHub Copilot auth - Store OAuth tokens in config directory - Register application in versions.json - Fix API endpoint URLs and request formatting --- copilot.go | 150 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 127 insertions(+), 23 deletions(-) diff --git a/copilot.go b/copilot.go index c7dff279..8cf9f108 100644 --- a/copilot.go +++ b/copilot.go @@ -17,11 +17,14 @@ import ( const ( copilotAuthDeviceCodeURL = "https://github.com/login/device/code" - copilotAuthTokenURL = "https://api.github.com/login/oauth/access_token" + copilotAuthTokenURL = "https://github.com/login/oauth/access_token" copilotChatAuthURL = "https://api.github.com/copilot_internal/v2/token" copilotEditorVersion = "vscode/1.95.3" - copilotUserAgent = "curl/7.81.0" // Necessay to bypass the user-agent check - copilotClientID = "Iv1.b507a08c87ecfe98" // Copilot Editor + copilotUserAgent = "curl/7.81.0" // Necessay to bypass the user-agent check + + // if you change this, don't forget to update the + // `copilotOAuthToken` json struct tag + copilotClientID = "Iv1.b507a08c87ecfe98" ) // Authentication response from GitHub Copilot's token endpoint. @@ -57,6 +60,21 @@ type CopilotDeviceTokenResponse struct { Error string `json:"error,omitempty"` } +type CopilotFailedRequestResponse struct { + DocumentationURL string `json:"documentation_url"` + Message string `json:"message"` +} + +type copilotGithubOAuthTokenWrapper struct { + User string `json:"user"` + OAuthToken string `json:"oauth_token"` + GithubAppId string `json:"githubAppId"` +} + +type copilotOAuthToken struct { + GithubWrapper copilotGithubOAuthTokenWrapper `json:"github.com:Iv1.b507a08c87ecfe98"` +} + type copilotHTTPClient struct { client *http.Client AccessToken *CopilotAccessToken @@ -96,11 +114,15 @@ func (c *copilotHTTPClient) Do(req *http.Request) (*http.Response, error) { } func copilotLogin(client *http.Client, configPath string) (string, error) { - resp, err := client.Post( - copilotAuthDeviceCodeURL, - "application/x-www-form-urlencoded", - strings.NewReader(fmt.Sprintf("client_id=%s&scope=copilot", copilotClientID)), - ) + data := strings.NewReader(fmt.Sprintf("client_id=%s&scope=copilot", copilotClientID)) + req, err := http.NewRequest("POST", copilotAuthDeviceCodeURL, data) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to get device code: %w", err) } @@ -111,28 +133,50 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { } var deviceCodeResp CopilotDeviceCodeResponse = CopilotDeviceCodeResponse{} + + parsedData, err := url.ParseQuery(string(responseBody)) if err != nil { return "", fmt.Errorf("failed to parse device code response: %w", err) } - data, err := url.ParseQuery((string(responseBody))) - - deviceCodeResp.UserCode = data.Get("user_code") - deviceCodeResp.ExpiresIn, _ = strconv.Atoi(data.Get("expires_in")) - deviceCodeResp.Interval, _ = strconv.Atoi(data.Get("interval")) - deviceCodeResp.DeviceCode = data.Get("device_code") - deviceCodeResp.VerificationUri = data.Get("verification_uri") + deviceCodeResp.UserCode = parsedData.Get("user_code") + deviceCodeResp.ExpiresIn, _ = strconv.Atoi(parsedData.Get("expires_in")) + deviceCodeResp.Interval, _ = strconv.Atoi(parsedData.Get("interval")) + deviceCodeResp.DeviceCode = parsedData.Get("device_code") + deviceCodeResp.VerificationUri = parsedData.Get("verification_uri") fmt.Printf("Please go to %s and enter the code %s\n", deviceCodeResp.VerificationUri, deviceCodeResp.UserCode) - saveCopilotRefreshToken(client, deviceCodeResp.DeviceCode, deviceCodeResp.Interval, deviceCodeResp.ExpiresIn) + oAuthToken, err := fetchCopilotRefreshToken(client, deviceCodeResp.DeviceCode, deviceCodeResp.Interval, deviceCodeResp.ExpiresIn) - return "", fmt.Errorf("not implemented") + if err != nil { + return "", err + } + + err = saveCopilotOAuthToken( + copilotOAuthToken{ + GithubWrapper: copilotGithubOAuthTokenWrapper{ + User: "", + OAuthToken: oAuthToken.AccessToken, + GithubAppId: copilotClientID, + }, + }, + configPath, + ) + + if err != nil { + return "", err + } + + return oAuthToken.AccessToken, nil } -func saveCopilotRefreshToken(client *http.Client, deviceCode string, interval int, expiresIn int) (CopilotDeviceTokenResponse, error) { +func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval int, expiresIn int) (CopilotDeviceTokenResponse, error) { var accessTokenResp CopilotDeviceTokenResponse + var errResp CopilotFailedRequestResponse + endTime := time.Now().Add(time.Duration(expiresIn) * time.Second) ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() for range ticker.C { @@ -140,6 +184,7 @@ func saveCopilotRefreshToken(client *http.Client, deviceCode string, interval in return CopilotDeviceTokenResponse{}, fmt.Errorf("authorization polling timeout") } + fmt.Println("Trying to fetch token...") data := strings.NewReader( fmt.Sprintf( "client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code", @@ -159,12 +204,30 @@ func saveCopilotRefreshToken(client *http.Client, deviceCode string, interval in } defer resp.Body.Close() + isRequestFailed := resp.StatusCode != 200 + + if isRequestFailed { + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { + return CopilotDeviceTokenResponse{}, err + } + + return CopilotDeviceTokenResponse{}, fmt.Errorf( + "Failed to check refresh token\n\tMessage: %s\n\tDocumentation: %s", + errResp.Message, + errResp.DocumentationURL, + ) + } + if err := json.NewDecoder(resp.Body).Decode(&accessTokenResp); err != nil { return CopilotDeviceTokenResponse{}, err } + if accessTokenResp.AccessToken != "" { - return accessTokenResp, nil // Successfully authenticated + // save to the new location + + return accessTokenResp, nil } + if accessTokenResp.Error != "" { // Handle errors like "authorization_pending" or "expired_token" appropriately if accessTokenResp.Error != "authorization_pending" { @@ -176,7 +239,48 @@ func saveCopilotRefreshToken(client *http.Client, deviceCode string, interval in return CopilotDeviceTokenResponse{}, fmt.Errorf("authorization polling failed or timed out") } -func getCopilotRefreshToken(client *http.Client) (string, error) { +func saveCopilotOAuthToken(oAuthToken copilotOAuthToken, configPath string) error { + fileContent, err := json.Marshal(oAuthToken) + + if err != nil { + return fmt.Errorf("Error mashaling oAuthToken: %e", err) + } + + err = os.WriteFile(configPath, fileContent, os.ModePerm) + + versionsPath := filepath.Join(filepath.Dir(configPath), "versions.json") + copilotRegisterApp(versionsPath) + + if err != nil { + return fmt.Errorf("Error writing oAuthToken to %s: %e", configPath, err) + } + + return nil +} + +func copilotRegisterApp(versionsPath string) error { + versions := make(map[string]string) + + data, err := os.ReadFile(versionsPath) + if err == nil { + // File exists, unmarshal contents + if err := json.Unmarshal(data, &versions); err != nil { + return fmt.Errorf("error parsing versions file: %w", err) + } + } + + // Add/update our entry + versions["mods"] = Version + + updatedData, err := json.Marshal(versions) + if err != nil { + return fmt.Errorf("error marshaling versions data: %w", err) + } + + return os.WriteFile(versionsPath, updatedData, 0644) +} + +func getCopilotOAuthToken(client *http.Client) (string, error) { configPath := filepath.Join(os.Getenv("HOME"), ".config/github-copilot") if runtime.GOOS == "windows" { configPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot") @@ -253,9 +357,9 @@ func getCopilotAccessToken(client *http.Client) (CopilotAccessToken, error) { } } - refreshToken, err := getCopilotRefreshToken(client) + oAuthToken, err := getCopilotOAuthToken(client) if err != nil { - return CopilotAccessToken{}, fmt.Errorf("failed to get refresh token: %w", err) + return CopilotAccessToken{}, fmt.Errorf("failed to get oAuth token: %w", err) } tokenReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, copilotChatAuthURL, nil) @@ -263,7 +367,7 @@ func getCopilotAccessToken(client *http.Client) (CopilotAccessToken, error) { return CopilotAccessToken{}, fmt.Errorf("failed to create token request: %w", err) } - tokenReq.Header.Set("Authorization", "token "+refreshToken) + tokenReq.Header.Set("Authorization", "token "+oAuthToken) tokenReq.Header.Set("Accept", "application/json") tokenReq.Header.Set("Editor-Version", copilotEditorVersion) tokenReq.Header.Set("User-Agent", copilotUserAgent) From 29b76600da3f29d0c06d9b4ea66c977528f8396d Mon Sep 17 00:00:00 2001 From: nathabonfim59 Date: Tue, 1 Apr 2025 13:01:53 -0300 Subject: [PATCH 4/8] fix(copilot): remove unreachable code in OAuth token retrieval --- copilot.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/copilot.go b/copilot.go index 8cf9f108..df3d7b67 100644 --- a/copilot.go +++ b/copilot.go @@ -315,8 +315,6 @@ func getCopilotOAuthToken(client *http.Client) (string, error) { } return "", fmt.Errorf(token) - - return "", fmt.Errorf("no token found in %s", strings.Join(configFiles, ", ")) } func extractCopilotTokenFromFile(path string) (string, error) { From fef423c3d4328f7825305dc619b36addcc5a70a9 Mon Sep 17 00:00:00 2001 From: Nathanael Date: Fri, 18 Apr 2025 16:57:21 -0300 Subject: [PATCH 5/8] feat(copilot): add a 30s delay before authCallback This give the user some time to open the browser and proceed with the login workflow. --- copilot.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/copilot.go b/copilot.go index df3d7b67..3f07b3fa 100644 --- a/copilot.go +++ b/copilot.go @@ -174,6 +174,10 @@ func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval i var accessTokenResp CopilotDeviceTokenResponse var errResp CopilotFailedRequestResponse + // Adds a delay to give the user time to open + // the browser and type the code + time.Sleep(30 * time.Second) + endTime := time.Now().Add(time.Duration(expiresIn) * time.Second) ticker := time.NewTicker(time.Duration(interval) * time.Second) From 340578c0097e79c24f8c6cdc2f7efbf110001c40 Mon Sep 17 00:00:00 2001 From: Nathanael Date: Fri, 18 Apr 2025 16:58:46 -0300 Subject: [PATCH 6/8] fix(copilot): create config directory before saving the oAuthToken --- copilot.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/copilot.go b/copilot.go index 3f07b3fa..ddb80031 100644 --- a/copilot.go +++ b/copilot.go @@ -250,6 +250,11 @@ func saveCopilotOAuthToken(oAuthToken copilotOAuthToken, configPath string) erro return fmt.Errorf("Error mashaling oAuthToken: %e", err) } + configDir := filepath.Dir(configPath) + if err = os.MkdirAll(configDir, os.ModePerm); err != nil { + return fmt.Errorf("Error creating config directory: %e", err) + } + err = os.WriteFile(configPath, fileContent, os.ModePerm) versionsPath := filepath.Join(filepath.Dir(configPath), "versions.json") From d2651d670bab802e961c456b7d1bd619b72aa4eb Mon Sep 17 00:00:00 2001 From: Nathanael Date: Fri, 18 Apr 2025 18:02:23 -0300 Subject: [PATCH 7/8] chore(copilot): fix styling and linting errors --- copilot.go | 79 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/copilot.go b/copilot.go index ddb80031..49904c39 100644 --- a/copilot.go +++ b/copilot.go @@ -17,7 +17,7 @@ import ( const ( copilotAuthDeviceCodeURL = "https://github.com/login/device/code" - copilotAuthTokenURL = "https://github.com/login/oauth/access_token" + copilotAuthTokenURL = "https://github.com/login/oauth/access_token" // #nosec G101 copilotChatAuthURL = "https://api.github.com/copilot_internal/v2/token" copilotEditorVersion = "vscode/1.95.3" copilotUserAgent = "curl/7.81.0" // Necessay to bypass the user-agent check @@ -45,22 +45,22 @@ type CopilotAccessToken struct { } `json:"error_details,omitempty"` } -type CopilotDeviceCodeResponse struct { +type copilotDeviceCodeResponse struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` - VerificationUri string `json:"verification_uri"` + VerificationURI string `json:"verification_uri"` ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } -type CopilotDeviceTokenResponse struct { +type copilotDeviceTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` Error string `json:"error,omitempty"` } -type CopilotFailedRequestResponse struct { +type copilotFailedRequestResponse struct { DocumentationURL string `json:"documentation_url"` Message string `json:"message"` } @@ -68,7 +68,7 @@ type CopilotFailedRequestResponse struct { type copilotGithubOAuthTokenWrapper struct { User string `json:"user"` OAuthToken string `json:"oauth_token"` - GithubAppId string `json:"githubAppId"` + GithubAppID string `json:"githubAppId"` } type copilotOAuthToken struct { @@ -132,7 +132,13 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { return "", fmt.Errorf("failed to decode device code response: %w", err) } - var deviceCodeResp CopilotDeviceCodeResponse = CopilotDeviceCodeResponse{} + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("error closing response body: %w", closeErr) + } + }() + + deviceCodeResp := copilotDeviceCodeResponse{} parsedData, err := url.ParseQuery(string(responseBody)) if err != nil { @@ -143,9 +149,9 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { deviceCodeResp.ExpiresIn, _ = strconv.Atoi(parsedData.Get("expires_in")) deviceCodeResp.Interval, _ = strconv.Atoi(parsedData.Get("interval")) deviceCodeResp.DeviceCode = parsedData.Get("device_code") - deviceCodeResp.VerificationUri = parsedData.Get("verification_uri") + deviceCodeResp.VerificationURI = parsedData.Get("verification_uri") - fmt.Printf("Please go to %s and enter the code %s\n", deviceCodeResp.VerificationUri, deviceCodeResp.UserCode) + fmt.Printf("Please go to %s and enter the code %s\n", deviceCodeResp.VerificationURI, deviceCodeResp.UserCode) oAuthToken, err := fetchCopilotRefreshToken(client, deviceCodeResp.DeviceCode, deviceCodeResp.Interval, deviceCodeResp.ExpiresIn) if err != nil { @@ -157,7 +163,7 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { GithubWrapper: copilotGithubOAuthTokenWrapper{ User: "", OAuthToken: oAuthToken.AccessToken, - GithubAppId: copilotClientID, + GithubAppID: copilotClientID, }, }, configPath, @@ -170,9 +176,9 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { return oAuthToken.AccessToken, nil } -func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval int, expiresIn int) (CopilotDeviceTokenResponse, error) { - var accessTokenResp CopilotDeviceTokenResponse - var errResp CopilotFailedRequestResponse +func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval int, expiresIn int) (copilotDeviceTokenResponse, error) { + var accessTokenResp copilotDeviceTokenResponse + var errResp copilotFailedRequestResponse // Adds a delay to give the user time to open // the browser and type the code @@ -185,7 +191,7 @@ func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval i for range ticker.C { if time.Now().After(endTime) { - return CopilotDeviceTokenResponse{}, fmt.Errorf("authorization polling timeout") + return copilotDeviceTokenResponse{}, fmt.Errorf("authorization polling timeout") } fmt.Println("Trying to fetch token...") @@ -198,70 +204,75 @@ func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval i ) req, err := http.NewRequest("POST", copilotAuthTokenURL, data) if err != nil { - return CopilotDeviceTokenResponse{}, err + return copilotDeviceTokenResponse{}, err } req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { - return CopilotDeviceTokenResponse{}, err + return copilotDeviceTokenResponse{}, err } - defer resp.Body.Close() + + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("error closing response body: %w", closeErr) + } + }() isRequestFailed := resp.StatusCode != 200 if isRequestFailed { if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return CopilotDeviceTokenResponse{}, err + return copilotDeviceTokenResponse{}, err } - return CopilotDeviceTokenResponse{}, fmt.Errorf( - "Failed to check refresh token\n\tMessage: %s\n\tDocumentation: %s", + return copilotDeviceTokenResponse{}, fmt.Errorf( + "failed to check refresh token\n\tMessage: %s\n\tDocumentation: %s", errResp.Message, errResp.DocumentationURL, ) } if err := json.NewDecoder(resp.Body).Decode(&accessTokenResp); err != nil { - return CopilotDeviceTokenResponse{}, err + return copilotDeviceTokenResponse{}, err } if accessTokenResp.AccessToken != "" { - // save to the new location - return accessTokenResp, nil } if accessTokenResp.Error != "" { // Handle errors like "authorization_pending" or "expired_token" appropriately if accessTokenResp.Error != "authorization_pending" { - return CopilotDeviceTokenResponse{}, fmt.Errorf("token error: %s", accessTokenResp.Error) + return copilotDeviceTokenResponse{}, fmt.Errorf("token error: %s", accessTokenResp.Error) } } } - return CopilotDeviceTokenResponse{}, fmt.Errorf("authorization polling failed or timed out") + return copilotDeviceTokenResponse{}, fmt.Errorf("authorization polling failed or timed out") } func saveCopilotOAuthToken(oAuthToken copilotOAuthToken, configPath string) error { fileContent, err := json.Marshal(oAuthToken) if err != nil { - return fmt.Errorf("Error mashaling oAuthToken: %e", err) + return fmt.Errorf("error mashaling oAuthToken: %e", err) } configDir := filepath.Dir(configPath) - if err = os.MkdirAll(configDir, os.ModePerm); err != nil { - return fmt.Errorf("Error creating config directory: %e", err) + if err = os.MkdirAll(configDir, 0o600); err != nil { + return fmt.Errorf("error creating config directory: %e", err) } - err = os.WriteFile(configPath, fileContent, os.ModePerm) + err = os.WriteFile(configPath, fileContent, 0o600) + if err != nil { + return fmt.Errorf("error writing oAuthToken to %s: %e", configPath, err) + } versionsPath := filepath.Join(filepath.Dir(configPath), "versions.json") - copilotRegisterApp(versionsPath) - + err = copilotRegisterApp(versionsPath) if err != nil { - return fmt.Errorf("Error writing oAuthToken to %s: %e", configPath, err) + return fmt.Errorf("error registering mods as copilot app %e", err) } return nil @@ -286,7 +297,7 @@ func copilotRegisterApp(versionsPath string) error { return fmt.Errorf("error marshaling versions data: %w", err) } - return os.WriteFile(versionsPath, updatedData, 0644) + return os.WriteFile(versionsPath, updatedData, 0o600) } func getCopilotOAuthToken(client *http.Client) (string, error) { @@ -323,7 +334,7 @@ func getCopilotOAuthToken(client *http.Client) (string, error) { return token, nil } - return "", fmt.Errorf(token) + return "", fmt.Errorf("empty token") } func extractCopilotTokenFromFile(path string) (string, error) { From 9509c3d2682c8bc6bf31eb870bb98b48cf842d07 Mon Sep 17 00:00:00 2001 From: nathabonfim59 Date: Wed, 28 May 2025 03:30:55 -0300 Subject: [PATCH 8/8] refactor(copilot): compatibility with new project structure --- internal/copilot/copilot.go | 121 +++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 58 deletions(-) diff --git a/internal/copilot/copilot.go b/internal/copilot/copilot.go index fc7c4035..5f37daa0 100644 --- a/internal/copilot/copilot.go +++ b/internal/copilot/copilot.go @@ -26,7 +26,7 @@ const ( copilotUserAgent = "curl/7.81.0" // Necessay to bypass the user-agent check // if you change this, don't forget to update the - // `copilotOAuthToken` json struct tag + // `OAuthToken` json struct tag copilotClientID = "Iv1.b507a08c87ecfe98" ) @@ -48,7 +48,7 @@ type AccessToken struct { } `json:"error_details,omitempty"` } -type copilotDeviceCodeResponse struct { +type DeviceCodeResponse struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURI string `json:"verification_uri"` @@ -56,29 +56,30 @@ type copilotDeviceCodeResponse struct { Interval int `json:"interval"` } -type copilotDeviceTokenResponse struct { +type DeviceTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` Error string `json:"error,omitempty"` } -type copilotFailedRequestResponse struct { +type FailedRequestResponse struct { DocumentationURL string `json:"documentation_url"` Message string `json:"message"` } -type copilotGithubOAuthTokenWrapper struct { +type OAuthTokenWrapper struct { User string `json:"user"` OAuthToken string `json:"oauth_token"` GithubAppID string `json:"githubAppId"` } -type copilotOAuthToken struct { - GithubWrapper copilotGithubOAuthTokenWrapper `json:"github.com:Iv1.b507a08c87ecfe98"` +type OAuthToken struct { + GithubWrapper OAuthTokenWrapper `json:"github.com:Iv1.b507a08c87ecfe98"` } -type copilotHTTPClient struct { +// Client copilot client. +type Client struct { client *http.Client cache string AccessToken *AccessToken @@ -120,7 +121,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { return httpResp, nil } -func copilotLogin(client *http.Client, configPath string) (string, error) { +func Login(client *http.Client, configPath string) (string, error) { data := strings.NewReader(fmt.Sprintf("client_id=%s&scope=copilot", copilotClientID)) req, err := http.NewRequest("POST", copilotAuthDeviceCodeURL, data) if err != nil { @@ -145,7 +146,7 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { } }() - deviceCodeResp := copilotDeviceCodeResponse{} + deviceCodeResp := DeviceCodeResponse{} parsedData, err := url.ParseQuery(string(responseBody)) if err != nil { @@ -159,15 +160,15 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { deviceCodeResp.VerificationURI = parsedData.Get("verification_uri") fmt.Printf("Please go to %s and enter the code %s\n", deviceCodeResp.VerificationURI, deviceCodeResp.UserCode) - oAuthToken, err := fetchCopilotRefreshToken(client, deviceCodeResp.DeviceCode, deviceCodeResp.Interval, deviceCodeResp.ExpiresIn) + oAuthToken, err := fetchRefreshToken(client, deviceCodeResp.DeviceCode, deviceCodeResp.Interval, deviceCodeResp.ExpiresIn) if err != nil { return "", err } - err = saveCopilotOAuthToken( - copilotOAuthToken{ - GithubWrapper: copilotGithubOAuthTokenWrapper{ + err = saveOAuthToken( + OAuthToken{ + GithubWrapper: OAuthTokenWrapper{ User: "", OAuthToken: oAuthToken.AccessToken, GithubAppID: copilotClientID, @@ -183,9 +184,9 @@ func copilotLogin(client *http.Client, configPath string) (string, error) { return oAuthToken.AccessToken, nil } -func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval int, expiresIn int) (copilotDeviceTokenResponse, error) { - var accessTokenResp copilotDeviceTokenResponse - var errResp copilotFailedRequestResponse +func fetchRefreshToken(client *http.Client, deviceCode string, interval int, expiresIn int) (DeviceTokenResponse, error) { + var accessTokenResp DeviceTokenResponse + var errResp FailedRequestResponse // Adds a delay to give the user time to open // the browser and type the code @@ -198,7 +199,7 @@ func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval i for range ticker.C { if time.Now().After(endTime) { - return copilotDeviceTokenResponse{}, fmt.Errorf("authorization polling timeout") + return DeviceTokenResponse{}, fmt.Errorf("authorization polling timeout") } fmt.Println("Trying to fetch token...") @@ -211,13 +212,13 @@ func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval i ) req, err := http.NewRequest("POST", copilotAuthTokenURL, data) if err != nil { - return copilotDeviceTokenResponse{}, err + return DeviceTokenResponse{}, err } req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { - return copilotDeviceTokenResponse{}, err + return DeviceTokenResponse{}, err } defer func() { @@ -230,10 +231,10 @@ func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval i if isRequestFailed { if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return copilotDeviceTokenResponse{}, err + return DeviceTokenResponse{}, err } - return copilotDeviceTokenResponse{}, fmt.Errorf( + return DeviceTokenResponse{}, fmt.Errorf( "failed to check refresh token\n\tMessage: %s\n\tDocumentation: %s", errResp.Message, errResp.DocumentationURL, @@ -241,7 +242,7 @@ func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval i } if err := json.NewDecoder(resp.Body).Decode(&accessTokenResp); err != nil { - return copilotDeviceTokenResponse{}, err + return DeviceTokenResponse{}, err } if accessTokenResp.AccessToken != "" { @@ -251,15 +252,41 @@ func fetchCopilotRefreshToken(client *http.Client, deviceCode string, interval i if accessTokenResp.Error != "" { // Handle errors like "authorization_pending" or "expired_token" appropriately if accessTokenResp.Error != "authorization_pending" { - return copilotDeviceTokenResponse{}, fmt.Errorf("token error: %s", accessTokenResp.Error) + return DeviceTokenResponse{}, fmt.Errorf("token error: %s", accessTokenResp.Error) } } } - return copilotDeviceTokenResponse{}, fmt.Errorf("authorization polling failed or timed out") + return DeviceTokenResponse{}, fmt.Errorf("authorization polling failed or timed out") } -func saveCopilotOAuthToken(oAuthToken copilotOAuthToken, configPath string) error { +// Registers `mods` as an application that uses copilot +// NOTE: Only if initial config not available. +// TODO: Add support for when the user already has an oAuthToken +func registerApp(versionsPath string) error { + versions := make(map[string]string) + + data, err := os.ReadFile(versionsPath) + if err == nil { + // File exists, unmarshal contents + if err := json.Unmarshal(data, &versions); err != nil { + return fmt.Errorf("error parsing versions file: %w", err) + } + } + + // Add/update our entry + // TODO: How can we import this? Create a `meta.go`? + //versions["mods"] = main.Version + + updatedData, err := json.Marshal(versions) + if err != nil { + return fmt.Errorf("error marshaling versions data: %w", err) + } + + return os.WriteFile(versionsPath, updatedData, 0o640) +} + +func saveOAuthToken(oAuthToken OAuthToken, configPath string) error { fileContent, err := json.Marshal(oAuthToken) if err != nil { @@ -267,17 +294,17 @@ func saveCopilotOAuthToken(oAuthToken copilotOAuthToken, configPath string) erro } configDir := filepath.Dir(configPath) - if err = os.MkdirAll(configDir, 0o600); err != nil { + if err = os.MkdirAll(configDir, 0o700); err != nil { return fmt.Errorf("error creating config directory: %e", err) } - err = os.WriteFile(configPath, fileContent, 0o600) + err = os.WriteFile(configPath, fileContent, 0o700) if err != nil { return fmt.Errorf("error writing oAuthToken to %s: %e", configPath, err) } versionsPath := filepath.Join(filepath.Dir(configPath), "versions.json") - err = copilotRegisterApp(versionsPath) + err = registerApp(versionsPath) if err != nil { return fmt.Errorf("error registering mods as copilot app %e", err) } @@ -285,29 +312,7 @@ func saveCopilotOAuthToken(oAuthToken copilotOAuthToken, configPath string) erro return nil } -func copilotRegisterApp(versionsPath string) error { - versions := make(map[string]string) - - data, err := os.ReadFile(versionsPath) - if err == nil { - // File exists, unmarshal contents - if err := json.Unmarshal(data, &versions); err != nil { - return fmt.Errorf("error parsing versions file: %w", err) - } - } - - // Add/update our entry - versions["mods"] = Version - - updatedData, err := json.Marshal(versions) - if err != nil { - return fmt.Errorf("error marshaling versions data: %w", err) - } - - return os.WriteFile(versionsPath, updatedData, 0o600) -} - -func getCopilotOAuthToken(client *http.Client) (string, error) { +func getOAuthToken(client *http.Client) (string, error) { configPath := filepath.Join(os.Getenv("HOME"), ".config/github-copilot") if runtime.GOOS == "windows" { configPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot") @@ -325,14 +330,14 @@ func getCopilotOAuthToken(client *http.Client) (string, error) { // Try to get token from config files for _, path := range configFiles { - token, err := extractCopilotTokenFromFile(path) + token, err := extractTokenFromFile(path) if err == nil && token != "" { return token, nil } } // Try to login in into Copilot - token, err := copilotLogin(client, currentConfigPath) + token, err := Login(client, currentConfigPath) if err != nil { return "", fmt.Errorf("failed to login into Copilot: %w", err) } @@ -344,7 +349,7 @@ func getCopilotOAuthToken(client *http.Client) (string, error) { return "", fmt.Errorf("empty token") } -func extractCopilotTokenFromFile(path string) (string, error) { +func extractTokenFromFile(path string) (string, error) { bytes, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("failed to read Copilot configuration file at %s: %w", path, err) @@ -383,9 +388,9 @@ func (c *Client) Auth() (AccessToken, error) { } } - oAuthToken, err := getCopilotOAuthToken(client) + refreshToken, err := getOAuthToken(c.client) if err != nil { - return CopilotAccessToken{}, fmt.Errorf("failed to get oAuth token: %w", err) + return AccessToken{}, fmt.Errorf("failed to get oAuth token: %w", err) } tokenReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, copilotChatAuthURL, nil) @@ -393,7 +398,7 @@ func (c *Client) Auth() (AccessToken, error) { return AccessToken{}, fmt.Errorf("failed to create token request: %w", err) } - tokenReq.Header.Set("Authorization", "token "+oAuthToken) + tokenReq.Header.Set("Authorization", "token "+refreshToken) tokenReq.Header.Set("Accept", "application/json") tokenReq.Header.Set("Editor-Version", copilotEditorVersion) tokenReq.Header.Set("User-Agent", copilotUserAgent)