From c3d03bf50c56ec91a7844b988c7560a68eb3932f Mon Sep 17 00:00:00 2001 From: Yunus Emre Korkmaz Date: Thu, 16 Apr 2026 13:06:14 +0300 Subject: [PATCH 1/2] feat(config): support HTTP Basic Authentication credentials Add optional `solr.username` / `solr.password` configuration properties (also bound from the `SOLR_USERNAME` / `SOLR_PASSWORD` environment variables) that, when both are set, are applied to every SolrJ request via `HttpJdkSolrClient.Builder#withBasicAuthCredentials`. When either value is missing or the username is blank, the client is built without credentials so existing unauthenticated deployments are unaffected. Documents the new variables in the Development and Architecture guides and adds `SolrConfigAuthTest` covering: - no credentials attached when both values are missing, - no credentials attached when only one value is provided (or the username is blank), and - credentials applied when both values are provided. The existing `SolrConfigUrlNormalizationTest` is updated to the new record constructor. Co-authored-by: Claude Signed-off-by: Yunus Emre Korkmaz --- dev-docs/ARCHITECTURE.md | 2 + dev-docs/DEVELOPMENT.md | 2 + .../solr/mcp/server/config/SolrConfig.java | 15 ++- .../config/SolrConfigurationProperties.java | 20 +++- .../mcp/server/config/SolrConfigAuthTest.java | 96 +++++++++++++++++++ .../SolrConfigUrlNormalizationTest.java | 2 +- 6 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java diff --git a/dev-docs/ARCHITECTURE.md b/dev-docs/ARCHITECTURE.md index eae59f09..f379d77b 100644 --- a/dev-docs/ARCHITECTURE.md +++ b/dev-docs/ARCHITECTURE.md @@ -165,6 +165,8 @@ All dependencies are managed via Gradle version catalogs in `gradle/libs.version ### Solr Connection - `SOLR_URL`: Solr instance URL (default: `http://localhost:8983/solr/`) +- `SOLR_USERNAME`: HTTP Basic Authentication username (optional; required together with `SOLR_PASSWORD`) +- `SOLR_PASSWORD`: HTTP Basic Authentication password (optional; required together with `SOLR_USERNAME`) ### Transport Mode - `PROFILES`: Set to `stdio` or `http` to select transport mode diff --git a/dev-docs/DEVELOPMENT.md b/dev-docs/DEVELOPMENT.md index a9624c18..5fd6166a 100644 --- a/dev-docs/DEVELOPMENT.md +++ b/dev-docs/DEVELOPMENT.md @@ -76,6 +76,8 @@ The server will start on http://localhost:8080 ### Environment Variables - `SOLR_URL`: Solr instance URL (default: `http://localhost:8983/solr/`) +- `SOLR_USERNAME`: HTTP Basic Authentication username (optional; required together with `SOLR_PASSWORD`) +- `SOLR_PASSWORD`: HTTP Basic Authentication password (optional; required together with `SOLR_USERNAME`) - `PROFILES`: Transport mode (`stdio` or `http`) - `SPRING_DOCKER_COMPOSE_ENABLED`: Enable/disable Docker Compose integration (default: `true`) diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java index 7e7f9836..52cec514 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java @@ -195,8 +195,19 @@ SolrClient solrClient(SolrConfigurationProperties properties, JsonResponseParser // additional reflection metadata in GraalVM native images. // Force HTTP/1.1: the JDK HttpClient's HTTP/2 transport intermittently // closes reused connections with an EOFException against Solr/Jetty. - return new HttpJdkSolrClient.Builder(url).withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + HttpJdkSolrClient.Builder builder = new HttpJdkSolrClient.Builder(url) + .withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).useHttp1_1(true) - .withResponseParser(jsonResponseParser).withRequestWriter(new XMLRequestWriter()).build(); + .withResponseParser(jsonResponseParser).withRequestWriter(new XMLRequestWriter()); + + // Optional HTTP Basic Authentication: applied only when both credentials + // are provided so existing unauthenticated deployments are unaffected. + String username = properties.username(); + String password = properties.password(); + if (username != null && !username.isEmpty() && password != null) { + builder.withBasicAuthCredentials(username, password); + } + + return builder.build(); } } diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java index 37d458b7..a0e15357 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java @@ -16,6 +16,7 @@ */ package org.apache.solr.mcp.server.config; +import org.jspecify.annotations.Nullable; import org.springframework.boot.context.properties.ConfigurationProperties; /** @@ -107,12 +108,29 @@ * validation and normalization occurs in the {@link SolrConfig} class during * SolrClient bean creation. * + *

+ * Optional Basic Authentication: + * + *

+ * When the target Solr instance requires HTTP Basic Authentication, set both + * {@code solr.username} and {@code solr.password} (or the corresponding + * {@code SOLR_USERNAME} / {@code SOLR_PASSWORD} environment variables). + * Credentials are applied on every request by the configured SolrJ client. If + * either value is missing or {@code username} is blank, no authentication + * header is attached and the client behaves as before. + * * @param url * the base URL of the Apache Solr server (required, non-null) + * @param username + * the HTTP Basic Authentication username (optional; required + * together with {@code password} to enable auth) + * @param password + * the HTTP Basic Authentication password (optional; required + * together with {@code username} to enable auth) * @see SolrConfig * @see org.springframework.boot.context.properties.ConfigurationProperties * @see org.springframework.boot.context.properties.EnableConfigurationProperties */ @ConfigurationProperties(prefix = "solr") -public record SolrConfigurationProperties(String url) { +public record SolrConfigurationProperties(String url, @Nullable String username, @Nullable String password) { } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java new file mode 100644 index 00000000..8a49ded7 --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.solr.mcp.server.config; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.Field; +import java.util.Base64; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.impl.HttpJdkSolrClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; + +/** + * Unit tests for the optional HTTP Basic Authentication wiring performed by + * {@link SolrConfig}. + * + *

+ * Verifies that credentials are only attached to the SolrJ client when both + * {@code username} and {@code password} are provided, and that the resulting + * {@code Authorization} header string matches the expected Basic scheme + * encoding. + */ +@JsonTest +class SolrConfigAuthTest { + + @Autowired + private ObjectMapper objectMapper; + + @Test + void noCredentialsWhenBothMissing() throws Exception { + assertNull(authorizationHeaderFor(null, null)); + } + + @ParameterizedTest + @CsvSource({"alice,", ",secret", "'',secret"}) + void noCredentialsWhenEitherValueIsMissingOrBlank(String username, String password) throws Exception { + assertNull(authorizationHeaderFor(username, password)); + } + + @Test + void credentialsAppliedWhenBothProvided() throws Exception { + String header = authorizationHeaderFor("alice", "s3cret"); + assertNotNull(header); + String expected = Base64.getEncoder().encodeToString("alice:s3cret".getBytes()); + assertTrue(header.equals(expected) || header.equals("Basic " + expected), + "Expected Basic auth payload for alice:s3cret, got: " + header); + } + + private @org.jspecify.annotations.Nullable String authorizationHeaderFor( + @org.jspecify.annotations.Nullable String username, @org.jspecify.annotations.Nullable String password) + throws Exception { + SolrConfigurationProperties properties = new SolrConfigurationProperties("http://localhost:8983/solr/", + username, password); + SolrConfig config = new SolrConfig(); + try (SolrClient client = config.solrClient(properties, new JsonResponseParser(objectMapper))) { + HttpJdkSolrClient httpClient = assertInstanceOf(HttpJdkSolrClient.class, client); + Field field = findField(httpClient.getClass(), "basicAuthAuthorizationStr"); + field.setAccessible(true); + return (String) field.get(httpClient); + } + } + + private static Field findField(Class type, String name) throws NoSuchFieldException { + Class current = type; + while (current != null) { + try { + return current.getDeclaredField(name); + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } +} diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java index 2ca312f9..4bcd222b 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java @@ -39,7 +39,7 @@ class SolrConfigUrlNormalizationTest { "http://localhost:8983/solr/, http://localhost:8983/solr", "http://localhost:8983/custom/solr/, http://localhost:8983/custom/solr"}) void testUrlNormalization(String inputUrl, String expectedUrl) throws Exception { - SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl); + SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl, null, null); SolrConfig solrConfig = new SolrConfig(); try (SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper))) { From ce90fd34f84af6270a4a433a774d0fa271bb431c Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 14 Jun 2026 01:37:26 -0400 Subject: [PATCH 2/2] refactor(config): reuse Spring StringUtils/ReflectionUtils in basic-auth wiring Review cleanup on top of the HTTP Basic Auth support from #89 (by @dolphinium). No behavioural change to the feature. - SolrConfig: replace the inline `username != null && !username.isEmpty()` guard with Spring's `StringUtils.hasText`, matching the convention already used in HttpSecurityConfiguration and SearchService. - SolrConfigAuthTest: drop the hand-rolled superclass-walking `findField` helper in favour of `ReflectionUtils.findField`/`makeAccessible`/`getField`, and tighten the over-defensive `equals(x) || equals("Basic " + x)` assertion to an exact `assertEquals` now that the stored format is known (SolrJ stores `"Basic " + Base64(user:pass)` in UTF-8). ./gradlew build passes (Spotless + NullAway + full test suite incl. Testcontainers). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- .../solr/mcp/server/config/SolrConfig.java | 3 +- .../mcp/server/config/SolrConfigAuthTest.java | 30 +++++++------------ 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java index 52cec514..382da6cd 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java @@ -24,6 +24,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; /** * Spring Configuration class for Apache Solr client setup and connection @@ -204,7 +205,7 @@ SolrClient solrClient(SolrConfigurationProperties properties, JsonResponseParser // are provided so existing unauthenticated deployments are unaffected. String username = properties.username(); String password = properties.password(); - if (username != null && !username.isEmpty() && password != null) { + if (StringUtils.hasText(username) && password != null) { builder.withBasicAuthCredentials(username, password); } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java index 8a49ded7..8dd57408 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java @@ -16,13 +16,14 @@ */ package org.apache.solr.mcp.server.config; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; import java.util.Base64; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.HttpJdkSolrClient; @@ -31,6 +32,7 @@ import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.util.ReflectionUtils; /** * Unit tests for the optional HTTP Basic Authentication wiring performed by @@ -62,10 +64,9 @@ void noCredentialsWhenEitherValueIsMissingOrBlank(String username, String passwo @Test void credentialsAppliedWhenBothProvided() throws Exception { String header = authorizationHeaderFor("alice", "s3cret"); - assertNotNull(header); - String expected = Base64.getEncoder().encodeToString("alice:s3cret".getBytes()); - assertTrue(header.equals(expected) || header.equals("Basic " + expected), - "Expected Basic auth payload for alice:s3cret, got: " + header); + String expected = "Basic " + + Base64.getEncoder().encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8)); + assertEquals(expected, header); } private @org.jspecify.annotations.Nullable String authorizationHeaderFor( @@ -76,21 +77,10 @@ void credentialsAppliedWhenBothProvided() throws Exception { SolrConfig config = new SolrConfig(); try (SolrClient client = config.solrClient(properties, new JsonResponseParser(objectMapper))) { HttpJdkSolrClient httpClient = assertInstanceOf(HttpJdkSolrClient.class, client); - Field field = findField(httpClient.getClass(), "basicAuthAuthorizationStr"); - field.setAccessible(true); - return (String) field.get(httpClient); + Field field = ReflectionUtils.findField(httpClient.getClass(), "basicAuthAuthorizationStr"); + assertNotNull(field, "SolrJ field 'basicAuthAuthorizationStr' not found"); + ReflectionUtils.makeAccessible(field); + return (String) ReflectionUtils.getField(field, httpClient); } } - - private static Field findField(Class type, String name) throws NoSuchFieldException { - Class current = type; - while (current != null) { - try { - return current.getDeclaredField(name); - } catch (NoSuchFieldException ignored) { - current = current.getSuperclass(); - } - } - throw new NoSuchFieldException(name); - } }