Skip to content

Commit 11e4bed

Browse files
feat(serveredition): per-user brokered-credential REST surfaces (spec 074 T8) (#692)
* feat(serveredition): per-user brokered-credential REST surfaces (spec 074, MCP-1041) Add the T8 REST API for per-user upstream credentials (server edition), mounted behind the session/JWT auth middleware: - GET /api/v1/user/credentials list connection status + non-secret metadata (connected/expired/not_connected/unavailable); access/refresh tokens are NEVER serialized (FR-026). - DELETE /api/v1/user/credentials/{server} disconnect/revoke the caller's own credential. - GET /api/v1/user/credentials/{server}/connect initiate Path B; builds a PKCE authorize URL bound to the user and 302-redirects upstream. - GET /api/v1/user/credentials/{server}/callback validates state, exchanges the code, persists the per-user credential, redirects to the Web UI. Every operation is scoped to the authenticated caller (FR-027): list/delete key the store by the caller's userID, and the connect callback persists under the *initiating* user (the connector binds state->userID), so a callback cannot write into another user's record. A connectorProvider caches one OAuthConnector (T5) per oauth_connect upstream so the in-memory PKCE/state survives between the connect redirect and the callback, and satisfies broker.ConnectorProvider so the T6 CredentialResolver can mint connect URLs through the same connectors. TDD: secret redaction, status mapping, store-disabled, delete-removes, unknown-server 404, cross-user isolation, connect redirect, connect+callback end-to-end credential persistence, denied callback, unauthenticated 401. Docs: CLAUDE.md server API table + docs/features/idp-token-storage.md credentials API section. swagger.yaml is generated from personal-edition swag annotations (server-edition routes are build-tagged and not scanned), consistent with every other /api/v1/user/* and /api/v1/auth/* server-edition endpoint. Related spec 074, MCP-1041 (blocked-by T5/T6, now resolved). * fix(docs): keep CLAUDE.md under 40k char limit (check-size) The T8 server-API table addition pushed CLAUDE.md from 39,949 to 40,411 chars, tripping the claude-md-check (check-size) 40,000-char gate. The credential endpoints are already fully documented in docs/features/idp-token-storage.md (the canonical server-edition home), so drop the redundant CLAUDE.md table rows rather than trim unrelated content. Co-Authored-By: Paperclip <noreply@paperclip.ing> * refactor(docs): move credentials API docs out of #692 (PR-size split) Maintainer fix-before-merge: PR #692 tripped the PR-size review gate at ~913 added lines. Remove the docs/features/idp-token-storage.md credentials API section from this PR so it reduces to handlers + tests only (~852 lines). The docs land in a separate follow-up PR so they're not lost. Co-Authored-By: Paperclip <noreply@paperclip.ing> * docs(serveredition): credentials REST API reference (spec 074 T8 follow-up) Documents the per-user brokered-credential REST surfaces added in PR #692 (spec 074 T8): GET/DELETE /api/v1/user/credentials, the connection-status model (no secrets, FR-026), and the Path B connect/callback flow. Split out of #692 to keep that PR under the PR-size review gate; the code landed there, this is the documentation follow-up. Related spec 074, MCP-1041. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 3ab5b18 commit 11e4bed

5 files changed

Lines changed: 909 additions & 0 deletions

File tree

docs/features/idp-token-storage.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,63 @@ Secret, etc.) and inject it as `MCPPROXY_CRED_KEY` at runtime.
7878
4. **Re-auth** — when no refresh token is available, or the refresh fails, the
7979
user is required to sign in again (`ErrReauthRequired`).
8080

