diff --git a/forge-go/conf/oauth-providers.yaml b/forge-go/conf/oauth-providers.yaml index 7f0d0d8..19f7470 100644 --- a/forge-go/conf/oauth-providers.yaml +++ b/forge-go/conf/oauth-providers.yaml @@ -13,12 +13,20 @@ # use_pkce: controls PKCE (S256 code challenge). Defaults to true. Set to # false for providers that do not support it. # +# resource_url: the canonical URI of the protected resource behind this +# provider (e.g. an MCP server endpoint). When set, Forge sends it as the +# RFC 8707 `resource` parameter on the authorization, token and refresh +# requests, so the authorization server can issue a token audience-restricted +# to that resource. Must be an absolute URI with no fragment. Leave it unset +# for authorization servers that do not understand the parameter — nothing is +# sent then. It does NOT provide endpoint discovery on its own: a static +# provider still needs auth_url and token_url (or a built-in endpoint). +# # 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. +# resource_url. Forge then also discovers the auth/token/registration endpoints +# from it (RFC 9728 + RFC 8414) and registers its own client on demand — no +# client_id/client_secret needed. # # Add a new provider by adding an entry under `providers`. Restart Forge after # editing this file. @@ -44,6 +52,8 @@ providers: # auth_url: https://example.com/oauth/authorize # token_url: https://example.com/oauth/token # use_pkce: false +# # Optional: ask for a token restricted to this resource (RFC 8707). +# resource_url: https://api.example.com # # Dynamic Client Registration example (e.g. a remote MCP server): # notion-mcp: diff --git a/forge-go/oauth/config.go b/forge-go/oauth/config.go index 81d3309..0032978 100644 --- a/forge-go/oauth/config.go +++ b/forge-go/oauth/config.go @@ -2,6 +2,7 @@ package oauth import ( "fmt" + "net/url" "os" "regexp" @@ -21,11 +22,20 @@ type ProviderConfig struct { // Set to false for providers that do not support it. 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 is the canonical URI of the OAuth2 protected resource this + // provider fronts (e.g. an MCP server endpoint). + // + // Whenever it is set — with or without UseDCRP — it is sent as the RFC 8707 + // `resource` parameter on the authorization, token-exchange and refresh + // requests, asking the authorization server for a token audience-restricted + // to that resource. MCP servers require this; static providers whose + // authorization server does not understand the parameter should leave + // resource_url unset. + // + // With UseDCRP it is additionally the discovery input: the + // auth/token/registration endpoints are read from the metadata it advertises + // (RFC 9728 + RFC 8414). Static providers still need AuthURL and TokenURL + // (or a built-in endpoint) — resource_url alone discovers nothing for them. 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 @@ -42,16 +52,25 @@ func (p ProviderConfig) RequiresClientCredentials() bool { } // 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). +// an auth flow is started: DCR discovers its endpoints from the resource, so it +// requires resource_url, and resource_url — which is sent as the RFC 8707 +// `resource` parameter — must be a valid resource indicator: an absolute URI +// with no fragment (RFC 8707, section 2). 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) + if p.ResourceURL != "" { + u, err := url.Parse(p.ResourceURL) + if err != nil { + return fmt.Errorf("provider %q: invalid resource_url %q: %w", id, p.ResourceURL, err) + } + if u.Scheme == "" || u.Host == "" { + return fmt.Errorf("provider %q: resource_url must be an absolute URI, got %q", id, p.ResourceURL) + } + if u.Fragment != "" { + return fmt.Errorf("provider %q: resource_url must not contain a fragment, got %q", id, p.ResourceURL) + } } return nil } diff --git a/forge-go/oauth/keychain_token_store.go b/forge-go/oauth/keychain_token_store.go index 9cead24..cff57c6 100644 --- a/forge-go/oauth/keychain_token_store.go +++ b/forge-go/oauth/keychain_token_store.go @@ -22,6 +22,9 @@ type storedEntry struct { TokenURL string `json:"token_url"` AuthStyle int `json:"auth_style"` Scopes []string `json:"scopes"` + // Resource is the RFC 8707 resource indicator, needed to refresh the token + // with the same audience. Absent in entries written before it was supported. + Resource string `json:"resource,omitempty"` } func toStoredEntry(e *tokenEntry) *storedEntry { @@ -36,6 +39,7 @@ func toStoredEntry(e *tokenEntry) *storedEntry { TokenURL: e.endpoint.TokenURL, AuthStyle: int(e.endpoint.AuthStyle), Scopes: e.scopes, + Resource: e.resource, } } @@ -54,7 +58,8 @@ func fromStoredEntry(s *storedEntry) *tokenEntry { TokenURL: s.TokenURL, AuthStyle: oauth2.AuthStyle(s.AuthStyle), }, - scopes: s.Scopes, + scopes: s.Scopes, + resource: s.Resource, } } diff --git a/forge-go/oauth/manager.go b/forge-go/oauth/manager.go index 93c6977..eda3019 100644 --- a/forge-go/oauth/manager.go +++ b/forge-go/oauth/manager.go @@ -25,7 +25,7 @@ import ( // (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. +// explicitly, or use use_dcrp for RFC 9728/8414 auto-discovery. var builtinEndpoints = map[string]oauth2.Endpoint{ "github": endpoints.GitHub, "google": endpoints.Google, @@ -47,7 +47,7 @@ func resolveEndpoint(providerID string, cfg ProviderConfig) (oauth2.Endpoint, er 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, + "provider %q has no known endpoints; set auth_url and token_url, or use_dcrp with resource_url for auto-discovery", providerID, ) } @@ -65,7 +65,10 @@ type pendingFlow struct { redirectURL string // endpoint is captured at authorize time so the callback exchange uses the // same (possibly discovered) endpoints without re-resolving. - endpoint oauth2.Endpoint + endpoint oauth2.Endpoint + // resource is the RFC 8707 resource indicator sent with the authorization + // request; the token exchange must repeat it. Empty when unconfigured. + resource string expiresAt time.Time } @@ -76,6 +79,9 @@ type tokenEntry struct { clientSecret string endpoint oauth2.Endpoint scopes []string + // resource is the RFC 8707 resource indicator the token was issued for. It + // is repeated on refresh so the new token keeps the same audience. + resource string } // ProviderStatus is the public view of a provider's connection state. @@ -183,8 +189,10 @@ func (m *Manager) GetAuthURL(ctx context.Context, orgID, providerID, clientID, c // // 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. + // an OAuth-protected resource such as a remote MCP server), so only UseDCRP + // resolves endpoints from resource_url. Everything else is static — but a + // static provider may still declare resource_url to have the RFC 8707 + // `resource` parameter sent (see below). var endpoint oauth2.Endpoint usePKCE := cfg.pkce() @@ -232,6 +240,7 @@ func (m *Manager) GetAuthURL(ctx context.Context, orgID, providerID, clientID, c codeVerifier: verifier, redirectURL: redirectURL, endpoint: endpoint, + resource: cfg.ResourceURL, expiresAt: time.Now().Add(10 * time.Minute), } m.mu.Unlock() @@ -248,6 +257,11 @@ func (m *Manager) GetAuthURL(ctx context.Context, orgID, providerID, clientID, c if usePKCE { authOpts = append(authOpts, oauth2.S256ChallengeOption(verifier)) } + // RFC 8707: name the protected resource the token is for, so the + // authorization server can audience-restrict it. Required by MCP servers. + if cfg.ResourceURL != "" { + authOpts = append(authOpts, resourceParamOption(cfg.ResourceURL)) + } return oc.AuthCodeURL(state, authOpts...), state, nil } @@ -327,6 +341,11 @@ func (m *Manager) ExchangeCode(ctx context.Context, code, state string) (provide if flow.codeVerifier != "" { exchangeOpts = append(exchangeOpts, oauth2.VerifierOption(flow.codeVerifier)) } + // RFC 8707 requires the resource indicator from the authorization request to + // be repeated here; omitting it can yield a token for a different audience. + if flow.resource != "" { + exchangeOpts = append(exchangeOpts, resourceParamOption(flow.resource)) + } token, err := oc.Exchange(ctx, code, exchangeOpts...) if err != nil { return providerID, fmt.Errorf("exchanging code: %w", err) @@ -338,6 +357,7 @@ func (m *Manager) ExchangeCode(ctx context.Context, code, state string) (provide clientSecret: flow.clientSecret, endpoint: endpoint, scopes: cfg.Scopes, + resource: flow.resource, }) } @@ -356,6 +376,14 @@ func (m *Manager) GetAccessToken(ctx context.Context, orgID, providerID string) return entry.token.AccessToken, nil } + // RFC 8707: the refresh must carry the same resource indicator, otherwise the + // renewed token can come back scoped to a different audience. x/oauth2 builds + // the refresh request itself and exposes no per-request option, so the + // parameter is injected by the HTTP client (see resource_param.go). + if entry.resource != "" { + ctx = context.WithValue(ctx, oauth2.HTTPClient, m.resourceParamClient(entry.resource)) + } + ts := (&oauth2.Config{ ClientID: entry.clientID, ClientSecret: entry.clientSecret, diff --git a/forge-go/oauth/manager_test.go b/forge-go/oauth/manager_test.go index 5c90f04..6cdc01d 100644 --- a/forge-go/oauth/manager_test.go +++ b/forge-go/oauth/manager_test.go @@ -153,14 +153,20 @@ func TestDisconnect_KeepsGlobalDCRClient(t *testing.T) { } } -func TestLoadProvidersConfig_DCRResourceURLPairing(t *testing.T) { +func TestLoadProvidersConfig_RejectsInvalidResourceConfig(t *testing.T) { cases := map[string]string{ + // DCR discovers its endpoints from the resource, so it needs one. "dcr without resource_url": `providers: broken: use_dcrp: true`, - "resource_url without dcr": `providers: + // resource_url is sent as the RFC 8707 resource indicator, which must be + // an absolute URI with no fragment. + "relative resource_url": `providers: broken: - resource_url: https://mcp.example.com/mcp`, + resource_url: /mcp`, + "resource_url with fragment": `providers: + broken: + resource_url: https://mcp.example.com/mcp#frag`, } for name, yaml := range cases { t.Run(name, func(t *testing.T) { @@ -175,6 +181,32 @@ func TestLoadProvidersConfig_DCRResourceURLPairing(t *testing.T) { } } +// A static provider may declare resource_url purely to have the RFC 8707 +// resource indicator sent; it is not DCR-only. +func TestLoadProvidersConfig_ResourceURLWithoutDCR(t *testing.T) { + yaml := `providers: + api: + auth_url: https://example.com/oauth/authorize + token_url: https://example.com/oauth/token + resource_url: https://api.example.com` + + path := t.TempDir() + "/providers.yaml" + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + cfg, err := LoadProvidersConfig(path) + if err != nil { + t.Fatalf("LoadProvidersConfig failed: %v", err) + } + p := cfg.Providers["api"] + if p.ResourceURL != "https://api.example.com" { + t.Errorf("resource_url = %q", p.ResourceURL) + } + if p.UseDCRP { + t.Error("expected use_dcrp to stay false") + } +} + func TestWithDynamicClient(t *testing.T) { cfg := &ProvidersConfig{Providers: map[string]ProviderConfig{}} diff --git a/forge-go/oauth/resource_param.go b/forge-go/oauth/resource_param.go new file mode 100644 index 0000000..eaa29d2 --- /dev/null +++ b/forge-go/oauth/resource_param.go @@ -0,0 +1,72 @@ +package oauth + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + + "golang.org/x/oauth2" +) + +// resourceParam is the RFC 8707 resource indicator parameter name. It names the +// protected resource a token is requested for, so the authorization server can +// restrict the token's audience to it. +const resourceParam = "resource" + +// resourceParamOption sends resource as the RFC 8707 resource indicator on an +// authorization or token request. +func resourceParamOption(resource string) oauth2.AuthCodeOption { + return oauth2.SetAuthURLParam(resourceParam, resource) +} + +// resourceParamClient returns an HTTP client that adds the RFC 8707 resource +// indicator to the refresh request. Refresh is the one request x/oauth2 builds +// internally — Config.TokenSource takes no oauth2.AuthCodeOption, unlike +// AuthCodeURL and Exchange — so the parameter can only be added on the way out. +// +// The client is handed to nothing else, so the transport assumes what a refresh +// request is: a form-encoded POST to the token endpoint. +func (m *Manager) resourceParamClient(resource string) *http.Client { + return &http.Client{ + Timeout: m.httpClient.Timeout, + Transport: &resourceParamTransport{base: m.httpClient.Transport, resource: resource}, + } +} + +type resourceParamTransport struct { + base http.RoundTripper // nil means http.DefaultTransport + resource string +} + +func (t *resourceParamTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.base + if base == nil { + base = http.DefaultTransport + } + if req.Body == nil { + return base.RoundTrip(req) + } + + body, err := io.ReadAll(req.Body) + req.Body.Close() //nolint:errcheck // the body is fully consumed above + if err != nil { + return nil, fmt.Errorf("reading token request body: %w", err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + return nil, fmt.Errorf("parsing token request body: %w", err) + } + form.Set(resourceParam, t.resource) + encoded := []byte(form.Encode()) + + // RoundTrip must leave the caller's request alone. + clone := req.Clone(req.Context()) + clone.Body = io.NopCloser(bytes.NewReader(encoded)) + clone.ContentLength = int64(len(encoded)) + clone.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(encoded)), nil + } + return base.RoundTrip(clone) +} diff --git a/forge-go/oauth/resource_param_test.go b/forge-go/oauth/resource_param_test.go new file mode 100644 index 0000000..a227f0e --- /dev/null +++ b/forge-go/oauth/resource_param_test.go @@ -0,0 +1,290 @@ +package oauth + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" +) + +// tokenServer serves a fixed token response and exposes the form of the last +// token request it received. +func tokenServer(t *testing.T) (*httptest.Server, *url.Values) { + t.Helper() + var got url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parsing token request: %v", err) + } + got = r.PostForm + w.Header().Set("Content-Type", "application/json") + // refresh_token is deliberately omitted: the caller must keep the old one. + _, _ = w.Write([]byte(`{"access_token":"new-access","token_type":"Bearer","expires_in":3600}`)) + })) + t.Cleanup(srv.Close) + return srv, &got +} + +func TestGetAuthURL_SendsResourceParam(t *testing.T) { + const resource = "https://api.example.com" + cfg := &ProvidersConfig{ + Providers: map[string]ProviderConfig{ + // A static provider: resource_url is only there for RFC 8707. + "api": { + AuthURL: "https://example.com/oauth/authorize", + TokenURL: "https://example.com/oauth/token", + ResourceURL: resource, + }, + "plain": { + AuthURL: "https://example.com/oauth/authorize", + TokenURL: "https://example.com/oauth/token", + }, + "mcp": {ResourceURL: "https://mcp.example.com/mcp", UseDCRP: true}, + }, + } + m := NewManager(cfg) + for _, id := range []string{"api", "plain", "mcp"} { + m.CheckAndUpdateProvider(id, nil) + } + 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"}, + }) + _ = m.credStore.SaveCredentials("mcp", &clientCredentials{ClientID: "registered-id"}) + + t.Run("static provider with resource_url", func(t *testing.T) { + authURL, state, err := m.GetAuthURL(context.Background(), "org1", "api", "cid", "csecret", "https://example.com/cb") + if err != nil { + t.Fatalf("GetAuthURL failed: %v", err) + } + if got := queryParam(t, authURL, "resource"); got != resource { + t.Errorf("resource = %q, want %q", got, resource) + } + // The exchange has to repeat it, so the flow must carry it. + m.mu.Lock() + flow := m.pendingFlows[state] + m.mu.Unlock() + if flow == nil || flow.resource != resource { + t.Errorf("pending flow resource = %q, want %q", flow.resource, resource) + } + }) + + t.Run("no resource_url sends nothing", func(t *testing.T) { + authURL, _, err := m.GetAuthURL(context.Background(), "org1", "plain", "cid", "csecret", "https://example.com/cb") + if err != nil { + t.Fatalf("GetAuthURL failed: %v", err) + } + if got := queryParam(t, authURL, "resource"); got != "" { + t.Errorf("resource = %q, want it absent", got) + } + }) + + t.Run("dcr provider", func(t *testing.T) { + authURL, _, err := m.GetAuthURL(context.Background(), "org1", "mcp", "", "", "https://example.com/cb") + if err != nil { + t.Fatalf("GetAuthURL failed: %v", err) + } + if got := queryParam(t, authURL, "resource"); got != "https://mcp.example.com/mcp" { + t.Errorf("resource = %q, want the discovery resource_url", got) + } + }) +} + +func queryParam(t *testing.T, rawURL, key string) string { + t.Helper() + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parsing %q: %v", rawURL, err) + } + return u.Query().Get(key) +} + +func TestExchangeCode_SendsResourceParam(t *testing.T) { + srv, form := tokenServer(t) + const resource = "https://api.example.com" + m := NewManager(&ProvidersConfig{ + Providers: map[string]ProviderConfig{ + "api": { + AuthURL: srv.URL + "/authorize", + TokenURL: srv.URL + "/token", + ResourceURL: resource, + }, + }, + }) + m.CheckAndUpdateProvider("api", nil) + + ctx := context.Background() + _, state, err := m.GetAuthURL(ctx, "org1", "api", "cid", "csecret", "https://example.com/cb") + if err != nil { + t.Fatalf("GetAuthURL failed: %v", err) + } + if _, err := m.ExchangeCode(ctx, "the-code", state); err != nil { + t.Fatalf("ExchangeCode failed: %v", err) + } + + if got := form.Get("resource"); got != resource { + t.Errorf("token request resource = %q, want %q", got, resource) + } + // Stored so the refresh can repeat it. + entry, ok := m.store.Load("org1", "api") + if !ok { + t.Fatal("expected a stored token entry") + } + if entry.resource != resource { + t.Errorf("stored resource = %q, want %q", entry.resource, resource) + } +} + +func TestGetAccessToken_RefreshSendsResourceParam(t *testing.T) { + const resource = "https://api.example.com" + + newManager := func(t *testing.T, tokenURL, entryResource string) *Manager { + m := NewManager(&ProvidersConfig{ + Providers: map[string]ProviderConfig{"api": {TokenURL: tokenURL, ResourceURL: resource}}, + }) + m.CheckAndUpdateProvider("api", nil) + // An expired token with a refresh token: GetAccessToken must refresh it. + if err := m.store.Save("org1", "api", &tokenEntry{ + token: &oauth2.Token{ + AccessToken: "old-access", + TokenType: "Bearer", + RefreshToken: "the-refresh-token", + Expiry: time.Now().Add(-time.Minute), + }, + clientID: "cid", + clientSecret: "csecret", + endpoint: oauth2.Endpoint{TokenURL: tokenURL, AuthStyle: oauth2.AuthStyleInParams}, + resource: entryResource, + }); err != nil { + t.Fatal(err) + } + return m + } + + t.Run("resource repeated on refresh", func(t *testing.T) { + srv, form := tokenServer(t) + m := newManager(t, srv.URL+"/token", resource) + + tok, err := m.GetAccessToken(context.Background(), "org1", "api") + if err != nil { + t.Fatalf("GetAccessToken failed: %v", err) + } + if tok != "new-access" { + t.Errorf("token = %q, want the refreshed one", tok) + } + if got := form.Get("grant_type"); got != "refresh_token" { + t.Fatalf("grant_type = %q, want refresh_token", got) + } + if got := form.Get("resource"); got != resource { + t.Errorf("refresh resource = %q, want %q", got, resource) + } + if got := form.Get("refresh_token"); got != "the-refresh-token" { + t.Errorf("refresh_token = %q, want it forwarded intact", got) + } + // The response omitted refresh_token, so the old one must survive. + entry, _ := m.store.Load("org1", "api") + if entry.token.RefreshToken != "the-refresh-token" { + t.Errorf("stored refresh token = %q, want it preserved", entry.token.RefreshToken) + } + if entry.resource != resource { + t.Errorf("stored resource = %q, want it preserved", entry.resource) + } + }) + + t.Run("no resource sends nothing", func(t *testing.T) { + srv, form := tokenServer(t) + m := newManager(t, srv.URL+"/token", "") + + if _, err := m.GetAccessToken(context.Background(), "org1", "api"); err != nil { + t.Fatalf("GetAccessToken failed: %v", err) + } + if got := form.Get("resource"); got != "" { + t.Errorf("resource = %q, want it absent", got) + } + }) +} + +// recordingRT captures the request its wrapper hands down. +type recordingRT struct { + req *http.Request + body string +} + +func (r *recordingRT) RoundTrip(req *http.Request) (*http.Response, error) { + r.req = req + if req.Body != nil { + b, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + r.body = string(b) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: http.Header{}, Request: req}, nil +} + +func TestResourceParamTransport(t *testing.T) { + const resource = "https://api.example.com" + + roundTrip := func(t *testing.T, req *http.Request) *recordingRT { + t.Helper() + base := &recordingRT{} + rt := &resourceParamTransport{base: base, resource: resource} + if _, err := rt.RoundTrip(req); err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + return base + } + + t.Run("adds resource to the form body", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://as.example.com/token", + strings.NewReader("grant_type=refresh_token&refresh_token=rt")) + if err != nil { + t.Fatal(err) + } + got := roundTrip(t, req) + + vals, err := url.ParseQuery(got.body) + if err != nil { + t.Fatalf("parsing forwarded body %q: %v", got.body, err) + } + if vals.Get("resource") != resource { + t.Errorf("resource = %q, want %q", vals.Get("resource"), resource) + } + if vals.Get("refresh_token") != "rt" { + t.Errorf("existing params lost: %q", got.body) + } + if got.req.ContentLength != int64(len(got.body)) { + t.Errorf("ContentLength = %d, want %d", got.req.ContentLength, len(got.body)) + } + if got.req == req { + t.Error("expected the caller's request to be left alone") + } + }) + + t.Run("passes a bodiless request through", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://as.example.com/token", nil) + if err != nil { + t.Fatal(err) + } + if got := roundTrip(t, req); got.body != "" || got.req.URL.Query().Get("resource") != "" { + t.Errorf("request was modified: body %q, url %s", got.body, got.req.URL) + } + }) +} + +func TestResourceParamClient_KeepsManagerTimeout(t *testing.T) { + m := NewManager(&ProvidersConfig{Providers: map[string]ProviderConfig{}}) + c := m.resourceParamClient("https://api.example.com") + if c.Timeout != m.httpClient.Timeout { + t.Errorf("timeout = %v, want %v", c.Timeout, m.httpClient.Timeout) + } + if _, ok := c.Transport.(*resourceParamTransport); !ok { + t.Errorf("transport = %T, want *resourceParamTransport", c.Transport) + } +} diff --git a/forge-go/version/version.go b/forge-go/version/version.go index 684e076..d4b3ffb 100644 --- a/forge-go/version/version.go +++ b/forge-go/version/version.go @@ -1,7 +1,7 @@ package version var ( - Version = "0.4.4" + Version = "0.4.5" GitCommit = "none" BuildDate = "unknown" ) diff --git a/forge-python/pyproject.toml b/forge-python/pyproject.toml index be1859b..957fdcd 100644 --- a/forge-python/pyproject.toml +++ b/forge-python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rusticai-forge" -version = "0.4.4" +version = "0.4.5" description = "Python agent wrapper and execution engine for Forge" readme = "README.md" requires-python = ">=3.13,<3.14" diff --git a/forge-python/uv.lock b/forge-python/uv.lock index 8a5d618..61e49aa 100644 --- a/forge-python/uv.lock +++ b/forge-python/uv.lock @@ -837,7 +837,7 @@ wheels = [ [[package]] name = "rusticai-forge" -version = "0.4.4" +version = "0.4.5" source = { editable = "." } dependencies = [ { name = "httpx" },