mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -03:30
[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:
parent
9ef7ff22d2
commit
ce05241c7f
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
core/src/main/java/org/keycloak/sdjwt/TimeClaimVerifier.java
Normal file
130
core/src/main/java/org/keycloak/sdjwt/TimeClaimVerifier.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
215
core/src/test/java/org/keycloak/sdjwt/TimeClaimVerifierTest.java
Normal file
215
core/src/test/java/org/keycloak/sdjwt/TimeClaimVerifierTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
{
|
||||
"iss": "https://issuer.example.com",
|
||||
"iat": 1683000000,
|
||||
"exp": 1883000000
|
||||
"aud": "https://api.example.com"
|
||||
}
|
||||
@ -10,8 +10,7 @@
|
||||
"vg70gfzXO8HR7ERDkL46S6Ior1ey0DvZoEUHupJwoxc"
|
||||
],
|
||||
"iss": "https://issuer.example.com",
|
||||
"iat": 1683000000,
|
||||
"exp": 1883000000,
|
||||
"aud": "https://api.example.com",
|
||||
"address": {
|
||||
"_sd": [
|
||||
"IZOyJn0D17aK845iZ8i5hDloSkartiUlvp_hKjNbAt8",
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user