package server import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" "gitweb/internal/auth" "gitweb/internal/store" "upspin.io/upspin" ) // newSSOTestServer builds a Server with browser SSO enabled and returns a // function that mints issuer-signed tokens the server will accept. func newSSOTestServer(t *testing.T) (*Server, func(sub string, exp time.Time) string) { t.Helper() priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } pub := upspin.PublicKey(fmt.Sprintf("p256\n%s\n%s\n", priv.X.String(), priv.Y.String())) const issuer = "vault@cosmocr.at" verifier := auth.NewVaultVerifier(issuer, "gitweb", func(upspin.UserName) (upspin.PublicKey, error) { return pub, nil }) s, err := New(Config{ Store: store.NewLocalStore(t.TempDir()), Web: http.FS(os.DirFS("../../web")), CacheDir: t.TempDir(), Vault: verifier, SSOAuthorizeURL: "https://vault.cosmocr.at/authorize", SSOAudience: "gitweb", BaseURL: "https://git.cosmocr.at", }) if err != nil { t.Fatal(err) } mint := func(sub string, exp time.Time) string { claims := map[string]any{ "iss": issuer, "sub": sub, "aud": "gitweb", "iat": time.Now().Unix(), "exp": exp.Unix(), } payload, _ := json.Marshal(claims) p := base64.RawURLEncoding.EncodeToString(payload) hash := sha256.Sum256([]byte("v1." + p)) r, ss, err := ecdsa.Sign(rand.Reader, priv, hash[:]) if err != nil { t.Fatal(err) } return "v1." + p + "." + base64.RawURLEncoding.EncodeToString(r.Bytes()) + "." + base64.RawURLEncoding.EncodeToString(ss.Bytes()) } return s, mint } // TestAuthenticateSessionCookie: a valid session cookie authenticates the // browser; an expired one is ignored (anonymous) and cleared. func TestAuthenticateSessionCookie(t *testing.T) { s, mint := newSSOTestServer(t) // Valid cookie -> authenticated subject. r := httptest.NewRequest("GET", "/u@d.com/x", nil) r.AddCookie(&http.Cookie{Name: sessionCookie, Value: mint("alice@d.com", time.Now().Add(time.Hour))}) w := httptest.NewRecorder() user, ok := s.authenticate(w, r) if !ok || user != "alice@d.com" { t.Fatalf("authenticate = (%q, %v), want (alice@d.com, true)", user, ok) } // Expired cookie -> anonymous, and the stale cookie is cleared. r = httptest.NewRequest("GET", "/u@d.com/x", nil) r.AddCookie(&http.Cookie{Name: sessionCookie, Value: mint("alice@d.com", time.Now().Add(-time.Minute))}) w = httptest.NewRecorder() user, ok = s.authenticate(w, r) if !ok || user != "" { t.Fatalf("expired cookie: authenticate = (%q, %v), want (\"\", true)", user, ok) } if !clearsCookie(w, sessionCookie) { t.Errorf("expired cookie was not cleared: %v", w.Result().Cookies()) } } // TestHandleSession covers the callback token-exchange endpoint: state must // match the oauth cookie and the token must verify before a session is set. func TestHandleSession(t *testing.T) { s, mint := newSSOTestServer(t) token := mint("bob@d.com", time.Now().Add(time.Hour)) post := func(oauthState, bodyState, tok string) *httptest.ResponseRecorder { body, _ := json.Marshal(map[string]string{"token": tok, "state": bodyState}) r := httptest.NewRequest("POST", "/_auth/session", strings.NewReader(string(body))) r.AddCookie(&http.Cookie{Name: oauthCookie, Value: url.Values{"state": {oauthState}, "next": {"/u@d.com/priv.git"}}.Encode()}) w := httptest.NewRecorder() s.handleSession(w, r) return w } // Happy path: matching state + valid token -> 200, session cookie, next. w := post("s123", "s123", token) if w.Code != 200 { t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body) } if !setsCookie(w, sessionCookie, token) { t.Errorf("session cookie not set to the token") } var out struct{ Next string } json.Unmarshal(w.Body.Bytes(), &out) if out.Next != "/u@d.com/priv.git" { t.Errorf("next = %q, want /u@d.com/priv.git", out.Next) } // State mismatch -> 400, no session. if w := post("s123", "WRONG", token); w.Code != 400 { t.Errorf("state mismatch status = %d, want 400", w.Code) } // Invalid token -> 401, no session. if w := post("s123", "s123", "v1.garbage.a.b"); w.Code != 401 { t.Errorf("invalid token status = %d, want 401", w.Code) } } // TestDenyReadRoutes: an anonymous browser is redirected to login, a git/curl // client gets a Basic-auth challenge, an authenticated caller gets 404. func TestDenyReadRoutes(t *testing.T) { s, _ := newSSOTestServer(t) // Browser (Accept: text/html), anonymous -> 303 to /_auth/login. r := httptest.NewRequest("GET", "/u@d.com/priv.git", nil) r.Header.Set("Accept", "text/html") w := httptest.NewRecorder() s.denyRead(w, r, "") if w.Code != http.StatusSeeOther { t.Fatalf("browser deny status = %d, want 303", w.Code) } if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/_auth/login?next=") { t.Errorf("Location = %q, want /_auth/login?next=…", loc) } // git/curl (no text/html), anonymous -> 401 challenge. r = httptest.NewRequest("GET", "/u@d.com/priv.git/info/refs", nil) w = httptest.NewRecorder() s.denyRead(w, r, "") if w.Code != http.StatusUnauthorized { t.Errorf("cli deny status = %d, want 401", w.Code) } // Authenticated but unauthorized -> 404 (does not reveal existence). r = httptest.NewRequest("GET", "/u@d.com/priv.git", nil) r.Header.Set("Accept", "text/html") w = httptest.NewRecorder() s.denyRead(w, r, "eve@d.com") if w.Code != http.StatusNotFound { t.Errorf("authed deny status = %d, want 404", w.Code) } } func setsCookie(w *httptest.ResponseRecorder, name, val string) bool { for _, c := range w.Result().Cookies() { if c.Name == name && c.Value == val && c.MaxAge >= 0 { return true } } return false } func clearsCookie(w *httptest.ResponseRecorder, name string) bool { for _, c := range w.Result().Cookies() { if c.Name == name && c.MaxAge < 0 { return true } } return false }