Define a max expiration window for Signed JWT client authentication

Closes #38576

Signed-off-by: rmartinc <rmartinc@redhat.com>
(cherry picked from commit a10c8119d4452b866b90a9019b2cc159919276ca)
This commit is contained in:
rmartinc 2025-04-01 13:08:20 +02:00 committed by Marek Posolda
parent 4f08adc65d
commit 154206c5f3
12 changed files with 197 additions and 25 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -19,6 +19,8 @@ image:images/client-credentials-jwt.png[Signed JWT]
*Signed JWT* is "Signed JSON Web Token".
In this authenticator you can enforce the *Signature algorithm* used by the client (any algorithm is valid by default) and the *Max expiration* allowed for the JWT token (tokens received after this period will not be accepted because they are too old, note that tokens should be issued right before the authentication, 60 seconds by default).
When choosing this credential type you will have to also generate a private key and certificate for the client in the tab `Keys`. The private key will be used to sign the JWT, while the certificate is used by the server to verify the signature.
.Keys tab
@ -63,6 +65,8 @@ If you select this option, you can use a JWT signed by client secret instead of
The client secret will be used to sign the JWT by the client.
Like in the *Signed JWT* authenticator you can configure the *Signature algorithm* and the *Max expiration* for the JWT token.
*X509 Certificate*
{project_name} will validate if the client uses proper X509 certificate during the TLS Handshake.

View File

@ -73,7 +73,7 @@
:highavailabilityguide_name: High Availability Guide
:highavailabilityguide_link: https://www.keycloak.org/guides#high-availability
:tracingguide_name: Enabling Tracing
:tracingguide_link: https://www.keycloak.org/server/tracing
:tracingguide_link: https://www.keycloak.org/observability/tracing
:upgradingguide_name: Upgrading Guide
:upgradingguide_name_short: Upgrading
:upgradingguide_link: {project_doc_base_url}/upgrading/

View File

@ -61,7 +61,7 @@ Apart from disabling the /q/ endpoints, these are the other improvements made to
* The `health/ready` endpoint used for readiness probes still checks for a working database connection. Make sure you have not only `health-enabled=true` but also `metrics-enabled=true` set in your configuration, to enable the database check, resulting in an effective readiness probe. It will return HTTP status-code 503 and a status of DOWN when the database connection is not in a healthy state.
Expect more enhancements in this area in the future.
For more information, see the https://www.keycloak.org/server/health[Health guide]
For more information, see the https://www.keycloak.org/observability/health[Health guide]
= Changes using GELF / centralized log management

View File

