Skip to content

Commit e435e91

Browse files
fix: mcp auth session reconciliation fixes
1 parent 311e88c commit e435e91

11 files changed

Lines changed: 634 additions & 88 deletions

File tree

core/mcp/credstore/per_user_headers.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,14 @@ func (r *perUserHeadersResolver) buildAuthRequiredError(ctx *schemas.BifrostCont
108108
}
109109

110110
// missingRequiredHeaderKeys returns the names of any required header key
111-
// that's absent or whose stored value is empty in storedHeaders. Comparison
112-
// is case-insensitive at the wire level but the schema is the source of
113-
// truth — we look up by the exact key the admin declared.
111+
// that's absent or whose stored value is empty in storedHeaders.
112+
//
113+
// Both inputs are assumed to be in canonical form (lowercase + trimmed) —
114+
// see the invariant doc on mcputils.CanonicalizeHeaderKey. All write
115+
// boundaries (HTTP create/update, flow submit, config.json load) run
116+
// the inputs through that helper, so exact map lookup here is correct.
117+
// Do NOT add defensive case-folding inside this function: it would mask
118+
// a missed write-side canonicalization rather than catching it.
114119
func missingRequiredHeaderKeys(required []string, storedHeaders map[string]string) []string {
115120
if len(storedHeaders) == 0 {
116121
return append([]string(nil), required...)
@@ -128,6 +133,12 @@ func missingRequiredHeaderKeys(required []string, storedHeaders map[string]strin
128133
// user-submitted credential values for the required keys. Keys not declared
129134
// by the current schema are dropped on purpose so a stale row that still
130135
// stores a deprecated key cannot leak it onto the wire.
136+
//
137+
// Required keys and storedHeaders keys are both canonical (lowercase +
138+
// trimmed) by the write-side invariant — see missingRequiredHeaderKeys
139+
// above and mcputils.CanonicalizeHeaderKey. http.Header.Set runs its own
140+
// MIME canonicalization on the way out (so "authorization" becomes
141+
// "Authorization" on the wire), which is what upstream servers expect.
131142
func buildPerUserHeaderValues(required []string, storedHeaders map[string]string) http.Header {
132143
out := http.Header{}
133144
for _, key := range required {

core/mcp/utils/utils.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ func FlattenHeaders(h http.Header) map[string]string {
2525
// BuildMCPCallbackBaseURL extracts the base URL set on the BifrostContext by
2626
// the HTTP middleware (e.g. "https://host"). Per-user OAuth and per-user
2727
// headers resolvers append their respective paths on top.
28+
//
29+
// Trailing slashes are stripped defensively. The sole writer today
30+
// (lib/ctx.go BuildBaseURL) already normalizes, but OAuth providers match
31+
// redirect URIs exactly — a `https://host//api/oauth/callback` produced by
32+
// a future writer that forgets to trim would silently break every per-user
33+
// OAuth flow. Guarding once on the read side keeps that invariant local
34+
// to this function rather than spread across every potential writer.
2835
func BuildMCPCallbackBaseURL(ctx *schemas.BifrostContext) string {
2936
if base, ok := ctx.Value(schemas.BifrostContextKeyMCPCallbackBaseURL).(string); ok && base != "" {
30-
return base
37+
return strings.TrimRight(base, "/")
3138
}
3239
return ""
3340
}
@@ -91,6 +98,69 @@ func matchesPerUserHeaderKey(name string, perUserKeys []string) bool {
9198
return false
9299
}
93100

101+
// Canonical-form invariant for per-user-headers data
102+
// =====================================================
103+
// HTTP header names are case-insensitive on the wire (RFC 7230 §3.2),
104+
// so anywhere the per-user-headers feature compares a schema key against
105+
// a stored or submitted header name we'd need EqualFold lookups. Doing
106+
// that defensively at every read site is fragile — a single missed call
107+
// site re-introduces the bug (stored `authorization` looking missing
108+
// against schema `Authorization`, etc.).
109+
//
110+
// Instead we enforce a write-time invariant: every external boundary
111+
// that accepts a header key (or a credential header map) lowercases and
112+
// trims via the helpers below before persisting. Downstream code can
113+
// then assume canonical form and use plain map lookups.
114+
//
115+
// Write boundaries that MUST call these:
116+
// - createMCPClient / updateMCPClient / resolvePerUserHeaderKeys
117+
// (handlers/mcp.go) for MCPClientConfig.PerUserHeaderKeys
118+
// - flowSubmit (handlers/mcp_per_user_headers.go) for the
119+
// user-submitted credential.Headers map
120+
// - loadMCPClientConfigFromFile (lib/config.go) for the config.json
121+
// load path
122+
//
123+
// New write paths added in the future must canonicalize too — there is
124+
// no defensive case-folding on the read side anymore.
125+
126+
// CanonicalizeHeaderKey returns the canonical lowercase + trimmed form
127+
// of a single header key. Empty input returns empty.
128+
func CanonicalizeHeaderKey(key string) string {
129+
return strings.ToLower(strings.TrimSpace(key))
130+
}
131+
132+
// CanonicalizeHeaderKeys returns a new slice with every entry passed
133+
// through CanonicalizeHeaderKey. Nil in → nil out so a caller that
134+
// uses "nil means preserve existing" semantics (e.g.
135+
// resolvePerUserHeaderKeys, UpdateMCPClientConfig) keeps that signal.
136+
// The input slice is not mutated.
137+
func CanonicalizeHeaderKeys(keys []string) []string {
138+
if keys == nil {
139+
return nil
140+
}
141+
out := make([]string, len(keys))
142+
for i, k := range keys {
143+
out[i] = CanonicalizeHeaderKey(k)
144+
}
145+
return out
146+
}
147+
148+
// CanonicalizeHeaderMap returns a new map whose keys are passed through
149+
// CanonicalizeHeaderKey. On collision (e.g. "Authorization" and
150+
// "authorization" both present in the input), the last value wins —
151+
// callers that need duplicate detection should run it on the raw input
152+
// before calling this. Nil in → nil out.
153+
func CanonicalizeHeaderMap(m map[string]string) map[string]string {
154+
if m == nil {
155+
return nil
156+
}
157+
out := make(map[string]string, len(m))
158+
for k, v := range m {
159+
out[CanonicalizeHeaderKey(k)] = v
160+
}
161+
return out
162+
}
163+
94164
// ExtractFilteredExtras returns just the per-request "extra" headers carried
95165
// in the BifrostContext (BifrostContextKeyMCPExtraHeaders), scoped by the
96166
// client's AllowedExtraHeaders. Static config headers are NOT included here —

0 commit comments

Comments
 (0)