diff --git a/README.md b/README.md index 2a0e55e..be30cfd 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ Once your web app is running, you can run the TCK against this webapp: FACEBOOK_CLIENT_SECRET= \ mvn clean -Prun-ITs verify +**NOTE:** If you are running against in Okta application you will need to include the following environment variables: + + STORMPATH_TCK_VALIDATE_JWT_URL=https://dev-123456.oktapreview.com/oauth2//v1/keys + STORMPATH_TCK_EMAIL_DOMAIN= + + This will run all tests against the targeted webapp. NOTE: The 3 environment variables shown above are *required* in order to run the TCK. diff --git a/pom.xml b/pom.xml index 78e6f3b..98611c7 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,7 @@ localhost 8080 v100,json,html + stormpath_only @@ -197,6 +198,7 @@ ${stormpath.tck.webapp.port} ${failsafe.groups} + ${failsafe.excludedGroups} diff --git a/src/main/groovy/com/stormpath/tck/AbstractIT.groovy b/src/main/groovy/com/stormpath/tck/AbstractIT.groovy index dd08b96..1a2ae48 100644 --- a/src/main/groovy/com/stormpath/tck/AbstractIT.groovy +++ b/src/main/groovy/com/stormpath/tck/AbstractIT.groovy @@ -50,6 +50,7 @@ abstract class AbstractIT { static final private String webappUrlPortSuffix = toPortSuffix(webappUrlScheme, webappUrlPort) static final private String defaultWebappBaseUrl = "$webappUrlScheme://$webappUrlHost$webappUrlPortSuffix" static final String webappBaseUrl = getVal("STORMPATH_TCK_WEBAPP_URL", defaultWebappBaseUrl) + static final String fromEmailDomain = getVal("STORMPATH_TCK_EMAIL_DOMAIN", "stormpath.com") static final private List possibleCSRFKeys = ['_csrf', 'csrfToken', 'authenticity_token', 'st'] diff --git a/src/main/groovy/com/stormpath/tck/authentication/CookieIT.groovy b/src/main/groovy/com/stormpath/tck/authentication/CookieIT.groovy index ecf11a1..7b8a99b 100644 --- a/src/main/groovy/com/stormpath/tck/authentication/CookieIT.groovy +++ b/src/main/groovy/com/stormpath/tck/authentication/CookieIT.groovy @@ -30,6 +30,7 @@ import static com.stormpath.tck.util.TestAccount.Mode.WITHOUT_DISPOSABLE_EMAIL import static org.hamcrest.Matchers.is import static org.hamcrest.Matchers.not import static org.testng.Assert.assertEquals +import static org.testng.Assert.assertNotNull import static org.testng.Assert.assertTrue class CookieIT extends AbstractIT { @@ -68,8 +69,14 @@ class CookieIT extends AbstractIT { .extract() .response() - assertTrue(isCookieDeleted(response.detailedCookies.get("access_token"))) - assertTrue(isCookieDeleted(response.detailedCookies.get("refresh_token"))) + def accessTokenCookie = response.detailedCookies.get("access_token") + def refreshTokenCookie = response.detailedCookies.get("refresh_token") + + assertNotNull(accessTokenCookie, "Cookie 'access_token'") + assertNotNull(refreshTokenCookie, "Cookie 'refresh_token") + + assertTrue(isCookieDeleted(accessTokenCookie)) + assertTrue(isCookieDeleted(refreshTokenCookie)) } /** Reject unauthorized text/html requests with 302 to login route @@ -105,18 +112,18 @@ class CookieIT extends AbstractIT { saveCSRFAndCookies(LoginRoute) def requestSpecification = given() - .accept(ContentType.JSON) - .contentType(ContentType.JSON) - .body([ "login": account.email, "password": account.password ]) + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .body(["login": account.email, "password": account.password]) setCSRFAndCookies(requestSpecification, ContentType.JSON); def response = requestSpecification - .when() + .when() .post(LoginRoute) - .then() + .then() .statusCode(200) - .extract() + .extract() .response() def now = new Date().time @@ -127,16 +134,21 @@ class CookieIT extends AbstractIT { if (accessTokenCookie.expiryDate) { assertEquals accessTokenCookie.expiryDate.time, accessTokenTtl } else { - assertTrue accessTokenCookie.maxAge * 1000L + now - accessTokenTtl < 2000 + assertTrue accessTokenCookie.maxAge * 1000L + now - accessTokenTtl < 2000 } def refreshTokenCookie = response.detailedCookies.get("refresh_token") - def refreshTokenTtl = JwtUtils.parseJwt(refreshTokenCookie.value).getBody().getExpiration().time - // some integrations use max-age and some use expires - if (refreshTokenCookie.expiryDate) { - assertEquals refreshTokenCookie.expiryDate.time, refreshTokenTtl - } else { - assertTrue refreshTokenCookie.maxAge * 1000L + now - refreshTokenTtl < 2000 + assertNotNull(refreshTokenCookie) + + // Okta does NOT use a JWT for the refresh token + if (refreshTokenCookie.getValue().split("\\.").length == 3) { + def refreshTokenTtl = JwtUtils.parseJwt(refreshTokenCookie.value).getBody().getExpiration().time + // some integrations use max-age and some use expires + if (refreshTokenCookie.expiryDate) { + assertEquals refreshTokenCookie.expiryDate.time, refreshTokenTtl + } else { + assertTrue refreshTokenCookie.maxAge * 1000L + now - refreshTokenTtl < 2000 + } } } diff --git a/src/main/groovy/com/stormpath/tck/forgot/ChangePasswordIT.groovy b/src/main/groovy/com/stormpath/tck/forgot/ChangePasswordIT.groovy index 80c2bb0..2991594 100644 --- a/src/main/groovy/com/stormpath/tck/forgot/ChangePasswordIT.groovy +++ b/src/main/groovy/com/stormpath/tck/forgot/ChangePasswordIT.groovy @@ -194,10 +194,12 @@ class ChangePasswordIT extends AbstractIT { .then() .statusCode(200) - // TODO - will need to make this configurable for Okta - String rawChangePasswordEmail = account.getEmail("stormpath.com") + String rawChangePasswordEmail = account.getEmail(fromEmailDomain) String changePasswordHref = StringUtils.extractChangePasswordHref(rawChangePasswordEmail, "sptoken") + // sleep between requests okta rate limiting + Thread.sleep(1000) + def response = given() .accept(ContentType.HTML) .when() @@ -235,8 +237,10 @@ class ChangePasswordIT extends AbstractIT { .then() .statusCode(200) - // TODO - will need to make this configurable for Okta - String rawChangePasswordEmail = account.getEmail("stormpath.com") + // sleep between requests okta rate limiting + Thread.sleep(1000) + + String rawChangePasswordEmail = account.getEmail(fromEmailDomain) String changePasswordHref = StringUtils.extractChangePasswordHref(rawChangePasswordEmail, "sptoken") String sptoken = StringUtils.extractTokenFromHref(changePasswordHref, "sptoken") @@ -252,6 +256,9 @@ class ChangePasswordIT extends AbstractIT { .statusCode(200) .body(isEmptyOrNullString()) + // sleep between requests okta rate limiting + Thread.sleep(1000) + // Verify that the password is now the new password through a login attempt / OAuth token request given() .param("grant_type", "password") diff --git a/src/main/groovy/com/stormpath/tck/login/FacebookSocialLoginIT.groovy b/src/main/groovy/com/stormpath/tck/login/FacebookSocialLoginIT.groovy index efbbe15..2aa3f16 100644 --- a/src/main/groovy/com/stormpath/tck/login/FacebookSocialLoginIT.groovy +++ b/src/main/groovy/com/stormpath/tck/login/FacebookSocialLoginIT.groovy @@ -62,7 +62,7 @@ class FacebookSocialLoginIT extends AbstractIT { * Attempts to login with the Facebook Access Token, and expects an account object back. * @throws Exception */ - @Test(groups = ["v100", "json"]) + @Test(groups = ["v100", "json", "stormpath_only"]) void loginWithValidFacebookAccessTokenSucceeds() throws Exception { def loginJSON = ["providerData": [ "providerId": "facebook", @@ -87,7 +87,7 @@ class FacebookSocialLoginIT extends AbstractIT { * Attempts to login with an invalid access token, and should fail. * @throws Exception */ - @Test(groups = ["v100", "json"]) + @Test(groups = ["v100", "json", "stormpath_only"]) void loginWithInvalidFacebookAccessTokenFails() throws Exception { def loginJSON = ["providerData": [ "providerId": "facebook", @@ -108,7 +108,7 @@ class FacebookSocialLoginIT extends AbstractIT { * Attempts to use grant_type=stormpath_social with the Facebook Access Token, and expects an access_token back. * @throws Exception */ - @Test(groups = ["v100", "json"]) + @Test(groups = ["v100", "json", "stormpath_only"]) void loginWithGrantTypeStormpathSocialSucceeds() throws Exception { given() diff --git a/src/main/groovy/com/stormpath/tck/oauth2/Oauth2IT.groovy b/src/main/groovy/com/stormpath/tck/oauth2/Oauth2IT.groovy index 8b9e959..49e8209 100644 --- a/src/main/groovy/com/stormpath/tck/oauth2/Oauth2IT.groovy +++ b/src/main/groovy/com/stormpath/tck/oauth2/Oauth2IT.groovy @@ -32,8 +32,7 @@ import static org.hamcrest.Matchers.is import static org.hamcrest.Matchers.isEmptyOrNullString import static org.hamcrest.Matchers.not import static org.hamcrest.Matchers.nullValue -import static org.testng.Assert.assertNotEquals -import static org.testng.Assert.assertTrue +import static org.testng.Assert.* class Oauth2IT extends AbstractIT { @@ -128,7 +127,7 @@ class Oauth2IT extends AbstractIT { .extract() .path("access_token") - assertTrue(JwtUtils.extractJwtClaim(accessToken, "sub") == account.href) + assertTrue isAccountSubInClaim(account, accessToken) } /** Password grant flow with username/password and access_token cookie present @@ -142,6 +141,9 @@ class Oauth2IT extends AbstractIT { def account = createTestAccount() def cookies = createSession(account) + // sleep between requests okta rate limiting + Thread.sleep(1000) + // @formatter:off String accessToken = given() @@ -153,10 +155,10 @@ class Oauth2IT extends AbstractIT { .post(OauthRoute) .then() .spec(JsonResponseSpec.validAccessAndRefreshTokens()) - .extract() + .extract() .path("access_token") // @formatter:on - assertTrue(JwtUtils.extractJwtClaim(accessToken, "sub") == account.href) + assertTrue isAccountSubInClaim(account, accessToken) } /** Password grant flow with email/password @@ -178,7 +180,7 @@ class Oauth2IT extends AbstractIT { .extract() .path("access_token") - assertTrue(JwtUtils.extractJwtClaim(accessToken, "sub") == account.href) + assertTrue isAccountSubInClaim(account, accessToken) } /** Refresh grant flow @@ -215,7 +217,7 @@ class Oauth2IT extends AbstractIT { .path("access_token") assertNotEquals(accessToken, newAccessToken, "The new access token should not equal to the old access token") - assertTrue(JwtUtils.extractJwtClaim(accessToken, "sub") == account.href, "The access token should be a valid jwt for the test user") + assertTrue isAccountSubInClaim(account, accessToken) } /** Refresh grant flow should fail without valid refresh token @@ -310,7 +312,7 @@ class Oauth2IT extends AbstractIT { /** We shouldn't be able to use client credentials to get an access token without a API secret * @see #8 */ - @Test(groups=["v100", "json"]) + @Test(groups=["v100", "json", "client_credentials"]) void oauthClientCredentialsGrantFailsWithoutAPISecret() throws Exception { // Get API keys so we can use it for client credentials @@ -345,4 +347,9 @@ class Oauth2IT extends AbstractIT { .contentType(ContentType.JSON) .body("error", is("invalid_client")) } + + private boolean isAccountSubInClaim(def account, String jwt) { + def sub = JwtUtils.extractJwtClaim(jwt, "sub") + return account.href == sub || account.email == sub + } } diff --git a/src/main/groovy/com/stormpath/tck/util/EnvUtils.groovy b/src/main/groovy/com/stormpath/tck/util/EnvUtils.groovy index 4b20aae..d38ca83 100644 --- a/src/main/groovy/com/stormpath/tck/util/EnvUtils.groovy +++ b/src/main/groovy/com/stormpath/tck/util/EnvUtils.groovy @@ -25,13 +25,20 @@ class EnvUtils { public static final String jwtSigningKey public static final String facebookClientId public static final String facebookClientSecret + public static final String jwtSigningKeysUrl static { + jwtSigningKeysUrl = getVal("STORMPATH_TCK_VALIDATE_JWT_URL") jwtSigningKey = getVal("JWT_SIGNING_KEY") facebookClientId = getVal("FACEBOOK_CLIENT_ID") facebookClientSecret = getVal("FACEBOOK_CLIENT_SECRET") + + if (jwtSigningKeysUrl == null && jwtSigningKey == null) { + fail("One of JWT_SIGNING_KEY or STORMPATH_TCK_VALIDATE_JWT_URL environment variables is required") + } + if (jwtSigningKey == null || facebookClientId == null || facebookClientSecret == null) { - fail("JWT_SIGNING_KEY, FACEBOOK_CLIENT_ID and FACEBOOK_CLIENT_SECRET environment variables are required") + fail("FACEBOOK_CLIENT_ID and FACEBOOK_CLIENT_SECRET environment variables are required") } } diff --git a/src/main/groovy/com/stormpath/tck/util/JwtUtils.groovy b/src/main/groovy/com/stormpath/tck/util/JwtUtils.groovy index 7b5ff30..4a4d04e 100644 --- a/src/main/groovy/com/stormpath/tck/util/JwtUtils.groovy +++ b/src/main/groovy/com/stormpath/tck/util/JwtUtils.groovy @@ -15,20 +15,88 @@ */ package com.stormpath.tck.util +import groovy.json.JsonSlurper import io.jsonwebtoken.Claims import io.jsonwebtoken.Jws +import io.jsonwebtoken.JwsHeader import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SigningKeyResolver +import io.jsonwebtoken.lang.Assert +import org.apache.commons.codec.binary.Base64 + +import java.security.Key +import java.security.KeyFactory +import java.security.NoSuchAlgorithmException +import java.security.spec.InvalidKeySpecException +import java.security.spec.RSAPublicKeySpec class JwtUtils { + + static String extractJwtClaim(String jwt, String property) { - String secret = EnvUtils.jwtSigningKey - Claims claims = Jwts.parser().setSigningKey(secret.getBytes()).parseClaimsJws(jwt).getBody() - return (String) claims.get(property) + return parseJwt(jwt).getBody().get(property) } static Jws parseJwt(String jwt) { - String secret = EnvUtils.jwtSigningKey - return Jwts.parser().setSigningKey(secret.getBytes()).parseClaimsJws(jwt) + + + if (EnvUtils.jwtSigningKeysUrl) { + return Jwts.parser().setSigningKeyResolver(new URLSigningKeyResolver(EnvUtils.jwtSigningKeysUrl)).parseClaimsJws(jwt) + } + else { + String secret = EnvUtils.jwtSigningKey + return Jwts.parser().setSigningKey(secret.getBytes()).parseClaimsJws(jwt) + } + } + + private static class URLSigningKeyResolver implements SigningKeyResolver { + def json + + URLSigningKeyResolver(String keysUrl) { + def jsonSlurper = new JsonSlurper() + json = jsonSlurper.parse(new URL(keysUrl)) + } + + @Override + Key resolveSigningKey(JwsHeader header, Claims claims) { + return getKey(header) + } + + @Override + Key resolveSigningKey(JwsHeader header, String plaintext) { + return getKey(header) + } + + private Key getKey(JwsHeader header) { + String keyId = header.getKeyId() + String keyAlgorithm = header.getAlgorithm() + + if (!"RS256".equals(keyAlgorithm)) { + throw new UnsupportedOperationException("Only 'RS256' key algorithm is supported.") + } + + def key = null + for (def keyElement : json.keys) { + if (keyId.equals(keyElement.kid)) { + key = keyElement + break + } + } + Assert.notNull(key, "Key with 'kid' of "+keyId+" could not be found.") + + try { + + BigInteger modulus = new BigInteger(1, Base64.decodeBase64(key.n)) + BigInteger publicExponent = new BigInteger(1, Base64.decodeBase64(key.e)) + return KeyFactory.getInstance("RSA").generatePublic( + new RSAPublicKeySpec(modulus, publicExponent)) + + } catch (NoSuchAlgorithmException e) { + throw new UnsupportedOperationException("Failed to load key Algorithm", e) + } catch (InvalidKeySpecException e) { + throw new UnsupportedOperationException("Failed to load key", e) + } + } } }