From aa2290405ddcddee3bc0c21d013875b953efb015 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Wed, 9 Feb 2022 09:38:58 +0530 Subject: [PATCH] fix: fix refresh token flow in REST API OAuth (#10875) Co-authored-by: Segun Daniel Oluwadare --- .../src/entities/Datasource/RestAPIForm.ts | 2 + .../Editor/DataSourceEditor/Collapsible.tsx | 2 +- .../RestAPIDatasourceForm.tsx | 77 +++++++++++++++ .../RestAPIDatasourceFormTransformer.ts | 6 ++ .../com/appsmith/external/models/OAuth2.java | 9 ++ .../connections/OAuth2AuthorizationCode.java | 95 +++++++++++++------ .../connections/OAuth2ClientCredentials.java | 1 + .../src/main/resources/form.json | 34 +++++++ .../ce/AuthenticationServiceCEImpl.java | 1 + .../ExamplesOrganizationClonerTests.java | 2 + 10 files changed, 198 insertions(+), 31 deletions(-) diff --git a/app/client/src/entities/Datasource/RestAPIForm.ts b/app/client/src/entities/Datasource/RestAPIForm.ts index 1a8829e85d..90fb5777ba 100644 --- a/app/client/src/entities/Datasource/RestAPIForm.ts +++ b/app/client/src/entities/Datasource/RestAPIForm.ts @@ -49,6 +49,8 @@ export interface Oauth2Common { isTokenHeader: boolean; audience: string; resource: string; + sendScopeWithRefreshToken: string; + refreshTokenClientCredentialsLocation: string; } export interface ClientCredentials extends Oauth2Common { diff --git a/app/client/src/pages/Editor/DataSourceEditor/Collapsible.tsx b/app/client/src/pages/Editor/DataSourceEditor/Collapsible.tsx index 39cba64939..25944a1363 100644 --- a/app/client/src/pages/Editor/DataSourceEditor/Collapsible.tsx +++ b/app/client/src/pages/Editor/DataSourceEditor/Collapsible.tsx @@ -34,7 +34,7 @@ interface ComponentState { interface ComponentProps { children: any; title: string; - defaultIsOpen: boolean; + defaultIsOpen?: boolean; } type Props = ComponentProps; diff --git a/app/client/src/pages/Editor/DataSourceEditor/RestAPIDatasourceForm.tsx b/app/client/src/pages/Editor/DataSourceEditor/RestAPIDatasourceForm.tsx index 7d50784606..e0d36ef6c2 100644 --- a/app/client/src/pages/Editor/DataSourceEditor/RestAPIDatasourceForm.tsx +++ b/app/client/src/pages/Editor/DataSourceEditor/RestAPIDatasourceForm.tsx @@ -235,6 +235,28 @@ class DatasourceRestAPIEditor extends React.Component { this.props.change("authentication.isAuthorizationHeader", true); } } + + if (_.get(authentication, "grantType") === GrantType.AuthorizationCode) { + if ( + _.get(authentication, "sendScopeWithRefreshToken") === undefined || + _.get(authentication, "sendScopeWithRefreshToken") === "" + ) { + this.props.change("authentication.sendScopeWithRefreshToken", false); + } + } + + if (_.get(authentication, "grantType") === GrantType.AuthorizationCode) { + if ( + _.get(authentication, "refreshTokenClientCredentialsLocation") === + undefined || + _.get(authentication, "refreshTokenClientCredentialsLocation") === "" + ) { + this.props.change( + "authentication.refreshTokenClientCredentialsLocation", + "BODY", + ); + } + } }; disableSave = (): boolean => { @@ -712,6 +734,57 @@ class DatasourceRestAPIEditor extends React.Component { ); }; + renderOauth2AdvancedSettings = () => { + return ( + <> + + {this.renderDropdownControlViaFormControl( + "authentication.sendScopeWithRefreshToken", + [ + { + label: "Yes", + value: true, + }, + { + label: "No", + value: false, + }, + ], + "Send scope with refresh token", + "", + false, + "", + )} + + + {this.renderDropdownControlViaFormControl( + "authentication.refreshTokenClientCredentialsLocation", + [ + { + label: "Body", + value: "BODY", + }, + { + label: "Header", + value: "HEADER", + }, + ], + "Send client credentials with", + "", + false, + "", + )} + + + ); + }; + renderOauth2CommonAdvanced = () => { return ( <> @@ -815,8 +888,12 @@ class DatasourceRestAPIEditor extends React.Component { "", )} + {!_.get(formData.authentication, "isAuthorizationHeader", true) && this.renderOauth2CommonAdvanced()} + + {this.renderOauth2AdvancedSettings()} + scope; + Boolean sendScopeWithRefreshToken; + String headerPrefix; Set customTokenParameters; diff --git a/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/connections/OAuth2AuthorizationCode.java b/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/connections/OAuth2AuthorizationCode.java index aeb4111175..4488bd9a60 100644 --- a/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/connections/OAuth2AuthorizationCode.java +++ b/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/connections/OAuth2AuthorizationCode.java @@ -10,6 +10,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.bson.internal.Base64; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -31,6 +32,10 @@ import java.time.Duration; import java.time.Instant; import java.util.Map; +import static com.appsmith.external.models.OAuth2.RefreshTokenClientCredentialsLocation.BODY; +import static com.appsmith.external.models.OAuth2.RefreshTokenClientCredentialsLocation.HEADER; +import static org.apache.commons.lang3.StringUtils.isBlank; + @Setter @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -45,6 +50,25 @@ public class OAuth2AuthorizationCode extends APIConnection implements UpdatableC private Object tokenResponse; private static final int MAX_IN_MEMORY_SIZE = 10 * 1024 * 1024; // 10 MB + private static void updateConnection(OAuth2AuthorizationCode connection, OAuth2 token) { + connection.setToken(token.getAuthenticationResponse().getToken()); + connection.setHeader(token.getIsTokenHeader()); + connection.setHeaderPrefix(token.getHeaderPrefix()); + connection.setExpiresAt(token.getAuthenticationResponse().getExpiresAt()); + connection.setRefreshToken(token.getAuthenticationResponse().getRefreshToken()); + connection.setTokenResponse(token.getAuthenticationResponse().getTokenResponse()); + } + + private static boolean isAuthenticationResponseValid(OAuth2 oAuth2) { + if (oAuth2.getAuthenticationResponse() == null + || isBlank(oAuth2.getAuthenticationResponse().getToken()) + || isExpired(oAuth2)) { + return false; + } + + return true; + } + public static Mono create(OAuth2 oAuth2) { if (oAuth2 == null) { return Mono.empty(); @@ -52,41 +76,46 @@ public class OAuth2AuthorizationCode extends APIConnection implements UpdatableC // Create OAuth2Connection OAuth2AuthorizationCode connection = new OAuth2AuthorizationCode(); - return Mono.just(oAuth2) - // Validate existing token - .filter(x -> x.getAuthenticationResponse() != null - && x.getAuthenticationResponse().getToken() != null - && !x.getAuthenticationResponse().getToken().isBlank()) - .filter(x -> x.getAuthenticationResponse().getExpiresAt() != null) - .filter(x -> { - Instant now = connection.clock.instant(); - Instant expiresAt = x.getAuthenticationResponse().getExpiresAt(); + if (!isAuthenticationResponseValid(oAuth2)) { + return connection.generateOAuth2Token(oAuth2) + .flatMap(token -> { + updateConnection(connection, token); + return Mono.just(connection); + }); + } - return now.isBefore(expiresAt.minus(Duration.ofMinutes(1))); - }) - // If invalid, regenerate token - .switchIfEmpty(connection.generateOAuth2Token(oAuth2)) - // Store valid token - .flatMap(token -> { - connection.setToken(token.getAuthenticationResponse().getToken()); - connection.setHeader(token.getIsTokenHeader()); - connection.setHeaderPrefix(token.getHeaderPrefix()); - connection.setExpiresAt(token.getAuthenticationResponse().getExpiresAt()); - connection.setRefreshToken(token.getAuthenticationResponse().getRefreshToken()); - connection.setTokenResponse(token.getAuthenticationResponse().getTokenResponse()); - return Mono.just(connection); - }); + updateConnection(connection, oAuth2); + return Mono.just(connection); + } + + private static boolean isExpired(OAuth2 oAuth2) { + if (oAuth2.getAuthenticationResponse().getExpiresAt() == null) { + return true; + } + + OAuth2AuthorizationCode connection = new OAuth2AuthorizationCode(); + Instant now = connection.clock.instant(); + Instant expiresAt = oAuth2.getAuthenticationResponse().getExpiresAt(); + + return now.isAfter(expiresAt.minus(Duration.ofMinutes(1))); } private Mono generateOAuth2Token(OAuth2 oAuth2) { - // Webclient - WebClient webClient = WebClient.builder() + WebClient.Builder webClientBuilder = WebClient.builder() .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .exchangeStrategies(ExchangeStrategies .builder() .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(MAX_IN_MEMORY_SIZE)) - .build()) - .build(); + .build()); + + if (HEADER.equals(oAuth2.getRefreshTokenClientCredentialsLocation())) { + byte[] clientCredentials = (oAuth2.getClientId() + ":" + oAuth2.getClientSecret()).getBytes(); + final String authorizationHeader = "Basic " + Base64.encode(clientCredentials); + webClientBuilder.defaultHeader("Authorization", authorizationHeader); + } + + // Webclient + WebClient webClient = webClientBuilder.build(); // Send oauth2 generic request return webClient @@ -168,9 +197,14 @@ public class OAuth2AuthorizationCode extends APIConnection implements UpdatableC private BodyInserters.FormInserter getTokenBody(OAuth2 oAuth2) { BodyInserters.FormInserter body = BodyInserters .fromFormData(Authentication.GRANT_TYPE, Authentication.REFRESH_TOKEN) - .with(Authentication.CLIENT_ID, oAuth2.getClientId()) - .with(Authentication.CLIENT_SECRET, oAuth2.getClientSecret()) .with(Authentication.REFRESH_TOKEN, oAuth2.getAuthenticationResponse().getRefreshToken()); + + if (BODY.equals(oAuth2.getRefreshTokenClientCredentialsLocation()) + || oAuth2.getRefreshTokenClientCredentialsLocation() == null) { + body.with(Authentication.CLIENT_ID, oAuth2.getClientId()) + .with(Authentication.CLIENT_SECRET, oAuth2.getClientSecret()); + } + // Adding optional audience parameter if (!StringUtils.isEmpty(oAuth2.getAudience())) { body.with(Authentication.AUDIENCE, oAuth2.getAudience()); @@ -180,7 +214,8 @@ public class OAuth2AuthorizationCode extends APIConnection implements UpdatableC body.with(Authentication.RESOURCE, oAuth2.getResource()); } // Optionally add scope, if applicable - if (!CollectionUtils.isEmpty(oAuth2.getScope())) { + if (!CollectionUtils.isEmpty(oAuth2.getScope()) + && (Boolean.TRUE.equals(oAuth2.getSendScopeWithRefreshToken()) || oAuth2.getSendScopeWithRefreshToken() == null)) { body.with(Authentication.SCOPE, StringUtils.collectionToDelimitedString(oAuth2.getScope(), " ")); } return body; diff --git a/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/connections/OAuth2ClientCredentials.java b/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/connections/OAuth2ClientCredentials.java index d859efbf5d..1954f3c5e1 100644 --- a/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/connections/OAuth2ClientCredentials.java +++ b/app/server/appsmith-plugins/restApiPlugin/src/main/java/com/external/connections/OAuth2ClientCredentials.java @@ -165,6 +165,7 @@ public class OAuth2ClientCredentials extends APIConnection implements UpdatableC .fromFormData(Authentication.GRANT_TYPE, Authentication.CLIENT_CREDENTIALS) .with(Authentication.CLIENT_ID, oAuth2.getClientId()) .with(Authentication.CLIENT_SECRET, oAuth2.getClientSecret()); + // Adding optional audience parameter if (!StringUtils.isEmpty(oAuth2.getAudience())) { body.with(Authentication.AUDIENCE, oAuth2.getAudience()); diff --git a/app/server/appsmith-plugins/restApiPlugin/src/main/resources/form.json b/app/server/appsmith-plugins/restApiPlugin/src/main/resources/form.json index e90447c7a0..69418d1623 100644 --- a/app/server/appsmith-plugins/restApiPlugin/src/main/resources/form.json +++ b/app/server/appsmith-plugins/restApiPlugin/src/main/resources/form.json @@ -194,6 +194,40 @@ "comparison": "NOT_EQUALS", "value": "oAuth2" } + }, + { + "label": "Send scope with refresh token", + "configProperty": "datasourceConfiguration.authentication.sendScopeWithRefreshToken", + "controlType": "DROP_DOWN", + "isRequired": true, + "initialValue": false, + "options": [ + { + "label": "Yes", + "value": true + }, + { + "label": "No", + "value": false + } + ] + }, + { + "label": "Send client credentials with (on refresh token)", + "configProperty": "datasourceConfiguration.authentication.refreshTokenClientCredentialsLocation", + "controlType": "DROP_DOWN", + "isRequired": true, + "initialValue": "BODY", + "options": [ + { + "label": "Body", + "value": "BODY" + }, + { + "label": "Header", + "value": "HEADER" + } + ] } ] } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/AuthenticationServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/AuthenticationServiceCEImpl.java index 56dd3a8197..f4ccc5cacf 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/AuthenticationServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/AuthenticationServiceCEImpl.java @@ -231,6 +231,7 @@ public class AuthenticationServiceCEImpl implements AuthenticationServiceCE { expiresAt = Instant.ofEpochSecond(Long.valueOf((Integer) expiresAtResponse)); } else if (expiresInResponse != null) { expiresAt = issuedAt.plusSeconds(Long.valueOf((Integer) expiresInResponse)); + } authenticationResponse.setExpiresAt(expiresAt); // Replacing with returned scope instead diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ExamplesOrganizationClonerTests.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ExamplesOrganizationClonerTests.java index 93f0a39859..60c220f528 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ExamplesOrganizationClonerTests.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/ExamplesOrganizationClonerTests.java @@ -854,6 +854,7 @@ public class ExamplesOrganizationClonerTests { DatasourceConfiguration dc2 = new DatasourceConfiguration(); ds2.setDatasourceConfiguration(dc2); dc2.setAuthentication(new OAuth2( + OAuth2.RefreshTokenClientCredentialsLocation.BODY, OAuth2.Type.CLIENT_CREDENTIALS, true, true, @@ -863,6 +864,7 @@ public class ExamplesOrganizationClonerTests { "access token url", "scope", Set.of("scope1", "scope2", "scope3"), + true, "header prefix", Set.of( new Property("custom token param 1", "custom token param value 1"),