[OID4VCI] Tolerate clock skew in SD-JWT time checks (#43506)

Closes #43456

Signed-off-by: Ingrid Kamga <Ingrid.Kamga@adorsys.com>
This commit is contained in:
Ingrid Kamga 2025-11-11 09:02:44 +01:00 committed by GitHub
parent 9ef7ff22d2
commit ce05241c7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 603 additions and 262 deletions

View File

@ -22,61 +22,30 @@ package org.keycloak.sdjwt;
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class IssuerSignedJwtVerificationOpts {
private final boolean validateIssuedAtClaim;
private final boolean validateExpirationClaim;
private final boolean validateNotBeforeClaim;
public class IssuerSignedJwtVerificationOpts extends TimeClaimVerificationOpts {
public IssuerSignedJwtVerificationOpts(
private IssuerSignedJwtVerificationOpts(
boolean validateIssuedAtClaim,
boolean validateExpirationClaim,
boolean validateNotBeforeClaim) {
this.validateIssuedAtClaim = validateIssuedAtClaim;
this.validateExpirationClaim = validateExpirationClaim;
this.validateNotBeforeClaim = validateNotBeforeClaim;
boolean validateNotBeforeClaim,
int leewaySeconds
) {
super(validateIssuedAtClaim, validateExpirationClaim, validateNotBeforeClaim, leewaySeconds);
}
public boolean mustValidateIssuedAtClaim() {
return validateIssuedAtClaim;
public static Builder builder() {
return new Builder();
}
public boolean mustValidateExpirationClaim() {
return validateExpirationClaim;
}
public boolean mustValidateNotBeforeClaim() {
return validateNotBeforeClaim;
}
public static IssuerSignedJwtVerificationOpts.Builder builder() {
return new IssuerSignedJwtVerificationOpts.Builder();
}
public static class Builder {
private boolean validateIssuedAtClaim;
private boolean validateExpirationClaim = true;
private boolean validateNotBeforeClaim = true;
public Builder withValidateIssuedAtClaim(boolean validateIssuedAtClaim) {
this.validateIssuedAtClaim = validateIssuedAtClaim;
return this;
}
public Builder withValidateExpirationClaim(boolean validateExpirationClaim) {
this.validateExpirationClaim = validateExpirationClaim;
return this;
}
public Builder withValidateNotBeforeClaim(boolean validateNotBeforeClaim) {
this.validateNotBeforeClaim = validateNotBeforeClaim;
return this;
}
public static class Builder extends TimeClaimVerificationOpts.Builder<Builder> {
@Override
public IssuerSignedJwtVerificationOpts build() {
return new IssuerSignedJwtVerificationOpts(
validateIssuedAtClaim,
validateExpirationClaim,
validateNotBeforeClaim
requireIssuedAtClaim,
requireExpirationClaim,
requireNotBeforeClaim,
leewaySeconds
);
}
}

View File

@ -18,7 +18,6 @@ package org.keycloak.sdjwt;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
@ -95,7 +94,7 @@ public abstract class SdJws {
}
}
private static final JWSInput parse(String jwsString) {
private static JWSInput parse(String jwsString) {
try {
return new JWSInput(Objects.requireNonNull(jwsString, "jwsString must not be null"));
} catch (JWSInputException e) {
@ -103,7 +102,7 @@ public abstract class SdJws {
}
}
private static final JsonNode readPayload(JWSInput jwsInput) {
private static JsonNode readPayload(JWSInput jwsInput) {
try {
return SdJwtUtils.mapper.readTree(jwsInput.getContent());
} catch (IOException e) {
@ -115,48 +114,6 @@ public abstract class SdJws {
return this.jwsInput.getHeader();
}
public void verifyIssuedAtClaim() throws VerificationException {
long now = Instant.now().getEpochSecond();
long iat = SdJwtUtils.readTimeClaim(payload, "iat");
if (now < iat) {
throw new VerificationException("jwt issued in the future");
}
}
public void verifyExpClaim() throws VerificationException {
long now = Instant.now().getEpochSecond();
long exp = SdJwtUtils.readTimeClaim(payload, "exp");
if (now >= exp) {
throw new VerificationException("jwt has expired");
}
}
public void verifyNotBeforeClaim() throws VerificationException {
long now = Instant.now().getEpochSecond();
long nbf = SdJwtUtils.readTimeClaim(payload, "nbf");
if (now < nbf) {
throw new VerificationException("jwt not valid yet");
}
}
/**
* Verifies that the JWS is not too old.
*
* @param maxAge Maximum age in seconds
* @throws VerificationException if too old
*/
public void verifyAge(int maxAge) throws VerificationException {
long now = Instant.now().getEpochSecond();
long iat = SdJwtUtils.readTimeClaim(getPayload(), "iat");
if (now - iat > maxAge) {
throw new VerificationException("jwt is too old");
}
}
/**
* Verifies that SD-JWT was issued by one of the provided issuers.
* @param issuers List of trusted issuers

View File

@ -27,7 +27,6 @@ import org.keycloak.sdjwt.consumer.PresentationRequirements;
import org.keycloak.sdjwt.vp.KeyBindingJWT;
import org.keycloak.sdjwt.vp.KeyBindingJwtVerificationOpts;
import java.time.Instant;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
@ -290,31 +289,22 @@ public class SdJwtVerificationContext {
JsonNode payload,
IssuerSignedJwtVerificationOpts issuerSignedJwtVerificationOpts
) throws VerificationException {
long now = Instant.now().getEpochSecond();
TimeClaimVerifier timeClaimVerifier = new TimeClaimVerifier(issuerSignedJwtVerificationOpts);
try {
if (issuerSignedJwtVerificationOpts.mustValidateIssuedAtClaim()
&& now < SdJwtUtils.readTimeClaim(payload, "iat")) {
throw new VerificationException("JWT issued in the future");
}
timeClaimVerifier.verifyIssuedAtClaim(payload);
} catch (VerificationException e) {
throw new VerificationException("Issuer-Signed JWT: Invalid `iat` claim", e);
}
try {
if (issuerSignedJwtVerificationOpts.mustValidateExpirationClaim()
&& now >= SdJwtUtils.readTimeClaim(payload, "exp")) {
throw new VerificationException("JWT has expired");
}
timeClaimVerifier.verifyExpirationClaim(payload);
} catch (VerificationException e) {
throw new VerificationException("Issuer-Signed JWT: Invalid `exp` claim", e);
}
try {
if (issuerSignedJwtVerificationOpts.mustValidateNotBeforeClaim()
&& now < SdJwtUtils.readTimeClaim(payload, "nbf")) {
throw new VerificationException("JWT is not yet valid");
}
timeClaimVerifier.verifyNotBeforeClaim(payload);
} catch (VerificationException e) {
throw new VerificationException("Issuer-Signed JWT: Invalid `nbf` claim", e);
}
@ -328,17 +318,20 @@ public class SdJwtVerificationContext {
private void validateKeyBindingJwtTimeClaims(
KeyBindingJwtVerificationOpts keyBindingJwtVerificationOpts
) throws VerificationException {
JsonNode kbJwtPayload = keyBindingJwt.getPayload();
TimeClaimVerifier timeClaimVerifier = new TimeClaimVerifier(keyBindingJwtVerificationOpts);
// Check that the creation time of the Key Binding JWT, as determined by the iat claim,
// is within an acceptable window
try {
keyBindingJwt.verifyIssuedAtClaim();
timeClaimVerifier.verifyIssuedAtClaim(kbJwtPayload);
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT: Invalid `iat` claim", e);
}
try {
keyBindingJwt.verifyAge(keyBindingJwtVerificationOpts.getAllowedMaxAge());
timeClaimVerifier.verifyAge(kbJwtPayload, keyBindingJwtVerificationOpts.getAllowedMaxAge());
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT is too old");
}
@ -346,17 +339,13 @@ public class SdJwtVerificationContext {
// Check other time claims
try {
if (keyBindingJwtVerificationOpts.mustValidateExpirationClaim()) {
keyBindingJwt.verifyExpClaim();
}
timeClaimVerifier.verifyExpirationClaim(kbJwtPayload);
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT: Invalid `exp` claim", e);
}
try {
if (keyBindingJwtVerificationOpts.mustValidateNotBeforeClaim()) {
keyBindingJwt.verifyNotBeforeClaim();
}
timeClaimVerifier.verifyNotBeforeClaim(kbJwtPayload);
} catch (VerificationException e) {
throw new VerificationException("Key binding JWT: Invalid `nbf` claim", e);
}

View File

@ -0,0 +1,113 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.sdjwt;
/**
* Options for validating common time claims during SD-JWT verification.
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class TimeClaimVerificationOpts {
public static final int DEFAULT_LEEWAY_SECONDS = 10;
// These options configure whether the respective time claims must be present
// during validation. They will always be validated if present.
private final boolean requireIssuedAtClaim;
private final boolean requireExpirationClaim;
private final boolean requireNotBeforeClaim;
/**
* Tolerance window to account for clock skew when checking time claims
*/
private final int leewaySeconds;
protected TimeClaimVerificationOpts(
boolean requireIssuedAtClaim,
boolean requireExpirationClaim,
boolean validateNotBeforeClaim,
int leewaySeconds
) {
this.requireIssuedAtClaim = requireIssuedAtClaim;
this.requireExpirationClaim = requireExpirationClaim;
this.requireNotBeforeClaim = validateNotBeforeClaim;
this.leewaySeconds = leewaySeconds;
}
public boolean mustRequireIssuedAtClaim() {
return requireIssuedAtClaim;
}
public boolean mustRequireExpirationClaim() {
return requireExpirationClaim;
}
public boolean mustRequireNotBeforeClaim() {
return requireNotBeforeClaim;
}
public int getLeewaySeconds() {
return leewaySeconds;
}
public static <T extends Builder<T>> Builder<T> builder() {
return new Builder<>();
}
public static class Builder<T extends Builder<T>> {
protected boolean requireIssuedAtClaim = true;
protected boolean requireExpirationClaim = true;
protected boolean requireNotBeforeClaim = true;
protected int leewaySeconds = DEFAULT_LEEWAY_SECONDS;
@SuppressWarnings("unchecked")
public T withRequireIssuedAtClaim(boolean requireIssuedAtClaim) {
this.requireIssuedAtClaim = requireIssuedAtClaim;
return (T) this;
}
@SuppressWarnings("unchecked")
public T withRequireExpirationClaim(boolean requireExpirationClaim) {
this.requireExpirationClaim = requireExpirationClaim;
return (T) this;
}
@SuppressWarnings("unchecked")
public T withRequireNotBeforeClaim(boolean requireNotBeforeClaim) {
this.requireNotBeforeClaim = requireNotBeforeClaim;
return (T) this;
}
@SuppressWarnings("unchecked")
public T withLeewaySeconds(int leewaySeconds) {
this.leewaySeconds = leewaySeconds;
return (T) this;
}
public TimeClaimVerificationOpts build() {
return new TimeClaimVerificationOpts(
requireIssuedAtClaim,
requireExpirationClaim,
requireNotBeforeClaim,
leewaySeconds
);
}
}
}

View File

@ -0,0 +1,130 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.sdjwt;
import com.fasterxml.jackson.databind.JsonNode;
import org.keycloak.common.VerificationException;
import java.time.Instant;
/**
* Module for checking the validity of JWT time claims.
* All checks account for a leeway window to accommodate clock skew.
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class TimeClaimVerifier {
public static final String CLAIM_NAME_IAT = "iat";
public static final String CLAIM_NAME_EXP = "exp";
public static final String CLAIM_NAME_NBF = "nbf";
private final TimeClaimVerificationOpts opts;
public TimeClaimVerifier(TimeClaimVerificationOpts opts) {
if (opts.getLeewaySeconds() < 0) {
throw new IllegalArgumentException("Leeway seconds cannot be negative");
}
this.opts = opts;
}
/**
* Validates that JWT was not issued in the future
*
* @param jwtPayload the JWT's payload
*/
public void verifyIssuedAtClaim(JsonNode jwtPayload) throws VerificationException {
if (!jwtPayload.hasNonNull(CLAIM_NAME_IAT)) {
if (opts.mustRequireIssuedAtClaim()) {
throw new VerificationException("Missing 'iat' claim or null");
}
return; // Not required, skipping check
}
long iat = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_IAT);
if ((currentTimestamp() + opts.getLeewaySeconds()) < iat) {
throw new VerificationException("JWT was issued in the future");
}
}
/**
* Validates that JWT has not expired
*
* @param jwtPayload the JWT's payload
*/
public void verifyExpirationClaim(JsonNode jwtPayload) throws VerificationException {
if (!jwtPayload.hasNonNull(CLAIM_NAME_EXP)) {
if (opts.mustRequireExpirationClaim()) {
throw new VerificationException("Missing 'exp' claim or null");
}
return; // Not required, skipping check
}
long exp = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_EXP);
if ((currentTimestamp() - opts.getLeewaySeconds()) >= exp) {
throw new VerificationException("JWT has expired");
}
}
/**
* Validates that JWT can yet be processed
*
* @param jwtPayload the JWT's payload
*/
public void verifyNotBeforeClaim(JsonNode jwtPayload) throws VerificationException {
if (!jwtPayload.hasNonNull(CLAIM_NAME_NBF)) {
if (opts.mustRequireNotBeforeClaim()) {
throw new VerificationException("Missing 'nbf' claim or null");
}
return; // Not required, skipping check
}
long nbf = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_NBF);
if ((currentTimestamp() + opts.getLeewaySeconds()) < nbf) {
throw new VerificationException("JWT is not yet valid");
}
}
/**
* Validates that JWT is not too old
*
* @param jwtPayload the JWT's payload
* @param maxAge maximum allowed age in seconds
*/
public void verifyAge(JsonNode jwtPayload, int maxAge) throws VerificationException {
long iat = SdJwtUtils.readTimeClaim(jwtPayload, CLAIM_NAME_IAT);
if ((currentTimestamp() - iat - opts.getLeewaySeconds()) > maxAge) {
throw new VerificationException("JWT is too old");
}
}
/**
* Returns current timestamp in seconds.
*/
public long currentTimestamp() {
return Instant.now().getEpochSecond();
}
}

View File

@ -17,12 +17,17 @@
package org.keycloak.sdjwt.vp;
import org.keycloak.sdjwt.TimeClaimVerificationOpts;
/**
* Options for Key Binding JWT verification.
*
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class KeyBindingJwtVerificationOpts {
public class KeyBindingJwtVerificationOpts extends TimeClaimVerificationOpts {
public static final int DEFAULT_ALLOWED_MAX_AGE = 5 * 60; // 5 minutes
/**
* Specifies the Verifier's policy whether to check Key Binding
*/
@ -36,22 +41,20 @@ public class KeyBindingJwtVerificationOpts {
private final String nonce;
private final String aud;
private final boolean validateExpirationClaim;
private final boolean validateNotBeforeClaim;
public KeyBindingJwtVerificationOpts(
private KeyBindingJwtVerificationOpts(
boolean keyBindingRequired,
int allowedMaxAge,
String nonce,
String aud,
boolean validateExpirationClaim,
boolean validateNotBeforeClaim) {
boolean validateNotBeforeClaim,
int leewaySeconds
) {
super(true, validateExpirationClaim, validateNotBeforeClaim, leewaySeconds);
this.keyBindingRequired = keyBindingRequired;
this.allowedMaxAge = allowedMaxAge;
this.nonce = nonce;
this.aud = aud;
this.validateExpirationClaim = validateExpirationClaim;
this.validateNotBeforeClaim = validateNotBeforeClaim;
}
public boolean isKeyBindingRequired() {
@ -70,25 +73,17 @@ public class KeyBindingJwtVerificationOpts {
return aud;
}
public boolean mustValidateExpirationClaim() {
return validateExpirationClaim;
public static Builder builder() {
return new Builder();
}
public boolean mustValidateNotBeforeClaim() {
return validateNotBeforeClaim;
}
public static class Builder extends TimeClaimVerificationOpts.Builder<Builder> {
public static KeyBindingJwtVerificationOpts.Builder builder() {
return new KeyBindingJwtVerificationOpts.Builder();
}
public static class Builder {
private boolean keyBindingRequired = true;
private int allowedMaxAge = 5 * 60;
protected boolean validateIssuedAtClaim = true;
private int allowedMaxAge = DEFAULT_ALLOWED_MAX_AGE;
private String nonce;
private String aud;
private boolean validateExpirationClaim = true;
private boolean validateNotBeforeClaim = true;
public Builder withKeyBindingRequired(boolean keyBindingRequired) {
this.keyBindingRequired = keyBindingRequired;
@ -110,17 +105,14 @@ public class KeyBindingJwtVerificationOpts {
return this;
}
public Builder withValidateExpirationClaim(boolean validateExpirationClaim) {
this.validateExpirationClaim = validateExpirationClaim;
return this;
}
public Builder withValidateNotBeforeClaim(boolean validateNotBeforeClaim) {
this.validateNotBeforeClaim = validateNotBeforeClaim;
return this;
}
@Override
public KeyBindingJwtVerificationOpts build() {
if (!validateIssuedAtClaim) {
throw new IllegalArgumentException(
"Validating `iat` claim cannot be disabled for KB-JWT verification because mandated"
);
}
if (keyBindingRequired && (aud == null || nonce == null || nonce.isEmpty())) {
throw new IllegalArgumentException(
"Missing `nonce` and `aud` claims for replay protection"
@ -132,8 +124,9 @@ public class KeyBindingJwtVerificationOpts {
allowedMaxAge,
nonce,
aud,
validateExpirationClaim,
validateNotBeforeClaim
requireExpirationClaim,
requireNotBeforeClaim,
leewaySeconds
);
}
}

View File

@ -65,42 +65,6 @@ public abstract class SdJwsTest {
assertThrows(VerificationException.class, () -> sdJws.verifySignature(testSettings.issuerVerifierContext));
}
@Test
public void testVerifyExpClaim_ExpiredJWT() {
JsonNode payload = createPayload();
((ObjectNode) payload).put("exp", Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond());
SdJws sdJws = new SdJws(payload) {
};
assertThrows(VerificationException.class, sdJws::verifyExpClaim);
}
@Test
public void testVerifyExpClaim_Positive() throws Exception {
JsonNode payload = createPayload();
((ObjectNode) payload).put("exp", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond());
SdJws sdJws = new SdJws(payload) {
};
sdJws.verifyExpClaim();
}
@Test
public void testVerifyNotBeforeClaim_Negative() {
JsonNode payload = createPayload();
((ObjectNode) payload).put("nbf", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond());
SdJws sdJws = new SdJws(payload) {
};
assertThrows(VerificationException.class, sdJws::verifyNotBeforeClaim);
}
@Test
public void testVerifyNotBeforeClaim_Positive() throws Exception {
JsonNode payload = createPayload();
((ObjectNode) payload).put("nbf", Instant.now().minus(1, ChronoUnit.HOURS).getEpochSecond());
SdJws sdJws = new SdJws(payload) {
};
sdJws.verifyNotBeforeClaim();
}
@Test
public void testPayloadJwsConstruction() {
SdJws sdJws = new SdJws(createPayload()) {
@ -159,27 +123,4 @@ public abstract class SdJwsTest {
SdJws sdJws = new SdJws(payload) {};
sdJws.verifyVctClaim(Collections.singletonList("IdentityCredential"));
}
@Test
public void shouldValidateAgeSinceIssued() throws VerificationException {
long now = Instant.now().getEpochSecond();
SdJws sdJws = exampleSdJws(now);
sdJws.verifyAge(180);
}
@Test
public void shouldValidateAgeSinceIssued_IfJwtIsTooOld() {
long now = Instant.now().getEpochSecond();
SdJws sdJws = exampleSdJws(now - 1000); // that will be too old
VerificationException exception = assertThrows(VerificationException.class, () -> sdJws.verifyAge(180));
assertEquals("jwt is too old", exception.getMessage());
}
private SdJws exampleSdJws(long iat) {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.set("iat", SdJwtUtils.mapper.valueToTree(iat));
return new SdJws(payload) {
};
}
}

View File

@ -139,6 +139,10 @@ public abstract class SdJwtFacadeTest {
}
private IssuerSignedJwtVerificationOpts createVerificationOptions() {
return new IssuerSignedJwtVerificationOpts(true, true, false);
return IssuerSignedJwtVerificationOpts.builder()
.withRequireIssuedAtClaim(false)
.withRequireExpirationClaim(false)
.withRequireNotBeforeClaim(false)
.build();
}
}

View File

@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.ClassRule;
import org.junit.Test;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.rule.CryptoInitRule;
@ -31,8 +32,6 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.keycloak.crypto.SignatureSignerContext;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
@ -168,9 +167,7 @@ public abstract class SdJwtVerificationTest {
VerificationException.class,
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts()
.withValidateExpirationClaim(true)
.build()
defaultIssuerSignedJwtVerificationOpts().build()
)
);
@ -188,7 +185,7 @@ public abstract class SdJwtVerificationTest {
// exp: invalid
ObjectNode claimSet2 = mapper.createObjectNode();
claimSet1.put("given_name", "John");
claimSet1.put("exp", "should-not-be-a-string");
claimSet1.set("exp", null);
DisclosureSpec disclosureSpec = DisclosureSpec.builder()
.withUndisclosedClaim("given_name", "eluV5Og3gSNII8EYnsxA_A")
@ -203,13 +200,13 @@ public abstract class SdJwtVerificationTest {
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts()
.withValidateExpirationClaim(true)
.withRequireExpirationClaim(true)
.build()
)
);
assertEquals("Issuer-Signed JWT: Invalid `exp` claim", exception.getMessage());
assertEquals("Missing or invalid 'exp' claim", exception.getCause().getMessage());
assertEquals("Missing 'exp' claim or null", exception.getCause().getMessage());
}
}
@ -234,14 +231,12 @@ public abstract class SdJwtVerificationTest {
VerificationException.class,
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts()
.withValidateIssuedAtClaim(true)
.build()
defaultIssuerSignedJwtVerificationOpts().build()
)
);
assertEquals("Issuer-Signed JWT: Invalid `iat` claim", exception.getMessage());
assertEquals("JWT issued in the future", exception.getCause().getMessage());
assertEquals("JWT was issued in the future", exception.getCause().getMessage());
}
}
@ -266,9 +261,7 @@ public abstract class SdJwtVerificationTest {
VerificationException.class,
() -> sdJwt.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts()
.withValidateNotBeforeClaim(true)
.build()
defaultIssuerSignedJwtVerificationOpts().build()
)
);
@ -370,9 +363,9 @@ public abstract class SdJwtVerificationTest {
private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() {
return IssuerSignedJwtVerificationOpts.builder()
.withValidateIssuedAtClaim(false)
.withValidateExpirationClaim(false)
.withValidateNotBeforeClaim(false);
.withRequireIssuedAtClaim(false)
.withRequireExpirationClaim(false)
.withRequireNotBeforeClaim(false);
}
private SdJwt.Builder exampleFlatSdJwtV1() {

View File

@ -0,0 +1,215 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.sdjwt;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import org.keycloak.common.VerificationException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_EXP;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_IAT;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_NBF;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
*/
public class TimeClaimVerifierTest {
private static final long CURRENT_TIMESTAMP = 1609459200L; // Fixed timestamp: 2021-01-01 00:00:00 UTC
private static final int DEFAULT_LEEWAY_SECONDS = 20;
private final TimeClaimVerifier timeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_LEEWAY_SECONDS, false);
private final TimeClaimVerifier strictTimeClaimVerifier = new FixedTimeClaimVerifier(DEFAULT_LEEWAY_SECONDS, true);
static class FixedTimeClaimVerifier extends TimeClaimVerifier {
public FixedTimeClaimVerifier(int leewaySeconds, boolean requireClaims) {
super(createOptsWithLeeway(leewaySeconds, requireClaims));
}
@Override
public long currentTimestamp() {
return CURRENT_TIMESTAMP;
}
}
@Test
public void testVerifyIatClaimInTheFuture() {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP + 100); // 100 seconds in the future
VerificationException exception = assertThrows(VerificationException.class,
() -> timeClaimVerifier.verifyIssuedAtClaim(payload));
assertEquals("JWT was issued in the future", exception.getMessage());
}
@Test
public void testVerifyIatClaimValid() throws VerificationException {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 1); // Issued 1 second ago, in the past
timeClaimVerifier.verifyIssuedAtClaim(payload);
}
@Test
public void testVerifyIatClaimEdge() throws VerificationException {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP + 19); // Issued 19 seconds in the future, within the 20 second leeway
timeClaimVerifier.verifyIssuedAtClaim(payload);
}
@Test
public void testVerifyExpClaimExpired() {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_EXP, CURRENT_TIMESTAMP - 100); // Expired 100 seconds ago
VerificationException exception = assertThrows(VerificationException.class,
() -> timeClaimVerifier.verifyExpirationClaim(payload));
assertEquals("JWT has expired", exception.getMessage());
}
@Test
public void testVerifyExpClaimValid() throws VerificationException {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_EXP, CURRENT_TIMESTAMP + 100); // Expires 100 seconds in the future
timeClaimVerifier.verifyExpirationClaim(payload);
}
@Test
public void testVerifyExpClaimEdge() throws VerificationException {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_EXP, CURRENT_TIMESTAMP - 19); // 19 seconds ago, within the 20 second leeway
// No exception expected for JWT expiring within leeway
timeClaimVerifier.verifyExpirationClaim(payload);
}
@Test
public void testVerifyNotBeforeClaimNotYetValid() {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP + 100); // Not valid for another 100 seconds
VerificationException exception = assertThrows(VerificationException.class,
() -> timeClaimVerifier.verifyNotBeforeClaim(payload));
assertEquals("JWT is not yet valid", exception.getMessage());
}
@Test
public void testVerifyNotBeforeClaimValid() throws VerificationException {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP - 100); // Valid since 100 seconds ago
timeClaimVerifier.verifyNotBeforeClaim(payload);
}
// Test for verifyNotBeforeClaim (edge case: valid exactly at current time with leeway)
@Test
public void testVerifyNotBeforeClaimEdge() throws VerificationException {
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_NBF, CURRENT_TIMESTAMP + 19); // 19 seconds in the future, within the 20 second leeway
// No exception expected for JWT becoming valid within leeway
timeClaimVerifier.verifyNotBeforeClaim(payload);
}
@Test
public void testVerifyAgeJwtTooOld() {
int maxAgeAllowed = 300; // 5 minutes
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 361); // 361 seconds old
VerificationException exception = assertThrows(VerificationException.class,
() -> timeClaimVerifier.verifyAge(payload, maxAgeAllowed));
assertEquals("JWT is too old", exception.getMessage());
}
@Test
public void testVerifyAgeValid() throws VerificationException {
int maxAgeAllowed = 300; // 5 minutes
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 100); // Only 100 seconds old
timeClaimVerifier.verifyAge(payload, maxAgeAllowed);
}
@Test
public void testVerifyAgeValidEdge() throws VerificationException {
int maxAgeAllowed = 300; // 5 minutes
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
payload.put(CLAIM_NAME_IAT, CURRENT_TIMESTAMP - 320); // 320 seconds old, within the 20 second leeway
timeClaimVerifier.verifyAge(payload, maxAgeAllowed);
}
@Test
public void instantiationShouldFailIfLeewayNegative() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> new TimeClaimVerifier(createOptsWithLeeway(-1, false)));
assertEquals("Leeway seconds cannot be negative", exception.getMessage());
}
@Test
public void testPermissiveVerifierMissingClaims() throws VerificationException {
// No time claims added
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
// No exception expected as claims are not required
timeClaimVerifier.verifyIssuedAtClaim(payload);
timeClaimVerifier.verifyExpirationClaim(payload);
timeClaimVerifier.verifyNotBeforeClaim(payload);
}
@Test
public void testStrictVerifierMissingClaims() {
// No time claims added
ObjectNode payload = SdJwtUtils.mapper.createObjectNode();
VerificationException exceptionIat = assertThrows(VerificationException.class,
() -> strictTimeClaimVerifier.verifyIssuedAtClaim(payload));
assertEquals("Missing 'iat' claim or null", exceptionIat.getMessage());
VerificationException exceptionExp = assertThrows(VerificationException.class,
() -> strictTimeClaimVerifier.verifyExpirationClaim(payload));
assertEquals("Missing 'exp' claim or null", exceptionExp.getMessage());
VerificationException exceptionNbf = assertThrows(VerificationException.class,
() -> strictTimeClaimVerifier.verifyNotBeforeClaim(payload));
assertEquals("Missing 'nbf' claim or null", exceptionNbf.getMessage());
}
private static TimeClaimVerificationOpts createOptsWithLeeway(int leewaySeconds, boolean requireClaims) {
return TimeClaimVerificationOpts.builder()
.withLeewaySeconds(leewaySeconds)
.withRequireIssuedAtClaim(requireClaims)
.withRequireExpirationClaim(requireClaims)
.withRequireNotBeforeClaim(requireClaims)
.build();
}
}

