Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<stripe-token>"}
```

#### GET /admin/tokens/{user_id}
List linked services for a user (no token values exposed).

Expand Down
3 changes: 2 additions & 1 deletion cmd/agentgw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,15 @@ 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)
mux.HandleFunc("GET /v1/receipts/pubkey", signer.PubkeyHandler(signerStore))
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)
Expand Down
37 changes: 36 additions & 1 deletion internal/admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -15,19 +17,21 @@ 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()
}
return &Handler{
keyStore: ks,
oauthHandler: oh,
vault: v,
registry: reg,
adminSecret: adminSecret,
logger: logger,
}
Expand Down Expand Up @@ -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) == "" {
Comment on lines +152 to +154
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) {
Expand Down
78 changes: 77 additions & 1 deletion tests/integration/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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},
Expand Down
Loading