diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index d0900492..3794793c 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1929,8 +1929,6 @@ func (h *Handler) RequestGitHubToken(c *gin.Context) { state := fmt.Sprintf("gh-%d", time.Now().UnixNano()) // Initialize Copilot auth service - // We need to import "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" first if not present - // Assuming copilot package is imported as "copilot" deviceClient := copilot.NewDeviceFlowClient(h.cfg) // Initiate device flow @@ -1944,7 +1942,7 @@ func (h *Handler) RequestGitHubToken(c *gin.Context) { authURL := deviceCode.VerificationURI userCode := deviceCode.UserCode - RegisterOAuthSession(state, "github") + RegisterOAuthSession(state, "github-copilot") go func() { fmt.Printf("Please visit %s and enter code: %s\n", authURL, userCode) @@ -1956,9 +1954,13 @@ func (h *Handler) RequestGitHubToken(c *gin.Context) { return } - username, errUser := deviceClient.FetchUserInfo(ctx, tokenData.AccessToken) + userInfo, errUser := deviceClient.FetchUserInfo(ctx, tokenData.AccessToken) if errUser != nil { log.Warnf("Failed to fetch user info: %v", errUser) + } + + username := userInfo.Login + if username == "" { username = "github-user" } @@ -1967,18 +1969,26 @@ func (h *Handler) RequestGitHubToken(c *gin.Context) { TokenType: tokenData.TokenType, Scope: tokenData.Scope, Username: username, + Email: userInfo.Email, + Name: userInfo.Name, Type: "github-copilot", } - fileName := fmt.Sprintf("github-%s.json", username) + fileName := fmt.Sprintf("github-copilot-%s.json", username) + label := userInfo.Email + if label == "" { + label = username + } record := &coreauth.Auth{ ID: fileName, - Provider: "github", + Provider: "github-copilot", + Label: label, FileName: fileName, Storage: tokenStorage, Metadata: map[string]any{ - "email": username, + "email": userInfo.Email, "username": username, + "name": userInfo.Name, }, } @@ -1992,7 +2002,7 @@ func (h *Handler) RequestGitHubToken(c *gin.Context) { fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) fmt.Println("You can now use GitHub Copilot services through this CLI") CompleteOAuthSession(state) - CompleteOAuthSessionsByProvider("github") + CompleteOAuthSessionsByProvider("github-copilot") }() c.JSON(200, gin.H{ diff --git a/internal/auth/copilot/copilot_auth.go b/internal/auth/copilot/copilot_auth.go index c40e7082..5776648c 100644 --- a/internal/auth/copilot/copilot_auth.go +++ b/internal/auth/copilot/copilot_auth.go @@ -82,15 +82,21 @@ func (c *CopilotAuth) WaitForAuthorization(ctx context.Context, deviceCode *Devi } // Fetch the GitHub username - username, err := c.deviceClient.FetchUserInfo(ctx, tokenData.AccessToken) + userInfo, err := c.deviceClient.FetchUserInfo(ctx, tokenData.AccessToken) if err != nil { log.Warnf("copilot: failed to fetch user info: %v", err) - username = "unknown" + } + + username := userInfo.Login + if username == "" { + username = "github-user" } return &CopilotAuthBundle{ TokenData: tokenData, Username: username, + Email: userInfo.Email, + Name: userInfo.Name, }, nil } @@ -150,12 +156,12 @@ func (c *CopilotAuth) ValidateToken(ctx context.Context, accessToken string) (bo return false, "", nil } - username, err := c.deviceClient.FetchUserInfo(ctx, accessToken) + userInfo, err := c.deviceClient.FetchUserInfo(ctx, accessToken) if err != nil { return false, "", err } - return true, username, nil + return true, userInfo.Login, nil } // CreateTokenStorage creates a new CopilotTokenStorage from auth bundle. @@ -165,6 +171,8 @@ func (c *CopilotAuth) CreateTokenStorage(bundle *CopilotAuthBundle) *CopilotToke TokenType: bundle.TokenData.TokenType, Scope: bundle.TokenData.Scope, Username: bundle.Username, + Email: bundle.Email, + Name: bundle.Name, Type: "github-copilot", } } diff --git a/internal/auth/copilot/oauth.go b/internal/auth/copilot/oauth.go index d3f46aaa..c2fe52cb 100644 --- a/internal/auth/copilot/oauth.go +++ b/internal/auth/copilot/oauth.go @@ -53,7 +53,7 @@ func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient { func (c *DeviceFlowClient) RequestDeviceCode(ctx context.Context) (*DeviceCodeResponse, error) { data := url.Values{} data.Set("client_id", copilotClientID) - data.Set("scope", "user:email") + data.Set("scope", "read:user user:email") req, err := http.NewRequestWithContext(ctx, http.MethodPost, copilotDeviceCodeURL, strings.NewReader(data.Encode())) if err != nil { @@ -211,15 +211,25 @@ func (c *DeviceFlowClient) exchangeDeviceCode(ctx context.Context, deviceCode st }, nil } -// FetchUserInfo retrieves the GitHub username for the authenticated user. -func (c *DeviceFlowClient) FetchUserInfo(ctx context.Context, accessToken string) (string, error) { +// GitHubUserInfo holds GitHub user profile information. +type GitHubUserInfo struct { + // Login is the GitHub username. + Login string + // Email is the primary email address (may be empty if not public). + Email string + // Name is the display name. + Name string +} + +// FetchUserInfo retrieves the GitHub user profile for the authenticated user. +func (c *DeviceFlowClient) FetchUserInfo(ctx context.Context, accessToken string) (GitHubUserInfo, error) { if accessToken == "" { - return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("access token is empty")) + return GitHubUserInfo{}, NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("access token is empty")) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotUserInfoURL, nil) if err != nil { - return "", NewAuthenticationError(ErrUserInfoFailed, err) + return GitHubUserInfo{}, NewAuthenticationError(ErrUserInfoFailed, err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") @@ -227,7 +237,7 @@ func (c *DeviceFlowClient) FetchUserInfo(ctx context.Context, accessToken string resp, err := c.httpClient.Do(req) if err != nil { - return "", NewAuthenticationError(ErrUserInfoFailed, err) + return GitHubUserInfo{}, NewAuthenticationError(ErrUserInfoFailed, err) } defer func() { if errClose := resp.Body.Close(); errClose != nil { @@ -237,19 +247,25 @@ func (c *DeviceFlowClient) FetchUserInfo(ctx context.Context, accessToken string if !isHTTPSuccess(resp.StatusCode) { bodyBytes, _ := io.ReadAll(resp.Body) - return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes))) + return GitHubUserInfo{}, NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("status %d: %s", resp.StatusCode, string(bodyBytes))) } - var userInfo struct { + var raw struct { Login string `json:"login"` + Email string `json:"email"` + Name string `json:"name"` } - if err = json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { - return "", NewAuthenticationError(ErrUserInfoFailed, err) + if err = json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return GitHubUserInfo{}, NewAuthenticationError(ErrUserInfoFailed, err) } - if userInfo.Login == "" { - return "", NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("empty username")) + if raw.Login == "" { + return GitHubUserInfo{}, NewAuthenticationError(ErrUserInfoFailed, fmt.Errorf("empty username")) } - return userInfo.Login, nil + return GitHubUserInfo{ + Login: raw.Login, + Email: raw.Email, + Name: raw.Name, + }, nil } diff --git a/internal/auth/copilot/oauth_test.go b/internal/auth/copilot/oauth_test.go new file mode 100644 index 00000000..3311b4f8 --- /dev/null +++ b/internal/auth/copilot/oauth_test.go @@ -0,0 +1,213 @@ +package copilot + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// roundTripFunc lets us inject a custom transport for testing. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// newTestClient returns an *http.Client whose requests are redirected to the given test server, +// regardless of the original URL host. +func newTestClient(srv *httptest.Server) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + req2.URL.Scheme = "http" + req2.URL.Host = strings.TrimPrefix(srv.URL, "http://") + return srv.Client().Transport.RoundTrip(req2) + }), + } +} + +// TestFetchUserInfo_FullProfile verifies that FetchUserInfo returns login, email, and name. +func TestFetchUserInfo_FullProfile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "login": "octocat", + "email": "octocat@github.com", + "name": "The Octocat", + }) + })) + defer srv.Close() + + client := &DeviceFlowClient{httpClient: newTestClient(srv)} + info, err := client.FetchUserInfo(context.Background(), "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Login != "octocat" { + t.Errorf("Login: got %q, want %q", info.Login, "octocat") + } + if info.Email != "octocat@github.com" { + t.Errorf("Email: got %q, want %q", info.Email, "octocat@github.com") + } + if info.Name != "The Octocat" { + t.Errorf("Name: got %q, want %q", info.Name, "The Octocat") + } +} + +// TestFetchUserInfo_EmptyEmail verifies graceful handling when email is absent (private account). +func TestFetchUserInfo_EmptyEmail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // GitHub returns null for private emails. + _, _ = w.Write([]byte(`{"login":"privateuser","email":null,"name":"Private User"}`)) + })) + defer srv.Close() + + client := &DeviceFlowClient{httpClient: newTestClient(srv)} + info, err := client.FetchUserInfo(context.Background(), "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Login != "privateuser" { + t.Errorf("Login: got %q, want %q", info.Login, "privateuser") + } + if info.Email != "" { + t.Errorf("Email: got %q, want empty string", info.Email) + } + if info.Name != "Private User" { + t.Errorf("Name: got %q, want %q", info.Name, "Private User") + } +} + +// TestFetchUserInfo_EmptyToken verifies error is returned for empty access token. +func TestFetchUserInfo_EmptyToken(t *testing.T) { + client := &DeviceFlowClient{httpClient: http.DefaultClient} + _, err := client.FetchUserInfo(context.Background(), "") + if err == nil { + t.Fatal("expected error for empty token, got nil") + } +} + +// TestFetchUserInfo_EmptyLogin verifies error is returned when API returns no login. +func TestFetchUserInfo_EmptyLogin(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"email":"someone@example.com","name":"No Login"}`)) + })) + defer srv.Close() + + client := &DeviceFlowClient{httpClient: newTestClient(srv)} + _, err := client.FetchUserInfo(context.Background(), "test-token") + if err == nil { + t.Fatal("expected error for empty login, got nil") + } +} + +// TestFetchUserInfo_HTTPError verifies error is returned on non-2xx response. +func TestFetchUserInfo_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + })) + defer srv.Close() + + client := &DeviceFlowClient{httpClient: newTestClient(srv)} + _, err := client.FetchUserInfo(context.Background(), "bad-token") + if err == nil { + t.Fatal("expected error for 401 response, got nil") + } +} + +// TestCopilotTokenStorage_EmailNameFields verifies Email and Name serialise correctly. +func TestCopilotTokenStorage_EmailNameFields(t *testing.T) { + ts := &CopilotTokenStorage{ + AccessToken: "ghu_abc", + TokenType: "bearer", + Scope: "read:user user:email", + Username: "octocat", + Email: "octocat@github.com", + Name: "The Octocat", + Type: "github-copilot", + } + + data, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var out map[string]any + if err = json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + for _, key := range []string{"access_token", "username", "email", "name", "type"} { + if _, ok := out[key]; !ok { + t.Errorf("expected key %q in JSON output, not found", key) + } + } + if out["email"] != "octocat@github.com" { + t.Errorf("email: got %v, want %q", out["email"], "octocat@github.com") + } + if out["name"] != "The Octocat" { + t.Errorf("name: got %v, want %q", out["name"], "The Octocat") + } +} + +// TestCopilotTokenStorage_OmitEmptyEmailName verifies email/name are omitted when empty (omitempty). +func TestCopilotTokenStorage_OmitEmptyEmailName(t *testing.T) { + ts := &CopilotTokenStorage{ + AccessToken: "ghu_abc", + Username: "octocat", + Type: "github-copilot", + } + + data, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var out map[string]any + if err = json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if _, ok := out["email"]; ok { + t.Error("email key should be omitted when empty (omitempty), but was present") + } + if _, ok := out["name"]; ok { + t.Error("name key should be omitted when empty (omitempty), but was present") + } +} + +// TestCopilotAuthBundle_EmailNameFields verifies bundle carries email and name through the pipeline. +func TestCopilotAuthBundle_EmailNameFields(t *testing.T) { + bundle := &CopilotAuthBundle{ + TokenData: &CopilotTokenData{AccessToken: "ghu_abc"}, + Username: "octocat", + Email: "octocat@github.com", + Name: "The Octocat", + } + if bundle.Email != "octocat@github.com" { + t.Errorf("bundle.Email: got %q, want %q", bundle.Email, "octocat@github.com") + } + if bundle.Name != "The Octocat" { + t.Errorf("bundle.Name: got %q, want %q", bundle.Name, "The Octocat") + } +} + +// TestGitHubUserInfo_Struct verifies the exported GitHubUserInfo struct fields are accessible. +func TestGitHubUserInfo_Struct(t *testing.T) { + info := GitHubUserInfo{ + Login: "octocat", + Email: "octocat@github.com", + Name: "The Octocat", + } + if info.Login == "" || info.Email == "" || info.Name == "" { + t.Error("GitHubUserInfo fields should not be empty") + } +} diff --git a/internal/auth/copilot/token.go b/internal/auth/copilot/token.go index 4e5eed6c..aa7ea949 100644 --- a/internal/auth/copilot/token.go +++ b/internal/auth/copilot/token.go @@ -26,6 +26,10 @@ type CopilotTokenStorage struct { ExpiresAt string `json:"expires_at,omitempty"` // Username is the GitHub username associated with this token. Username string `json:"username"` + // Email is the GitHub email address associated with this token. + Email string `json:"email,omitempty"` + // Name is the GitHub display name associated with this token. + Name string `json:"name,omitempty"` // Type indicates the authentication provider type, always "github-copilot" for this storage. Type string `json:"type"` } @@ -46,6 +50,10 @@ type CopilotAuthBundle struct { TokenData *CopilotTokenData // Username is the GitHub username. Username string + // Email is the GitHub email address. + Email string + // Name is the GitHub display name. + Name string } // DeviceCodeResponse represents GitHub's device code response. diff --git a/sdk/auth/github_copilot.go b/sdk/auth/github_copilot.go index 1d14ac47..c2d2f14e 100644 --- a/sdk/auth/github_copilot.go +++ b/sdk/auth/github_copilot.go @@ -86,6 +86,8 @@ func (a GitHubCopilotAuthenticator) Login(ctx context.Context, cfg *config.Confi metadata := map[string]any{ "type": "github-copilot", "username": authBundle.Username, + "email": authBundle.Email, + "name": authBundle.Name, "access_token": authBundle.TokenData.AccessToken, "token_type": authBundle.TokenData.TokenType, "scope": authBundle.TokenData.Scope, @@ -98,13 +100,18 @@ func (a GitHubCopilotAuthenticator) Login(ctx context.Context, cfg *config.Confi fileName := fmt.Sprintf("github-copilot-%s.json", authBundle.Username) + label := authBundle.Email + if label == "" { + label = authBundle.Username + } + fmt.Printf("\nGitHub Copilot authentication successful for user: %s\n", authBundle.Username) return &coreauth.Auth{ ID: fileName, Provider: a.Provider(), FileName: fileName, - Label: authBundle.Username, + Label: label, Storage: tokenStorage, Metadata: metadata, }, nil