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 @@ -22,8 +22,13 @@ public class OAuthConstants {
public static final String CLIENT_SECRET = "client_secret";
public static final String AUDIENCE = "audience";
public static final String SCOPE = "scope";
public static final String REFRESH_TOKEN = "refresh_token";
public static final String ACCESS_TOKEN = "access_token";
public static final String EXPIRES_IN = "expires_in";
public static final String BASIC_AUTH_HEADER = "basicAuthHeader";
public static final String CREDENTIALS_BODY = "credentialsBody";
public static final String ERROR = "error";
public static final String ERROR_DESCRIPTION = "error_description";
public static final String INVALID_GRANT = "invalid_grant";
public static final String INTERACTION_REQUIRED = "interaction_required";
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import io.camunda.connector.http.client.model.HttpClientRequest;
import io.camunda.connector.http.client.model.HttpMethod;
import io.camunda.connector.http.client.model.auth.OAuthAuthentication;
import io.camunda.connector.http.client.model.auth.OAuthRefreshTokenAuthentication;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
Expand Down Expand Up @@ -81,6 +82,72 @@ public TokenResponse extractTokenFromResponse(StreamingHttpResponse body) {
"OAuth token response does not contain an access_token field"));
}

/**
* Creates an OAuth token request for the refresh-token grant flow.
*
* @param authentication the refresh-token authentication configuration
* @return a request targeting the token endpoint with the refresh-token body
*/
public HttpClientRequest createOAuthRefreshTokenRequestFrom(
OAuthRefreshTokenAuthentication authentication) {
HttpClientRequest oauthRequest = new HttpClientRequest();
Map<String, String> headers = new HashMap<>();
headers.put(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.getMimeType());

oauthRequest.setMethod(HttpMethod.POST);
oauthRequest.setUrl(authentication.oauthTokenEndpoint());
oauthRequest.setBody(authentication.getDataForAuthRequestBody());
oauthRequest.setHeaders(headers);
return oauthRequest;
}

/**
* Extracts an access token from a refresh-token grant response, with actionable error messages
* for common failure modes ({@code invalid_grant}, {@code interaction_required}).
*
* @param body the raw token endpoint response
* @return the access token
* @throws ConnectorException if the response contains an OAuth error or no access_token
*/
public TokenResponse extractTokenFromRefreshTokenResponse(StreamingHttpResponse body) {
var jsonNode = ResponseMappers.asJsonNode(() -> OBJECT_MAPPER).apply(body);
if (jsonNode != null && jsonNode.isObject()) {
var errorNode = jsonNode.findValue(OAuthConstants.ERROR);
if (errorNode != null) {
String error = errorNode.asText();
String description =
Optional.ofNullable(jsonNode.findValue(OAuthConstants.ERROR_DESCRIPTION))
.map(JsonNode::asText)
.orElse("no description provided");
if (OAuthConstants.INVALID_GRANT.equals(error)) {
throw new ConnectorException(
"OAUTH_REFRESH_TOKEN_EXPIRED",
"Refresh token is invalid or expired; re-authorization is required. Details: "
+ description);
}
if (OAuthConstants.INTERACTION_REQUIRED.equals(error)) {
throw new ConnectorException(
"OAUTH_INTERACTION_REQUIRED",
"The identity provider requires user interaction before a token can be issued; "
+ "re-authorization is required. Details: "
+ description);
}
throw new ConnectorException(
"OAUTH_TOKEN_ERROR", "OAuth token exchange failed: " + error + ". " + description);
}
}
return Optional.ofNullable(jsonNode)
.filter(JsonNode::isObject)
.map(node -> node.findValue(OAuthConstants.ACCESS_TOKEN))
.map(JsonNode::asText)
.map(accessToken -> toTokenResponse(accessToken, jsonNode))
.orElseThrow(
() ->
new ConnectorException(
"OAUTH_TOKEN_ERROR",
"OAuth token response does not contain an access_token field"));
}

