Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a6d93e9
Enhance SupportedCredentialConfiguration to support optional claims o…
francis-pouatcha Jun 14, 2024
cba3f18
Merge branch 'keycloak:main' into issue-30419
francis-pouatcha Jun 14, 2024
8db7d15
Modifications from issue comment https://github.com/keycloak/keycloak…
francis-pouatcha Jun 15, 2024
e06264d
Fix on issue 30525
francis-pouatcha Jun 19, 2024
5f5f2d1
Replaced code param with pre-authorized_code as stated by spec
francis-pouatcha Jun 23, 2024
aa63e2c
Removed mandatory presence of nbf claim as it is optional in the spec
francis-pouatcha Jun 23, 2024
803ac11
Serializing a proof type enum
francis-pouatcha Jun 23, 2024
2b6f429
Fix: verifier wallet provided proof
francis-pouatcha Jun 24, 2024
e4a1ac2
Merge branch 'keycloak:main' into issue-30525
francis-pouatcha Jun 24, 2024
7152b53
Merged from main
francis-pouatcha Jun 24, 2024
b5e52b3
Fix: removed dupplicate instantiation of service
francis-pouatcha Jun 24, 2024
cc415b1
Fix(test): set code request param grant ty urn:ietf:params:oauth:gran…
francis-pouatcha Jun 25, 2024
af6abfd
Fix(test): set code request param grant ty urn:ietf:params:oauth:gran…
francis-pouatcha Jun 25, 2024
80dd9c7
Fix(test): remove check of nbf from tests, as nbf claim not mandatory…
francis-pouatcha Jun 25, 2024
b5e326d
Processing feedbacks from first review comments
francis-pouatcha Jul 15, 2024
39100ed
Processing feedbacks from first review comments
francis-pouatcha Jul 15, 2024
7ea1d46
Merging from main
francis-pouatcha Jul 15, 2024
346b415
Processing review comments
francis-pouatcha Jul 15, 2024
c7d69ab
Deleting from last main merge
francis-pouatcha Jul 15, 2024
3753e0f
Processing more review comments
francis-pouatcha Jul 15, 2024
15c381d
Merged from upstream
francis-pouatcha Jul 17, 2024
98ba316
Merged conficts from main
francis-pouatcha Jul 20, 2024
d8cd042
Removed unnecessary toString. Proofreading from review comments.
francis-pouatcha Jul 22, 2024
5f99e16
Processed review comments, formating, coding rules
francis-pouatcha Jul 22, 2024
c93bdc3
Merge branch 'keycloak:main' into issue-30525
francis-pouatcha Jul 23, 2024
9717c87
Review comments. Final fields on immutable classes.
francis-pouatcha Jul 23, 2024
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 @@ -39,12 +39,10 @@
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCUserAttributeMapper;
import org.keycloak.protocol.oid4vc.issuance.signing.VCSigningServiceProviderFactory;
import org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.managers.AppAuthManager;

