Skip to content

A test and a fix for #260 - Int overrun parsing EXP and IAT from JWT token #262

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 2, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ public TokenInfo(JsonNode payload, String token, String principal, Set<String> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> 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);
Expand All @@ -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<String, String> getExtraClaimsMap(MultiMap form) {

// Make a copy of the form entries and remove the ones that are not the extra claims
List<Map.Entry<String, String>> formEntries = new ArrayList<>(form.entries());
Iterator<Map.Entry<String, String>> it = formEntries.iterator();
HashSet<String> forRemoval = new HashSet<>(Arrays.asList(
"grant_type", "client_id", "client_assertion", "client_assertion_type", "username",
"password", "refresh_token"));

while (it.hasNext()) {
Map.Entry<String, String> e = it.next();
if (forRemoval.contains(e.getKey().toLowerCase(Locale.ENGLISH))) {
it.remove();
}
}

Map<String, String> 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);
Expand Down Expand Up @@ -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<String, String> claims) throws JOSEException, NoSuchAlgorithmException {

// Create RSA-signer with the private key
JWSSigner signer = new RSASSASigner(verticle.getSigKey());
Expand All @@ -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<String, String> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +93,18 @@ static Properties buildCommonConfigPlain(Map<String, String> plainConfig) {
return p;
}

/**
* Get an access token from the token endpoint using client credentials by way of the <code>OAuthAuthenticator.loginWithClientSecret</code> 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),
Expand All @@ -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.
* <p>
* 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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> 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);
}
}
Loading