// Copyright 2025 Dolthub, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "encoding/json" "fmt" "net" "net/http" "os" "path/filepath" "testing" "time" driver "github.com/dolthub/doltgresql/integration-tests/go-sql-server-driver/driver" "github.com/stretchr/testify/require" "gopkg.in/go-jose/go-jose.v2" ) type getConfigFunc func(serverPort, metricsPort int) string // runJWKSServer starts a local HTTP server that serves the JWKS file at /jwks.json. // The server is started in a goroutine and will be shut down via t.Cleanup. func runJWKSServer(t *testing.T, jwksFilePath string, port int) { data, err := os.ReadFile(jwksFilePath) require.NoError(t, err) mux := http.NewServeMux() mux.HandleFunc("/jwks.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(data) }) addr := fmt.Sprintf("127.0.0.1:%d", port) server := &http.Server{ Addr: addr, Handler: mux, } ln, err := net.Listen("tcp", addr) require.NoError(t, err) go func() { if err := server.Serve(ln); err != nil && err != http.ErrServerClosed { // Use t.Log instead of t.Fatal because Serve runs in goroutine t.Logf("runJWKSServer: server error: %v", err) } }() t.Logf("Started test JWKS server on %s serving %s", addr, jwksFilePath) t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { t.Logf("runJWKSServer: shutdown error: %v", err) } }) } func createJWT(t *testing.T, issuer, audience, subject string) string { const kid = "749df841-6e38-48f1-a178-20ecdd0b09f7" // load jwks from testdata data, err := os.ReadFile("testdata/test_jwks_private.json") require.NoError(t, err) var jwks jose.JSONWebKeySet err = json.Unmarshal(data, &jwks) require.NoError(t, err) require.NotEmpty(t, jwks.Keys) // choose key by kid or default to first var jwk *jose.JSONWebKey if kid == "" { jwk = &jwks.Keys[0] } else { for i := range jwks.Keys { if jwks.Keys[i].KeyID == kid { jwk = &jwks.Keys[i] break } } require.NotNil(t, jwk) } // ensure we have a private key require.False(t, jwk.IsPublic()) // create signer with kid header (if present) opts := (&jose.SignerOptions{}).WithType("JWT") if jwk.KeyID != "" { opts = opts.WithHeader("kid", jwk.KeyID) } signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: jwk.Key}, opts) require.NoError(t, err) // build claims now := time.Now().UTC() claims := map[string]interface{}{ "iss": issuer, "aud": audience, "sub": subject, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), } payload, err := json.Marshal(claims) require.NoError(t, err) jws, err := signer.Sign(payload) require.NoError(t, err) compact, err := jws.CompactSerialize() require.NoError(t, err) return compact } func makeMetricsCall(t *testing.T, metricsPort int, bearerToken string) *http.Response { url := fmt.Sprintf("http://127.0.0.1:%d/metrics", metricsPort) req, err := http.NewRequest("GET", url, nil) require.NoError(t, err) if bearerToken != "" { req.Header.Add("Authorization", "Bearer "+bearerToken) } client := &http.Client{} resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() return resp } var jwksPort int func TestMetricsAuth(t *testing.T) { // Doltgres serves a /metrics HTTP endpoint, but its metrics configuration // (DoltgesMetricsConfig) does not support the `jwks` / // `jwt_required_for_localhost` options this test relies on, and the metrics // server does not enforce JWT auth (MetricsJwksConfig() returns nil). The // full logic below is a faithful port and should be enabled once Doltgres // supports JWT-protected metrics. t.Skip("metrics JWKS / JWT-auth configuration (metrics.jwks, jwt_required_for_localhost) is unsupported in Doltgres") jwksPort = GlobalPorts.GetPort(t) absPath, err := filepath.Abs("./testdata/test_jwks.json") require.NoError(t, err) runJWKSServer(t, absPath, jwksPort) t.Parallel() t.Run("No Metrics Auth", testNoMetricsAuth) t.Run("Missing Metrics Auth", testMissingMetricsAuth) t.Run("Valid Metrics Auth", testValidMetricsAuth) t.Run("Bad Audience Claim", testBadAudienceClaim) t.Run("Bad Issuer Claim", testBadIssuerClaim) t.Run("Bad Subject Claim", testBadSubjectClaim) } func startServerWithMetrics(t *testing.T, getConfig getConfigFunc) int { ports := newPorts(t) serverPort := ports.GetOrAllocatePort("server") metricsPort := ports.GetOrAllocatePort("metrics") config := getConfig(serverPort, metricsPort) u, err := driver.NewDoltUser() require.NoError(t, err) t.Cleanup(func() { u.Cleanup() }) rs, err := u.MakeRepoStore() require.NoError(t, err) _, err = rs.MakeRepo("metrics_auth_test") require.NoError(t, err) f, err := os.CreateTemp("", "config-*.yaml") require.NoError(t, err) t.Cleanup(func() { os.Remove(f.Name()) }) _, err = f.WriteString(config) require.NoError(t, err) args := []string{"--config", f.Name()} srvSettings := &driver.Server{ Args: args, DynamicPort: "server", } t.Log("Starting server with config:\n" + config) MakeServer(t, rs, rs.Dir, srvSettings, ports) // Wait for the metrics HTTP listener to be ready. require.Eventually(t, func() bool { resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", metricsPort)) if err != nil { return false } resp.Body.Close() return true }, 5*time.Second, 50*time.Millisecond) return metricsPort } func testNoMetricsAuth(t *testing.T) { t.Parallel() getConfig := func(serverPort, metricsPort int) string { return fmt.Sprintf(` listener: host: localhost port: %d metrics: host: localhost port: %d jwt_required_for_localhost: true `, serverPort, metricsPort) } metricsPort := startServerWithMetrics(t, getConfig) resp := makeMetricsCall(t, metricsPort, "") require.Equal(t, http.StatusOK, resp.StatusCode) } func testMissingMetricsAuth(t *testing.T) { t.Parallel() getConfig := func(serverPort, metricsPort int) string { return fmt.Sprintf(`listener: host: localhost port: %d metrics: host: localhost port: %d jwks: name: jwksname location_url: http://127.0.0.1:%d/jwks.json claims: alg: RS256 iss: dolthub.com sub: test_sub aud: test_aud jwt_required_for_localhost: true `, serverPort, metricsPort, jwksPort) } metricsPort := startServerWithMetrics(t, getConfig) resp := makeMetricsCall(t, metricsPort, "") require.Equal(t, http.StatusUnauthorized, resp.StatusCode) } func testValidMetricsAuth(t *testing.T) { t.Parallel() getConfig := func(serverPort, metricsPort int) string { return fmt.Sprintf(`listener: host: localhost port: %d metrics: host: localhost port: %d jwks: name: jwksname location_url: http://127.0.0.1:%d/jwks.json claims: iss: dolthub.com sub: test_sub aud: test_aud jwt_required_for_localhost: true `, serverPort, metricsPort, jwksPort) } metricsPort := startServerWithMetrics(t, getConfig) jwt := createJWT(t, "dolthub.com", "test_aud", "test_sub") resp := makeMetricsCall(t, metricsPort, jwt) require.Equal(t, http.StatusOK, resp.StatusCode) } func testBadIssuerClaim(t *testing.T) { t.Parallel() getConfig := func(serverPort, metricsPort int) string { return fmt.Sprintf(`listener: host: localhost port: %d metrics: host: localhost port: %d jwks: name: jwksname location_url: http://127.0.0.1:%d/jwks.json claims: iss: dolthub.com sub: test_sub aud: test_aud jwt_required_for_localhost: true `, serverPort, metricsPort, jwksPort) } metricsPort := startServerWithMetrics(t, getConfig) jwt := createJWT(t, "badissuer.com", "test_aud", "test_sub") resp := makeMetricsCall(t, metricsPort, jwt) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) } func testBadAudienceClaim(t *testing.T) { t.Parallel() getConfig := func(serverPort, metricsPort int) string { return fmt.Sprintf(`listener: host: localhost port: %d metrics: host: localhost port: %d jwks: name: jwksname location_url: http://127.0.0.1:%d/jwks.json claims: iss: dolthub.com sub: test_sub aud: test_aud jwt_required_for_localhost: true `, serverPort, metricsPort, jwksPort) } metricsPort := startServerWithMetrics(t, getConfig) jwt := createJWT(t, "dolthub.com", "bad_aud", "test_sub") resp := makeMetricsCall(t, metricsPort, jwt) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) } func testBadSubjectClaim(t *testing.T) { t.Parallel() getConfig := func(serverPort, metricsPort int) string { return fmt.Sprintf(`listener: host: localhost port: %d metrics: host: localhost port: %d jwks: name: jwksname location_url: http://127.0.0.1:%d/jwks.json claims: iss: dolthub.com sub: test_sub aud: test_aud jwt_required_for_localhost: true `, serverPort, metricsPort, jwksPort) } metricsPort := startServerWithMetrics(t, getConfig) jwt := createJWT(t, "dolthub.com", "test_aud", "bad_sub") resp := makeMetricsCall(t, metricsPort, jwt) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) }