git@cosmocr.at/gitweb.git @ main · summary refs log
main / internal / server / sso_test.go /
internal/server/sso_test.go · 5.9 KiB · raw
view: SourceMarkdown ☑ force

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 }