Skip to content
Merged
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
62 changes: 60 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ For each subsequent request to protected resources, the client includes the JWT

3. **Server Verification**:
The receiving server verifies the JWT’s signature, expiration time, and claims to ensure the token is valid and trusted.
If valid, access is granted; otherwise, a 401 Unauthorized response is returned.
If valid, access is granted; otherwise, a ``401 Unauthorized`` response is returned.

### Feign Client Configuration (JWT)
In this example, we assume that our Spring Boot application receives a request that already includes a valid ``Authorization: Bearer <token>`` header.
Expand Down Expand Up @@ -321,9 +321,67 @@ public class JwtClientConfig {

```
## Digest Authentication Example
**Digest Authentication** is a challenge-response mechanism defined by ***RFC 7616***, where the client proves knowledge of a
password without sending it in plaintext. It's a more secure alternative to Basic Auth, as it protects credentials using
hashing and nonce-based mechanisms.
How it works:
How it Works:
1. **Initial Challenge:**
The client makes an unauthenticated request to the server. The server responds with a ``401 Unauthorized`` status and a
``WWW-Authenticate`` header containing the digest challenge (realm, nonce, opaque, etc.).

#### WIP
2. **Digest Response:**
The client computes a hash (digest) of the username, password, and challenge parameters, and resends the request with
an ``Authorization: Digest ...`` header containing the computed response.

3. **Authentication Success:**
The server verifies the digest response. If valid, it returns a ``200 OK`` and the requested resource.
Otherwise, another ``401`` is issued.

### Implementation Notes:
- In this example, we do not use a Feign client because Apache HttpClient provides more precise control over the
Digest scheme negotiation.

- The client initializes a ``DigestScheme`` using the first ``401`` response and then reuses this authentication context
``(HttpClientContext)`` across subsequent requests using an ``AuthCache``.

- This avoids redundant challenge-response handshakes after the first authenticated request.

### Apache HttpClient Configuration
Here's the key part of the implementation (simplified):
```java
// Provides the credentials (username and password) for a specific AuthScope
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
new AuthScope(host, port),
new UsernamePasswordCredentials(username, password)
);
// Register the Digest authentication scheme (needed explicitly)
Lookup<AuthSchemeProvider> authSchemeRegistry = RegistryBuilder.<AuthSchemeProvider>create()
.register(AuthSchemes.DIGEST, new DigestSchemeFactory())
.build();
// Build the HTTP client with the credentials and scheme
this.httpClient = HttpClients.custom()
.setDefaultCredentialsProvider(credentialsProvider)
.setDefaultAuthSchemeRegistry(authSchemeRegistry)
.build();
```
Then, a first unauthenticated request is performed explicitly to force the ``401 Unauthorized``, and the server's
challenge is processed manually:
```java
DigestScheme digestScheme = new DigestScheme();
digestScheme.processChallenge(wwwAuthHeaderFrom401);

AuthCache authCache = new BasicAuthCache();
authCache.put(targetHost, digestScheme);