View File

@ -101,8 +101,8 @@ public abstract class SdJwtPresentationConsumerTest {
private IssuerSignedJwtVerificationOpts defaultIssuerSignedJwtVerificationOpts() {
return IssuerSignedJwtVerificationOpts.builder()
.withValidateIssuedAtClaim(false)
.withValidateNotBeforeClaim(false)
.withRequireIssuedAtClaim(false)
.withRequireNotBeforeClaim(false)
.build();
}
@ -112,8 +112,8 @@ public abstract class SdJwtPresentationConsumerTest {
.withAllowedMaxAge(Integer.MAX_VALUE)
.withNonce("1234567890")
.withAud("https://verifier.example.org")
.withValidateExpirationClaim(false)
.withValidateNotBeforeClaim(false)
.withRequireExpirationClaim(false)
.withRequireNotBeforeClaim(false)
.build();
}
}

View File

@ -42,6 +42,9 @@ import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_EXP;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_IAT;
import static org.keycloak.sdjwt.TimeClaimVerifier.CLAIM_NAME_NBF;
/**
* @author <a href="mailto:Ingrid.Kamga@adorsys.com">Ingrid Kamga</a>
@ -263,13 +266,31 @@ public abstract class SdJwtVPVerificationTest {
long now = Instant.now().getEpochSecond();
ObjectNode kbPayload = exampleKbPayload();
kbPayload.set("iat", mapper.valueToTree(now + 1000));
kbPayload.set(CLAIM_NAME_IAT, mapper.valueToTree(now + 1000));
testShouldFailGeneric2(
kbPayload,
defaultKeyBindingJwtVerificationOpts().build(),
"Key binding JWT: Invalid `iat` claim",
"jwt issued in the future"
"JWT was issued in the future"
);
}
@Test
public void testShouldTolerateKbIssuedInTheFutureWithinLeeway() throws VerificationException {
long now = Instant.now().getEpochSecond();
ObjectNode kbPayload = exampleKbPayload();
// Issued just 5 seconds in the future. Should pass with a leeway of 10 seconds.
kbPayload.set(CLAIM_NAME_IAT, mapper.valueToTree(now + 5));
SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayload);
sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts()
.withLeewaySeconds(10)
.build()
);
}
@ -279,7 +300,7 @@ public abstract class SdJwtVPVerificationTest {
ObjectNode kbPayload = exampleKbPayload();
// This KB-JWT is then issued more than 60s ago
kbPayload.set("iat", mapper.valueToTree(issuerSignedJwtIat - 120));
kbPayload.set(CLAIM_NAME_IAT, mapper.valueToTree(issuerSignedJwtIat - 120));
testShouldFailGeneric2(
kbPayload,
@ -296,15 +317,32 @@ public abstract class SdJwtVPVerificationTest {
long now = Instant.now().getEpochSecond();
ObjectNode kbPayload = exampleKbPayload();
kbPayload.set("exp", mapper.valueToTree(now - 1000));
kbPayload.set(CLAIM_NAME_EXP, mapper.valueToTree(now - 1000));
testShouldFailGeneric2(
kbPayload,
defaultKeyBindingJwtVerificationOpts()
.withValidateExpirationClaim(true)
.build(),
defaultKeyBindingJwtVerificationOpts().build(),
"Key binding JWT: Invalid `exp` claim",
"jwt has expired"
"JWT has expired"
);
}
@Test
public void testShouldTolerateExpiredKbWithinLeeway() throws VerificationException {
long now = Instant.now().getEpochSecond();
ObjectNode kbPayload = exampleKbPayload();
// Expires just 5 seconds ago. Should pass with a leeway of 10 seconds.
kbPayload.set(CLAIM_NAME_EXP, mapper.valueToTree(now - 5));
SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayload);
sdJwtVP.verify(
defaultIssuerVerifyingKeys(),
defaultIssuerSignedJwtVerificationOpts().build(),
defaultKeyBindingJwtVerificationOpts()
.withRequireExpirationClaim(true)
.withLeewaySeconds(10)
.build()
);
}
@ -313,15 +351,13 @@ public abstract class SdJwtVPVerificationTest {
long now = Instant.now().getEpochSecond();
ObjectNode kbPayload = exampleKbPayload();
kbPayload.set("nbf", mapper.valueToTree(now + 1000));
kbPayload.set(CLAIM_NAME_NBF, mapper.valueToTree(now + 1000));
testShouldFailGeneric2(
kbPayload,
defaultKeyBindingJwtVerificationOpts()
.withValidateNotBeforeClaim(true)
.build(),
defaultKeyBindingJwtVerificationOpts().build(),
"Key binding JWT: Invalid `nbf` claim",
"jwt not valid yet"
"JWT is not yet valid"
);
}
@ -399,17 +435,7 @@ public abstract class SdJwtVPVerificationTest {
String exceptionMessage,
String exceptionCauseMessage
) {
KeyBindingJWT keyBindingJWT = KeyBindingJWT.from(
kbPayloadSubstitute,
testSettings.holderSigContext,
KeyBindingJWT.TYP
);
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s20.1-sdjwt+kb.txt");
SdJwtVP sdJwtVP = SdJwtVP.of(
sdJwtVPString.substring(0, sdJwtVPString.lastIndexOf(SdJwt.DELIMITER) + 1)
+ keyBindingJWT.toJws()
);
SdJwtVP sdJwtVP = exampleSdJwtWithCustomKbPayload(kbPayloadSubstitute);
VerificationException exception = assertThrows(
VerificationException.class,
@ -432,8 +458,8 @@ public abstract class SdJwtVPVerificationTest {
private IssuerSignedJwtVerificationOpts.Builder defaultIssuerSignedJwtVerificationOpts() {
return IssuerSignedJwtVerificationOpts.builder()
.withValidateIssuedAtClaim(false)
.withValidateNotBeforeClaim(false);
.withRequireIssuedAtClaim(false)
.withRequireNotBeforeClaim(false);
}
private KeyBindingJwtVerificationOpts.Builder defaultKeyBindingJwtVerificationOpts() {
@ -442,8 +468,8 @@ public abstract class SdJwtVPVerificationTest {
.withAllowedMaxAge(Integer.MAX_VALUE)
.withNonce("1234567890")
.withAud("https://verifier.example.org")
.withValidateExpirationClaim(false)
.withValidateNotBeforeClaim(false);
.withRequireExpirationClaim(false)
.withRequireNotBeforeClaim(false);
}
private ObjectNode exampleKbPayload() {
@ -455,4 +481,17 @@ public abstract class SdJwtVPVerificationTest {
return payload;
}
private SdJwtVP exampleSdJwtWithCustomKbPayload(JsonNode kbPayloadSubstitute) {
KeyBindingJWT keyBindingJWT = KeyBindingJWT.from(
kbPayloadSubstitute,
testSettings.holderSigContext,
KeyBindingJWT.TYP
);
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s20.1-sdjwt+kb.txt");
String sdJwtWithoutKb = sdJwtVPString.substring(0, sdJwtVPString.lastIndexOf(SdJwt.DELIMITER) + 1);
return SdJwtVP.of(sdJwtWithoutKb + keyBindingJWT.toJws());
}
}

View File

@ -1,5 +1,4 @@
{
"iss": "https://issuer.example.com",
"iat": 1683000000,
"exp": 1883000000
"aud": "https://api.example.com"
}

View File

@ -10,8 +10,7 @@
"vg70gfzXO8HR7ERDkL46S6Ior1ey0DvZoEUHupJwoxc"
],
"iss": "https://issuer.example.com",
"iat": 1683000000,
"exp": 1883000000,
"aud": "https://api.example.com",
"address": {
"_sd": [
"IZOyJn0D17aK845iZ8i5hDloSkartiUlvp_hKjNbAt8",

View File

@ -138,9 +138,9 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest {
sdJwt.getSdJwtVerificationContext().verifyIssuance(
List.of(exampleVerifier()),
IssuerSignedJwtVerificationOpts.builder()
.withValidateIssuedAtClaim(false)
.withValidateNotBeforeClaim(false)
.withValidateExpirationClaim(false)
.withRequireIssuedAtClaim(false)
.withRequireNotBeforeClaim(false)
.withRequireExpirationClaim(false)
.build(),
null
);