Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ public final class DoclingServeApiConfig {
*/
public static final ConfigOption<String> API_KEY = ConfigOption.of("apiKey", String.class);

/**
* The bearer token used to authenticate requests, sent as {@code Authorization: Bearer <token>}.
* Not set by default.
*
* <p>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.
*
* <p>Providers must honor this option when it is set.
*/
public static final ConfigOption<String> BEARER_TOKEN = ConfigOption.of("bearerToken", String.class);

/**
* Whether requests are logged. Defaults to {@code false}.
*
Expand Down Expand Up @@ -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<ConfigOption<?>> 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);
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -179,6 +181,7 @@ private static DoclingServeApiBuilder fullyConfigured() {
return DoclingServeApi.builder()
.baseUrl("http://example.com:8080")
.apiKey("key")
.bearerToken("token")
.logRequests()
.logResponses()
.prettyPrint()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, List<String>> maskSensitiveHeaderValues(Map.Entry<String, List<String>> entry) {
Expand Down Expand Up @@ -330,6 +330,12 @@ protected <I, O> HttpRequest.Builder createRequestBuilder(RequestContext<I, O> 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;
}

Expand Down Expand Up @@ -604,6 +610,21 @@ public B apiKey(@Nullable String apiKey) {
return (B) this;
}

/**
* Sets the bearer token used to authenticate requests.
*
* <p>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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>}, 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}.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -150,6 +153,7 @@ private DoclingServeClient fullyConfiguredClient() {
return newClientBuilder()
.baseUrl(getWireMock().baseUrl())
.apiKey("key")
.bearerToken("token")
.logRequests()
.logResponses()
.prettyPrint()
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
23 changes: 23 additions & 0 deletions docs/src/doc/docs/docling-serve/serve-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>, 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:
Expand Down