Skip to content
This repository was archived by the owner on Apr 29, 2026. It is now read-only.

Commit 11602de

Browse files
salmon-21claude
andcommitted
fix: rewrite auth server URLs for remote deployments
When the authorization server (e.g. Dex) runs on an internal network with its issuer set to the public gateway URL, three issues arise: 1. The authorization redirect sends users to the internal server URL 2. Discovery metadata exposes internal URLs to external clients 3. JWKS fetching creates a circular dependency (gateway fetches from itself) This commit fixes all three: - Rewrite the authorization redirect to use the public host URL - Add rewriteMetadataURLs() to rewrite all discovery metadata URLs from internal to public - Build the JWKS URI from the internal server address to break the circular dependency - Reverse-proxy auth server endpoints (derived from metadata) so external clients can reach token, keys, userinfo, etc. through the public gateway URL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 776bbc3 commit 11602de

4 files changed

Lines changed: 225 additions & 13 deletions

File tree

oauth/authorization.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@ func NewAuthorizationHandler(config *config.Config, meta map[string]any) (http.H
2222

2323
if authorizationEndpointStr, ok := meta["authorization_endpoint"].(string); !ok {
2424
return nil, errors.New("authorization metadata is missing authorization_endpoint field")
25-
} else if _, err := url.Parse(authorizationEndpointStr); err != nil {
25+
} else if authEndpointURL, err := url.Parse(authorizationEndpointStr); err != nil {
2626
return nil, fmt.Errorf("could not parse authorization endpoint: %w", err)
2727
} else {
28+
// Rewrite the authorization endpoint to use the public host URL
29+
publicURL, _ := url.Parse(config.Host.String())
30+
publicURL.Path = authEndpointURL.Path
31+
2832
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29-
redirectURI, _ := url.Parse(authorizationEndpointStr)
33+
redirectURI := *publicURL
3034
q := r.URL.Query()
3135
scopes := q.Get("scope")
3236
for _, scope := range requiredScopes {

oauth/authorization_server_metadata.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"net/http/httputil"
88
"net/url"
9+
"strings"
910

1011
"github.com/hyprmcp/mcp-gateway/config"
1112
"github.com/hyprmcp/mcp-gateway/log"
@@ -48,6 +49,9 @@ func NewAuthorizationServerMetadataHandler(config *config.Config) http.Handler {
4849
"url", metadata["authorization_endpoint"])
4950
}
5051

52+
// Rewrite all URL fields pointing to internal auth server to public host
53+
rewriteMetadataURLs(metadata, config.Authorization.Server, config.Host.String())
54+
5155
w.Header().Set("Content-Type", "application/json")
5256
if err := json.NewEncoder(w).Encode(metadata); err != nil {
5357
log.Get(r.Context()).Error(err, "failed to encode authorization server metadata response")
@@ -93,6 +97,18 @@ func GetMedatata(server string) (map[string]any, error) {
9397
return nil, err
9498
}
9599

100+
// rewriteMetadataURLs rewrites all string values in metadata that start with
101+
// the internal auth server URL to use the public host URL instead.
102+
func rewriteMetadataURLs(metadata map[string]any, internalServer string, publicHost string) {
103+
internal := strings.TrimRight(internalServer, "/")
104+
public := strings.TrimRight(publicHost, "/")
105+
for key, value := range metadata {
106+
if s, ok := value.(string); ok && strings.HasPrefix(s, internal) {
107+
metadata[key] = strings.Replace(s, internal, public, 1)
108+
}
109+
}
110+
}
111+
96112
func getMetadataURIs(server string) ([]string, error) {
97113
var uris []string
98114

oauth/oauth.go

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"net/http"
8+
"net/http/httputil"
89
"net/url"
910
"strings"
1011
"time"
@@ -32,29 +33,48 @@ func NewManager(ctx context.Context, config *config.Config) (*Manager, error) {
3233
timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
3334
defer cancel()
3435

35-
if cache, err := jwk.NewCache(ctx, httprc.NewClient(
36+
cache, err := jwk.NewCache(ctx, httprc.NewClient(
3637
httprc.WithTraceSink(tracesink.Func(func(ctx context.Context, s string) { log.V(1).Info(s) })),
3738
httprc.WithErrorSink(errsink.NewFunc(func(ctx context.Context, err error) { log.V(1).Error(err, "httprc.NewClient error") })),
38-
)); err != nil {
39+
))
40+
if err != nil {
3941
return nil, fmt.Errorf("jwk cache creation error: %w", err)
40-
} else if meta, err := GetMedatata(config.Authorization.Server); err != nil {
42+
}
43+
44+
meta, err := GetMedatata(config.Authorization.Server)
45+
if err != nil {
4146
return nil, fmt.Errorf("authorization server metadata error: %w", err)
42-
} else if jwksURI, ok := meta["jwks_uri"].(string); !ok {
47+
}
48+
49+
jwksURIRaw, ok := meta["jwks_uri"].(string)
50+
if !ok {
4351
return nil, errors.New("no jwks_uri")
44-
} else if err := cache.Register(
45-
timeoutCtx,
46-
jwksURI,
52+
}
53+
54+
// Build internal JWKS URI using the auth server address to avoid a
55+
// circular dependency when the auth server's issuer is set to the
56+
// public gateway URL (the metadata would advertise the gateway's own
57+
// URL as jwks_uri, causing the gateway to fetch from itself).
58+
internalJWKSURI := strings.TrimRight(config.Authorization.Server, "/") + "/keys"
59+
if u, err := url.Parse(jwksURIRaw); err == nil {
60+
internalJWKSURI = strings.TrimRight(config.Authorization.Server, "/") + u.Path
61+
}
62+
63+
if err := cache.Register(timeoutCtx, internalJWKSURI,
4764
jwk.WithMinInterval(10*time.Second),
4865
jwk.WithMaxInterval(5*time.Minute),
4966
); err != nil {
5067
return nil, fmt.Errorf("jwks registration error: %w", err)
51-
} else if _, err := cache.Refresh(timeoutCtx, jwksURI); err != nil {
68+
}
69+
if _, err := cache.Refresh(timeoutCtx, internalJWKSURI); err != nil {
5270
return nil, fmt.Errorf("jwks refresh error: %w", err)
53-
} else if s, err := cache.CachedSet(jwksURI); err != nil {
71+
}
72+
s, err := cache.CachedSet(internalJWKSURI)
73+
if err != nil {
5474
return nil, fmt.Errorf("jwks cache set error: %w", err)
55-
} else {
56-
return &Manager{jwkSet: s, config: config, authServerMeta: meta}, nil
5775
}
76+
77+
return &Manager{jwkSet: s, config: config, authServerMeta: meta}, nil
5878
}
5979

6080
func (mgr *Manager) Register(mux *http.ServeMux) error {
@@ -79,6 +99,24 @@ func (mgr *Manager) Register(mux *http.ServeMux) error {
7999
} else {
80100
mux.Handle(AuthorizationPath, handler)
81101
}
102+
103+
// Reverse-proxy auth server endpoints so that external clients can
104+
// reach them through the public gateway URL. Paths are derived from
105+
// the authorization server metadata (token, jwks, userinfo, etc.).
106+
authURL, err := url.Parse(mgr.config.Authorization.Server)
107+
if err != nil {
108+
return fmt.Errorf("failed to parse auth server URL: %w", err)
109+
}
110+
authProxy := &httputil.ReverseProxy{
111+
Rewrite: func(r *httputil.ProxyRequest) {
112+
r.Out.URL.Scheme = authURL.Scheme
113+
r.Out.URL.Host = authURL.Host
114+
r.Out.Host = ""
115+
},
116+
}
117+
for _, path := range authServerProxyPaths(mgr.authServerMeta) {
118+
mux.Handle(path, authProxy)
119+
}
82120
}
83121

84122
return nil
@@ -110,3 +148,56 @@ func (mgr *Manager) getMetadataURL(u *url.URL) *url.URL {
110148
metadataURL = metadataURL.JoinPath(u.Path)
111149
return metadataURL
112150
}
151+
152+
// authServerProxyPaths returns the HTTP paths that should be reverse-proxied
153+
// to the authorization server. Standard endpoint paths are extracted from the
154+
// authorization server metadata. The authorization endpoint is also registered
155+
// as a prefix pattern (trailing slash) so that connector sub-paths (e.g.
156+
// /auth/github) are proxied as well.
157+
func authServerProxyPaths(meta map[string]any) []string {
158+
endpointKeys := []string{
159+
"token_endpoint",
160+
"jwks_uri",
161+
"userinfo_endpoint",
162+
"introspection_endpoint",
163+
"revocation_endpoint",
164+
"device_authorization_endpoint",
165+
"end_session_endpoint",
166+
}
167+
168+
seen := make(map[string]bool)
169+
var paths []string
170+
add := func(p string) {
171+
if p != "" && p != "/" && !seen[p] {
172+
seen[p] = true
173+
paths = append(paths, p)
174+
}
175+
}
176+
177+
for _, key := range endpointKeys {
178+
if s, ok := meta[key].(string); ok {
179+
if u, err := url.Parse(s); err == nil {
180+
add(u.Path)
181+
}
182+
}
183+
}
184+
185+
// Register the authorization endpoint both as an exact match and as a
186+
// prefix pattern so that connector-specific sub-paths (e.g.
187+
// /auth/github) are also proxied.
188+
if s, ok := meta["authorization_endpoint"].(string); ok {
189+
if u, err := url.Parse(s); err == nil {
190+
add(u.Path)
191+
add(strings.TrimRight(u.Path, "/") + "/")
192+
}
193+
}
194+
195+
// Common auth server paths that are not advertised in metadata but are
196+
// required for the OAuth flow when the auth server uses external
197+
// identity provider connectors (e.g. the IdP redirects back to
198+
// /callback after user authentication).
199+
add("/callback")
200+
add("/approval")
201+
202+
return paths
203+
}

oauth/oauth_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package oauth
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestAuthServerProxyPaths(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
meta map[string]any
11+
wantLen int
12+
contains []string
13+
excludes []string
14+
}{
15+
{
16+
name: "extracts standard endpoint paths from metadata",
17+
meta: map[string]any{
18+
"issuer": "https://example.com",
19+
"authorization_endpoint": "https://example.com/auth",
20+
"token_endpoint": "https://example.com/token",
21+
"jwks_uri": "https://example.com/keys",
22+
"userinfo_endpoint": "https://example.com/userinfo",
23+
"introspection_endpoint": "https://example.com/token/introspect",
24+
"device_authorization_endpoint": "https://example.com/device/code",
25+
},
26+
contains: []string{
27+
"/token",
28+
"/keys",
29+
"/userinfo",
30+
"/token/introspect",
31+
"/device/code",
32+
"/auth",
33+
"/auth/",
34+
"/callback",
35+
"/approval",
36+
},
37+
excludes: []string{"/"},
38+
},
39+
{
40+
name: "does not duplicate paths",
41+
meta: map[string]any{
42+
"token_endpoint": "https://example.com/token",
43+
"jwks_uri": "https://example.com/token", // same path
44+
"userinfo_endpoint": "https://example.com/userinfo",
45+
},
46+
contains: []string{"/token", "/userinfo", "/callback", "/approval"},
47+
},
48+
{
49+
name: "handles empty metadata",
50+
meta: map[string]any{},
51+
contains: []string{"/callback", "/approval"},
52+
wantLen: 2,
53+
},
54+
{
55+
name: "skips non-string and unparseable values",
56+
meta: map[string]any{
57+
"token_endpoint": 42,
58+
"jwks_uri": "https://example.com/keys",
59+
"userinfo_endpoint": "://invalid",
60+
},
61+
contains: []string{"/keys", "/callback", "/approval"},
62+
},
63+
{
64+
name: "registers authorization endpoint prefix for sub-paths",
65+
meta: map[string]any{
66+
"authorization_endpoint": "https://example.com/dex/auth",
67+
},
68+
contains: []string{"/dex/auth", "/dex/auth/"},
69+
},
70+
}
71+
72+
for _, tt := range tests {
73+
t.Run(tt.name, func(t *testing.T) {
74+
paths := authServerProxyPaths(tt.meta)
75+
76+
if tt.wantLen > 0 && len(paths) != tt.wantLen {
77+
t.Errorf("got %d paths, want %d: %v", len(paths), tt.wantLen, paths)
78+
}
79+
80+
pathSet := make(map[string]bool)
81+
for _, p := range paths {
82+
if pathSet[p] {
83+
t.Errorf("duplicate path: %s", p)
84+
}
85+
pathSet[p] = true
86+
}
87+
88+
for _, want := range tt.contains {
89+
if !pathSet[want] {
90+
t.Errorf("missing expected path %q in %v", want, paths)
91+
}
92+
}
93+
94+
for _, exclude := range tt.excludes {
95+
if pathSet[exclude] {
96+
t.Errorf("unexpected path %q in %v", exclude, paths)
97+
}
98+
}
99+
})
100+
}
101+
}

0 commit comments

Comments
 (0)