Skip to content

Commit c539884

Browse files
ozozgunjak78dkt
authored andcommitted
feat(http): add oauth2 client_credentials support (#813)
* add oauth2 client_credentials support * use BASIC_AUTH for getting bearer token from oauth2 server * update authenticated steps gherkin phrases * fix tests * add tests for erroneous cases * refactor tests, update doc * prevent unnecessary call to oauth2 server if client already registered * refactor authentication step's gherkin statement * refactor authentication step's gherkin statement * try git guardian config file * try git guardian config file * remove gitguardian config file * fix authentication step base method
1 parent 01d05cf commit c539884

File tree

6 files changed

+755
-0
lines changed

6 files changed

+755
-0
lines changed

tzatziki-http/README.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,129 @@ Then a user calling "http://backend/endpoint" receives:
9999

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

102+
### OAuth2 Client Credentials Authentication
103+
104+
This library provides built-in support for OAuth2 client credentials flow authentication. This allows you to set up authenticated API calls in your tests.
105+
106+
#### Setting up OAuth2 Authentication
107+
108+
Use the `that the user "<user>" is authenticated with:` step to configure OAuth2 client credentials. This will automatically fetch the access token from the specified token URL and associate it with a user:
109+
110+
```gherkin
111+
Background:
112+
Given that the user "my-service" is authenticated with:
113+
"""yml
114+
client_id: my-client-id
115+
client_secret: secret123
116+
token_url: "http://auth-server/oauth/token"
117+
"""
118+
```
119+
120+
The docstring accepts the following YAML keys:
121+
- `client_id` — the OAuth2 client ID
122+
- `client_secret` — the OAuth2 client secret
123+
- `token_url` — the OAuth2 token endpoint URL
124+
125+
: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.
126+
127+
This step will:
128+
1. Make a POST request to the token URL with `grant_type=client_credentials`
129+
2. Parse the `access_token` from the JSON response
130+
3. Add the `Authorization: Bearer <token>` header for the specified user
131+
132+
#### Making Authenticated HTTP Calls
133+
134+
Once authentication is set up, you can make authenticated HTTP calls using the existing user-based syntax:
135+
136+
```gherkin
137+
# Simple GET request
138+
When my-service calls "http://backend/api/resource"
139+
140+
# POST with body
141+
When my-service posts on "http://backend/api/users":
142+
"""json
143+
{
144+
"name": "John Doe"
145+
}
146+
"""
147+
148+
# Assert response with authentication
149+
Then my-service calling "http://backend/api/status" receives a status OK_200
150+
151+
# Assert response with body
152+
Then my-service calling "http://backend/api/data" receives a status OK_200 and:
153+
"""json
154+
{
155+
"result": "success"
156+
}
157+
"""
158+
```
159+
160+
The authenticated calls will automatically include the `Authorization: Bearer <token>` header.
161+
162+
#### Multiple Authenticated Clients
163+
164+
You can set up multiple OAuth2 clients for different services:
165+
166+
```gherkin
167+
Background:
168+
Given that the user "service-a" is authenticated with:
169+
"""yml
170+
client_id: client-a
171+
client_secret: secret-a
172+
token_url: "http://auth/token"
173+
"""
174+
And that the user "service-b" is authenticated with:
175+
"""yml
176+
client_id: client-b
177+
client_secret: secret-b
178+
token_url: "http://auth/token"
179+
"""
180+
181+
Scenario: Different services access different APIs
182+
When service-a calls "http://backend/api/a"
183+
Then we receive a status OK_200
184+
185+
When service-b calls "http://backend/api/b"
186+
Then we receive a status OK_200
187+
```
188+
189+
#### Testing with Mocked OAuth2 Server
190+
191+
In tests, you can mock the OAuth2 token endpoint:
192+
193+
```gherkin
194+
Background:
195+
Given that posting on "http://auth-server/oauth/token" will return:
196+
"""json
197+
{
198+
"access_token": "test-token-12345",
199+
"token_type": "Bearer",
200+
"expires_in": 3600
201+
}
202+
"""
203+
And that the user "test-client" is authenticated with:
204+
"""yml
205+
client_id: test-client-id
206+
client_secret: test-secret
207+
token_url: "http://auth-server/oauth/token"
208+
"""
209+
210+
Scenario: Make authenticated call to protected API
211+
Given that calling "http://backend/api/protected" will return:
212+
"""json
213+
{"message": "Hello authenticated user!"}
214+
"""
215+
When test-client calls "http://backend/api/protected"
216+
Then we receive:
217+
"""json
218+
{"message": "Hello authenticated user!"}
219+
"""
220+
```
221+
222+
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.
223+
224+
102225
### Mocking and interactions
103226

104227
Internally, WireMock is used for defining mocks and asserting interactions.

tzatziki-http/src/main/java/com/decathlon/tzatziki/steps/HttpSteps.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,30 @@ public void before() {
124124
if (resetMocksBetweenTests) {
125125
wireMockServer.resetAll();
126126
MOCKED_PATHS.clear();
127+
OAuth2ClientCredentialsStore.reset();
127128
}
128129
}
129130

131+
// ==================== OAuth2 Client Credentials Flow Steps ====================
132+
133+
/**
134+
* Sets up OAuth2 authentication for a client using the client credentials flow, reading credentials from a docstring.
135+
* The docstring should be YAML-formatted with keys: client_id, client_secret, token_url.
136+
*
137+
* @param user the user alias to bind this authentication to
138+
* @param content the YAML docstring containing client_id, client_secret, and token_url
139+
*/
140+
@Given(THAT + GUARD + "the user " + QUOTED_CONTENT + " is authenticated with:$")
141+
public void setup_oauth2_authentication(Guard guard, String user, String content) {
142+
Map<String, String> params = Mapper.read(objects.resolve(content));
143+
String resolvedClientId = objects.resolve(params.get("client_id"));
144+
String resolvedClientSecret = objects.resolve(params.get("client_secret"));
145+
String resolvedTokenUrl = objects.resolve(params.get("token_url"));
146+
OAuth2ClientCredentialsStore.registerClient(resolvedClientId, resolvedClientSecret, resolvedTokenUrl);
147+
String accessToken = OAuth2ClientCredentialsStore.getAccessToken(resolvedClientId);
148+
addHeader(user, "Authorization", "Bearer " + accessToken);
149+
}
150+
130151
@Given(THAT + GUARD + CALLING + " (?:on )?" + QUOTED_CONTENT + " will(?: take " + A_DURATION + " to)? return(?: " + A + TYPE + ")?:$")
131152
public void calling_on_will_return(Guard guard, Method method, String path, long delay, Type type, String content) {
132153
calling_on_will_return_a_status_and(guard, method, path, delay, HttpStatusCode.OK_200, type, content);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.decathlon.tzatziki.utils;
2+
3+
import lombok.AccessLevel;
4+
import lombok.NoArgsConstructor;
5+
6+
import java.util.Map;
7+
import java.util.concurrent.ConcurrentHashMap;
8+
9+
/**
10+
* Store for OAuth2 client credentials configurations and cached access tokens.
11+
* <p>
12+
* This class manages OAuth2 client credentials (clientId, clientSecret, tokenUrl) and
13+
* caches the fetched access tokens per clientId. Tokens are fetched once when the client
14+
* is registered and cached for the duration of the test scenario.
15+
* </p>
16+
*/
17+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
18+
public final class OAuth2ClientCredentialsStore {
19+
20+
private static final Map<String, OAuth2ClientConfig> clientConfigs = new ConcurrentHashMap<>();
21+
private static final Map<String, String> accessTokens = new ConcurrentHashMap<>();
22+
23+
/**
24+
* Registers a new OAuth2 client and immediately fetches the access token.
25+
* If the client is already registered with the same configuration, the cached token is reused.
26+
*
27+
* @param clientId the OAuth2 client ID
28+
* @param clientSecret the OAuth2 client secret
29+
* @param tokenUrl the OAuth2 token endpoint URL
30+
* @throws AssertionError if token fetch fails
31+
*/
32+
public static void registerClient(String clientId, String clientSecret, String tokenUrl) {
33+
OAuth2ClientConfig newConfig = new OAuth2ClientConfig(clientId, clientSecret, tokenUrl);
34+
OAuth2ClientConfig existingConfig = clientConfigs.get(clientId);
35+
36+
// Skip if client is already registered with the same configuration
37+
if (newConfig.equals(existingConfig) && accessTokens.containsKey(clientId)) {
38+
return;
39+
}
40+
41+
clientConfigs.put(clientId, newConfig);
42+
43+
// Fetch token immediately and cache it
44+
String accessToken = OAuth2TokenFetcher.fetchAccessToken(clientId, clientSecret, tokenUrl);
45+
accessTokens.put(clientId, accessToken);
46+
}
47+
48+
/**
49+
* Gets the cached access token for the given clientId.
50+
*
51+
* @param clientId the OAuth2 client ID
52+
* @return the cached access token
53+
* @throws AssertionError if no token is found for the clientId
54+
*/
55+
public static String getAccessToken(String clientId) {
56+
String token = accessTokens.get(clientId);
57+
if (token == null) {
58+
throw new AssertionError("No OAuth2 access token found for clientId: " + clientId +
59+
". Please setup authentication first using: Setup authentication for clientId \"" +
60+
clientId + "\" with clientSecret \"...\" and token url \"...\"");
61+
}
62+
return token;
63+
}
64+
65+
/**
66+
* Checks if a client is registered.
67+
*
68+
* @param clientId the OAuth2 client ID
69+
* @return true if the client is registered, false otherwise
70+
*/
71+
public static boolean hasClient(String clientId) {
72+
return clientConfigs.containsKey(clientId);
73+
}
74+
75+
/**
76+
* Resets the store, clearing all cached tokens and configurations.
77+
* Should be called between test scenarios.
78+
*/
79+
public static void reset() {
80+
clientConfigs.clear();
81+
accessTokens.clear();
82+
}
83+
84+
/**
85+
* Internal configuration holder for OAuth2 client credentials.
86+
*/
87+
public record OAuth2ClientConfig(String clientId, String clientSecret, String tokenUrl) {
88+
}
89+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.decathlon.tzatziki.utils;
2+
3+
import io.restassured.response.Response;
4+
import lombok.AccessLevel;
5+
import lombok.NoArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
8+
import java.util.Base64;
9+
10+
import static io.restassured.RestAssured.given;
11+
12+
/**
13+
* Utility class for fetching OAuth2 access tokens using the client credentials flow.
14+
* <p>
15+
* This class performs HTTP POST requests to OAuth2 token endpoints to obtain
16+
* access tokens. It throws immediately on any failure.
17+
* </p>
18+
*/
19+
@Slf4j
20+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
21+
public final class OAuth2TokenFetcher {
22+
23+
private static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
24+
25+
/**
26+
* Fetches an access token from the OAuth2 token endpoint using client credentials flow.
27+
*
28+
* @param clientId the OAuth2 client ID
29+
* @param clientSecret the OAuth2 client secret
30+
* @param tokenUrl the OAuth2 token endpoint URL
31+
* @return the access token
32+
* @throws AssertionError if the token request fails or the response is invalid
33+
*/
34+
public static String fetchAccessToken(String clientId, String clientSecret, String tokenUrl) {
35+
log.debug("Fetching OAuth2 access token for clientId: {} from: {}", clientId, tokenUrl);
36+
37+
// Resolve the token URL through HttpWiremockUtils to support mocked endpoints
38+
String resolvedTokenUrl = HttpWiremockUtils.target(tokenUrl);
39+
log.debug("Resolved token URL: {}", resolvedTokenUrl);
40+
41+
// Encode client credentials in base64 for Basic Authorization
42+
String credentials = clientId + ":" + clientSecret;
43+
String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes());
44+
String authorizationHeader = "Basic " + encodedCredentials;
45+
46+
try {
47+
Response response = given()
48+
.contentType("application/x-www-form-urlencoded")
49+
.header("Authorization", authorizationHeader)
50+
.formParam("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS)
51+
.post(resolvedTokenUrl);
52+
53+
int statusCode = response.getStatusCode();
54+
if (statusCode < 200 || statusCode >= 300) {
55+
throw new AssertionError(
56+
"OAuth2 token request failed for clientId: " + clientId +
57+
". Status: " + statusCode +
58+
". Response: " + response.getBody().asString());
59+
}
60+
61+
String accessToken = response.jsonPath().getString("access_token");
62+
if (accessToken == null || accessToken.isBlank()) {
63+
throw new AssertionError(
64+
"OAuth2 token response does not contain 'access_token' for clientId: " + clientId +
65+
". Response: " + response.getBody().asString());
66+
}
67+
68+
log.debug("Successfully fetched OAuth2 access token for clientId: {}", clientId);
69+
return accessToken;
70+
71+
} catch (Error e) {
72+
if (e instanceof AssertionError) {
73+
throw e;
74+
}
75+
throw new AssertionError(
76+
"Failed to fetch OAuth2 access token for clientId: " + clientId +
77+
" from: " + tokenUrl + ". Error: " + e.getMessage(), e);
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)