mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-28 04:13:22 -04:00
[OID4VCI] Migrate OID4VCCredentialOfferMatrixTest (#46946)
closes #46971 Signed-off-by: Thomas Diesler <tdiesler@ibm.com>
This commit is contained in:
parent
014267dc0e
commit
b2dbdd3866
12 changed files with 738 additions and 527 deletions
|
|
@ -360,7 +360,7 @@ public class OID4VCIssuerEndpoint {
|
|||
* Creates a Credential Offer that is bound to a specific user.
|
||||
*/
|
||||
public Response createCredentialOffer(String credConfigId, boolean preAuthorized, String targetUser) {
|
||||
return createCredentialOffer(credConfigId, preAuthorized, false, targetUser, null, OfferResponseType.URI, 0, 0);
|
||||
return createCredentialOffer(credConfigId, preAuthorized, targetUser, null, OfferResponseType.URI, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -417,7 +417,6 @@ public class OID4VCIssuerEndpoint {
|
|||
* @param credConfigId A valid credential configuration id
|
||||
* @param preAuthorized A flag whether the offer should be pre-authorized
|
||||
* @param targetUser The username that the offer is authorized for
|
||||
* @param withTxCode A flag whether a tx_code should be generated for a pre-auth offer
|
||||
* @param expireAt The date/time when the offer expires (in Unix timestamp seconds)
|
||||
* @param responseType The response type, which can be 'uri', 'qr' or 'uri+qr'
|
||||
* @param width The width of the QR code image
|
||||
|
|
@ -429,7 +428,6 @@ public class OID4VCIssuerEndpoint {
|
|||
public Response createCredentialOffer(
|
||||
@QueryParam("credential_configuration_id") String credConfigId,
|
||||
@QueryParam("pre_authorized") @DefaultValue("true") Boolean preAuthorized,
|
||||
@QueryParam("tx_code") @DefaultValue("false") Boolean withTxCode,
|
||||
@QueryParam("target_user") String targetUser,
|
||||
@QueryParam("expire") Integer expireAt,
|
||||
@QueryParam("type") @DefaultValue("uri") OfferResponseType responseType,
|
||||
|
|
@ -546,12 +544,6 @@ public class OID4VCIssuerEndpoint {
|
|||
String targetUserId = Optional.ofNullable(targetUserModel).map(UserModel::getId).orElse(null);
|
||||
CredentialOfferState offerState = new CredentialOfferState(credOffer, targetClientId, targetUserId, expireAt);
|
||||
|
||||
// Generate the TxCode
|
||||
//
|
||||
if (preAuthorized && withTxCode) {
|
||||
offerState.generateTxCode();
|
||||
}
|
||||
|
||||
// Store the CredentialOfferState
|
||||
//
|
||||
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
|
||||
|
|
@ -567,11 +559,9 @@ public class OID4VCIssuerEndpoint {
|
|||
.detail(Details.VERIFIABLE_CREDENTIAL_TARGET_USER_ID, targetUserId)
|
||||
.success();
|
||||
|
||||
String redactedTxCode = !Strings.isEmpty(offerState.getTxCode()) ? "******" : null;
|
||||
CredentialOfferURI credOfferURI = new CredentialOfferURI()
|
||||
.setIssuer(credOffer.getCredentialIssuer() + "/protocol/" + OID4VC_PROTOCOL + "/" + CREDENTIAL_OFFER_PATH)
|
||||
.setNonce(offerState.getNonce())
|
||||
.setTxCode(redactedTxCode);
|
||||
.setNonce(offerState.getNonce());
|
||||
|
||||
// Respond with QR-Code as 'image/png'
|
||||
if (responseType == OfferResponseType.QR) {
|
||||
|
|
@ -580,7 +570,7 @@ public class OID4VCIssuerEndpoint {
|
|||
}
|
||||
|
||||
// Respond with URI + QR-Code as 'application/json'
|
||||
if (responseType == OfferResponseType.URI_AND_QR) {
|
||||
if (responseType == OfferResponseType.URI_QR) {
|
||||
byte[] qrBytes = generateQrCode(credOfferURI, width, height);
|
||||
String encodedBytes = Arrays.toString(Base64.getEncoder().encode(qrBytes));
|
||||
credOfferURI.setQrCode("data:image/png;base64," + encodedBytes);
|
||||
|
|
|
|||
|
|
@ -35,9 +35,6 @@ public class CredentialOfferURI {
|
|||
private String issuer;
|
||||
private String nonce;
|
||||
|
||||
@JsonProperty("tx_code")
|
||||
private String txCode;
|
||||
|
||||
@JsonProperty("qr_code")
|
||||
private String qrCode;
|
||||
|
||||
|
|
@ -59,15 +56,6 @@ public class CredentialOfferURI {
|
|||
return this;
|
||||
}
|
||||
|
||||
public String getTxCode() {
|
||||
return txCode;
|
||||
}
|
||||
|
||||
public CredentialOfferURI setTxCode(String txCode) {
|
||||
this.txCode = txCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getQrCode() {
|
||||
return qrCode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public enum OfferResponseType {
|
|||
|
||||
QR("qr"),
|
||||
URI("uri"),
|
||||
URI_AND_QR("uri+qr");
|
||||
URI_QR("uri_qr");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
|
@ -50,8 +50,8 @@ public enum OfferResponseType {
|
|||
return QR;
|
||||
} else if (v.equals(URI.getValue())) {
|
||||
return URI;
|
||||
} else if (v.equals(URI_AND_QR.getValue())) {
|
||||
return URI_AND_QR;
|
||||
} else if (v.equals(URI_QR.getValue())) {
|
||||
return URI_QR;
|
||||
} else return null;
|
||||
})
|
||||
.orElseThrow(() -> new IllegalArgumentException(String.format("%s is not a supported OfferUriType.", value)));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,340 @@
|
|||
package org.keycloak.tests.oid4vc;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.keycloak.admin.client.Keycloak;
|
||||
import org.keycloak.admin.client.resource.RealmResource;
|
||||
import org.keycloak.admin.client.resource.UserResource;
|
||||
import org.keycloak.jose.jws.JWSHeader;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse.Credential;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testframework.oauth.OAuthClient;
|
||||
import org.keycloak.testsuite.util.oauth.AbstractOAuthClient;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenRequest;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
|
||||
import org.keycloak.testsuite.util.oauth.LoginUrlBuilder;
|
||||
import org.keycloak.testsuite.util.oauth.PkceGenerator;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferRequest;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriRequest;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialRequest;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.PreAuthorizedCodeGrantRequest;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
|
||||
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
|
||||
import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestRealmConfig.TEST_REALM_NAME;
|
||||
import static org.keycloak.tests.oid4vc.OID4VCTestContext.ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY;
|
||||
import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_OFFER_ATTACHMENT_KEY;
|
||||
import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIAL_OFFER_URI_ATTACHMENT_KEY;
|
||||
import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIAL_RESPONSE_ATTACHMENT_KEY;
|
||||
import static org.keycloak.tests.oid4vc.OID4VCTestContext.ISSUER_METADATA_ATTACHMENT_KEY;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* A basic Wallet to exercise various OID4VCI message flows.
|
||||
*
|
||||
* Wallet state between messages is maintained in {@code OID4VCTestContext}.
|
||||
*
|
||||
* @author <a href="mailto:tdiesler@ibm.com">Thomas Diesler</a>
|
||||
*/
|
||||
public class OID4VCBasicWallet {
|
||||
|
||||
final Keycloak keycloak;
|
||||
final OAuthClient oauth;
|
||||
|
||||
final Set<String> loginUsers = new HashSet<>();
|
||||
|
||||
public OID4VCBasicWallet(Keycloak keycloak, OAuthClient oauth) {
|
||||
this.keycloak = keycloak;
|
||||
this.oauth = oauth;
|
||||
}
|
||||
|
||||
// Composite Actions -----------------------------------------------------------------------------------------------
|
||||
|
||||
public CredentialsOffer createPreAuthCredentialOffer(OID4VCTestContext ctx, String targetUser) throws Exception {
|
||||
|
||||
// Get Issuer AccessToken
|
||||
//
|
||||
AccessTokenResponse issTokenResponse = getIssuerAccessToken(ctx.issuer);
|
||||
assertNotNull(issTokenResponse.getAccessToken(), "No accessToken");
|
||||
|
||||
// Exclude scope: <credScope>
|
||||
// Require role: credential-offer-create
|
||||
String issToken = validateIssuerAccessToken(issTokenResponse,
|
||||
List.of(), List.of(ctx.credScopeName),
|
||||
List.of(CREDENTIAL_OFFER_CREATE.getName()), List.of());
|
||||
|
||||
// Create Pre-Authorized CredentialOffer
|
||||
//
|
||||
CredentialOfferURI credOfferUri;
|
||||
try {
|
||||
credOfferUri = createCredentialOffer(ctx, ctx.credConfigId)
|
||||
.preAuthorized(true)
|
||||
.targetUser(targetUser)
|
||||
.bearerToken(issToken)
|
||||
.send().getCredentialOfferURI();
|
||||
} finally {
|
||||
logout(ctx.issuer);
|
||||
}
|
||||
|
||||
// Fetch the CredentialsOffer
|
||||
//
|
||||
CredentialsOffer credOffer = getCredentialOffer(ctx, credOfferUri)
|
||||
.send().getCredentialsOffer();
|
||||
|
||||
String preAuthCode = credOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode();
|
||||
assertNotNull(preAuthCode, "No PreAuth Code");
|
||||
|
||||
return credOffer;
|
||||
}
|
||||
|
||||
// Low Level Messages ----------------------------------------------------------------------------------------------
|
||||
|
||||
public CredentialOfferUriRequest createCredentialOffer(OID4VCTestContext ctx, String credConfigId) {
|
||||
CredentialOfferUriRequest request = new CredentialOfferUriRequest(oauth, credConfigId) {
|
||||
public CredentialOfferUriResponse send() {
|
||||
CredentialOfferUriResponse response = super.send();
|
||||
ctx.putAttachment(CREDENTIAL_OFFER_URI_ATTACHMENT_KEY, response.getCredentialOfferURI());
|
||||
return response;
|
||||
}
|
||||
};
|
||||
return request;
|
||||
}
|
||||
|
||||
public CredentialOfferRequest getCredentialOffer(OID4VCTestContext ctx, CredentialOfferURI credOfferUri) {
|
||||
CredentialOfferRequest request = new CredentialOfferRequest(oauth, credOfferUri) {
|
||||
public CredentialOfferResponse send() {
|
||||
CredentialOfferResponse response = super.send();
|
||||
ctx.putAttachment(CREDENTIALS_OFFER_ATTACHMENT_KEY, response.getCredentialsOffer());
|
||||
return response;
|
||||
}
|
||||
};
|
||||
return request;
|
||||
}
|
||||
|
||||
public AuthorizationEndpointRequest authorizationRequest() {
|
||||
AuthorizationEndpointRequest request = new AuthorizationEndpointRequest(oauth) {
|
||||
public AuthorizationEndpointResponse send(String username, String password) {
|
||||
loginUsers.add(username);
|
||||
return super.send(username, password);
|
||||
}
|
||||
};
|
||||
return request;
|
||||
}
|
||||
|
||||
public AccessTokenRequest accessTokenRequest(OID4VCTestContext ctx, String authCode) {
|
||||
AccessTokenRequest request = new AccessTokenRequest(oauth, authCode) {
|
||||
public AccessTokenResponse send() {
|
||||
AccessTokenResponse response = super.send();
|
||||
ctx.putAttachment(ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY, response);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
return request;
|
||||
}
|
||||
|
||||
public PreAuthorizedCodeGrantRequest preAuthAccessTokenRequest(OID4VCTestContext ctx, String preAuthCode) {
|
||||
PreAuthorizedCodeGrantRequest request = new PreAuthorizedCodeGrantRequest(oauth, preAuthCode) {
|
||||
public AccessTokenResponse send() {
|
||||
AccessTokenResponse response = super.send();
|
||||
ctx.putAttachment(ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY, response);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
return request;
|
||||
}
|
||||
|
||||
public Oid4vcCredentialRequest credentialRequest(OID4VCTestContext ctx, String accessToken) {
|
||||
Oid4vcCredentialRequest request = new Oid4vcCredentialRequest(oauth, new CredentialRequest()) {
|
||||
public Oid4vcCredentialResponse send() {
|
||||
Oid4vcCredentialResponse response = super.send();
|
||||
CredentialResponse credentialResponse = response.getCredentialResponse();
|
||||
ctx.putAttachment(CREDENTIAL_RESPONSE_ATTACHMENT_KEY, credentialResponse);
|
||||
return response;
|
||||
}
|
||||
}.bearerToken(accessToken);
|
||||
return request;
|
||||
}
|
||||
|
||||
public AccessTokenResponse getIssuerAccessToken(String username) {
|
||||
PkceGenerator pkce = PkceGenerator.s256();
|
||||
AuthorizationEndpointResponse authResponse = authorizationRequest()
|
||||
.codeChallenge(pkce)
|
||||
.send(username, "password");
|
||||
|
||||
String authCode = authResponse.getCode();
|
||||
assertNotNull(authCode, "No authCode");
|
||||
AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authCode)
|
||||
.codeVerifier(pkce)
|
||||
.send();
|
||||
assertNotNull(tokenResponse.getAccessToken(), "No AccessToken");
|
||||
return tokenResponse;
|
||||
}
|
||||
|
||||
public CredentialIssuer getIssuerMetadata(OID4VCTestContext ctx) {
|
||||
CredentialIssuer issuerMetadata = Optional.ofNullable(ctx.getAttachment(ISSUER_METADATA_ATTACHMENT_KEY))
|
||||
.orElse(oauth.oid4vc().doIssuerMetadataRequest().getMetadata());
|
||||
ctx.putAttachment(ISSUER_METADATA_ATTACHMENT_KEY, issuerMetadata);
|
||||
return issuerMetadata;
|
||||
}
|
||||
|
||||
public void logout() {
|
||||
for (String user : loginUsers) {
|
||||
logout(user);
|
||||
}
|
||||
loginUsers.clear();
|
||||
}
|
||||
|
||||
public void logout(String username) {
|
||||
RealmResource realm = keycloak.realm(TEST_REALM_NAME);
|
||||
UserRepresentation userRep = realm.users().search(username).get(0);
|
||||
UserResource userResource = realm.users().get(userRep.getId());
|
||||
userResource.logout();
|
||||
}
|
||||
|
||||
// State Validation ------------------------------------------------------------------------------------------------
|
||||
|
||||
public void verifyCredentialsSignature(CredentialResponse credResponse, String algorithm) throws Exception {
|
||||
for (Credential credEntry : credResponse.getCredentials()) {
|
||||
|
||||
String encodedCredential = credEntry.getCredential().toString();
|
||||
JWSInput jwsInput = new JWSInput(encodedCredential);
|
||||
JWSHeader header = jwsInput.getHeader();
|
||||
|
||||
assertEquals(algorithm, header.getRawAlgorithm());
|
||||
oauth.verifyToken(encodedCredential, JsonWebToken.class);
|
||||
}
|
||||
}
|
||||
|
||||
public String validateIssuerAccessToken(
|
||||
AccessTokenResponse tokenResponse,
|
||||
List<String> includeScopes, List<String> excludeScopes,
|
||||
List<String> includeRoles, List<String> excludeRoles
|
||||
) throws Exception {
|
||||
|
||||
String accessToken = tokenResponse.getAccessToken();
|
||||
JsonWebToken jwt = JsonSerialization.readValue(new JWSInput(accessToken).getContent(), JsonWebToken.class);
|
||||
List<String> wasScopes = Arrays.stream(((String) jwt.getOtherClaims().get("scope")).split("\\s")).toList();
|
||||
includeScopes.forEach(it -> assertTrue(wasScopes.contains(it), "Missing scope: " + it));
|
||||
excludeScopes.forEach(it -> assertFalse(wasScopes.contains(it), "Invalid scope: " + it));
|
||||
|
||||
List<String> allRoles = new ArrayList<>();
|
||||
Object realmAccess = jwt.getOtherClaims().get("realm_access");
|
||||
if (realmAccess != null) {
|
||||
@SuppressWarnings("unchecked")
|
||||
var realmRoles = ((Map<String, List<String>>) realmAccess).get("roles");
|
||||
allRoles.addAll(realmRoles);
|
||||
}
|
||||
Object resourceAccess = jwt.getOtherClaims().get("resource_access");
|
||||
if (resourceAccess != null) {
|
||||
@SuppressWarnings("unchecked")
|
||||
var resourceAccessMapping = (Map<String, Map<String, List<String>>>) resourceAccess;
|
||||
resourceAccessMapping.forEach((k, v) ->
|
||||
allRoles.addAll(v.get("roles")));
|
||||
}
|
||||
includeRoles.forEach(it -> assertTrue(allRoles.contains(it), "Missing role: " + it));
|
||||
excludeRoles.forEach(it -> assertFalse(allRoles.contains(it), "Invalid role: " + it));
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public String validateHolderAccessToken(OID4VCTestContext ctx, AccessTokenResponse tokenResponse) throws Exception {
|
||||
|
||||
// Check that we can extract the AccessToken
|
||||
if (!tokenResponse.isSuccess()) {
|
||||
fail("Error in AccessToken response: " + tokenResponse.getErrorDescription());
|
||||
}
|
||||
|
||||
String accessToken = tokenResponse.getAccessToken();
|
||||
assertNotNull(accessToken, "No AccessToken");
|
||||
|
||||
// Extract authorization_details from AccessTokenResponse
|
||||
//
|
||||
List<OID4VCAuthorizationDetail> tokenAuthDetails = tokenResponse.getOID4VCAuthorizationDetails();
|
||||
assertTrue(tokenAuthDetails != null && !tokenAuthDetails.isEmpty(), "No authorization_details in AccessTokenResponse");
|
||||
|
||||
// Extract authorization_details from AccessToken (JWT)
|
||||
//
|
||||
JsonWebToken jwt = new JWSInput(tokenResponse.getAccessToken()).readJsonContent(JsonWebToken.class);
|
||||
Object authDetailsClaim = jwt.getOtherClaims().get(AUTHORIZATION_DETAILS);
|
||||
String authDetailsJson = Optional.ofNullable(authDetailsClaim)
|
||||
.map(JsonSerialization::valueAsString)
|
||||
.orElse(null);
|
||||
List<OID4VCAuthorizationDetail> jwtAuthDetails = Optional.ofNullable(authDetailsJson)
|
||||
.map(it -> JsonSerialization.valueFromString(it, OID4VCAuthorizationDetail[].class))
|
||||
.map(Arrays::asList)
|
||||
.orElse(null);
|
||||
assertTrue(jwtAuthDetails != null && !jwtAuthDetails.isEmpty(), "No authorization_details in AccessTokenJWT");
|
||||
|
||||
assertEquals(1, tokenAuthDetails.size(), "Expected one authorization_details entry");
|
||||
var tokenAuthDetail = tokenAuthDetails.get(0);
|
||||
|
||||
assertEquals(1, jwtAuthDetails.size(), "Expected one authorization_details entry");
|
||||
var jwtAuthDetail = jwtAuthDetails.get(0);
|
||||
|
||||
assertEquals(ctx.credConfigId, tokenAuthDetail.getCredentialConfigurationId());
|
||||
assertEquals(tokenAuthDetail, jwtAuthDetail);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public static class AuthorizationEndpointRequest {
|
||||
|
||||
protected final AbstractOAuthClient<?> client;
|
||||
protected final LoginUrlBuilder loginForm;
|
||||
|
||||
public AuthorizationEndpointRequest(AbstractOAuthClient<?> client) {
|
||||
this.client = client;
|
||||
this.loginForm = client.loginForm();
|
||||
}
|
||||
|
||||
public AuthorizationEndpointRequest authorizationDetails(OID4VCAuthorizationDetail authDetail) {
|
||||
loginForm.authorizationDetails(List.of(authDetail));
|
||||
return this;
|
||||
}
|
||||
|
||||
public AuthorizationEndpointRequest codeChallenge(PkceGenerator pkce) {
|
||||
loginForm.codeChallenge(pkce);
|
||||
return this;
|
||||
}
|
||||
|
||||
public AuthorizationEndpointRequest request(String request) {
|
||||
loginForm.request(request);
|
||||
return this;
|
||||
}
|
||||
|
||||
public AuthorizationEndpointRequest scope(String... scopes) {
|
||||
loginForm.scope(scopes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public AuthorizationEndpointResponse send(String username, String password) {
|
||||
loginForm.open();
|
||||
client.fillLoginForm(username, password);
|
||||
return client.parseLoginResponse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
package org.keycloak.tests.oid4vc;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
|
||||
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.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
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.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Credential Offer Validity Matrix
|
||||
* <p>
|
||||
* +----------+----------+---------+------------------------------------------------------+
|
||||
* | Pre-Auth | Username | Valid | Notes |
|
||||
* +----------+----------+---------+------------------------------------------------------+
|
||||
* | no | no | yes | Anonymous offer; any logged-in user may redeem. |
|
||||
* | no | yes | yes | Offer restricted to a specific user. |
|
||||
* +----------+----------+---------+------------------------------------------------------+
|
||||
* | yes | no | no | Pre-auth requires a target user. |
|
||||
* | yes | yes | yes | Pre-auth for a specific target user. |
|
||||
* +----------+----------+---------+------------------------------------------------------+
|
||||
*/
|
||||
@KeycloakIntegrationTest(config = VCTestServerConfig.class)
|
||||
public class OID4VCCredentialOfferMatrixTest extends OID4VCIssuerTestBase {
|
||||
|
||||
OID4VCBasicWallet wallet;
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach() {
|
||||
wallet = new OID4VCBasicWallet(keycloak, oauth);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void afterEach() {
|
||||
wallet.logout();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRealmSetup() {
|
||||
RealmRepresentation realmRep = testRealm.admin().toRepresentation();
|
||||
assertEquals(shouldEnableOid4vci(realmRep), realmRep.isVerifiableCredentialsEnabled());
|
||||
assertEquals(shouldEnableOid4vci(client), isOid4vciEnabled(client));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWithoutOffer_Scope() throws Exception {
|
||||
|
||||
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
|
||||
|
||||
// Send AuthorizationRequest
|
||||
//
|
||||
AuthorizationEndpointResponse authResponse = wallet
|
||||
.authorizationRequest()
|
||||
.scope(ctx.credScopeName)
|
||||
.send(ctx.holder, "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)
|
||||
.send().getCredentialResponse();
|
||||
|
||||
verifyCredentialResponse(ctx, ctx.holder, credResponse);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWithoutOffer_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.credConfigId);
|
||||
authDetail.setLocations(List.of(issuerMetadata.getCredentialIssuer()));
|
||||
|
||||
// Send AuthorizationRequest
|
||||
//
|
||||
AuthorizationEndpointResponse authResponse = wallet
|
||||
.authorizationRequest()
|
||||
.scope(ctx.credScopeName)
|
||||
.authorizationDetails(authDetail)
|
||||
.send(ctx.holder, "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)
|
||||
.send().getCredentialResponse();
|
||||
|
||||
verifyCredentialResponse(ctx, ctx.holder, credResponse);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPreAuthOffer_DisabledUser() {
|
||||
|
||||
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
|
||||
|
||||
// Disable user
|
||||
UserRepresentation userRep = testRealm.admin().users().search(ctx.holder).get(0);
|
||||
UserResource userResource = testRealm.admin().users().get(userRep.getId());
|
||||
userRep.setEnabled(false);
|
||||
userResource.update(userRep);
|
||||
|
||||
try {
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> wallet.createPreAuthCredentialOffer(ctx, ctx.holder));
|
||||
assertTrue(error.getMessage().contains("User 'alice' disabled"), error.getMessage());
|
||||
} finally {
|
||||
userRep.setEnabled(true);
|
||||
userResource.update(userRep);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPreAuthOffer_SelfIssued() throws Exception {
|
||||
|
||||
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
|
||||
|
||||
// Create Pre-Authorized CredentialOffer
|
||||
//
|
||||
CredentialsOffer credOffer = wallet.createPreAuthCredentialOffer(ctx, null);
|
||||
String preAuthCode = credOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode();
|
||||
|
||||
// Redeem Pre-Authorized Code for AccessToken
|
||||
//
|
||||
AccessTokenResponse tokenResponse = wallet.preAuthAccessTokenRequest(ctx, preAuthCode).send();
|
||||
assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription());
|
||||
|
||||
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)
|
||||
.send().getCredentialResponse();
|
||||
|
||||
verifyCredentialResponse(ctx, ctx.issuer, credResponse);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPreAuthOffer_Targeted() throws Exception {
|
||||
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
|
||||
|
||||
// Create Pre-Authorized CredentialOffer
|
||||
//
|
||||
CredentialsOffer credOffer = wallet.createPreAuthCredentialOffer(ctx, ctx.holder);
|
||||
String preAuthCode = credOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode();
|
||||
|
||||
// Redeem Pre-Authorized Code for AccessToken
|
||||
//
|
||||
AccessTokenResponse tokenResponse = wallet.preAuthAccessTokenRequest(ctx, preAuthCode).send();
|
||||
assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription());
|
||||
|
||||
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)
|
||||
.send().getCredentialResponse();
|
||||
|
||||
verifyCredentialResponse(ctx, ctx.holder, credResponse);
|
||||
}
|
||||
|
||||
// Private ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception {
|
||||
|
||||
String scope = ctx.credentialScope.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 + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package org.keycloak.tests.oid4vc;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
|
||||
|
||||
/**
|
||||
* A context that can maintain state across OID4VCI message flows.
|
||||
*
|
||||
* It uses a typed in-memory value store based on attachment keys.
|
||||
* Values can be accessed by type and when there are multiple values - by name+type.
|
||||
*
|
||||
* @author <a href="mailto:tdiesler@ibm.com">Thomas Diesler</a>
|
||||
*/
|
||||
public class OID4VCTestContext {
|
||||
|
||||
static final AttachmentKey<CredentialIssuer> ISSUER_METADATA_ATTACHMENT_KEY = new AttachmentKey<>(CredentialIssuer.class);
|
||||
static final AttachmentKey<CredentialOfferURI> CREDENTIAL_OFFER_URI_ATTACHMENT_KEY = new AttachmentKey<>(CredentialOfferURI.class);
|
||||
static final AttachmentKey<CredentialsOffer> CREDENTIALS_OFFER_ATTACHMENT_KEY = new AttachmentKey<>(CredentialsOffer.class);
|
||||
static final AttachmentKey<AccessTokenResponse> ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(AccessTokenResponse.class);
|
||||
static final AttachmentKey<CredentialResponse> CREDENTIAL_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(CredentialResponse.class);
|
||||
|
||||
ClientRepresentation client;
|
||||
String clientId;
|
||||
String issuer; // Issuing username (i.e. agent who creates credential offers)
|
||||
String holder; // Holder who requests the credential
|
||||
String credConfigId;
|
||||
String credScopeName;
|
||||
CredentialScopeRepresentation credentialScope;
|
||||
|
||||
Map<AttachmentKey<?>, Object> attachments = new HashMap<>();
|
||||
|
||||
public OID4VCTestContext(ClientRepresentation client, CredentialScopeRepresentation credentialScope) {
|
||||
this.client = client;
|
||||
this.clientId = client.getClientId();
|
||||
this.issuer = "john";
|
||||
this.holder = "alice";
|
||||
this.credentialScope = credentialScope;
|
||||
this.credScopeName = credentialScope.getName();
|
||||
this.credConfigId = credentialScope.getCredentialConfigurationId();
|
||||
}
|
||||
|
||||
public List<String> getAuthorizedCredentialIdentifiers() {
|
||||
OID4VCAuthorizationDetail tokenAuthDetails = getOID4VCAuthorizationDetail();
|
||||
return Optional.ofNullable(tokenAuthDetails)
|
||||
.map(OID4VCAuthorizationDetail::getCredentialIdentifiers)
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
|
||||
public String getAuthorizedCredentialIdentifier() {
|
||||
List<String> authorizedIdentifiers = getAuthorizedCredentialIdentifiers();
|
||||
return authorizedIdentifiers.size() == 1 ? authorizedIdentifiers.get(0) : null;
|
||||
}
|
||||
|
||||
public String getAuthorizedCredentialConfigurationId() {
|
||||
OID4VCAuthorizationDetail tokenAuthDetails = getOID4VCAuthorizationDetail();
|
||||
return Optional.ofNullable(tokenAuthDetails)
|
||||
.map(OID4VCAuthorizationDetail::getCredentialConfigurationId)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public List<OID4VCAuthorizationDetail> getOID4VCAuthorizationDetails() {
|
||||
AccessTokenResponse response = assertAttachment(ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY);
|
||||
return Optional.ofNullable(response)
|
||||
.map(AccessTokenResponse::getOID4VCAuthorizationDetails)
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
|
||||
public OID4VCAuthorizationDetail getOID4VCAuthorizationDetail() {
|
||||
List<OID4VCAuthorizationDetail> tokenAuthDetails = getOID4VCAuthorizationDetails();
|
||||
return tokenAuthDetails.size() == 1 ? tokenAuthDetails.get(0) : null;
|
||||
}
|
||||
|
||||
// Attachment Support ----------------------------------------------------------------------------------------------
|
||||
|
||||
<T> void putAttachment(AttachmentKey<T> key, T value) {
|
||||
if (value != null) {
|
||||
attachments.put(key, value);
|
||||
} else {
|
||||
attachments.remove(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
<T> T assertAttachment(AttachmentKey<T> key) {
|
||||
return Optional.of(getAttachment(key)).get();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
<T> T getAttachment(AttachmentKey<T> key) {
|
||||
return (T) attachments.get(key);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
<T> T removeAttachment(AttachmentKey<T> key) {
|
||||
return (T) attachments.remove(key);
|
||||
}
|
||||
|
||||
static class AttachmentKey<T> {
|
||||
private final String name;
|
||||
private final Class<T> type;
|
||||
|
||||
AttachmentKey(Class<T> type) {
|
||||
this(null, type);
|
||||
}
|
||||
|
||||
AttachmentKey(String name, Class<T> type) {
|
||||
this.name = Optional.ofNullable(name).orElse("");
|
||||
this.type = Optional.of(type).get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
AttachmentKey<?> that = (AttachmentKey<?>) o;
|
||||
return Objects.equals(name, that.name) && Objects.equals(type, that.type);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
package org.keycloak.testsuite.util.oauth;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
|
||||
import org.keycloak.representations.ClaimsRepresentation;
|
||||
|
||||
public class LoginUrlBuilder extends AbstractUrlBuilder {
|
||||
|
|
@ -29,6 +31,16 @@ public class LoginUrlBuilder extends AbstractUrlBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
public LoginUrlBuilder authorizationDetails(AuthorizationDetailsJSONRepresentation authDetail) {
|
||||
parameter(OAuth2Constants.AUTHORIZATION_DETAILS, authDetail != null ? List.of(authDetail) : List.of());
|
||||
return this;
|
||||
}
|
||||
|
||||
public LoginUrlBuilder authorizationDetails(List<AuthorizationDetailsJSONRepresentation> authDetails) {
|
||||
parameter(OAuth2Constants.AUTHORIZATION_DETAILS, authDetails);
|
||||
return this;
|
||||
}
|
||||
|
||||
public LoginUrlBuilder state(String state) {
|
||||
parameter(OIDCLoginProtocol.STATE_PARAM, state);
|
||||
return this;
|
||||
|
|
@ -103,8 +115,9 @@ public class LoginUrlBuilder extends AbstractUrlBuilder {
|
|||
parameter(OIDCLoginProtocol.RESPONSE_MODE_PARAM, client.config().getResponseMode());
|
||||
parameter(OAuth2Constants.CLIENT_ID, client.config().getClientId());
|
||||
parameter(OAuth2Constants.REDIRECT_URI, client.config().getRedirectUri());
|
||||
|
||||
parameter(OAuth2Constants.SCOPE, client.config().getScope());
|
||||
if (!params.containsKey(OAuth2Constants.SCOPE)) {
|
||||
parameter(OAuth2Constants.SCOPE, client.config().getScope());
|
||||
}
|
||||
}
|
||||
|
||||
public AuthorizationEndpointResponse doLogin(String username, String password) {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ public class CredentialOfferRequest extends AbstractHttpGetRequest<CredentialOff
|
|||
|
||||
private final CredentialOfferURI credOfferURI;
|
||||
|
||||
CredentialOfferRequest(AbstractOAuthClient<?> client, CredentialOfferURI credOfferUri) {
|
||||
public CredentialOfferRequest(AbstractOAuthClient<?> client, CredentialOfferURI credOfferUri) {
|
||||
super(client);
|
||||
this.credOfferURI = credOfferUri;
|
||||
}
|
||||
|
||||
CredentialOfferRequest(AbstractOAuthClient<?> client, String nonce) {
|
||||
public CredentialOfferRequest(AbstractOAuthClient<?> client, String nonce) {
|
||||
super(client);
|
||||
credOfferURI = new CredentialOfferURI();
|
||||
credOfferURI.setIssuer(client.getEndpoints().getOid4vcCredentialOffer());
|
||||
|
|
|
|||
|
|
@ -15,12 +15,11 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest<Credential
|
|||
|
||||
private final String credConfigId;
|
||||
private Boolean preAuthorized;
|
||||
private Boolean txCode;
|
||||
private String targetUser;
|
||||
private Integer expireAt;
|
||||
private OfferResponseType responseType;
|
||||
|
||||
CredentialOfferUriRequest(AbstractOAuthClient<?> client, String credConfigId) {
|
||||
public CredentialOfferUriRequest(AbstractOAuthClient<?> client, String credConfigId) {
|
||||
super(client);
|
||||
this.credConfigId = credConfigId;
|
||||
}
|
||||
|
|
@ -30,11 +29,6 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest<Credential
|
|||
return this;
|
||||
}
|
||||
|
||||
public CredentialOfferUriRequest txCode(Boolean txCode) {
|
||||
this.txCode = txCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CredentialOfferUriRequest targetUser(String targetUser) {
|
||||
this.targetUser = targetUser;
|
||||
return this;
|
||||
|
|
@ -55,7 +49,6 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest<Credential
|
|||
UriBuilder builder = UriBuilder.fromUri(client.getEndpoints().getOid4vcCredentialOfferUri());
|
||||
if (!Strings.isEmpty(credConfigId)) builder.queryParam("credential_configuration_id", credConfigId);
|
||||
if (preAuthorized != null) builder.queryParam("pre_authorized", preAuthorized);
|
||||
if (txCode != null) builder.queryParam("tx_code", txCode);
|
||||
if (!Strings.isEmpty(targetUser)) builder.queryParam("target_user", targetUser);
|
||||
if (expireAt != null) builder.queryParam("expire", expireAt);
|
||||
if (responseType != null) builder.queryParam("type", responseType.getValue());
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public class Oid4vcCredentialRequest extends AbstractHttpPostRequest<Oid4vcCrede
|
|||
|
||||
private final CredentialRequest credRequest;
|
||||
|
||||
Oid4vcCredentialRequest(AbstractOAuthClient<?> client, CredentialRequest credRequest) {
|
||||
public Oid4vcCredentialRequest(AbstractOAuthClient<?> client, CredentialRequest credRequest) {
|
||||
super(client);
|
||||
this.credRequest = credRequest;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,8 @@ import org.apache.http.client.methods.CloseableHttpResponse;
|
|||
public class PreAuthorizedCodeGrantRequest extends AbstractHttpPostRequest<PreAuthorizedCodeGrantRequest, AccessTokenResponse> {
|
||||
|
||||
private final String preAuthCode;
|
||||
private String txCode;
|
||||
|
||||
PreAuthorizedCodeGrantRequest(AbstractOAuthClient<?> client, String preAuthorizedCode) {
|
||||
public PreAuthorizedCodeGrantRequest(AbstractOAuthClient<?> client, String preAuthorizedCode) {
|
||||
super(client);
|
||||
this.preAuthCode = preAuthorizedCode;
|
||||
}
|
||||
|
|
@ -28,11 +27,6 @@ public class PreAuthorizedCodeGrantRequest extends AbstractHttpPostRequest<PreAu
|
|||
return this;
|
||||
}
|
||||
|
||||
public PreAuthorizedCodeGrantRequest txCode(String txCode) {
|
||||
this.txCode = txCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getEndpoint() {
|
||||
return client.getEndpoints().getToken();
|
||||
|
|
@ -42,7 +36,6 @@ public class PreAuthorizedCodeGrantRequest extends AbstractHttpPostRequest<PreAu
|
|||
protected void initRequest() {
|
||||
parameter(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE);
|
||||
parameter(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthCode);
|
||||
parameter(PreAuthorizedCodeGrantTypeFactory.TX_CODE_PARAM, txCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -1,478 +0,0 @@
|
|||
/*
|
||||
* 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.testsuite.oid4vc.issuance;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.admin.client.resource.UserResource;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.ErrorType;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse;
|
||||
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.apache.directory.api.util.Strings;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.keycloak.OAuth2Constants.SCOPE_OPENID;
|
||||
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
|
||||
import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
|
||||
/**
|
||||
* Credential Offer Validity Matrix
|
||||
* <p>
|
||||
* +----------+----------+---------+------------------------------------------------------+
|
||||
* | Pre-Auth | Username | Valid | Notes |
|
||||
* +----------+----------+---------+------------------------------------------------------+
|
||||
* | no | no | yes | Anonymous offer; any logged-in user may redeem. |
|
||||
* | no | yes | yes | Offer restricted to a specific user. |
|
||||
* +----------+----------+---------+------------------------------------------------------+
|
||||
* | yes | no | no | Pre-auth requires a target user. |
|
||||
* | yes | yes | yes | Pre-auth for a specific target user. |
|
||||
* +----------+----------+---------+------------------------------------------------------+
|
||||
*/
|
||||
public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
String namedClientId = "named-test-app";
|
||||
|
||||
String issUsername = "john";
|
||||
|
||||
String appUsername = "alice";
|
||||
|
||||
String credScopeName = jwtTypeNaturalPersonScopeName;
|
||||
String credConfigId = jwtTypeNaturalPersonScopeName;
|
||||
|
||||
class TestContext {
|
||||
boolean preAuthorized;
|
||||
String issUser;
|
||||
String appUser;
|
||||
CredentialIssuer issuerMetadata;
|
||||
OIDCConfigurationRepresentation authorizationMetadata;
|
||||
SupportedCredentialConfiguration credentialConfiguration;
|
||||
|
||||
TestContext(boolean preAuth, String appUser) {
|
||||
this.preAuthorized = preAuth;
|
||||
this.issUser = issUsername;
|
||||
this.appUser = appUser;
|
||||
this.issuerMetadata = getCredentialIssuerMetadata();
|
||||
this.authorizationMetadata = getAuthorizationMetadata(this.issuerMetadata.getAuthorizationServers().get(0));
|
||||
this.credentialConfiguration = this.issuerMetadata.getCredentialsSupported().get(credConfigId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterAbstractKeycloakTestRealmImport() {
|
||||
ClientRepresentation namedClient = requireExistingClient(namedClientId);
|
||||
assignOptionalClientScope(namedClient, credScopeName);
|
||||
setOid4vciEnabled(namedClient, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialWithoutOffer() throws Exception {
|
||||
var ctx = new TestContext(false, appUsername);
|
||||
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(ctx.issuerMetadata.getCredentialIssuer()));
|
||||
|
||||
// [TODO #44320] Requires Credential scope in AuthorizationRequest although already given in AuthorizationDetails
|
||||
// https://github.com/keycloak/keycloak/issues/44320
|
||||
String accessToken = getBearerToken(clientId, ctx.appUser, credScopeName, authDetail);
|
||||
|
||||
// Extract credential_identifier from the access token's authorization_details
|
||||
JsonWebToken tokenDecoded = new JWSInput(accessToken).readJsonContent(JsonWebToken.class);
|
||||
Object tokenAuthDetails = tokenDecoded.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
assertNotNull("authorization_details not found in access token", tokenAuthDetails);
|
||||
|
||||
// When authorization_details are sent in token request, they are returned in token response with credential_identifiers
|
||||
// The credential request MUST use credential_identifier (not credential_configuration_id)
|
||||
List<OID4VCAuthorizationDetail> authDetailsResponse = JsonSerialization.readValue(
|
||||
JsonSerialization.writeValueAsString(tokenAuthDetails),
|
||||
new TypeReference<>() {}
|
||||
);
|
||||
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
|
||||
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
|
||||
|
||||
OID4VCAuthorizationDetail authDetailResponse = authDetailsResponse.get(0);
|
||||
List<String> credentialIdentifiers = authDetailResponse.getCredentialIdentifiers();
|
||||
assertNotNull("credential_identifiers should be present", credentialIdentifiers);
|
||||
assertFalse("credential_identifiers should not be empty", credentialIdentifiers.isEmpty());
|
||||
|
||||
var credRequest = new CredentialRequest()
|
||||
.setCredentialIdentifier(credentialIdentifiers.get(0));
|
||||
|
||||
CredentialResponse credResponse = sendCredentialRequest(accessToken, credRequest);
|
||||
verifyCredentialResponse(ctx, credResponse);
|
||||
}
|
||||
|
||||
// Pre Authorized --------------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void testCredentialOffer_PreAuth_SelfIssued() throws Exception {
|
||||
runCredentialOfferTest(new TestContext(true, issUsername));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOffer_PreAuth_Targeted() throws Exception {
|
||||
runCredentialOfferTest(new TestContext(true, appUsername));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOffer_PreAuth_DisabledUser() throws Exception {
|
||||
// Disable user
|
||||
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), appUsername);
|
||||
UserRepresentation userRep = user.toRepresentation();
|
||||
userRep.setEnabled(false);
|
||||
user.update(userRep);
|
||||
|
||||
try {
|
||||
runCredentialOfferTest(new TestContext(true, appUsername));
|
||||
fail("Expected " + ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue());
|
||||
} catch (RuntimeException ex) {
|
||||
List.of(ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), "User '" + appUsername + "' disabled")
|
||||
.forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it)));
|
||||
} finally {
|
||||
// Re-enable user
|
||||
userRep.setEnabled(true);
|
||||
user.update(userRep);
|
||||
}
|
||||
}
|
||||
|
||||
// Authorization Code ----------------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
public void testCredentialOffer_noPreAuth_Anonymous() throws Exception {
|
||||
runCredentialOfferTest(new TestContext(false, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOffer_noPreAuth_Targeted() throws Exception {
|
||||
runCredentialOfferTest(new TestContext(false, appUsername));
|
||||
}
|
||||
|
||||
void runCredentialOfferTest(TestContext ctx) throws Exception {
|
||||
|
||||
// Issuer login
|
||||
//
|
||||
String issToken = getBearerToken(clientId, ctx.issUser, SCOPE_OPENID);
|
||||
|
||||
// Exclude scope: <credScope>
|
||||
// Require role: credential-offer-create
|
||||
// [TODO] Require role: credential-offer-create
|
||||
verifyTokenJwt(issToken,
|
||||
List.of(), List.of(ctx.credentialConfiguration.getScope()),
|
||||
List.of(), List.of());
|
||||
|
||||
// Retrieving the credential-offer-uri
|
||||
//
|
||||
CredentialOfferURI credOfferUri = getCredentialOfferUri(ctx, issToken);
|
||||
|
||||
// Issuer logout in order to remove unwanted session state
|
||||
//
|
||||
logout(ctx.issUser);
|
||||
|
||||
try {
|
||||
|
||||
// Using the uri to get the actual credential offer
|
||||
//
|
||||
CredentialsOffer credOffer = getCredentialsOffer(ctx, credOfferUri);
|
||||
|
||||
if (credOffer.getCredentialConfigurationIds().size() > 1)
|
||||
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
|
||||
|
||||
if (ctx.preAuthorized) {
|
||||
|
||||
// Get an access token for the pre-authorized code (PAC)
|
||||
//
|
||||
// For a PAC access token, we treat all scopes and all roles as non-meaningful.
|
||||
// The access token:
|
||||
// 1. has no authenticated user, and therefore cannot carry any user roles
|
||||
// 2. does not perform authorization-based scope filtering
|
||||
// 3. does not derive scopes from the client configuration
|
||||
// 4. does not reflect anything from the credential offer
|
||||
//
|
||||
AccessTokenResponse accessToken = getPreAuthorizedAccessTokenResponse(credOffer);
|
||||
assertTrue(accessToken.getErrorDescription(), accessToken.isSuccess());
|
||||
|
||||
List<OID4VCAuthorizationDetail> authDetailsResponse = accessToken.getOID4VCAuthorizationDetails();
|
||||
if (authDetailsResponse == null || authDetailsResponse.isEmpty()) {
|
||||
throw new IllegalStateException("No authorization_details in token response");
|
||||
}
|
||||
if (authDetailsResponse.size() > 1) {
|
||||
throw new IllegalStateException("Multiple authorization_details in token response");
|
||||
}
|
||||
OID4VCAuthorizationDetail authDetailResponse = authDetailsResponse.get(0);
|
||||
|
||||
// Get the credential and verify
|
||||
//
|
||||
CredentialResponse credResponse = getCredentialByAuthDetail(accessToken.getAccessToken(), authDetailResponse);
|
||||
verifyCredentialResponse(ctx, credResponse);
|
||||
|
||||
} else {
|
||||
|
||||
String username = ctx.appUser != null ? ctx.appUser : appUsername;
|
||||
String credConfigId = credOffer.getCredentialConfigurationIds().get(0);
|
||||
|
||||
SupportedCredentialConfiguration credConfig = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
|
||||
String scope = credConfig.getScope();
|
||||
|
||||
AccessTokenResponse tokenResponse = getBearerTokenResponse(clientId, username, scope);
|
||||
String accessToken = tokenResponse.getAccessToken();
|
||||
|
||||
// Get the credential and verify
|
||||
//
|
||||
CredentialResponse credResponse = getCredentialByOffer(accessToken, tokenResponse, credOffer);
|
||||
verifyCredentialResponse(ctx, credResponse);
|
||||
}
|
||||
} finally {
|
||||
if (ctx.appUser != null) {
|
||||
logout(ctx.appUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Private ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
private AccessTokenResponse getBearerTokenResponse(String clientId, String username, String scope) {
|
||||
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
|
||||
|
||||
// For credential scopes, we need to request authorization_details to get credential_identifier
|
||||
if (scope != null && scope.equals(credScopeName)) {
|
||||
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
|
||||
authDetail.setType(OPENID_CREDENTIAL);
|
||||
authDetail.setCredentialConfigurationId(credConfigId);
|
||||
authDetail.setLocations(List.of(getCredentialIssuerMetadata().getCredentialIssuer()));
|
||||
|
||||
// Set the redirect URI from the client's configuration
|
||||
if (client.getRedirectUris() != null && !client.getRedirectUris().isEmpty()) {
|
||||
oauth.redirectUri(client.getRedirectUris().get(0));
|
||||
}
|
||||
|
||||
String authCode = getAuthorizationCode(oauth, client, username, scope);
|
||||
return getBearerToken(oauth, authCode, authDetail);
|
||||
}
|
||||
|
||||
// For non-credential scopes, use the appropriate flow based on client configuration
|
||||
if (client.isDirectAccessGrantsEnabled()) {
|
||||
return getBearerTokenDirectAccess(oauth, client, username, scope);
|
||||
} else {
|
||||
return getBearerTokenCodeFlow(oauth, client, username, scope);
|
||||
}
|
||||
}
|
||||
|
||||
private List<OID4VCAuthorizationDetail> extractAuthorizationDetails(AccessTokenResponse tokenResponse) {
|
||||
// First check if already populated in token response
|
||||
List<OID4VCAuthorizationDetail> authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails();
|
||||
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
|
||||
return authDetailsResponse;
|
||||
}
|
||||
|
||||
// Otherwise, extract from JWT access token
|
||||
try {
|
||||
JsonWebToken jwt = new JWSInput(tokenResponse.getAccessToken()).readJsonContent(JsonWebToken.class);
|
||||
Object authDetails = jwt.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
|
||||
if (authDetails != null) {
|
||||
return JsonSerialization.readValue(
|
||||
JsonSerialization.writeValueAsString(authDetails),
|
||||
new TypeReference<>() {}
|
||||
);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ignore - authorization_details not present or couldn't be parsed
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getBearerToken(String clientId, String username, String scope) {
|
||||
return getBearerTokenResponse(clientId, username, scope).getAccessToken();
|
||||
}
|
||||
|
||||
private String getBearerToken(String clientId, String username, String scope, OID4VCAuthorizationDetail... authDetail) {
|
||||
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
|
||||
String authCode = getAuthorizationCode(oauth, client, username, scope);
|
||||
return getBearerToken(oauth, authCode, authDetail).getAccessToken();
|
||||
}
|
||||
|
||||
private String getBearerTokenAndLogout(String clientId, String userId, String scope) {
|
||||
String token = getBearerToken(clientId, userId, scope);
|
||||
logout(userId);
|
||||
return token;
|
||||
}
|
||||
|
||||
private void logout(String userId) {
|
||||
findUserByUsernameId(testRealm(), userId).logout();
|
||||
}
|
||||
|
||||
private CredentialOfferURI getCredentialOfferUri(TestContext ctx, String token) throws Exception {
|
||||
String credConfigId = ctx.credentialConfiguration.getId();
|
||||
CredentialOfferUriResponse credentialOfferURIResponse = oauth.oid4vc()
|
||||
.credentialOfferUriRequest(credConfigId)
|
||||
.preAuthorized(ctx.preAuthorized)
|
||||
.txCode(ctx.preAuthorized)
|
||||
.targetUser(ctx.appUser)
|
||||
.bearerToken(token)
|
||||
.send();
|
||||
CredentialOfferURI credentialOfferURI = credentialOfferURIResponse.getCredentialOfferURI();
|
||||
assertTrue(credentialOfferURI.getIssuer().startsWith(ctx.issuerMetadata.getCredentialIssuer()));
|
||||
assertTrue(Strings.isNotEmpty(credentialOfferURI.getNonce()));
|
||||
return credentialOfferURI;
|
||||
}
|
||||
|
||||
private CredentialsOffer getCredentialsOffer(TestContext ctx, CredentialOfferURI credOfferUri) throws Exception {
|
||||
CredentialOfferResponse credentialOfferResponse = oauth.oid4vc().doCredentialOfferRequest(credOfferUri);
|
||||
CredentialsOffer credOffer = credentialOfferResponse.getCredentialsOffer();
|
||||
assertEquals(List.of(ctx.credentialConfiguration.getId()), credOffer.getCredentialConfigurationIds());
|
||||
return credOffer;
|
||||
}
|
||||
|
||||
private AccessTokenResponse getPreAuthorizedAccessTokenResponse(CredentialsOffer credOffer) throws Exception {
|
||||
PreAuthorizedCode preAuthCodeGrant = credOffer.getGrants().getPreAuthorizedCode();
|
||||
String preAuthCode = preAuthCodeGrant.getPreAuthorizedCode();
|
||||
String txCode = getTestingClient().testing().getTxCode(preAuthCode);
|
||||
return oauth.oid4vc().preAuthorizedCodeGrantRequest(preAuthCode)
|
||||
.txCode(txCode)
|
||||
.send();
|
||||
}
|
||||
|
||||
private CredentialResponse getCredentialByAuthDetail(String accessToken, OID4VCAuthorizationDetail authDetail) throws Exception {
|
||||
var credentialRequest = new CredentialRequest();
|
||||
if (authDetail.getCredentialIdentifiers() != null) {
|
||||
credentialRequest.setCredentialIdentifier(authDetail.getCredentialIdentifiers().get(0));
|
||||
} else if (authDetail.getCredentialConfigurationId() == null) {
|
||||
credentialRequest.setCredentialConfigurationId(authDetail.getCredentialConfigurationId());
|
||||
}
|
||||
return sendCredentialRequest(accessToken, credentialRequest);
|
||||
}
|
||||
|
||||
private CredentialResponse getCredentialByOffer(String accessToken, AccessTokenResponse tokenResponse, CredentialsOffer credOffer) throws Exception {
|
||||
List<String> credConfigIds = credOffer.getCredentialConfigurationIds();
|
||||
if (credConfigIds.size() > 1)
|
||||
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
|
||||
var credentialRequest = new CredentialRequest();
|
||||
|
||||
// Extract authorization_details (from token response or JWT)
|
||||
List<OID4VCAuthorizationDetail> authDetailsResponse = extractAuthorizationDetails(tokenResponse);
|
||||
|
||||
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
|
||||
// If authorization_details are present, credential_identifier is required
|
||||
if (authDetailsResponse.get(0).getCredentialIdentifiers() != null &&
|
||||
!authDetailsResponse.get(0).getCredentialIdentifiers().isEmpty()) {
|
||||
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
|
||||
credentialRequest.setCredentialIdentifier(credentialIdentifier);
|
||||
} else {
|
||||
throw new IllegalStateException("authorization_details present but no credential_identifier found");
|
||||
}
|
||||
} else {
|
||||
// No authorization_details, use credential_configuration_id
|
||||
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
|
||||
}
|
||||
|
||||
return sendCredentialRequest(accessToken, credentialRequest);
|
||||
}
|
||||
|
||||
private CredentialResponse sendCredentialRequest(String accessToken, CredentialRequest credRequest) {
|
||||
|
||||
Oid4vcCredentialResponse credRequestResponse = oauth.oid4vc()
|
||||
.credentialRequest(credRequest)
|
||||
.bearerToken(accessToken)
|
||||
.send();
|
||||
|
||||
CredentialResponse credResponse = credRequestResponse.getCredentialResponse();
|
||||
assertNotNull("The credentials array should be present in the response", credResponse.getCredentials());
|
||||
assertFalse("The credentials array should not be empty", credResponse.getCredentials().isEmpty());
|
||||
return credResponse;
|
||||
}
|
||||
|
||||
private void verifyCredentialResponse(TestContext ctx, CredentialResponse credResponse) throws Exception {
|
||||
|
||||
String issuer = ctx.issuerMetadata.getCredentialIssuer();
|
||||
List<String> expectedTypes = ctx.credentialConfiguration.getCredentialDefinition().getType();
|
||||
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
|
||||
assertNotNull("The first credential in the array should not be null", credentialObj);
|
||||
|
||||
String expUsername = ctx.appUser != null ? ctx.appUser : appUsername;
|
||||
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
|
||||
assertEquals(issuer, jsonWebToken.getIssuer());
|
||||
Object vc = jsonWebToken.getOtherClaims().get("vc");
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
|
||||
assertEquals(expectedTypes, credential.getType());
|
||||
assertEquals(URI.create(issuer), credential.getIssuer());
|
||||
assertEquals(expUsername + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
|
||||
}
|
||||
|
||||
private void verifyTokenJwt(
|
||||
String token,
|
||||
List<String> includeScopes,
|
||||
List<String> excludeScopes,
|
||||
List<String> includeRoles,
|
||||
List<String> excludeRoles
|
||||
) throws Exception {
|
||||
JsonWebToken jwt = JsonSerialization.readValue(new JWSInput(token).getContent(), JsonWebToken.class);
|
||||
List<String> wasScopes = Arrays.stream(((String) jwt.getOtherClaims().get("scope")).split("\\s")).toList();
|
||||
includeScopes.forEach(it -> assertTrue("Missing scope: " + it, wasScopes.contains(it)));
|
||||
excludeScopes.forEach(it -> assertFalse("Invalid scope: " + it, wasScopes.contains(it)));
|
||||
|
||||
List<String> allRoles = new ArrayList<>();
|
||||
Object realmAccess = jwt.getOtherClaims().get("realm_access");
|
||||
if (realmAccess != null) {
|
||||
var realmRoles = ((Map<String, List<String>>) realmAccess).get("roles");
|
||||
allRoles.addAll(realmRoles);
|
||||
}
|
||||
Object resourceAccess = jwt.getOtherClaims().get("resource_access");
|
||||
if (resourceAccess != null) {
|
||||
var resourceAccessMapping = (Map<String, Map<String, List<String>>>) resourceAccess;
|
||||
resourceAccessMapping.forEach((k, v) -> {
|
||||
allRoles.addAll(v.get("roles"));
|
||||
});
|
||||
}
|
||||
includeRoles.forEach(it -> assertTrue("Missing role: " + it, allRoles.contains(it)));
|
||||
excludeRoles.forEach(it -> assertFalse("Invalid role: " + it, allRoles.contains(it)));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue