/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.
176 lines
5.8 KiB
Go
176 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|