Skip to content

Commit ba00028

Browse files
committed
merge: resolve conflicts with origin/main
Integrate GrantAudience, trailing-slash normalization, healthz endpoint, and state-generation changes from main while preserving header-mapping feature additions.
2 parents b4013df + 4aaca6f commit ba00028

9 files changed

Lines changed: 369 additions & 4 deletions

File tree

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "2.5.4"
2+
".": "2.7.0"
33
}

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
# Changelog
22

3+
## [2.7.0](https://github.com/sigbit/mcp-auth-proxy/compare/v2.6.1...v2.7.0) (2026-04-03)
4+
5+
6+
### Features
7+
8+
* add unauthenticated /healthz endpoint for health checks ([#131](https://github.com/sigbit/mcp-auth-proxy/issues/131)) ([9803d0f](https://github.com/sigbit/mcp-auth-proxy/commit/9803d0fb3bc3e7c1bb915a01ce99495e02a27c53))
9+
10+
11+
### Bug Fixes
12+
13+
* prevent panic on SSE reverse proxy when backend closes connection ([#128](https://github.com/sigbit/mcp-auth-proxy/issues/128)) ([76d1ac5](https://github.com/sigbit/mcp-auth-proxy/commit/76d1ac516899a763f75c8e015c3bfc08cb5a36b2))
14+
* set JWT audience claim to external URL for RFC 8707 compliance ([#133](https://github.com/sigbit/mcp-auth-proxy/issues/133)) ([351305a](https://github.com/sigbit/mcp-auth-proxy/commit/351305a8bb6a34f3ce8f4b5444c7834c469e364c)), closes [#129](https://github.com/sigbit/mcp-auth-proxy/issues/129)
15+
16+
## [2.6.1](https://github.com/sigbit/mcp-auth-proxy/compare/v2.6.0...v2.6.1) (2026-03-18)
17+
18+
19+
### Bug Fixes
20+
21+
* generate server-side OAuth state when client omits it ([#126](https://github.com/sigbit/mcp-auth-proxy/issues/126)) ([940e91e](https://github.com/sigbit/mcp-auth-proxy/commit/940e91e5979e3441cb590f2d706661bd7fdccf99))
22+
23+
## [2.6.0](https://github.com/sigbit/mcp-auth-proxy/compare/v2.5.4...v2.6.0) (2026-03-16)
24+
25+
26+
### Features
27+
28+
* Add OIDC Attribute-Based Authorization ([#120](https://github.com/sigbit/mcp-auth-proxy/issues/120)) ([51b6e85](https://github.com/sigbit/mcp-auth-proxy/commit/51b6e85ff100a621e28720822908781b2561452d))
29+
* support injecting cryptographic keys via env vars ([#119](https://github.com/sigbit/mcp-auth-proxy/issues/119)) ([ec9e857](https://github.com/sigbit/mcp-auth-proxy/commit/ec9e857c821e5b7cd1538f473427a366eefbc01f))
30+
31+
32+
### Bug Fixes
33+
34+
* fix prettier formatting in oauth-setup.md ([#124](https://github.com/sigbit/mcp-auth-proxy/issues/124)) ([ef5731d](https://github.com/sigbit/mcp-auth-proxy/commit/ef5731dc8be1c2294b39f3eaabeb9f92df094689))
35+
* normalize external URL with trailing slash per RFC 3986 ([#125](https://github.com/sigbit/mcp-auth-proxy/issues/125)) ([e377aa9](https://github.com/sigbit/mcp-auth-proxy/commit/e377aa9ed27b14a8f5d557512b1b9ad521e3fe35))
36+
337
## [2.5.4](https://github.com/sigbit/mcp-auth-proxy/compare/v2.5.3...v2.5.4) (2026-03-03)
438

539

docs/docs/oauth-setup.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,6 @@ For group-based authorization with Okta:
172172
```
173173

174174
2. Configure a groups claim in Okta Admin:
175-
176175
- Go to Security → API → Authorization Servers
177176
- Select your authorization server → Claims tab
178177
- Add a claim named "groups" with value type "Groups" and filter as needed

pkg/backend/transparent.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ func (p *TransparentBackend) Run(ctx context.Context) (http.Handler, error) {
125125
base: http.DefaultTransport,
126126
targetHost: p.url.Host,
127127
},
128+
FlushInterval: -1,
128129
Rewrite: func(pr *httputil.ProxyRequest) {
129130
pr.SetURL(p.url)
130131
if p.isTrusted(pr.In.RemoteAddr) {

pkg/idp/idp.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,22 @@ func (a *IDPRouter) SetupRoutes(router gin.IRouter) {
116116
func (a *IDPRouter) handleAuth(c *gin.Context) {
117117
ctx := c.Request.Context()
118118

119+
// RFC 6749 makes state RECOMMENDED, not REQUIRED, but fosite enforces
120+
// minimum entropy (8 chars). Generate a server-side state for clients
121+
// that omit it (e.g., MCP Inspector, Cursor CLI) so they can complete
122+
// the OAuth flow. The generated state is echoed back in the redirect;
123+
// clients that didn't send state will simply ignore it.
124+
if c.Request.URL.Query().Get("state") == "" {
125+
state, err := utils.GenerateState()
126+
if err != nil {
127+
a.provider.WriteAuthorizeError(ctx, c.Writer, nil, fosite.ErrServerError.WithWrap(err))
128+
return
129+
}
130+
q := c.Request.URL.Query()
131+
q.Set("state", state)
132+
c.Request.URL.RawQuery = q.Encode()
133+
}
134+
119135
ar, err := a.provider.NewAuthorizeRequest(ctx, c.Request)
120136
if err != nil {
121137
a.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
@@ -144,6 +160,7 @@ func (a *IDPRouter) handleAuthorizationReturn(c *gin.Context) {
144160
for _, scope := range ar.GetRequestedScopes() {
145161
ar.GrantScope(scope)
146162
}
163+
ar.GrantAudience(a.externalURL)
147164

148165
session := sessions.Default(c)
149166
subject := "user"
@@ -281,6 +298,7 @@ func (a *IDPRouter) handleRegister(c *gin.Context) {
281298
GrantTypes: req.GrantTypes,
282299
ResponseTypes: req.ResponseTypes,
283300
Scopes: strings.Fields(req.Scope),
301+
Audience: []string{a.externalURL},
284302
Public: isPublic,
285303
}
286304
if err := a.repo.RegisterClient(ctx, client); err != nil {

pkg/idp/idp_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"crypto/rand"
66
"crypto/rsa"
77
"crypto/sha256"
8+
"encoding/base64"
89
"encoding/json"
10+
"fmt"
911
"net/http"
1012
"net/http/cookiejar"
1113
"net/http/httptest"
@@ -271,3 +273,196 @@ func TestPrivateClient(t *testing.T) {
271273
require.NotEmpty(t, newAccessToken)
272274
require.NotEqual(t, originalAccessToken, newAccessToken, "Access token should be different after refresh")
273275
}
276+
277+
// registerTestClient is a helper that registers a private OAuth client and returns the registration response.
278+
func registerTestClient(t *testing.T, serverURL string) registrationResponse {
279+
t.Helper()
280+
281+
regReq := registrationRequest{
282+
ClientName: "Test OAuth Client",
283+
GrantTypes: []string{"authorization_code", "refresh_token"},
284+
ResponseTypes: []string{"code"},
285+
TokenEndpointAuthMethod: "client_secret_basic",
286+
Scope: "test",
287+
RedirectURIs: []string{"http://localhost:8080/callback"},
288+
}
289+
290+
reqBody, err := json.Marshal(regReq)
291+
require.NoError(t, err)
292+
293+
resp, err := http.Post(serverURL+RegistrationEndpoint, "application/json", bytes.NewReader(reqBody))
294+
require.NoError(t, err)
295+
defer resp.Body.Close()
296+
297+
require.Equal(t, http.StatusCreated, resp.StatusCode)
298+
299+
var regResp registrationResponse
300+
err = json.NewDecoder(resp.Body).Decode(&regResp)
301+
require.NoError(t, err)
302+
303+
return regResp
304+
}
305+
306+
// testAuthFlowWithURL performs the OAuth authorization flow given a raw authorization URL
307+
// and returns the callback URL after authorization completes.
308+
func testAuthFlowWithURL(t *testing.T, serverURL, authURL string) *url.URL {
309+
t.Helper()
310+
311+
jar, err := cookiejar.New(nil)
312+
require.NoError(t, err)
313+
client := &http.Client{
314+
Jar: jar,
315+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
316+
return http.ErrUseLastResponse
317+
},
318+
}
319+
320+
// Step 1: Make initial authorization request
321+
authResp, err := client.Get(authURL)
322+
require.NoError(t, err)
323+
defer authResp.Body.Close()
324+
325+
require.Contains(t, []int{http.StatusFound, http.StatusSeeOther}, authResp.StatusCode,
326+
"expected redirect, got %d", authResp.StatusCode)
327+
location := authResp.Header.Get("Location")
328+
require.NotEmpty(t, location)
329+
require.Contains(t, location, strings.ReplaceAll(AuthorizationReturnEndpoint, ":ar_id", ""))
330+
331+
// Step 2: Follow the redirect to complete authorization
332+
authReturnResp, err := client.Get(serverURL + location)
333+
require.NoError(t, err)
334+
defer authReturnResp.Body.Close()
335+
336+
require.Contains(t, []int{http.StatusFound, http.StatusSeeOther}, authReturnResp.StatusCode,
337+
"expected redirect with authorization code, got %d", authReturnResp.StatusCode)
338+
callbackLocation := authReturnResp.Header.Get("Location")
339+
require.NotEmpty(t, callbackLocation)
340+
341+
callbackURL, err := url.Parse(callbackLocation)
342+
require.NoError(t, err)
343+
require.NotEmpty(t, callbackURL.Query().Get("code"), "callback URL should contain an authorization code")
344+
345+
return callbackURL
346+
}
347+
348+
func TestAuthWithoutState(t *testing.T) {
349+
server, _, _ := setupTestServer(t)
350+
regResp := registerTestClient(t, server.URL)
351+
352+
// Build authorization URL manually WITHOUT a state parameter
353+
authURL := fmt.Sprintf("%s%s?response_type=code&client_id=%s&redirect_uri=%s",
354+
server.URL, AuthorizationEndpoint, regResp.ClientID,
355+
url.QueryEscape("http://localhost:8080/callback"))
356+
357+
callbackURL := testAuthFlowWithURL(t, server.URL, authURL)
358+
359+
// Server should have generated a state and echoed it back
360+
require.NotEmpty(t, callbackURL.Query().Get("state"), "server should generate a state when client omits it")
361+
362+
// Exchange authorization code for tokens
363+
code := callbackURL.Query().Get("code")
364+
tokenReq := url.Values{}
365+
tokenReq.Set("grant_type", "authorization_code")
366+
tokenReq.Set("code", code)
367+
tokenReq.Set("redirect_uri", "http://localhost:8080/callback")
368+
tokenReq.Set("client_id", regResp.ClientID)
369+
tokenReq.Set("client_secret", regResp.ClientSecret)
370+
371+
tokenResp, err := http.PostForm(server.URL+TokenEndpoint, tokenReq)
372+
require.NoError(t, err)
373+
defer tokenResp.Body.Close()
374+
375+
require.Equal(t, http.StatusOK, tokenResp.StatusCode)
376+
377+
var tokenResult map[string]any
378+
err = json.NewDecoder(tokenResp.Body).Decode(&tokenResult)
379+
require.NoError(t, err)
380+
require.NotEmpty(t, tokenResult["access_token"])
381+
}
382+
383+
func TestAuthWithEmptyState(t *testing.T) {
384+
server, _, _ := setupTestServer(t)
385+
regResp := registerTestClient(t, server.URL)
386+
387+
// Build authorization URL with an empty state parameter
388+
authURL := fmt.Sprintf("%s%s?response_type=code&client_id=%s&redirect_uri=%s&state=",
389+
server.URL, AuthorizationEndpoint, regResp.ClientID,
390+
url.QueryEscape("http://localhost:8080/callback"))
391+
392+
callbackURL := testAuthFlowWithURL(t, server.URL, authURL)
393+
394+
// Server should have generated a state and echoed it back
395+
require.NotEmpty(t, callbackURL.Query().Get("state"), "server should generate a state when client sends empty state")
396+
397+
// Exchange authorization code for tokens
398+
code := callbackURL.Query().Get("code")
399+
tokenReq := url.Values{}
400+
tokenReq.Set("grant_type", "authorization_code")
401+
tokenReq.Set("code", code)
402+
tokenReq.Set("redirect_uri", "http://localhost:8080/callback")
403+
tokenReq.Set("client_id", regResp.ClientID)
404+
tokenReq.Set("client_secret", regResp.ClientSecret)
405+
406+
tokenResp, err := http.PostForm(server.URL+TokenEndpoint, tokenReq)
407+
require.NoError(t, err)
408+
defer tokenResp.Body.Close()
409+
410+
require.Equal(t, http.StatusOK, tokenResp.StatusCode)
411+
412+
var tokenResult map[string]any
413+
err = json.NewDecoder(tokenResp.Body).Decode(&tokenResult)
414+
require.NoError(t, err)
415+
require.NotEmpty(t, tokenResult["access_token"])
416+
}
417+
418+
func TestAccessTokenAudienceClaim(t *testing.T) {
419+
server, _, _ := setupTestServer(t)
420+
regResp := registerTestClient(t, server.URL)
421+
422+
config := &oauth2.Config{
423+
ClientID: regResp.ClientID,
424+
ClientSecret: regResp.ClientSecret,
425+
RedirectURL: "http://localhost:8080/callback",
426+
Scopes: []string{},
427+
Endpoint: oauth2.Endpoint{
428+
AuthURL: server.URL + AuthorizationEndpoint,
429+
TokenURL: server.URL + TokenEndpoint,
430+
},
431+
}
432+
433+
callbackURL := testAuthFlowWithURL(t, server.URL, config.AuthCodeURL("test-state"))
434+
code := callbackURL.Query().Get("code")
435+
436+
tokenReq := url.Values{}
437+
tokenReq.Set("grant_type", "authorization_code")
438+
tokenReq.Set("code", code)
439+
tokenReq.Set("redirect_uri", "http://localhost:8080/callback")
440+
tokenReq.Set("client_id", regResp.ClientID)
441+
tokenReq.Set("client_secret", regResp.ClientSecret)
442+
443+
tokenResp, err := http.PostForm(server.URL+TokenEndpoint, tokenReq)
444+
require.NoError(t, err)
445+
defer tokenResp.Body.Close()
446+
require.Equal(t, http.StatusOK, tokenResp.StatusCode)
447+
448+
var tokenResult map[string]any
449+
err = json.NewDecoder(tokenResp.Body).Decode(&tokenResult)
450+
require.NoError(t, err)
451+
452+
accessToken := tokenResult["access_token"].(string)
453+
454+
// Decode JWT payload and verify aud claim contains the external URL
455+
parts := strings.Split(accessToken, ".")
456+
require.Len(t, parts, 3, "access token should be a JWT with 3 parts")
457+
458+
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
459+
require.NoError(t, err)
460+
461+
var claims map[string]any
462+
err = json.Unmarshal(payload, &claims)
463+
require.NoError(t, err)
464+
465+
aud, ok := claims["aud"].([]any)
466+
require.True(t, ok, "aud claim should be present as an array")
467+
require.Contains(t, aud, "http://localhost:8080", "aud should contain the external URL")
468+
}

pkg/mcp-proxy/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,11 @@ func Run(
8585
if err != nil {
8686
return fmt.Errorf("failed to parse external URL: %w", err)
8787
}
88-
if parsedExternalURL.Path != "" {
88+
if parsedExternalURL.Path != "" && parsedExternalURL.Path != "/" {
8989
return fmt.Errorf("external URL must not have a path, got: %s", parsedExternalURL.Path)
9090
}
91+
parsedExternalURL.Path = "/"
92+
externalURL = parsedExternalURL.String()
9193

9294
if (tlsCertFile == "") != (tlsKeyFile == "") {
9395
return fmt.Errorf("both TLS certificate and key files must be provided together")
@@ -299,8 +301,18 @@ func Run(
299301
router := gin.New()
300302
router.SetTrustedProxies(trustedProxy)
301303

304+
router.GET("/healthz", func(c *gin.Context) {
305+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
306+
})
307+
302308
router.Use(ginzap.Ginzap(logger, time.RFC3339, true))
303-
router.Use(ginzap.RecoveryWithZap(logger, true))
309+
router.Use(ginzap.CustomRecoveryWithZap(logger, true, func(c *gin.Context, err any) {
310+
if err == http.ErrAbortHandler {
311+
c.Abort()
312+
return
313+
}
314+
c.AbortWithStatus(http.StatusInternalServerError)
315+
}))
304316
store := cookie.NewStore(secret)
305317
store.Options(sessions.Options{
306318
Path: "/",

0 commit comments

Comments
 (0)