private TokenResponse toTokenResponse(String accessToken, JsonNode jsonNode) {
return Optional.of(jsonNode)
.map(node -> node.findValue(OAuthConstants.EXPIRES_IN))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ public void build(ClassicRequestBuilder builder, HttpClientRequest request) {
var token = tokenCache.getOrFetch(auth, () -> fetchOAuthToken(auth));
builder.addHeader(AUTHORIZATION, String.format(BEARER, token));
}
case OAuthRefreshTokenAuthentication auth -> {
var token = fetchOAuthRefreshToken(auth);
builder.addHeader(AUTHORIZATION, String.format(BEARER, token));
}
Comment on lines +57 to +60
case BearerAuthentication auth ->
builder.addHeader(AUTHORIZATION, String.format(BEARER, auth.token()));
case ApiKeyAuthentication auth -> {
Expand All @@ -76,4 +80,13 @@ TokenResponse fetchOAuthToken(OAuthAuthentication authentication) {
.execute(oAuthRequest, oAuthService::extractTokenFromResponse)
.entity();
}

String fetchOAuthRefreshToken(OAuthRefreshTokenAuthentication authentication) {
HttpClientRequest oAuthRequest =
oAuthService.createOAuthRefreshTokenRequestFrom(authentication);
return new CustomApacheHttpClient()
.execute(oAuthRequest, oAuthService::extractTokenFromRefreshTokenResponse)
.entity()
.accessToken();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
@JsonSubTypes.Type(value = BasicAuthentication.class, name = BasicAuthentication.TYPE),
@JsonSubTypes.Type(value = NoAuthentication.class, name = NoAuthentication.TYPE),
@JsonSubTypes.Type(value = OAuthAuthentication.class, name = OAuthAuthentication.TYPE),
@JsonSubTypes.Type(
value = OAuthRefreshTokenAuthentication.class,
name = OAuthRefreshTokenAuthentication.TYPE),
@JsonSubTypes.Type(value = BearerAuthentication.class, name = BearerAuthentication.TYPE),
@JsonSubTypes.Type(value = ApiKeyAuthentication.class, name = ApiKeyAuthentication.TYPE)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH
* under one or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information regarding copyright
* ownership. Camunda licenses this file to you under the Apache License,
* Version 2.0; you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.camunda.connector.http.client.model.auth;

import io.camunda.connector.http.client.authentication.OAuthConstants;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;

public record OAuthRefreshTokenAuthentication(
String oauthTokenEndpoint,
String clientId,
String clientSecret,
String refreshToken,
String scopes)
implements HttpAuthentication {

public static final String TYPE = "oauth-refresh-token";
public static final String GRANT_TYPE = "refresh_token";

public Map<String, String> getDataForAuthRequestBody() {
Map<String, String> data = new HashMap<>();
data.put(OAuthConstants.GRANT_TYPE, GRANT_TYPE);
data.put(OAuthConstants.CLIENT_ID, clientId());
data.put(OAuthConstants.REFRESH_TOKEN, refreshToken());
if (StringUtils.isNotBlank(clientSecret())) {
data.put(OAuthConstants.CLIENT_SECRET, clientSecret());
}
if (StringUtils.isNotBlank(scopes())) {
data.put(OAuthConstants.SCOPE, scopes());
}
return data;
}

@Override
public String toString() {
return "OAuthRefreshTokenAuthentication{"
+ "oauthTokenEndpoint='"
+ oauthTokenEndpoint
+ "'"
+ ", clientId='"
+ clientId
+ "'"
+ ", clientSecret=[REDACTED]"
+ ", refreshToken=[REDACTED]"
+ ", scopes='"
+ scopes
+ "'"
+ "}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH
* under one or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information regarding copyright
* ownership. Camunda licenses this file to you under the Apache License,
* Version 2.0; you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.camunda.connector.http.client.authentication;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.camunda.connector.api.error.ConnectorException;
import io.camunda.connector.http.client.HttpClientObjectMapperSupplier;
import io.camunda.connector.http.client.mapper.StreamingHttpResponse;
import io.camunda.connector.http.client.model.HttpMethod;
import io.camunda.connector.http.client.model.auth.OAuthRefreshTokenAuthentication;
import java.io.ByteArrayInputStream;
import java.util.Map;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

public class OAuthRefreshTokenServiceTest {

private final OAuthService oAuthService = new OAuthService();
private final ObjectMapper objectMapper = HttpClientObjectMapperSupplier.getCopy();

@Nested
class CreateOAuthRefreshTokenRequestTests {

@Test
void shouldCreateRequestWithRefreshTokenGrantType() {
var auth =
new OAuthRefreshTokenAuthentication(
"https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
"clientId",
"clientSecret",
"theRefreshToken",
"https://graph.microsoft.com/.default");

var request = oAuthService.createOAuthRefreshTokenRequestFrom(auth);

assertThat(request.getUrl())
.isEqualTo("https://login.microsoftonline.com/tenant/oauth2/v2.0/token");
assertThat(request.getMethod()).isEqualTo(HttpMethod.POST);
assertThat(request.getHeaders().orElse(Map.of()))
.containsEntry("Content-Type", "application/x-www-form-urlencoded");
assertThat((Map) request.getBody()).containsEntry("grant_type", "refresh_token");
assertThat((Map) request.getBody()).containsEntry("client_id", "clientId");
assertThat((Map) request.getBody()).containsEntry("client_secret", "clientSecret");
assertThat((Map) request.getBody()).containsEntry("refresh_token", "theRefreshToken");
assertThat((Map) request.getBody())
.containsEntry("scope", "https://graph.microsoft.com/.default");
}

@Test
void shouldOmitClientSecretWhenBlank() {
var auth =
new OAuthRefreshTokenAuthentication(
"https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
"clientId",
null,
"theRefreshToken",
null);

var request = oAuthService.createOAuthRefreshTokenRequestFrom(auth);

assertThat((Map) request.getBody()).doesNotContainKey("client_secret");
assertThat((Map) request.getBody()).doesNotContainKey("scope");
assertThat((Map) request.getBody()).containsEntry("grant_type", "refresh_token");
assertThat((Map) request.getBody()).containsEntry("client_id", "clientId");
assertThat((Map) request.getBody()).containsEntry("refresh_token", "theRefreshToken");
}
}

@Nested
class ExtractTokenFromRefreshTokenResponseTests {

@Test
void shouldReturnToken_whenResponseContainsAccessToken() throws JsonProcessingException {
var body =
Map.of(
"access_token", "myAccessToken",
"token_type", "Bearer",
"expires_in", 3600);
String json = objectMapper.writeValueAsString(body);
var response =
new StreamingHttpResponse(200, null, null, new ByteArrayInputStream(json.getBytes()));

var tokenResponse = oAuthService.extractTokenFromRefreshTokenResponse(response);

assertThat(tokenResponse.accessToken()).isEqualTo("myAccessToken");
assertThat(tokenResponse.expiresInSeconds()).hasValue(3600);
}

@Test
void shouldThrowInvalidGrantException_whenResponseContainsInvalidGrant() {
String body =
"{\"error\":\"invalid_grant\",\"error_description\":\"AADSTS70008: Refresh token expired.\"}";
var response =
new StreamingHttpResponse(400, null, null, new ByteArrayInputStream(body.getBytes()));

assertThatThrownBy(() -> oAuthService.extractTokenFromRefreshTokenResponse(response))
.isInstanceOf(ConnectorException.class)
.hasMessageContaining("re-authorization is required")
.hasMessageContaining("AADSTS70008");
}

@Test
void shouldThrowInteractionRequiredException_whenResponseContainsInteractionRequired() {
String body =
"{\"error\":\"interaction_required\",\"error_description\":\"User must re-authenticate.\"}";
var response =
new StreamingHttpResponse(400, null, null, new ByteArrayInputStream(body.getBytes()));

assertThatThrownBy(() -> oAuthService.extractTokenFromRefreshTokenResponse(response))
.isInstanceOf(ConnectorException.class)
.hasMessageContaining("re-authorization is required")
.hasMessageContaining("User must re-authenticate");
}

@Test
void shouldThrowGenericOAuthError_whenResponseContainsUnknownError() {
String body =
"{\"error\":\"unauthorized_client\",\"error_description\":\"Client not allowed.\"}";
var response =
new StreamingHttpResponse(400, null, null, new ByteArrayInputStream(body.getBytes()));

assertThatThrownBy(() -> oAuthService.extractTokenFromRefreshTokenResponse(response))
.isInstanceOf(ConnectorException.class)
.hasMessageContaining("unauthorized_client");
}

@Test
void shouldThrowOAuthTokenError_whenResponseHasNoAccessToken() {
String body = "{\"scope\":\"read\",\"token_type\":\"Bearer\"}";
var response =
new StreamingHttpResponse(200, null, null, new ByteArrayInputStream(body.getBytes()));

assertThatThrownBy(() -> oAuthService.extractTokenFromRefreshTokenResponse(response))
.isInstanceOf(ConnectorException.class)
.hasMessageContaining("access_token");
}
}
}
Loading
Loading