import java.util.EnumMap;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
Expand Down Expand Up @@ -104,7 +102,8 @@ private void addServiceFromComponent(Map<String, VerifiableCredentialsSigningSer
.getKeycloakSessionFactory()
.getProviderFactory(VerifiableCredentialsSigningService.class, componentModel.getProviderId());
if (factory instanceof VCSigningServiceProviderFactory sspf) {
signingServices.put(sspf.supportedFormat(), sspf.create(keycloakSession, componentModel));
VerifiableCredentialsSigningService verifiableCredentialsSigningService = sspf.create(keycloakSession, componentModel);
signingServices.put(verifiableCredentialsSigningService.locator(), verifiableCredentialsSigningService);
} else {
throw new IllegalArgumentException(String.format("The component %s is not a VerifiableCredentialsSigningServiceProviderFactory", componentModel.getProviderId()));
}
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright 2024 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.protocol.oid4vc.issuance;

import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.services.managers.AuthenticationManager;

/**
* Holds the verifiable credential to sign and additional context information.
*
* Helps keeps the {@link VerifiableCredential} as clean pojo. Without any risk to
* mistakenly serialize unwanted information.
*
* @author <a href="mailto:[email protected]">Francis Pouatcha</a>
*/
public class VCIssuanceContext {
private VerifiableCredential verifiableCredential;

private SupportedCredentialConfiguration credentialConfig;
private CredentialRequest credentialRequest;
private AuthenticationManager.AuthResult authResult;

public VerifiableCredential getVerifiableCredential() {
return verifiableCredential;
}

public VCIssuanceContext setVerifiableCredential(VerifiableCredential verifiableCredential) {
this.verifiableCredential = verifiableCredential;
return this;
}

public SupportedCredentialConfiguration getCredentialConfig() {
return credentialConfig;
}

public VCIssuanceContext setCredentialConfig(SupportedCredentialConfiguration credentialConfig) {
this.credentialConfig = credentialConfig;
return this;
}

public CredentialRequest getCredentialRequest() {
return credentialRequest;
}

public VCIssuanceContext setCredentialRequest(CredentialRequest credentialRequest) {
this.credentialRequest = credentialRequest;
return this;
}

public AuthenticationManager.AuthResult getAuthResult() {
return authResult;
}

public VCIssuanceContext setAuthResult(AuthenticationManager.AuthResult authResult) {
this.authResult = authResult;
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ public void close() {
/**
* Checks if the mapper supports the given credential type. Allows to configure them not only per client, but also per VC Type.
*
* @param credentialType type of the VerifiableCredential that should be checked
* @param credentialScope type of the VerifiableCredential that should be checked
* @return true if it is supported
*/
public boolean isTypeSupported(String credentialType) {
var optionalTypes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY));
if (optionalTypes.isEmpty()) {
public boolean isScopeSupported(String credentialScope) {
var optionalScopes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY));
if (optionalScopes.isEmpty()) {
return false;
}
return Arrays.asList(optionalTypes.get().split(",")).contains(credentialType);
return Arrays.asList(optionalScopes.get().split(",")).contains(credentialScope);
}

/**
Expand All @@ -120,4 +120,4 @@ public abstract void setClaimsForCredential(VerifiableCredential verifiableCrede
public abstract void setClaimsForSubject(Map<String, Object> claims,
UserSessionModel userSessionModel);

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* Copyright 2024 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.protocol.oid4vc.issuance.signing;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;

import org.jboss.logging.Logger;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
import org.keycloak.protocol.oid4vc.model.Proof;
import org.keycloak.protocol.oid4vc.model.ProofType;
import org.keycloak.protocol.oid4vc.model.ProofTypeJWT;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.AccessToken;
import org.keycloak.util.JsonSerialization;

/**
* Common signing service logic to handle proofs.
*
* @author <a href="mailto:[email protected]">Francis Pouatcha</a>
*/
public abstract class JwtProofBasedSigningService<T> extends SigningService<T> {

private static final Logger LOGGER = Logger.getLogger(JwtProofBasedSigningService.class);
private static final String CRYPTOGRAPHIC_BINDING_METHOD_JWK = "jwk";
public static final String PROOF_JWT_TYP="openid4vci-proof+jwt";

protected JwtProofBasedSigningService(KeycloakSession keycloakSession, String keyId, String format, String type) {
super(keycloakSession, keyId, format, type);
}

/*
* Validates a proof provided by the client if any.
*
* Returns null if there is no need to include a key binding in the credential
*
* Return the JWK to be included as key binding in the JWK if the provided proof was correctly validated
*
* @param vcIssuanceContext
* @return
* @throws VCIssuerException
* @throws JWSInputException
* @throws VerificationException
* @throws IllegalStateException: is credential type badly configured
* @throws IOException
*/
protected JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException, JWSInputException, VerificationException, IOException {

Optional<Proof> optionalProof = getProofFromContext(vcIssuanceContext);

if (optionalProof.isEmpty()) {
return null; // No proof support
}

// Check key binding config for jwt. Only type supported.
checkCryptographicKeyBinding(vcIssuanceContext);

JWSInput jwsInput = getJwsInput(optionalProof.get());
JWSHeader jwsHeader = jwsInput.getHeader();
validateJwsHeader(vcIssuanceContext, jwsHeader);

JWK jwk = Optional.ofNullable(jwsHeader.getKey())
.orElseThrow(() -> new VCIssuerException("Missing binding key. Make sure provided JWT contains the jwk jwsHeader claim."));

// Parsing the Proof as an access token shall work, as a proof is a strict subset of an access token.
AccessToken proofPayload = JsonSerialization.readValue(jwsInput.getContent(), AccessToken.class);
validateProofPayload(vcIssuanceContext, proofPayload);

SignatureVerifierContext signatureVerifierContext = getVerifier(jwk, jwsHeader.getAlgorithm().name());
if (signatureVerifierContext == null) {
throw new VCIssuerException("No verifier configured for " + jwsHeader.getAlgorithm());
}
if (!signatureVerifierContext.verify(jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jwsInput.getSignature())) {
throw new VCIssuerException("Could not verify provided proof");
}

return jwk;
}

private void checkCryptographicKeyBinding(VCIssuanceContext vcIssuanceContext){
// Make sure we are dealing with a jwk proof.
if (vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported() == null ||
!vcIssuanceContext.getCredentialConfig().getCryptographicBindingMethodsSupported().contains(CRYPTOGRAPHIC_BINDING_METHOD_JWK)) {
throw new IllegalStateException("This SD-JWT implementation only supports jwk as cryptographic binding method");
}
}

private Optional<Proof> getProofFromContext(VCIssuanceContext vcIssuanceContext) throws VCIssuerException {
return Optional.ofNullable(vcIssuanceContext.getCredentialConfig())
.map(SupportedCredentialConfiguration::getProofTypesSupported)
.flatMap(proofTypesSupported -> {
Optional.ofNullable(proofTypesSupported.getJwt())
.orElseThrow(() -> new VCIssuerException("SD-JWT supports only jwt proof type."));

Proof proof = Optional.ofNullable(vcIssuanceContext.getCredentialRequest().getProof())
.orElseThrow(() -> new VCIssuerException("Credential configuration requires a proof of type: " + ProofType.JWT));

if (!Objects.equals(proof.getProofType(), ProofType.JWT)) {
throw new VCIssuerException("Wrong proof type");
}

return Optional.of(proof);
});
}

private JWSInput getJwsInput(Proof proof) throws JWSInputException {
return new JWSInput(proof.getJwt());
}

/**
* As we limit accepted algorithm to the ones listed by the issuer, we can omit checking for "none"
* The Algorithm enum class does not list the none value anyway.
*
* @param vcIssuanceContext
* @param jwsHeader
* @throws VCIssuerException
*/
private void validateJwsHeader(VCIssuanceContext vcIssuanceContext, JWSHeader jwsHeader) throws VCIssuerException {
Optional.ofNullable(jwsHeader.getAlgorithm())
.orElseThrow(() -> new VCIssuerException("Missing jwsHeader claim alg"));

// As we limit accepted algorithm to the ones listed by the server, we can omit checking for "none"
// The Algorithm enum class does not list the none value anyway.
Optional.ofNullable(vcIssuanceContext.getCredentialConfig())
.map(SupportedCredentialConfiguration::getProofTypesSupported)
.map(ProofTypesSupported::getJwt)
.map(ProofTypeJWT::getProofSigningAlgValuesSupported)
.filter(supportedAlgs -> supportedAlgs.contains(jwsHeader.getAlgorithm().name()))
.orElseThrow(() -> new VCIssuerException("Proof signature algorithm not supported: " + jwsHeader.getAlgorithm().name()));

Optional.ofNullable(jwsHeader.getType())
.filter(type -> Objects.equals(PROOF_JWT_TYP, type))
.orElseThrow(() -> new VCIssuerException("JWT type must be: " + PROOF_JWT_TYP));

// KeyId shall not be present alongside the jwk.
Optional.ofNullable(jwsHeader.getKeyId())
.ifPresent(keyId -> {
throw new VCIssuerException("KeyId not expected in this JWT. Use the jwk claim instead.");
});
}

private void validateProofPayload(VCIssuanceContext vcIssuanceContext, AccessToken proofPayload) throws VCIssuerException {
// azp is the id of the client, as mentioned in the access token used to request the credential.
// Token provided from user is obtained with a clientId that support the oidc login protocol.
// oid4vci client doesn't. But it is the client needed at the credential endpoint.
// String azp = vcIssuanceContext.getAuthResult().getToken().getIssuedFor();
// Optional.ofNullable(proofPayload.getIssuer())
// .filter(proofIssuer -> Objects.equals(azp, proofIssuer))
// .orElseThrow(() -> new VCIssuerException("Issuer claim must be null for preauthorized code else the clientId of the client making the request: " + azp));

// The issuer is the token / credential is the audience of the proof
String credentialIssuer = vcIssuanceContext.getVerifiableCredential().getIssuer().toString();
Optional.ofNullable(proofPayload.getAudience()) // Ensure null-safety with Optional
.map(Arrays::asList) // Convert to List<String>
.filter(audiences -> audiences.contains(credentialIssuer)) // Check if the issuer is in the audience list
.orElseThrow(() -> new VCIssuerException(
"Proof not produced for this audience. Audience claim must be: " + credentialIssuer + " but are " + Arrays.asList(proofPayload.getAudience())));

// Validate mandatory iat.
// I do not understand the rationale behind requiring an issue time if we are not checking expiration.
Optional.ofNullable(proofPayload.getIat())
.orElseThrow(() -> new VCIssuerException("Missing proof issuing time. iat claim must be provided."));

// Check cNonce matches.
// If the token endpoint provides a c_nonce, we would like this:
// - stored in the access token
// - having the same validity as the access token.
Optional.ofNullable(vcIssuanceContext.getAuthResult().getToken().getNonce())
.ifPresent(
cNonce -> {
Optional.ofNullable(proofPayload.getNonce())
.filter(nonce -> Objects.equals(cNonce, nonce))
.orElseThrow(() -> new VCIssuerException("Missing or wrong nonce value. Please provide nonce returned by the issuer if any."));

// We expect the expiration to be identical to the token expiration. We assume token expiration has been checked by AuthManager,
// So no_op
}
);

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;

Expand Down Expand Up @@ -54,7 +56,7 @@ public class JwtSigningService extends SigningService<String> {
protected final String issuerDid;

public JwtSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String tokenType, String issuerDid, TimeProvider timeProvider) {
super(keycloakSession, keyId, algorithmType);
super(keycloakSession, keyId, Format.JWT_VC, algorithmType);
this.issuerDid = issuerDid;
this.timeProvider = timeProvider;
this.tokenType = tokenType;
Expand All @@ -69,9 +71,11 @@ public JwtSigningService(KeycloakSession keycloakSession, String keyId, String a
}

@Override
public String signCredential(VerifiableCredential verifiableCredential) {
public String signCredential(VCIssuanceContext vcIssuanceContext) {
LOGGER.debugf("Sign credentials to jwt-vc format.");

VerifiableCredential verifiableCredential = vcIssuanceContext.getVerifiableCredential();

// Get the issuance date from the credential. Since nbf is mandatory, we set it to the current time if not
// provided
long iat = Optional.ofNullable(verifiableCredential.getIssuanceDate())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.signing.vcdm.Ed255192018Suite;
import org.keycloak.protocol.oid4vc.issuance.signing.vcdm.LinkedDataCryptographicSuite;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oid4vc.model.vcdm.LdProof;

Expand All @@ -48,7 +50,7 @@ public class LDSigningService extends SigningService<VerifiableCredential> {
private final String keyId;

public LDSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String ldpType, TimeProvider timeProvider, Optional<String> kid) {
super(keycloakSession, keyId, algorithmType);
super(keycloakSession, keyId, Format.LDP_VC, algorithmType);
this.timeProvider = timeProvider;
this.keyId = kid.orElse(keyId);
KeyWrapper signingKey = getKey(keyId, algorithmType);
Expand All @@ -71,8 +73,8 @@ public LDSigningService(KeycloakSession keycloakSession, String keyId, String al
}

@Override
public VerifiableCredential signCredential(VerifiableCredential verifiableCredential) {
return addProof(verifiableCredential);
public VerifiableCredential signCredential(VCIssuanceContext vcIssuanceContext) {
return addProof(vcIssuanceContext.getVerifiableCredential());
}

// add the signed proof to the credential.
Expand Down
Loading