81+
## Per-user credentials REST API (server edition)
82+
83+
Brokered upstreams expose a per-user credential surface under the session/JWT
84+
auth middleware. Every endpoint is scoped to the authenticated caller — a user
85+
can only see and manage their own credentials, never another user's.
86+
87+
| Endpoint | Description |
88+
|----------|-------------|
89+
| `GET /api/v1/user/credentials` | List the connection status of every brokered upstream for the caller. |
90+
| `DELETE /api/v1/user/credentials/{server}` | Disconnect (revoke) the caller's credential for an upstream. |
91+
| `GET /api/v1/user/credentials/{server}/connect` | Initiate the per-user OAuth connect flow (Path B); 302-redirects to the upstream authorization server. |
92+
| `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. |
93+
94+
### Connection status (`GET /api/v1/user/credentials`)
95+
96+
The list returns **non-secret metadata only** — access and refresh tokens are
97+
never serialized. Each entry carries a `status`:
98+
99+
- `connected` — a valid, non-expired per-user credential exists.
100+
- `expired` — a credential exists but its access token has expired.
101+
- `not_connected` — no per-user credential exists for this upstream.
102+
- `unavailable` — the credential store is disabled (no encryption key configured).
103+
104+
For `oauth_connect` upstreams that are `not_connected` or `expired`, the entry
105+
includes an actionable `connect_path` pointing at the connect endpoint.
106+
107+
```json
108+
{
109+
"credentials": [
110+
{
111+
"server": "github-shared",
112+
"mode": "oauth_connect",
113+
"status": "not_connected",
114+
"connect_path": "/api/v1/user/credentials/github-shared/connect"
115+
},
116+
{
117+
"server": "internal-api",
118+
"mode": "token_exchange",
119+
"status": "connected",
120+
"token_type": "Bearer",
121+
"scopes": ["read"],
122+
"expires_at": "2026-06-15T20:00:00Z"
123+
}
124+
]
125+
}
126+
```
127+
128+
### Connect flow (Path B)
129+
130+
`connect` builds an authorization-code + PKCE URL bound to the authenticated
131+
user and redirects there. After consent, the upstream redirects to `callback`,
132+
which validates the one-time `state`, exchanges the code, and persists the
133+
per-user credential (encrypted, `obtained_via=connect_flow`). The credential is
134+
always stored under the **initiating** user, so the callback cannot be used to
135+
write into another user's record. The browser then lands on `/ui/` with a
136+
`credential_connected` / `credential_error` query flag.
137+
81138
## Operational notes
82139

83140
- **Key rotation** is not yet supported. Rotating the key requires clearing the
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//go:build server
2+
3+
package api
4+
5+
import (
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
"sync"
11+
12+
"go.uber.org/zap"
13+
14+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
15+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth"
16+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/broker"
17+
)
18+
19+
// connectorProvider builds and caches one broker.OAuthConnector per
20+
// oauth_connect upstream (keyed by serverKey). The same connector instance must
21+
// serve both the connect redirect and the callback because the connector holds
22+
// the in-memory PKCE/state for each pending flow; rebuilding it per request
23+
// would lose that state. It satisfies broker.ConnectorProvider so the T6
24+
// CredentialResolver can reuse the same connectors when it needs to produce a
25+
// connect URL for an unconnected user.
26+
type connectorProvider struct {
27+
store broker.CredentialStore
28+
logger *zap.Logger
29+
30+
mu sync.Mutex
31+
baseURL string // gateway public origin, e.g. "https://gw.example.com"
32+
cache map[string]*broker.OAuthConnector
33+
}
34+
35+
// newConnectorProvider constructs an empty provider.
36+
func newConnectorProvider(store broker.CredentialStore, logger *zap.Logger) *connectorProvider {
37+
if logger == nil {
38+
logger = zap.NewNop()
39+
}
40+
return &connectorProvider{
41+
store: store,
42+
logger: logger,
43+
cache: make(map[string]*broker.OAuthConnector),
44+
}
45+
}
46+
47+
// observeBaseURL records the gateway's public origin the first time it is seen
48+
// (from an incoming request). The connect callback URL registered with the
49+
// upstream authorization server is derived from it, and OAuth requires the
50+
// redirect_uri to be byte-identical between the authorize request and the token
51+
// exchange — so it is fixed once and reused for the lifetime of a connector.
52+
func (p *connectorProvider) observeBaseURL(r *http.Request) {
53+
base := baseURLFromRequest(r)
54+
p.mu.Lock()
55+
defer p.mu.Unlock()
56+
if p.baseURL == "" {
57+
p.baseURL = base
58+
}
59+
}
60+
61+
// connector returns the cached connector for an oauth_connect upstream, building
62+
// it on first use. It errors for non-oauth_connect or unbrokered servers.
63+
func (p *connectorProvider) connector(server *config.ServerConfig) (*broker.OAuthConnector, error) {
64+
if server == nil || server.AuthBroker == nil {
65+
return nil, fmt.Errorf("connector provider: server has no auth_broker configuration")
66+
}
67+
if server.AuthBroker.Mode != config.AuthBrokerModeOAuthConnect {
68+
return nil, fmt.Errorf("connector provider: server %q is not an oauth_connect upstream", server.Name)
69+
}
70+
71+
key := oauth.GenerateServerKey(server.Name, server.URL)
72+
73+
p.mu.Lock()
74+
defer p.mu.Unlock()
75+
if c, ok := p.cache[key]; ok {
76+
return c, nil
77+
}
78+
79+
ab := server.AuthBroker
80+
cfg := broker.ConnectorConfig{
81+
ServerName: server.Name,
82+
ServerURL: server.URL,
83+
AuthorizationEndpoint: ab.AuthorizationEndpoint,
84+
TokenEndpoint: ab.TokenEndpoint,
85+
ClientID: ab.ClientID,
86+
ClientSecret: ab.ClientSecret,
87+
Scopes: ab.Scopes,
88+
RedirectURI: p.callbackURLLocked(server.Name),
89+
Resource: ab.Resource,
90+
}
91+
conn, err := broker.NewOAuthConnector(p.store, cfg, p.logger)
92+
if err != nil {
93+
return nil, err
94+
}
95+
p.cache[key] = conn
96+
return conn, nil
97+
}
98+
99+
// ConnectorFor satisfies broker.ConnectorProvider for the credential resolver.
100+
func (p *connectorProvider) ConnectorFor(server *config.ServerConfig) (broker.Connector, error) {
101+
return p.connector(server)
102+
}
103+
104+
// callbackURLLocked builds the gateway callback URL for a server. Caller holds p.mu.
105+
func (p *connectorProvider) callbackURLLocked(serverName string) string {
106+
base := strings.TrimSuffix(p.baseURL, "/")
107+
return base + connectCallbackPath(serverName)
108+
}
109+
110+
// connectCallbackPath is the relative callback route for a server's connect flow.
111+
func connectCallbackPath(serverName string) string {
112+
return "/api/v1/user/credentials/" + url.PathEscape(serverName) + "/callback"
113+
}
114+
115+
// connectInitiatePath is the relative connect route for a server.
116+
func connectInitiatePath(serverName string) string {
117+
return "/api/v1/user/credentials/" + url.PathEscape(serverName) + "/connect"
118+
}
119+
120+
// baseURLFromRequest derives the gateway's public origin (scheme://host),
121+
// honoring X-Forwarded-Proto for reverse-proxy deployments. Mirrors the OAuth
122+
// login handler's buildCallbackURL scheme detection.
123+
func baseURLFromRequest(r *http.Request) string {
124+
scheme := "http"
125+
if r.TLS != nil {
126+
scheme = "https"
127+
}
128+
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
129+
scheme = proto
130+
}
131+
return scheme + "://" + r.Host
132+
}
133+
134+
// Compile-time assertion that the provider satisfies the resolver's interface.
135+
var _ broker.ConnectorProvider = (*connectorProvider)(nil)

0 commit comments

Comments
 (0)