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: