From ae7cb34ab161b491006a73e2c059f2238068719f Mon Sep 17 00:00:00 2001 From: WMK666666 <3496319356@qq.com> Date: Wed, 11 Nov 2026 13:17:30 +0800 Subject: [PATCH] feat(client): send a bearer token in the Authorization header docling-serve has no built-in authentication: it is normally deployed behind a proxy, and the oauth-proxy deployment in its docs authenticates on an Authorization: Bearer header. The client only knew an API key, which it always sends as X-Api-Key, so a deployment behind that proxy could not be used at all. Add a bearerToken option next to apiKey. It is sent as "Authorization: Bearer " on every request, independently of the API key, and it is masked together with the API key when request logging is enabled. The setter goes on the concrete builders only, not on the deprecated DoclingApiBuilder interface, which is documented as not gaining new configuration options and which third-party implementations must keep compiling. As for every option added since that deprecation, LegacyProviderAdapter reports it as unsupported, so a caller going through a legacy factory gets an error naming the factory instead of the token being silently dropped. Signed-off-by: WMK666666 <3496319356@qq.com> --- .../serve/api/DoclingServeApiBuilder.java | 11 +++ .../serve/api/DoclingServeApiConfig.java | 23 ++++- .../serve/api/DoclingServeApiConfigTests.java | 3 + .../serve/api/LegacyProviderAdapterTests.java | 2 +- .../serve/client/DoclingServeClient.java | 23 ++++- .../client/operations/HttpOperations.java | 6 ++ ...AbstractDoclingServeClientConfigTests.java | 6 +- .../DoclingServeClientBearerTokenTests.java | 88 +++++++++++++++++++ .../doc/docs/docling-serve/serve-client.md | 23 +++++ 9 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBearerTokenTests.java diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiBuilder.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiBuilder.java index 4dfe2c2f..11d1c898 100644 --- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiBuilder.java +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiBuilder.java @@ -80,6 +80,17 @@ public DoclingServeApiBuilder apiKey(@Nullable String apiKey) { return set(DoclingServeApiConfig.API_KEY, apiKey); } + /** + * Sets the bearer token used to authenticate requests. + * + * @param bearerToken the bearer token, or {@code null} to unset it + * @return this builder + * @see DoclingServeApiConfig#BEARER_TOKEN + */ + public DoclingServeApiBuilder bearerToken(@Nullable String bearerToken) { + return set(DoclingServeApiConfig.BEARER_TOKEN, bearerToken); + } + /** * Enables logging of requests. * diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiConfig.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiConfig.java index f376148d..4b0c904d 100644 --- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiConfig.java +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiConfig.java @@ -44,6 +44,17 @@ public final class DoclingServeApiConfig { */ public static final ConfigOption API_KEY = ConfigOption.of("apiKey", String.class); + /** + * The bearer token used to authenticate requests, sent as {@code Authorization: Bearer }. + * Not set by default. + * + *

This is what an OAuth proxy in front of docling-serve authenticates on. docling-serve itself + * has no built-in authentication, see the {@code oauth-proxy} deployment in the docling-serve docs. + * + *

Providers must honor this option when it is set. + */ + public static final ConfigOption BEARER_TOKEN = ConfigOption.of("bearerToken", String.class); + /** * Whether requests are logged. Defaults to {@code false}. * @@ -110,7 +121,7 @@ public final class DoclingServeApiConfig { * adapter, and the tests guarding that every option has an accessor and a setter. */ static final List> ALL_OPTIONS = List.of( - BASE_URL, API_KEY, LOG_REQUESTS, LOG_RESPONSES, PRETTY_PRINT, CONNECT_TIMEOUT, READ_TIMEOUT, ASYNC_POLL_INTERVAL, ASYNC_TIMEOUT, ASYNC_EXECUTOR); + BASE_URL, API_KEY, BEARER_TOKEN, LOG_REQUESTS, LOG_RESPONSES, PRETTY_PRINT, CONNECT_TIMEOUT, READ_TIMEOUT, ASYNC_POLL_INTERVAL, ASYNC_TIMEOUT, ASYNC_EXECUTOR); private static final URI DEFAULT_BASE_URL = URI.create("http://localhost:5001"); private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(5); @@ -158,6 +169,16 @@ public URI baseUrl() { return get(API_KEY); } + /** + * The bearer token used to authenticate requests. + * + * @return the bearer token, or {@code null} if not set + * @see #BEARER_TOKEN + */ + public @Nullable String bearerToken() { + return get(BEARER_TOKEN); + } + /** * Whether requests are logged. * diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiConfigTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiConfigTests.java index 84b31672..9aed087e 100644 --- a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiConfigTests.java +++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiConfigTests.java @@ -34,6 +34,7 @@ void defaultsWhenNothingIsSet() { assertThat(config) .returns(URI.create("http://localhost:5001"), DoclingServeApiConfig::baseUrl) .returns(null, DoclingServeApiConfig::apiKey) + .returns(null, DoclingServeApiConfig::bearerToken) .returns(false, DoclingServeApiConfig::logRequests) .returns(false, DoclingServeApiConfig::logResponses) .returns(false, DoclingServeApiConfig::prettyPrint) @@ -51,6 +52,7 @@ void setOptionsAreReturnedAndRecordedAsExplicitlySet() { assertThat(config) .returns(URI.create("http://example.com:8080"), DoclingServeApiConfig::baseUrl) .returns("key", DoclingServeApiConfig::apiKey) + .returns("token", DoclingServeApiConfig::bearerToken) .returns(true, DoclingServeApiConfig::logRequests) .returns(true, DoclingServeApiConfig::logResponses) .returns(true, DoclingServeApiConfig::prettyPrint) @@ -179,6 +181,7 @@ private static DoclingServeApiBuilder fullyConfigured() { return DoclingServeApi.builder() .baseUrl("http://example.com:8080") .apiKey("key") + .bearerToken("token") .logRequests() .logResponses() .prettyPrint() diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/LegacyProviderAdapterTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/LegacyProviderAdapterTests.java index 67683a15..ca6dad42 100644 --- a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/LegacyProviderAdapterTests.java +++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/LegacyProviderAdapterTests.java @@ -56,7 +56,7 @@ void everyLegacyOptionIsReplayed() { @Test void optionsAddedAfterTheDeprecationAreUnsupported() { assertThat(new LegacyProviderAdapter(new RecordingLegacyFactory()).unsupportedOptions()) - .containsExactly(Map.entry(DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.FAIL)); + .containsExactly(Map.entry(DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.FAIL), Map.entry(DoclingServeApiConfig.BEARER_TOKEN, Unsupported.FAIL)); } @Test diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java index c99b921c..7e54d58c 100644 --- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java +++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java @@ -215,7 +215,7 @@ protected void logRequest(HttpRequest request) { } private boolean isSensitiveHeader(String headerName) { - return API_KEY_HEADER_NAME.equalsIgnoreCase(headerName); + return API_KEY_HEADER_NAME.equalsIgnoreCase(headerName) || AUTHORIZATION_HEADER_NAME.equalsIgnoreCase(headerName); } private Map.Entry> maskSensitiveHeaderValues(Map.Entry> entry) { @@ -330,6 +330,12 @@ protected HttpRequest.Builder createRequestBuilder(RequestContext r requestBuilder.header(API_KEY_HEADER_NAME, apiKey); } + var bearerToken = this.config.bearerToken(); + + if (Utils.isNotNullOrBlank(bearerToken)) { + requestBuilder.header(AUTHORIZATION_HEADER_NAME, "Bearer " + bearerToken); + } + return requestBuilder; } @@ -604,6 +610,21 @@ public B apiKey(@Nullable String apiKey) { return (B) this; } + /** + * Sets the bearer token used to authenticate requests. + * + *

Deliberately not part of {@link DoclingApiBuilder}: that interface is deprecated and will not gain + * new configuration options, so a third-party implementation of it keeps compiling. + * + * @param bearerToken the bearer token, or {@code null} to unset it + * @return this builder instance for method chaining + * @see DoclingServeApiConfig#BEARER_TOKEN + */ + public B bearerToken(@Nullable String bearerToken) { + this.settings.bearerToken(bearerToken); + return (B) this; + } + /** * Sets whether requests are logged. * diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/HttpOperations.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/HttpOperations.java index bc9c215a..1e55ff66 100644 --- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/HttpOperations.java +++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/HttpOperations.java @@ -28,6 +28,12 @@ public abstract class HttpOperations { */ public static final String API_KEY_HEADER_NAME = "X-Api-Key"; + /** + * The header name used to specify a bearer token in HTTP requests. + * The value is sent as {@code Bearer }, which is what an OAuth proxy in front of docling-serve expects. + */ + public static final String AUTHORIZATION_HEADER_NAME = "Authorization"; + /** * Executes an HTTP GET request using the details specified in the provided {@code RequestContext}. * diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientConfigTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientConfigTests.java index 5bea1154..5268fb8b 100644 --- a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientConfigTests.java +++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientConfigTests.java @@ -5,6 +5,7 @@ import static ai.docling.serve.api.DoclingServeApiConfig.ASYNC_POLL_INTERVAL; import static ai.docling.serve.api.DoclingServeApiConfig.ASYNC_TIMEOUT; import static ai.docling.serve.api.DoclingServeApiConfig.BASE_URL; +import static ai.docling.serve.api.DoclingServeApiConfig.BEARER_TOKEN; import static ai.docling.serve.api.DoclingServeApiConfig.CONNECT_TIMEOUT; import static ai.docling.serve.api.DoclingServeApiConfig.LOG_REQUESTS; import static ai.docling.serve.api.DoclingServeApiConfig.LOG_RESPONSES; @@ -55,6 +56,7 @@ void configReportsEverySetting() { assertThat(config) .returns(URI.create(getWireMock().baseUrl()), DoclingServeApiConfig::baseUrl) .returns("key", DoclingServeApiConfig::apiKey) + .returns("token", DoclingServeApiConfig::bearerToken) .returns(true, DoclingServeApiConfig::logRequests) .returns(true, DoclingServeApiConfig::logResponses) .returns(true, DoclingServeApiConfig::prettyPrint) @@ -65,7 +67,7 @@ void configReportsEverySetting() { .returns(EXECUTOR, DoclingServeApiConfig::asyncExecutor); assertThat(config.explicitlySetOptions()) - .containsExactly(BASE_URL, API_KEY, LOG_REQUESTS, LOG_RESPONSES, PRETTY_PRINT, CONNECT_TIMEOUT, READ_TIMEOUT, ASYNC_POLL_INTERVAL, ASYNC_TIMEOUT, ASYNC_EXECUTOR); + .containsExactly(BASE_URL, API_KEY, BEARER_TOKEN, LOG_REQUESTS, LOG_RESPONSES, PRETTY_PRINT, CONNECT_TIMEOUT, READ_TIMEOUT, ASYNC_POLL_INTERVAL, ASYNC_TIMEOUT, ASYNC_EXECUTOR); } @Test @@ -77,6 +79,7 @@ void configOfAnUnconfiguredClientReportsTheDefaults() { assertThat(config) .returns(URI.create("http://localhost:5001"), DoclingServeApiConfig::baseUrl) .returns(null, DoclingServeApiConfig::apiKey) + .returns(null, DoclingServeApiConfig::bearerToken) .returns(false, DoclingServeApiConfig::logRequests) .returns(false, DoclingServeApiConfig::logResponses) .returns(false, DoclingServeApiConfig::prettyPrint) @@ -150,6 +153,7 @@ private DoclingServeClient fullyConfiguredClient() { return newClientBuilder() .baseUrl(getWireMock().baseUrl()) .apiKey("key") + .bearerToken("token") .logRequests() .logResponses() .prettyPrint() diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBearerTokenTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBearerTokenTests.java new file mode 100644 index 00000000..14ea2e47 --- /dev/null +++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBearerTokenTests.java @@ -0,0 +1,88 @@ +package ai.docling.serve.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; + +import ai.docling.serve.api.DoclingServeApiConfig; +import ai.docling.serve.client.operations.HttpOperations; + +/** + * Tests for {@code bearerToken}, which is the credential an OAuth proxy in front of docling-serve + * authenticates on. A stub server is enough here, so these do not start a docling-serve container. + */ +class DoclingServeClientBearerTokenTests { + private static final String AUTHORIZATION = "Authorization"; + + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort()) + .build(); + + @BeforeEach + void stubHealth() { + wireMock.stubFor(get(urlPathEqualTo("/health")).willReturn(okJson("{\"status\":\"ok\"}"))); + } + + @Test + void sendsTheBearerTokenInTheAuthorizationHeader() { + var client = DoclingServeJackson3Client.builder() + .baseUrl(URI.create(wireMock.baseUrl())) + .bearerToken("my-token") + .build(); + + assertThat(client.health().getStatus()).isEqualTo("ok"); + + wireMock.verify(getRequestedFor(urlPathEqualTo("/health")).withHeader(AUTHORIZATION, equalTo("Bearer my-token"))); + } + + @Test + void sendsNoAuthorizationHeaderWhenNoBearerTokenIsSet() { + var client = DoclingServeJackson3Client.builder() + .baseUrl(URI.create(wireMock.baseUrl())) + .build(); + + assertThat(client.health().getStatus()).isEqualTo("ok"); + + wireMock.verify(getRequestedFor(urlPathEqualTo("/health")).withoutHeader(AUTHORIZATION)); + } + + @Test + void bearerTokenIsIndependentOfTheApiKey() { + var client = DoclingServeJackson3Client.builder() + .baseUrl(URI.create(wireMock.baseUrl())) + .apiKey("key") + .bearerToken("my-token") + .build(); + + assertThat(client.health().getStatus()).isEqualTo("ok"); + + wireMock.verify(getRequestedFor(urlPathEqualTo("/health")) + .withHeader(AUTHORIZATION, equalTo("Bearer my-token")) + .withHeader(HttpOperations.API_KEY_HEADER_NAME, equalTo("key"))); + } + + @Test + void nullBearerTokenUnsetsTheOption() { + var client = DoclingServeJackson3Client.builder() + .baseUrl(URI.create(wireMock.baseUrl())) + .bearerToken("my-token") + .bearerToken(null) + .build(); + + assertThat(client.config().explicitlySetOptions()).doesNotContain(DoclingServeApiConfig.BEARER_TOKEN); + assertThat(client.config().bearerToken()).isNull(); + } +} diff --git a/docs/src/doc/docs/docling-serve/serve-client.md b/docs/src/doc/docs/docling-serve/serve-client.md index 232d20a1..8bd1b66b 100644 --- a/docs/src/doc/docs/docling-serve/serve-client.md +++ b/docs/src/doc/docs/docling-serve/serve-client.md @@ -134,6 +134,29 @@ DoclingServeApi api = DoclingServeApi.builder() Note: When using plain `http://`, the client enforces HTTP/1.1 for compatibility with FastAPI, which avoids HTTP/2 downgrade mishaps in some environments. +### Authentication + +docling-serve has no built-in authentication: it is normally put behind a proxy that authenticates +the caller. Two credentials are supported, and they can be combined if your proxy asks for one and +docling-serve itself for the other: + +```java +// An API key, sent as X-Api-Key +DoclingServeApi withApiKey = DoclingServeApi.builder() + .baseUrl("http://localhost:8000") + .apiKey(System.getenv("DOCLING_SERVE_API_KEY")) + .build(); + +// A bearer token, sent as Authorization: Bearer , which is what an OAuth proxy in front of +// docling-serve authenticates on +DoclingServeApi withBearerToken = DoclingServeApi.builder() + .baseUrl("https://serve.example.com") + .bearerToken(accessToken) + .build(); +``` + +Both values are masked when request logging is enabled. + ### HTTP client customization (timeouts, proxies, TLS) You can supply and tune a `java.net.http.HttpClient.Builder` through the client-specific builder: