diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf9ceaf..e8d9b41 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -178,6 +178,25 @@ Repository Resolution must occur before publication. --- +# Authentication Resolution + +Responsibilities: + +* initiate device flow login +* obtain user access token +* persist token locally +* load token for publishing and diagnostics + +Outputs: + +```text +GitHub App User Token +``` + +Authentication Resolution is not responsible for issue creation or repository selection. + +--- + # Issue Construction Responsibilities: diff --git a/FRAMEWORK.md b/FRAMEWORK.md index 3a5a7f6..f78d7b9 100644 --- a/FRAMEWORK.md +++ b/FRAMEWORK.md @@ -103,6 +103,8 @@ Never: * commit secrets * store secrets in repository files +GitHub App device flow tokens must be stored locally with restricted file permissions. + --- # Markdown Processing diff --git a/PRODUCT_SPEC.md b/PRODUCT_SPEC.md index 9e18776..a4b0276 100644 --- a/PRODUCT_SPEC.md +++ b/PRODUCT_SPEC.md @@ -155,6 +155,12 @@ The product shall provide a diagnostic command that validates publishing prerequ --- +## FR-011 + +The product shall support GitHub App login through device flow authentication. + +--- + # Issue Construction Rules ## Title Rule @@ -195,6 +201,16 @@ Publisher information must be preserved as part of the generated issue. --- +# Authentication + +The product uses a GitHub App user access token obtained through device flow authentication. + +The user authenticates with the `ai-issue login` command. + +The token is stored locally and reused for publishing and diagnostics. + +--- + # User Flow ## Publish Issue diff --git a/TASKS.md b/TASKS.md index a784286..d44de07 100644 --- a/TASKS.md +++ b/TASKS.md @@ -50,6 +50,20 @@ Acceptance Criteria: --- +### Authentication + +* [ ] Implement GitHub App device flow login +* [ ] Store and load GitHub App token locally +* [ ] Use stored token for publishing and diagnostics + +Acceptance Criteria: + +* `ai-issue login` obtains a GitHub App user access token +* Token is persisted locally +* Publishing works without manual PAT setup + +--- + ### Issue Construction * [X] Construct publishable issue diff --git a/internal/adapter/github/client.go b/internal/adapter/github/client.go index ec82c8e..6df425d 100644 --- a/internal/adapter/github/client.go +++ b/internal/adapter/github/client.go @@ -6,9 +6,10 @@ import ( "fmt" "io" "net/http" - "os" + "net/url" "strings" + "github.com/replworks/ai-issue/internal/config" "github.com/replworks/ai-issue/internal/extraction" ) @@ -18,14 +19,36 @@ type Client struct { BaseURL string } +var ( + deviceCodeEndpoint = "https://github.com/login/device/code" + accessTokenEndpoint = "https://github.com/login/oauth/access_token" +) + +func SetDeviceFlowEndpointsForTest(device, access string) { + deviceCodeEndpoint = device + accessTokenEndpoint = access +} + +func RestoreDeviceFlowEndpoints(device, access string) { + deviceCodeEndpoint = device + accessTokenEndpoint = access +} + +func DeviceFlowEndpointsForTest() (string, string) { + return deviceCodeEndpoint, accessTokenEndpoint +} + type APIError struct { Message string `json:"message"` } func NewClient() (*Client, error) { - token := os.Getenv("GITHUB_TOKEN") + token, err := config.LoadToken() + if err != nil { + return nil, extraction.NewError("auth", err.Error()) + } if token == "" { - return nil, extraction.NewError("auth", "GITHUB_TOKEN environment variable is required. Set it with your publisher token.") + return nil, extraction.NewError("auth", "GitHub App token is required. Run `ai-issue login` first.") } return &Client{ Token: token, @@ -34,6 +57,93 @@ func NewClient() (*Client, error) { }, nil } +type DeviceCodeResponse struct { + DeviceCode string + UserCode string + VerificationURI string + ExpiresIn int + Interval int +} + +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error"` +} + +func RequestDeviceCode(clientID string) (*DeviceCodeResponse, error) { + form := url.Values{} + form.Set("client_id", clientID) + + req, err := http.NewRequest(http.MethodPost, deviceCodeEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create device code request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request device code: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("device code request failed: %s", githubErrorMessage(body)) + } + + var decoded 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"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, fmt.Errorf("failed to decode device code response: %w", err) + } + return &DeviceCodeResponse{ + DeviceCode: decoded.DeviceCode, + UserCode: decoded.UserCode, + VerificationURI: decoded.VerificationURI, + ExpiresIn: decoded.ExpiresIn, + Interval: decoded.Interval, + }, nil +} + +func ExchangeDeviceCode(clientID, deviceCode string) (*AccessTokenResponse, error) { + form := url.Values{} + form.Set("client_id", clientID) + form.Set("device_code", deviceCode) + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + req, err := http.NewRequest(http.MethodPost, accessTokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create access token request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to exchange device code: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("access token request failed: %s", githubErrorMessage(body)) + } + + var decoded AccessTokenResponse + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, fmt.Errorf("failed to decode access token response: %w", err) + } + return &decoded, nil +} + func (c *Client) CreateIssue(repo, title, body string, labels []string) (string, error) { if c == nil { return "", fmt.Errorf("GitHub client is not initialized") @@ -64,7 +174,7 @@ func (c *Client) CreateIssue(repo, title, body string, labels []string) (string, if err != nil { return "", fmt.Errorf("failed to create GitHub issue request: %w", err) } - req.Header.Set("Authorization", "token "+c.Token) + req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("Content-Type", "application/json") @@ -102,7 +212,7 @@ func (c *Client) CheckRepositoryAccess(repo string) error { if err != nil { return fmt.Errorf("failed to create GitHub repository access request: %w", err) } - req.Header.Set("Authorization", "token "+c.Token) + req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Accept", "application/vnd.github+json") resp, err := c.HTTPClient.Do(req) @@ -118,6 +228,8 @@ func (c *Client) CheckRepositoryAccess(repo string) error { return &RepositoryAccessError{ Status: http.StatusText(resp.StatusCode), Message: githubErrorMessage(body), + Headers: resp.Header.Clone(), + RawBody: strings.TrimSpace(string(body)), } } @@ -127,6 +239,8 @@ func (c *Client) CheckRepositoryAccess(repo string) error { type RepositoryAccessError struct { Status string Message string + Headers http.Header + RawBody string } func (e *RepositoryAccessError) Error() string { diff --git a/internal/adapter/github/client_test.go b/internal/adapter/github/client_test.go index 76bec13..464f297 100644 --- a/internal/adapter/github/client_test.go +++ b/internal/adapter/github/client_test.go @@ -50,8 +50,8 @@ func TestCreateIssueSuccess(t *testing.T) { if gotPath != "/repos/company/backend/issues" { t.Fatalf("path = %q, want %q", gotPath, "/repos/company/backend/issues") } - if gotAuth != "token token-123" { - t.Fatalf("auth = %q, want %q", gotAuth, "token token-123") + if gotAuth != "Bearer token-123" { + t.Fatalf("auth = %q, want %q", gotAuth, "Bearer token-123") } if len(gotLabels) != 1 || gotLabels[0] != "ai-generated" { t.Fatalf("labels = %v, want [ai-generated]", gotLabels) @@ -110,8 +110,8 @@ func TestCheckRepositoryAccessSuccess(t *testing.T) { if gotPath != "/repos/company/backend" { t.Fatalf("path = %q, want %q", gotPath, "/repos/company/backend") } - if gotAuth != "token token-123" { - t.Fatalf("auth = %q, want %q", gotAuth, "token token-123") + if gotAuth != "Bearer token-123" { + t.Fatalf("auth = %q, want %q", gotAuth, "Bearer token-123") } if gotAccept != "application/vnd.github+json" { t.Fatalf("accept = %q, want %q", gotAccept, "application/vnd.github+json") @@ -146,4 +146,10 @@ func TestCheckRepositoryAccessFailureParsesMessage(t *testing.T) { if accessErr.Message != "fine-grained tokens are not allowed for this org" { t.Fatalf("message = %q, want %q", accessErr.Message, "fine-grained tokens are not allowed for this org") } + if accessErr.RawBody == "" { + t.Fatal("expected raw body to be preserved") + } + if accessErr.Headers == nil { + t.Fatal("expected response headers to be preserved") + } } diff --git a/internal/adapter/github/device_flow_test.go b/internal/adapter/github/device_flow_test.go new file mode 100644 index 0000000..ccd4205 --- /dev/null +++ b/internal/adapter/github/device_flow_test.go @@ -0,0 +1,60 @@ +package github + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRequestAndExchangeDeviceCode(t *testing.T) { + oldDevice := deviceCodeEndpoint + oldAccess := accessTokenEndpoint + defer func() { + deviceCodeEndpoint = oldDevice + accessTokenEndpoint = oldAccess + }() + + var gotDevicePath string + var gotAccessPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login/device/code": + gotDevicePath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"device_code":"device-123","user_code":"WDJB-MJHT","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}`)) + case "/login/oauth/access_token": + gotAccessPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"token-123","token_type":"bearer","scope":""}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + deviceCodeEndpoint = server.URL + "/login/device/code" + accessTokenEndpoint = server.URL + "/login/oauth/access_token" + + device, err := RequestDeviceCode("client-123") + if err != nil { + t.Fatalf("RequestDeviceCode error: %v", err) + } + if device.DeviceCode != "device-123" || device.UserCode != "WDJB-MJHT" { + t.Fatalf("unexpected device response: %#v", device) + } + if gotDevicePath != "/login/device/code" { + t.Fatalf("gotDevicePath = %q", gotDevicePath) + } + + token, err := ExchangeDeviceCode("client-123", device.DeviceCode) + if err != nil { + t.Fatalf("ExchangeDeviceCode error: %v", err) + } + if token.AccessToken != "token-123" { + t.Fatalf("token = %#v", token) + } + if gotAccessPath != "/login/oauth/access_token" { + t.Fatalf("gotAccessPath = %q", gotAccessPath) + } +} diff --git a/internal/cli/diagnose.go b/internal/cli/diagnose.go index a385f90..bda39df 100644 --- a/internal/cli/diagnose.go +++ b/internal/cli/diagnose.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "os" "os/exec" "strings" @@ -52,22 +51,28 @@ var diagnoseCmd = &cobra.Command{ // Clipboard (simple check) fmt.Println("✅ Clipboard support: Available") - // GITHUB_TOKEN - if os.Getenv("GITHUB_TOKEN") != "" { - fmt.Println("✅ GITHUB_TOKEN: Set") - } else { - fmt.Println("⚠️ GITHUB_TOKEN: Not set (required for publishing)") + // GitHub App token + ghClient, err := github.NewClient() + if err != nil { + fmt.Printf("❌ GitHub App token: %v\n", err) failed = true + } else { + fmt.Println("✅ GitHub App token: Loaded") } - if os.Getenv("GITHUB_TOKEN") != "" && repo != "" { - ghClient, err := github.NewClient() - if err != nil { - fmt.Printf("\n❌ Repository access: Forbidden\n\nReason:\n%s\n", err) - failed = true - } else if err := ghClient.CheckRepositoryAccess(repo); err != nil { - if accessErr, ok := err.(*github.RepositoryAccessError); ok && accessErr.Message != "" { - fmt.Printf("\n❌ Repository access: Forbidden\n\nReason:\n%s\n", accessErr.Message) + if err == nil && repo != "" { + if err := ghClient.CheckRepositoryAccess(repo); err != nil { + if accessErr, ok := err.(*github.RepositoryAccessError); ok { + fmt.Printf("\n❌ Repository access: Forbidden\n") + if accessErr.Message != "" { + fmt.Printf("\nReason:\n%s\n", accessErr.Message) + } + if sso := accessErr.Headers.Get("X-GitHub-SSO"); sso != "" { + fmt.Printf("\nX-GitHub-SSO:\n%s\n", sso) + } + if accessErr.RawBody != "" { + fmt.Printf("\nRaw response:\n%s\n", accessErr.RawBody) + } } else { fmt.Printf("\n❌ Repository access: Forbidden\n\nReason:\n%s\n", err) } diff --git a/internal/cli/login.go b/internal/cli/login.go new file mode 100644 index 0000000..3eae0ea --- /dev/null +++ b/internal/cli/login.go @@ -0,0 +1,93 @@ +package cli + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/replworks/ai-issue/internal/adapter/github" + "github.com/replworks/ai-issue/internal/config" +) + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate with GitHub App device flow", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runLogin(cmd.Context()) + }, +} + +func runLogin(ctx context.Context) error { + clientID := config.GitHubAppClientID() + device, err := github.RequestDeviceCode(clientID) + if err != nil { + return err + } + + fmt.Println("Open this URL in your browser:") + fmt.Println(device.VerificationURI) + fmt.Printf("Enter this code: %s\n", device.UserCode) + if err := openBrowser(device.VerificationURI); err == nil { + fmt.Println("Browser opened automatically.") + } else { + fmt.Printf("Could not open browser automatically: %v\n", err) + } + + interval := time.Duration(device.Interval) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + deadline := time.Now().Add(time.Duration(device.ExpiresIn) * time.Second) + if device.ExpiresIn <= 0 { + deadline = time.Now().Add(15 * time.Minute) + } + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + } + + token, err := github.ExchangeDeviceCode(clientID, device.DeviceCode) + if err != nil { + if strings.Contains(err.Error(), "authorization_pending") || strings.Contains(err.Error(), "slow_down") { + continue + } + if strings.Contains(err.Error(), "access_denied") { + return fmt.Errorf("login was denied in GitHub") + } + if strings.Contains(err.Error(), "expired_token") { + return fmt.Errorf("device code expired. Run `ai-issue login` again") + } + return err + } + if strings.TrimSpace(token.AccessToken) == "" { + continue + } + if err := config.SaveToken(token.AccessToken); err != nil { + return err + } + fmt.Println("Login successful. Token saved locally.") + return nil + } + + return fmt.Errorf("device code expired. Run `ai-issue login` again") +} + +func openBrowser(target string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", target).Run() + case "windows": + return exec.Command("cmd", "/c", "start", "", target).Run() + default: + return exec.Command("xdg-open", target).Run() + } +} diff --git a/internal/cli/login_test.go b/internal/cli/login_test.go new file mode 100644 index 0000000..c142313 --- /dev/null +++ b/internal/cli/login_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/replworks/ai-issue/internal/adapter/github" + "github.com/replworks/ai-issue/internal/config" +) + +func TestRunLoginSavesToken(t *testing.T) { + oldDevice, oldAccess := github.DeviceFlowEndpointsForTest() + defer github.RestoreDeviceFlowEndpoints(oldDevice, oldAccess) + + deviceCalls := 0 + accessCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login/device/code": + deviceCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"device_code":"device-123","user_code":"WDJB-MJHT","verification_uri":"https://github.com/login/device","expires_in":900,"interval":1}`)) + case "/login/oauth/access_token": + accessCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"token-123","token_type":"bearer","scope":""}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + github.SetDeviceFlowEndpointsForTest(server.URL+"/login/device/code", server.URL+"/login/oauth/access_token") + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + if err := runLogin(t.Context()); err != nil { + t.Fatalf("runLogin error: %v", err) + } + + if deviceCalls != 1 { + t.Fatalf("deviceCalls = %d, want 1", deviceCalls) + } + if accessCalls == 0 { + t.Fatal("expected access token polling") + } + + token, err := config.LoadToken() + if err != nil { + t.Fatalf("LoadToken error: %v", err) + } + if token != "token-123" { + t.Fatalf("token = %q, want %q", token, "token-123") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f044936..baf4f09 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -40,4 +40,5 @@ func init() { rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Display the issue preview without creating a GitHub Issue") rootCmd.AddCommand(publishCmd) rootCmd.AddCommand(diagnoseCmd) + rootCmd.AddCommand(loginCmd) } diff --git a/internal/config/config.go b/internal/config/config.go index 4c701f5..1309874 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,16 +1,72 @@ package config import ( + "errors" + "fmt" "os" + "path/filepath" "strings" ) const defaultPublisher = "ai-backlog-bot" +const defaultGitHubAppClientID = "Iv23lisWVphLHQXlTQql" func PublisherIdentity() string { return normalizePublisher(os.Getenv("AI_ISSUE_PUBLISHER")) } +func GitHubAppClientID() string { + if v := strings.TrimSpace(os.Getenv("AI_ISSUE_GITHUB_APP_CLIENT_ID")); v != "" { + return v + } + return defaultGitHubAppClientID +} + +func TokenPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to determine config directory: %w", err) + } + return filepath.Join(dir, "ai-issue", "token"), nil +} + +func LoadToken() (string, error) { + path, err := TokenPath() + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("GitHub App token not found. Run `ai-issue login` first") + } + return "", fmt.Errorf("failed to read GitHub App token: %w", err) + } + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("GitHub App token not found. Run `ai-issue login` first") + } + return token, nil +} + +func SaveToken(token string) error { + token = strings.TrimSpace(token) + if token == "" { + return fmt.Errorf("token is required") + } + path, err := TokenPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { + return fmt.Errorf("failed to store GitHub App token: %w", err) + } + return nil +} + func normalizePublisher(value string) string { value = strings.TrimSpace(value) if value == "" { diff --git a/internal/config/token_test.go b/internal/config/token_test.go new file mode 100644 index 0000000..294b905 --- /dev/null +++ b/internal/config/token_test.go @@ -0,0 +1,35 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveAndLoadToken(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + if err := SaveToken("token-123"); err != nil { + t.Fatalf("SaveToken error: %v", err) + } + + got, err := LoadToken() + if err != nil { + t.Fatalf("LoadToken error: %v", err) + } + if got != "token-123" { + t.Fatalf("LoadToken = %q, want %q", got, "token-123") + } + + path, err := TokenPath() + if err != nil { + t.Fatalf("TokenPath error: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("token file missing: %v", err) + } + if filepath.Dir(path) == "" { + t.Fatal("expected token path") + } +}