Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
98 changes: 98 additions & 0 deletions tzatziki-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,104 @@ Then a user calling "http://backend/endpoint" receives:

This will wait and retry following what is described in the [timeout and retry delay](#Timeout-and-retry-delay) section.

### OAuth2 Client Credentials Authentication

This library provides built-in support for OAuth2 client credentials flow authentication. This allows you to set up authenticated API calls in your tests.

#### Setting up OAuth2 Authentication

Use the `Setup authentication for user` step to configure OAuth2 client credentials. This will automatically fetch the access token from the specified token URL and associate it with a user:

```gherkin
Background:
* Setup authentication for user "my-service" with clientId "my-client-id" with clientSecret "secret123" and token url "http://auth-server/oauth/token"
```

:warning: Warning: This feature is only for mocked oauth2 servers, as for now we support only a way to provide the clientSecret in plain text. Do not use it with a real oauth2 server if you don't want to expose your secret in your tests.

This step will:
1. Make a POST request to the token URL with `grant_type=client_credentials`
2. Parse the `access_token` from the JSON response
3. Add the `Authorization: Bearer <token>` header for the specified user

#### Making Authenticated HTTP Calls

Once authentication is set up, you can make authenticated HTTP calls using the existing user-based syntax:

```gherkin
# Simple GET request
When my-service calls "http://backend/api/resource"

# POST with body
When my-service posts on "http://backend/api/users":
"""json
{
"name": "John Doe"
}
"""

# Assert response with authentication
Then my-service calling "http://backend/api/status" receives a status OK_200

# Assert response with body
Then my-service calling "http://backend/api/data" receives a status OK_200 and:
"""json
{
"result": "success"
}
"""
```

The authenticated calls will automatically include the `Authorization: Bearer <token>` header.

#### Multiple Authenticated Clients

You can set up multiple OAuth2 clients for different services:

```gherkin
Background:
* Setup authentication for user "service-a" with clientId "client-a" with clientSecret "secret-a" and token url "http://auth/token"
* Setup authentication for user "service-b" with clientId "client-b" with clientSecret "secret-b" and token url "http://auth/token"

Scenario: Different services access different APIs
When service-a calls "http://backend/api/a"
Then we receive a status OK_200

When service-b calls "http://backend/api/b"
Then we receive a status OK_200
```

#### Testing with Mocked OAuth2 Server

In tests, you can mock the OAuth2 token endpoint:

```gherkin
Background:
Given that posting on "http://auth-server/oauth/token" will return:
"""json
{
"access_token": "test-token-12345",
"token_type": "Bearer",
"expires_in": 3600
}
"""
And Setup authentication for user "test-client" with clientId "test-client-id" with clientSecret "test-secret" and token url "http://auth-server/oauth/token"

Scenario: Make authenticated call to protected API
Given that calling "http://backend/api/protected" will return:
"""json
{"message": "Hello authenticated user!"}
"""
When test-client calls "http://backend/api/protected"
Then we receive:
"""json
{"message": "Hello authenticated user!"}
"""
```

But if you want to test your API's authorization, mocking just the token endpoint will not suffice. You will have to use a real oauth2 server. You can use the [mock-oauth2-server](https://github.com/navikt/mock-oauth2-server) for that.


### Mocking and interactions

Internally, WireMock is used for defining mocks and asserting interactions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,31 @@ private String getTypeString(Type type, String content) {
public void before() {
if (resetMocksBetweenTests) {
HttpUtils.reset();
OAuth2ClientCredentialsStore.reset();
}
}

// ==================== OAuth2 Client Credentials Flow Steps ====================

/**
* Sets up OAuth2 authentication for a client using the client credentials flow.
* This step fetches the access token immediately and caches it for use in subsequent authenticated calls.
*
* @param clientId the OAuth2 client ID
* @param clientSecret the OAuth2 client secret
* @param tokenUrl the OAuth2 token endpoint URL
*/
@Given("^[Ss]etup authentication for user " + QUOTED_CONTENT + " with clientId " + QUOTED_CONTENT + " with clientSecret " + QUOTED_CONTENT + " and token url " + QUOTED_CONTENT + "$")
public void setup_oauth2_authentication(String user, String clientId, String clientSecret, String tokenUrl) {
String resolvedClientId = objects.resolve(clientId);
String resolvedClientSecret = objects.resolve(clientSecret);
String resolvedTokenUrl = objects.resolve(tokenUrl);
OAuth2ClientCredentialsStore.registerClient(resolvedClientId, resolvedClientSecret, resolvedTokenUrl);
// Add the bearer token as a header for this authenticated user
String accessToken = OAuth2ClientCredentialsStore.getAccessToken(resolvedClientId);
addHeader(user, "Authorization", "Bearer " + accessToken);
}

@Given(THAT + GUARD + CALLING + " (?:on )?" + QUOTED_CONTENT + " will(?: take " + A_DURATION + " to)? return(?: " + A + TYPE + ")?:$")
public void calling_on_will_return(Guard guard, Method method, String path, long delay, Type type, String content) {
calling_on_will_return_a_status_and(guard, method, path, delay, HttpStatusCode.OK_200, type, content);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.decathlon.tzatziki.utils;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Store for OAuth2 client credentials configurations and cached access tokens.
* <p>
* This class manages OAuth2 client credentials (clientId, clientSecret, tokenUrl) and
* caches the fetched access tokens per clientId. Tokens are fetched once when the client
* is registered and cached for the duration of the test scenario.
* </p>
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class OAuth2ClientCredentialsStore {

private static final Map<String, OAuth2ClientConfig> clientConfigs = new ConcurrentHashMap<>();
private static final Map<String, String> accessTokens = new ConcurrentHashMap<>();

/**
* Registers a new OAuth2 client and immediately fetches the access token.
* If the client is already registered with the same configuration, the cached token is reused.
*
* @param clientId the OAuth2 client ID
* @param clientSecret the OAuth2 client secret
* @param tokenUrl the OAuth2 token endpoint URL
* @throws AssertionError if token fetch fails
*/
public static void registerClient(String clientId, String clientSecret, String tokenUrl) {
OAuth2ClientConfig newConfig = new OAuth2ClientConfig(clientId, clientSecret, tokenUrl);
OAuth2ClientConfig existingConfig = clientConfigs.get(clientId);

// Skip if client is already registered with the same configuration
if (newConfig.equals(existingConfig) && accessTokens.containsKey(clientId)) {
return;
}

clientConfigs.put(clientId, newConfig);

// Fetch token immediately and cache it
String accessToken = OAuth2TokenFetcher.fetchAccessToken(clientId, clientSecret, tokenUrl);
accessTokens.put(clientId, accessToken);
}

/**
* Gets the cached access token for the given clientId.
*
* @param clientId the OAuth2 client ID
* @return the cached access token
* @throws AssertionError if no token is found for the clientId
*/
public static String getAccessToken(String clientId) {
String token = accessTokens.get(clientId);
if (token == null) {
throw new AssertionError("No OAuth2 access token found for clientId: " + clientId +
". Please setup authentication first using: Setup authentication for clientId \"" +
clientId + "\" with clientSecret \"...\" and token url \"...\"");
}
return token;
}

/**
* Checks if a client is registered.
*
* @param clientId the OAuth2 client ID
* @return true if the client is registered, false otherwise
*/
public static boolean hasClient(String clientId) {
return clientConfigs.containsKey(clientId);
}

/**
* Resets the store, clearing all cached tokens and configurations.
* Should be called between test scenarios.
*/
public static void reset() {
clientConfigs.clear();
accessTokens.clear();
}

/**
* Internal configuration holder for OAuth2 client credentials.
*/
public record OAuth2ClientConfig(String clientId, String clientSecret, String tokenUrl) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.decathlon.tzatziki.utils;

import io.restassured.response.Response;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.Base64;

import static io.restassured.RestAssured.given;

/**
* Utility class for fetching OAuth2 access tokens using the client credentials flow.
* <p>
* This class performs HTTP POST requests to OAuth2 token endpoints to obtain
* access tokens. It throws immediately on any failure.
* </p>
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class OAuth2TokenFetcher {

private static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";

/**
* Fetches an access token from the OAuth2 token endpoint using client credentials flow.
*
* @param clientId the OAuth2 client ID
* @param clientSecret the OAuth2 client secret
* @param tokenUrl the OAuth2 token endpoint URL
* @return the access token
* @throws AssertionError if the token request fails or the response is invalid
*/
public static String fetchAccessToken(String clientId, String clientSecret, String tokenUrl) {
log.debug("Fetching OAuth2 access token for clientId: {} from: {}", clientId, tokenUrl);

// Resolve the token URL through HttpWiremockUtils to support mocked endpoints
String resolvedTokenUrl = HttpWiremockUtils.target(tokenUrl);
log.debug("Resolved token URL: {}", resolvedTokenUrl);

// Encode client credentials in base64 for Basic Authorization
String credentials = clientId + ":" + clientSecret;
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
String authorizationHeader = "Basic " + encodedCredentials;

try {
Response response = given()
.contentType("application/x-www-form-urlencoded")
.header("Authorization", authorizationHeader)
.formParam("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS)
.post(resolvedTokenUrl);

int statusCode = response.getStatusCode();
if (statusCode < 200 || statusCode >= 300) {
throw new AssertionError(
"OAuth2 token request failed for clientId: " + clientId +
". Status: " + statusCode +
". Response: " + response.getBody().asString());
}

String accessToken = response.jsonPath().getString("access_token");
if (accessToken == null || accessToken.isBlank()) {
throw new AssertionError(
"OAuth2 token response does not contain 'access_token' for clientId: " + clientId +
". Response: " + response.getBody().asString());
}

log.debug("Successfully fetched OAuth2 access token for clientId: {}", clientId);
return accessToken;

} catch (Error e) {
if (e instanceof AssertionError) {
throw e;
}
throw new AssertionError(
"Failed to fetch OAuth2 access token for clientId: " + clientId +
" from: " + tokenUrl + ". Error: " + e.getMessage(), e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -715,4 +715,88 @@ void handlesUrlGenerationAndMocking() {
.body(containsString("success"));
}
}
}

@Nested
@DisplayName("OAuth2 Client Credentials Flow")
class OAuth2ClientCredentialsFlow {

@AfterEach
void cleanupOAuth2() {
OAuth2ClientCredentialsStore.reset();
}

@Test
@DisplayName("should throw when fetching token for unregistered client")
void throwsForUnregisteredClient() {
assertThatThrownBy(() -> OAuth2ClientCredentialsStore.getAccessToken("unknown-client"))
.isInstanceOf(AssertionError.class)
.hasMessageContaining("No OAuth2 access token found for clientId: unknown-client");
}

@Test
@DisplayName("should correctly identify registered clients")
void identifiesRegisteredClients() {
// Register URL as mocked and get the mocked path
String tokenUrl = "http://auth-server/oauth/token";
String mockedPath = HttpWiremockUtils.mocked(tokenUrl);
mockSimpleRequest(mockedPath, POST, "200",
"{\"access_token\":\"test-token\",\"token_type\":\"Bearer\"}");

// Register client
OAuth2ClientCredentialsStore.registerClient("registered-client", "secret", tokenUrl);

assertThat(OAuth2ClientCredentialsStore.hasClient("registered-client")).isTrue();
assertThat(OAuth2ClientCredentialsStore.hasClient("unregistered-client")).isFalse();
}

@Test
@DisplayName("should reset clears all cached tokens and configurations")
void resetClearsAllData() {
// Register URL as mocked and get the mocked path
String tokenUrl = "http://auth-server/oauth/token";
String mockedPath = HttpWiremockUtils.mocked(tokenUrl);
mockSimpleRequest(mockedPath, POST, "200",
"{\"access_token\":\"token-to-clear\",\"token_type\":\"Bearer\"}");

// Register client
OAuth2ClientCredentialsStore.registerClient("client-to-reset", "secret", tokenUrl);
assertThat(OAuth2ClientCredentialsStore.hasClient("client-to-reset")).isTrue();

// Reset
OAuth2ClientCredentialsStore.reset();

// Verify client is no longer registered
assertThat(OAuth2ClientCredentialsStore.hasClient("client-to-reset")).isFalse();
}

@Test
@DisplayName("should throw when token endpoint returns error status")
void throwsOnTokenEndpointError() {
// Register URL as mocked and get the mocked path
String tokenUrl = "http://auth-server-error/oauth/token";
String mockedPath = HttpWiremockUtils.mocked(tokenUrl);
mockSimpleRequest(mockedPath, POST, "401",
"{\"error\":\"invalid_client\",\"error_description\":\"Client authentication failed\"}");

assertThatThrownBy(() -> OAuth2ClientCredentialsStore.registerClient(
"invalid-client", "wrong-secret", tokenUrl))
.isInstanceOf(AssertionError.class)
.hasMessageContaining("OAuth2 token request failed");
}

@Test
@DisplayName("should throw when token response does not contain access_token")
void throwsOnMissingAccessToken() {
// Register URL as mocked and get the mocked path
String tokenUrl = "http://auth-server-missing/oauth/token";
String mockedPath = HttpWiremockUtils.mocked(tokenUrl);
mockSimpleRequest(mockedPath, POST, "200",
"{\"token_type\":\"Bearer\",\"expires_in\":3600}");

assertThatThrownBy(() -> OAuth2ClientCredentialsStore.registerClient(
"test-client", "test-secret", tokenUrl))
.isInstanceOf(AssertionError.class)
.hasMessageContaining("does not contain 'access_token'");
}
}
}
Loading