Skip to content
Open
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
1 change: 0 additions & 1 deletion forge-go/agent/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ type ServerConfig struct {
RaftBindAddr string
GossipBindAddr string
GossipJoinPeers []string
OAuthTokenStore string
StateStore string
TelemetryEnabled bool
TelemetryMode string
Expand Down
3 changes: 2 additions & 1 deletion forge-go/agent/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,8 @@ func StartServer(ctx context.Context, cfg *ServerConfig) error {
httpServer := api.NewServer(db, statusStore, controlPlane, msgBackend, fileStore, cfg.ListenAddress).
WithObservability(cfg.TelemetryMode, cfg.TelemetrySQLiteDBPath).
WithModelFit("", cfg.DependencyConfig, nil).
WithOAuth(cfg.OAuthTokenStore)
WithOAuth().
WithSecrets()
wg.Add(1)
go func() {
defer wg.Done()
Expand Down
32 changes: 23 additions & 9 deletions forge-go/api/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ func (s *Server) registerOAuthRoutes(router *gin.Engine, prefix string) {
s.oauthRoutePrefix = prefix
router.GET(prefix+"/oauth/organizations/:org_id/providers", wrapHTTPWithPathValues(s.handleOAuthListProviders(), "org_id"))
router.POST(prefix+"/oauth/organizations/:org_id/providers/:provider_id/authorize", wrapHTTPWithPathValues(s.handleOAuthAuthorize(), "org_id", "provider_id"))
router.GET(prefix+"/oauth/organizations/:org_id/providers/:provider_id/callback", wrapHTTPWithPathValues(s.handleOAuthCallback(), "org_id", "provider_id"))
// Single, provider- and org-agnostic callback for every provider: the flow is
// identified entirely by the opaque state inside ExchangeCode.
router.GET(prefix+"/oauth/callback", wrapHTTP(s.handleOAuthCallback()))
router.GET(prefix+"/oauth/organizations/:org_id/providers/:provider_id/status", wrapHTTPWithPathValues(s.handleOAuthStatus(), "org_id", "provider_id"))
router.DELETE(prefix+"/oauth/organizations/:org_id/providers/:provider_id", wrapHTTPWithPathValues(s.handleOAuthDisconnect(), "org_id", "provider_id"))
}
Expand Down Expand Up @@ -68,8 +70,18 @@ func (s *Server) handleOAuthAuthorize() http.HandlerFunc {
if !decodeJSONBody(w, r, &req) {
return
}
if strings.TrimSpace(req.ClientID) == "" || strings.TrimSpace(req.ClientSecret) == "" {
ReplyError(w, http.StatusUnprocessableEntity, "clientId and clientSecret are required")
clientID := strings.TrimSpace(req.ClientID)
clientSecret := strings.TrimSpace(req.ClientSecret)
// Validation depends on the provider's auth mode. Static providers
// require caller-supplied credentials; providers using Dynamic Client
// Registration register their own and must not be sent any.
if s.oauthManager.RequiresClientCredentials(providerID) {
if clientID == "" || clientSecret == "" {
ReplyError(w, http.StatusUnprocessableEntity, "clientId and clientSecret are required")
return
}
} else if clientID != "" || clientSecret != "" {
ReplyError(w, http.StatusUnprocessableEntity, "this provider uses dynamic client registration; do not send clientId or clientSecret")
return
}

Expand All @@ -80,10 +92,12 @@ func (s *Server) handleOAuthAuthorize() http.HandlerFunc {
}
redirectURL := req.RedirectURL
if redirectURL == "" {
redirectURL = s.publicBaseURL() + s.oauthRoutePrefix + "/oauth/organizations/" + orgID + "/providers/" + providerID + "/callback"
// Single constant callback for all providers; the flow is identified
// by state at callback time.
redirectURL = s.publicBaseURL() + s.oauthRoutePrefix + "/oauth/callback"
}

authURL, _, err := s.oauthManager.GetAuthURL(orgID, providerID, req.ClientID, req.ClientSecret, redirectURL)
authURL, _, err := s.oauthManager.GetAuthURL(r.Context(), orgID, providerID, clientID, clientSecret, redirectURL)
if err != nil {
ReplyError(w, http.StatusInternalServerError, err.Error())
return
Expand All @@ -98,7 +112,6 @@ func (s *Server) handleOAuthAuthorize() http.HandlerFunc {

func (s *Server) handleOAuthCallback() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
providerID := strings.TrimSpace(r.PathValue("provider_id"))
code := strings.TrimSpace(r.URL.Query().Get("code"))
state := strings.TrimSpace(r.URL.Query().Get("state"))

Expand All @@ -111,9 +124,10 @@ func (s *Server) handleOAuthCallback() http.HandlerFunc {
return
}

// userID is recovered from pendingFlow via state — no header needed here
// since the callback is driven by the browser redirect.
if err := s.oauthManager.ExchangeCode(r.Context(), code, state); err != nil {
// The org and provider are recovered from the pendingFlow via state, so
// the callback URL itself carries no path parameters.
providerID, err := s.oauthManager.ExchangeCode(r.Context(), code, state)
if err != nil {
writeCallbackPage(w, false, "Failed to connect: "+err.Error())
return
}
Expand Down
189 changes: 189 additions & 0 deletions forge-go/api/secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package api

import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/rustic-ai/forge/forge-go/secrets"
)

// maxSecretValueBytes caps the size of a stored secret value.
const maxSecretValueBytes = 64 * 1024

type createSecretRequest struct {
Name string `json:"name"`
// Value is the secret, base64-encoded (standard encoding). The handler
// decodes it before storage; the raw bytes are never persisted encoded.
Value string `json:"value"`
}

type updateSecretRequest struct {
// Value is the secret, base64-encoded (standard encoding). See
// createSecretRequest.Value.
Value string `json:"value"`
}

// validateSecretName restricts secret names to non-empty alphanumeric and
// underscore characters. This keeps names safe as keychain accounts and free of
// the "|" StoreKey delimiter.
func validateSecretName(name string) error {
if name == "" {
return fmt.Errorf("secret name is required")
}
if len(name) > 255 {
return fmt.Errorf("secret name must be at most 255 characters")
}
for _, r := range name {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_') {
return fmt.Errorf("secret name must contain only alphanumeric characters and underscores")
}
}
return nil
}

// decodeSecretValue decodes the base64-encoded value carried in create/update
// requests and enforces the size limit on the decoded bytes (what is actually
// stored), not on the inflated base64 representation.
func decodeSecretValue(encoded string) (string, error) {
if encoded == "" {
return "", fmt.Errorf("secret value is required")
}
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("secret value must be valid base64")
}
if len(decoded) == 0 {
return "", fmt.Errorf("secret value is required")
}
// Reject an all-whitespace value: it is almost always a fat-finger and
// behaves like an empty secret. Internal/leading/trailing whitespace on an
// otherwise non-blank value is preserved, since it can be significant.
if strings.TrimSpace(string(decoded)) == "" {
return "", fmt.Errorf("secret value must not be blank")
}
if len(decoded) > maxSecretValueBytes {
return "", fmt.Errorf("secret value must be at most %d bytes", maxSecretValueBytes)
}
return string(decoded), nil
}

// registerSecretRoutes wires the org-scoped secret CRUD endpoints. There is
// deliberately no endpoint that returns a secret value; values are only ever
// read internally through the SecretProvider chain.
func (s *Server) registerSecretRoutes(router *gin.Engine, prefix string) {
router.GET(prefix+"/organizations/:org_id/secrets", wrapHTTPWithPathValues(s.handleListSecrets(), "org_id"))
router.POST(prefix+"/organizations/:org_id/secrets", wrapHTTPWithPathValues(s.handleCreateSecret(), "org_id"))
router.PUT(prefix+"/organizations/:org_id/secrets/:name", wrapHTTPWithPathValues(s.handleUpdateSecret(), "org_id", "name"))
router.DELETE(prefix+"/organizations/:org_id/secrets/:name", wrapHTTPWithPathValues(s.handleDeleteSecret(), "org_id", "name"))
}

func (s *Server) handleListSecrets() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID := strings.TrimSpace(r.PathValue("org_id"))
if err := validateOrgID(orgID); err != nil {
ReplyError(w, http.StatusBadRequest, err.Error())
return
}
names, err := s.secretManager.List(orgID)
if err != nil {
ReplyError(w, http.StatusInternalServerError, err.Error())
return
}
ReplyJSON(w, http.StatusOK, map[string]interface{}{"secrets": names})
}
}

func (s *Server) handleCreateSecret() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID := strings.TrimSpace(r.PathValue("org_id"))
if err := validateOrgID(orgID); err != nil {
ReplyError(w, http.StatusBadRequest, err.Error())
return
}
var req createSecretRequest
if !decodeJSONBody(w, r, &req) {
return
}
name := strings.TrimSpace(req.Name)
if err := validateSecretName(name); err != nil {
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
return
}
value, err := decodeSecretValue(req.Value)
if err != nil {
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
return
}
err = s.secretManager.Set(orgID, name, value)
if errors.Is(err, secrets.ErrSecretExists) {
ReplyError(w, http.StatusConflict, "secret already exists: "+name)
return
}
if err != nil {
ReplyError(w, http.StatusInternalServerError, err.Error())
return
}
ReplyJSON(w, http.StatusCreated, map[string]interface{}{"name": name})
}
}

func (s *Server) handleUpdateSecret() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID := strings.TrimSpace(r.PathValue("org_id"))
if err := validateOrgID(orgID); err != nil {
ReplyError(w, http.StatusBadRequest, err.Error())
return
}
name := strings.TrimSpace(r.PathValue("name"))
if err := validateSecretName(name); err != nil {
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
return
}
var req updateSecretRequest
if !decodeJSONBody(w, r, &req) {
return
}
value, err := decodeSecretValue(req.Value)
if err != nil {
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
return
}
err = s.secretManager.Update(orgID, name, value)
if errors.Is(err, secrets.ErrSecretNotFound) {
ReplyError(w, http.StatusNotFound, "secret not found: "+name)
return
}
if err != nil {
ReplyError(w, http.StatusInternalServerError, err.Error())
return
}
ReplyJSON(w, http.StatusOK, map[string]interface{}{"name": name})
}
}

func (s *Server) handleDeleteSecret() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID := strings.TrimSpace(r.PathValue("org_id"))
if err := validateOrgID(orgID); err != nil {
ReplyError(w, http.StatusBadRequest, err.Error())
return
}
name := strings.TrimSpace(r.PathValue("name"))
if err := validateSecretName(name); err != nil {
ReplyError(w, http.StatusUnprocessableEntity, err.Error())
return
}
if !s.secretManager.Delete(orgID, name) {
ReplyError(w, http.StatusNotFound, "secret not found: "+name)
return
}
ReplyJSON(w, http.StatusOK, map[string]interface{}{
"name": name,
"deleted": true,
})
}
}
Loading
Loading