diff --git a/docs/documentation/server_admin/images/client-credentials-jwt.png b/docs/documentation/server_admin/images/client-credentials-jwt.png index da328b5fe71..fc9148614c6 100644 Binary files a/docs/documentation/server_admin/images/client-credentials-jwt.png and b/docs/documentation/server_admin/images/client-credentials-jwt.png differ diff --git a/docs/documentation/server_admin/topics/clients/oidc/con-confidential-client-credentials.adoc b/docs/documentation/server_admin/topics/clients/oidc/con-confidential-client-credentials.adoc index ab28bb5c592..251a4b18e00 100644 --- a/docs/documentation/server_admin/topics/clients/oidc/con-confidential-client-credentials.adoc +++ b/docs/documentation/server_admin/topics/clients/oidc/con-confidential-client-credentials.adoc @@ -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. diff --git a/docs/documentation/upgrading/topics/changes/changes-26_1_5.adoc b/docs/documentation/upgrading/topics/changes/changes-26_1_5.adoc new file mode 100644 index 00000000000..ace5f0c805f --- /dev/null +++ b/docs/documentation/upgrading/topics/changes/changes-26_1_5.adoc @@ -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). diff --git a/docs/documentation/upgrading/topics/changes/changes.adoc b/docs/documentation/upgrading/topics/changes/changes.adoc index ad9d6b90058..30bace416ee 100644 --- a/docs/documentation/upgrading/topics/changes/changes.adoc +++ b/docs/documentation/upgrading/topics/changes/changes.adoc @@ -1,6 +1,10 @@ [[migration-changes]] == Migration Changes +=== Migrating to 26.1.5 + +include::changes-26_1_5.adoc[leveloffset=2] + === Migrating to 26.1.3 include::changes-26_1_3.adoc[leveloffset=2] diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 9f12231238f..b25c2482517 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3337,3 +3337,5 @@ savingUserEventsOff=Saving user events turned off savingAdminEventsOff=Saving admin events turned off membershipEvents=Membership events childGroupEvents=Child group events +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. diff --git a/js/apps/admin-ui/src/clients/credentials/SignedJWT.tsx b/js/apps/admin-ui/src/clients/credentials/SignedJWT.tsx index 7f2c023d9bf..63ba6160177 100644 --- a/js/apps/admin-ui/src/clients/credentials/SignedJWT.tsx +++ b/js/apps/admin-ui/src/clients/credentials/SignedJWT.tsx @@ -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(); return ( - ( - "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 })), - ]} - /> + <> + ( + "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 })), + ]} + /> + + } + > + ( + "attributes.token.endpoint.auth.signing.max.exp", + )} + defaultValue="" + control={control} + render={({ field }) => ( + + )} + /> + + ); }; diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java index ed0ccbb9e22..f3fa66d16af 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -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"; diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java index 457295ac0f4..5fe5dde9fb5 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java @@ -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; @@ -176,6 +174,10 @@ 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) { // 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 @@ -192,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()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java index 08a9c8e8855..cbf478fdba5 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -310,6 +310,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); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java index c6cc07e9ddf..1fad6f0c0fb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java @@ -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; @@ -794,4 +796,42 @@ public class ClientAuthSignedJWTTest extends AbstractClientAuthSignedJWTTest { 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")); + } }