8000 [OID4VCI-HAIP] Pass oid4vci-1_0-issuer-fail-replay-nonce by tdiesler · Pull Request #47479 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@
import java.util.List;
import java.util.Optional;

import org.keycloak.common.util.Time;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
import org.keycloak.protocol.oid4vc.model.ErrorType;
Expand All @@ -30,6 +35,10 @@
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;

import org.apache.commons.codec.binary.Hex;

import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_NONCE;

/**
* Validates attestation proofs as per OID4VCI specification.
*
Expand Down Expand Up @@ -63,6 +72,19 @@ public List<JWK> validateProof(VCIssuanceContext vcIssuanceContext) throws VCIss
throw new VCIssuerException(ErrorType.INVALID_PROOF, "No valid attested keys found in attestation proof");
}

// Nonce replay protection
//
String nonce = attestationBody.getNonce();
if (nonce != null) {
RealmModel realmModel = keycloakSession.getContext().getRealm();
SingleUseObjectProvider singleUseCache = keycloakSession.singleUseObjects();
String hashString = Hex.encodeHexString(HashUtils.hash("SHA1", nonce.getBytes()));
Long nonceLifetimeSeconds = realmModel.getAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS, 60L);
if (!singleUseCache.putIfAbsent(hashString, Time.currentTime() + 10 * nonceLifetimeSeconds)) {
throw new VCIssuerException(INVALID_NONCE, "Nonce in proof has already been used");
}
}

return attestationBody.getAttestedKeys();
} catch (VCIssuerException e) {
throw e; // Re-throw specific exceptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ public static void handleDPoPHeader(KeycloakSession keycloakSession,

HttpRequest request = keycloakSession.getContext().getHttpRequest();
final boolean isClientRequiresDpop = clientConfig != null && clientConfig.isUseDPoP();
final boolean isDpopHeaderPresent = request.getHttpHeaders().getHeaderString(DPOP_HTTP_HEADER) != null;
final String dpopHeaderValue = request.getHttpHeaders().getHeaderString(DPOP_HTTP_HEADER);
final boolean isDpopHeaderPresent = dpopHeaderValue != null;

if (!isClientRequiresDpop && !isDpopHeaderPresent) {
return;
Expand Down Expand Up @@ -391,8 +392,8 @@ public DPoPReplayCheck(KeycloakSession session, int lifetime) {
@Override
public boolean test(DPoP t) throws DPoPVerificationException {
SingleUseObjectProvider singleUseCache = session.singleUseObjects();
byte[] hash = HashUtils.hash("SHA1", (t.getId() + "\n" + t.getHttpUri()).getBytes());
String hashString = Hex.encodeHexString(hash);
String hashKey = t.getId() + "\n" + t.getHttpUri();
String hashString = Hex.encodeHexString(HashUtils.hash("SHA1", hashKey.getBytes()));
if (!singleUseCache.putIfAbsent(hashString, (int)(t.getIat() + lifetime - Time.currentTime()))) {
throw new DPoPVerificationException(t, "DPoP proof has already been used");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.keycloak.tests.oid4vc;

import java.net.URI;
import java.util.List;

import org.keycloak.TokenVerifier;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestServerConfig;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.util.JsonSerialization;

import org.junit.jupiter.api.Test;

import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

@KeycloakIntegrationTest(config = VCTestServerConfig.class)
public class OID4VCredentialByScopeTest extends OID4VCIssuerTestBase {

@Test
public void testNoOffer_Scope() throws Exception {

var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);

// Send AuthorizationRequest
//
AuthorizationEndpointResponse authResponse = wallet
.authorizationRequest()
.scope(ctx.getScope())
.send(ctx.getHolder(), "password");
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");

// Build and send AccessTokenRequest
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");

String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier, "No authorized credential identifier");

// Send the CredentialRequest
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(wallet.generateJwtProof(ctx, ctx.getHolder()))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}

@Test
public void testNoOffer_Scope_AuthDetails() throws Exception {

var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);

CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx);

OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(ctx.getCredentialConfigurationId());
authDetail.setLocations(List.of(issuerMetadata.getCredentialIssuer()));

// Send AuthorizationRequest
//
AuthorizationEndpointResponse authResponse = wallet
.authorizationRequest()
.scope(ctx.getScope())
.authorizationDetails(authDetail)
.send(ctx.getHolder(), "password");
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");

// Build and send AccessTokenRequest
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");

String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier, "No authorized credential identifier");

// Send the CredentialRequest
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(wallet.generateJwtProof(ctx, ctx.getHolder()))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}

// Private ---------------------------------------------------------------------------------------------------------

private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception {

String scope = ctx.getCredentialScope().getName();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull(credentialObj, "The first credential in the array should not be null");

JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
Object vc = jsonWebToken.getOtherClaims().get("vc");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
assertEquals(List.of(scope), credential.getType());
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
assertEquals(expUser + "@e 8000 mail.cz", credential.getCredentialSubject().getClaims().get("email"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@
import java.util.List;

import org.keycloak.TokenVerifier;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.sdjwt.IssuerSignedJWT;
Expand All @@ -22,10 +19,8 @@
import org.junit.jupiter.api.Test;

import static org.keycloak.OID4VCConstants.CLAIM_NAME_VCT;
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.DEFAULT_CODE_LIFESPAN_S;
import static org.keycloak.protocol.oidc.OIDCLoginProtocol.PROMPT_VALUE_LOGIN;
import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.jwtProofs;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
Expand All @@ -49,80 +44,6 @@
@KeycloakIntegrationTest(config = VCTestServerConfig.class)
public class OID4VCredentialOfferAuthCodeTest extends OID4VCIssuerTestBase {

@Test
public void testNoOffer_Scope() throws Exception {

var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);

// Send AuthorizationRequest
//
AuthorizationEndpointResponse authResponse = wallet
.authorizationRequest()
.scope(ctx.getScope())
.send(ctx.getHolder(), "password");
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");

// Build and send AccessTokenRequest
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");

String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier, "No authorized credential identifier");

// Send the CredentialRequest
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(getJwtProofs(ctx))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}

@Test
public void testNoOffer_Scope_AuthDetails() throws Exception {

var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);

CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx);

OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(ctx.getCredentialConfigurationId());
authDetail.setLocations(List.of(issuerMetadata.getCredentialIssuer()));

// Send AuthorizationRequest
//
AuthorizationEndpointResponse authResponse = wallet
.authorizationRequest()
.scope(ctx.getScope())
.authorizationDetails(authDetail)
.send(ctx.getHolder(), "password");
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");

// Build and send AccessTokenRequest
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");

String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier, "No authorized credential identifier");

// Send the CredentialRequest
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(getJwtProofs(ctx))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}

@Test
public void testAuthCodeOffer_Anonymous() throws Exception {

Expand Down Expand Up @@ -157,7 +78,7 @@ public void testAuthCodeOffer_Anonymous() throws Exception {
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(getJwtProofs(ctx))
.proofs(wallet.generateJwtProof(ctx, ctx.getHolder()))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
Expand Down Expand Up @@ -229,7 +150,7 @@ public void testAuthCodeOffer_Anonymous_multipleOffers() throws Exception {
//
CredentialResponse credResponse = wallet.credentialRequest(ctx1, accessToken1)
.credentialIdentifier(authorizedIdentifier1)
.proofs(getJwtProofs(ctx1))
.proofs(wallet.generateJwtProof(ctx1, ctx1.getHolder()))
.send().getCredentialResponse();

verifyCredentialResponse(ctx1, ctx1.getHolder(), credResponse);
Expand Down Expand Up @@ -285,7 +206,7 @@ public void testAuthCodeOffer_Anonymous_expiredOffer() throws Exception {
IllegalStateException error = assertThrows(IllegalStateException.class,
() -> wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(getJwtProofs(ctx))
.proofs(wallet.generateJwtProof(ctx, ctx.getHolder()))
.send().getCredentialResponse());
assertTrue(error.getMessage().contains("Credential offer has already expired"), error.getMessage());
timeOffSet.set(0);
Expand Down Expand Up @@ -325,7 +246,7 @@ public void testAuthCodeOffer_Targeted() throws Exception {
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(getJwtProofs(ctx))
.proofs(wallet.generateJwtProof(ctx, ctx.getHolder()))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
Expand All @@ -347,10 +268,4 @@ private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, Cre
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}

private Proofs getJwtProofs(OID4VCTestContext ctx) {
String cNonce = oauth.oid4vc().nonceRequest().send().getNonce();
String issuer = wallet.getIssuerMetadata(ctx).getCredentialIssuer();
return jwtProofs(issuer, cNonce);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@

import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.UserRepresentation;
Expand All @@ -20,8 +18,6 @@

import org.junit.jupiter.api.Test;

import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.jwtProofs;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
Expand Down Expand Up @@ -89,7 +85,7 @@ public void testPreAuthOffer_SelfIssued() throws Exception {
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(getJwtProofs(ctx))
.proofs(wallet.generateJwtProof(ctx, ctx.getHolder()))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getIssuer(), credResponse);
Expand Down Expand Up @@ -119,7 +115,7 @@ public void testPreAuthOffer_Targeted() throws Exception {
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(getJwtProofs(ctx))
.proofs(wallet.generateJwtProof(ctx, ctx.getHolder()))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
Expand All @@ -141,10 +137,4 @@ private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, Cre
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}

private Proofs getJwtProofs(OID4VCTestContext ctx) {
String cNonce = oauth.oid4vc().nonceRequest().send().getNonce();
CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx);
return jwtProofs(issuerMetadata.getCredentialIssuer(), cNonce);
}
}
Loading
0