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
57 changes: 57 additions & 0 deletions docs/features/idp-token-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,63 @@ Secret, etc.) and inject it as `MCPPROXY_CRED_KEY` at runtime.
4. **Re-auth** — when no refresh token is available, or the refresh fails, the
user is required to sign in again (`ErrReauthRequired`).

## Per-user credentials REST API (server edition)

Brokered upstreams expose a per-user credential surface under the session/JWT
auth middleware. Every endpoint is scoped to the authenticated caller — a user
can only see and manage their own credentials, never another user's.

| Endpoint | Description |
|----------|-------------|
| `GET /api/v1/user/credentials` | List the connection status of every brokered upstream for the caller. |
| `DELETE /api/v1/user/credentials/{server}` | Disconnect (revoke) the caller's credential for an upstream. |
| `GET /api/v1/user/credentials/{server}/connect` | Initiate the per-user OAuth connect flow (Path B); 302-redirects to the upstream authorization server. |
| `GET /api/v1/user/credentials/{server}/callback` | OAuth connect callback; exchanges the code, stores the per-user credential, and redirects back to the Web UI. |

### Connection status (`GET /api/v1/user/credentials`)

The list returns **non-secret metadata only** — access and refresh tokens are
never serialized. Each entry carries a `status`:

- `connected` — a valid, non-expired per-user credential exists.
- `expired` — a credential exists but its access token has expired.
- `not_connected` — no per-user credential exists for this upstream.
- `unavailable` — the credential store is disabled (no encryption key configured).

For `oauth_connect` upstreams that are `not_connected` or `expired`, the entry
includes an actionable `connect_path` pointing at the connect endpoint.

```json
{
"credentials": [
{
"server": "github-shared",
"mode": "oauth_connect",
"status": "not_connected",
"connect_path": "/api/v1/user/credentials/github-shared/connect"
},
{
"server": "internal-api",
"mode": "token_exchange",
"status": "connected",
"token_type": "Bearer",
"scopes": ["read"],
"expires_at": "2026-06-15T20:00:00Z"
}
]
}
```

### Connect flow (Path B)

`connect` builds an authorization-code + PKCE URL bound to the authenticated
user and redirects there. After consent, the upstream redirects to `callback`,
which validates the one-time `state`, exchanges the code, and persists the
per-user credential (encrypted, `obtained_via=connect_flow`). The credential is
always stored under the **initiating** user, so the callback cannot be used to
write into another user's record. The browser then lands on `/ui/` with a
`credential_connected` / `credential_error` query flag.

## Operational notes

- **Key rotation** is not yet supported. Rotating the key requires clearing the
Expand Down
135 changes: 135 additions & 0 deletions internal/serveredition/api/connector_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//go:build server

package api

import (
"fmt"
"net/http"
"net/url"
"strings"
"sync"

"go.uber.org/zap"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/broker"
)

// connectorProvider builds and caches one broker.OAuthConnector per
// oauth_connect upstream (keyed by serverKey). The same connector instance must
// serve both the connect redirect and the callback because the connector holds
// the in-memory PKCE/state for each pending flow; rebuilding it per request
// would lose that state. It satisfies broker.ConnectorProvider so the T6
// CredentialResolver can reuse the same connectors when it needs to produce a
// connect URL for an unconnected user.
type connectorProvider struct {
store broker.CredentialStore
logger *zap.Logger

mu sync.Mutex
baseURL string // gateway public origin, e.g. "https://gw.example.com"
cache map[string]*broker.OAuthConnector
}

// newConnectorProvider constructs an empty provider.
func newConnectorProvider(store broker.CredentialStore, logger *zap.Logger) *connectorProvider {
if logger == nil {
logger = zap.NewNop()
}
return &connectorProvider{
store: store,
logger: logger,
cache: make(map[string]*broker.OAuthConnector),
}
}

// observeBaseURL records the gateway's public origin the first time it is seen
// (from an incoming request). The connect callback URL registered with the
// upstream authorization server is derived from it, and OAuth requires the
// redirect_uri to be byte-identical between the authorize request and the token
// exchange — so it is fixed once and reused for the lifetime of a connector.
func (p *connectorProvider) observeBaseURL(r *http.Request) {
base := baseURLFromRequest(r)
p.mu.Lock()
defer p.mu.Unlock()
if p.baseURL == "" {
p.baseURL = base
}
}

// connector returns the cached connector for an oauth_connect upstream, building
// it on first use. It errors for non-oauth_connect or unbrokered servers.
func (p *connectorProvider) connector(server *config.ServerConfig) (*broker.OAuthConnector, error) {
if server == nil || server.AuthBroker == nil {
return nil, fmt.Errorf("connector provider: server has no auth_broker configuration")
}
if server.AuthBroker.Mode != config.AuthBrokerModeOAuthConnect {
return nil, fmt.Errorf("connector provider: server %q is not an oauth_connect upstream", server.Name)
}

key := oauth.GenerateServerKey(server.Name, server.URL)

p.mu.Lock()
defer p.mu.Unlock()
if c, ok := p.cache[key]; ok {
return c, nil
}

ab := server.AuthBroker
cfg := broker.ConnectorConfig{
ServerName: server.Name,
ServerURL: server.URL,
AuthorizationEndpoint: ab.AuthorizationEndpoint,
TokenEndpoint: ab.TokenEndpoint,
ClientID: ab.ClientID,
ClientSecret: ab.ClientSecret,
Scopes: ab.Scopes,
RedirectURI: p.callbackURLLocked(server.Name),
Resource: ab.Resource,
}
conn, err := broker.NewOAuthConnector(p.store, cfg, p.logger)
if err != nil {
return nil, err
}
p.cache[key] = conn
return conn, nil
}

// ConnectorFor satisfies broker.ConnectorProvider for the credential resolver.
func (p *connectorProvider) ConnectorFor(server *config.ServerConfig) (broker.Connector, error) {
return p.connector(server)
}

// callbackURLLocked builds the gateway callback URL for a server. Caller holds p.mu.
func (p *connectorProvider) callbackURLLocked(serverName string) string {
base := strings.TrimSuffix(p.baseURL, "/")
return base + connectCallbackPath(serverName)
}

// connectCallbackPath is the relative callback route for a server's connect flow.
func connectCallbackPath(serverName string) string {
return "/api/v1/user/credentials/" + url.PathEscape(serverName) + "/callback"
}

// connectInitiatePath is the relative connect route for a server.
func connectInitiatePath(serverName string) string {
return "/api/v1/user/credentials/" + url.PathEscape(serverName) + "/connect"
}

// baseURLFromRequest derives the gateway's public origin (scheme://host),
// honoring X-Forwarded-Proto for reverse-proxy deployments. Mirrors the OAuth
// login handler's buildCallbackURL scheme detection.
func baseURLFromRequest(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
return scheme + "://" + r.Host
}

// Compile-time assertion that the provider satisfies the resolver's interface.
var _ broker.ConnectorProvider = (*connectorProvider)(nil)
Loading
Loading