Repository: SAP/cloud-security-xsuaa-integration
Pull request #162 (2019-10-16 14:18:38)

Test for DefaultOAuth2TokenService
Test for DefaultOAuth2TokenService plus new Exception when expires_in is missing.


Patch:
diff --git a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/Token.java b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/Token.java
index dd7361c1..2a7d0248 100644
--- a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/Token.java
+++ b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/Token.java
@@ -1,12 +1,13 @@
 package com.sap.cloud.security.xsuaa.token;
 
-import java.util.Collection;
-import java.util.Date;
-
 import org.springframework.lang.Nullable;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.userdetails.UserDetails;
 
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Date;
+
 public interface Token extends UserDetails {
 
 	static final String GRANTTYPE_CLIENTCREDENTIAL = "client_credentials";
@@ -91,7 +92,7 @@ public interface Token extends UserDetails {
 	 * in which the user is persisted. For example, users that are authenticated by
 	 * the UAA itself with a username / password combination have their origin set
 	 * to the value "uaa".
-	 *
+	 * <p>
 	 * May be null in case this JWT was not created with OAuth 2.0 client
 	 * credentials flow.
 	 *
@@ -106,7 +107,6 @@ public interface Token extends UserDetails {
 	 *
 	 * @param attributeName
 	 *            name of the attribute inside 'ext_ctx' or 'xs.user.attributes'.
-	 *
 	 * @return the attribute values array or null if there exists no such attribute.
 	 */
 	@Nullable
@@ -153,7 +153,7 @@ public interface Token extends UserDetails {
 
 	/**
 	 * Returns by default list of scopes {@link #getScopes()}.
-	 *
+	 * <p>
 	 * The default behavior can be adapted as part of
 	 * {@link com.sap.cloud.security.xsuaa.token.TokenAuthenticationConverter} class
 	 *
@@ -166,8 +166,18 @@ public interface Token extends UserDetails {
 	 * Returns date of when jwt token expires.
 	 *
 	 * @return expiration date if present
+	 * @deprecated use {@link #getExpiration()}.
 	 */
 	@Nullable
+	@Deprecated
 	Date getExpirationDate();
 
+	/**
+	 * Returns the moment in time when the token will be expired.
+	 *
+	 * @return the expiration point in time if present.
+	 */
+	@Nullable
+	Instant getExpiration();
+
 }
\ No newline at end of file
diff --git a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/XsuaaToken.java b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/XsuaaToken.java
index fffcc8ca..057e7020 100644
--- a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/XsuaaToken.java
+++ b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/XsuaaToken.java
@@ -1,21 +1,6 @@
 package com.sap.cloud.security.xsuaa.token;
 
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_CLIENT_ID;
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_EMAIL;
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_FAMILY_NAME;
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_GIVEN_NAME;
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_GRANT_TYPE;
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_ORIGIN;
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_USER_NAME;
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_ZDN;
-import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_ZONE_ID;
-
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
-
+import net.minidev.json.JSONArray;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.lang.Nullable;
@@ -24,28 +9,27 @@ import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
 import org.springframework.util.Assert;
 
-import net.minidev.json.JSONArray;
+import java.time.Instant;
+import java.util.*;
+
+import static com.sap.cloud.security.xsuaa.token.TokenClaims.*;
 
 /**
  * Custom XSUAA token implementation.
- *
+ * <p>
  * This class inherits Spring Security's standard Jwt implementation and can be
  * used interchangeably with it.
  */
 public class XsuaaToken extends Jwt implements Token {
-	private static final long serialVersionUID = -836947635254353927L;
-
-	private static final Logger logger = LoggerFactory.getLogger(XsuaaToken.class);
-
 	static final String GRANTTYPE_SAML2BEARER = "urn:ietf:params:oauth:grant-type:saml2-bearer";
 	static final String UNIQUE_USER_NAME_FORMAT = "user/%s/%s"; // user/<origin>/<logonName>
 	static final String UNIQUE_CLIENT_NAME_FORMAT = "client/%s"; // client/<clientid>
-
 	static final String CLAIM_SERVICEINSTANCEID = "serviceinstanceid";
 	static final String CLAIM_ADDITIONAL_AZ_ATTR = "az_attr";
 	static final String CLAIM_EXTERNAL_ATTR = "ext_attr";
 	static final String CLAIM_EXTERNAL_CONTEXT = "ext_ctx";
-
+	private static final long serialVersionUID = -836947635254353927L;
+	private static final Logger logger = LoggerFactory.getLogger(XsuaaToken.class);
 	private Collection<GrantedAuthority> authorities = Collections.emptyList();
 
 	/**
@@ -66,6 +50,11 @@ public class XsuaaToken extends Jwt implements Token {
 		return getExpiresAt() != null ? Date.from(getExpiresAt()) : null;
 	}
 
+	@Override
+	public Instant getExpiration() {
+		return getExpiresAt();
+	}
+
 	@Override
 	public String getPassword() {
 		return null;
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenService.java
index c1890175..1a8fc103 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenService.java
@@ -73,14 +73,25 @@ public class DefaultOAuth2TokenService extends AbstractOAuth2TokenService {
 				.lines().collect(Collectors.joining(System.lineSeparator()));
 	}
 
-	private OAuth2TokenResponse convertToOAuth2TokenResponse(Map<String, Object> accessTokenMap) {
+	private OAuth2TokenResponse convertToOAuth2TokenResponse(Map<String, Object> accessTokenMap)
+			throws OAuth2ServiceException {
 		String accessToken = getParameter(accessTokenMap, ACCESS_TOKEN);
 		String refreshToken = getParameter(accessTokenMap, REFRESH_TOKEN);
 		String expiresIn = getParameter(accessTokenMap, EXPIRES_IN);
-		return new OAuth2TokenResponse(accessToken, Long.parseLong(expiresIn),
+
+		return new OAuth2TokenResponse(accessToken, convertExpiresInToLong(expiresIn),
 				refreshToken);
 	}
 
+	private Long convertExpiresInToLong(String expiresIn) throws OAuth2ServiceException {
+		try {
+			return Long.parseLong(expiresIn);
+		} catch (NumberFormatException e) {
+			throw new OAuth2ServiceException(
+					String.format("Cannot convert expires_in from response (%s) to long", expiresIn));
+		}
+	}
+
 	private String getParameter(Map<String, Object> accessTokenMap, String key) {
 		return String.valueOf(accessTokenMap.get(key));
 	}
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenResponse.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenResponse.java
index 4305423f..126eadf3 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenResponse.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenResponse.java
@@ -4,6 +4,7 @@ import com.sap.cloud.security.xsuaa.jwt.Base64JwtDecoder;
 import com.sap.cloud.security.xsuaa.jwt.DecodedJwt;
 
 import javax.annotation.Nullable;
+import java.time.Instant;
 import java.util.Date;
 import java.util.concurrent.TimeUnit;
 
@@ -42,10 +43,26 @@ public class OAuth2TokenResponse {
 		return new Base64JwtDecoder().decode(accessToken);
 	}
 
+	/**
+	 * Returns the moment in time when the token will be expired.
+	 *
+	 * @return the expiration point in time if present.
+	 * @deprecated use {@link #getExpiredAt()}.
+	 */
+	@Deprecated
 	public Date getExpiredAtDate() {
 		return new Date(expiredTimeMillis);
 	}
 
+	/**
+	 * Returns the moment in time when the token will be expired.
+	 *
+	 * @return the expiration point in time if present.
+	 */
+	public Instant getExpiredAt() {
+		return Instant.ofEpochMilli(expiredTimeMillis);
+	}
+
 	/**
 	 * An OAuth2 refresh token. Clients typically use the refresh token to obtain a
 	 * new access token without the need for the user to authenticate again.


Test patch:
diff --git a/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/token/XsuaaTokenTest.java b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/token/XsuaaTokenTest.java
index 30710f2e..e156a3fa 100644
--- a/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/token/XsuaaTokenTest.java
+++ b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/token/XsuaaTokenTest.java
@@ -74,6 +74,7 @@ public class XsuaaTokenTest {
 		assertThat(token.getAuthorities().size(), is(0));
 		assertThat(token.isEnabled(), is(false));
 		assertThat(token.getExpirationDate(), is(JwtGenerator.NO_EXPIRE_DATE));
+		assertThat(token.getExpiration(), is(JwtGenerator.NO_EXPIRE_DATE.toInstant()));
 		assertThat(token.getAdditionalAuthAttribute("any"), nullValue());
 	}
 
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java
new file mode 100644
index 00000000..dea64be8
--- /dev/null
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java
@@ -0,0 +1,116 @@
+package com.sap.cloud.security.xsuaa.client;
+
+import com.sap.cloud.security.xsuaa.http.HttpHeaders;
+import com.sap.cloud.security.xsuaa.http.HttpHeadersFactory;
+import org.apache.http.HttpStatus;
+import org.apache.http.HttpVersion;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.message.BasicStatusLine;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class DefaultOAuth2TokenServiceTest {
+
+	private static final String ACCESS_TOKEN = "abc123";
+	private static final String REFRESH_TOKEN = "def456";
+	private static final String VALID_JSON_RESPONSE = String
+			.format("{expires_in: 1234, access_token: %s, refresh_token: %s}",
+					ACCESS_TOKEN, REFRESH_TOKEN);
+
+	private CloseableHttpClient mockHttpClient;
+	private DefaultOAuth2TokenService cut;
+
+	@Before
+	public void setup() {
+		mockHttpClient = Mockito.mock(CloseableHttpClient.class);
+		cut = new DefaultOAuth2TokenService(mockHttpClient);
+	}
+
+	@Test
+	public void emptyResponse_throwsException() throws IOException {
+		CloseableHttpResponse response = createHttpResponse("{}");
+		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
+
+		assertThatThrownBy(() -> requestAccessToken())
+				.isInstanceOf(OAuth2ServiceException.class)
+				.hasMessageContaining("expires_in");
+	}
+
+	@Test
+	public void httpResponseWithExpiresIn_yieldsExpiresInTokenResponse() throws IOException {
+		CloseableHttpResponse response = createHttpResponse(VALID_JSON_RESPONSE);
+		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
+
+		OAuth2TokenResponse re = requestAccessToken();
+
+		assertThat(re.getExpiredAtDate()).isNotNull();
+	}
+
+	@Test
+	public void httpResponseWithToken_yieldsTokenInTokenResponse() throws IOException {
+		CloseableHttpResponse response = createHttpResponse(VALID_JSON_RESPONSE);
+		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
+
+		OAuth2TokenResponse re = requestAccessToken();
+
+		assertThat(re.getAccessToken()).isEqualTo(ACCESS_TOKEN);
+	}
+
+	@Test
+	public void httpResponseWithRefreshToken_yieldsTokenInTokenResponse() throws IOException {
+		CloseableHttpResponse response = createHttpResponse(VALID_JSON_RESPONSE);
+		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
+
+		OAuth2TokenResponse re = requestAccessToken();
+
+		assertThat(re.getRefreshToken()).isEqualTo(REFRESH_TOKEN);
+	}
+
+	@Test
+	public void httpResponseWithErrorStatusCode_throwsExceptionContainingMessage() throws IOException {
+		String unauthorizedResponseText = "Unauthorized!";
+		CloseableHttpResponse response = createHttpResponse(unauthorizedResponseText, HttpStatus.SC_UNAUTHORIZED);
+		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
+
+		assertThatThrownBy(() -> requestAccessToken())
+				.isInstanceOf(OAuth2ServiceException.class)
+				.hasMessageContaining(unauthorizedResponseText);
+	}
+
+	private OAuth2TokenResponse requestAccessToken() throws OAuth2ServiceException {
+		URI tokenEndpointUri = URI.create("https://subdomain.myauth.server.com/oauth/token");
+		HttpHeaders withoutAuthorizationHeader = new HttpHeadersFactory().createWithoutAuthorizationHeader();
+		Map<String, String> parameters = Collections.emptyMap();
+		return cut.requestAccessToken(tokenEndpointUri, withoutAuthorizationHeader, parameters);
+	}
+
+	private CloseableHttpResponse createHttpResponse(String stringResponse, int statusCode) {
+		CloseableHttpResponse response = Mockito.mock(CloseableHttpResponse.class);
+		when(response.getStatusLine()).thenReturn(new BasicStatusLine(HttpVersion.HTTP_1_1, statusCode, null));
+		when(response.getEntity()).thenReturn(new StringEntity(stringResponse, ContentType.APPLICATION_JSON));
+		return response;
+	}
+
+	private CloseableHttpResponse createHttpResponse(String responseAsJson) {
+		return createHttpResponse(responseAsJson, HttpStatus.SC_OK);
+	}
+
+}
\ No newline at end of file
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenResponseTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenResponseTest.java
index b58d32f7..a9fe433b 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenResponseTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenResponseTest.java
@@ -1,13 +1,14 @@
 package com.sap.cloud.security.xsuaa.client;
 
-import static org.hamcrest.CoreMatchers.allOf;
-import static org.junit.Assert.assertThat;
+import org.hamcrest.number.OrderingComparison;
+import org.junit.Test;
 
+import java.time.Instant;
 import java.util.Date;
 import java.util.concurrent.TimeUnit;
 
-import org.hamcrest.number.OrderingComparison;
-import org.junit.Test;
+import static org.hamcrest.CoreMatchers.allOf;
+import static org.junit.Assert.assertThat;
 
 public class OAuth2TokenResponseTest {
 
@@ -22,4 +23,21 @@ public class OAuth2TokenResponseTest {
 		assertThat(accessToken.getExpiredAtDate(), allOf(OrderingComparison.greaterThanOrEqualTo(minExpireDate),
 				OrderingComparison.lessThanOrEqualTo(maxExpireDate)));
 	}
+
+	@Test
+	public void getExpiredFromAccessToken() {
+		long expireInSeconds = 47299;
+		Instant minExpireDate = getCurrentInstant().plusSeconds(expireInSeconds);
+
+		OAuth2TokenResponse accessToken = new OAuth2TokenResponse(null, expireInSeconds, null);
+
+		Instant maxExpireDate = getCurrentInstant().plusSeconds(expireInSeconds);
+
+		assertThat(accessToken.getExpiredAt(), allOf(OrderingComparison.greaterThanOrEqualTo(minExpireDate),
+				OrderingComparison.lessThanOrEqualTo(maxExpireDate)));
+	}
+
+	private Instant getCurrentInstant() {
+		return Instant.ofEpochMilli(System.currentTimeMillis());
+	}
 }