@ -0,0 +1,7 @@
== Notable changes
Notable changes where an internal behavior changed to prevent common misconfigurations, fix bugs or simplify running {project_name}.
=== JWT client authentication defines a new max expiration option for the token
When a client is configured to authenticate using the *Signed JWT* or *Signed JWT with Client Secret* type, {project_name} now enforces a maximum expiration for the token. This means that, although the `exp` (expiration) claim in the token may be much later, {project_name} will not accept tokens issued before that max expiration time. The default value is 60 seconds. Note that JWT tokens should be issued right before being sent for authentication. This way, the client has one minute window to send the token for login. Nevertheless this expiration can be tuned using the *Max expiration* configuration option in the client *Credentials* tab (see link:{adminguide_link}#_client-credentials[Confidential client credentials in the {adminguide_name}] for more information).

View File

@ -1,6 +1,10 @@
[[migration-changes]]
== Migration Changes
=== Migrating to 26.0.11
include::changes-26_0_11.adoc[leveloffset=2]
=== Migrating to 26.0.10
include::changes-26_0_10.adoc[leveloffset=3]

View File

@ -3288,4 +3288,6 @@ organizationsMembersListError=Could not fetch organization members\: {{error}}
MANAGED=Managed
UNMANAGED=Unmanaged
deleteConfirmUsers_one=Delete user {{name}}?
deleteConfirmUsers_other=Delete {{count}} users?
deleteConfirmUsers_other=Delete {{count}} users?
signatureMaxExp=Max expiration
signatureMaxExpHelp=Maximum expiration allowed for the JWT. Tokens need to be generated right before authentication. After this period will be considered invalid because they are too old. If undefined the default value is 60 seconds.

View File

@ -1,8 +1,12 @@
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import { SelectControl } from "@keycloak/keycloak-ui-shared";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { convertAttributeNameToForm } from "../../util";
import { FormFields } from "../ClientDetails";
import { TimeSelector } from "../../components/time-selector/TimeSelector";
import { FormGroup } from "@patternfly/react-core";
import { HelpItem } from "@keycloak/keycloak-ui-shared";
type SignedJWTProps = {
clientAuthenticatorType: string;
@ -16,23 +20,53 @@ export const SignedJWT = ({ clientAuthenticatorType }: SignedJWTProps) => {
: (cryptoInfo?.clientSignatureSymmetricAlgorithms ?? []);
const { t } = useTranslation();
const { control } = useFormContext<FormFields>();
return (
<SelectControl
name={convertAttributeNameToForm<FormFields>(
"attributes.token.endpoint.auth.signing.alg",
)}
label={t("signatureAlgorithm")}
labelIcon={t("signatureAlgorithmHelp")}
controller={{
defaultValue: "",
}}
isScrollable
maxMenuHeight="200px"
options={[
{ key: "", value: t("anyAlgorithm") },
...providers.map((option) => ({ key: option, value: option })),
]}
/>
<>
<SelectControl
name={convertAttributeNameToForm<FormFields>(
"attributes.token.endpoint.auth.signing.alg",
)}
label={t("signatureAlgorithm")}
labelIcon={t("signatureAlgorithmHelp")}
controller={{
defaultValue: "",
}}
isScrollable
maxMenuHeight="200px"
options={[
{ key: "", value: t("anyAlgorithm") },
...providers.map((option) => ({ key: option, value: option })),
]}
/>
<FormGroup
label={t("signatureMaxExp")}
fieldId="signatureMaxExp"
className="pf-v5-u-my-md"
labelIcon={
<HelpItem
helpText={t("signatureMaxExpHelp")}
fieldLabelId="signatureMaxExp"
/>
}
>
<Controller
name={convertAttributeNameToForm<FormFields>(
"attributes.token.endpoint.auth.signing.max.exp",
)}
defaultValue=""
control={control}
render={({ field }) => (
<TimeSelector
value={field.value!}
onChange={field.onChange}
units={["second", "minute"]}
min="1"
/>
)}
/>
</FormGroup>
</>
);
};

View File

@ -64,6 +64,7 @@ public final class OIDCConfigAttributes {
public static final String PKCE_CODE_CHALLENGE_METHOD = "pkce.code.challenge.method";
public static final String TOKEN_ENDPOINT_AUTH_SIGNING_ALG = "token.endpoint.auth.signing.alg";
public static final String TOKEN_ENDPOINT_AUTH_SIGNING_MAX_EXP = "token.endpoint.auth.signing.max.exp";
public static final String BACKCHANNEL_LOGOUT_URL = "backchannel.logout.url";

View File

@ -19,8 +19,6 @@
package org.keycloak.authentication.authenticators.client;
import java.util.Optional;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
@ -58,6 +56,8 @@ public class JWTClientValidator {
private JsonWebToken token;
private ClientModel client;
private static final int ALLOWED_CLOCK_SKEW = 15; // sec
public JWTClientValidator(ClientAuthenticationFlowContext context, String clientAuthenticatorProviderId) {
this.context = context;
this.realm = context.getRealm();
@ -174,9 +174,19 @@ public class JWTClientValidator {
throw new RuntimeException("Token is not active");
}
if ((token.getExp() == null || token.getExp() <= 0) && (token.getIat() == null || token.getIat() <= 0)) {
throw new RuntimeException("Token cannot be validated. Neither the exp nor the iat claim are present.");
}
// KEYCLOAK-2986, token-timeout or token-expiration in keycloak.json might not be used
if ((token.getExp() == null || token.getExp() <= 0) && token.getIat() + 10 < currentTime) {
throw new RuntimeException("Token is not active");
if (token.getExp() == null || token.getExp() <= 0) { // in case of "exp" not exist
if (token.getIat() + ALLOWED_CLOCK_SKEW + 10 < currentTime) { // consider "exp" = 10, client's clock delays from Keycloak's clock
throw new RuntimeException("Token is not active");
}
} else {
if ((token.getIat() != null && token.getIat() > 0) && token.getIat() - ALLOWED_CLOCK_SKEW > currentTime) { // consider client's clock is ahead from Keycloak's clock
throw new RuntimeException("Token was issued in the future");
}
}
if (token.getId() == null) {
@ -184,12 +194,50 @@ public class JWTClientValidator {
}
}
private long calculateLifespanInSeconds() {
if ((token.getExp() == null || token.getExp() <= 0) && (token.getIat() == null || token.getIat() <= 0)) {
throw new RuntimeException("Token cannot be validated. Neither the exp nor the iat claim are present.");
}
// rfc7523 marks exp as required and iat as optional: https://datatracker.ietf.org/doc/html/rfc7523#section-3
if (token.getExp() == null || token.getExp() <= 0) {
// exp not present but iat present, just allow a short period of time from iat (10s)
final long lifespan = token.getIat() + ALLOWED_CLOCK_SKEW + 10 - currentTime;
if (lifespan <= 0) {
throw new RuntimeException("Token is not active");
}
return lifespan;
} else if (token.getIat() == null || token.getIat() <= 0) {
// iat not present but exp present, the max-exp should not be exceeded
final int maxExp = OIDCAdvancedConfigWrapper.fromClientModel(client).getTokenEndpointAuthSigningMaxExp();
final long lifespan = token.getExp() - currentTime;
if (lifespan > maxExp) {
throw new RuntimeException("Token expiration is too far in the future and iat claim not present in token");
}
return lifespan;
} else {
// both iat and exp present, the token is just allowed to be used max-age as much
if (token.getIat() - ALLOWED_CLOCK_SKEW > currentTime) {
throw new RuntimeException("Token was issued in the future");
}
final int maxExp = OIDCAdvancedConfigWrapper.fromClientModel(client).getTokenEndpointAuthSigningMaxExp();
final long lifespan = Math.min(token.getExp() - currentTime, maxExp);
if (lifespan <= 0) {
throw new RuntimeException("Token is not active");
}
if (currentTime > token.getIat() + maxExp) {
throw new RuntimeException("Token was issued too far in the past to be used now");
}
return lifespan;
}
}
public void validateTokenReuse() {
if (token == null) throw new IllegalStateException("Incorrect usage. Variable 'token' is null. Need to read token first before validateToken reuse");
if (client == null) throw new IllegalStateException("Incorrect usage. Variable 'client' is null. Need to validate client first before validateToken reuse");
SingleUseObjectProvider singleUseCache = context.getSession().singleUseObjects();
long lifespanInSecs = Math.max(Optional.ofNullable(token.getExp()).orElse(0L) - currentTime, 10);
long lifespanInSecs = calculateLifespanInSeconds();
if (singleUseCache.putIfAbsent(token.getId(), lifespanInSecs)) {
logger.tracef("Added token '%s' to single-use cache. Lifespan: %d seconds, client: %s", token.getId(), lifespanInSecs, client.getClientId());

View File

@ -309,6 +309,26 @@ public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper {
setAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, algName);
}
public int getTokenEndpointAuthSigningMaxExp() {
final String value = getAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_MAX_EXP);
try {
final int maxExp = Integer.parseInt(value);
if (maxExp > 0) {
return maxExp;
}
} catch (NumberFormatException e) {
// ignore and return default value
}
return 60; // default to 60s
}
public void setTokenEndpointAuthSigningMaxExp(int maxExp) {
if (maxExp <= 0) {
throw new IllegalArgumentException("Maximum expiration is a positive number in seconds");
}
setAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_MAX_EXP, String.valueOf(maxExp));
}
public String getBackchannelLogoutUrl() {
return getAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL);
}

View File

@ -53,6 +53,8 @@ import java.security.PublicKey;
import java.util.LinkedList;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
@ -736,7 +738,7 @@ public class ClientAuthSignedJWTTest extends AbstractClientAuthSignedJWTTest {
assertSuccess(response, app1.getClientId(), serviceAccountUser.getId(), serviceAccountUser.getUsername());
// Test expired lifespan
response = testMissingClaim(-11, "expiration");
response = testMissingClaim(- 11 - 15, "expiration"); // 15 sec clock skew
assertError(response, app1.getClientId(), OAuthErrorException.INVALID_CLIENT, Errors.INVALID_CLIENT_CREDENTIALS);
// Missing exp and issuedAt should return error
@ -782,4 +784,54 @@ public class ClientAuthSignedJWTTest extends AbstractClientAuthSignedJWTTest {
public void testDirectGrantRequestFailureES256() throws Exception {
testDirectGrantRequestFailure(Algorithm.ES256);
}
@Test
public void testClockSkew() throws Exception {
OAuthClient.AccessTokenResponse response = testMissingClaim(15, "issuedAt", "notBefore"); // allowable clock skew is 15 sec
assertSuccess(response, app1.getClientId(), serviceAccountUser.getId(), serviceAccountUser.getUsername());
// excess allowable clock skew
response = testMissingClaim(15 + 15, "issuedAt");
assertError(response, app1.getClientId(), OAuthErrorException.INVALID_CLIENT, Errors.INVALID_CLIENT_CREDENTIALS);
response = testMissingClaim(15 + 15, "notBefore");
assertError(response, app1.getClientId(), OAuthErrorException.INVALID_CLIENT, Errors.INVALID_CLIENT_CREDENTIALS);
}
@Test
public void testLongExpirationWithIssuedAt() throws Exception {
CustomJWTClientCredentialsProvider jwtProvider = new CustomJWTClientCredentialsProvider();
jwtProvider.setupKeyPair(keyPairClient1);
jwtProvider.setTokenTimeout(3600); // one hour of token expiration
// the token should be valid the first time inside the max-exp window
String jwt = jwtProvider.createSignedRequestToken(app1.getClientId(), getRealmInfoUrl());
OAuthClient.AccessTokenResponse response = doClientCredentialsGrantRequest(jwt);
assertSuccess(response, app1.getClientId(), serviceAccountUser.getId(), serviceAccountUser.getUsername());
// in the max-exp window the token should be detected as already used
setTimeOffset(30);
response = doClientCredentialsGrantRequest(jwt);
assertError(response, app1.getClientId(), OAuthErrorException.INVALID_CLIENT, Errors.INVALID_CLIENT_CREDENTIALS);
assertThat(response.getErrorDescription(), containsString("Token reuse detected"));
// after the max-exp window the token cannot be used because iat is too far in the past
setTimeOffset(65);
response = doClientCredentialsGrantRequest(jwt);
assertError(response, app1.getClientId(), OAuthErrorException.INVALID_CLIENT, Errors.INVALID_CLIENT_CREDENTIALS);
assertThat(response.getErrorDescription(), containsString("Token was issued too far in the past to be used now"));
}
@Test
public void testLongExpirationWithoutIssuedAt() throws Exception {
CustomJWTClientCredentialsProvider jwtProvider = new CustomJWTClientCredentialsProvider();
jwtProvider.setupKeyPair(keyPairClient1);
jwtProvider.setTokenTimeout(3600); // one hour of token expiration
jwtProvider.enableClaim("issuedAt", false);
// the token should not be valid because expiration is to far in the future
String jwt = jwtProvider.createSignedRequestToken(app1.getClientId(), getRealmInfoUrl());
OAuthClient.AccessTokenResponse response = doClientCredentialsGrantRequest(jwt);
assertError(response, app1.getClientId(), OAuthErrorException.INVALID_CLIENT, Errors.INVALID_CLIENT_CREDENTIALS);
assertThat(response.getErrorDescription(), containsString("Token expiration is too far in the future and iat claim not present in token"));
}
}