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
4 changes: 4 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 17 additions & 13 deletions pkg/auth/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ type AuthInfo struct {

// AuthServerInfo contains information about a validated authorization server
type AuthServerInfo struct {
Issuer string
AuthorizationURL string
TokenURL string
RegistrationEndpoint string
Issuer string
AuthorizationURL string
TokenURL string
RegistrationEndpoint string
ClientIDMetadataDocumentSupported bool
}

// Config holds configuration for authentication discovery
Expand Down Expand Up @@ -539,9 +540,10 @@ func PerformOAuthFlow(ctx context.Context, issuer string, config *OAuthFlowConfi
return nil, fmt.Errorf("OAuth flow config cannot be nil")
}

// Resolve port availability BEFORE dynamic registration
// This ensures we register the OAuth client with the same port we'll actually use

// Resolve port availability before registration. DCR clients allow port fallback
// because the actual port is registered after selection. Pre-registered and CIMD
// clients require the configured port to be available as-is — it is already
// published in their IdP application or metadata document redirect URI.
if shouldDynamicallyRegisterClient(config) {
// For dynamic registration, we can allow fallback to alternative ports
// since we can register the client with the actual port we'll use
Expand All @@ -555,8 +557,9 @@ func PerformOAuthFlow(ctx context.Context, issuer string, config *OAuthFlowConfi
}
config.CallbackPort = port
} else {
// For pre-registered clients, use strict port checking
// The user likely configured this port in their IdP/app
// For pre-registered clients and CIMD, use strict port checking.
// The port is either configured in the IdP app or baked into the
// redirect URI in the hosted metadata document.
if !networking.IsAvailable(config.CallbackPort) {
return nil, fmt.Errorf(
"specified auth callback port %d is not available - please choose a different port or ensure it's not in use",
Expand Down Expand Up @@ -836,10 +839,11 @@ func ValidateAndDiscoverAuthServer(ctx context.Context, potentialIssuer string)
}

return &AuthServerInfo{
Issuer: doc.Issuer,
AuthorizationURL: doc.AuthorizationEndpoint,
TokenURL: doc.TokenEndpoint,
RegistrationEndpoint: doc.RegistrationEndpoint,
Issuer: doc.Issuer,
AuthorizationURL: doc.AuthorizationEndpoint,
TokenURL: doc.TokenEndpoint,
RegistrationEndpoint: doc.RegistrationEndpoint,
ClientIDMetadataDocumentSupported: doc.ClientIDMetadataDocumentSupported,
}, nil
}

Expand Down
6 changes: 6 additions & 0 deletions pkg/auth/oauth/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ func discoverOIDCEndpointsWithClientAndValidation(
if oauthDoc.Issuer == doc.Issuer {
doc.RegistrationEndpoint = oauthDoc.RegistrationEndpoint
slog.Debug("Found registration_endpoint in OAuth authorization server metadata", "endpoint", doc.RegistrationEndpoint)
// Merge CIMD support flag — some servers (e.g. Granola) only advertise
// client_id_metadata_document_supported in the OAuth AS metadata, not
// in the OIDC discovery document.
if oauthDoc.ClientIDMetadataDocumentSupported {
doc.ClientIDMetadataDocumentSupported = true
}
} else {
slog.Warn("Issuer mismatch between OIDC and OAuth discovery documents, not merging registration_endpoint",
"oidc_issuer", doc.Issuer, "oauth_issuer", oauthDoc.Issuer)
Expand Down
14 changes: 14 additions & 0 deletions pkg/auth/remote/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ type Config struct {
// RegistrationAccessToken is used to update/delete the client registration.
// Stored as a secret reference since it's sensitive.
CachedRegTokenRef string `json:"cached_reg_token_ref,omitempty" yaml:"cached_reg_token_ref,omitempty"`

// CachedCIMDClientID stores the CIMD metadata URL used as client_id when CIMD
// authentication was used. Kept separate from CachedClientID (which holds
// DCR-issued IDs) so the two can have independent lifecycles — DCR credential
// rotation clears CachedClientID without touching the stable CIMD URL.
// Read by resolveClientCredentials to send the correct client_id on token refresh.
CachedCIMDClientID string `json:"cached_cimd_client_id,omitempty" yaml:"cached_cimd_client_id,omitempty"`
Comment thread
jhrozek marked this conversation as resolved.
}

// BearerTokenEnvVarName is the environment variable name used for bearer token authentication.
Expand Down Expand Up @@ -164,7 +171,14 @@ func (c *Config) HasCachedClientCredentials() bool {
return c.CachedClientID != ""
}

// HasCachedCIMDClientID returns true if a CIMD client_id was cached from a prior session.
func (c *Config) HasCachedCIMDClientID() bool {
return c.CachedCIMDClientID != ""
}

// ClearCachedClientCredentials removes any cached DCR client credential references from the config.
// It does not clear CachedCIMDClientID — the CIMD URL is a stable constant that does not
// need to be rotated alongside DCR secrets.
func (c *Config) ClearCachedClientCredentials() {
c.CachedClientID = ""
c.CachedClientSecretRef = ""
Expand Down
31 changes: 31 additions & 0 deletions pkg/auth/remote/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,37 @@ func TestConfig_HasCachedClientCredentials(t *testing.T) {
}
}

func TestConfig_HasCachedCIMDClientID(t *testing.T) {
t.Parallel()

tests := []struct {
name string
config Config
expected bool
}{
{
name: "no cached CIMD client_id",
config: Config{},
expected: false,
},
{
name: "has cached CIMD client_id",
config: Config{
CachedCIMDClientID: "https://toolhive.dev/oauth/client-metadata.json",
},
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := tt.config.HasCachedCIMDClientID()
assert.Equal(t, tt.expected, result)
})
}
}

func TestConfig_ClearCachedClientCredentials(t *testing.T) {
t.Parallel()

Expand Down
93 changes: 86 additions & 7 deletions pkg/auth/remote/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ package remote

import (
"context"
"errors"
"fmt"
"log/slog"
"strings"

"golang.org/x/oauth2"

"github.com/stacklok/toolhive/pkg/auth/discovery"
"github.com/stacklok/toolhive/pkg/oauthproto"
"github.com/stacklok/toolhive/pkg/secrets"
)

Expand Down Expand Up @@ -131,15 +134,29 @@ func (h *Handler) performOAuthFlow(
) (oauth2.TokenSource, error) {
slog.Debug("Starting OAuth authentication flow", "issuer", issuer)

// Create OAuth flow config
// Client registration priority (MCP spec: stored credentials → CIMD → DCR):
// Priority 1: Pre-configured credentials — set by buildOAuthFlowConfig from h.config.ClientID/ClientSecret.
// Priority 2: CIMD — AS advertises support and no credentials are set; use metadata URL as client_id.
// Priority 3: DCR — PerformOAuthFlow handles this when ClientID is still empty after the above.
flowConfig := h.buildOAuthFlowConfig(scopes, authServerInfo)
if shouldUseCIMD(authServerInfo, flowConfig) {
flowConfig.ClientID = oauthproto.ToolHiveClientMetadataDocumentURL
slog.Debug("Using CIMD client_id", "url", oauthproto.ToolHiveClientMetadataDocumentURL)
}

result, err := discovery.PerformOAuthFlow(ctx, issuer, flowConfig)
if err != nil {
// If we used CIMD and it was rejected, we need to retry with DCR.
if flowConfig.ClientID == oauthproto.ToolHiveClientMetadataDocumentURL && isCIMDRejectionError(err) {
slog.Warn("CIMD client_id rejected by AS, retrying with DCR", "issuer", issuer, "error", err)
flowConfig.ClientID = ""
result, err = discovery.PerformOAuthFlow(ctx, issuer, flowConfig)
}
}
if err != nil {
return nil, err
}

// Persist and wrap the token source
return h.wrapWithPersistence(result), nil
}

Expand Down Expand Up @@ -187,14 +204,23 @@ func (h *Handler) wrapWithPersistence(result *discovery.OAuthFlowResult) oauth2.

// Persist DCR client credentials if available (for servers that use Dynamic Client Registration)
// Only persist if client_id exists - client_secret may be empty for PKCE flows
if h.clientCredentialsPersister != nil && result.ClientID != "" {
// CIMD client IDs (HTTPS URLs) are stable constants and are stored separately below.
if h.clientCredentialsPersister != nil && result.ClientID != "" &&
!oauthproto.IsClientIDMetadataDocumentURL(result.ClientID) {
if err := h.clientCredentialsPersister(result.ClientID, result.ClientSecret); err != nil {
slog.Warn("Failed to persist DCR client credentials", "error", err)
} else {
slog.Debug("Successfully persisted DCR client credentials for future restarts")
}
}

// Persist the CIMD metadata URL separately so it can be used as client_id
// on token refresh without conflating it with DCR-issued credentials.
if oauthproto.IsClientIDMetadataDocumentURL(result.ClientID) {
h.config.CachedCIMDClientID = result.ClientID
slog.Debug("Persisted CIMD client_id for future restarts", "url", result.ClientID)
}

// Wrap the token source to persist refreshed tokens
tokenSource := result.TokenSource
if h.tokenPersister != nil {
Expand All @@ -211,6 +237,15 @@ func (h *Handler) resolveClientCredentials(ctx context.Context) (clientID, clien
clientID = h.config.ClientID
clientSecret = h.config.ClientSecret

// If CIMD was used in a prior session, use the cached metadata URL as client_id.
// CIMD clients have no secret (token_endpoint_auth_method=none).
// Checked before DCR so that DCR credential rotation does not change which
// client_id is sent on token refresh.
if h.config.HasCachedCIMDClientID() {
slog.Debug("Using cached CIMD client_id", "url", h.config.CachedCIMDClientID)
return h.config.CachedCIMDClientID, ""
}

// If we have cached DCR client credentials, use those instead
if h.config.HasCachedClientCredentials() {
// ClientID is stored as plain text (it's public information)
Expand Down Expand Up @@ -317,18 +352,23 @@ func (h *Handler) discoverIssuerAndScopes(
authInfo *discovery.AuthInfo,
remoteURL string,
) (string, []string, *discovery.AuthServerInfo, error) {
// Priority 1: Use configured issuer if available
// Priority 1: Use configured issuer if available. Fetch discovery to populate
// AuthServerInfo (including ClientIDMetadataDocumentSupported) even when the
// issuer is pre-configured, so CIMD detection works on this path.
if h.config.Issuer != "" {
slog.Debug("Using configured issuer", "issuer", h.config.Issuer)
return h.config.Issuer, h.config.Scopes, nil, nil
authServerInfo, _ := discovery.ValidateAndDiscoverAuthServer(ctx, h.config.Issuer)
return h.config.Issuer, h.config.Scopes, authServerInfo, nil
}

// Priority 2: Try to derive from realm (RFC 8414)
// Priority 2: Try to derive from realm (RFC 8414). Fetch discovery for the
// same reason as Priority 1 — the realm path skips resource metadata discovery.
if authInfo.Realm != "" {
derivedIssuer := discovery.DeriveIssuerFromRealm(authInfo.Realm)
if derivedIssuer != "" {
slog.Debug("Derived issuer from realm", "issuer", derivedIssuer)
return derivedIssuer, h.config.Scopes, nil, nil
authServerInfo, _ := discovery.ValidateAndDiscoverAuthServer(ctx, derivedIssuer)
return derivedIssuer, h.config.Scopes, authServerInfo, nil
}
}

Expand Down Expand Up @@ -457,3 +497,42 @@ func (h *Handler) tryDiscoverFromWellKnown(

return authServerInfo.Issuer, scopes, authServerInfo, nil
}

// shouldUseCIMD reports whether the CIMD client_id should be presented to the AS.
// The AS must advertise CIMD support and no pre-configured credentials may be set.
// Mirrors shouldDynamicallyRegisterClient in pkg/auth/discovery for consistency.
func shouldUseCIMD(authServerInfo *discovery.AuthServerInfo, flowConfig *discovery.OAuthFlowConfig) bool {
if authServerInfo == nil || !authServerInfo.ClientIDMetadataDocumentSupported {
return false
}
return flowConfig.ClientID == "" && flowConfig.ClientSecret == ""
}

// isCIMDRejectionError returns true if err indicates the AS rejected the CIMD
// client_id. Only the RFC 6749 error codes invalid_client and unauthorized_client
// trigger a DCR retry; all other errors — including invalid_request and
// token-exchange failures — surface as-is.
//
// CIMD rejection can surface from two stages:
// - Authorization endpoint: AS redirects to callback with error=invalid_client;
// flow.go formats this as "OAuth error: <code> - <description>" (a plain error).
// - Token endpoint: oauth2.RetrieveError with ErrorCode set.
func isCIMDRejectionError(err error) bool {
if err == nil {
return false
}
// Token endpoint rejection — structured error from golang.org/x/oauth2.
var rerr *oauth2.RetrieveError
if errors.As(err, &rerr) {
switch rerr.ErrorCode {
case "invalid_client", "unauthorized_client":
return true
}
return false
}
// Authorization endpoint rejection — flow.go formats callback errors as
// "OAuth error: <code> - <description>". Check for the code after the prefix.
msg := err.Error()
return strings.HasPrefix(msg, "OAuth error: invalid_client") ||
strings.HasPrefix(msg, "OAuth error: unauthorized_client")
}
Loading
Loading