From cb0e8aaf06646a1a0bff3ac091c70c1d3a9ea19a Mon Sep 17 00:00:00 2001 From: Denis Mishin Date: Wed, 23 Apr 2025 12:24:00 -0400 Subject: [PATCH] mcp: add oauth metadata endpoint (#5579) --- config/envoyconfig/http_connection_manager.go | 3 +- config/envoyconfig/route_configurations.go | 23 ++-- .../envoyconfig/route_configurations_test.go | 8 +- config/envoyconfig/routes.go | 7 + config/envoyconfig/routes_test.go | 108 ++++++++++++++- config/options.go | 39 ++++-- config/options_test.go | 22 +-- internal/controlplane/http.go | 6 + internal/mcp/handler.go | 11 ++ internal/mcp/handler_metadata.go | 129 ++++++++++++++++++ 10 files changed, 324 insertions(+), 32 deletions(-) create mode 100644 internal/mcp/handler.go create mode 100644 internal/mcp/handler_metadata.go diff --git a/config/envoyconfig/http_connection_manager.go b/config/envoyconfig/http_connection_manager.go index a9ed55b92..884d8392a 100644 --- a/config/envoyconfig/http_connection_manager.go +++ b/config/envoyconfig/http_connection_manager.go @@ -20,6 +20,7 @@ func (b *Builder) buildVirtualHost( options *config.Options, name string, host string, + hasMCPPolicy bool, ) (*envoy_config_route_v3.VirtualHost, error) { vh := &envoy_config_route_v3.VirtualHost{ Name: name, @@ -36,7 +37,7 @@ func (b *Builder) buildVirtualHost( } // these routes match /.pomerium/... and similar paths - rs, err := b.buildPomeriumHTTPRoutes(options, host) + rs, err := b.buildPomeriumHTTPRoutes(options, host, hasMCPPolicy) if err != nil { return nil, err } diff --git a/config/envoyconfig/route_configurations.go b/config/envoyconfig/route_configurations.go index dbf67a8fa..a88d9e588 100644 --- a/config/envoyconfig/route_configurations.go +++ b/config/envoyconfig/route_configurations.go @@ -50,14 +50,14 @@ func (b *Builder) buildMainRouteConfiguration( return nil, err } - allHosts, err := getAllRouteableHosts(cfg.Options, cfg.Options.Addr) + allHosts, mcpHosts, err := getAllRouteableHosts(cfg.Options, cfg.Options.Addr) if err != nil { return nil, err } var virtualHosts []*envoy_config_route_v3.VirtualHost for _, host := range allHosts { - vh, err := b.buildVirtualHost(cfg.Options, host, host) + vh, err := b.buildVirtualHost(cfg.Options, host, host, mcpHosts[host]) if err != nil { return nil, err } @@ -88,7 +88,7 @@ func (b *Builder) buildMainRouteConfiguration( } } - vh, err := b.buildVirtualHost(cfg.Options, "catch-all", "*") + vh, err := b.buildVirtualHost(cfg.Options, "catch-all", "*", false) if err != nil { return nil, err } @@ -106,21 +106,28 @@ func (b *Builder) buildMainRouteConfiguration( return rc, nil } -func getAllRouteableHosts(options *config.Options, addr string) ([]string, error) { +func getAllRouteableHosts(options *config.Options, addr string) ([]string, map[string]bool, error) { allHosts := set.NewTreeSet(cmp.Compare[string]) + mcpHosts := make(map[string]bool) if addr == options.Addr { - hosts, err := options.GetAllRouteableHTTPHosts() + hosts, hostsMCP, err := options.GetAllRouteableHTTPHosts() if err != nil { - return nil, err + return nil, nil, err } allHosts.InsertSlice(hosts) + // Merge any MCP hosts + for host, isMCP := range hostsMCP { + if isMCP { + mcpHosts[host] = true + } + } } if addr == options.GetGRPCAddr() { hosts, err := options.GetAllRouteableGRPCHosts() if err != nil { - return nil, err + return nil, nil, err } allHosts.InsertSlice(hosts) } @@ -131,7 +138,7 @@ func getAllRouteableHosts(options *config.Options, addr string) ([]string, error filtered = append(filtered, host) } } - return filtered, nil + return filtered, mcpHosts, nil } func newRouteConfiguration(name string, virtualHosts []*envoy_config_route_v3.VirtualHost) *envoy_config_route_v3.RouteConfiguration { diff --git a/config/envoyconfig/route_configurations_test.go b/config/envoyconfig/route_configurations_test.go index 7d55daacd..04afa06ca 100644 --- a/config/envoyconfig/route_configurations_test.go +++ b/config/envoyconfig/route_configurations_test.go @@ -195,7 +195,7 @@ func Test_getAllDomains(t *testing.T) { } t.Run("routable", func(t *testing.T) { t.Run("http", func(t *testing.T) { - actual, err := getAllRouteableHosts(options, "127.0.0.1:9000") + actual, _, err := getAllRouteableHosts(options, "127.0.0.1:9000") require.NoError(t, err) expect := []string{ "a.example.com", @@ -214,7 +214,7 @@ func Test_getAllDomains(t *testing.T) { assert.Equal(t, expect, actual) }) t.Run("grpc", func(t *testing.T) { - actual, err := getAllRouteableHosts(options, "127.0.0.1:9001") + actual, _, err := getAllRouteableHosts(options, "127.0.0.1:9001") require.NoError(t, err) expect := []string{ "authorize.example.com:9001", @@ -225,7 +225,7 @@ func Test_getAllDomains(t *testing.T) { t.Run("both", func(t *testing.T) { newOptions := *options newOptions.GRPCAddr = newOptions.Addr - actual, err := getAllRouteableHosts(&newOptions, "127.0.0.1:9000") + actual, _, err := getAllRouteableHosts(&newOptions, "127.0.0.1:9000") require.NoError(t, err) expect := []string{ "a.example.com", @@ -252,7 +252,7 @@ func Test_getAllDomains(t *testing.T) { options.Policies = []config.Policy{ {From: "https://a.example.com"}, } - actual, err := getAllRouteableHosts(options, ":443") + actual, _, err := getAllRouteableHosts(options, ":443") require.NoError(t, err) assert.Equal(t, []string{"a.example.com"}, actual) }) diff --git a/config/envoyconfig/routes.go b/config/envoyconfig/routes.go index 4bbb10263..f96783930 100644 --- a/config/envoyconfig/routes.go +++ b/config/envoyconfig/routes.go @@ -50,6 +50,7 @@ func (b *Builder) buildGRPCRoutes() ([]*envoy_config_route_v3.Route, error) { func (b *Builder) buildPomeriumHTTPRoutes( options *config.Options, host string, + isMCPHost bool, ) ([]*envoy_config_route_v3.Route, error) { var routes []*envoy_config_route_v3.Route @@ -60,6 +61,7 @@ func (b *Builder) buildPomeriumHTTPRoutes( return nil, err } if !isFrontingAuthenticate { + // Add common routes routes = append(routes, b.buildControlPlanePathRoute(options, "/ping"), b.buildControlPlanePathRoute(options, "/healthz"), @@ -68,6 +70,11 @@ func (b *Builder) buildPomeriumHTTPRoutes( b.buildControlPlanePathRoute(options, "/.well-known/pomerium"), b.buildControlPlanePrefixRoute(options, "/.well-known/pomerium/"), ) + + // Only add oauth-authorization-server route if there's an MCP policy for this host + if isMCPHost { + routes = append(routes, b.buildControlPlanePathRoute(options, "/.well-known/oauth-authorization-server")) + } } authRoutes, err := b.buildPomeriumAuthenticateHTTPRoutes(options, host) diff --git a/config/envoyconfig/routes_test.go b/config/envoyconfig/routes_test.go index c20c060f9..aedaf6638 100644 --- a/config/envoyconfig/routes_test.go +++ b/config/envoyconfig/routes_test.go @@ -104,7 +104,7 @@ func Test_buildPomeriumHTTPRoutes(t *testing.T) { AuthenticateURLString: "https://authenticate.example.com", AuthenticateCallbackPath: "/oauth2/callback", } - routes, err := b.buildPomeriumHTTPRoutes(options, "authenticate.example.com") + routes, err := b.buildPomeriumHTTPRoutes(options, "authenticate.example.com", false) require.NoError(t, err) testutil.AssertProtoJSONEqual(t, `[ @@ -125,7 +125,7 @@ func Test_buildPomeriumHTTPRoutes(t *testing.T) { AuthenticateURLString: "https://authenticate.example.com", AuthenticateCallbackPath: "/oauth2/callback", } - routes, err := b.buildPomeriumHTTPRoutes(options, "authenticate.example.com") + routes, err := b.buildPomeriumHTTPRoutes(options, "authenticate.example.com", false) require.NoError(t, err) testutil.AssertProtoJSONEqual(t, "null", routes) }) @@ -2244,3 +2244,107 @@ func mustParseURL(t *testing.T, str string) *url.URL { func ptr[T any](v T) *T { return &v } + +func Test_buildPomeriumHTTPRoutesWithMCP(t *testing.T) { + routeString := func(typ, name string) string { + str := `{ + "name": "pomerium-` + typ + `-` + name + `", + "decorator": { + "operation": "internal: ${method} ${host}${path}" + }, + "match": { + "` + typ + `": "` + name + `" + }, + "responseHeadersToAdd": [ + { + "appendAction": "OVERWRITE_IF_EXISTS_OR_ADD", + "header": { + "key": "X-Frame-Options", + "value": "SAMEORIGIN" + } + }, + { + "appendAction": "OVERWRITE_IF_EXISTS_OR_ADD", + "header": { + "key": "X-XSS-Protection", + "value": "1; mode=block" + } + } + ], + "route": { + "cluster": "pomerium-control-plane-http" + }, + "typedPerFilterConfig": { + "envoy.filters.http.ext_authz": { + "@type": "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute", + "checkSettings": { + "contextExtensions": { + "internal": "true", + "route_id": "0" + } + } + } + } + }` + return str + } + + t.Run("without MCP policy", func(t *testing.T) { + b := &Builder{filemgr: filemgr.NewManager()} + options := &config.Options{ + Services: "all", + AuthenticateURLString: "https://authenticate.example.com", + Policies: []config.Policy{ + { + From: "https://example.com", + To: mustParseWeightedURLs(t, "https://to.example.com"), + }, + }, + } + + routes, err := b.buildPomeriumHTTPRoutes(options, "example.com", false) + require.NoError(t, err) + + hasOAuthServer := false + for _, route := range routes { + if route.GetMatch().GetPath() == "/.well-known/oauth-authorization-server" { + hasOAuthServer = true + } + } + + assert.False(t, hasOAuthServer, "/.well-known/oauth-authorization-server route should NOT be present") + }) + + t.Run("with MCP policy", func(t *testing.T) { + b := &Builder{filemgr: filemgr.NewManager()} + options := &config.Options{ + Services: "all", + AuthenticateURLString: "https://authenticate.example.com", + Policies: []config.Policy{ + { + From: "https://example.com", + To: mustParseWeightedURLs(t, "https://to.example.com"), + }, + { + From: "https://mcp.example.com", + To: mustParseWeightedURLs(t, "https://mcp-backend.example.com"), + MCP: &config.MCP{}, // This marks the policy as an MCP policy + }, + }, + } + + routes, err := b.buildPomeriumHTTPRoutes(options, "example.com", true) + require.NoError(t, err) + + // Verify the expected route structures + testutil.AssertProtoJSONEqual(t, `[ + `+routeString("path", "/ping")+`, + `+routeString("path", "/healthz")+`, + `+routeString("path", "/.pomerium")+`, + `+routeString("prefix", "/.pomerium/")+`, + `+routeString("path", "/.well-known/pomerium")+`, + `+routeString("prefix", "/.well-known/pomerium/")+`, + `+routeString("path", "/.well-known/oauth-authorization-server")+` + ]`, routes) + }) +} diff --git a/config/options.go b/config/options.go index fe49d03b0..87015ed19 100644 --- a/config/options.go +++ b/config/options.go @@ -1273,23 +1273,27 @@ func (o *Options) GetAllRouteableGRPCHosts() ([]string, error) { } // GetAllRouteableHTTPHosts returns all the possible HTTP hosts handled by the Pomerium options. -func (o *Options) GetAllRouteableHTTPHosts() ([]string, error) { +func (o *Options) GetAllRouteableHTTPHosts() ([]string, map[string]bool, error) { hosts := goset.NewTreeSet(cmp.Compare[string]) + mcpHosts := make(map[string]bool) + if IsAuthenticate(o.Services) { if o.AuthenticateInternalURLString != "" { authenticateURL, err := o.GetInternalAuthenticateURL() if err != nil { - return nil, err + return nil, nil, err } - hosts.InsertSlice(urlutil.GetDomainsForURL(authenticateURL, !o.IsRuntimeFlagSet(RuntimeFlagMatchAnyIncomingPort))) + domains := urlutil.GetDomainsForURL(authenticateURL, !o.IsRuntimeFlagSet(RuntimeFlagMatchAnyIncomingPort)) + hosts.InsertSlice(domains) } if o.AuthenticateURLString != "" { authenticateURL, err := o.GetAuthenticateURL() if err != nil { - return nil, err + return nil, nil, err } - hosts.InsertSlice(urlutil.GetDomainsForURL(authenticateURL, !o.IsRuntimeFlagSet(RuntimeFlagMatchAnyIncomingPort))) + domains := urlutil.GetDomainsForURL(authenticateURL, !o.IsRuntimeFlagSet(RuntimeFlagMatchAnyIncomingPort)) + hosts.InsertSlice(domains) } } @@ -1298,18 +1302,35 @@ func (o *Options) GetAllRouteableHTTPHosts() ([]string, error) { for policy := range o.GetAllPolicies() { fromURL, err := urlutil.ParseAndValidateURL(policy.From) if err != nil { - return nil, err + return nil, nil, err + } + + domains := urlutil.GetDomainsForURL(fromURL, !o.IsRuntimeFlagSet(RuntimeFlagMatchAnyIncomingPort)) + hosts.InsertSlice(domains) + + // Track if the domains are associated with an MCP policy + if policy.IsMCP() { + for _, domain := range domains { + mcpHosts[domain] = true + } } - hosts.InsertSlice(urlutil.GetDomainsForURL(fromURL, !o.IsRuntimeFlagSet(RuntimeFlagMatchAnyIncomingPort))) if policy.TLSDownstreamServerName != "" { tlsURL := fromURL.ResolveReference(&url.URL{Host: policy.TLSDownstreamServerName}) - hosts.InsertSlice(urlutil.GetDomainsForURL(tlsURL, !o.IsRuntimeFlagSet(RuntimeFlagMatchAnyIncomingPort))) + tlsDomains := urlutil.GetDomainsForURL(tlsURL, !o.IsRuntimeFlagSet(RuntimeFlagMatchAnyIncomingPort)) + hosts.InsertSlice(tlsDomains) + + // Track if the TLS domains are associated with an MCP policy + if policy.IsMCP() { + for _, domain := range tlsDomains { + mcpHosts[domain] = true + } + } } } } - return hosts.Slice(), nil + return hosts.Slice(), mcpHosts, nil } // GetClientSecret gets the client secret. diff --git a/config/options_test.go b/config/options_test.go index a91f69711..aff398afb 100644 --- a/config/options_test.go +++ b/config/options_test.go @@ -888,22 +888,26 @@ func TestOptions_GetAllRouteableGRPCHosts(t *testing.T) { } func TestOptions_GetAllRouteableHTTPHosts(t *testing.T) { - p1 := Policy{From: "https://from1.example.com"} - p1.Validate() - p2 := Policy{From: "https://from2.example.com"} - p2.Validate() - p3 := Policy{From: "https://from3.example.com", TLSDownstreamServerName: "from.example.com"} - p3.Validate() + to := WeightedURLs{{URL: url.URL{Scheme: "https", Host: "to.example.com"}}} + p1 := Policy{From: "https://from1.example.com", To: to} + assert.NoError(t, p1.Validate()) + p2 := Policy{From: "https://from2.example.com", To: to} + assert.NoError(t, p2.Validate()) + p3 := Policy{From: "https://from3.example.com", TLSDownstreamServerName: "from.example.com", To: to} + assert.NoError(t, p3.Validate()) + p4 := Policy{From: "https://from4.example.com", MCP: &MCP{}, To: to} + assert.NoError(t, p4.Validate()) opts := &Options{ AuthenticateURLString: "https://authenticate.example.com", AuthorizeURLString: "https://authorize.example.com", DataBrokerURLString: "https://databroker.example.com", - Policies: []Policy{p1, p2, p3}, + Policies: []Policy{p1, p2, p3, p4}, Services: "all", } - hosts, err := opts.GetAllRouteableHTTPHosts() + hosts, mcpHosts, err := opts.GetAllRouteableHTTPHosts() assert.NoError(t, err) + assert.Empty(t, cmp.Diff(mcpHosts, map[string]bool{"from4.example.com:443": true, "from4.example.com": true})) assert.Equal(t, []string{ "authenticate.example.com", @@ -916,6 +920,8 @@ func TestOptions_GetAllRouteableHTTPHosts(t *testing.T) { "from2.example.com:443", "from3.example.com", "from3.example.com:443", + "from4.example.com", + "from4.example.com:443", }, hosts) } diff --git a/internal/controlplane/http.go b/internal/controlplane/http.go index e6091cf97..ab8c9e77d 100644 --- a/internal/controlplane/http.go +++ b/internal/controlplane/http.go @@ -15,6 +15,7 @@ import ( "github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/internal/handlers" "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/mcp" "github.com/pomerium/pomerium/internal/middleware" "github.com/pomerium/pomerium/internal/telemetry" "github.com/pomerium/pomerium/internal/urlutil" @@ -79,5 +80,10 @@ func (srv *Server) mountCommonEndpoints(root *mux.Router, cfg *config.Config) er root.Handle("/.well-known/pomerium/", traceHandler(handlers.WellKnownPomerium(authenticateURL))) root.Path("/.well-known/pomerium/jwks.json").Methods(http.MethodGet).Handler(traceHandler(handlers.JWKSHandler(signingKey))) root.Path(urlutil.HPKEPublicKeyPath).Methods(http.MethodGet).Handler(traceHandler(hpke_handlers.HPKEPublicKeyHandler(hpkePublicKey))) + + root.Path("/.well-known/oauth-authorization-server"). + Methods(http.MethodGet, http.MethodOptions). + Handler(mcp.AuthorizationServerMetadataHandler(mcp.DefaultPrefix)) + return nil } diff --git a/internal/mcp/handler.go b/internal/mcp/handler.go new file mode 100644 index 000000000..47e284d21 --- /dev/null +++ b/internal/mcp/handler.go @@ -0,0 +1,11 @@ +package mcp + +const ( + DefaultPrefix = "/.pomerium/mcp" + + authorizationEndpoint = "/authorize" + oauthCallbackEndpoint = "/oauth/callback" + registerEndpoint = "/register" + revocationEndpoint = "/revoke" + tokenEndpoint = "/token" +) diff --git a/internal/mcp/handler_metadata.go b/internal/mcp/handler_metadata.go new file mode 100644 index 000000000..338782d7a --- /dev/null +++ b/internal/mcp/handler_metadata.go @@ -0,0 +1,129 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "net/url" + "path" + + "github.com/gorilla/mux" + "github.com/rs/cors" +) + +// AuthorizationServerMetadata represents the OAuth 2.0 Authorization Server Metadata (RFC 8414). +// https://datatracker.ietf.org/doc/html/rfc8414#section-2 +type AuthorizationServerMetadata struct { + // Issuer is REQUIRED. The authorization server's issuer identifier, a URL using the "https" scheme with no query or fragment. + Issuer string `json:"issuer"` + + // AuthorizationEndpoint is the URL of the authorization server's authorization endpoint. REQUIRED unless no grant types use the authorization endpoint. + AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` + + // TokenEndpoint is the URL of the authorization server's token endpoint. REQUIRED unless only the implicit grant type is supported. + TokenEndpoint string `json:"token_endpoint,omitempty"` + + // JwksURI is OPTIONAL. URL of the authorization server's JWK Set document. + JwksURI string `json:"jwks_uri,omitempty"` + + // RegistrationEndpoint is OPTIONAL. URL of the authorization server's OAuth 2.0 Dynamic Client Registration endpoint. + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + + // ScopesSupported is RECOMMENDED. JSON array of supported OAuth 2.0 "scope" values. + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // ResponseTypesSupported is REQUIRED. JSON array of supported OAuth 2.0 "response_type" values. + ResponseTypesSupported []string `json:"response_types_supported"` + + // ResponseModesSupported is OPTIONAL. JSON array of supported OAuth 2.0 "response_mode" values. Default: ["query", "fragment"]. + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + + // GrantTypesSupported is OPTIONAL. JSON array of supported OAuth 2.0 grant type values. Default: ["authorization_code", "implicit"]. + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + // TokenEndpointAuthMethodsSupported is OPTIONAL. JSON array of client authentication methods supported by the token endpoint. Default: "client_secret_basic". + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + + // TokenEndpointAuthSigningAlgValuesSupported is OPTIONAL. JSON array of JWS signing algorithms supported by the token endpoint for JWT client authentication. + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + + // ServiceDocumentation is OPTIONAL. URL of a page with human-readable information for developers. + ServiceDocumentation string `json:"service_documentation,omitempty"` + + // UILocalesSupported is OPTIONAL. JSON array of supported languages and scripts for the UI, as BCP 47 language tags. + UILocalesSupported []string `json:"ui_locales_supported,omitempty"` + + // OpPolicyURI is OPTIONAL. URL for the authorization server's policy on client data usage. + OpPolicyURI string `json:"op_policy_uri,omitempty"` + + // OpTosURI is OPTIONAL. URL for the authorization server's terms of service. + OpTosURI string `json:"op_tos_uri,omitempty"` + + // RevocationEndpoint is OPTIONAL. URL of the authorization server's OAuth 2.0 revocation endpoint. + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + + // RevocationEndpointAuthMethodsSupported is OPTIONAL. JSON array of client authentication methods supported by the revocation endpoint. Default: "client_secret_basic". + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + + // RevocationEndpointAuthSigningAlgValuesSupported is OPTIONAL. JSON array of JWS signing algorithms supported by the revocation endpoint for JWT client authentication. + RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` + + // IntrospectionEndpoint is OPTIONAL. URL of the authorization server's OAuth 2.0 introspection endpoint. + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + + // IntrospectionEndpointAuthMethodsSupported is OPTIONAL. JSON array of client authentication methods supported by the introspection endpoint. + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + + // IntrospectionEndpointAuthSigningAlgValuesSupported is OPTIONAL. JSON array of JWS signing algorithms supported by the introspection endpoint for JWT client authentication. + IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` + + // CodeChallengeMethodsSupported is OPTIONAL. JSON array of PKCE code challenge methods supported by this authorization server. + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +func AuthorizationServerMetadataHandler(prefix string) http.HandlerFunc { + c := cors.New(cors.Options{ + AllowedMethods: []string{http.MethodGet, http.MethodOptions}, + AllowedOrigins: []string{"*"}, + AllowedHeaders: []string{"mcp-protocol-version"}, + }) + r := mux.NewRouter() + r.Use(c.Handler) + r.Methods(http.MethodGet).HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + meta := getAuthorizationServerMetadata(r.Host, prefix) + _ = json.NewEncoder(w).Encode(meta) + }) + r.Methods(http.MethodOptions).HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + return http.HandlerFunc(r.ServeHTTP) +} + +func getAuthorizationServerMetadata(host, prefix string) AuthorizationServerMetadata { + baseURL := url.URL{ + Scheme: "https", + Host: host, + } + P := func(path string) string { + u := baseURL + u.Path = path + return u.String() + } + + return AuthorizationServerMetadata{ + Issuer: P("/"), + ServiceDocumentation: "https://pomerium.com/docs", + AuthorizationEndpoint: P(path.Join(prefix, authorizationEndpoint)), + ResponseTypesSupported: []string{"code"}, + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpoint: P(path.Join(prefix, tokenEndpoint)), + TokenEndpointAuthMethodsSupported: []string{"none"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + RevocationEndpoint: P(path.Join(prefix, revocationEndpoint)), + RevocationEndpointAuthMethodsSupported: []string{"client_secret_post"}, + RegistrationEndpoint: P(path.Join(prefix, registerEndpoint)), + ScopesSupported: []string{"openid", "offline"}, + } +}