Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
96 changes: 96 additions & 0 deletions tzatziki-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,102 @@ 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` step to configure OAuth2 client credentials. This will automatically fetch the access token from the specified token URL:

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

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. Cache the token for use in subsequent authenticated calls

#### Making Authenticated HTTP Calls

Once authentication is set up, you can make authenticated HTTP calls using the `as authenticated user` syntax:

```gherkin
# Simple GET request
When we call "http://backend/api/resource" as authenticated user "my-service"

# POST with body
When we post on "http://backend/api/users" as authenticated user "my-service" with:
"""json
{
"name": "John Doe"
}
"""

# Assert response with authentication
Then calling "http://backend/api/status" as authenticated user "my-service" returns a status OK_200

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

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

#### Multiple Authenticated Users

You can set up multiple OAuth2 clients for different services:

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

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

When we call "http://backend/api/b" as authenticated user "service-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 clientId "test-client" 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 we call "http://backend/api/protected" as authenticated user "test-client"
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,79 @@ 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 clientId " + QUOTED_CONTENT + " with clientSecret " + QUOTED_CONTENT + " and token url " + QUOTED_CONTENT + "$")
public void setup_oauth2_authentication(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(resolvedClientId, "Authorization", "Bearer " + accessToken);
}

@When(THAT + GUARD + A_USER + CALL + " (?:on )?" + QUOTED_CONTENT + " as authenticated user " + QUOTED_CONTENT + "$")
public void call_as_authenticated_user(Guard guard, Method method, String path, String clientId) {
guard.in(objects, () -> {
String resolvedClientId = objects.resolve(clientId);
call(always(), resolvedClientId, method, path);
});
}

@When(THAT + GUARD + A_USER + SEND + " (?:on )?" + QUOTED_CONTENT + " as authenticated user " + QUOTED_CONTENT + "(?: with)?(?: " + A + TYPE + ")?:$")
public void send_as_authenticated_user(Guard guard, Method method, String path, String clientId, Type type, String content) {
guard.in(objects, () -> {
String resolvedClientId = objects.resolve(clientId);
send(guard, resolvedClientId, method, path, type, content);
});
}

@When(THAT + GUARD + A_USER + SEND + " (?:on )?" + QUOTED_CONTENT + " as authenticated user " + QUOTED_CONTENT + "(?: with)?(?: " + A + TYPE + ")? " + QUOTED_CONTENT + "$")
public void send_as_authenticated_user_(Guard guard, Method method, String path, String clientId, Type type, String content) {
send_as_authenticated_user(guard, method, path, clientId, type, content);
}

@Then(THAT + GUARD + A_USER + CALLING + " (?:on )?" + QUOTED_CONTENT + " as authenticated user " + QUOTED_CONTENT + " (?:returns|receives) a status " + STATUS + "$")
public void call_as_authenticated_user_returns_status(Guard guard, Method method, String path, String clientId, HttpStatusCode status) {
guard.in(objects, () -> {
String resolvedClientId = objects.resolve(clientId);
call(always(), resolvedClientId, method, path);
we_receive_a_status(always(), status);
});
}

@Then(THAT + GUARD + A_USER + CALLING + " (?:on )?" + QUOTED_CONTENT + " as authenticated user " + QUOTED_CONTENT + " (?:returns|receives) a status " + STATUS + " and" + COMPARING_WITH + "(?: " + A + TYPE + ")?:$")
public void call_as_authenticated_user_returns_status_and(Guard guard, Method method, String path, String clientId, HttpStatusCode status, Comparison comparison, Type type, String content) {
guard.in(objects, () -> {
String resolvedClientId = objects.resolve(clientId);
call(always(), resolvedClientId, method, path);
we_receive_a_status_and(always(), status, comparison, type, content);
});
}

@Then(THAT + GUARD + A_USER + CALLING + " (?:on )?" + QUOTED_CONTENT + " as authenticated user " + QUOTED_CONTENT + " (?:returns|receives)" + COMPARING_WITH + "(?: " + A + TYPE + ")?:$")
public void call_as_authenticated_user_returns(Guard guard, Method method, String path, String clientId, Comparison comparison, Type type, String content) {
guard.in(objects, () -> {
String resolvedClientId = objects.resolve(clientId);
call(always(), resolvedClientId, method, path);
we_receive(always(), comparison, type, content);
});
}

@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,81 @@
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.
*
* @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 config = new OAuth2ClientConfig(clientId, clientSecret, tokenUrl);
clientConfigs.put(clientId, config);

// 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,74 @@
package com.decathlon.tzatziki.utils;

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

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);

try {
Response response = given()
.contentType("application/x-www-form-urlencoded")
.formParam("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS)
.formParam("client_id", clientId)
.formParam("client_secret", clientSecret)
.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);
}
}
}
Loading