fix: fix refresh token flow in REST API OAuth (#10875)
Co-authored-by: Segun Daniel Oluwadare <dodanieloluwadare@gmail.com>
This commit is contained in:
parent
be1b5a5db3
commit
aa2290405d
|
|
@ -49,6 +49,8 @@ export interface Oauth2Common {
|
|||
isTokenHeader: boolean;
|
||||
audience: string;
|
||||
resource: string;
|
||||
sendScopeWithRefreshToken: string;
|
||||
refreshTokenClientCredentialsLocation: string;
|
||||
}
|
||||
|
||||
export interface ClientCredentials extends Oauth2Common {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ interface ComponentState {
|
|||
interface ComponentProps {
|
||||
children: any;
|
||||
title: string;
|
||||
defaultIsOpen: boolean;
|
||||
defaultIsOpen?: boolean;
|
||||
}
|
||||
|
||||
type Props = ComponentProps;
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user