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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions forge-go/conf/oauth-providers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
41 changes: 30 additions & 11 deletions forge-go/oauth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package oauth

import (
"fmt"
"net/url"
"os"
"regexp"

Expand All @@ -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
Expand All @@ -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
}
Expand Down
7 changes: 6 additions & 1 deletion forge-go/oauth/keychain_token_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -36,6 +39,7 @@ func toStoredEntry(e *tokenEntry) *storedEntry {
TokenURL: e.endpoint.TokenURL,
AuthStyle: int(e.endpoint.AuthStyle),
Scopes: e.scopes,
Resource: e.resource,
}
}

Expand All @@ -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,
}
}

Expand Down
38 changes: 33 additions & 5 deletions forge-go/oauth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
}

Expand All @@ -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
}

Expand All @@ -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.
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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,
})
}

Expand All @@ -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,
Expand Down
38 changes: 35 additions & 3 deletions forge-go/oauth/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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{}}

Expand Down
72 changes: 72 additions & 0 deletions forge-go/oauth/resource_param.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading