From 048dd9a94e961f40b41dcefb173afac29e5de877 Mon Sep 17 00:00:00 2001 From: Shiti Saxena Date: Mon, 13 Jul 2026 12:39:52 -0700 Subject: [PATCH 1/2] fix: support oauth dcrp - commonly used by remote MCP servers --- forge-go/agent/config.go | 1 - forge-go/agent/server.go | 2 +- forge-go/api/oauth.go | 33 ++- forge-go/api/server.go | 26 +- forge-go/command/server.go | 3 - forge-go/conf/oauth-providers.yaml | 37 ++- forge-go/go.mod | 33 +-- forge-go/go.sum | 72 ++--- forge-go/oauth/client_credentials_store.go | 89 +++++++ forge-go/oauth/config.go | 52 +++- forge-go/oauth/discovery.go | 187 +++++++++++++ forge-go/oauth/discovery_test.go | 92 +++++++ forge-go/oauth/helper.go | 13 + .../keychain_client_credentials_store.go | 54 ++++ forge-go/oauth/known_endpoints.go | 46 ---- forge-go/oauth/manager.go | 247 +++++++++++++++--- forge-go/oauth/manager_test.go | 177 ++++++++++++- 17 files changed, 993 insertions(+), 171 deletions(-) create mode 100644 forge-go/oauth/client_credentials_store.go create mode 100644 forge-go/oauth/discovery.go create mode 100644 forge-go/oauth/discovery_test.go create mode 100644 forge-go/oauth/keychain_client_credentials_store.go delete mode 100644 forge-go/oauth/known_endpoints.go diff --git a/forge-go/agent/config.go b/forge-go/agent/config.go index e39061a..52b4387 100644 --- a/forge-go/agent/config.go +++ b/forge-go/agent/config.go @@ -27,7 +27,6 @@ type ServerConfig struct { RaftBindAddr string GossipBindAddr string GossipJoinPeers []string - OAuthTokenStore string StateStore string TelemetryEnabled bool TelemetryMode string diff --git a/forge-go/agent/server.go b/forge-go/agent/server.go index f2c72fb..4309ea1 100644 --- a/forge-go/agent/server.go +++ b/forge-go/agent/server.go @@ -339,7 +339,7 @@ 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() wg.Add(1) go func() { defer wg.Done() diff --git a/forge-go/api/oauth.go b/forge-go/api/oauth.go index b6ec970..29a25fc 100644 --- a/forge-go/api/oauth.go +++ b/forge-go/api/oauth.go @@ -40,6 +40,11 @@ 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")) + // 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())) + // Backward-compat alias for providers registered with the older org-scoped + // callback URL. Shares the same state-driven handler. router.GET(prefix+"/oauth/organizations/:org_id/providers/:provider_id/callback", wrapHTTPWithPathValues(s.handleOAuthCallback(), "org_id", "provider_id")) 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")) @@ -68,8 +73,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 } @@ -80,10 +95,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 @@ -98,7 +115,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")) @@ -111,9 +127,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 } diff --git a/forge-go/api/server.go b/forge-go/api/server.go index 27de691..2f71f47 100644 --- a/forge-go/api/server.go +++ b/forge-go/api/server.go @@ -58,26 +58,32 @@ func NewServer(db store.Store, statusStore supervisor.AgentStatusStore, controlP return s } -// WithOAuth initialises OAuth support. kind selects the token store backend -// ("memory" or "keychain"); if empty, FORGE_OAUTH_TOKEN_STORE is consulted. -func (s *Server) WithOAuth(kind string) *Server { - if kind == "" { - kind = os.Getenv("FORGE_OAUTH_TOKEN_STORE") - } +// WithOAuth initialises OAuth support. Both store backends are selected from the +// environment: FORGE_OAUTH_TOKEN_STORE for tokens and FORGE_OAUTH_CLIENT_STORE for +// DCR client credentials, each "memory" (default) or "keychain". +func (s *Server) WithOAuth() *Server { + kind := os.Getenv("FORGE_OAUTH_TOKEN_STORE") cfg, err := oauth.LoadProvidersConfig(forgepath.OAuthProvidersConfigPath()) if err != nil { fmt.Printf("WARN: failed to load OAuth providers config: %v\n", err) return s } - if len(cfg.Providers) == 0 { - return s - } store, err := oauth.NewTokenStore(kind) if err != nil { fmt.Printf("WARN: %v; falling back to in-memory token store\n", err) store, _ = oauth.NewTokenStore("memory") } - s.oauthManager = oauth.NewManagerWithStore(cfg, store) + // Client credentials use their own backend (empty -> in-memory). + credStore, err := oauth.NewClientCredentialsStore(os.Getenv("FORGE_OAUTH_CLIENT_STORE")) + if err != nil { + fmt.Printf("WARN: %v; falling back to in-memory client credentials store\n", err) + credStore, _ = oauth.NewClientCredentialsStore("memory") + } + // FORGE_OAUTH_CLIENT_NAME / FORGE_OAUTH_CLIENT_URI override the client_name + // and client_uri registered with DCR providers; empty keeps the defaults + // (name "Forge", uri omitted). + s.oauthManager = oauth.NewManagerWithStores(cfg, store, credStore, + oauth.WithDynamicClient(os.Getenv("FORGE_OAUTH_CLIENT_NAME"), os.Getenv("FORGE_OAUTH_CLIENT_URI"))) keychain.SetOAuthManager(s.oauthManager) return s } diff --git a/forge-go/command/server.go b/forge-go/command/server.go index ef312c4..9e6c378 100644 --- a/forge-go/command/server.go +++ b/forge-go/command/server.go @@ -42,7 +42,6 @@ var ( serverTelemetrySQLiteBin string serverTelemetrySQLiteDB string serverTelemetrySQLitePort int - serverOAuthTokenStore string ) func init() { @@ -75,7 +74,6 @@ func init() { ServerCmd.Flags().StringVar(&serverTelemetrySQLiteBin, "otel-sqlite-binary", "", "Path to sqlite-otel binary for desktop_sqlite mode") ServerCmd.Flags().StringVar(&serverTelemetrySQLiteDB, "otel-sqlite-db-path", "", "Path to sqlite-otel SQLite database file") ServerCmd.Flags().IntVar(&serverTelemetrySQLitePort, "otel-sqlite-port", 4318, "Port for sqlite-otel OTLP/HTTP listener") - ServerCmd.Flags().StringVar(&serverOAuthTokenStore, "oauth-token-store", "", `OAuth token store backend: "memory" (default) or "keychain"`) RootCmd.AddCommand(ServerCmd) } @@ -125,7 +123,6 @@ var ServerCmd = &cobra.Command{ ClientDefaultTransport: serverClientTransport, ClientZMQBridgeMode: serverClientZMQBridgeMode, ClientAttachProcessTree: serverClientAttachTree, - OAuthTokenStore: serverOAuthTokenStore, StateStore: serverStateStore, TelemetryEnabled: serverTelemetryEnabled, TelemetryMode: serverTelemetryMode, diff --git a/forge-go/conf/oauth-providers.yaml b/forge-go/conf/oauth-providers.yaml index cb81df5..7f0d0d8 100644 --- a/forge-go/conf/oauth-providers.yaml +++ b/forge-go/conf/oauth-providers.yaml @@ -5,13 +5,21 @@ # are supplied at runtime by the caller (e.g. the Electron app) when initiating # the auth flow. # -# For well-known providers (github, google, google-drive, slack, microsoft, -# notion), auth_url and token_url are optional — Forge fills them in -# automatically. +# For well-known providers (github, google, google-drive, slack, microsoft), +# auth_url and token_url are optional — Forge fills them in from the +# golang.org/x/oauth2 library. For any other static provider, set auth_url and +# token_url explicitly. # # use_pkce: controls PKCE (S256 code challenge). Defaults to true. Set to # false for providers that do not support it. # +# Dynamic Client Registration (DCR / RFC 7591): for OAuth-protected resources +# that support it (e.g. remote MCP servers), set use_dcrp: true and provide +# resource_url. Forge discovers the auth/token/registration endpoints +# (RFC 9728 + RFC 8414) and registers its own client on demand — no +# client_id/client_secret needed. resource_url is only used in this mode; it is +# not a general endpoint-discovery mechanism for traditional OAuth providers. +# # Add a new provider by adding an entry under `providers`. Restart Forge after # editing this file. @@ -28,11 +36,18 @@ providers: # display_name: Slack # description: Send messages and read channel history to integrate agents with your Slack workspace. # scopes: [channels:history, channels:read, chat:write, files:read, users:read, team:read] -# use_pkce: false - - # Custom service example — uncomment and fill in: - # my-service: - # display_name: My Service - # description: What this integration is used for. - # auth_url: https://example.com/oauth/authorize - # token_url: https://example.com/oauth/token +# +# Custom service example — uncomment and fill in: +# my-service: +# display_name: My Service +# description: What this integration is used for. +# auth_url: https://example.com/oauth/authorize +# token_url: https://example.com/oauth/token +# use_pkce: false +# +# Dynamic Client Registration example (e.g. a remote MCP server): +# notion-mcp: +# display_name: Notion (MCP) +# description: Connect to the Notion MCP server. +# resource_url: https://mcp.notion.com/mcp +# use_dcrp: true diff --git a/forge-go/go.mod b/forge-go/go.mod index 49eb09e..0698a84 100644 --- a/forge-go/go.mod +++ b/forge-go/go.mod @@ -12,9 +12,10 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/hashicorp/memberlist v0.6.0 github.com/hashicorp/raft v1.7.3 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/nats-io/nats-server/v2 v2.14.3 github.com/nats-io/nats.go v1.52.0 - github.com/oapi-codegen/runtime v1.4.2 + github.com/oapi-codegen/runtime v1.5.0 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.21.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 @@ -51,7 +52,7 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect - cloud.google.com/go/storage v1.63.0 // indirect + cloud.google.com/go/storage v1.63.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.58.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.58.0 // indirect @@ -61,10 +62,10 @@ require ( github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.29 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.28 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect - github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.1 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect @@ -72,11 +73,11 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect github.com/aws/smithy-go v1.27.3 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect @@ -95,7 +96,7 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.10.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/fatih/color v1.19.0 // indirect @@ -142,7 +143,7 @@ require ( github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-sqlite3 v1.14.47 // indirect + github.com/mattn/go-sqlite3 v1.14.48 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -171,6 +172,8 @@ require ( github.com/quic-go/quic-go v0.60.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect @@ -196,9 +199,9 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.48.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/genproto v0.0.0-20260706201446-f0a921348800 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/genproto v0.0.0-20260713194154-142c488ef3d3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260713194154-142c488ef3d3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260713194154-142c488ef3d3 // indirect google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gotest.tools/v3 v3.5.2 // indirect diff --git a/forge-go/go.sum b/forge-go/go.sum index c56fbf0..0ccd57b 100644 --- a/forge-go/go.sum +++ b/forge-go/go.sum @@ -12,12 +12,12 @@ cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k= cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= -cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE= -cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ= -cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE= +cloud.google.com/go/storage v1.63.1 h1:CYXILV9G4CH0C18IQ9+V0h4XiqD2LhKnMLO0o7uJWNs= +cloud.google.com/go/storage v1.63.1/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -50,14 +50,14 @@ github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= -github.com/aws/aws-sdk-go-v2/config v1.32.29 h1:BcMHHnpiWKogf+gGfpj3K1w+Sktz29XDo/cPSAPO3FU= -github.com/aws/aws-sdk-go-v2/config v1.32.29/go.mod h1:+Kbhn8Es4kPUph3F/0W7avykytc+Jh2Ld9/msv9ljV4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q= +github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk= +github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= -github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.1 h1:in1rmZHNBr3OntzYxPRKnjW7eCYLrTExI0VQXR+C8AY= -github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.1/go.mod h1:KYWha/ENCwaAgWraz9liTvWPx59WRs3yJDSu1cjk5io= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.2 h1:4xH65pNJoefR82fgVZ7lc1tmlrdU2Wq4AvWe41ikc8k= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.2/go.mod h1:2npm33fpF/XXHpaDtQPfV7WU0tEaWxdyDUiRCP0N8js= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= @@ -72,16 +72,16 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I= -github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= -github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w= -github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= -github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo= -github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= -github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU= -github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 h1:LkBKxAOE5WXjlFuFZqPG1rREnl6I6QCMElcXFDEidos= +github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -137,8 +137,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= @@ -192,6 +192,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -304,8 +306,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= -github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= @@ -319,6 +321,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -345,8 +349,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= -github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE= -github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oapi-codegen/runtime v1.5.0 h1:aiil4QnH+eiWYSO60eaYZ4aur7sJH3rz6BvT5EBFnxc= +github.com/oapi-codegen/runtime v1.5.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -404,6 +408,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -550,12 +558,12 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.288.0 h1:glhO/J88obKP5I269W3hB73dvBKrjU56ZfmNlNXpgTU= google.golang.org/api v0.288.0/go.mod h1:lM2kYRzYUCBY91P9h6VF1PYmvhxii3O5hji37qRvIcY= -google.golang.org/genproto v0.0.0-20260706201446-f0a921348800 h1:NDCaohnq5LRpt5soWpH6+U2tsRCpcgD0ftByMJAAAt4= -google.golang.org/genproto v0.0.0-20260706201446-f0a921348800/go.mod h1:J1jBkXm41jiQyoU7J/Q2o2jqrZZwyFYMOKcZwWCIzDM= -google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4= -google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260713194154-142c488ef3d3 h1:cJCrQXppRLNPo1s6IiihpRUKTt00R8Ui89t3hz3qxjE= +google.golang.org/genproto v0.0.0-20260713194154-142c488ef3d3/go.mod h1:v47/Kzm6KzB/2uGxuDjGww+7Dk7ny/VFIaIw4WxEtu0= +google.golang.org/genproto/googleapis/api v0.0.0-20260713194154-142c488ef3d3 h1:S6V0N10wKhLPdmiy9LfpCkS4dWczv3FahcmRZqszFp0= +google.golang.org/genproto/googleapis/api v0.0.0-20260713194154-142c488ef3d3/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260713194154-142c488ef3d3 h1:DwViqVhQimfB1nXWJ+mK+edj/X69P/p+tzan0JE7j6k= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260713194154-142c488ef3d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/forge-go/oauth/client_credentials_store.go b/forge-go/oauth/client_credentials_store.go new file mode 100644 index 0000000..602a7c8 --- /dev/null +++ b/forge-go/oauth/client_credentials_store.go @@ -0,0 +1,89 @@ +package oauth + +import ( + "fmt" + "sync" + "time" +) + +// clientPrefix namespaces client-credential keychain entries so they do not +// collide with token entries (which use the "oauth:" prefix). +const clientPrefix = "oauth-client:" + +func clientStoreKey(providerID string) string { + return clientPrefix + providerID +} + +// clientCredentials holds an OAuth2 client_id/client_secret pair. For providers +// using Dynamic Client Registration (RFC 7591) these are issued by the provider +// and persisted so they can be reused across reconnects and token refreshes. +type clientCredentials struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + // SecretExpiresAt is when the client_secret expires. Zero means it never + // expires (RFC 7591 client_secret_expires_at of 0). + SecretExpiresAt time.Time `json:"secret_expires_at"` +} + +// expired reports whether the client_secret has passed its expiry. +func (c *clientCredentials) expired() bool { + return !c.SecretExpiresAt.IsZero() && time.Now().After(c.SecretExpiresAt) +} + +// ClientCredentialsStore persists DCR-issued client credentials keyed by +// providerID. The registered client is global to the deployment (shared across +// all orgs), so credentials are not scoped per org — org isolation lives in the +// token store. It mirrors TokenStore so backends can be swapped independently of +// the token backend. +type ClientCredentialsStore interface { + SaveCredentials(providerID string, c *clientCredentials) error + LoadCredentials(providerID string) (*clientCredentials, bool) + DeleteCredentials(providerID string) bool +} + +// NewClientCredentialsStore creates a ClientCredentialsStore by name, matching +// the backends supported by NewTokenStore ("memory" or "keychain"). +func NewClientCredentialsStore(kind string) (ClientCredentialsStore, error) { + switch kind { + case "", "memory": + return NewInMemoryClientCredentialsStore(), nil + case "keychain": + return NewKeychainClientCredentialsStore(), nil + default: + return nil, fmt.Errorf("unknown oauth client credentials store %q; supported: memory, keychain", kind) + } +} + +// InMemoryClientCredentialsStore is the default in-process credentials store. +// Credentials are lost on restart, forcing re-registration. +type InMemoryClientCredentialsStore struct { + mu sync.Mutex + creds map[string]*clientCredentials +} + +func NewInMemoryClientCredentialsStore() *InMemoryClientCredentialsStore { + return &InMemoryClientCredentialsStore{creds: make(map[string]*clientCredentials)} +} + +func (s *InMemoryClientCredentialsStore) SaveCredentials(providerID string, c *clientCredentials) error { + s.mu.Lock() + defer s.mu.Unlock() + s.creds[clientStoreKey(providerID)] = c + return nil +} + +func (s *InMemoryClientCredentialsStore) LoadCredentials(providerID string) (*clientCredentials, bool) { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.creds[clientStoreKey(providerID)] + return c, ok +} + +func (s *InMemoryClientCredentialsStore) DeleteCredentials(providerID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + key := clientStoreKey(providerID) + _, ok := s.creds[key] + delete(s.creds, key) + return ok +} diff --git a/forge-go/oauth/config.go b/forge-go/oauth/config.go index 59e2178..81d3309 100644 --- a/forge-go/oauth/config.go +++ b/forge-go/oauth/config.go @@ -11,15 +11,49 @@ import ( // ProviderConfig defines a named OAuth2 provider. Scopes and endpoint URLs are // set here; credentials are supplied per-request by the caller. type ProviderConfig struct { - DisplayName string `yaml:"display_name"` - Description string `yaml:"description"` - AuthURL string `yaml:"auth_url"` - TokenURL string `yaml:"token_url"` - Scopes []string `yaml:"scopes"` - RedirectURL string `yaml:"redirect_url"` + DisplayName string `yaml:"display_name" json:"displayName,omitempty"` + Description string `yaml:"description" json:"description,omitempty"` + AuthURL string `yaml:"auth_url" json:"authUrl,omitempty"` + TokenURL string `yaml:"token_url" json:"tokenUrl,omitempty"` + Scopes []string `yaml:"scopes" json:"scopes,omitempty"` + RedirectURL string `yaml:"redirect_url" json:"redirectUrl,omitempty"` // UsePKCE controls whether PKCE (S256) is used. Defaults to true. // Set to false for providers that do not support it. - UsePKCE *bool `yaml:"use_pkce"` + UsePKCE *bool `yaml:"use_pkce" json:"usePkce,omitempty"` + + // ResourceURL is the OAuth2 protected-resource URL (e.g. an MCP server + // endpoint) used by the Dynamic Client Registration flow to discover the + // auth/token/registration endpoints (RFC 9728 + RFC 8414). It is only used + // when UseDCRP is set; traditional providers should configure AuthURL and + // TokenURL (or rely on the built-in endpoints) instead. + ResourceURL string `yaml:"resource_url" json:"resourceUrl,omitempty"` + // UseDCRP enables Dynamic Client Registration (RFC 7591): the endpoints are + // discovered from ResourceURL and the client_id/client_secret are registered + // with the provider on demand instead of being supplied by the caller. + // Requires ResourceURL. Defaults to false. + UseDCRP bool `yaml:"use_dcrp" json:"useDcrp,omitempty"` +} + +// RequiresClientCredentials reports whether the caller must supply a client_id +// and client_secret when starting an auth flow. Providers using Dynamic Client +// Registration register their own credentials and require none. +func (p ProviderConfig) RequiresClientCredentials() bool { + return !p.UseDCRP +} + +// Validate reports configuration errors that would otherwise surface only when +// an auth flow is started. resource_url and use_dcrp go together: DCR relies on +// the endpoints advertised by the resource, and resource_url is meaningless +// outside the DCR flow (it is not general endpoint discovery for traditional +// providers). +func (p ProviderConfig) Validate(id string) error { + if p.UseDCRP && p.ResourceURL == "" { + return fmt.Errorf("provider %q: use_dcrp requires resource_url", id) + } + if p.ResourceURL != "" && !p.UseDCRP { + return fmt.Errorf("provider %q: resource_url is only used with use_dcrp", id) + } + return nil } // pkce returns whether PKCE should be used for this provider (default: true). @@ -64,6 +98,10 @@ func LoadProvidersConfig(path string) (*ProvidersConfig, error) { p.AuthURL = interpolateEnv(p.AuthURL) p.TokenURL = interpolateEnv(p.TokenURL) p.RedirectURL = interpolateEnv(p.RedirectURL) + p.ResourceURL = interpolateEnv(p.ResourceURL) + if err := p.Validate(id); err != nil { + return nil, fmt.Errorf("parsing oauth providers config: %w", err) + } interpolated[id] = p } cfg.Providers = interpolated diff --git a/forge-go/oauth/discovery.go b/forge-go/oauth/discovery.go new file mode 100644 index 0000000..018db7c --- /dev/null +++ b/forge-go/oauth/discovery.go @@ -0,0 +1,187 @@ +package oauth + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/oauthex" + "golang.org/x/oauth2" +) + +// discoveryTTL is how long a resolved provider's endpoints are cached before +// being re-discovered. Authorization servers rarely rotate endpoints, but a +// bounded TTL lets changes propagate without a restart. +const discoveryTTL = 30 * time.Minute + +// Well-known metadata path suffixes passed to wellKnownURL. These are the two +// documents the discovery chain fetches. +const ( + wkProtectedResource = "oauth-protected-resource" // RFC 9728 + wkAuthorizationServer = "oauth-authorization-server" // RFC 8414 +) + +// resolvedProvider holds the endpoints and capabilities discovered for a +// resource_url provider via RFC 9728 (protected resource metadata) and +// RFC 8414 (authorization server metadata). +type resolvedProvider struct { + endpoint oauth2.Endpoint + registrationEndpoint string + scopesSupported []string + authMethods []string // token_endpoint_auth_methods_supported + supportsS256 bool + fetchedAt time.Time +} + +// supportsPublicClient reports whether the authorization server allows the +// "none" token endpoint auth method, i.e. registering a public client with no +// client_secret (paired with PKCE). +func (r *resolvedProvider) supportsPublicClient() bool { + for _, m := range r.authMethods { + if m == "none" { + return true + } + } + return false +} + +// tokenEndpointAuthMethod picks the auth method to request during Dynamic +// Client Registration: prefer a public client ("none") when supported, else +// fall back to client_secret_post / client_secret_basic. +func (r *resolvedProvider) tokenEndpointAuthMethod() string { + if r.supportsPublicClient() { + return "none" + } + for _, m := range r.authMethods { + if m == "client_secret_post" { + return "client_secret_post" + } + } + return "client_secret_basic" +} + +// discoverer performs and caches OAuth2 endpoint discovery for resource_url +// providers. It is safe for concurrent use. +type discoverer struct { + client *http.Client + + mu sync.Mutex + cache map[string]*resolvedProvider // key: resource URL +} + +func newDiscoverer(client *http.Client) *discoverer { + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + return &discoverer{client: client, cache: map[string]*resolvedProvider{}} +} + +// resolve discovers the authorization/token/registration endpoints for the +// given resource URL, caching the result for discoveryTTL. +func (d *discoverer) resolve(ctx context.Context, resourceURL string) (*resolvedProvider, error) { + resourceURL = strings.TrimSpace(resourceURL) + if resourceURL == "" { + return nil, fmt.Errorf("resolve: empty resource URL") + } + + d.mu.Lock() + if r, ok := d.cache[resourceURL]; ok && time.Since(r.fetchedAt) < discoveryTTL { + d.mu.Unlock() + return r, nil + } + d.mu.Unlock() + + r, err := d.discover(ctx, resourceURL) + if err != nil { + return nil, err + } + + d.mu.Lock() + d.cache[resourceURL] = r + d.mu.Unlock() + return r, nil +} + +func (d *discoverer) discover(ctx context.Context, resourceURL string) (*resolvedProvider, error) { + // Step 1 (RFC 9728): fetch protected resource metadata to learn the + // authorization server(s) backing this resource. + prmURL, err := wellKnownURL(resourceURL, wkProtectedResource) + if err != nil { + return nil, fmt.Errorf("building protected-resource metadata URL: %w", err) + } + prm, err := oauthex.GetProtectedResourceMetadata(ctx, prmURL, resourceURL, d.client) + if err != nil { + return nil, fmt.Errorf("fetching protected resource metadata for %q: %w", resourceURL, err) + } + if prm == nil || len(prm.AuthorizationServers) == 0 { + return nil, fmt.Errorf("resource %q advertises no authorization servers", resourceURL) + } + issuer := prm.AuthorizationServers[0] + + // Step 2 (RFC 8414): fetch authorization server metadata to learn the + // authorization, token, and registration endpoints. + asURL, err := wellKnownURL(issuer, wkAuthorizationServer) + if err != nil { + return nil, fmt.Errorf("building authorization-server metadata URL: %w", err) + } + as, err := oauthex.GetAuthServerMeta(ctx, asURL, issuer, d.client) + if err != nil { + return nil, fmt.Errorf("fetching authorization server metadata for %q: %w", issuer, err) + } + if as == nil { + return nil, fmt.Errorf("authorization server %q returned no metadata", issuer) + } + if as.AuthorizationEndpoint == "" || as.TokenEndpoint == "" { + return nil, fmt.Errorf("authorization server %q is missing authorization or token endpoint", issuer) + } + + s256 := false + for _, m := range as.CodeChallengeMethodsSupported { + if m == "S256" { + s256 = true + break + } + } + + return &resolvedProvider{ + endpoint: oauth2.Endpoint{ + AuthURL: as.AuthorizationEndpoint, + TokenURL: as.TokenEndpoint, + }, + registrationEndpoint: as.RegistrationEndpoint, + scopesSupported: as.ScopesSupported, + authMethods: as.TokenEndpointAuthMethodsSupported, + supportsS256: s256, + fetchedAt: time.Now(), + }, nil +} + +// wellKnownURL builds an RFC 8414 / RFC 9728 well-known metadata URL by +// inserting "/.well-known/" ahead of the base URL's path. For a base +// with no path, the well-known segment is simply appended. +// +// https://host + oauth-protected-resource -> https://host/.well-known/oauth-protected-resource +// https://host/mcp + oauth-protected-resource -> https://host/.well-known/oauth-protected-resource/mcp +func wellKnownURL(base, suffix string) (string, error) { + u, err := url.Parse(strings.TrimSpace(base)) + if err != nil { + return "", err + } + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("invalid URL %q", base) + } + path := strings.Trim(u.Path, "/") + suffix = strings.TrimSpace(suffix) + wk := "/.well-known/" + suffix + if path != "" { + wk += "/" + path + } + u.Path = wk + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} diff --git a/forge-go/oauth/discovery_test.go b/forge-go/oauth/discovery_test.go new file mode 100644 index 0000000..aec39c8 --- /dev/null +++ b/forge-go/oauth/discovery_test.go @@ -0,0 +1,92 @@ +package oauth + +import "testing" + +func TestWellKnownURL(t *testing.T) { + cases := []struct { + name string + base string + suffix string + want string + }{ + { + name: "host with path (RFC 9728 path insertion)", + base: "https://example.com/mcp", + suffix: "oauth-protected-resource", + want: "https://example.com/.well-known/oauth-protected-resource/mcp", + }, + { + name: "bare host", + base: "https://example.com", + suffix: "oauth-authorization-server", + want: "https://example.com/.well-known/oauth-authorization-server", + }, + { + name: "trailing slash is trimmed", + base: "https://example.com/a/b/", + suffix: "oauth-authorization-server", + want: "https://example.com/.well-known/oauth-authorization-server/a/b", + }, + { + name: "query and fragment are dropped", + base: "https://example.com/mcp?x=1#frag", + suffix: "oauth-protected-resource", + want: "https://example.com/.well-known/oauth-protected-resource/mcp", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := wellKnownURL(tc.base, tc.suffix) + if err != nil { + t.Fatalf("wellKnownURL(%q) error: %v", tc.base, err) + } + if got != tc.want { + t.Errorf("wellKnownURL(%q, %q) = %q, want %q", tc.base, tc.suffix, got, tc.want) + } + }) + } +} + +func TestWellKnownURL_Invalid(t *testing.T) { + for _, base := range []string{"", "not-a-url", "/relative/path", "mcp.notion.com/mcp"} { + if _, err := wellKnownURL(base, "oauth-protected-resource"); err == nil { + t.Errorf("wellKnownURL(%q) expected error, got nil", base) + } + } +} + +func TestResolvedProvider_AuthMethodSelection(t *testing.T) { + cases := []struct { + name string + methods []string + wantPublic bool + wantMethod string + }{ + {"public preferred", []string{"client_secret_basic", "client_secret_post", "none"}, true, "none"}, + {"post fallback", []string{"client_secret_basic", "client_secret_post"}, false, "client_secret_post"}, + {"basic fallback", []string{"client_secret_basic"}, false, "client_secret_basic"}, + {"empty defaults to basic", nil, false, "client_secret_basic"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := &resolvedProvider{authMethods: tc.methods} + if got := r.supportsPublicClient(); got != tc.wantPublic { + t.Errorf("supportsPublicClient() = %v, want %v", got, tc.wantPublic) + } + if got := r.tokenEndpointAuthMethod(); got != tc.wantMethod { + t.Errorf("tokenEndpointAuthMethod() = %q, want %q", got, tc.wantMethod) + } + }) + } +} + +func TestProviderConfig_RequiresClientCredentials(t *testing.T) { + static := ProviderConfig{AuthURL: "https://x/a", TokenURL: "https://x/t"} + if !static.RequiresClientCredentials() { + t.Error("static provider should require client credentials") + } + dcr := ProviderConfig{ResourceURL: "https://mcp.example.com/mcp", UseDCRP: true} + if dcr.RequiresClientCredentials() { + t.Error("DCR provider should not require client credentials") + } +} diff --git a/forge-go/oauth/helper.go b/forge-go/oauth/helper.go index b0ce8d0..2096b73 100644 --- a/forge-go/oauth/helper.go +++ b/forge-go/oauth/helper.go @@ -10,6 +10,19 @@ func StoreKey(orgID, providerID string) string { return prefix + orgID + "|" + providerID } +// callbackPath is the single, provider- and org-agnostic OAuth2 redirect/callback +// path. The flow is fully identified by the opaque state at callback time, so +// neither the provider nor the org belongs in the URL. A single stable redirect +// URI also keeps the door open for OAuth Client ID Metadata Documents, where the +// client publishes one metadata document listing its redirect URIs. +const callbackPath = "/oauth/callback" + +// callbackURL is the full redirect/callback URL. base already includes the OAuth +// route prefix. +func callbackURL(base string) string { + return base + callbackPath +} + // ParseOAuthKey parses an "oauth:orgID|providerID" key. Returns ok=false for non-OAuth keys. func ParseOAuthKey(key string) (orgID, providerID string, ok bool) { rest, found := strings.CutPrefix(key, prefix) diff --git a/forge-go/oauth/keychain_client_credentials_store.go b/forge-go/oauth/keychain_client_credentials_store.go new file mode 100644 index 0000000..075dcae --- /dev/null +++ b/forge-go/oauth/keychain_client_credentials_store.go @@ -0,0 +1,54 @@ +package oauth + +import ( + "encoding/json" + "fmt" + + "github.com/rustic-ai/forge/forge-go/forgepath" + "github.com/zalando/go-keyring" +) + +// KeychainClientCredentialsStore persists DCR-issued client credentials in the +// OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service +// via libsecret), namespaced separately from OAuth tokens. +// +// It is selected independently of the token store: set +// FORGE_OAUTH_CLIENT_STORE=keychain. +type KeychainClientCredentialsStore struct { + service string +} + +func NewKeychainClientCredentialsStore() *KeychainClientCredentialsStore { + return NewKeychainClientCredentialsStoreWithService(forgepath.KeychainService()) +} + +func NewKeychainClientCredentialsStoreWithService(service string) *KeychainClientCredentialsStore { + return &KeychainClientCredentialsStore{service: service} +} + +func (s *KeychainClientCredentialsStore) SaveCredentials(providerID string, c *clientCredentials) error { + data, err := json.Marshal(c) + if err != nil { + return fmt.Errorf("marshaling client credentials: %w", err) + } + if err := keyring.Set(s.service, clientStoreKey(providerID), string(data)); err != nil { + return fmt.Errorf("saving client credentials to keychain: %w", err) + } + return nil +} + +func (s *KeychainClientCredentialsStore) LoadCredentials(providerID string) (*clientCredentials, bool) { + data, err := keyring.Get(s.service, clientStoreKey(providerID)) + if err != nil { + return nil, false + } + var c clientCredentials + if err := json.Unmarshal([]byte(data), &c); err != nil { + return nil, false + } + return &c, true +} + +func (s *KeychainClientCredentialsStore) DeleteCredentials(providerID string) bool { + return keyring.Delete(s.service, clientStoreKey(providerID)) == nil +} diff --git a/forge-go/oauth/known_endpoints.go b/forge-go/oauth/known_endpoints.go deleted file mode 100644 index a2aafa8..0000000 --- a/forge-go/oauth/known_endpoints.go +++ /dev/null @@ -1,46 +0,0 @@ -package oauth - -import ( - "fmt" - - "golang.org/x/oauth2" -) - -var knownEndpoints = map[string]oauth2.Endpoint{ - "github": { - AuthURL: "https://github.com/login/oauth/authorize", - TokenURL: "https://github.com/login/oauth/access_token", - }, - "google": { - AuthURL: "https://accounts.google.com/o/oauth2/auth", - TokenURL: "https://oauth2.googleapis.com/token", - }, - "google-drive": { - AuthURL: "https://accounts.google.com/o/oauth2/auth", - TokenURL: "https://oauth2.googleapis.com/token", - }, - "slack": { - AuthURL: "https://slack.com/oauth/v2/authorize", - TokenURL: "https://slack.com/api/oauth.v2.access", - }, - "microsoft": { - AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", - }, - "notion": { - AuthURL: "https://api.notion.com/v1/oauth/authorize", - TokenURL: "https://api.notion.com/v1/oauth/token", - }, -} - -func resolveEndpoint(providerID string, cfg ProviderConfig) (oauth2.Endpoint, error) { - if cfg.AuthURL != "" && cfg.TokenURL != "" { - return oauth2.Endpoint{AuthURL: cfg.AuthURL, TokenURL: cfg.TokenURL}, nil - } - if ep, ok := knownEndpoints[providerID]; ok { - return ep, nil - } - return oauth2.Endpoint{}, fmt.Errorf( - "provider %q has no known endpoints; set auth_url and token_url in config", providerID, - ) -} diff --git a/forge-go/oauth/manager.go b/forge-go/oauth/manager.go index 0dfba5a..93c6977 100644 --- a/forge-go/oauth/manager.go +++ b/forge-go/oauth/manager.go @@ -6,12 +6,51 @@ import ( "encoding/base64" "errors" "fmt" + "net/http" + "strings" "sync" "time" + "github.com/modelcontextprotocol/go-sdk/oauthex" "golang.org/x/oauth2" + "golang.org/x/oauth2/endpoints" ) +// builtinEndpoints maps provider IDs to endpoints published by +// golang.org/x/oauth2 so we don't maintain our own copies of these URLs. +// +// These endpoints are hardcoded (not auto-discovered) on purpose. Some of these +// providers publish no OAuth metadata at all; others (e.g. Slack) publish an +// OpenID Connect discovery document whose endpoints are the *login* surface +// (openid/profile/email only) and differ from the OAuth API endpoints used here +// (chat:write, channels:read, ...). Discovering them would resolve the wrong, +// narrower surface. Providers not listed here must set auth_url/token_url +// explicitly, or use resource_url for RFC 9728/8414 auto-discovery. +var builtinEndpoints = map[string]oauth2.Endpoint{ + "github": endpoints.GitHub, + "google": endpoints.Google, + "google-drive": endpoints.Google, + "slack": endpoints.Slack, + // AzureAD("common") is the Azure AD v2.0 endpoint that handles both work and + // personal accounts — not the legacy consumer endpoints.Microsoft. + "microsoft": endpoints.AzureAD("common"), +} + +// resolveEndpoint returns the OAuth2 endpoint for a static (non-discovery) +// provider: an explicit auth_url/token_url pair from config takes precedence, +// otherwise a built-in endpoint is used if the provider ID is known. +func resolveEndpoint(providerID string, cfg ProviderConfig) (oauth2.Endpoint, error) { + if cfg.AuthURL != "" && cfg.TokenURL != "" { + return oauth2.Endpoint{AuthURL: cfg.AuthURL, TokenURL: cfg.TokenURL}, nil + } + if ep, ok := builtinEndpoints[providerID]; ok { + return ep, nil + } + return oauth2.Endpoint{}, fmt.Errorf( + "provider %q has no known endpoints; set auth_url and token_url, or resource_url for auto-discovery", providerID, + ) +} + // ErrNotConnected is returned by GetAccessToken when no token exists for the // given (orgID, providerID) pair. var ErrNotConnected = errors.New("oauth: provider not connected") @@ -24,7 +63,10 @@ type pendingFlow struct { clientSecret string codeVerifier string redirectURL string - expiresAt time.Time + // endpoint is captured at authorize time so the callback exchange uses the + // same (possibly discovered) endpoints without re-resolving. + endpoint oauth2.Endpoint + expiresAt time.Time } // tokenEntry stores a connected provider's live tokens. @@ -45,6 +87,9 @@ type ProviderStatus struct { Scopes []string `json:"scopes"` CallbackURL string `json:"callbackUrl"` ExpiresAt *time.Time `json:"expiresAt,omitempty"` + // RequiresClientCredentials tells the UI whether to prompt for a client_id + // and client_secret. False for providers using Dynamic Client Registration. + RequiresClientCredentials bool `json:"requiresClientCredentials"` } // Manager handles OAuth2 flows and token storage for all configured providers. @@ -54,6 +99,13 @@ type Manager struct { activeProviders map[string]struct{} pendingFlows map[string]*pendingFlow store TokenStore + credStore ClientCredentialsStore + disco *discoverer + httpClient *http.Client + // clientName and clientURI are sent as client_name and client_uri during + // Dynamic Client Registration. + clientName string + clientURI string } // NewManager constructs a Manager using the default in-memory token store. @@ -61,46 +113,116 @@ func NewManager(cfg *ProvidersConfig) *Manager { return NewManagerWithStore(cfg, NewInMemoryTokenStore()) } -// NewManagerWithStore constructs a Manager with a custom token store. -func NewManagerWithStore(cfg *ProvidersConfig, store TokenStore) *Manager { - return &Manager{ +// NewManagerWithStore constructs a Manager with a custom token store and the +// default in-memory client credentials store. +func NewManagerWithStore(cfg *ProvidersConfig, store TokenStore, opts ...ManagerOption) *Manager { + return NewManagerWithStores(cfg, store, NewInMemoryClientCredentialsStore(), opts...) +} + +// ManagerOption configures optional Manager behaviour. +type ManagerOption func(*Manager) + +// WithDynamicClient sets the client_name and client_uri sent to the provider +// during Dynamic Client Registration (RFC 7591). client_uri is a URL of a page +// with information about the client, which providers may show on the consent +// screen. Empty values are ignored: the name keeps its default ("Forge") and +// the uri stays omitted from the registration request. +func WithDynamicClient(name, uri string) ManagerOption { + return func(m *Manager) { + if name != "" { + m.clientName = name + } + if uri != "" { + m.clientURI = uri + } + } +} + +// NewManagerWithStores constructs a Manager with custom token and client +// credentials stores. +func NewManagerWithStores(cfg *ProvidersConfig, store TokenStore, credStore ClientCredentialsStore, opts ...ManagerOption) *Manager { + hc := &http.Client{Timeout: 30 * time.Second} + m := &Manager{ providers: cfg.Providers, activeProviders: make(map[string]struct{}), pendingFlows: make(map[string]*pendingFlow), store: store, + credStore: credStore, + disco: newDiscoverer(hc), + httpClient: hc, + clientName: "Forge", + } + for _, opt := range opts { + opt(m) } + return m } // GetAuthURL begins an OAuth2 PKCE flow for the given provider. It returns the // authorization URL to open in the browser and the opaque state token. -func (m *Manager) GetAuthURL(orgID, providerID, clientID, clientSecret, redirectURL string) (string, string, error) { +// +// For providers using Dynamic Client Registration (UseDCRP), clientID and +// clientSecret may be empty: endpoints are discovered from the provider's +// resource URL and a client is registered on demand. +func (m *Manager) GetAuthURL(ctx context.Context, orgID, providerID, clientID, clientSecret, redirectURL string) (string, string, error) { m.mu.Lock() - defer m.mu.Unlock() - - if _, active := m.activeProviders[providerID]; !active { - return "", "", fmt.Errorf("unknown provider: %s", providerID) - } + _, active := m.activeProviders[providerID] cfg := m.providers[providerID] + m.mu.Unlock() - endpoint, err := resolveEndpoint(providerID, cfg) - if err != nil { - return "", "", err + if !active { + return "", "", fmt.Errorf("unknown provider: %s", providerID) } if redirectURL == "" { redirectURL = cfg.RedirectURL } + // Resolve endpoints and, for DCR providers, obtain client credentials. + // Network I/O happens outside the lock. + // + // Endpoint discovery is the Dynamic Client Registration flow: it relies on + // the provider advertising RFC 9728 protected-resource metadata (in practice + // an OAuth-protected resource such as a remote MCP server), so resource_url + // is only used when UseDCRP is set. Everything else resolves static endpoints. + var endpoint oauth2.Endpoint + usePKCE := cfg.pkce() + + if cfg.UseDCRP { + res, err := m.disco.resolve(ctx, cfg.ResourceURL) + if err != nil { + return "", "", fmt.Errorf("discovering provider %q: %w", providerID, err) + } + endpoint = res.endpoint + + clientID, clientSecret, err = m.registerIfNeeded(ctx, providerID, cfg, res, redirectURL) + if err != nil { + return "", "", fmt.Errorf("registering client for %q: %w", providerID, err) + } + // A dynamically registered public client has no client_secret, so PKCE is + // the only thing binding the code to this client: force it on. + if res.supportsPublicClient() { + usePKCE = true + } + } else { + ep, err := resolveEndpoint(providerID, cfg) + if err != nil { + return "", "", err + } + endpoint = ep + } + state, err := randomString(32) if err != nil { return "", "", fmt.Errorf("generating state: %w", err) } var verifier string - if cfg.pkce() { + if usePKCE { verifier = oauth2.GenerateVerifier() } + m.mu.Lock() m.cleanExpiredFlows() m.pendingFlows[state] = &pendingFlow{ orgID: orgID, @@ -109,8 +231,10 @@ func (m *Manager) GetAuthURL(orgID, providerID, clientID, clientSecret, redirect clientSecret: clientSecret, codeVerifier: verifier, redirectURL: redirectURL, + endpoint: endpoint, expiresAt: time.Now().Add(10 * time.Minute), } + m.mu.Unlock() oc := &oauth2.Config{ ClientID: clientID, @@ -121,36 +245,75 @@ func (m *Manager) GetAuthURL(orgID, providerID, clientID, clientSecret, redirect } var authOpts []oauth2.AuthCodeOption - if cfg.pkce() { + if usePKCE { authOpts = append(authOpts, oauth2.S256ChallengeOption(verifier)) } return oc.AuthCodeURL(state, authOpts...), state, nil } -// ExchangeCode validates the state from a callback and exchanges the code for tokens. -func (m *Manager) ExchangeCode(ctx context.Context, code, state string) error { +// registerIfNeeded returns the deployment-global client credentials for the +// provider, registering a new client via Dynamic Client Registration (RFC 7591) +// when none exist or the stored client_secret has expired. The client is shared +// across all orgs, so it is keyed by providerID only. +func (m *Manager) registerIfNeeded(ctx context.Context, providerID string, cfg ProviderConfig, res *resolvedProvider, redirectURL string) (string, string, error) { + if creds, ok := m.credStore.LoadCredentials(providerID); ok && !creds.expired() { + return creds.ClientID, creds.ClientSecret, nil + } + if res.registrationEndpoint == "" { + return "", "", fmt.Errorf("provider %q does not advertise a registration endpoint", providerID) + } + + meta := &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{redirectURL}, + TokenEndpointAuthMethod: res.tokenEndpointAuthMethod(), + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + ClientName: m.clientName, + ClientURI: m.clientURI, + Scope: strings.Join(cfg.Scopes, " "), + } + resp, err := oauthex.RegisterClient(ctx, res.registrationEndpoint, meta, m.httpClient) + if err != nil { + return "", "", err + } + + creds := &clientCredentials{ + ClientID: resp.ClientID, + ClientSecret: resp.ClientSecret, + SecretExpiresAt: resp.ClientSecretExpiresAt, + } + if err := m.credStore.SaveCredentials(providerID, creds); err != nil { + return "", "", fmt.Errorf("persisting client credentials: %w", err) + } + return resp.ClientID, resp.ClientSecret, nil +} + +// ExchangeCode validates the state from a callback and exchanges the code for +// tokens. It returns the provider the state resolved to (usable for display even +// on some errors), so the callback need not carry the provider in its URL. +func (m *Manager) ExchangeCode(ctx context.Context, code, state string) (providerID string, err error) { m.mu.Lock() defer m.mu.Unlock() flow, ok := m.pendingFlows[state] if !ok { - return fmt.Errorf("unknown or expired state") + return "", fmt.Errorf("unknown or expired state") } delete(m.pendingFlows, state) + providerID = flow.providerID if time.Now().After(flow.expiresAt) { - return fmt.Errorf("auth flow expired") + return providerID, fmt.Errorf("auth flow expired") } if _, active := m.activeProviders[flow.providerID]; !active { - return fmt.Errorf("unknown provider: %s", flow.providerID) + return providerID, fmt.Errorf("unknown provider: %s", flow.providerID) } cfg := m.providers[flow.providerID] - endpoint, err := resolveEndpoint(flow.providerID, cfg) - if err != nil { - return err - } + // Reuse the endpoint captured at authorize time so DCR-discovered endpoints + // don't need to be re-resolved here. + endpoint := flow.endpoint oc := &oauth2.Config{ ClientID: flow.clientID, @@ -166,10 +329,10 @@ func (m *Manager) ExchangeCode(ctx context.Context, code, state string) error { } token, err := oc.Exchange(ctx, code, exchangeOpts...) if err != nil { - return fmt.Errorf("exchanging code: %w", err) + return providerID, fmt.Errorf("exchanging code: %w", err) } - return m.store.Save(flow.orgID, flow.providerID, &tokenEntry{ + return providerID, m.store.Save(flow.orgID, flow.providerID, &tokenEntry{ token: token, clientID: flow.clientID, clientSecret: flow.clientSecret, @@ -221,8 +384,14 @@ func (m *Manager) SeedToken(orgID, providerID, accessToken string) { }) } -// Disconnect removes stored tokens for the provider. Returns true if the -// provider was connected. +// Disconnect removes the org's stored tokens for the provider. It returns true +// if the provider had a stored token. +// +// For providers using Dynamic Client Registration the registered client is +// deployment-global (shared across all orgs), so it is NOT discarded here — +// disconnecting one org must not break others. The client persists for the +// deployment's lifetime and is re-registered automatically only when its +// client_secret expires (see registerIfNeeded). func (m *Manager) Disconnect(orgID, providerID string) bool { return m.store.Delete(orgID, providerID) } @@ -240,12 +409,13 @@ func (m *Manager) ListProviders(orgID, callbackBaseURL string) []ProviderStatus cfg := m.providers[id] entry, connected := m.store.Load(orgID, id) ps := ProviderStatus{ - ID: id, - DisplayName: cfg.DisplayName, - Description: cfg.Description, - Connected: connected, - Scopes: cfg.Scopes, - CallbackURL: callbackBaseURL + "/oauth/organizations/" + orgID + "/providers/" + id + "/callback", + ID: id, + DisplayName: cfg.DisplayName, + Description: cfg.Description, + Connected: connected, + Scopes: cfg.Scopes, + CallbackURL: callbackURL(callbackBaseURL), + RequiresClientCredentials: cfg.RequiresClientCredentials(), } if connected && !entry.token.Expiry.IsZero() { exp := entry.token.Expiry @@ -302,6 +472,15 @@ func (m *Manager) ProviderDisplayName(id string) string { return m.providers[id].DisplayName } +// RequiresClientCredentials reports whether the caller must supply a client_id +// and client_secret to start an auth flow for this provider. Providers using +// Dynamic Client Registration require none. +func (m *Manager) RequiresClientCredentials(id string) bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.providers[id].RequiresClientCredentials() +} + func (m *Manager) cleanExpiredFlows() { now := time.Now() for k, f := range m.pendingFlows { diff --git a/forge-go/oauth/manager_test.go b/forge-go/oauth/manager_test.go index 2b3ab92..5c90f04 100644 --- a/forge-go/oauth/manager_test.go +++ b/forge-go/oauth/manager_test.go @@ -1,8 +1,13 @@ package oauth import ( + "context" "os" + "strings" "testing" + "time" + + "golang.org/x/oauth2" ) func TestGetAuthURL_UsesProviderScopes(t *testing.T) { @@ -19,7 +24,7 @@ func TestGetAuthURL_UsesProviderScopes(t *testing.T) { m := NewManager(cfg) m.CheckAndUpdateProvider("github", nil) - authURL, state, err := m.GetAuthURL("org1", "github", "client-id", "client-secret", "https://example.com/callback") + authURL, state, err := m.GetAuthURL(context.Background(), "org1", "github", "client-id", "client-secret", "https://example.com/callback") if err != nil { t.Fatalf("GetAuthURL failed: %v", err) } @@ -48,7 +53,7 @@ func TestGetAuthURL_NoScopes(t *testing.T) { m := NewManager(cfg) m.CheckAndUpdateProvider("github", nil) - _, state, err := m.GetAuthURL("org1", "github", "client-id", "client-secret", "https://example.com/callback") + _, state, err := m.GetAuthURL(context.Background(), "org1", "github", "client-id", "client-secret", "https://example.com/callback") if err != nil { t.Fatalf("GetAuthURL failed: %v", err) } @@ -61,13 +66,179 @@ func TestGetAuthURL_NoScopes(t *testing.T) { } } +// seedDiscovery pre-populates the discoverer cache so GetAuthURL resolves +// endpoints without network I/O. +func (m *Manager) seedDiscovery(resourceURL string, r *resolvedProvider) { + r.fetchedAt = time.Now() + m.disco.mu.Lock() + m.disco.cache[resourceURL] = r + m.disco.mu.Unlock() +} + +func TestGetAuthURL_DCRDiscoversAndUsesRegisteredClient(t *testing.T) { + // use_pkce is unset; a discovered public client must force PKCE on. + cfg := &ProvidersConfig{ + Providers: map[string]ProviderConfig{ + "mcp": { + DisplayName: "MCP", + ResourceURL: "https://mcp.example.com/mcp", + UseDCRP: true, + }, + }, + } + m := NewManager(cfg) + m.CheckAndUpdateProvider("mcp", nil) + // Seed discovery and stored client credentials so GetAuthURL performs no + // network I/O: resolve() hits the cache and registerIfNeeded() finds creds. + m.seedDiscovery("https://mcp.example.com/mcp", &resolvedProvider{ + endpoint: oauth2.Endpoint{AuthURL: "https://as.example.com/authorize", TokenURL: "https://as.example.com/token"}, + authMethods: []string{"none"}, // public client + }) + _ = m.credStore.SaveCredentials("mcp", &clientCredentials{ClientID: "registered-id"}) + + authURL, state, err := m.GetAuthURL(context.Background(), "org1", "mcp", "", "", "https://example.com/callback") + if err != nil { + t.Fatalf("GetAuthURL failed: %v", err) + } + if !strings.HasPrefix(authURL, "https://as.example.com/authorize") { + t.Errorf("expected discovered auth endpoint, got %s", authURL) + } + + m.mu.Lock() + flow := m.pendingFlows[state] + m.mu.Unlock() + if flow == nil { + t.Fatal("expected pending flow to exist") + } + if flow.clientID != "registered-id" { + t.Errorf("expected registered client id, got %q", flow.clientID) + } + // Public client must use PKCE regardless of the (unset) use_pkce default. + if flow.codeVerifier == "" || !strings.Contains(authURL, "code_challenge") { + t.Error("expected PKCE to be forced on for a public DCR client") + } +} + +func TestCallbackURL(t *testing.T) { + base := "https://forge.example.com/api" + // A single constant callback for every provider (flow identified by state). + if got := callbackURL(base); got != base+"/oauth/callback" { + t.Errorf("callback = %q", got) + } +} + +func TestDisconnect_KeepsGlobalDCRClient(t *testing.T) { + cfg := &ProvidersConfig{ + Providers: map[string]ProviderConfig{ + "mcp": {DisplayName: "MCP", ResourceURL: "https://mcp.example.com/mcp", UseDCRP: true}, + }, + } + m := NewManager(cfg) + m.CheckAndUpdateProvider("mcp", nil) + + // A registered (global) client and an org's token both exist. + _ = m.credStore.SaveCredentials("mcp", &clientCredentials{ClientID: "registered-id"}) + m.SeedToken("org1", "mcp", "tok") + + if !m.Disconnect("org1", "mcp") { + t.Fatal("expected Disconnect to report a removed token") + } + // Token is gone... + if m.IsConnected("org1", "mcp") { + t.Error("expected org token to be removed after Disconnect") + } + // ...but the deployment-global client is retained for other orgs. + if _, ok := m.credStore.LoadCredentials("mcp"); !ok { + t.Error("expected DCR client credentials to survive Disconnect") + } +} + +func TestLoadProvidersConfig_DCRResourceURLPairing(t *testing.T) { + cases := map[string]string{ + "dcr without resource_url": `providers: + broken: + use_dcrp: true`, + "resource_url without dcr": `providers: + broken: + resource_url: https://mcp.example.com/mcp`, + } + for name, yaml := range cases { + t.Run(name, func(t *testing.T) { + path := t.TempDir() + "/providers.yaml" + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + if _, err := LoadProvidersConfig(path); err == nil { + t.Errorf("expected validation error for %q, got nil", name) + } + }) + } +} + +func TestWithDynamicClient(t *testing.T) { + cfg := &ProvidersConfig{Providers: map[string]ProviderConfig{}} + + // Defaults: name "Forge", uri empty (omitted from registration). + def := NewManager(cfg) + if def.clientName != "Forge" || def.clientURI != "" { + t.Errorf("defaults = (%q, %q), want (%q, %q)", def.clientName, def.clientURI, "Forge", "") + } + + // Both set. + set := NewManagerWithStore(cfg, NewInMemoryTokenStore(), WithDynamicClient("Acme", "https://acme.example.com")) + if set.clientName != "Acme" || set.clientURI != "https://acme.example.com" { + t.Errorf("set = (%q, %q)", set.clientName, set.clientURI) + } + + // Empty values are ignored independently: name keeps default, uri stays empty. + empty := NewManagerWithStore(cfg, NewInMemoryTokenStore(), WithDynamicClient("", "")) + if empty.clientName != "Forge" || empty.clientURI != "" { + t.Errorf("empty = (%q, %q), want (%q, %q)", empty.clientName, empty.clientURI, "Forge", "") + } +} + +func TestResolveEndpoint(t *testing.T) { + // Built-in providers resolve to library endpoints. + slack, err := resolveEndpoint("slack", ProviderConfig{}) + if err != nil { + t.Fatalf("slack: %v", err) + } + if slack.AuthURL != "https://slack.com/oauth/v2/authorize" { + t.Errorf("unexpected slack auth URL: %s", slack.AuthURL) + } + + // Microsoft must be the Azure AD v2.0 common endpoint, not the legacy + // consumer endpoint. + ms, err := resolveEndpoint("microsoft", ProviderConfig{}) + if err != nil { + t.Fatalf("microsoft: %v", err) + } + if ms.AuthURL != "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" { + t.Errorf("unexpected microsoft auth URL: %s", ms.AuthURL) + } + + // Explicit config overrides the built-in. + custom, err := resolveEndpoint("github", ProviderConfig{AuthURL: "https://x/a", TokenURL: "https://x/t"}) + if err != nil { + t.Fatalf("override: %v", err) + } + if custom.AuthURL != "https://x/a" || custom.TokenURL != "https://x/t" { + t.Errorf("expected config override, got %+v", custom) + } + + // Unknown provider with no config is an error. + if _, err := resolveEndpoint("mystery", ProviderConfig{}); err == nil { + t.Error("expected error for unknown provider without endpoints") + } +} + func TestGetAuthURL_UnknownProvider(t *testing.T) { cfg := &ProvidersConfig{ Providers: map[string]ProviderConfig{}, } m := NewManager(cfg) - _, _, err := m.GetAuthURL("org1", "unknown", "id", "secret", "") + _, _, err := m.GetAuthURL(context.Background(), "org1", "unknown", "id", "secret", "") if err == nil { t.Error("expected error for unknown provider, got nil") } From e62d8b2b484fedf16f986acf4c64f961f3816c74 Mon Sep 17 00:00:00 2001 From: Shiti Saxena Date: Mon, 13 Jul 2026 21:05:49 -0700 Subject: [PATCH 2/2] feat: secrets api and injection --- forge-go/agent/server.go | 3 +- forge-go/api/oauth.go | 3 - forge-go/api/secrets.go | 189 +++++++++++++++ forge-go/api/secrets_test.go | 221 ++++++++++++++++++ forge-go/api/server.go | 26 +++ forge-go/go.mod | 2 +- forge-go/go.sum | 2 + forge-go/helper/envvars/envvars.go | 28 ++- forge-go/helper/envvars/envvars_test.go | 47 ++++ forge-go/keychain/provider.go | 7 +- forge-go/secrets/keychain_secret_store.go | 52 +++++ .../secrets/keychain_secret_store_test.go | 62 +++++ forge-go/secrets/manager.go | 66 ++++++ forge-go/secrets/manager_test.go | 115 +++++++++ forge-go/secrets/secret_store.go | 112 +++++++++ 15 files changed, 920 insertions(+), 15 deletions(-) create mode 100644 forge-go/api/secrets.go create mode 100644 forge-go/api/secrets_test.go create mode 100644 forge-go/secrets/keychain_secret_store.go create mode 100644 forge-go/secrets/keychain_secret_store_test.go create mode 100644 forge-go/secrets/manager.go create mode 100644 forge-go/secrets/manager_test.go create mode 100644 forge-go/secrets/secret_store.go diff --git a/forge-go/agent/server.go b/forge-go/agent/server.go index 4309ea1..a2f953c 100644 --- a/forge-go/agent/server.go +++ b/forge-go/agent/server.go @@ -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() + WithOAuth(). + WithSecrets() wg.Add(1) go func() { defer wg.Done() diff --git a/forge-go/api/oauth.go b/forge-go/api/oauth.go index 29a25fc..db00f37 100644 --- a/forge-go/api/oauth.go +++ b/forge-go/api/oauth.go @@ -43,9 +43,6 @@ func (s *Server) registerOAuthRoutes(router *gin.Engine, prefix string) { // 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())) - // Backward-compat alias for providers registered with the older org-scoped - // callback URL. Shares the same state-driven handler. - router.GET(prefix+"/oauth/organizations/:org_id/providers/:provider_id/callback", wrapHTTPWithPathValues(s.handleOAuthCallback(), "org_id", "provider_id")) 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")) } diff --git a/forge-go/api/secrets.go b/forge-go/api/secrets.go new file mode 100644 index 0000000..c814a72 --- /dev/null +++ b/forge-go/api/secrets.go @@ -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, + }) + } +} diff --git a/forge-go/api/secrets_test.go b/forge-go/api/secrets_test.go new file mode 100644 index 0000000..7e45d36 --- /dev/null +++ b/forge-go/api/secrets_test.go @@ -0,0 +1,221 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rustic-ai/forge/forge-go/secrets" +) + +// b64 encodes a plaintext secret value the way API clients must send it. +func b64(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +// capturingStore wraps the in-memory store to expose the last value passed to +// Save, so tests can assert the handler stores the decoded (not base64) value. +type capturingStore struct { + *secrets.InMemorySecretStore + lastValue string +} + +func (c *capturingStore) Save(orgID, name, value string) error { + c.lastValue = value + return c.InMemorySecretStore.Save(orgID, name, value) +} + +func newSecretsServer() *Server { + return &Server{secretManager: secrets.NewManager(secrets.NewInMemorySecretStore())} +} + +func doSecretReq(t *testing.T, h http.HandlerFunc, method, path, body string, pathVals map[string]string) *httptest.ResponseRecorder { + t.Helper() + var r *http.Request + if body != "" { + r = httptest.NewRequest(method, path, bytes.NewBufferString(body)) + } else { + r = httptest.NewRequest(method, path, nil) + } + for k, v := range pathVals { + r.SetPathValue(k, v) + } + rr := httptest.NewRecorder() + h(rr, r) + return rr +} + +func TestHandleCreateSecret_Success(t *testing.T) { + store := &capturingStore{InMemorySecretStore: secrets.NewInMemorySecretStore()} + s := &Server{secretManager: secrets.NewManager(store)} + rr := doSecretReq(t, s.handleCreateSecret(), http.MethodPost, + "/rustic/organizations/org1/secrets", + `{"name":"API_KEY","value":"`+b64("s3cr3t")+`"}`, + map[string]string{"org_id": "org1"}) + + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "s3cr3t") { + t.Fatal("response must never echo the secret value") + } + if !s.secretManager.Exists("org1", "API_KEY") { + t.Fatal("secret not stored") + } + if store.lastValue != "s3cr3t" { + t.Fatalf("stored value must be the decoded secret, got %q", store.lastValue) + } +} + +func TestHandleCreateSecret_InvalidBase64(t *testing.T) { + s := newSecretsServer() + rr := doSecretReq(t, s.handleCreateSecret(), http.MethodPost, + "/rustic/organizations/org1/secrets", + `{"name":"K","value":"not!base64"}`, + map[string]string{"org_id": "org1"}) + + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", rr.Code) + } +} + +func TestHandleCreateSecret_DuplicateConflict(t *testing.T) { + s := newSecretsServer() + s.secretManager.Set("org1", "API_KEY", "a") //nolint:errcheck + + rr := doSecretReq(t, s.handleCreateSecret(), http.MethodPost, + "/rustic/organizations/org1/secrets", + `{"name":"API_KEY","value":"`+b64("b")+`"}`, + map[string]string{"org_id": "org1"}) + + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", rr.Code) + } +} + +func TestHandleCreateSecret_InvalidName(t *testing.T) { + s := newSecretsServer() + rr := doSecretReq(t, s.handleCreateSecret(), http.MethodPost, + "/rustic/organizations/org1/secrets", + `{"name":"bad name!","value":"v"}`, + map[string]string{"org_id": "org1"}) + + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", rr.Code) + } +} + +func TestHandleCreateSecret_EmptyValue(t *testing.T) { + s := newSecretsServer() + rr := doSecretReq(t, s.handleCreateSecret(), http.MethodPost, + "/rustic/organizations/org1/secrets", + `{"name":"K","value":""}`, + map[string]string{"org_id": "org1"}) + + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", rr.Code) + } +} + +func TestHandleCreateSecret_BlankValue(t *testing.T) { + s := newSecretsServer() + rr := doSecretReq(t, s.handleCreateSecret(), http.MethodPost, + "/rustic/organizations/org1/secrets", + `{"name":"K","value":"`+b64(" \t\n")+`"}`, + map[string]string{"org_id": "org1"}) + + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", rr.Code) + } +} + +func TestHandleUpdateSecret_Success(t *testing.T) { + s := newSecretsServer() + s.secretManager.Set("org1", "K", "v1") //nolint:errcheck + + rr := doSecretReq(t, s.handleUpdateSecret(), http.MethodPut, + "/rustic/organizations/org1/secrets/K", + `{"value":"`+b64("v2")+`"}`, + map[string]string{"org_id": "org1", "name": "K"}) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + if !s.secretManager.Exists("org1", "K") { + t.Fatal("secret should still exist after update") + } +} + +func TestHandleUpdateSecret_NotFound(t *testing.T) { + s := newSecretsServer() + rr := doSecretReq(t, s.handleUpdateSecret(), http.MethodPut, + "/rustic/organizations/org1/secrets/NOPE", + `{"value":"`+b64("v")+`"}`, + map[string]string{"org_id": "org1", "name": "NOPE"}) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rr.Code) + } +} + +func TestHandleDeleteSecret(t *testing.T) { + s := newSecretsServer() + s.secretManager.Set("org1", "K", "v") //nolint:errcheck + + rr := doSecretReq(t, s.handleDeleteSecret(), http.MethodDelete, + "/rustic/organizations/org1/secrets/K", "", + map[string]string{"org_id": "org1", "name": "K"}) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + // Second delete should 404. + rr = doSecretReq(t, s.handleDeleteSecret(), http.MethodDelete, + "/rustic/organizations/org1/secrets/K", "", + map[string]string{"org_id": "org1", "name": "K"}) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 on second delete, got %d", rr.Code) + } +} + +func TestHandleListSecrets_NoValues(t *testing.T) { + s := newSecretsServer() + s.secretManager.Set("org1", "A", "secretA") //nolint:errcheck + s.secretManager.Set("org1", "B", "secretB") //nolint:errcheck + + rr := doSecretReq(t, s.handleListSecrets(), http.MethodGet, + "/rustic/organizations/org1/secrets", "", + map[string]string{"org_id": "org1"}) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + body := rr.Body.String() + if strings.Contains(body, "secretA") || strings.Contains(body, "secretB") { + t.Fatalf("list must not include secret values: %s", body) + } + + var got struct { + Secrets []string `json:"secrets"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal list: %v", err) + } + if len(got.Secrets) != 2 || got.Secrets[0] != "A" || got.Secrets[1] != "B" { + t.Fatalf("unexpected list: %+v", got.Secrets) + } +} + +func TestHandleCreateSecret_InvalidOrg(t *testing.T) { + s := newSecretsServer() + rr := doSecretReq(t, s.handleCreateSecret(), http.MethodPost, + "/rustic/organizations//secrets", + `{"name":"K","value":"v"}`, + map[string]string{"org_id": ""}) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } +} diff --git a/forge-go/api/server.go b/forge-go/api/server.go index 2f71f47..53004b9 100644 --- a/forge-go/api/server.go +++ b/forge-go/api/server.go @@ -19,6 +19,7 @@ import ( "github.com/rustic-ai/forge/forge-go/modelfit" "github.com/rustic-ai/forge/forge-go/oauth" "github.com/rustic-ai/forge/forge-go/protocol" + "github.com/rustic-ai/forge/forge-go/secrets" "github.com/rustic-ai/forge/forge-go/supervisor" ) @@ -36,6 +37,7 @@ type Server struct { modelFit *modelFitService oauthManager *oauth.Manager oauthRoutePrefix string + secretManager *secrets.Manager listenAddr string server *http.Server } @@ -93,6 +95,27 @@ func (s *Server) OAuthManager() *oauth.Manager { return s.oauthManager } +// WithSecrets initialises org-scoped secret management. The store backend is +// selected from FORGE_SECRET_STORE ("memory" (default) or "keychain"), mirroring +// the OAuth token store. With the keychain backend, secrets are stored under +// "secret:orgID|name" and resolve through the keychain SecretProvider directly — +// no delegation is needed since the stored value is the secret itself. +func (s *Server) WithSecrets() *Server { + store, err := secrets.NewSecretStore(os.Getenv("FORGE_SECRET_STORE")) + if err != nil { + fmt.Printf("WARN: %v; falling back to in-memory secret store\n", err) + store, _ = secrets.NewSecretStore("memory") + } + s.secretManager = secrets.NewManager(store) + return s +} + +// SecretManager returns the secrets.Manager initialised by WithSecrets, or nil +// if secret management is not configured. +func (s *Server) SecretManager() *secrets.Manager { + return s.secretManager +} + func (s *Server) WithObservability(mode, sqliteDBPath string) *Server { s.observeService = newObserveService(mode, sqliteDBPath) return s @@ -194,6 +217,9 @@ func (s *Server) buildRouter() *gin.Engine { if s.oauthManager != nil { s.registerOAuthRoutes(router, "/rustic") } + if s.secretManager != nil { + s.registerSecretRoutes(router, "/rustic") + } router.GET("/rustic/modelfit/local-models", wrapHTTP(s.handleListLocalModelFits())) router.GET("/rustic/modelfit/capabilities", wrapHTTP(s.handleGetModelFitCapabilities())) router.GET("/rustic/observe/guilds/:guild_id/messages/:msg_id/spans", wrapHTTPWithPathValues(s.handleObserveMessageSpans(), "guild_id", "msg_id")) diff --git a/forge-go/go.mod b/forge-go/go.mod index 0698a84..193e451 100644 --- a/forge-go/go.mod +++ b/forge-go/go.mod @@ -22,7 +22,7 @@ require ( github.com/shirou/gopsutil/v4 v4.26.6 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - github.com/zalando/go-keyring v0.2.8 + github.com/zalando/go-keyring v0.2.9-0.20260619152553-5c05d1864e65 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 diff --git a/forge-go/go.sum b/forge-go/go.sum index 0ccd57b..b733ed7 100644 --- a/forge-go/go.sum +++ b/forge-go/go.sum @@ -458,6 +458,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +github.com/zalando/go-keyring v0.2.9-0.20260619152553-5c05d1864e65 h1:02aBOWjuosNhrQoDfK2zNIX4JS+JZTYVQGsf39dkOtM= +github.com/zalando/go-keyring v0.2.9-0.20260619152553-5c05d1864e65/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= diff --git a/forge-go/helper/envvars/envvars.go b/forge-go/helper/envvars/envvars.go index 5963aba..5840b4c 100644 --- a/forge-go/helper/envvars/envvars.go +++ b/forge-go/helper/envvars/envvars.go @@ -3,6 +3,7 @@ package envvars import ( "context" "encoding/json" + "errors" "fmt" "os" @@ -148,6 +149,23 @@ func BuildAgentEnv( return result, nil } +func resolveSecretWithFallback( + ctx context.Context, + secretProvider secrets.SecretProvider, + orgID string, + key string, +) (string, error) { + // secrets api stores secrets by orgID + secretKey := secrets.SecretStoreKey(orgID, key) + val, err := secretProvider.Resolve(ctx, secretKey) + if err != nil && errors.Is(err, secrets.ErrSecretNotFound) { + // Try key directly - secret is not managed using secrets api + val, err = secretProvider.Resolve(ctx, key) + return val, err + } + return val, err +} + func resolveSecrets( ctx context.Context, agentSpec *protocol.AgentSpec, @@ -157,9 +175,9 @@ func resolveSecrets( envMap map[string]string, ) error { for _, key := range agentSpec.Resources.Secrets { - val, err := secretProvider.Resolve(ctx, key) + val, err := resolveSecretWithFallback(ctx, secretProvider, orgID, key) if err != nil { - if err == secrets.ErrSecretNotFound { + if errors.Is(err, secrets.ErrSecretNotFound) { continue } return fmt.Errorf("failed to resolve secret '%s' for agent '%s': %w", key, agentSpec.ID, err) @@ -172,9 +190,9 @@ func resolveSecrets( } for _, s := range regEntry.Secrets { - val, err := secretProvider.Resolve(ctx, s.Key) + val, err := resolveSecretWithFallback(ctx, secretProvider, orgID, s.Key) if err != nil { - if err == secrets.ErrSecretNotFound && (s.Optional == nil || *s.Optional) { + if errors.Is(err, secrets.ErrSecretNotFound) && (s.Optional == nil || *s.Optional) { continue } return fmt.Errorf("failed to resolve secret '%s' for agent '%s': %w", s.Key, agentSpec.ID, err) @@ -186,7 +204,7 @@ func resolveSecrets( secretKey := oauth.StoreKey(orgID, o.Provider) val, err := secretProvider.Resolve(ctx, secretKey) if err != nil { - if err == secrets.ErrSecretNotFound && (o.Optional == nil || *o.Optional) { + if errors.Is(err, secrets.ErrSecretNotFound) && (o.Optional == nil || *o.Optional) { continue } return fmt.Errorf("failed to resolve OAuth token for provider '%s', agent '%s': %w", o.Provider, agentSpec.ID, err) diff --git a/forge-go/helper/envvars/envvars_test.go b/forge-go/helper/envvars/envvars_test.go index a121405..50edc87 100644 --- a/forge-go/helper/envvars/envvars_test.go +++ b/forge-go/helper/envvars/envvars_test.go @@ -538,3 +538,50 @@ func TestBuildAgentEnv_SerializesAgentDependencyMap(t *testing.T) { t.Errorf("unexpected filesystem properties in FORGE_AGENT_CONFIG_JSON: %v", props) } } + +func TestBuildAgentEnv_RegistrySecret_WithOrgId(t *testing.T) { + ctx := context.Background() + orgID := "acme" + + guildSpec := &protocol.GuildSpec{ID: "test/guild", Name: "Test"} + agentSpec := &protocol.AgentSpec{ID: "AgentSec", ClassName: "test.AgentSec"} + + regEntry := ®istry.AgentRegistryEntry{ + Secrets: []protocol.SecretNeed{ + protocol.NewSecretNeed("TEST_GLOBAL_KEY"), + protocol.NewSecretNeed("ORG_KEY"), + protocol.NewSecretNeed("USER_NAME"), + }, + } + + provider := &mockSecretProvider{ + secrets: map[string]string{ + "TEST_GLOBAL_KEY": "myGlobalKey", + "USER_NAME": "globalUserName", + secrets.SecretStoreKey(orgID, "ORG_KEY"): "myOrgKey", + secrets.SecretStoreKey(orgID, "USER_NAME"): "orgUserName", + }, + } + + envSlice, err := BuildAgentEnv(ctx, guildSpec, agentSpec, regEntry, provider, orgID) + if err != nil { + t.Fatalf("BuildAgentEnv failed: %v", err) + } + + envMap := make(map[string]string) + for _, e := range envSlice { + if parts := strings.SplitN(e, "=", 2); len(parts) == 2 { + envMap[parts[0]] = parts[1] + } + } + + if envMap["TEST_GLOBAL_KEY"] != "myGlobalKey" { + t.Errorf("expected TEST_GLOBAL_KEY=myGlobalKey, got %q", envMap["TEST_GLOBAL_KEY"]) + } + if envMap["USER_NAME"] != "orgUserName" { + t.Errorf("expected USER_NAME=orgUserName, got %q", envMap["USER_NAME"]) + } + if envMap["ORG_KEY"] != "myOrgKey" { + t.Errorf("expected ORG_KEY=myOrgKey, got %q", envMap["ORG_KEY"]) + } +} diff --git a/forge-go/keychain/provider.go b/forge-go/keychain/provider.go index 802fe1e..67ea428 100644 --- a/forge-go/keychain/provider.go +++ b/forge-go/keychain/provider.go @@ -1,12 +1,9 @@ // Package keychain provides a secrets.SecretProvider backed by the OS keychain // (macOS Keychain, Windows Credential Manager, Linux Secret Service). // -// Keys with the "oauth:orgID|providerID" format are handled specially: -// if an OAuth manager is registered via SetOAuthManager, Resolve delegates +// If an OAuth manager is registered via SetOAuthManager, Resolve delegates // to it (with automatic token refresh). Otherwise it reads the raw keychain -// value and extracts the access_token field from the stored JSON. -// -// All other keys are returned as-is from the keychain. +// value package keychain import ( diff --git a/forge-go/secrets/keychain_secret_store.go b/forge-go/secrets/keychain_secret_store.go new file mode 100644 index 0000000..1d6ea67 --- /dev/null +++ b/forge-go/secrets/keychain_secret_store.go @@ -0,0 +1,52 @@ +package secrets + +import ( + "sort" + "strings" + + "github.com/rustic-ai/forge/forge-go/forgepath" + "github.com/zalando/go-keyring" +) + +// KeychainSecretStore persists org-scoped secret values in the OS keychain + +type KeychainSecretStore struct { + service string +} + +func NewKeychainSecretStore() *KeychainSecretStore { + return NewKeychainSecretStoreWithService(forgepath.KeychainService()) +} + +func NewKeychainSecretStoreWithService(service string) *KeychainSecretStore { + return &KeychainSecretStore{service: service} +} + +func (s *KeychainSecretStore) Save(orgID, name, value string) error { + return keyring.Set(s.service, SecretStoreKey(orgID, name), value) +} + +func (s *KeychainSecretStore) Delete(orgID, name string) bool { + return keyring.Delete(s.service, SecretStoreKey(orgID, name)) == nil +} + +func (s *KeychainSecretStore) Exists(orgID, name string) bool { + _, err := keyring.Get(s.service, SecretStoreKey(orgID, name)) + return err == nil +} + +func (s *KeychainSecretStore) List(orgID string) ([]string, error) { + var filtered []string + availableKeys, err := keyring.ListUsers(s.service) + // using empty string for name since we want to list all secrets saved for the org + prefix := SecretStoreKey(orgID, "") + for _, key := range availableKeys { + if strings.HasPrefix(key, prefix) { + // Add only the part after the prefix + stripped := strings.TrimPrefix(key, prefix) + filtered = append(filtered, stripped) + } + } + sort.Strings(filtered) + return filtered, err +} diff --git a/forge-go/secrets/keychain_secret_store_test.go b/forge-go/secrets/keychain_secret_store_test.go new file mode 100644 index 0000000..a508888 --- /dev/null +++ b/forge-go/secrets/keychain_secret_store_test.go @@ -0,0 +1,62 @@ +package secrets + +import ( + "testing" + + "github.com/zalando/go-keyring" +) + +func newTestKeychainStore(t *testing.T) *KeychainSecretStore { + t.Helper() + keyring.MockInit() // in-memory keychain for tests + return NewKeychainSecretStoreWithService("forge-test") +} + +func TestKeychainSecretStore_CRUDAndList(t *testing.T) { + s := newTestKeychainStore(t) + + if err := s.Save("org1", "A", "va"); err != nil { + t.Fatalf("Save A: %v", err) + } + if err := s.Save("org1", "B", "vb"); err != nil { + t.Fatalf("Save B: %v", err) + } + if !s.Exists("org1", "A") { + t.Fatal("expected A to exist") + } + if s.Exists("org1", "MISSING") { + t.Fatal("did not expect MISSING to exist") + } + + names, err := s.List("org1") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(names) != 2 || names[0] != "A" || names[1] != "B" { + t.Fatalf("expected sorted [A B], got %+v", names) + } + + if !s.Delete("org1", "A") { + t.Fatal("expected Delete A to report true") + } + names, _ = s.List("org1") + if len(names) != 1 || names[0] != "B" { + t.Fatalf("expected [B] after delete, got %+v", names) + } + if s.Exists("org1", "A") { + t.Fatal("A should be gone from the keychain after delete") + } +} + +func TestKeychainSecretStore_OrgIsolation(t *testing.T) { + s := newTestKeychainStore(t) + + s.Save("org1", "K", "one") //nolint:errcheck + s.Save("org2", "K", "two") //nolint:errcheck + + n1, _ := s.List("org1") + n2, _ := s.List("org2") + if len(n1) != 1 || len(n2) != 1 { + t.Fatalf("expected one secret per org, got org1=%v org2=%v", n1, n2) + } +} diff --git a/forge-go/secrets/manager.go b/forge-go/secrets/manager.go new file mode 100644 index 0000000..27f43f2 --- /dev/null +++ b/forge-go/secrets/manager.go @@ -0,0 +1,66 @@ +package secrets + +import ( + "errors" + "sync" +) + +// ErrSecretExists is returned by Manager.Set when a secret with the same name +// already exists for the org. +var ErrSecretExists = errors.New("secret already exists") + +// Manager provides org-scoped secret CRUD on top of a SecretStore. It holds no +// state of its own — the store is the single source of truth, so there is no +// separate name index to maintain. Its mutex only serializes the +// exists-then-write sequence in Set/Update against concurrent callers. +// +// The Manager deliberately exposes no method that returns a secret value: +// values are read only by a SecretProvider during resolution, never through the +// management API, so they cannot be leaked via an HTTP handler. +type Manager struct { + mu sync.Mutex + store SecretStore +} + +// NewManager constructs a Manager backed by the given SecretStore. +func NewManager(store SecretStore) *Manager { + return &Manager{store: store} +} + +// Set creates a new secret for the org. It returns ErrSecretExists if a secret +// with the same name already exists (use Update to change an existing value). +func (m *Manager) Set(orgID, name, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.store.Exists(orgID, name) { + return ErrSecretExists + } + return m.store.Save(orgID, name, value) +} + +// Update replaces the value of an existing secret. It returns ErrSecretNotFound +// if no secret with that name exists for the org. +func (m *Manager) Update(orgID, name, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + if !m.store.Exists(orgID, name) { + return ErrSecretNotFound + } + return m.store.Save(orgID, name, value) +} + +// Delete removes a secret. It returns false if no secret with that name exists +// for the org. +func (m *Manager) Delete(orgID, name string) bool { + return m.store.Delete(orgID, name) +} + +// List returns the names of the org's secrets, sorted. It never returns values. +func (m *Manager) List(orgID string) ([]string, error) { + return m.store.List(orgID) +} + +// Exists reports whether a secret with that name exists for the org. +func (m *Manager) Exists(orgID, name string) bool { + return m.store.Exists(orgID, name) +} diff --git a/forge-go/secrets/manager_test.go b/forge-go/secrets/manager_test.go new file mode 100644 index 0000000..05a33b9 --- /dev/null +++ b/forge-go/secrets/manager_test.go @@ -0,0 +1,115 @@ +package secrets + +import ( + "errors" + "testing" +) + +func TestManager_SetAndList(t *testing.T) { + m := NewManager(NewInMemorySecretStore()) + + if err := m.Set("org1", "API_KEY", "s3cr3t"); err != nil { + t.Fatalf("Set failed: %v", err) + } + if !m.Exists("org1", "API_KEY") { + t.Fatal("expected secret to exist after Set") + } + + names, err := m.List("org1") + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(names) != 1 || names[0] != "API_KEY" { + t.Fatalf("unexpected list: %+v", names) + } +} + +func TestManager_SetDuplicateReturnsExists(t *testing.T) { + m := NewManager(NewInMemorySecretStore()) + if err := m.Set("org1", "TOKEN", "a"); err != nil { + t.Fatalf("first Set failed: %v", err) + } + if err := m.Set("org1", "TOKEN", "b"); !errors.Is(err, ErrSecretExists) { + t.Fatalf("expected ErrSecretExists, got %v", err) + } +} + +func TestManager_UpdateMissingReturnsNotFound(t *testing.T) { + m := NewManager(NewInMemorySecretStore()) + if err := m.Update("org1", "NOPE", "x"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("expected ErrSecretNotFound, got %v", err) + } +} + +func TestManager_UpdateExisting(t *testing.T) { + m := NewManager(NewInMemorySecretStore()) + if err := m.Set("org1", "K", "v1"); err != nil { + t.Fatalf("Set failed: %v", err) + } + if err := m.Update("org1", "K", "v2"); err != nil { + t.Fatalf("Update failed: %v", err) + } + if !m.Exists("org1", "K") { + t.Fatal("expected secret to still exist after Update") + } +} + +func TestManager_Delete(t *testing.T) { + m := NewManager(NewInMemorySecretStore()) + if err := m.Set("org1", "K", "v"); err != nil { + t.Fatalf("Set failed: %v", err) + } + + if !m.Delete("org1", "K") { + t.Fatal("expected Delete to report true") + } + if m.Delete("org1", "K") { + t.Fatal("expected second Delete to report false") + } + if m.Exists("org1", "K") { + t.Fatal("expected secret to be gone after delete") + } + names, _ := m.List("org1") + if len(names) != 0 { + t.Fatalf("expected empty list after delete, got %+v", names) + } +} + +func TestManager_OrgIsolation(t *testing.T) { + m := NewManager(NewInMemorySecretStore()) + m.Set("org1", "K", "one") //nolint:errcheck + m.Set("org2", "K", "two") //nolint:errcheck + + if !m.Exists("org1", "K") || !m.Exists("org2", "K") { + t.Fatal("both orgs should have their own secret") + } + names, _ := m.List("org1") + if len(names) != 1 || names[0] != "K" { + t.Fatalf("expected org1 to see only its own secret, got %+v", names) + } +} + +func TestManager_ListSorted(t *testing.T) { + m := NewManager(NewInMemorySecretStore()) + m.Set("org1", "B", "1") //nolint:errcheck + m.Set("org1", "A", "2") //nolint:errcheck + m.Set("org1", "C", "3") //nolint:errcheck + + names, _ := m.List("org1") + if len(names) != 3 || names[0] != "A" || names[1] != "B" || names[2] != "C" { + t.Fatalf("expected sorted [A B C], got %+v", names) + } +} + +func TestParseSecretKey(t *testing.T) { + orgID, name, ok := ParseSecretKey(SecretStoreKey("org1", "MY_KEY")) + if !ok || orgID != "org1" || name != "MY_KEY" { + t.Fatalf("round-trip failed: org=%q name=%q ok=%v", orgID, name, ok) + } + if _, _, ok := ParseSecretKey("oauth:org1|github"); ok { + t.Fatal("oauth key must not parse as a secret key") + } + if _, _, ok := ParseSecretKey("plain-key"); ok { + t.Fatal("plain key must not parse as a secret key") + } +} diff --git a/forge-go/secrets/secret_store.go b/forge-go/secrets/secret_store.go new file mode 100644 index 0000000..313e63a --- /dev/null +++ b/forge-go/secrets/secret_store.go @@ -0,0 +1,112 @@ +package secrets + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// secretPrefix namespaces org-scoped secret entries so they do not collide with +// OAuth token entries ("oauth:") or DCR client credentials ("oauth-client:"). +const secretPrefix = "secret:" + +// SecretStoreKey builds the storage key for an org-scoped secret. It mirrors +// oauth.StoreKey: "secret:orgID|name". +func SecretStoreKey(orgID, name string) string { + return secretPrefix + orgID + "|" + name +} + +// ParseSecretKey parses a "secret:orgID|name" key. Returns ok=false for keys +// that are not org-scoped secrets. +func ParseSecretKey(key string) (orgID, name string, ok bool) { + rest, found := strings.CutPrefix(key, secretPrefix) + if !found { + return "", "", false + } + idx := strings.Index(rest, "|") + if idx <= 0 || idx == len(rest)-1 { + return "", "", false + } + return rest[:idx], rest[idx+1:], true +} + +// SecretStore persists org-scoped secret values keyed by (orgID, name) and is +// the single source of truth for which secrets exist. Implement this interface +// to swap in keychain, encrypted DB, or other backends. +// +// It deliberately exposes no method that returns a stored value: values are read +// only by a SecretProvider during resolution (which owns its own backend access), +// never through the management path, so the CRUD API cannot leak them. +type SecretStore interface { + // Save creates or overwrites the secret's value. Create/update semantics + // (conflict vs not-found) are enforced by the Manager via Exists. + Save(orgID, name, value string) error + Delete(orgID, name string) bool + Exists(orgID, name string) bool + // List returns the names of the org's secrets, sorted. The error return lets + // backends report enumeration failures; current backends do not fail. + List(orgID string) ([]string, error) +} + +// NewSecretStore creates a SecretStore by name. Supported backends: +// - "memory" (default): in-process store, secrets lost on restart. +// - "keychain": OS keychain (macOS Keychain, Windows Credential Manager, +// Linux Secret Service). Set FORGE_SECRET_STORE=keychain to activate. +func NewSecretStore(kind string) (SecretStore, error) { + switch kind { + case "", "memory": + return NewInMemorySecretStore(), nil + case "keychain": + return NewKeychainSecretStore(), nil + default: + return nil, fmt.Errorf("unknown secret store %q; supported: memory, keychain", kind) + } +} + +// InMemorySecretStore is the default in-process secret store. Values are lost on +// server restart. Its map is the single source of truth for listing. +type InMemorySecretStore struct { + mu sync.Mutex + secrets map[string]string // key: "SecretStoreKey(orgID, name)" +} + +func NewInMemorySecretStore() *InMemorySecretStore { + return &InMemorySecretStore{secrets: make(map[string]string)} +} + +func (s *InMemorySecretStore) Save(orgID, name, value string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.secrets[SecretStoreKey(orgID, name)] = value + return nil +} + +func (s *InMemorySecretStore) Delete(orgID, name string) bool { + s.mu.Lock() + defer s.mu.Unlock() + key := SecretStoreKey(orgID, name) + _, ok := s.secrets[key] + delete(s.secrets, key) + return ok +} + +func (s *InMemorySecretStore) Exists(orgID, name string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.secrets[SecretStoreKey(orgID, name)] + return ok +} + +func (s *InMemorySecretStore) List(orgID string) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + names := make([]string, 0) + for key := range s.secrets { + if kOrg, name, ok := ParseSecretKey(key); ok && kOrg == orgID { + names = append(names, name) + } + } + sort.Strings(names) + return names, nil +}