fix: fix refresh token flow in REST API OAuth (#10875)

Co-authored-by: Segun Daniel Oluwadare <dodanieloluwadare@gmail.com>
This commit is contained in:
Sumit Kumar 2022-02-09 09:38:58 +05:30 committed by GitHub
parent be1b5a5db3
commit aa2290405d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 198 additions and 31 deletions

View File

@ -49,6 +49,8 @@ export interface Oauth2Common {
isTokenHeader: boolean;
audience: string;
resource: string;
sendScopeWithRefreshToken: string;
refreshTokenClientCredentialsLocation: string;
}
export interface ClientCredentials extends Oauth2Common {

View File

@ -34,7 +34,7 @@ interface ComponentState {
interface ComponentProps {
children: any;
title: string;
defaultIsOpen: boolean;
defaultIsOpen?: boolean;
}
type Props = ComponentProps;

View File

@ -235,6 +235,28 @@ class DatasourceRestAPIEditor extends React.Component<Props> {
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<Props> {
);
};
renderOauth2AdvancedSettings = () => {
return (
<>
<FormInputContainer
data-replay-id={btoa("authentication.sendScopeWithRefreshToken")}
>
{this.renderDropdownControlViaFormControl(
"authentication.sendScopeWithRefreshToken",
[
{
label: "Yes",
value: true,
},
{
label: "No",
value: false,
},
],
"Send scope with refresh token",
"",
false,
"",
)}
</FormInputContainer>
<FormInputContainer
data-replay-id={btoa(
"authentication.refreshTokenClientCredentialsLocation",
)}
>
{this.renderDropdownControlViaFormControl(
"authentication.refreshTokenClientCredentialsLocation",
[
{
label: "Body",
value: "BODY",
},
{
label: "Header",
value: "HEADER",
},
],
"Send client credentials with",
"",
false,
"",
)}
</FormInputContainer>
</>
);
};
renderOauth2CommonAdvanced = () => {
return (
<>
@ -815,8 +888,12 @@ class DatasourceRestAPIEditor extends React.Component<Props> {
"",
)}
</FormInputContainer>
{!_.get(formData.authentication, "isAuthorizationHeader", true) &&
this.renderOauth2CommonAdvanced()}
<Collapsible title="Advanced Settings">
{this.renderOauth2AdvancedSettings()}
</Collapsible>
<FormInputContainer>
<AuthorizeButton
disabled={this.disableSave()}

View File

@ -92,6 +92,9 @@ const formToDatasourceAuthentication = (
isTokenHeader: authentication.isTokenHeader,
audience: authentication.audience,
resource: authentication.resource,
sendScopeWithRefreshToken: authentication.sendScopeWithRefreshToken,
refreshTokenClientCredentialsLocation:
authentication.refreshTokenClientCredentialsLocation,
};
if (isClientCredentials(authType, authentication)) {
return {
@ -176,6 +179,9 @@ const datasourceToFormAuthentication = (
isTokenHeader: !!authentication.isTokenHeader,
audience: authentication.audience || "",
resource: authentication.resource || "",
sendScopeWithRefreshToken: authentication.sendScopeWithRefreshToken || "",
refreshTokenClientCredentialsLocation:
authentication.refreshTokenClientCredentialsLocation || "",
};
if (isClientCredentials(authType, authentication)) {
return {

View File

@ -28,6 +28,13 @@ import java.util.stream.Collectors;
@AllArgsConstructor
@DocumentType(Authentication.OAUTH2)
public class OAuth2 extends AuthenticationDTO {
public enum RefreshTokenClientCredentialsLocation {
HEADER,
BODY
}
RefreshTokenClientCredentialsLocation refreshTokenClientCredentialsLocation;
public enum Type {
@JsonProperty(Authentication.CLIENT_CREDENTIALS)
CLIENT_CREDENTIALS,
@ -58,6 +65,8 @@ public class OAuth2 extends AuthenticationDTO {
Set<String> scope;
Boolean sendScopeWithRefreshToken;
String headerPrefix;
Set<Property> customTokenParameters;

View File

@ -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<OAuth2AuthorizationCode> 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<OAuth2> 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<String> getTokenBody(OAuth2 oAuth2) {
BodyInserters.FormInserter<String> 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;

View File

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

View File

@ -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"
}
]
}
]
}

View File

@ -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

View File

@ -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"),