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
+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)
}
}
}