HttpClientContext context = HttpClientContext.create();
context.setAuthCache(authCache);
```
Finally, this context is reused in all subsequent authenticated requests:
```java
httpClient.execute(targetHost, request, reusableContext);
```

## Mutual TLS Authentication Example

Expand Down
14 changes: 7 additions & 7 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
<maven.compiler.source>23</maven.compiler.source>
<maven.compiler.target>23</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>3.4.1</spring-boot.version>
<spring-boot-devtools.version>3.4.1</spring-boot-devtools.version>
<spring-boot-starter-test.version>3.4.1</spring-boot-starter-test.version>
<lombok.version>1.18.36</lombok.version>
<jackson-core-databind.version>2.18.2</jackson-core-databind.version>
<spring-boot.version>3.5.3</spring-boot.version>
<spring-boot-devtools.version>3.5.3</spring-boot-devtools.version>
<spring-boot-starter-test.version>3.5.3</spring-boot-starter-test.version>
<lombok.version>1.18.38</lombok.version>
<jackson-core-databind.version>2.19.1</jackson-core-databind.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -77,7 +77,7 @@
<version>3.2.0</version>
</dependency>

<!-- testcontainers probably not needed -->
<!-- testcontainers for Awaitility -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down Expand Up @@ -108,7 +108,7 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Test containers, probably to remove -->
<!-- Test containers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package raff.stein.feignclient.client.digest;

import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthSchemeProvider;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.MalformedChallengeException;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.config.AuthSchemes;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.config.Lookup;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.impl.auth.DigestScheme;
import org.apache.http.impl.auth.DigestSchemeFactory;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;

@Component
public class DigestApacheClient {

private final CloseableHttpClient httpClient;
private final String baseUrl;
private final HttpHost targetHost;
private final HttpClientContext reusableContext;

/**
* Constructor that sets up the Apache HTTP client to support Digest Authentication.
* This includes:
* - Extracting the host and port from the base URL.
* - Configuring the CredentialsProvider with username and password.
* - Registering the Digest authentication scheme.
* - Pre-authenticating once to cache the Digest scheme and reuse it for future requests.
*/
public DigestApacheClient(
@Value("${spring.application.rest.client.digest-auth.host}") String baseUrl,
@Value("${spring.application.rest.client.digest-auth.username}") String username,
@Value("${spring.application.rest.client.digest-auth.password}") String password) {

this.baseUrl = baseUrl;
// post and host extraction from URI
URI uri;
try {
uri = new URI(baseUrl);
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Invalid base URL: " + baseUrl, e);
}

String host = uri.getHost();
int port = (uri.getPort() != -1) ? uri.getPort() : ("https".equals(uri.getScheme()) ? 443 : 80);
this.targetHost = new HttpHost(host, port, uri.getScheme());
// Provides the credentials (username and password) for a specific AuthScope
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
new AuthScope(host, port),
new UsernamePasswordCredentials(username, password)
);
// Register the Digest authentication scheme (needed explicitly)
Lookup<AuthSchemeProvider> authSchemeRegistry = RegistryBuilder.<AuthSchemeProvider>create()
.register(AuthSchemes.DIGEST, new DigestSchemeFactory())
.build();
// Build the HTTP client with the credentials and scheme
this.httpClient = HttpClients.custom()
.setDefaultCredentialsProvider(credentialsProvider)
.setDefaultAuthSchemeRegistry(authSchemeRegistry)
.build();

// Force an initial 401 challenge to extract the DigestScheme and cache it
this.reusableContext = preAuthenticate();
}

/**
* Performs an initial HTTP call (without credentials) to force a 401 response.
* This response contains the Digest challenge in the WWW-Authenticate header.
* The DigestScheme is extracted and stored in an AuthCache, which is later reused
* to avoid repeating the 401 round-trip on each request.
*
* @return HttpClientContext containing the cached DigestScheme
*/
private HttpClientContext preAuthenticate() {
// Creates a no-credential client in order to force the 401
try (CloseableHttpClient noCredentialClient = HttpClients.createDefault()) {
// unauthorized request to a specified endpoint
HttpGet unauthorizedRequest = new HttpGet(baseUrl + "/auth");
// empty context
HttpClientContext context = HttpClientContext.create();
try (CloseableHttpResponse response = noCredentialClient.execute(targetHost, unauthorizedRequest, context)) {
if (response.getStatusLine().getStatusCode() == 401) {
Header wwwAuth = response.getFirstHeader("WWW-Authenticate");
if (wwwAuth != null && wwwAuth.getValue().startsWith(AuthSchemes.DIGEST)) {
// Parse the WWW-Authenticate challenge into a DigestScheme
DigestScheme digestScheme = new DigestScheme();
digestScheme.processChallenge(wwwAuth);
// Cache the DigestScheme so it can be reused automatically
AuthCache authCache = new BasicAuthCache();
authCache.put(targetHost, digestScheme);
// insert it into the context, so it can be reused from the next api calls
HttpClientContext authContext = HttpClientContext.create();
authContext.setAuthCache(authCache);
return authContext;
}
} else {
throw new RuntimeException("Expected 401 but got " + response.getStatusLine().getStatusCode());
}
}
} catch (IOException | MalformedChallengeException e) {
throw new RuntimeException("Failed to initialize Digest auth context", e);
}
throw new RuntimeException("Digest challenge was not received from the server");
}


public String getFirstContent() {
HttpGet request = new HttpGet(baseUrl + "/get-first-content");

try (CloseableHttpResponse response = httpClient.execute(targetHost, request, reusableContext)) {
int status = response.getStatusLine().getStatusCode();
if (status == 200) {
return EntityUtils.toString(response.getEntity());
} else {
throw new RuntimeException("HTTP error: " + status);
}
} catch (IOException e) {
throw new RuntimeException("Error during HTTP Digest call: " + e.getMessage(), e);
}
}

public String getSecondContent() {
HttpGet request = new HttpGet(baseUrl + "/get-second-content");

try (CloseableHttpResponse response = httpClient.execute(targetHost, request, reusableContext)) {
int status = response.getStatusLine().getStatusCode();
if (status == 200) {
return EntityUtils.toString(response.getEntity());
} else {
throw new RuntimeException("HTTP error: " + status);
}
} catch (IOException e) {
throw new RuntimeException("Error during HTTP Digest call: " + e.getMessage(), e);
}
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,10 @@ public ResponseEntity<String> getDataWithJwt(@RequestHeader("Authorization") Str
return ResponseEntity.ok(responseString);
}

@GetMapping("/digest")
public ResponseEntity<String> getDataWithDigest() {
final String responseString = feignClientSimpleService.simpleDigestClientCall();
return ResponseEntity.ok(responseString);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.stereotype.Service;
import raff.stein.feignclient.client.apikey.ApiKeyClient;
import raff.stein.feignclient.client.basicauth.BasicAuthClient;
import raff.stein.feignclient.client.digest.DigestApacheClient;
import raff.stein.feignclient.client.jwt.JwtClient;
import raff.stein.feignclient.client.ntlm.NTLMClient;
import raff.stein.feignclient.client.oauth2.OAuth2Client;
Expand All @@ -19,6 +20,7 @@ public class FeignClientSimpleService {
private final NTLMClient ntlmClient;
private final ApiKeyClient apiKeyClient;
private final JwtClient jwtClient;
private final DigestApacheClient digestApacheClient;

public String simpleBasicAuthClientCall() {
return basicAuthClient.getData();
Expand All @@ -40,4 +42,10 @@ public String simpleJwtClientCall() {
return jwtClient.getData();
}

public String simpleDigestClientCall() {
String firstResult = digestApacheClient.getFirstContent();
String secondResult = digestApacheClient.getSecondContent();
return firstResult + secondResult;
}

}
6 changes: 5 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ spring:
host: http://localhost:8086
key: key
jwt:
host: http://localhost:8087
host: http://localhost:8087
digest-auth:
host: http://localhost:8088
username: user
password: password
Loading