Skip to content
This repository has been archived by the owner on Jun 16, 2021. It is now read-only.

Idea: Hacking up some changes to the TCK for Okta Support #331

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ Once your web app is running, you can run the TCK against this webapp:
FACEBOOK_CLIENT_SECRET=<Facebook secret for login tests> \
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/<as_id>/v1/keys
STORMPATH_TCK_EMAIL_DOMAIN=<your from 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.
Expand Down
2 changes: 2 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<stormpath.tck.webapp.host>localhost</stormpath.tck.webapp.host>
<stormpath.tck.webapp.port>8080</stormpath.tck.webapp.port>
<failsafe.groups>v100,json,html</failsafe.groups>
<failsafe.excludedGroups>stormpath_only</failsafe.excludedGroups>

</properties>

Expand Down Expand Up @@ -197,6 +198,7 @@
<stormpath.tck.webapp.port>${stormpath.tck.webapp.port}</stormpath.tck.webapp.port>
</systemProperties>
<groups>${failsafe.groups}</groups>
<excludedGroups>${failsafe.excludedGroups}</excludedGroups>
</configuration>
<executions>
<execution>
Expand Down
1 change: 1 addition & 0 deletions src/main/groovy/com/stormpath/tck/AbstractIT.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> possibleCSRFKeys = ['_csrf', 'csrfToken', 'authenticity_token', 'st']

Expand Down
42 changes: 27 additions & 15 deletions src/main/groovy/com/stormpath/tck/authentication/CookieIT.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
}

Expand Down
15 changes: 11 additions & 4 deletions src/main/groovy/com/stormpath/tck/forgot/ChangePasswordIT.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")

Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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()
Expand Down
23 changes: 15 additions & 8 deletions src/main/groovy/com/stormpath/tck/oauth2/Oauth2IT.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <a href="https://github.com/stormpath/stormpath-framework-tck/issues/8">#8</a>
*/
@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

Expand Down Expand Up @@ -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
}
}
9 changes: 8 additions & 1 deletion src/main/groovy/com/stormpath/tck/util/EnvUtils.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
78 changes: 73 additions & 5 deletions src/main/groovy/com/stormpath/tck/util/JwtUtils.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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<Claims> 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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nbarbettini Try this out

}
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)
}
}
}
}