diff --git a/cmd/agentgw/main.go b/cmd/agentgw/main.go index bbacaf6..fb19699 100644 --- a/cmd/agentgw/main.go +++ b/cmd/agentgw/main.go @@ -30,6 +30,7 @@ import ( "github.com/Clawdlinux/agentgate/internal/delegation" "github.com/Clawdlinux/agentgate/internal/gateway" "github.com/Clawdlinux/agentgate/internal/oauth" + "github.com/Clawdlinux/agentgate/internal/org" "github.com/Clawdlinux/agentgate/internal/ratelimit" "github.com/Clawdlinux/agentgate/internal/receipt" "github.com/Clawdlinux/agentgate/internal/registry" @@ -95,6 +96,11 @@ func main() { logger.Error("bootstrap agent key", "error", err) os.Exit(1) } + orgStore := org.NewStore(database) + if err := bootstrapAdmin(context.Background(), orgStore, logger); err != nil { + logger.Error("bootstrap admin", "error", err) + os.Exit(1) + } // Signer derives its own purpose-specific encryption key from // masterKey — it never uses masterKey directly (internal/signer's own @@ -148,7 +154,8 @@ func main() { } oauthHandler := oauth.NewCallbackHandler(oauthProviders, vaultStore, masterKey, publicURL, logger) - adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, reg, adminSecret, logger) + sessionManager := admin.NewSessionManager(masterKey, publicURL) + adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, reg, orgStore, sessionManager, adminSecret, logger) mux := http.NewServeMux() mux.Handle("/", srv) @@ -159,6 +166,9 @@ func main() { mux.Handle("POST /admin/tokens", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.ConnectBearerToken))) mux.Handle("GET /admin/tokens/{user_id}", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.ListTokens))) mux.Handle("GET /v1/receipts/export", adminHandler.RequireAdmin(receipt.ExportHandler(database, signerStore))) + mux.HandleFunc("GET /admin/login", adminHandler.LoginPage) + mux.HandleFunc("POST /admin/login", adminHandler.Login) + mux.Handle("POST /admin/logout", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.Logout))) mux.HandleFunc("GET /auth/callback/{service}", oauthHandler.ServeHTTP) // The dashboard is a static single-page app served same-origin so its @@ -244,6 +254,26 @@ func bootstrapAgentKey(ctx context.Context, keyStore *auth.KeyStore, logger *slo return nil } +func bootstrapAdmin(ctx context.Context, orgStore *org.Store, logger *slog.Logger) error { + email := strings.TrimSpace(os.Getenv("AGENTGATE_BOOTSTRAP_ADMIN_EMAIL")) + password := os.Getenv("AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD") + if email == "" && password == "" { + return nil + } + if email == "" || password == "" { + return fmt.Errorf("AGENTGATE_BOOTSTRAP_ADMIN_EMAIL and AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD must both be set") + } + organization, admin, created, err := orgStore.BootstrapAdmin(ctx, "Default organization", email, password) + if err != nil { + return err + } + if !created { + return nil + } + logger.Info("bootstrapped initial admin", "org_id", organization.ID, "admin_email", admin.Email) + return nil +} + // buildOAuthProviders constructs one oauth.Provider per registered service // whose auth.type is oauth2 and whose _CLIENT_ID/_CLIENT_SECRET // env vars are both set. A service missing either value is skipped with a diff --git a/cmd/agentgw/main_test.go b/cmd/agentgw/main_test.go new file mode 100644 index 0000000..893e51b --- /dev/null +++ b/cmd/agentgw/main_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "testing" + + agentgatedb "github.com/Clawdlinux/agentgate/internal/db" + "github.com/Clawdlinux/agentgate/internal/org" +) + +func TestBootstrapAdmin(t *testing.T) { + database, err := agentgatedb.Open(filepath.Join(t.TempDir(), "agentgate.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := agentgatedb.RunMigrations(database); err != nil { + t.Fatalf("run migrations: %v", err) + } + t.Setenv("AGENTGATE_BOOTSTRAP_ADMIN_EMAIL", "admin@example.com") + t.Setenv("AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD", "correct horse battery staple") + store := org.NewStore(database) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + if err := bootstrapAdmin(context.Background(), store, logger); err != nil { + t.Fatalf("bootstrapAdmin: %v", err) + } + if count, err := store.AdminCount(context.Background()); err != nil || count != 1 { + t.Fatalf("AdminCount = (%d, %v), want (1, nil)", count, err) + } + if _, err := store.Authenticate(context.Background(), "admin@example.com", "correct horse battery staple"); err != nil { + t.Fatalf("Authenticate bootstrap admin: %v", err) + } + if err := bootstrapAdmin(context.Background(), store, logger); err != nil { + t.Fatalf("second bootstrapAdmin: %v", err) + } + if count, err := store.AdminCount(context.Background()); err != nil || count != 1 { + t.Fatalf("AdminCount after second bootstrap = (%d, %v), want (1, nil)", count, err) + } +} + +func TestBootstrapAdminRequiresBothEnvironmentValues(t *testing.T) { + database, err := agentgatedb.Open(filepath.Join(t.TempDir(), "agentgate.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := agentgatedb.RunMigrations(database); err != nil { + t.Fatalf("run migrations: %v", err) + } + t.Setenv("AGENTGATE_BOOTSTRAP_ADMIN_EMAIL", "admin@example.com") + t.Setenv("AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD", "") + + if err := bootstrapAdmin(context.Background(), org.NewStore(database), slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil { + t.Fatal("bootstrapAdmin succeeded with only email configured") + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 08d1c0c..ecd73b4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,9 @@ services: - AGENTGATE_VAULT_KEY=dev-key-change-in-production-32b - AGENTGATE_ADMIN_SECRET=admin-dev-secret-change-me!! - AGENTGATE_PUBLIC_URL=http://localhost:8080 + # Optional. Both values create the first admin account on an empty database. Remove them after first startup. + - AGENTGATE_BOOTSTRAP_ADMIN_EMAIL=${AGENTGATE_BOOTSTRAP_ADMIN_EMAIL:-} + - AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD=${AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD:-} # Agent API keys are bootstrapped automatically on first boot; see # the container logs for the one-time plaintext key. - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 835178f..3de61eb 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -1,15 +1,23 @@ package admin import ( + "context" + "crypto/subtle" "encoding/json" + "html/template" "log/slog" + "net" "net/http" "strings" + "sync" + "time" "github.com/Clawdlinux/agentgate/internal/auth" "github.com/Clawdlinux/agentgate/internal/oauth" + "github.com/Clawdlinux/agentgate/internal/org" "github.com/Clawdlinux/agentgate/internal/registry" "github.com/Clawdlinux/agentgate/internal/vault" + "golang.org/x/time/rate" ) // Handler provides admin API endpoints for key management and user linking. @@ -18,12 +26,15 @@ type Handler struct { oauthHandler *oauth.CallbackHandler vault vault.Store registry *registry.Registry + orgStore *org.Store + sessions *SessionManager adminSecret string logger *slog.Logger + loginLimits sync.Map } // NewHandler creates the admin handler. -func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, reg *registry.Registry, adminSecret string, logger *slog.Logger) *Handler { +func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, reg *registry.Registry, orgStore *org.Store, sessions *SessionManager, adminSecret string, logger *slog.Logger) *Handler { if logger == nil { logger = slog.Default() } @@ -32,26 +43,115 @@ func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, reg oauthHandler: oh, vault: v, registry: reg, + orgStore: orgStore, + sessions: sessions, adminSecret: adminSecret, logger: logger, } } -// RequireAdmin is middleware that checks the X-Admin-Secret header. +type adminContextKey string + +const adminIdentityContextKey adminContextKey = "admin_identity" + +const csrfHeaderName = "X-Requested-With" + +const csrfHeaderValue = "AgentGate" + +func AdminFromContext(ctx context.Context) (SessionIdentity, bool) { + identity, ok := ctx.Value(adminIdentityContextKey).(SessionIdentity) + return identity, ok +} + +// RequireAdmin accepts the legacy X-Admin-Secret header or an authenticated session cookie. func (h *Handler) RequireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { secret := r.Header.Get("X-Admin-Secret") - if secret == "" || secret != h.adminSecret { + if secret != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(h.adminSecret)) == 1 { + next.ServeHTTP(w, r) + return + } + + identity, err := h.sessions.Authenticate(r) + if err != nil || (isMutatingRequest(r) && r.Header.Get(csrfHeaderName) != csrfHeaderValue) { writeJSON(w, http.StatusUnauthorized, map[string]string{ "error": "invalid admin secret", "code": "unauthorized", }) return } - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), adminIdentityContextKey, identity))) }) } +func isMutatingRequest(r *http.Request) bool { + return r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete +} + +func (h *Handler) LoginPage(w http.ResponseWriter, r *http.Request) { + renderLoginPage(w, http.StatusOK, false, false) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + if !h.allowLogin(r) { + renderLoginPage(w, http.StatusTooManyRequests, false, true) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + renderLoginPage(w, http.StatusBadRequest, false, false) + return + } + admin, err := h.orgStore.Authenticate(r.Context(), r.FormValue("email"), r.FormValue("password")) + if err != nil { + if err == org.ErrInvalidCredentials { + renderLoginPage(w, http.StatusUnauthorized, true, false) + return + } + h.logger.Error("admin login failed", "error", err) + http.Error(w, "login failed", http.StatusInternalServerError) + return + } + cookie, err := h.sessions.CreateCookie(SessionIdentity{AdminID: admin.ID, OrgID: admin.OrgID}) + if err != nil { + h.logger.Error("create admin session", "error", err) + http.Error(w, "login failed", http.StatusInternalServerError) + return + } + http.SetCookie(w, cookie) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, h.sessions.ClearCookie()) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} + +func (h *Handler) allowLogin(r *http.Request) bool { + remoteAddress := r.RemoteAddr + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + remoteAddress = host + } + limiter, _ := h.loginLimits.LoadOrStore(remoteAddress, rate.NewLimiter(rate.Every(12*time.Second), 5)) + return limiter.(*rate.Limiter).Allow() +} + +var loginTemplate = template.Must(template.New("login").Parse(` + +AgentGate Admin Login +

AgentGate Admin

{{if .InvalidCredentials}}

Email or password is incorrect.

{{end}}{{if .RateLimited}}

Too many sign-in attempts. Try again later.

{{end}}
`)) + +func renderLoginPage(w http.ResponseWriter, status int, invalidCredentials, rateLimited bool) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := loginTemplate.Execute(w, struct { + InvalidCredentials bool + RateLimited bool + }{InvalidCredentials: invalidCredentials, RateLimited: rateLimited}); err != nil { + http.Error(w, "render login page", http.StatusInternalServerError) + } +} + // CreateKey handles POST /admin/keys. func (h *Handler) CreateKey(w http.ResponseWriter, r *http.Request) { var req struct { diff --git a/internal/admin/handler_test.go b/internal/admin/handler_test.go new file mode 100644 index 0000000..20f4113 --- /dev/null +++ b/internal/admin/handler_test.go @@ -0,0 +1,171 @@ +package admin + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + agentgatedb "github.com/Clawdlinux/agentgate/internal/db" + "github.com/Clawdlinux/agentgate/internal/org" +) + +func testHandler(t *testing.T) (*Handler, SessionIdentity) { + t.Helper() + database, err := agentgatedb.Open(":memory:") + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := agentgatedb.RunMigrations(database); err != nil { + t.Fatalf("run migrations: %v", err) + } + orgStore := org.NewStore(database) + organization, err := orgStore.CreateOrg(t.Context(), "Example Inc.") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + admin, err := orgStore.CreateAdmin(t.Context(), organization.ID, "admin@example.com", "correct horse battery staple") + if err != nil { + t.Fatalf("CreateAdmin: %v", err) + } + return NewHandler(nil, nil, nil, nil, orgStore, NewSessionManager([]byte("dev-key-change-in-production-32b"), "https://agentgate.example"), "admin-secret", nil), SessionIdentity{AdminID: admin.ID, OrgID: organization.ID} +} + +func TestHandlerLoginSetsSessionCookie(t *testing.T) { + t.Parallel() + handler, identity := testHandler(t) + form := url.Values{"email": {"admin@example.com"}, "password": {"correct horse battery staple"}} + request := httptest.NewRequest(http.MethodPost, "/admin/login", bytes.NewBufferString(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + + handler.Login(response, request) + if response.Code != http.StatusSeeOther { + t.Fatalf("Login status = %d, want %d", response.Code, http.StatusSeeOther) + } + cookies := response.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != sessionCookieName || !cookies[0].HttpOnly || !cookies[0].Secure { + t.Fatalf("Login cookies = %#v", cookies) + } + protected := handler.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + actual, ok := AdminFromContext(r.Context()) + if !ok || actual != identity { + t.Fatalf("AdminFromContext = (%#v, %t), want (%#v, true)", actual, ok, identity) + } + w.WriteHeader(http.StatusNoContent) + })) + protectedRequest := httptest.NewRequest(http.MethodGet, "/admin/tokens/user-1", nil) + protectedRequest.AddCookie(cookies[0]) + protectedResponse := httptest.NewRecorder() + protected.ServeHTTP(protectedResponse, protectedRequest) + if protectedResponse.Code != http.StatusNoContent { + t.Fatalf("session protected status = %d, want %d", protectedResponse.Code, http.StatusNoContent) + } +} + +func TestHandlerRequireAdmin(t *testing.T) { + t.Parallel() + handler, identity := testHandler(t) + cookie, err := handler.sessions.CreateCookie(identity) + if err != nil { + t.Fatalf("CreateCookie: %v", err) + } + protected := handler.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + tests := []struct { + name string + method string + cookie bool + adminSecret string + requestedWith string + wantStatus int + }{ + {name: "legacy admin secret", method: http.MethodPost, adminSecret: "admin-secret", wantStatus: http.StatusNoContent}, + {name: "session safe request", method: http.MethodGet, cookie: true, wantStatus: http.StatusNoContent}, + {name: "session mutation requires csrf header", method: http.MethodPost, cookie: true, wantStatus: http.StatusUnauthorized}, + {name: "session mutation with csrf header", method: http.MethodPost, cookie: true, requestedWith: csrfHeaderValue, wantStatus: http.StatusNoContent}, + {name: "invalid legacy admin secret", method: http.MethodPost, adminSecret: "wrong", wantStatus: http.StatusUnauthorized}, + {name: "no credentials", method: http.MethodGet, wantStatus: http.StatusUnauthorized}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(test.method, "/admin/keys", nil) + if test.cookie { + request.AddCookie(cookie) + } + if test.adminSecret != "" { + request.Header.Set("X-Admin-Secret", test.adminSecret) + } + if test.requestedWith != "" { + request.Header.Set(csrfHeaderName, test.requestedWith) + } + response := httptest.NewRecorder() + protected.ServeHTTP(response, request) + if response.Code != test.wantStatus { + t.Fatalf("RequireAdmin status = %d, want %d", response.Code, test.wantStatus) + } + }) + } +} + +func TestHandlerLoginRejectsInvalidCredentialsAndLogoutClearsCookie(t *testing.T) { + t.Parallel() + handler, identity := testHandler(t) + form := url.Values{"email": {"admin@example.com"}, "password": {"wrong password"}} + request := httptest.NewRequest(http.MethodPost, "/admin/login", bytes.NewBufferString(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + handler.Login(response, request) + if response.Code != http.StatusUnauthorized || !bytes.Contains(response.Body.Bytes(), []byte("Email or password is incorrect.")) { + t.Fatalf("invalid login = status %d body %q", response.Code, response.Body.String()) + } + + cookie, err := handler.sessions.CreateCookie(identity) + if err != nil { + t.Fatalf("CreateCookie: %v", err) + } + logout := handler.RequireAdmin(http.HandlerFunc(handler.Logout)) + logoutRequest := httptest.NewRequest(http.MethodPost, "/admin/logout", nil) + logoutRequest.AddCookie(cookie) + logoutRequest.Header.Set(csrfHeaderName, csrfHeaderValue) + logoutResponse := httptest.NewRecorder() + logout.ServeHTTP(logoutResponse, logoutRequest) + if logoutResponse.Code != http.StatusSeeOther { + t.Fatalf("Logout status = %d, want %d", logoutResponse.Code, http.StatusSeeOther) + } + cookies := logoutResponse.Result().Cookies() + if len(cookies) != 1 || cookies[0].MaxAge != -1 { + t.Fatalf("Logout cookies = %#v", cookies) + } +} + +func TestHandlerLoginRateLimit(t *testing.T) { + t.Parallel() + handler, _ := testHandler(t) + form := url.Values{"email": {"admin@example.com"}, "password": {"wrong password"}} + + for attempt := 0; attempt < 5; attempt++ { + request := httptest.NewRequest(http.MethodPost, "/admin/login", bytes.NewBufferString(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.RemoteAddr = "192.0.2.1:1234" + response := httptest.NewRecorder() + handler.Login(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d status = %d, want %d", attempt+1, response.Code, http.StatusUnauthorized) + } + } + + request := httptest.NewRequest(http.MethodPost, "/admin/login", bytes.NewBufferString(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.RemoteAddr = "192.0.2.1:1234" + response := httptest.NewRecorder() + handler.Login(response, request) + if response.Code != http.StatusTooManyRequests || !bytes.Contains(response.Body.Bytes(), []byte("Too many sign-in attempts")) { + t.Fatalf("rate-limited login = status %d body %q", response.Code, response.Body.String()) + } +} diff --git a/internal/admin/session.go b/internal/admin/session.go new file mode 100644 index 0000000..5db7a51 --- /dev/null +++ b/internal/admin/session.go @@ -0,0 +1,137 @@ +package admin + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Clawdlinux/agentgate/internal/signer" +) + +const ( + sessionCookieName = "agentgate_admin_session" + sessionTTL = time.Hour + sessionKeyPurpose = "agentgate.admin-session.v1" +) + +var ErrInvalidSession = errors.New("admin: invalid session") + +type SessionIdentity struct { + AdminID string + OrgID string +} + +type sessionPayload struct { + AdminID string `json:"a"` + OrgID string `json:"o"` + ExpiresAt time.Time `json:"e"` +} + +type SessionManager struct { + key []byte + secure bool + now func() time.Time +} + +func NewSessionManager(masterKey []byte, publicURL string) *SessionManager { + return &SessionManager{ + key: signer.DerivePurposeKey(masterKey, sessionKeyPurpose), + secure: strings.HasPrefix(publicURL, "https://"), + now: time.Now, + } +} + +func (m *SessionManager) CreateCookie(identity SessionIdentity) (*http.Cookie, error) { + if identity.AdminID == "" || identity.OrgID == "" { + return nil, ErrInvalidSession + } + payload, err := json.Marshal(sessionPayload{ + AdminID: identity.AdminID, + OrgID: identity.OrgID, + ExpiresAt: m.now().Add(sessionTTL), + }) + if err != nil { + return nil, fmt.Errorf("admin.CreateCookie: marshal payload: %w", err) + } + + block, err := aes.NewCipher(m.key) + if err != nil { + return nil, fmt.Errorf("admin.CreateCookie: cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("admin.CreateCookie: gcm: %w", err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("admin.CreateCookie: nonce: %w", err) + } + ciphertext := gcm.Seal(nonce, nonce, payload, nil) + expiresAt := m.now().Add(sessionTTL) + return &http.Cookie{ + Name: sessionCookieName, + Value: base64.URLEncoding.EncodeToString(ciphertext), + Path: "/", + Expires: expiresAt, + MaxAge: int(sessionTTL.Seconds()), + HttpOnly: true, + Secure: m.secure, + SameSite: http.SameSiteLaxMode, + }, nil +} + +func (m *SessionManager) Authenticate(r *http.Request) (SessionIdentity, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return SessionIdentity{}, ErrInvalidSession + } + + ciphertext, err := base64.URLEncoding.DecodeString(cookie.Value) + if err != nil { + return SessionIdentity{}, ErrInvalidSession + } + block, err := aes.NewCipher(m.key) + if err != nil { + return SessionIdentity{}, fmt.Errorf("admin.Authenticate: cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return SessionIdentity{}, fmt.Errorf("admin.Authenticate: gcm: %w", err) + } + if len(ciphertext) < gcm.NonceSize() { + return SessionIdentity{}, ErrInvalidSession + } + plaintext, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], nil) + if err != nil { + return SessionIdentity{}, ErrInvalidSession + } + + var payload sessionPayload + if err := json.Unmarshal(plaintext, &payload); err != nil { + return SessionIdentity{}, ErrInvalidSession + } + if payload.AdminID == "" || payload.OrgID == "" || !m.now().Before(payload.ExpiresAt) { + return SessionIdentity{}, ErrInvalidSession + } + return SessionIdentity{AdminID: payload.AdminID, OrgID: payload.OrgID}, nil +} + +func (m *SessionManager) ClearCookie() *http.Cookie { + return &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: m.secure, + SameSite: http.SameSiteLaxMode, + } +} diff --git a/internal/admin/session_test.go b/internal/admin/session_test.go new file mode 100644 index 0000000..3d5b6be --- /dev/null +++ b/internal/admin/session_test.go @@ -0,0 +1,86 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSessionManagerAuthenticate(t *testing.T) { + t.Parallel() + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + manager := NewSessionManager([]byte("dev-key-change-in-production-32b"), "https://agentgate.example") + manager.now = func() time.Time { return now } + cookie, err := manager.CreateCookie(SessionIdentity{AdminID: "admin-1", OrgID: "org-1"}) + if err != nil { + t.Fatalf("CreateCookie: %v", err) + } + request := httptest.NewRequest(http.MethodGet, "/admin/keys", nil) + request.AddCookie(cookie) + + identity, err := manager.Authenticate(request) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if identity != (SessionIdentity{AdminID: "admin-1", OrgID: "org-1"}) { + t.Fatalf("identity = %#v", identity) + } + if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" { + t.Fatalf("cookie has unsafe attributes: %#v", cookie) + } +} + +func TestSessionManagerRejectsInvalidSessions(t *testing.T) { + t.Parallel() + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + manager := NewSessionManager([]byte("dev-key-change-in-production-32b"), "http://localhost:8080") + manager.now = func() time.Time { return now } + validCookie, err := manager.CreateCookie(SessionIdentity{AdminID: "admin-1", OrgID: "org-1"}) + if err != nil { + t.Fatalf("CreateCookie: %v", err) + } + expiredManager := NewSessionManager([]byte("dev-key-change-in-production-32b"), "http://localhost:8080") + expiredManager.now = func() time.Time { return now.Add(2 * sessionTTL) } + wrongKeyManager := NewSessionManager([]byte("other-key-change-in-production-32"), "http://localhost:8080") + + tests := []struct { + name string + cookie *http.Cookie + manager *SessionManager + }{ + {name: "missing cookie", manager: manager}, + {name: "malformed value", cookie: &http.Cookie{Name: sessionCookieName, Value: "not base64"}, manager: manager}, + {name: "tampered value", cookie: &http.Cookie{Name: sessionCookieName, Value: validCookie.Value[:len(validCookie.Value)-1] + "A"}, manager: manager}, + {name: "expired session", cookie: validCookie, manager: expiredManager}, + {name: "wrong session key", cookie: validCookie, manager: wrongKeyManager}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/admin/keys", nil) + if test.cookie != nil { + request.AddCookie(test.cookie) + } + if _, err := test.manager.Authenticate(request); err != ErrInvalidSession { + t.Fatalf("Authenticate error = %v, want %v", err, ErrInvalidSession) + } + }) + } +} + +func TestSessionManagerCookieSecurityFollowsPublicURL(t *testing.T) { + t.Parallel() + manager := NewSessionManager([]byte("dev-key-change-in-production-32b"), "http://localhost:8080") + cookie, err := manager.CreateCookie(SessionIdentity{AdminID: "admin-1", OrgID: "org-1"}) + if err != nil { + t.Fatalf("CreateCookie: %v", err) + } + if cookie.Secure { + t.Fatal("http public URL set Secure cookie") + } + cleared := manager.ClearCookie() + if cleared.MaxAge != -1 || !cleared.HttpOnly || cleared.SameSite != http.SameSiteLaxMode { + t.Fatalf("ClearCookie = %#v", cleared) + } +} diff --git a/internal/db/migrations/004_orgs.sql b/internal/db/migrations/004_orgs.sql new file mode 100644 index 0000000..ba4be1a --- /dev/null +++ b/internal/db/migrations/004_orgs.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS orgs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS org_admins ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES orgs(id), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_org_admins_org_id ON org_admins(org_id); \ No newline at end of file diff --git a/internal/db/sqlite_test.go b/internal/db/sqlite_test.go index 416e6c5..ae49dbb 100644 --- a/internal/db/sqlite_test.go +++ b/internal/db/sqlite_test.go @@ -24,7 +24,7 @@ func TestRunMigrations_AppliesAllMigrationsCleanly(t *testing.T) { t.Fatal(err) } - for _, table := range []string{"agent_keys", "tokens", "audit_log", "signer_keys"} { + for _, table := range []string{"agent_keys", "tokens", "audit_log", "signer_keys", "orgs", "org_admins"} { var count int if err := database.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count); err != nil { t.Fatal(err) diff --git a/internal/org/store.go b/internal/org/store.go new file mode 100644 index 0000000..37055ff --- /dev/null +++ b/internal/org/store.go @@ -0,0 +1,185 @@ +package org + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + passwordHashCost = 10 + maxPasswordBytes = 72 +) + +var ( + ErrInvalidCredentials = errors.New("org: invalid credentials") + ErrInvalidOrgName = errors.New("org: organization name is required") + ErrInvalidAdmin = errors.New("org: admin email and password are required") + ErrPasswordTooLong = errors.New("org: password exceeds bcrypt's 72-byte limit") + ErrOrgNotFound = errors.New("org: organization not found") +) + +type Org struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +type Admin struct { + ID string `json:"id"` + OrgID string `json:"org_id"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +type Store struct { + db *sql.DB +} + +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +func (s *Store) CreateOrg(ctx context.Context, name string) (*Org, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrInvalidOrgName + } + + organization := &Org{ID: generateID(), Name: name} + if _, err := s.db.ExecContext(ctx, "INSERT INTO orgs (id, name) VALUES (?, ?)", organization.ID, organization.Name); err != nil { + return nil, fmt.Errorf("org.CreateOrg: %w", err) + } + if err := s.db.QueryRowContext(ctx, "SELECT created_at FROM orgs WHERE id = ?", organization.ID).Scan(&organization.CreatedAt); err != nil { + return nil, fmt.Errorf("org.CreateOrg: read created_at: %w", err) + } + return organization, nil +} + +func (s *Store) CreateAdmin(ctx context.Context, orgID, email, password string) (*Admin, error) { + email = normalizeEmail(email) + if orgID == "" || email == "" || password == "" { + return nil, ErrInvalidAdmin + } + if len([]byte(password)) > maxPasswordBytes { + return nil, ErrPasswordTooLong + } + + var exists bool + if err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM orgs WHERE id = ?)", orgID).Scan(&exists); err != nil { + return nil, fmt.Errorf("org.CreateAdmin: check organization: %w", err) + } + if !exists { + return nil, ErrOrgNotFound + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), passwordHashCost) + if err != nil { + return nil, fmt.Errorf("org.CreateAdmin: hash password: %w", err) + } + + admin := &Admin{ID: generateID(), OrgID: orgID, Email: email} + if _, err := s.db.ExecContext(ctx, "INSERT INTO org_admins (id, org_id, email, password_hash) VALUES (?, ?, ?, ?)", admin.ID, admin.OrgID, admin.Email, string(passwordHash)); err != nil { + return nil, fmt.Errorf("org.CreateAdmin: %w", err) + } + if err := s.db.QueryRowContext(ctx, "SELECT created_at FROM org_admins WHERE id = ?", admin.ID).Scan(&admin.CreatedAt); err != nil { + return nil, fmt.Errorf("org.CreateAdmin: read created_at: %w", err) + } + return admin, nil +} + +func (s *Store) BootstrapAdmin(ctx context.Context, organizationName, email, password string) (*Org, *Admin, bool, error) { + organizationName = strings.TrimSpace(organizationName) + email = normalizeEmail(email) + if organizationName == "" { + return nil, nil, false, ErrInvalidOrgName + } + if email == "" || password == "" { + return nil, nil, false, ErrInvalidAdmin + } + if len([]byte(password)) > maxPasswordBytes { + return nil, nil, false, ErrPasswordTooLong + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), passwordHashCost) + if err != nil { + return nil, nil, false, fmt.Errorf("org.BootstrapAdmin: hash password: %w", err) + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, nil, false, fmt.Errorf("org.BootstrapAdmin: begin transaction: %w", err) + } + defer tx.Rollback() + + var count int + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM org_admins").Scan(&count); err != nil { + return nil, nil, false, fmt.Errorf("org.BootstrapAdmin: count admins: %w", err) + } + if count > 0 { + if err := tx.Commit(); err != nil { + return nil, nil, false, fmt.Errorf("org.BootstrapAdmin: commit existing admins: %w", err) + } + return nil, nil, false, nil + } + + organization := &Org{ID: generateID(), Name: organizationName} + if _, err := tx.ExecContext(ctx, "INSERT INTO orgs (id, name) VALUES (?, ?)", organization.ID, organization.Name); err != nil { + return nil, nil, false, fmt.Errorf("org.BootstrapAdmin: create organization: %w", err) + } + admin := &Admin{ID: generateID(), OrgID: organization.ID, Email: email} + if _, err := tx.ExecContext(ctx, "INSERT INTO org_admins (id, org_id, email, password_hash) VALUES (?, ?, ?, ?)", admin.ID, admin.OrgID, admin.Email, string(passwordHash)); err != nil { + return nil, nil, false, fmt.Errorf("org.BootstrapAdmin: create admin: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, nil, false, fmt.Errorf("org.BootstrapAdmin: commit: %w", err) + } + return organization, admin, true, nil +} + +func (s *Store) AdminCount(ctx context.Context) (int, error) { + var count int + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM org_admins").Scan(&count); err != nil { + return 0, fmt.Errorf("org.AdminCount: %w", err) + } + return count, nil +} + +func (s *Store) Authenticate(ctx context.Context, email, password string) (*Admin, error) { + email = normalizeEmail(email) + if email == "" || password == "" || len([]byte(password)) > maxPasswordBytes { + return nil, ErrInvalidCredentials + } + + var admin Admin + var passwordHash string + err := s.db.QueryRowContext(ctx, "SELECT id, org_id, email, password_hash, created_at FROM org_admins WHERE email = ?", email).Scan(&admin.ID, &admin.OrgID, &admin.Email, &passwordHash, &admin.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInvalidCredentials + } + if err != nil { + return nil, fmt.Errorf("org.Authenticate: %w", err) + } + if bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) != nil { + return nil, ErrInvalidCredentials + } + return &admin, nil +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func generateID() string { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + panic(fmt.Sprintf("org: generate ID: %v", err)) + } + return hex.EncodeToString(bytes) +} diff --git a/internal/org/store_test.go b/internal/org/store_test.go new file mode 100644 index 0000000..9488ca6 --- /dev/null +++ b/internal/org/store_test.go @@ -0,0 +1,149 @@ +package org + +import ( + "errors" + "testing" + + agentgatedb "github.com/Clawdlinux/agentgate/internal/db" + "golang.org/x/crypto/bcrypt" +) + +func testStore(t *testing.T) *Store { + t.Helper() + database, err := agentgatedb.Open(":memory:") + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := agentgatedb.RunMigrations(database); err != nil { + t.Fatalf("run migrations: %v", err) + } + return NewStore(database) +} + +func TestStoreCreateOrgAndAdmin(t *testing.T) { + t.Parallel() + store := testStore(t) + + organization, err := store.CreateOrg(t.Context(), "Example Inc.") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + admin, err := store.CreateAdmin(t.Context(), organization.ID, "Admin@Example.com", "correct horse battery staple") + if err != nil { + t.Fatalf("CreateAdmin: %v", err) + } + if admin.OrgID != organization.ID { + t.Fatalf("admin OrgID = %q, want %q", admin.OrgID, organization.ID) + } + if admin.Email != "admin@example.com" { + t.Fatalf("admin Email = %q, want normalized email", admin.Email) + } + + var passwordHash string + if err := store.db.QueryRow("SELECT password_hash FROM org_admins WHERE id = ?", admin.ID).Scan(&passwordHash); err != nil { + t.Fatalf("query password hash: %v", err) + } + cost, err := bcrypt.Cost([]byte(passwordHash)) + if err != nil { + t.Fatalf("bcrypt cost: %v", err) + } + if cost != passwordHashCost { + t.Fatalf("bcrypt cost = %d, want %d", cost, passwordHashCost) + } +} + +func TestStoreAuthenticate(t *testing.T) { + t.Parallel() + store := testStore(t) + organization, err := store.CreateOrg(t.Context(), "Example Inc.") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + admin, err := store.CreateAdmin(t.Context(), organization.ID, "admin@example.com", "correct horse battery staple") + if err != nil { + t.Fatalf("CreateAdmin: %v", err) + } + + tests := []struct { + name string + email string + password string + wantErr error + }{ + {name: "valid credentials", email: " ADMIN@example.com ", password: "correct horse battery staple"}, + {name: "unknown email", email: "unknown@example.com", password: "correct horse battery staple", wantErr: ErrInvalidCredentials}, + {name: "wrong password", email: "admin@example.com", password: "wrong password", wantErr: ErrInvalidCredentials}, + {name: "missing password", email: "admin@example.com", wantErr: ErrInvalidCredentials}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := store.Authenticate(t.Context(), test.email, test.password) + if !errors.Is(err, test.wantErr) { + t.Fatalf("Authenticate error = %v, want %v", err, test.wantErr) + } + if test.wantErr == nil && (actual.ID != admin.ID || actual.OrgID != organization.ID) { + t.Fatalf("Authenticate admin = %#v, want ID %q org %q", actual, admin.ID, organization.ID) + } + }) + } +} + +func TestStoreCreateAdminRejectsInvalidOrganizationAndDuplicateEmail(t *testing.T) { + t.Parallel() + store := testStore(t) + organization, err := store.CreateOrg(t.Context(), "Example Inc.") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + if _, err := store.CreateAdmin(t.Context(), "missing", "admin@example.com", "password"); !errors.Is(err, ErrOrgNotFound) { + t.Fatalf("CreateAdmin missing organization error = %v, want %v", err, ErrOrgNotFound) + } + if _, err := store.CreateAdmin(t.Context(), organization.ID, "Admin@Example.com", "password"); err != nil { + t.Fatalf("first CreateAdmin: %v", err) + } + if _, err := store.CreateAdmin(t.Context(), organization.ID, "admin@example.com", "password"); err == nil { + t.Fatal("duplicate normalized email was accepted") + } + if _, err := store.CreateAdmin(t.Context(), organization.ID, "long-password@example.com", string(make([]byte, maxPasswordBytes+1))); !errors.Is(err, ErrPasswordTooLong) { + t.Fatalf("CreateAdmin long password error = %v, want %v", err, ErrPasswordTooLong) + } +} + +func TestStoreAdminCount(t *testing.T) { + t.Parallel() + store := testStore(t) + if count, err := store.AdminCount(t.Context()); err != nil || count != 0 { + t.Fatalf("AdminCount = (%d, %v), want (0, nil)", count, err) + } + organization, err := store.CreateOrg(t.Context(), "Example Inc.") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + if _, err := store.CreateAdmin(t.Context(), organization.ID, "admin@example.com", "password"); err != nil { + t.Fatalf("CreateAdmin: %v", err) + } + if count, err := store.AdminCount(t.Context()); err != nil || count != 1 { + t.Fatalf("AdminCount = (%d, %v), want (1, nil)", count, err) + } +} + +func TestStoreBootstrapAdmin(t *testing.T) { + t.Parallel() + store := testStore(t) + organization, admin, created, err := store.BootstrapAdmin(t.Context(), "Example Inc.", "admin@example.com", "correct horse battery staple") + if err != nil { + t.Fatalf("BootstrapAdmin: %v", err) + } + if !created || organization == nil || admin == nil || admin.OrgID != organization.ID { + t.Fatalf("BootstrapAdmin = (%#v, %#v, %t), want created organization and admin", organization, admin, created) + } + organization, admin, created, err = store.BootstrapAdmin(t.Context(), "Other Inc.", "other@example.com", "password") + if err != nil { + t.Fatalf("second BootstrapAdmin: %v", err) + } + if created || organization != nil || admin != nil { + t.Fatalf("second BootstrapAdmin = (%#v, %#v, %t), want no-op", organization, admin, created) + } +} diff --git a/tests/integration/gateway_test.go b/tests/integration/gateway_test.go index 009289d..85d6f21 100644 --- a/tests/integration/gateway_test.go +++ b/tests/integration/gateway_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "path/filepath" "sync" "testing" @@ -20,6 +21,7 @@ import ( agentgatedb "github.com/Clawdlinux/agentgate/internal/db" "github.com/Clawdlinux/agentgate/internal/gateway" "github.com/Clawdlinux/agentgate/internal/oauth" + "github.com/Clawdlinux/agentgate/internal/org" "github.com/Clawdlinux/agentgate/internal/ratelimit" "github.com/Clawdlinux/agentgate/internal/receipt" "github.com/Clawdlinux/agentgate/internal/registry" @@ -125,7 +127,15 @@ services: }, vaultStore, masterKey, "http://localhost:8080", nil) // Admin. - adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, reg, "admin-secret", nil) + orgStore := org.NewStore(database) + organization, err := orgStore.CreateOrg(t.Context(), "Example Inc.") + if err != nil { + t.Fatal(err) + } + if _, err := orgStore.CreateAdmin(t.Context(), organization.ID, "admin@example.com", "correct horse battery staple"); err != nil { + t.Fatal(err) + } + adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, reg, orgStore, admin.NewSessionManager(masterKey, "http://localhost:8080"), "admin-secret", nil) // Gateway — real dependencies throughout, matching production wiring. gw := gateway.New(gateway.Config{ @@ -145,27 +155,15 @@ services: mux.HandleFunc("GET /v1/receipts/pubkey", signer.PubkeyHandler(signerStore)) // Admin routes. - mux.HandleFunc("POST /admin/keys", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-Admin-Secret") != "admin-secret" { - w.WriteHeader(401) - return - } - adminHandler.CreateKey(w, r) - }) - mux.HandleFunc("POST /admin/link", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-Admin-Secret") != "admin-secret" { - w.WriteHeader(401) - return - } - adminHandler.LinkAccount(w, r) - }) - mux.HandleFunc("POST /admin/tokens", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-Admin-Secret") != "admin-secret" { - w.WriteHeader(http.StatusUnauthorized) - return - } - adminHandler.ConnectBearerToken(w, r) - }) + mux.Handle("POST /admin/keys", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.CreateKey))) + mux.Handle("DELETE /admin/keys/{id}", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.RevokeKey))) + mux.Handle("POST /admin/link", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.LinkAccount))) + mux.Handle("POST /admin/tokens", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.ConnectBearerToken))) + mux.Handle("GET /admin/tokens/{user_id}", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.ListTokens))) + mux.Handle("GET /v1/receipts/export", adminHandler.RequireAdmin(receipt.ExportHandler(database, signerStore))) + mux.HandleFunc("GET /admin/login", adminHandler.LoginPage) + mux.HandleFunc("POST /admin/login", adminHandler.Login) + mux.HandleFunc("POST /admin/logout", adminHandler.Logout) // OAuth callback. mux.HandleFunc("GET /auth/callback/{service}", oauthHandler.ServeHTTP) @@ -188,6 +186,68 @@ func TestIntegration_Healthz(t *testing.T) { } } +func TestIntegration_AdminSessionAccessesProtectedRoutes(t *testing.T) { + ts, _, _ := setupIntegration(t, "") + form := url.Values{"email": {"admin@example.com"}, "password": {"correct horse battery staple"}} + loginRequest, err := http.NewRequest(http.MethodPost, ts.URL+"/admin/login", bytes.NewBufferString(form.Encode())) + if err != nil { + t.Fatal(err) + } + loginRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + loginResponse, err := client.Do(loginRequest) + if err != nil { + t.Fatal(err) + } + defer loginResponse.Body.Close() + if loginResponse.StatusCode != http.StatusSeeOther { + t.Fatalf("login status = %d, want %d", loginResponse.StatusCode, http.StatusSeeOther) + } + cookies := loginResponse.Cookies() + if len(cookies) != 1 { + t.Fatalf("login cookies = %#v, want one session cookie", cookies) + } + + tests := []struct { + name string + method string + path string + body string + }{ + {name: "create key", method: http.MethodPost, path: "/admin/keys", body: `{"name":"session-agent"}`}, + {name: "revoke key", method: http.MethodDelete, path: "/admin/keys/missing"}, + {name: "start oauth link", method: http.MethodPost, path: "/admin/link", body: `{"user_id":"user-42","service":"github"}`}, + {name: "connect bearer token", method: http.MethodPost, path: "/admin/tokens", body: `{"user_id":"user-42","service":"stripe","access_token":"test-token"}`}, + {name: "list tokens", method: http.MethodGet, path: "/admin/tokens/user-42"}, + {name: "export receipts", method: http.MethodGet, path: "/v1/receipts/export?from=1"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request, err := http.NewRequest(test.method, ts.URL+test.path, bytes.NewBufferString(test.body)) + if err != nil { + t.Fatal(err) + } + request.AddCookie(cookies[0]) + if isMutatingMethod(test.method) { + request.Header.Set("X-Requested-With", "AgentGate") + } + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusUnauthorized { + t.Fatalf("session request returned unauthorized") + } + }) + } +} + +func isMutatingMethod(method string) bool { + return method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete +} + func TestIntegration_ActUnauthorized(t *testing.T) { ts, _, _ := setupIntegration(t, "") body := `{"service":"github","action":"list_repos","on_behalf_of":"user-42"}`