From 69c023e77dc4343118e9cdb32ff4d5bc293347eb Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:14:14 +0530 Subject: [PATCH] feat: add bearer token connections Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --- README.md | 7 +++ cmd/agentgw/main.go | 3 +- internal/admin/handler.go | 37 ++++++++++++++- tests/integration/gateway_test.go | 78 ++++++++++++++++++++++++++++++- 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ea6637e..3d69c31 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,13 @@ Get OAuth authorization URL for user account linking. {"user_id": "user-42", "service": "github"} ``` +#### POST /admin/tokens +Connect a bearer token for Slack, Stripe, or Calendly. The token is encrypted +in the vault and never returned. +```json +{"user_id": "user-42", "service": "stripe", "access_token": ""} +``` + #### GET /admin/tokens/{user_id} List linked services for a user (no token values exposed). diff --git a/cmd/agentgw/main.go b/cmd/agentgw/main.go index 4a545e5..bbacaf6 100644 --- a/cmd/agentgw/main.go +++ b/cmd/agentgw/main.go @@ -148,7 +148,7 @@ func main() { } oauthHandler := oauth.NewCallbackHandler(oauthProviders, vaultStore, masterKey, publicURL, logger) - adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, adminSecret, logger) + adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, reg, adminSecret, logger) mux := http.NewServeMux() mux.Handle("/", srv) @@ -156,6 +156,7 @@ func main() { 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 /auth/callback/{service}", oauthHandler.ServeHTTP) diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 12c1759..835178f 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -4,9 +4,11 @@ import ( "encoding/json" "log/slog" "net/http" + "strings" "github.com/Clawdlinux/agentgate/internal/auth" "github.com/Clawdlinux/agentgate/internal/oauth" + "github.com/Clawdlinux/agentgate/internal/registry" "github.com/Clawdlinux/agentgate/internal/vault" ) @@ -15,12 +17,13 @@ type Handler struct { keyStore *auth.KeyStore oauthHandler *oauth.CallbackHandler vault vault.Store + registry *registry.Registry adminSecret string logger *slog.Logger } // NewHandler creates the admin handler. -func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, adminSecret string, logger *slog.Logger) *Handler { +func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, reg *registry.Registry, adminSecret string, logger *slog.Logger) *Handler { if logger == nil { logger = slog.Default() } @@ -28,6 +31,7 @@ func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, adm keyStore: ks, oauthHandler: oh, vault: v, + registry: reg, adminSecret: adminSecret, logger: logger, } @@ -134,6 +138,37 @@ func (h *Handler) LinkAccount(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"authorize_url": authorizeURL}) } +// ConnectBearerToken stores a bearer token for a configured bearer service. +func (h *Handler) ConnectBearerToken(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID string `json:"user_id"` + Service string `json:"service"` + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + req.UserID = strings.TrimSpace(req.UserID) + req.Service = strings.TrimSpace(req.Service) + if req.UserID == "" || req.Service == "" || strings.TrimSpace(req.AccessToken) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id, service, and access_token are required"}) + return + } + svc, err := h.registry.Get(req.Service) + if err != nil || svc.Auth.Type != "bearer" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "service does not accept bearer token connections"}) + return + } + if err := h.vault.Put(req.UserID, req.Service, vault.Token{AccessToken: req.AccessToken, TokenType: "Bearer"}); err != nil { + h.logger.Error("store bearer token failed", "service", req.Service, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to store bearer token"}) + return + } + + w.WriteHeader(http.StatusNoContent) +} + // ListTokens handles GET /admin/tokens/{user_id}. // Returns linked services (no token values!). func (h *Handler) ListTokens(w http.ResponseWriter, r *http.Request) { diff --git a/tests/integration/gateway_test.go b/tests/integration/gateway_test.go index 60f7000..009289d 100644 --- a/tests/integration/gateway_test.go +++ b/tests/integration/gateway_test.go @@ -125,7 +125,7 @@ services: }, vaultStore, masterKey, "http://localhost:8080", nil) // Admin. - adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, "admin-secret", nil) + adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, reg, "admin-secret", nil) // Gateway — real dependencies throughout, matching production wiring. gw := gateway.New(gateway.Config{ @@ -159,6 +159,13 @@ services: } 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) + }) // OAuth callback. mux.HandleFunc("GET /auth/callback/{service}", oauthHandler.ServeHTTP) @@ -365,6 +372,75 @@ func TestIntegration_AdminLinkAccount(t *testing.T) { } } +func TestIntegration_AdminConnectBearerTokenAndDispatch(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer stripe-test-token" { + t.Errorf("upstream authorization = %q, want bearer token", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[]}`) + })) + defer upstream.Close() + + ts, database, apiKey := setupIntegration(t, upstream.URL) + connectBody := `{"user_id":"bearer-user","service":"stripe","access_token":"stripe-test-token"}` + connectReq, _ := http.NewRequest("POST", ts.URL+"/admin/tokens", bytes.NewReader([]byte(connectBody))) + connectReq.Header.Set("Content-Type", "application/json") + connectReq.Header.Set("X-Admin-Secret", "admin-secret") + connectResp, err := http.DefaultClient.Do(connectReq) + if err != nil { + t.Fatal(err) + } + defer connectResp.Body.Close() + if connectResp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(connectResp.Body) + t.Fatalf("connect status = %d, want 204; body=%s", connectResp.StatusCode, body) + } + if body, _ := io.ReadAll(connectResp.Body); len(body) != 0 { + t.Fatalf("connect response must not return the token, got %q", body) + } + + actBody := `{"service":"stripe","action":"list_invoices","on_behalf_of":"bearer-user"}` + actReq, _ := http.NewRequest("POST", ts.URL+"/v1/act", bytes.NewReader([]byte(actBody))) + actReq.Header.Set("Authorization", "Bearer "+apiKey) + actReq.Header.Set("Content-Type", "application/json") + actResp, err := http.DefaultClient.Do(actReq) + if err != nil { + t.Fatal(err) + } + defer actResp.Body.Close() + if actResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(actResp.Body) + t.Fatalf("action status = %d, want 200; body=%s", actResp.StatusCode, body) + } + + var rawToken []byte + if err := database.QueryRow(`SELECT access_token_enc FROM tokens WHERE user_id = 'bearer-user' AND service = 'stripe'`).Scan(&rawToken); err != nil { + t.Fatalf("read stored token: %v", err) + } + if bytes.Contains(rawToken, []byte("stripe-test-token")) { + t.Fatal("stored bearer token is plaintext") + } +} + +func TestIntegration_AdminConnectBearerTokenRejectsOAuthService(t *testing.T) { + ts, _, _ := setupIntegration(t, "") + body := `{"user_id":"user-99","service":"github","access_token":"must-not-store"}` + req, _ := http.NewRequest("POST", ts.URL+"/admin/tokens", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Admin-Secret", "admin-secret") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 400; body=%s", resp.StatusCode, body) + } +} + func TestIntegration_RateLimiting(t *testing.T) { configs := map[string]ratelimit.Config{ "stripe": {RequestsPerSecond: 1, Burst: 1},