diff --git a/oauth-common/src/main/java/io/strimzi/kafka/oauth/common/TokenInfo.java b/oauth-common/src/main/java/io/strimzi/kafka/oauth/common/TokenInfo.java index 20710c1c..0c18d1e1 100644 --- a/oauth-common/src/main/java/io/strimzi/kafka/oauth/common/TokenInfo.java +++ b/oauth-common/src/main/java/io/strimzi/kafka/oauth/common/TokenInfo.java @@ -85,8 +85,8 @@ public TokenInfo(JsonNode payload, String token, String principal, Set g payload.has(SCOPE) ? payload.get(SCOPE).asText() : null, principal, groups, - payload.has(IAT) ? payload.get(IAT).asInt(0) * 1000L : 0L, - payload.get(EXP).asInt(0) * 1000L); + payload.has(IAT) ? payload.get(IAT).asLong(0) * 1000L : 0L, + payload.get(EXP).asLong(0) * 1000L); if (!(payload instanceof ObjectNode)) { throw new IllegalArgumentException("Unexpected JSON Node type (not ObjectNode): " + payload.getClass()); diff --git a/oauth-common/src/main/java/io/strimzi/kafka/oauth/common/TokenIntrospection.java b/oauth-common/src/main/java/io/strimzi/kafka/oauth/common/TokenIntrospection.java index f1b4fa5e..f2e5a7ed 100644 --- a/oauth-common/src/main/java/io/strimzi/kafka/oauth/common/TokenIntrospection.java +++ b/oauth-common/src/main/java/io/strimzi/kafka/oauth/common/TokenIntrospection.java @@ -78,7 +78,7 @@ public static void debugLogJWT(Logger log, String token) { if (expires == null) { log.debug("Access token has no expiry set."); } else { - log.debug("Access token expires at (UTC): " + (expires.isNumber() ? (LocalDateTime.ofEpochSecond(expires.asInt(), 0, ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME)) : ("invalid value: [" + expires.asText() + "]"))); + log.debug("Access token expires at (UTC): " + (expires.isNumber() ? (LocalDateTime.ofEpochSecond(expires.asLong(), 0, ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME)) : ("invalid value: [" + expires.asText() + "]"))); } } catch (Exception e) { log.debug("[IGNORED] Failed to parse JWT token's payload", e); diff --git a/oauth-common/src/main/java/io/strimzi/kafka/oauth/validator/JWTSignatureValidator.java b/oauth-common/src/main/java/io/strimzi/kafka/oauth/validator/JWTSignatureValidator.java index df205044..c06d2e35 100644 --- a/oauth-common/src/main/java/io/strimzi/kafka/oauth/validator/JWTSignatureValidator.java +++ b/oauth-common/src/main/java/io/strimzi/kafka/oauth/validator/JWTSignatureValidator.java @@ -476,7 +476,7 @@ public TokenInfo validate(String token) { if (exp == null) { throw new TokenValidationException("Token validation failed: Expiry not set"); } - long expiresMillis = exp.asInt(0) * 1000L; + long expiresMillis = exp.asLong(0) * 1000L; if (System.currentTimeMillis() > expiresMillis) { throw new TokenExpiredException("Token expired at: " + expiresMillis + " (" + TimeUtil.formatIsoDateTimeUTC(expiresMillis) + " UTC)"); diff --git a/testsuite/mock-oauth-server/src/main/java/io/strimzi/testsuite/oauth/server/AuthServerRequestHandler.java b/testsuite/mock-oauth-server/src/main/java/io/strimzi/testsuite/oauth/server/AuthServerRequestHandler.java index abd56fe8..7d716ea3 100644 --- a/testsuite/mock-oauth-server/src/main/java/io/strimzi/testsuite/oauth/server/AuthServerRequestHandler.java +++ b/testsuite/mock-oauth-server/src/main/java/io/strimzi/testsuite/oauth/server/AuthServerRequestHandler.java @@ -27,8 +27,13 @@ import org.slf4j.LoggerFactory; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -313,7 +318,19 @@ private void processTokenRequest(HttpServerRequest req, Mode mode) { UserInfo userInfo = username != null ? verticle.getUsers().get(username) : null; long expiresIn = userInfo != null && userInfo.expiresIn != null ? userInfo.expiresIn : EXPIRES_IN_SECONDS; - String accessToken = createSignedAccessToken(clientId, username, expiresIn); + Map extraClaimsMap = getExtraClaimsMap(form); + + // See if EXP (expiresAtEpochSeconds) override is present + String exp = extraClaimsMap.get("exp"); + if (exp != null) { + try { + expiresIn = (Long.parseLong(exp) * 1000 - System.currentTimeMillis()) / 1000; + } catch (NumberFormatException e) { + log.error("Failed to parse 'exp'': ", e); + } + } + + String accessToken = createSignedAccessToken(clientId, username, expiresIn, extraClaimsMap); String refreshToken = createRefreshToken(clientId, username); JsonObject result = new JsonObject(); result.put("access_token", accessToken); @@ -330,6 +347,33 @@ private void processTokenRequest(HttpServerRequest req, Mode mode) { }); } + /** + * Extracts the extra claims from the form + * + * @param form Attributes from the submitted form + * @return A map of key value pairs representing the extra claims + */ + private static Map getExtraClaimsMap(MultiMap form) { + + // Make a copy of the form entries and remove the ones that are not the extra claims + List> formEntries = new ArrayList<>(form.entries()); + Iterator> it = formEntries.iterator(); + HashSet forRemoval = new HashSet<>(Arrays.asList( + "grant_type", "client_id", "client_assertion", "client_assertion_type", "username", + "password", "refresh_token")); + + while (it.hasNext()) { + Map.Entry e = it.next(); + if (forRemoval.contains(e.getKey().toLowerCase(Locale.ENGLISH))) { + it.remove(); + } + } + + Map formMap = formEntries.stream() + .collect(Collectors.toMap(entry -> entry.getKey().toLowerCase(Locale.ENGLISH), Map.Entry::getValue, (existing, replacement) -> replacement)); + return formMap; + } + private boolean processUnauthorized(HttpServerRequest req, String grantType, String username, String clientId) { if (!supportedGrantType(grantType, clientId)) { sendResponse(req, UNAUTHORIZED); @@ -519,7 +563,7 @@ private boolean isExpired(int expiryTimeSeconds) { return System.currentTimeMillis() > expiryTimeSeconds * 1000L; } - private String createSignedAccessToken(String clientId, String username, long expiresIn) throws JOSEException, NoSuchAlgorithmException { + private String createSignedAccessToken(String clientId, String username, long expiresIn, Map claims) throws JOSEException, NoSuchAlgorithmException { // Create RSA-signer with the private key JWSSigner signer = new RSASSASigner(verticle.getSigKey()); @@ -530,6 +574,15 @@ private String createSignedAccessToken(String clientId, String username, long ex .issuer("https://mockoauth:8090") .expirationTime(new Date(System.currentTimeMillis() + expiresIn * 1000)); + for (Map.Entry entry : claims.entrySet()) { + if ("exp".equalsIgnoreCase(entry.getKey())) { + // override the expiration time + builder.expirationTime(new Date(Long.parseLong(entry.getValue()) * 1000)); + continue; + } + builder.claim(entry.getKey(), entry.getValue()); + } + if (clientId != null) { builder.claim("clientId", clientId); } diff --git a/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/MockOAuthTests.java b/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/MockOAuthTests.java index da08f80b..af87cdbf 100644 --- a/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/MockOAuthTests.java +++ b/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/MockOAuthTests.java @@ -13,6 +13,7 @@ import io.strimzi.testsuite.oauth.mockoauth.JWKSKeyUseTest; import io.strimzi.testsuite.oauth.mockoauth.JaasClientConfigTest; import io.strimzi.testsuite.oauth.mockoauth.JaasServerConfigTest; +import io.strimzi.testsuite.oauth.mockoauth.JwtExtractTest; import io.strimzi.testsuite.oauth.mockoauth.KeycloakAuthorizerTest; import io.strimzi.testsuite.oauth.mockoauth.PasswordAuthAndPrincipalExtractionTest; import io.strimzi.testsuite.oauth.mockoauth.RetriesTests; @@ -87,6 +88,9 @@ public void runTests() throws Exception { logStart("JWKSKeyUseTest :: JWKS KeyUse Test"); new JWKSKeyUseTest().doTest(); + logStart("JwtExtractTest :: JWT 'exp' attribute overflow Test"); + new JwtExtractTest().doTest(); + logStart("JaasClientConfigTest :: Client Configuration Tests"); new JaasClientConfigTest().doTest(); diff --git a/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/Common.java b/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/Common.java index e3488c06..900d1b1d 100644 --- a/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/Common.java +++ b/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/Common.java @@ -35,6 +35,8 @@ import java.util.Properties; import java.util.Set; +import static io.strimzi.kafka.oauth.common.OAuthAuthenticator.base64encode; + public class Common { static final String WWW_FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"; @@ -91,6 +93,18 @@ static Properties buildCommonConfigPlain(Map plainConfig) { return p; } + /** + * Get an access token from the token endpoint using client credentials by way of the OAuthAuthenticator.loginWithClientSecret method. + * + * @param tokenEndpoint The token endpoint + * @param clientId The client ID + * @param secret The client secret + * @param truststorePath The path to the truststore for TLS connection + * @param truststorePass The truststore password for TLS connection + * @return The access token returned from the authorization server's token endpoint + * + * @throws IOException Exception while sending the request + */ static String loginWithClientSecret(String tokenEndpoint, String clientId, String secret, String truststorePath, String truststorePass) throws IOException { TokenInfo tokenInfo = OAuthAuthenticator.loginWithClientSecret( URI.create(tokenEndpoint), @@ -107,6 +121,51 @@ static String loginWithClientSecret(String tokenEndpoint, String clientId, Strin return tokenInfo.token(); } + /** + * Get an access token from the token endpoint using client credentials and additional body attributes. + *

+ * The Mock OAuth server takes the extraAttrs and writes them into the payload of the generated access token. + * + * @param tokenEndpoint The token endpoint + * @param clientId The client ID + * @param secret The client secret + * @param truststorePath The path to the truststore for TLS connection + * @param truststorePass The truststore password for TLS connection + * @param extraAttrs The string of url-encoded key=value pairs separated by '&' to be added to the body of the token request + * @return The access token returned from the authorization server's token endpoint + * @throws IOException Exception while sending the request + */ + static String loginWithClientSecretAndExtraAttrs(String tokenEndpoint, String clientId, String secret, + String truststorePath, String truststorePass, String extraAttrs) throws IOException { + + if (clientId == null) { + throw new IllegalArgumentException("No clientId specified"); + } + if (secret == null) { + secret = ""; + } + + String authorization = "Basic " + base64encode(clientId + ':' + secret); + + StringBuilder body = new StringBuilder("grant_type=client_credentials"); + if (extraAttrs != null) { + body.append("&").append(extraAttrs); + } + JsonNode result = HttpUtil.post(URI.create(tokenEndpoint), + SSLUtil.createSSLFactory(truststorePath, null, truststorePass, null, null), + null, + authorization, + WWW_FORM_CONTENT_TYPE, + body.toString(), + JsonNode.class); + + JsonNode token = result.get("access_token"); + if (token == null) { + throw new IllegalStateException("Invalid response from authorization server: no access_token"); + } + return token.asText(); + } + static String loginWithUsernameForRefreshToken(String tokenEndpointUri, String username, String password, String clientId, String truststorePath, String truststorePass) throws IOException { JsonNode result = HttpUtil.post(URI.create(tokenEndpointUri), diff --git a/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/JwtExtractTest.java b/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/JwtExtractTest.java new file mode 100644 index 00000000..f9b54b32 --- /dev/null +++ b/testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/JwtExtractTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2017-2021, Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +package io.strimzi.testsuite.oauth.mockoauth; + +import java.util.HashMap; +import io.strimzi.kafka.oauth.common.PrincipalExtractor; +import io.strimzi.kafka.oauth.common.SSLUtil; +import io.strimzi.kafka.oauth.common.TokenIntrospection; +import io.strimzi.kafka.oauth.services.Services; +import io.strimzi.kafka.oauth.validator.JWTSignatureValidator; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLSocketFactory; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; + +import static io.strimzi.kafka.oauth.common.OAuthAuthenticator.urlencode; +import static io.strimzi.testsuite.oauth.mockoauth.Common.changeAuthServerMode; +import static io.strimzi.testsuite.oauth.mockoauth.Common.createOAuthClient; +import static io.strimzi.testsuite.oauth.mockoauth.Common.getProjectRoot; +import static io.strimzi.testsuite.oauth.mockoauth.Common.loginWithClientSecretAndExtraAttrs; + +public class JwtExtractTest { + + private static final Logger log = LoggerFactory.getLogger(JWKSKeyUseTest.class); + + public void doTest() throws Exception { + testExpiresAtOverflow(); + } + + public void testExpiresAtOverflow() throws Exception { + Services.configure(Collections.emptyMap()); + + changeAuthServerMode("jwks", "MODE_JWKS_RSA_WITH_SIG_USE"); + changeAuthServerMode("token", "MODE_200"); + + String testClient = "testclient"; + String testSecret = "testsecret"; + createOAuthClient(testClient, testSecret); + + String projectRoot = getProjectRoot(); + String trustStorePath = projectRoot + "/../docker/certificates/ca-truststore.p12"; + String trustStorePass = "changeit"; + + SSLSocketFactory sslFactory = SSLUtil.createSSLFactory( + trustStorePath, null, trustStorePass, null, null); + + JWTSignatureValidator validator = createTokenValidator("enforceKeyUse", sslFactory, false); + + Map extraAttrs = new HashMap<>(); + extraAttrs.put("EXP", Long.toString(Integer.MAX_VALUE + 1L)); + String extraAttrsString = extraAttrs.entrySet() + .stream() + .map(entry -> entry.getKey() + "=" + urlencode(entry.getValue())) + .collect(Collectors.joining("&")); + + // Now get a new token + String accessToken = loginWithClientSecretAndExtraAttrs( + "https://mockoauth:8090/token", + testClient, + testSecret, + trustStorePath, + trustStorePass, + extraAttrsString); + + TokenIntrospection.debugLogJWT(log, accessToken); + + // and try to validate it + // The overflow bug triggers a TokenExpiredException + validator.validate(accessToken); + } + + private static JWTSignatureValidator createTokenValidator(String validatorId, SSLSocketFactory sslFactory, boolean ignoreKeyUse) { + return new JWTSignatureValidator(validatorId, + null, + null, + null, + "https://mockoauth:8090/jwks", + sslFactory, + null, + new PrincipalExtractor(), + null, + null, + "https://mockoauth:8090", + 30, + 0, + 300, + ignoreKeyUse, + false, + null, + null, + 60, + 60, + true, + true, + true); + } +}