diff --git a/github.go b/github.go new file mode 100644 index 0000000..4ede6b6 --- /dev/null +++ b/github.go @@ -0,0 +1,215 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "time" +) + +// GitHub App user authorization: the browser is sent to GitHub, the callback exchanges the +// code for a user token held in memory against the session, and clones use that token. +type githubApp struct { + clientID, clientSecret string + slug string // optional; enables the "grant access to more repositories" link + webURL, apiURL string // github.com endpoints, overridden in tests +} + +type githubAuth struct { + token string + login string + expires time.Time + state string + stateExpires time.Time +} + +var githubClient = &http.Client{Timeout: 15 * time.Second} + +func githubAppFromEnv() githubApp { + return githubApp{ + clientID: os.Getenv("GITHUB_CLIENT_ID"), + clientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + slug: os.Getenv("GITHUB_APP_SLUG"), + webURL: "https://github.com", + apiURL: "https://api.github.com", + } +} + +func (g githubApp) configured() bool { return g.clientID != "" && g.clientSecret != "" } + +func (g githubApp) installURL() string { + if g.slug == "" { + return "" + } + return g.webURL + "/apps/" + url.PathEscape(g.slug) + "/installations/new" +} + +func (s *Server) githubRoute(w http.ResponseWriter, r *http.Request, action string) { + switch { + case action == "status" && r.Method == http.MethodGet: + auth := s.githubAuthFor(sessionID(nil, r)) + writeJSON(w, 200, map[string]any{ + "configured": s.github.configured(), + "connected": auth.token != "", + "login": auth.login, + "installUrl": s.github.installURL(), + }) + case !s.github.configured(): + writeError(w, 404, "GitHub sign-in is not configured") + case action == "login" && r.Method == http.MethodGet: + sid := sessionID(w, r) + state := rand.Text() + s.authMu.Lock() + auth := s.auth[sid] + if auth == nil { + auth = &githubAuth{} + s.auth[sid] = auth + } + auth.state, auth.stateExpires = state, time.Now().Add(10*time.Minute) + s.authMu.Unlock() + query := url.Values{"client_id": {s.github.clientID}, "state": {state}} + http.Redirect(w, r, s.github.webURL+"/login/oauth/authorize?"+query.Encode(), http.StatusFound) + case action == "callback" && r.Method == http.MethodGet: + http.Redirect(w, r, "/?github="+s.githubCallback(r), http.StatusFound) + case action == "logout" && r.Method == http.MethodPost: + s.authMu.Lock() + delete(s.auth, sessionID(nil, r)) + s.authMu.Unlock() + writeJSON(w, 200, map[string]bool{"ok": true}) + default: + writeError(w, 404, "Unknown GitHub route") + } +} + +// githubCallback completes sign-in and returns the outcome shown to the frontend. +func (s *Server) githubCallback(r *http.Request) string { + sid, query := sessionID(nil, r), r.URL.Query() + s.authMu.Lock() + auth := s.auth[sid] + valid := auth != nil && auth.state != "" && time.Now().Before(auth.stateExpires) && + subtle.ConstantTimeCompare([]byte(auth.state), []byte(query.Get("state"))) == 1 + if auth != nil { + auth.state = "" // single use + } + s.authMu.Unlock() + if !valid { + return "error" + } + if query.Get("code") == "" { + return "denied" + } + + token, lifetime, err := s.github.exchangeCode(query.Get("code")) + if err != nil { + return "error" + } + login, err := s.github.userLogin(token) + if err != nil { + return "error" + } + s.authMu.Lock() + s.auth[sid] = &githubAuth{token: token, login: login, expires: time.Now().Add(lifetime)} + s.authMu.Unlock() + return "connected" +} + +func (g githubApp) exchangeCode(code string) (string, time.Duration, error) { + form := url.Values{"client_id": {g.clientID}, "client_secret": {g.clientSecret}, "code": {code}} + req, _ := http.NewRequest("POST", g.webURL+"/login/oauth/access_token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + res, err := githubClient.Do(req) + if err != nil { + return "", 0, err + } + defer res.Body.Close() + var body struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + } + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + return "", 0, err + } + if body.AccessToken == "" { + return "", 0, errors.New("token exchange failed: " + body.Error) + } + lifetime := time.Duration(body.ExpiresIn) * time.Second + if lifetime <= 0 { + lifetime = 8 * time.Hour // apps with token expiry disabled; still forget it eventually + } + return body.AccessToken, lifetime, nil +} + +func (g githubApp) userLogin(token string) (string, error) { + req, _ := http.NewRequest("GET", g.apiURL+"/user", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + res, err := githubClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + var body struct{ Login string } + if res.StatusCode != 200 || json.NewDecoder(res.Body).Decode(&body) != nil || body.Login == "" { + return "", errors.New("could not read GitHub user") + } + return body.Login, nil +} + +// githubAuthFor returns a copy of the session's GitHub sign-in; token is "" when absent or expired. +func (s *Server) githubAuthFor(sid string) githubAuth { + s.authMu.Lock() + defer s.authMu.Unlock() + auth := s.auth[sid] + if sid == "" || auth == nil || auth.token == "" || time.Now().After(auth.expires) { + return githubAuth{} + } + return *auth +} + +// gitClone builds the clone command. The token travels in environment-provided git config, +// never in the URL or arguments, so it cannot leak through process lists or error output. +func gitClone(repoURL, dest, token string) *exec.Cmd { + cmd := exec.Command("git", "clone", "--depth=1", "--single-branch", "--quiet", "--", repoURL, dest) + // Ignore the host's git config and credential helpers: only the visitor's own token may + // ever authenticate a clone. + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=") + if token != "" { + basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token)) + cmd.Env = append(cmd.Env, + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_1=http.https://github.com/.extraHeader", + "GIT_CONFIG_VALUE_1=Authorization: Basic "+basic) + } + return cmd +} + +// cloneFailure turns git output into a user-facing message and whether signing in (or +// granting the app access) could fix it. +func (g githubApp) cloneFailure(output string, signedIn bool) (message string, authRequired string) { + lower := strings.ToLower(output) + needsAuth := false + for _, hint := range []string{"could not read username", "authentication failed", "repository not found", "terminal prompts disabled", "returned error: 403", "returned error: 401"} { + needsAuth = needsAuth || strings.Contains(lower, hint) + } + switch { + case !needsAuth: + return "git clone failed: " + strings.TrimSpace(output), "" + case !g.configured(): + return "Repository not found. It may be private; load it with Open local folder instead.", "" + case !signedIn: + return "This repository is private or does not exist. Sign in with GitHub to open private repositories.", "signin" + default: + return "Your GitHub account cannot open this repository through CodeNavigator. Grant the app access to it, then try again.", "install" + } +} diff --git a/github_test.go b/github_test.go new file mode 100644 index 0000000..ca9142b --- /dev/null +++ b/github_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os/exec" + "strings" + "testing" + "time" +) + +func fakeGitHub(t *testing.T) *httptest.Server { + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login/oauth/access_token": + r.ParseForm() + if r.Form.Get("client_secret") != "secret" || r.Form.Get("code") != "good" { + json.NewEncoder(w).Encode(map[string]string{"error": "bad_verification_code"}) + return + } + json.NewEncoder(w).Encode(map[string]any{"access_token": "ghu_token", "expires_in": 28800}) + case "/user": + if r.Header.Get("Authorization") != "Bearer ghu_token" { + w.WriteHeader(401) + return + } + json.NewEncoder(w).Encode(map[string]string{"login": "octocat"}) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(gh.Close) + return gh +} + +func (c client) noRedirects() client { + jarClient := *c.http + jarClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return client{c.t, c.url, &jarClient} +} + +func (c client) location(path string) *url.URL { + c.t.Helper() + res, err := c.http.Get(c.url + path) + if err != nil { + c.t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusFound { + c.t.Fatalf("GET %s = %d, want redirect", path, res.StatusCode) + } + loc, _ := url.Parse(res.Header.Get("Location")) + return loc +} + +type githubStatus struct { + Configured, Connected bool + Login, InstallURL string +} + +func TestGitHubSignIn(t *testing.T) { + server, c := newTestServer(t) + c = c.noRedirects() + gh := fakeGitHub(t) + server.github = githubApp{clientID: "client", clientSecret: "secret", slug: "code-nav", webURL: gh.URL, apiURL: gh.URL} + + var status githubStatus + c.json("GET", "/api/github/status", "", 200, &status) + if !status.Configured || status.Connected || status.InstallURL != gh.URL+"/apps/code-nav/installations/new" { + t.Fatalf("status before sign-in = %+v", status) + } + + login := c.location("/api/github/login") + state := login.Query().Get("state") + if login.Path != "/login/oauth/authorize" || login.Query().Get("client_id") != "client" || state == "" { + t.Fatalf("authorize redirect = %s", login) + } + if got := c.location("/api/github/callback?code=good&state=forged").String(); got != "/?github=error" { + t.Fatalf("forged state redirect = %s", got) + } + // State is single use, so the forged attempt burned it. + if got := c.location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=error" { + t.Fatalf("reused state redirect = %s", got) + } + + state = c.location("/api/github/login").Query().Get("state") + if got := c.stranger().noRedirects().location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=error" { + t.Fatalf("callback from another session = %s", got) + } + state = c.location("/api/github/login").Query().Get("state") + if got := c.location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=connected" { + t.Fatalf("callback redirect = %s", got) + } + c.json("GET", "/api/github/status", "", 200, &status) + if !status.Connected || status.Login != "octocat" { + t.Fatalf("status after sign-in = %+v", status) + } + if raw, _ := json.Marshal(status); strings.Contains(string(raw), "ghu_token") { + t.Fatal("status leaked the token") + } + c.stranger().json("GET", "/api/github/status", "", 200, &status) + if status.Connected { + t.Fatal("another session sees the sign-in") + } + + server.expire(time.Now().Add(9 * time.Hour)) + c.json("GET", "/api/github/status", "", 200, &status) + if status.Connected || len(server.auth) != 0 { + t.Fatalf("expired token still present: %+v, %d entries", status, len(server.auth)) + } + + state = c.location("/api/github/login").Query().Get("state") + c.location("/api/github/callback?code=good&state=" + state) + c.json("POST", "/api/github/logout", "", 200, nil) + c.json("GET", "/api/github/status", "", 200, &status) + if status.Connected { + t.Fatal("still connected after logout") + } +} + +func TestGitHubRoutesWhenUnconfigured(t *testing.T) { + server, c := newTestServer(t) + server.github = githubApp{} + var status githubStatus + c.json("GET", "/api/github/status", "", 200, &status) + if status.Configured { + t.Fatal("reported configured without credentials") + } + c.json("GET", "/api/github/login", "", 404, nil) +} + +func TestGitCloneKeepsTokenOutOfArgsAndIgnoresHostCredentials(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + cmd := gitClone("https://github.com/o/r", t.TempDir(), "ghu_secret") + if strings.Contains(strings.Join(cmd.Args, " "), "ghu_secret") { + t.Fatal("token appears in git arguments") + } + read := func(key string) string { + probe := exec.Command("git", "config", "--get-all", key) + probe.Env = cmd.Env + out, _ := probe.Output() + return strings.TrimSpace(string(out)) + } + if got := read("http.https://github.com/.extraheader"); got != "Authorization: Basic eC1hY2Nlc3MtdG9rZW46Z2h1X3NlY3JldA==" { + t.Errorf("extraheader = %q", got) + } + if got := read("credential.helper"); got != "" { + t.Errorf("credential.helper = %q, want empty", got) + } +} + +func TestCloneFailureMessages(t *testing.T) { + configured := githubApp{clientID: "id", clientSecret: "secret"} + private := "fatal: could not read Username for 'https://github.com': terminal prompts disabled" + for _, c := range []struct { + app githubApp + output string + signedIn bool + want string + }{ + {configured, private, false, "signin"}, + {configured, "remote: Repository not found.\nfatal: repository 'x' not found", true, "install"}, + {githubApp{}, private, false, ""}, + {configured, "fatal: unable to access: Could not resolve host: github.com", false, ""}, + } { + message, got := c.app.cloneFailure(c.output, c.signedIn) + if got != c.want || message == "" { + t.Errorf("cloneFailure(%q, %v) = %q, %q; want %q", c.output, c.signedIn, message, got, c.want) + } + } +} diff --git a/main.go b/main.go index ce71ad5..6221268 100644 --- a/main.go +++ b/main.go @@ -38,9 +38,11 @@ type Job struct { SnapshotID *string `json:"snapshotId"` Error *string `json:"error"` Warnings []string `json:"warnings"` - root string - owner string // session that created the job - updated time.Time // last progress, used to expire abandoned jobs + // AuthRequired is "signin" or "install" when GitHub access would fix a failed clone. + AuthRequired string `json:"authRequired,omitempty"` + root string + owner string // session that created the job + updated time.Time // last progress, used to expire abandoned jobs } const ( @@ -58,6 +60,9 @@ type Server struct { cors string static http.Handler indexSlots chan struct{} + github githubApp + authMu sync.Mutex + auth map[string]*githubAuth // by session ID } func NewServer(workspace string) *Server { @@ -73,6 +78,8 @@ func NewServer(workspace string) *Server { cors: cors, static: http.FileServerFS(web), indexSlots: make(chan struct{}, maxIndexing), + github: githubAppFromEnv(), + auth: map[string]*githubAuth{}, } } @@ -123,6 +130,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.createJob(w, r) case strings.HasPrefix(path, "/api/jobs/"): s.jobRoute(w, r, strings.Split(strings.TrimPrefix(path, "/api/jobs/"), "/")) + case strings.HasPrefix(path, "/api/github/"): + s.githubRoute(w, r, strings.TrimPrefix(path, "/api/github/")) case strings.HasPrefix(path, "/api/snapshots/"): s.snapshotRoute(w, r, strings.Split(strings.TrimPrefix(path, "/api/snapshots/"), "/")) case !strings.HasPrefix(path, "/api/") && (r.Method == http.MethodGet || r.Method == http.MethodHead): @@ -172,11 +181,17 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request) { s.jobsMu.Unlock() if request.Kind == "github" { + token := s.githubAuthFor(owner).token go func() { checkout := filepath.Join(root, "repo") - output, err := exec.Command("git", "clone", "--depth=1", "--single-branch", "--quiet", "--", url, checkout).CombinedOutput() + output, err := gitClone(url, checkout, token).CombinedOutput() if err != nil { - s.failJob(id, fmt.Sprintf("git clone failed: %v %s", err, strings.TrimSpace(string(output)))) + text := string(output) + if token != "" { + text = strings.ReplaceAll(text, token, "***") + } + message, authRequired := s.github.cloneFailure(text, token != "") + s.failJob(id, message, authRequired) s.removeWorkspace(root) return } @@ -533,7 +548,7 @@ func (s *Server) indexJob(id, root string) { }) if err != nil { s.removeWorkspace(root) - s.failJob(id, err.Error()) + s.failJob(id, err.Error(), "") return } s.jobsMu.Lock() @@ -605,6 +620,13 @@ func (s *Server) expire(now time.Time) { } } s.jobsMu.Unlock() + s.authMu.Lock() + for sid, auth := range s.auth { + if now.After(auth.expires) && now.After(auth.stateExpires) { + delete(s.auth, sid) + } + } + s.authMu.Unlock() for _, root := range roots { s.removeWorkspace(root) } @@ -619,11 +641,12 @@ func (s *Server) updateJob(id, phase string, completed, total int, message strin } } -func (s *Server) failJob(id, message string) { +func (s *Server) failJob(id, message, authRequired string) { s.jobsMu.Lock() defer s.jobsMu.Unlock() if job := s.jobs[id]; job != nil { job.Phase, job.Message, job.Error = "error", "Indexing failed", &message + job.AuthRequired = authRequired job.updated = time.Now() } }