Add GitHub App sign-in for private repositories

/api/github/login redirects to GitHub with a single-use state bound to
the session; the callback exchanges the code for a user token kept in
memory and expired with it. Clones pass the token through git config
in the environment, never the URL or arguments, and ignore host git
config and credential helpers. Failed clones report authRequired
(signin or install) so the frontend can offer the right next step.

Configured with GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_APP_SLUG.
This commit is contained in:
Andy Dixon
2026-02-28 12:27:34 +00:00
parent 25c8c45de4
commit b216b86004
3 changed files with 420 additions and 7 deletions
+215
View File
@@ -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"
}
}
+175
View File
@@ -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)
}
}
}
+30 -7
View File
@@ -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()
}
}