[OID4VCI] Move realm attributes to clientScope and protocol-mappers (#39768)

fixes #39527


Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de>
Signed-off-by: Captain-P-Goldfish <captain.p.goldfish@gmx.de>
This commit is contained in:
Pascal Knüppel 2025-07-10 14:46:36 +02:00 committed by GitHub
parent 66ffce6661
commit f39a37d8d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 3315 additions and 2091 deletions

View File

@ -30,7 +30,6 @@ This chapter covers the following technical configurations:
- Defining realm attributes to specify VC metadata.
- Establishing client scopes and mappers to include user attributes in VCs.
- Registering a client to handle VC requests.
- Configuring a credential builder for VC formatting.
- Verifying the configuration using the issuer metadata endpoint.
=== Prerequisites
@ -50,6 +49,14 @@ To enable the feature, add the following flag to the startup command:
Verify activation by checking the server logs for the `OID4VC_VCI` initialization message.
=== Configuring Credential Issuance in Keycloak
In {project_name}, Verifiable Credentials are managed through *ClientScopes*, with each ClientScope representing a single Verifiable Credential type. To enable the issuance of a credential, the corresponding ClientScope must be assigned to an OpenID Connect client - ideally as *optional*.
During the OAuth2 authorization process, the credential-specific scope can be requested by including the ClientScope's name in the `scope` parameter of the authorization request. Once the user has successfully authenticated, the resulting Access Token *MUST* include the requested ClientScope in its `scope` claim. To ensure this, make sure the ClientScope option *Include in token scope* is enabled.
With this Access Token, the Verifiable Credential can be issued at the Credential Endpoint.
=== Authentication
An access token is required to authenticate API requests.
@ -131,39 +138,15 @@ Create a JSON file (e.g., `realm-attributes.json`) with the following content:
"realm": "oid4vc-vci",
"enabled": true,
"attributes": {
"preAuthorizedCodeLifespanS": 120,
"issuerDid": "https://localhost:8443/realms/oid4vc-vci",
"vc.IdentityCredential.expiry_in_s": "31536000",
"vc.IdentityCredential.format": "vc+sd-jwt",
"vc.IdentityCredential.scope": "identity_credential",
"vc.IdentityCredential.vct": "https://credentials.example.com/identity_credential",
"vc.SteuerberaterCredential.expiry_in_s": "31536000",
"vc.SteuerberaterCredential.format": "vc+sd-jwt",
"vc.SteuerberaterCredential.scope": "stbk_westfalen_lippe",
"vc.SteuerberaterCredential.vct": "stbk_westfalen_lippe",
"vc.SteuerberaterCredential.cryptographic_binding_methods_supported": "jwk"
"preAuthorizedCodeLifespanS": 120
}
}
----
[NOTE]
====
This is a **sample configuration**. You can define **additional attributes** depending on your specific requirements, such as:
- Different VC types and scopes.
- Alternative credential formats.
- Custom cryptographic settings.
====
==== Attribute Breakdown
The attributes section contains issuer-specific and credential-specific metadata:
The attributes section contains issuer-specific metadata:
- **preAuthorizedCodeLifespanS** Defines how long pre-authorized codes remain valid (in seconds).
- **issuerDid** The Decentralized Identifier (DID) of the issuer.
- **expiry_in_s** Credential expiration time (in seconds).
- **format** Defines the VC format (e.g., `vc+sd-jwt`).
- **scope** Identifies the credentials scope.
- **vct** The **Verifiable Credential Type (VCT)**.
- **cryptographic_binding_methods_supported** Specifies supported cryptographic methods (if applicable).
==== Import Realm Attributes
@ -185,9 +168,9 @@ curl -X PUT "https://localhost:8443/admin/realms/oid4vc-vci" \
=== Create Client Scopes with Mappers
Client scopes define **which user attributes** are included in Verifiable Credentials (VCs). These scopes use **protocol mappers** to map specific claims into VCs.
Client scopes define **which user attributes** are included in Verifiable Credentials (VCs). Therefore, they are considered the Verifiable Credential configuration itself. These scopes use **protocol mappers** to map specific claims into VCs and the protocol mappers will also contain the corresponding metadata for claims that is displayed at the Credential Issuer Metadata Endpoint.
Since the **{project_name} Admin Console does not support direct client scope creation with mappers**, use the **{project_name} Admin REST API**.
You can create the ClientScopes using the {project_name} web Administration Console, but the web Administration Console does not yet support adding metadata configuration. For metadata configuration, you will need to use the Admin REST API.
==== Define a Client Scope with a Mapper
@ -197,10 +180,26 @@ Create a JSON file (e.g., `client-scopes.json`) with the following content:
----
{
"name": "vc-scope-mapping",
"protocol": "openid-connect",
"protocol": "oid4vc",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
"include.in.token.scope": "true",
"vc.issuer_did": "did:web:vc.example.com",
"vc.credential_configuration_id": "my-credential-configuration-id",
"vc.credential_identifier": "my-credential-identifier",
"vc.format": "jwt_vc",
"vc.expiry_in_seconds": 31536000,
"vc.verifiable_credential_type": "my-vct",
"vc.supported_credential_types": "credential-type-1,credential-type-2",
"vc.credential_contexts": "context-1,context-2",
"vc.proof_signing_alg_values_supported": "ES256",
"vc.cryptographic_binding_methods_supported": "jwk",
"vc.signing_key_id": "key-id-123456",
"vc.display": "[{\"name\": \"IdentityCredential\", \"logo\": {\"uri\": \"https://university.example.edu/public/logo.png\", \"alt_text\": \"a square logo of a university\"}, \"locale\": \"en-US\", \"background_color\": \"#12107c\", \"text_color\": \"#FFFFFF\"}]",
"vc.sd_jwt.number_of_decoys": "2",
"vc.credential_build_config.sd_jwt.visible_claims": "iat,jti,nbf,exp,given_name",
"vc.credential_build_config.hash_algorithm": "SHA-256",
"vc.credential_build_config.token_jws_type": "JWS",
"vc.include_in_metadata": "true"
},
"protocolMappers": [
{
@ -208,9 +207,19 @@ Create a JSON file (e.g., `client-scopes.json`) with the following content:
"protocol": "oid4vc",
"protocolMapper": "oid4vc-static-claim-mapper",
"config": {
"subjectProperty": "academic_title",
"staticValue": "N/A",
"supportedCredentialTypes": "stbk_westfalen_lippe"
"claim.name": "academic_title",
"staticValue": "N/A"
}
},
{
"name": "givenName",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-user-attribute-mapper",
"config": {
"claim.name": "given_name",
"userAttribute": "firstName",
"vc.mandatory": "false",
"vc.display": "[{\"name\": \"الاسم الشخصي\", \"locale\": \"ar-SA\"}, {\"name\": \"Vorname\", \"locale\": \"de-DE\"}, {\"name\": \"Given Name\", \"locale\": \"en-US\"}, {\"name\": \"Nombre\", \"locale\": \"es-ES\"}, {\"name\": \"نام\", \"locale\": \"fa-IR\"}, {\"name\": \"Etunimi\", \"locale\": \"fi-FI\"}, {\"name\": \"Prénom\", \"locale\": \"fr-FR\"}, {\"name\": \"पहचानी गई नाम\", \"locale\": \"hi-IN\"}, {\"name\": \"Nome\", \"locale\": \"it-IT\"}, {\"name\": \"名\", \"locale\": \"ja-JP\"}, {\"name\": \"Овог нэр\", \"locale\": \"mn-MN\"}, {\"name\": \"Voornaam\", \"locale\": \"nl-NL\"}, {\"name\": \"Nome Próprio\", \"locale\": \"pt-PT\"}, {\"name\": \"Förnamn\", \"locale\": \"sv-SE\"}, {\"name\": \"مسلمان نام\", \"locale\": \"ur-PK\"}]"
}
}
]
@ -221,26 +230,163 @@ Create a JSON file (e.g., `client-scopes.json`) with the following content:
====
This is a **sample configuration**.
You can define **additional protocol mappers** to support different claim mappings, such as:
- Dynamic attribute values instead of static ones.
- Mapping multiple attributes per credential type.
- Alternative supported credential types.
====
==== Attribute Breakdown
From the example above:
- It is important to set `include.in.token.scope=true`, see <<include.in.token.scope, Attribute table: include.in.token.scope>>.
- Most of the named attributes above are optional. See below: <<client-scope-attribute-breakdown,Attribute Breakdown>>.
- You can determine the appropriate `protocolMapper` names by first creating them through the Web Administration Console and then retrieving their definitions via the Admin REST API.
==== Attribute Breakdown - ClientScope [[client-scope-attribute-breakdown]]
[cols="1,1,2", options="header"]
|===
| Property
| Required
| Description / Default
| `name`
| required
| Name of the client scope.
| `protocol`
| required
| Protocol used by the client scope. Use `oid4vc` for OpenID for Verifiable Credential Issuance, which is an OAuth2 extension (like `openid-connect`).
| `include.in.token.scope`
| required
| [[include.in.token.scope]] This value MUST be `true`. It ensures that the scopes name is included in the `scope` claim of the issued Access Token.
| `protocolMappers`
| optional
| Defines how claims are mapped into the credential and how metadata is exposed via the issuers metadata endpoint.
| `vc.issuer_did`
| optional
| The Decentralized Identifier (DID) of the issuer. +
_Default_: `$\{name}`
| `vc.credential_configuration_id`
| optional
| The credentials configuration ID. +
_Default_: `$\{name}+`
| `vc.credential_identifier`
| optional
| The credentials identifier. +
_Default_: `$\{name}+`
| `vc.format`
| optional
| Defines the VC format (e.g., `jwt_vc`). +
_Default_: `vc+sd-jwt`
| `vc.verifiable_credential_type`
| optional
| The Verifiable Credential Type (VCT). +
_Default_: `$\{name}+`
| `vc.supported_credential_types`
| optional
| The type values of the Verifiable Credential Type. +
_Default_: `$\{name}+`
| `vc.credential_contexts`
| optional
| The context values of the Verifiable Credential Type. +
_Default_: `$\{name}+`
| `vc.proof_signing_alg_values_supported`
| optional
| Supported signature algorithms for this credential. +
_Default_: All present keys supporting JWS algorithms in the realm.
| `vc.cryptographic_binding_methods_supported`
| optional
| Supported cryptographic methods (if applicable). +
_Default_: `jwk`
| `vc.signing_key_id`
| optional
| The ID of the key to sign this credential. +
_Default_: _none_
| `vc.display`
| optional
| Display information shown in the user's wallet about the issued credential. +
_Default_: _none_
| `vc.sd_jwt.number_of_decoys`
| optional
| Used only with format `vc+sd-jwt`. Number of decoy hashes in the SD-JWT. +
_Default_: `10`
| `vc.credential_build_config.sd_jwt.visible_claims`
| optional
| Used only with format `vc+sd-jwt`. Claims always disclosed in the SD-JWT body. +
_Default_: `id,iat,nbf,exp,jti`
| `vc.credential_build_config.hash_algorithm`
| optional
| Hash algorithm used before signing the credential. +
_Default_: `SHA-256`
| `vc.credential_build_config.token_jws_type`
| optional
| JWT type written into the `typ` header of the token. +
_Default_: `JWS`
| `vc.expiry_in_s`
| optional
| Credential expiration time in seconds. +
_Default_: `31536000` (one year)
| `vc.include_in_metadata`
| optional
| If this claim should be listed in the credentials metadata. +
_Default_: `true` but depends on the mapper-type. Claims like `jti`, `nbf`, `exp`, etc. are set to `false` by default.
|===
==== Attribute Breakdown - ProtocolMappers
- **name** Name of the client scope.
- **protocol** Uses `openid-connect` for standard OAuth2 workflows.
- **attributes** Defines scope visibility and consent behavior:
- `include.in.token.scope`: Whether this scope should be included in access tokens.
- `display.on.consent.screen`: Whether to display this scope in user consent screens.
- **protocolMappers** Defines **how claims are mapped**:
- **name** Mapper identifier.
- **protocol** Uses `oid4vc` for Verifiable Credentials.
- **protocol** Must be `oid4vc` for Verifiable Credentials.
- **protocolMapper** Specifies the claim mapping strategy (e.g., `oid4vc-static-claim-mapper`).
- **config**:
- `subjectProperty` The user attribute to map.
- `staticValue` Static value assigned when the attribute is missing.
- `supportedCredentialTypes` Credential types that support this claim.
- **config**: contains the protocol-mappers specific attributes.
Most claims are dependent on the `protocolMapper`-value, but there are also commonly used claims available for all ProtocolMappers:
[cols="1,1,2", options="header"]
|===
| Property
| Required
| Description / Default
| `claim.name`
| required
| The name of the attribute that will be added into the Verifiable Credential. +
_Default_: _none_
| `userAttribute`
| required
| The name of the users-attribute that will be used to map the value into the `claim.name` of the Verifiable Credential. +
_Default_: _none_
| `vc.mandatory`
| optional
| If the credential must be issued with this claim. +
_Default_: `false`
| `vc.display`
| optional
| Metadata information that is displayed at the credential-issuer metadata-endpoint. +
_Default_: _none_
|===
==== Import the Client Scope
@ -261,9 +407,10 @@ curl -X POST "https://localhost:8443/admin/realms/oid4vc-vci/client-scopes" \
- If updating an existing scope, use `PUT` instead of `POST`.
====
=== Create the OID4VC Client
=== Create the Client
Set up a client to handle VC requests and assign it the necessary scopes.
Set up a client to handle Verifiable Credential (VC) requests and assign the necessary scopes.
The client does not differ from regular OpenID Connect clients — with one exception: it must have the appropriate **optional ClientScopes** assigned that define the Verifiable Credentials it is allowed to issue.
. Create a JSON file (e.g., `oid4vc-rest-api-client.json`) with the following content:
+
@ -302,45 +449,6 @@ curl -k -X POST "https://localhost:8443/admin/realms/oid4vc-vci/clients" \
-d @oid4vc-rest-api-client.json
----
=== Create a Credential Builder Component
A **Credential Builder** is responsible for formatting Verifiable Credentials (VCs), such as **SD-JWT**.
This component must be **registered in {project_name}** using the **Admin REST API**.
==== Register the Credential Builder
Use the following `curl` command to **create the credential builder**:
[source,bash]
----
curl -X POST "https://localhost:8443/admin/realms/oid4vc-vci/components" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "sd-jwt-credentialbuilder",
"providerId": "vc+sd-jwt",
"providerType": "org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder"
}'
----
[NOTE]
====
- Replace `$ACCESS_TOKEN` with a valid **{project_name} Admin API access token**.
- **Avoid using `-k` in production**; instead, configure a **trusted TLS certificate**.
====
==== Configuration Details
- **name** The identifier for the credential builder.
- **providerId** Specifies the **VC format** (e.g., `vc+sd-jwt`).
- **providerType** Points to the {project_name} **Credential Builder class** used for VC issuance.
[IMPORTANT]
====
This is a **sample configuration**.
You can **register multiple credential builders** for different VC formats **(e.g., JWT, JSON-LD, etc.)**.
====
=== Verify the Configuration
Validate the setup by accessing the **issuer metadata endpoint**:

View File

@ -95,9 +95,8 @@
"protocol": "oid4vc",
"protocolMapper": "oid4vc-target-role-mapper",
"config": {
"subjectProperty": "roles",
"clientId": "did:web:test-marketplace.org",
"supportedCredentialTypes": "NaturalPersonCredential"
"claim.name": "roles",
"clientId": "did:web:test-marketplace.org"
}
},
{
@ -105,9 +104,8 @@
"protocol": "oid4vc",
"protocolMapper": "oid4vc-target-role-mapper",
"config": {
"subjectProperty": "roles",
"clientId": "did:web:test-marketplace.org",
"supportedCredentialTypes": "VerifiableCredential"
"claim.name": "roles",
"clientId": "did:web:test-marketplace.org"
}
},
{
@ -115,9 +113,8 @@
"protocol": "oid4vc",
"protocolMapper": "oid4vc-user-attribute-mapper",
"config": {
"subjectProperty": "email",
"userAttribute": "email",
"supportedCredentialTypes": "NaturalPersonCredential"
"claim.name": "email",
"userAttribute": "email"
}
}
],

View File

@ -1439,6 +1439,17 @@ public class RealmCacheSession implements CacheRealmProvider {
getClientScopesStream(realm).map(ClientScopeModel::getId).forEach(id -> removeClientScope(realm, id));
}
@Override
public Stream<ClientScopeModel> getClientScopesByProtocol(RealmModel realm, String protocol) {
return getClientScopeDelegate().getClientScopesByProtocol(realm, protocol);
}
@Override
public Stream<ClientScopeModel> getClientScopesByAttributes(RealmModel realm, Map<String, String> searchMap,
boolean useOr) {
return getClientScopeDelegate().getClientScopesByAttributes(realm, searchMap, useOr);
}
@Override
public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) {
getClientDelegate().addClientScopes(realm, client, clientScopes, defaultScope);
@ -1471,13 +1482,16 @@ public class RealmCacheSession implements CacheRealmProvider {
return model;
}
Map<String, ClientScopeModel> assignedScopes = new HashMap<>();
List<String> acceptedClientProtocols = KeycloakModelUtils.getAcceptedClientScopeProtocols(client);
for (String id : query.getClientScopes()) {
ClientScopeModel clientScope = session.clientScopes().getClientScopeById(realm, id);
if (clientScope == null) {
invalidations.add(cacheKey);
return getClientDelegate().getClientScopes(realm, client, defaultScopes);
}
if (clientScope.getProtocol().equals((client.getProtocol() == null) ? "openid-connect" : client.getProtocol())) {
if (acceptedClientProtocols.contains(clientScope.getProtocol())) {
assignedScopes.put(clientScope.getName(), clientScope);
}
}

View File

@ -38,9 +38,12 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.hibernate.Session;
import org.jboss.logging.Logger;
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
@ -1229,17 +1232,98 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
realm.getClientScopesStream().map(ClientScopeModel::getId).forEach(id -> this.removeClientScope(realm, id));
}
@Override
public Stream<ClientScopeModel> getClientScopesByProtocol(RealmModel realm, String protocol)
{
TypedQuery<ClientScopeEntity> query = em.createNamedQuery("getClientScopesByProtocol",
ClientScopeEntity.class)
.setParameter("realm", realm.getId())
.setParameter("protocol", protocol);
return query.getResultStream()
.map(entity -> new ClientScopeAdapter(realm, em, session, entity));
}
/**
* This method filters clientScopes by specific attributes. To do this, it will generate the sql-statement
* dynamically based on the given search-parameters.<br />
* This method prevents SQL-Injections by adding dynamic parameters into the SQL-statement and resolves them
* later by using the JPA query function {@code query.setParameter(dynamicParamName, actualValue)}.<br/>
* Here is an example of a generated statement:
* <pre>
* {@code
* SELECT distinct C FROM ClientScopeEntity C
* inner join ClientScopeAttributeEntity CA0 on C.id = CA0.clientScope.id
* and CA0.name = :a3e8d01932c104f0ab79441d34884bada
* WHERE C.realmId = :realmId
* and CA0.value = :acedd0bedc7264a2fb524a37814f7aaa1
* }
* </pre>
*
* @param realm Realm.
* @param searchMap a key-value map that holds the attribute names and values to search for.
* @param useOr If the search-params should be combined with or-expressions or and-expressions
* @return a stream of clientScopes matching the given criteria
*/
@Override
public Stream<ClientScopeModel> getClientScopesByAttributes(RealmModel realm, Map<String, String> searchMap,
boolean useOr) {
// we build this specific query dynamically, but we enter the parameters as keys to avoid SQL injections.
StringBuilder jpql = new StringBuilder("SELECT distinct C FROM ClientScopeEntity C");
List<String> keys = new ArrayList<>(searchMap.keySet());
Map<String, String> dynamicParameterNameMap = new HashMap<>();
Map<String, String> dynamicParameterValueMap = new HashMap<>();
StringBuilder whereClauseExtension = new StringBuilder();
// I am using an indexed for-loop because I need the index for dynamic jpql references
for (int i = 0; i < keys.size(); i++) {
String key = keys.get(i);
String value = searchMap.get(key);
Supplier<String> generateDynamicParameterName = () -> {
return "a" /* dynamic params must start with a letter */
+ UUID.randomUUID().toString().replaceAll("-","");
};
final String dynamicParameterName = generateDynamicParameterName.get();
final String dynamicParameterValue = generateDynamicParameterName.get();
dynamicParameterNameMap.put(dynamicParameterName, key);
dynamicParameterValueMap.put(dynamicParameterValue, value);
jpql.append('\n')
.append("""
inner join ClientScopeAttributeEntity CA%1$s on C.id = CA%1$s.clientScope.id
and CA%1$s.name = :%2$s
""".stripIndent().strip().formatted(i, dynamicParameterName));
whereClauseExtension.append('\n');
if (useOr) {
whereClauseExtension.append("or");
}else {
whereClauseExtension.append("and");
}
whereClauseExtension.append(" CA%1$s.value = :%2$s".formatted(i, dynamicParameterValue));
}
jpql.append('\n').append(" WHERE C.realmId = :realmId").append(whereClauseExtension);
logger.debugf("Filter for clientScopes with query:\n%s", jpql);
TypedQuery<ClientScopeEntity> query = em.createQuery(jpql.toString(), ClientScopeEntity.class);
dynamicParameterNameMap.forEach(query::setParameter);
dynamicParameterValueMap.forEach(query::setParameter);
return query.setParameter("realmId", realm.getId())
.getResultStream().map(scope -> new ClientScopeAdapter(realm, em, session, scope));
}
@Override
public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) {
// Defaults to openid-connect
String clientProtocol = client.getProtocol() == null ? OIDCLoginProtocol.LOGIN_PROTOCOL : client.getProtocol();
List<String> acceptedClientProtocols = KeycloakModelUtils.getAcceptedClientScopeProtocols(client);
Map<String, ClientScopeModel> existingClientScopes = getClientScopes(realm, client, true);
existingClientScopes.putAll(getClientScopes(realm, client, false));
clientScopes.stream()
.filter(clientScope -> ! existingClientScopes.containsKey(clientScope.getName()))
.filter(clientScope -> Objects.equals(clientScope.getProtocol(), clientProtocol))
.filter(clientScope -> !existingClientScopes.containsKey(clientScope.getName()))
.filter(clientScope -> {
if (clientScope.getProtocol() == null) {
// set default protocol if not set. Otherwise, we will get a NullPointer
clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
}
return acceptedClientProtocols.contains(clientScope.getProtocol());
})
.forEach(clientScope -> {
ClientScopeClientMappingEntity entity = new ClientScopeClientMappingEntity();
entity.setClientScopeId(clientScope.getId());
@ -1274,8 +1358,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
@Override
public Map<String, ClientScopeModel> getClientScopes(RealmModel realm, ClientModel client, boolean defaultScope) {
// Defaults to openid-connect
String clientProtocol = client.getProtocol() == null ? OIDCLoginProtocol.LOGIN_PROTOCOL : client.getProtocol();
List<String> acceptedClientProtocols = KeycloakModelUtils.getAcceptedClientScopeProtocols(client);
TypedQuery<String> query = em.createNamedQuery("clientScopeClientMappingIdsByClient", String.class);
query.setParameter("clientId", client.getId());
@ -1284,7 +1367,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
return closing(query.getResultStream())
.map(clientScopeId -> session.clientScopes().getClientScopeById(realm, clientScopeId))
.filter(Objects::nonNull)
.filter(clientScope -> Objects.equals(clientScope.getProtocol(), clientProtocol))
.filter(clientScope -> acceptedClientProtocols.contains(clientScope.getProtocol()))
.collect(Collectors.toMap(ClientScopeModel::getName, Function.identity()));
}
@Override

View File

@ -46,7 +46,10 @@ import org.hibernate.annotations.Nationalized;
@Entity
@Table(name="CLIENT_SCOPE", uniqueConstraints = {@UniqueConstraint(columnNames = {"REALM_ID", "NAME"})})
@NamedQueries({
@NamedQuery(name="getClientScopeIds", query="select scope.id from ClientScopeEntity scope where scope.realmId = :realm")
@NamedQuery(name="getClientScopeIds", query="select scope.id from ClientScopeEntity scope where scope.realmId = :realm"),
@NamedQuery(name = "getClientScopesByProtocol",
query = "select S from ClientScopeEntity S " +
"where S.realmId = :realm and S.protocol = :protocol")
})
public class ClientScopeEntity {

View File

@ -16,6 +16,7 @@
*/
package org.keycloak.storage;
import java.util.Map;
import java.util.stream.Stream;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientScopeProvider;
@ -74,6 +75,17 @@ public class ClientScopeStorageManager extends AbstractStorageManager<ClientScop
localStorage().removeClientScopes(realm);
}
@Override
public Stream<ClientScopeModel> getClientScopesByProtocol(RealmModel realm, String protocol) {
return localStorage().getClientScopesByProtocol(realm, protocol);
}
@Override
public Stream<ClientScopeModel> getClientScopesByAttributes(RealmModel realm, Map<String, String> searchMap,
boolean useOr) {
return localStorage().getClientScopesByAttributes(realm, searchMap, useOr);
}
@Override
public void close() {
}

View File

@ -16,14 +16,19 @@
*
*/
package org.keycloak.oid4vci;
package org.keycloak.constants;
/**
* @author Pascal Knüppel
*/
public final class Oid4VciConstants {
public static final String OID4VC_PROTOCOL = "oid4vc";
public static final String C_NONCE_LIFETIME_IN_SECONDS = "vc.c-nonce-lifetime-seconds";
private Oid4VciConstants() {}
public static final String CREDENTIAL_SUBJECT = "credentialSubject";
private Oid4VciConstants() {
}
}

View File

@ -0,0 +1,489 @@
/*
* 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.models.oid4vci;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import static org.keycloak.constants.Oid4VciConstants.OID4VC_PROTOCOL;
/**
* This class acts as delegate for a {@link ClientScopeModel} implementation and adds additional functionality for
* OpenId4VC credentials
*
* @author Pascal Knüppel
*/
public class CredentialScopeModel implements ClientScopeModel {
public static final String SD_JWT_VISIBLE_CLAIMS_DEFAULT = "id,iat,nbf,exp,jti";
public static final int SD_JWT_DECOYS_DEFAULT = 10;
public static final String FORMAT_DEFAULT = "vc+sd-jwt";
public static final String HASH_ALGORITHM_DEFAULT = "SHA-256";
public static final String TOKEN_TYPE_DEFAULT = "JWS";
public static final int EXPIRY_IN_SECONDS_DEFAULT = 31536000;
public static final String CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT = "jwk";
/**
* the credential configuration id as provided in the metadata endpoint
*/
public static final String ISSUER_DID = "vc.issuer_did";
public static final String CONFIGURATION_ID = "vc.credential_configuration_id";
public static final String CREDENTIAL_IDENTIFIER = "vc.credential_identifier";
public static final String FORMAT = "vc.format";
public static final String EXPIRY_IN_SECONDS = "vc.expiry_in_seconds";
public static final String VCT = "vc.verifiable_credential_type";
/**
* the value that is added into the "types"-attribute of a verifiable credential
*/
public static final String TYPES = "vc.supported_credential_types";
/**
* the value that is entered into the "@contexts"-attribute of a verifiable credential
*/
public static final String CONTEXTS = "vc.credential_contexts";
/**
* if the credential is only meant for specific signing algorithms the global default list can be overridden here.
* The global default list is retrieved from the available keys in the realm.
*/
public static final String SIGNING_ALG_VALUES_SUPPORTED = "vc.proof_signing_alg_values_supported";
/**
* if the credential is only meant for specific cryptographic binding algorithms the global default list can be
* overridden here. The global default list is retrieved from the available keys in the realm.
*/
public static final String CRYPTOGRAPHIC_BINDING_METHODS = "vc.cryptographic_binding_methods_supported";
/**
* an optional configuration that can be used to select a specific key for signing the credential
*/
public static final String SIGNING_KEY_ID = "vc.signing_key_id";
/**
* an optional attribute for the metadata endpoint
*/
public static final String VC_DISPLAY = "vc.display";
/**
* this attribute holds a customizable value for the number of decoys to use in a SD-JWT credential
*/
public static final String SD_JWT_NUMBER_OF_DECOYS = "vc.sd_jwt.number_of_decoys";
/**
* an optional attribute that tells us which attributes should be added into the SD-JWT body.
*/
public static final String SD_JWT_VISIBLE_CLAIMS = "vc.credential_build_config.sd_jwt.visible_claims";
/**
* an optional configuration that can be used to select a specific hash algorithm
*/
public static final String HASH_ALGORITHM = "vc.credential_build_config.hash_algorithm";
/**
* this attribute holds the 'typ' value that will be added into the JWS header of the credential.
*/
public static final String TOKEN_JWS_TYPE = "vc.credential_build_config.token_jws_type";
/**
* this configuration property can be used to enforce specific claims to be included in the metadata, if they
* would normally not and vice versa
*/
public static final String INCLUDE_IN_METADATA = "vc.include_in_metadata";
/**
* the actual object that is represented by this scope
*/
private final ClientScopeModel clientScope;
public CredentialScopeModel(ClientScopeModel clientScope) {
this.clientScope = clientScope;
assert OID4VC_PROTOCOL.equals(clientScope.getProtocol());
}
public String getIssuerDid() {
return clientScope.getAttribute(ISSUER_DID);
}
public void setIssuerDid(String issuerDid) {
clientScope.setAttribute(ISSUER_DID, issuerDid);
}
public String getScope() {
return clientScope.getName();
}
public String getCredentialConfigurationId() {
return Optional.ofNullable(clientScope.getAttribute(CONFIGURATION_ID)).orElse(getName());
}
public void setCredentialConfigurationId(String credentialConfigurationId) {
clientScope.setAttribute(CONFIGURATION_ID, Optional.ofNullable(credentialConfigurationId).orElse(getName()));
}
public String getCredentialIdentifier() {
return Optional.ofNullable(clientScope.getAttribute(CREDENTIAL_IDENTIFIER)).orElse(getName());
}
public void setCredentialIdentifier(String credentialIdentifier) {
clientScope.setAttribute(CREDENTIAL_IDENTIFIER, Optional.ofNullable(credentialIdentifier).orElse(getName()));
}
public String getFormat() {
return Optional.ofNullable(clientScope.getAttribute(FORMAT)).orElse(FORMAT_DEFAULT);
}
public void setFormat(String credentialFormat) {
clientScope.setAttribute(FORMAT, Optional.ofNullable(credentialFormat).orElse(FORMAT_DEFAULT));
}
public Integer getExpiryInSeconds() {
return Optional.ofNullable(clientScope.getAttribute(EXPIRY_IN_SECONDS)).map(Integer::parseInt)
.orElse(EXPIRY_IN_SECONDS_DEFAULT);
}
public void setExpiryInSeconds(Integer expiryInSeconds) {
clientScope.setAttribute(EXPIRY_IN_SECONDS,
Optional.ofNullable(expiryInSeconds).map(String::valueOf)
.orElse(String.valueOf(EXPIRY_IN_SECONDS_DEFAULT)));
}
public int getSdJwtNumberOfDecoys() {
return Optional.ofNullable(clientScope.getAttribute(SD_JWT_NUMBER_OF_DECOYS)).map(Integer::parseInt)
.orElse(SD_JWT_DECOYS_DEFAULT);
}
public void setSdJwtNumberOfDecoys(Integer sdJwtNumberOfDecoys) {
clientScope.setAttribute(SD_JWT_NUMBER_OF_DECOYS,
Optional.ofNullable(sdJwtNumberOfDecoys).map(String::valueOf)
.orElse(String.valueOf(SD_JWT_DECOYS_DEFAULT)));
}
public String getVct() {
return Optional.ofNullable(clientScope.getAttribute(VCT)).orElse(getName());
}
public void setVct(String vct) {
clientScope.setAttribute(VCT, Optional.ofNullable(vct).orElse(getName()));
}
public String getTokenJwsType() {
return Optional.ofNullable(clientScope.getAttribute(TOKEN_JWS_TYPE)).orElse(TOKEN_TYPE_DEFAULT);
}
public void setTokenJwsType(String tokenJwsType) {
clientScope.setAttribute(TOKEN_JWS_TYPE, Optional.ofNullable(tokenJwsType).orElse(TOKEN_TYPE_DEFAULT));
}
public String getSigningKeyId() {
return clientScope.getAttribute(SIGNING_KEY_ID);
}
public void setSigningKeyId(String signingKeyId) {
clientScope.setAttribute(SIGNING_KEY_ID, signingKeyId);
}
public String getHashAlgorithm() {
return Optional.ofNullable(clientScope.getAttribute(HASH_ALGORITHM)).orElse(HASH_ALGORITHM_DEFAULT);
}
public void setHashAlgorithm(String hashAlgorithm) {
clientScope.setAttribute(HASH_ALGORITHM, hashAlgorithm);
}
public List<String> getSupportedCredentialTypes() {
return Optional.ofNullable(clientScope.getAttribute(TYPES))
.map(s -> s.split(","))
.map(Arrays::asList)
.orElse(Collections.singletonList(getName()));
}
public void setSupportedCredentialTypes(String supportedCredentialTypes) {
clientScope.setAttribute(TYPES, Optional.ofNullable(supportedCredentialTypes).orElse(getName()));
}
public void setSupportedCredentialTypes(List<String> supportedCredentialTypes) {
clientScope.setAttribute(TYPES, String.join(",", supportedCredentialTypes));
}
public List<String> getVcContexts() {
return Optional.ofNullable(clientScope.getAttribute(CONTEXTS))
.map(s -> s.split(","))
.map(Arrays::asList)
.orElse(Collections.singletonList(getName()));
}
public void setVcContexts(String vcContexts) {
clientScope.setAttribute(CONTEXTS, Optional.ofNullable(vcContexts).orElse(getName()));
}
public void setVcContexts(List<String> vcContexts) {
clientScope.setAttribute(CONTEXTS, String.join(",", vcContexts));
}
public List<String> getSigningAlgsSupported() {
return Optional.ofNullable(clientScope.getAttribute(SIGNING_ALG_VALUES_SUPPORTED))
.map(s -> s.split(","))
.map(Arrays::asList)
.orElse(Collections.emptyList());
}
public void setSigningAlgsSupported(String signingAlgsSupported) {
clientScope.setAttribute(SIGNING_ALG_VALUES_SUPPORTED, signingAlgsSupported);
}
public void setSigningAlgsSupported(List<String> signingAlgsSupported) {
clientScope.setAttribute(SIGNING_ALG_VALUES_SUPPORTED,
String.join(",", signingAlgsSupported));
}
public List<String> getCryptographicBindingMethods() {
return Optional.ofNullable(clientScope.getAttribute(CRYPTOGRAPHIC_BINDING_METHODS))
.map(s -> s.split(","))
.map(Arrays::asList)
.orElse(Collections.emptyList());
}
public void setCryptographicBindingMethods(String cryptographicBindingMethods) {
clientScope.setAttribute(CRYPTOGRAPHIC_BINDING_METHODS, cryptographicBindingMethods);
}
public void setCryptographicBindingMethods(List<String> cryptographicBindingMethods) {
clientScope.setAttribute(CRYPTOGRAPHIC_BINDING_METHODS,
String.join(",", cryptographicBindingMethods));
}
public List<String> getSdJwtVisibleClaims() {
return Optional.ofNullable(clientScope.getAttribute(SD_JWT_VISIBLE_CLAIMS))
.map(s -> s.split(","))
.map(Arrays::asList)
.orElse(List.of(SD_JWT_VISIBLE_CLAIMS_DEFAULT.split(",")));
}
public void setSdJwtVisibleClaims(String sdJwtVisibleClaims) {
clientScope.setAttribute(SD_JWT_VISIBLE_CLAIMS, Optional.ofNullable(sdJwtVisibleClaims)
.orElse(SD_JWT_VISIBLE_CLAIMS_DEFAULT));
}
public void setSdJwtVisibleClaims(List<String> sdJwtVisibleClaims) {
clientScope.setAttribute(SD_JWT_VISIBLE_CLAIMS,
String.join(",", sdJwtVisibleClaims));
}
public String getVcDisplay() {
return clientScope.getAttribute(VC_DISPLAY);
}
public void setVcDisplay(String vcDisplay) {
clientScope.setAttribute(VC_DISPLAY, vcDisplay);
}
@Override
public String getId() {
return clientScope.getId();
}
@Override
public String getName() {
return clientScope.getName();
}
@Override
public void setName(String name) {
clientScope.setName(name);
}
@Override
public RealmModel getRealm() {
return clientScope.getRealm();
}
@Override
public String getDescription() {
return clientScope.getDescription();
}
@Override
public void setDescription(String description) {
clientScope.setDescription(description);
}
@Override
public String getProtocol() {
return clientScope.getProtocol();
}
@Override
public void setProtocol(String protocol) {
clientScope.setProtocol(protocol);
}
@Override
public void setAttribute(String name, String value) {
clientScope.setAttribute(name, value);
}
@Override
public void removeAttribute(String name) {
clientScope.removeAttribute(name);
}
@Override
public String getAttribute(String name) {
return clientScope.getAttribute(name);
}
@Override
public Map<String, String> getAttributes() {
return clientScope.getAttributes();
}
@Override
public boolean isDisplayOnConsentScreen() {
return clientScope.isDisplayOnConsentScreen();
}
@Override
public void setDisplayOnConsentScreen(boolean displayOnConsentScreen) {
clientScope.setDisplayOnConsentScreen(displayOnConsentScreen);
}
@Override
public String getConsentScreenText() {
return clientScope.getConsentScreenText();
}
@Override
public void setConsentScreenText(String consentScreenText) {
clientScope.setConsentScreenText(consentScreenText);
}
@Override
public String getGuiOrder() {
return clientScope.getGuiOrder();
}
@Override
public void setGuiOrder(String guiOrder) {
clientScope.setGuiOrder(guiOrder);
}
@Override
public boolean isIncludeInTokenScope() {
return clientScope.isIncludeInTokenScope();
}
@Override
public void setIncludeInTokenScope(boolean includeInTokenScope) {
clientScope.setIncludeInTokenScope(includeInTokenScope);
}
@Override
public boolean isDynamicScope() {
return clientScope.isDynamicScope();
}
@Override
public void setIsDynamicScope(boolean isDynamicScope) {
clientScope.setIsDynamicScope(isDynamicScope);
}
@Override
public String getDynamicScopeRegexp() {
return clientScope.getDynamicScopeRegexp();
}
public Stream<Oid4vcProtocolMapperModel> getOid4vcProtocolMappersStream() {
return clientScope.getProtocolMappersStream().filter(pm -> {
return Oid4VciConstants.OID4VC_PROTOCOL.equals(pm.getProtocol());
}).map(Oid4vcProtocolMapperModel::new);
}
@Override
public Stream<ProtocolMapperModel> getProtocolMappersStream() {
return clientScope.getProtocolMappersStream();
}
@Override
public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) {
return clientScope.addProtocolMapper(model);
}
@Override
public void removeProtocolMapper(ProtocolMapperModel mapping) {
clientScope.removeProtocolMapper(mapping);
}
@Override
public void updateProtocolMapper(ProtocolMapperModel mapping) {
clientScope.updateProtocolMapper(mapping);
}
@Override
public ProtocolMapperModel getProtocolMapperById(String id) {
return clientScope.getProtocolMapperById(id);
}
@Override
public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) {
return clientScope.getProtocolMapperByName(protocol, name);
}
@Override
public Stream<RoleModel> getScopeMappingsStream() {
return clientScope.getScopeMappingsStream();
}
@Override
public Stream<RoleModel> getRealmScopeMappingsStream() {
return clientScope.getRealmScopeMappingsStream();
}
@Override
public void addScopeMapping(RoleModel role) {
clientScope.addScopeMapping(role);
}
@Override
public void deleteScopeMapping(RoleModel role) {
clientScope.deleteScopeMapping(role);
}
@Override
public boolean hasDirectScope(RoleModel role) {
return clientScope.hasDirectScope(role);
}
@Override
public boolean hasScope(RoleModel role) {
return clientScope.hasScope(role);
}
}

View File

@ -0,0 +1,148 @@
/*
* 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.models.oid4vci;
import org.keycloak.models.ProtocolMapperModel;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* This class acts as delegate for a {@link ProtocolMapperModel} implementation and adds additional functionality for
* OpenId4VC credentials
*
* @author Pascal Knüppel
*/
public class Oid4vcProtocolMapperModel extends ProtocolMapperModel {
public static final String PATH = "claim.name"; // TODO discuss if we can rename this.
// Renaming it would break existing installations
public static final String MANDATORY = "vc.mandatory";
public static final String DISPLAY = "vc.display";
private final ProtocolMapperModel protocolMapper;
public Oid4vcProtocolMapperModel(ProtocolMapperModel protocolMapper) {
this.protocolMapper = protocolMapper;
}
/**
* @return the path of the attribute where it can be extracted
*/
public List<String> getPath()
{
return Optional.ofNullable(protocolMapper.getConfig().get(PATH))
.map(s -> s.split("\\."))
.map(Arrays::asList)
.orElse(Collections.emptyList());
}
public void setPath(List<String> path) {
protocolMapper.getConfig().put(PATH, Optional.ofNullable(path)
.map(l -> String.join(".", l))
.orElse(null));
}
public boolean isMandatory()
{
return Optional.ofNullable(protocolMapper.getConfig().get(MANDATORY)).map(Boolean::valueOf).orElse(false);
}
public void setMandatory(Boolean mandatory)
{
if (mandatory == null) {
protocolMapper.getConfig().remove(MANDATORY);
}else {
protocolMapper.getConfig().put(MANDATORY, String.valueOf(mandatory));
}
}
public String getDisplay()
{
return protocolMapper.getConfig().get(DISPLAY);
}
public void setDisplay(String display)
{
protocolMapper.getConfig().put(DISPLAY, display);
}
@Override
public String getId() {
return protocolMapper.getId();
}
@Override
public void setId(String id) {
protocolMapper.setId(id);
}
@Override
public String getName() {
return protocolMapper.getName();
}
@Override
public void setName(String name) {
protocolMapper.setName(name);
}
@Override
public String getProtocol() {
return protocolMapper.getProtocol();
}
@Override
public void setProtocol(String protocol) {
protocolMapper.setProtocol(protocol);
}
@Override
public String getProtocolMapper() {
return protocolMapper.getProtocolMapper();
}
@Override
public void setProtocolMapper(String protocolMapper) {
this.protocolMapper.setProtocolMapper(protocolMapper);
}
@Override
public Map<String, String> getConfig() {
return protocolMapper.getConfig();
}
@Override
public void setConfig(Map<String, String> config) {
protocolMapper.setConfig(config);
}
@Override
public boolean equals(Object obj) {
return protocolMapper.equals(obj);
}
@Override
public int hashCode() {
return protocolMapper.hashCode();
}
}

View File

@ -28,6 +28,7 @@ import org.keycloak.common.util.PemUtils;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.crypto.Algorithm;
import org.keycloak.deployment.DeployedConfigurationsManager;
import org.keycloak.models.AccountRoles;
@ -1228,4 +1229,17 @@ public final class KeycloakModelUtils {
context.setRealm(currentRealm);
}
}
/**
* @return the list of protocols accepted for the given client.
*/
public static List<String> getAcceptedClientScopeProtocols(ClientModel client) {
List<String> acceptedClientProtocols;
if (client.getProtocol() == null || "openid-connect".equals(client.getProtocol())) {
acceptedClientProtocols = List.of("openid-connect", Oid4VciConstants.OID4VC_PROTOCOL);
}else {
acceptedClientProtocols = List.of(client.getProtocol());
}
return acceptedClientProtocols;
}
}

View File

@ -730,6 +730,7 @@ public class RepresentationToModel {
}
}
return clientScope;
}
@ -737,7 +738,6 @@ public class RepresentationToModel {
if (rep.getName() != null) resource.setName(rep.getName());
if (rep.getDescription() != null) resource.setDescription(rep.getDescription());
if (rep.getProtocol() != null) resource.setProtocol(rep.getProtocol());
if (rep.getAttributes() != null) {
@ -748,9 +748,6 @@ public class RepresentationToModel {
}
// Scope mappings
// Users
public static UserModel createUser(KeycloakSession session, RealmModel newRealm, UserRepresentation userRep) {

View File

@ -24,6 +24,7 @@ import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.ProviderEvent;
import org.keycloak.provider.ProviderEventListener;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import java.util.Objects;
import java.util.Set;
@ -96,7 +97,12 @@ public abstract class AbstractLoginProtocolFactory implements LoginProtocolFacto
newClients.forEach(addNonDefault);
}
protected abstract void addDefaults(ClientModel realm);
protected abstract void addDefaults(ClientModel clientModel);
@Override
public void addClientScopeDefaults(ClientScopeRepresentation clientModel) {
// do nothing
}
@Override
public void close() {

View File

@ -24,6 +24,8 @@ import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import java.util.Map;
/**
@ -59,4 +61,8 @@ public interface LoginProtocolFactory extends ProviderFactory<LoginProtocol> {
*/
void setupClientDefaults(ClientRepresentation rep, ClientModel newClient);
/**
* Add default values to {@link ClientScopeRepresentation}s that refer to the specific login-protocol
*/
void addClientScopeDefaults(ClientScopeRepresentation clientModel);
}

View File

@ -19,10 +19,8 @@ package org.keycloak.models;
import org.keycloak.provider.Provider;
import org.keycloak.storage.client.ClientLookupProvider;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
@ -105,19 +103,19 @@ public interface ClientProvider extends ClientLookupProvider, Provider {
void removeClients(RealmModel realm);
/**
* Assign clientScopes to the client. Add as default scopes (if parameter 'defaultScope' is true)
* Assign clientScopes to the client. Add as default scopes (if parameter 'defaultScope' is true)
* or optional scopes (if parameter 'defaultScope' is false)
*
*
* @param realm Realm.
* @param client Client.
* @param clientScopes to be assigned
* @param defaultScope if true the scopes are assigned as default, or optional in case of false
* @param defaultScope if true the scopes are assigned as default, or optional in case of false
*/
void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope);
/**
* Unassign clientScope from the client.
*
* Unassign clientScope from the client.
*
* @param realm Realm.
* @param client Client.
* @param clientScope to be unassigned
@ -144,4 +142,5 @@ public interface ClientProvider extends ClientLookupProvider, Provider {
*/
@Deprecated
Map<ClientModel, Set<String>> getAllRedirectUrisOfEnabledClients(RealmModel realm);
}

View File

@ -16,6 +16,7 @@
*/
package org.keycloak.models;
import java.util.Map;
import java.util.stream.Stream;
import org.keycloak.provider.Provider;
import org.keycloak.storage.clientscope.ClientScopeLookupProvider;
@ -34,7 +35,7 @@ public interface ClientScopeProvider extends Provider, ClientScopeLookupProvider
/**
* Creates new client scope with given {@code name} to the given realm.
* Spaces in {@code name} will be replaced by underscore so that scope name
* Spaces in {@code name} will be replaced by underscore so that scope name
* can be used as value of scope parameter. The internal ID will be created automatically.
* @param realm Realm owning this client scope.
* @param name String name of the client scope.
@ -47,7 +48,7 @@ public interface ClientScopeProvider extends Provider, ClientScopeLookupProvider
/**
* Creates new client scope with given internal ID and {@code name} to the given realm.
* Spaces in {@code name} will be replaced by underscore so that scope name
* Spaces in {@code name} will be replaced by underscore so that scope name
* can be used as value of scope parameter.
* @param realm Realm owning this client scope.
* @param id Internal ID of the client scope or {@code null} if one is to be created by the underlying store
@ -73,4 +74,21 @@ public interface ClientScopeProvider extends Provider, ClientScopeLookupProvider
* @param realm Realm.
*/
void removeClientScopes(RealmModel realm);
/**
* Must retrieve all client scopes of the given realm that are use the given protocol.
*
* @param realm the realm to retrieve the client scopes from.
* @param protocol the protocol expected from the clientScope
*/
Stream<ClientScopeModel> getClientScopesByProtocol(RealmModel realm, String protocol);
/**
* Allows us to filter for scopes by specific attributes
*
* @param realm Realm.
* @param searchMap a key-value map that holds the attribute names and values to search for.
* @param useOr If the search-params should be combined with or-expressions or and-expressions
*/
Stream<ClientScopeModel> getClientScopesByAttributes(RealmModel realm, Map<String, String> searchMap, boolean useOr);
}

View File

@ -1,169 +0,0 @@
/*
* 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;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.VC_KEY;
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.clientregistration.AbstractClientRegistrationProvider;
import org.keycloak.services.clientregistration.DefaultClientRegistrationContext;
import java.net.URI;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Provides the client-registration functionality for OID4VC-clients.
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public class OID4VCClientRegistrationProvider extends AbstractClientRegistrationProvider {
private static final Logger LOGGER = Logger.getLogger(OID4VCClientRegistrationProvider.class);
public OID4VCClientRegistrationProvider(KeycloakSession session) {
super(session);
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createOID4VCClient(OID4VCClient client) {
ClientRepresentation clientRepresentation = toClientRepresentation(client);
validate(clientRepresentation);
ClientRepresentation cr = create(
new DefaultClientRegistrationContext(session, clientRepresentation, this));
URI uri = session.getContext().getUri().getAbsolutePathBuilder().path(cr.getClientId()).build();
return Response.created(uri).entity(cr).build();
}
@PUT
@Path("{clientId}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response updateOID4VCClient(@PathParam("clientId") String clientDid, OID4VCClient client) {
client.setClientDid(clientDid);
ClientRepresentation clientRepresentation = toClientRepresentation(client);
validate(clientRepresentation);
clientRepresentation = update(clientDid,
new DefaultClientRegistrationContext(session, clientRepresentation, this));
return Response.ok(clientRepresentation).build();
}
@DELETE
@Path("{clientId}")
public Response deleteOID4VCClient(@PathParam("clientId") String clientDid) {
delete(clientDid);
return Response.noContent().build();
}
/**
* Validates the clientRepresentation to fulfill the requirement of an OID4VC client
*/
public static void validate(ClientRepresentation client) {
String did = client.getClientId();
if (did == null) {
throw new ErrorResponseException("no_did", "A client did needs to be configured for OID4VC clients",
Response.Status.BAD_REQUEST);
}
if (!did.startsWith("did:")) {
throw new ErrorResponseException("invalid_did", "The client id is not a did.",
Response.Status.BAD_REQUEST);
}
}
/**
* Translate an incoming {@link OID4VCClient} into a keycloak native {@link ClientRepresentation}.
*
* @param oid4VCClient pojo, containing the oid4vc client parameters
* @return a clientRepresentation
*/
protected static ClientRepresentation toClientRepresentation(OID4VCClient oid4VCClient) {
ClientRepresentation clientRepresentation = new ClientRepresentation();
clientRepresentation.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
clientRepresentation.setId(Optional.ofNullable(oid4VCClient.getId()).orElse(UUID.randomUUID().toString()));
clientRepresentation.setClientId(oid4VCClient.getClientDid());
// only add non-null parameters
Optional.ofNullable(oid4VCClient.getDescription()).ifPresent(clientRepresentation::setDescription);
Optional.ofNullable(oid4VCClient.getName()).ifPresent(clientRepresentation::setName);
Map<String, String> clientAttributes = oid4VCClient.getSupportedVCTypes()
.stream()
.map(SupportedCredentialConfiguration::toDotNotation)
.flatMap(dotNotated -> dotNotated.entrySet().stream())
.collect(Collectors.toMap(entry -> VC_KEY + "." + entry.getKey(), Map.Entry::getValue, (e1, e2) -> e1));
if (!clientAttributes.isEmpty()) {
clientRepresentation.setAttributes(clientAttributes);
}
LOGGER.debugf("Generated client representation {}.", clientRepresentation);
return clientRepresentation;
}
public static OID4VCClient fromClientAttributes(String clientId, Map<String, String> clientAttributes) {
OID4VCClient oid4VCClient = new OID4VCClient()
.setClientDid(clientId);
Set<String> supportedCredentialIds = new HashSet<>();
Map<String, String> attributes = new HashMap<>();
clientAttributes
.entrySet()
.forEach(entry -> {
if (!entry.getKey().startsWith(VC_KEY)) {
return;
}
String key = entry.getKey().substring((VC_KEY + ".").length());
supportedCredentialIds.add(key.split("\\.")[0]);
attributes.put(key, entry.getValue());
});
List<SupportedCredentialConfiguration> supportedCredentialConfigurations = supportedCredentialIds
.stream()
.map(id -> SupportedCredentialConfiguration.fromDotNotation(id, attributes))
.toList();
return oid4VCClient.setSupportedVCTypes(supportedCredentialConfigurations);
}
}

View File

@ -1,68 +0,0 @@
/*
* 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;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.clientregistration.ClientRegistrationProvider;
import org.keycloak.services.clientregistration.ClientRegistrationProviderFactory;
import java.util.List;
/**
* Implementation of the {@link ClientRegistrationProviderFactory} to integrate the OID4VC protocols with
* Keycloak's client-registration.
* <p>
* {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public class OID4VCClientRegistrationProviderFactory implements ClientRegistrationProviderFactory, OID4VCEnvironmentProviderFactory {
@Override
public ClientRegistrationProvider create(KeycloakSession session) {
return new OID4VCClientRegistrationProvider(session);
}
@Override
public void init(Config.Scope config) {
// no config required
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// nothing to do post init
}
@Override
public void close() {
// no resources to close
}
@Override
public String getId() {
return OID4VCLoginProtocolFactory.PROTOCOL_ID;
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return List.of();
}
}

View File

@ -22,9 +22,11 @@ import java.util.Map;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.ProtocolMapperModel;
@ -38,6 +40,7 @@ import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCTargetRoleMapper;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCUserAttributeMapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
/**
* Factory for creating all OID4VC related endpoints and the default mappers.
@ -48,7 +51,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
private static final Logger LOGGER = Logger.getLogger(OID4VCLoginProtocolFactory.class);
public static final String PROTOCOL_ID = "oid4vc";
public static final String PROTOCOL_ID = Oid4VciConstants.OID4VC_PROTOCOL;
private static final String CLIENT_ROLES_MAPPER = "client-roles";
private static final String USERNAME_MAPPER = "username";
@ -61,7 +64,7 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
@Override
public void init(Config.Scope config) {
builtins.put(CLIENT_ROLES_MAPPER, OID4VCTargetRoleMapper.create("id", "client roles"));
builtins.put(CLIENT_ROLES_MAPPER, OID4VCTargetRoleMapper.create("client roles"));
builtins.put(SUBJECT_ID_MAPPER, OID4VCSubjectIdMapper.create("subject id", "id"));
builtins.put(USERNAME_MAPPER, OID4VCUserAttributeMapper.create(USERNAME_MAPPER, "username", "username", false));
builtins.put(EMAIL_MAPPER, OID4VCUserAttributeMapper.create(EMAIL_MAPPER, "email", "email", false));
@ -114,6 +117,31 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE
//no-op
}
@Override
public void addClientScopeDefaults(ClientScopeRepresentation clientScope) {
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.CONFIGURATION_ID, k -> clientScope.getName());
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.CREDENTIAL_IDENTIFIER,
k -> clientScope.getName());
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.TYPES, k -> clientScope.getName());
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.CONTEXTS, k -> clientScope.getName());
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.VCT, k -> clientScope.getName());
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.ISSUER_DID, k -> clientScope.getName());
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.FORMAT,
k -> CredentialScopeModel.FORMAT_DEFAULT);
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS,
k -> CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.SD_JWT_NUMBER_OF_DECOYS,
k -> String.valueOf(CredentialScopeModel.SD_JWT_DECOYS_DEFAULT));
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.SD_JWT_VISIBLE_CLAIMS,
k -> CredentialScopeModel.SD_JWT_VISIBLE_CLAIMS_DEFAULT);
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.HASH_ALGORITHM,
k -> CredentialScopeModel.HASH_ALGORITHM_DEFAULT);
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.TOKEN_JWS_TYPE,
k -> CredentialScopeModel.TOKEN_TYPE_DEFAULT);
clientScope.getAttributes().computeIfAbsent(CredentialScopeModel.EXPIRY_IN_SECONDS,
k -> String.valueOf(CredentialScopeModel.EXPIRY_IN_SECONDS_DEFAULT));
}
@Override
public LoginProtocol create(KeycloakSession session) {
return null;

View File

@ -37,23 +37,20 @@ import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.component.ComponentFactory;
import org.keycloak.component.ComponentModel;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.ProtocolMapperContainerModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProvider;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory;
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
@ -67,7 +64,6 @@ import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.NonceResponse;
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
@ -78,7 +74,6 @@ import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
@ -99,12 +94,6 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.keycloak.protocol.oid4vc.model.Format.JWT_VC;
import static org.keycloak.protocol.oid4vc.model.Format.LDP_VC;
import static org.keycloak.protocol.oid4vc.model.Format.SD_JWT_VC;
import static org.keycloak.protocol.oid4vc.model.Format.SUPPORTED_FORMATS;
/**
* Provides the (REST-)endpoints required for the OID4VCI protocol.
@ -146,19 +135,16 @@ public class OID4VCIssuerEndpoint {
*/
private final Map<String, CredentialBuilder> credentialBuilders;
private final boolean isIgnoreScopeCheck;
public OID4VCIssuerEndpoint(KeycloakSession session,
Map<String, CredentialBuilder> credentialBuilders,
AppAuthManager.BearerTokenAuthenticator authenticator,
TimeProvider timeProvider, int preAuthorizedCodeLifeSpan,
boolean isIgnoreScopeCheck) {
TimeProvider timeProvider,
int preAuthorizedCodeLifeSpan) {
this.session = session;
this.bearerTokenAuthenticator = authenticator;
this.timeProvider = timeProvider;
this.credentialBuilders = credentialBuilders;
this.preAuthorizedCodeLifeSpan = preAuthorizedCodeLifeSpan;
this.isIgnoreScopeCheck = isIgnoreScopeCheck;
}
public OID4VCIssuerEndpoint(KeycloakSession keycloakSession) {
@ -172,7 +158,6 @@ public class OID4VCIssuerEndpoint {
this.preAuthorizedCodeLifeSpan = Optional.ofNullable(realm.getAttribute(CODE_LIFESPAN_REALM_ATTRIBUTE_KEY))
.map(Integer::valueOf)
.orElse(DEFAULT_CODE_LIFESPAN_S);
this.isIgnoreScopeCheck = false;
}
/**
@ -182,25 +167,11 @@ public class OID4VCIssuerEndpoint {
*/
private Map<String, CredentialBuilder> loadCredentialBuilders(KeycloakSession keycloakSession) {
KeycloakSessionFactory keycloakSessionFactory = keycloakSession.getKeycloakSessionFactory();
RealmModel realm = keycloakSession.getContext().getRealm();
Stream<ComponentModel> componentModels = realm.getComponentsStream(
realm.getId(), CredentialBuilder.class.getName());
return componentModels.map(componentModel -> {
ProviderFactory<CredentialBuilder> providerFactory = keycloakSessionFactory
.getProviderFactory(CredentialBuilder.class, componentModel.getProviderId());
if (!(providerFactory instanceof ComponentFactory<?, ?>)) {
throw new IllegalArgumentException(String.format(
"Component %s is unexpectedly not a ComponentFactory",
componentModel.getProviderId()
));
}
var componentFactory = (ComponentFactory<CredentialBuilder, CredentialBuilder>) providerFactory;
return componentFactory.create(keycloakSession, componentModel);
})
.collect(Collectors.toMap(CredentialBuilder::getSupportedFormat, component -> component));
return keycloakSessionFactory.getProviderFactoriesStream(CredentialBuilder.class)
.map(factory -> (CredentialBuilderFactory) factory)
.map(factory -> factory.create(keycloakSession, null))
.collect(Collectors.toMap(CredentialBuilder::getSupportedFormat,
credentialBuilder -> credentialBuilder));
}
/**
@ -323,36 +294,31 @@ public class OID4VCIssuerEndpoint {
.build();
}
private void checkScope(CredentialRequest credentialRequestVO) {
private void checkScope(CredentialScopeModel requestedCredential) {
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
String vcIssuanceFlow = clientSession.getNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
if (vcIssuanceFlow == null || !vcIssuanceFlow.equals(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)) {
// Authorization Code Flow
RealmModel realm = session.getContext().getRealm();
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
String scope = realm.getAttribute("vc." + credentialIdentifier + ".scope");
AccessToken accessToken = bearerTokenAuthenticator.authenticate().getToken();
if (scope == null || Arrays.stream(accessToken.getScope().split(" "))
.noneMatch(tokenScope -> tokenScope.equals(scope))) {
LOGGER.debugf("Scope check failure: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
credentialIdentifier, scope, accessToken.getScope());
if (Arrays.stream(accessToken.getScope().split(" "))
.noneMatch(tokenScope -> tokenScope.equals(requestedCredential.getScope()))) {
LOGGER.debugf("Scope check failure: required scope = %s, " +
"scope in access token = %s.",
requestedCredential.getName(), accessToken.getScope());
throw new CorsErrorResponseException(cors,
ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(),
"Scope check failure",
Response.Status.BAD_REQUEST);
} else {
LOGGER.debugf("Scope check success: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
credentialIdentifier, scope, accessToken.getScope());
LOGGER.debugf("Scope check success: required scope = %s, #" +
"scope in access token = %s.",
requestedCredential.getScope(), accessToken.getScope());
}
} else {
clientSession.removeNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
}
}
/**
* Returns a verifiable credential
*/
@ -369,96 +335,40 @@ public class OID4VCIssuerEndpoint {
// do first to fail fast on auth
AuthenticationManager.AuthResult authResult = getAuthResult();
if (!isIgnoreScopeCheck) {
checkScope(credentialRequestVO);
}
// Both Format and identifier are optional.
// If the credential_identifier is present, Format can't be present. But this implementation will
// tolerate the presence of both, waiting for clarity in specifications.
// This implementation will privilege the presence of the credential config identifier.
String requestedCredentialId = credentialRequestVO.getCredentialIdentifier();
String requestedFormat = credentialRequestVO.getFormat();
// Both credential_configuration_id and credential_identifier are optional.
// If the credential_configuration_id is present, credential_identifier can't be present.
// But this implementation will tolerate the presence of both, waiting for clarity in specifications.
// This implementation will privilege the presence of the credential_configuration_id.
String requestedCredentialConfigurationId = credentialRequestVO.getCredentialConfigurationId();
String requestedCredentialIdentifier = credentialRequestVO.getCredentialIdentifier();
// Check if at least one of both is available.
if (requestedCredentialId == null && requestedFormat == null) {
LOGGER.debugf("Missing both configuration id and requested format. At least one shall be specified.");
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG_AND_FORMAT));
if (requestedCredentialConfigurationId == null && requestedCredentialIdentifier == null) {
LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. " +
"At least one must be specified.");
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
}
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session);
CredentialScopeModel requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
LOGGER.debugf("Credential for request '%s' not found.",
credentialRequestVO.toString());
return new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
});
// resolve from identifier first
SupportedCredentialConfiguration supportedCredentialConfiguration = null;
if (requestedCredentialId != null) {
supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId);
if (supportedCredentialConfiguration == null) {
LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId);
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
}
// Then for format. We know spec does not allow both parameter. But we are tolerant if you send both
// Was found by id, check that the format matches.
if (requestedFormat != null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())) {
LOGGER.debugf("Credential with configuration id %s does not support requested format %s, but supports %s.", requestedCredentialId, requestedFormat, supportedCredentialConfiguration.getFormat());
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
}
}
checkScope(requestedCredential);
if (supportedCredentialConfiguration == null && requestedFormat != null) {
// Search by format
supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat);
if (supportedCredentialConfiguration == null) {
LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat);
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
}
}
SupportedCredentialConfiguration supportedCredential =
OID4VCIssuerWellKnownProvider.toSupportedCredentialConfiguration(session, requestedCredential);
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO);
CredentialResponse responseVO = new CredentialResponse();
Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO);
if (SUPPORTED_FORMATS.contains(requestedFormat)) {
responseVO
responseVO
.addCredential(theCredential)
.setNotificationId(generateNotificationId());
} else {
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
}
return Response.ok().entity(responseVO).build();
}
private SupportedCredentialConfiguration getSupportedCredentialConfiguration(CredentialRequest credentialRequestVO, Map<String, SupportedCredentialConfiguration> supportedCredentials, String requestedFormat) {
// 1. Format resolver
List<SupportedCredentialConfiguration> configs = supportedCredentials.values().stream()
.filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(), requestedFormat))
.collect(Collectors.toList());
List<SupportedCredentialConfiguration> matchingConfigs;
switch (requestedFormat) {
case SD_JWT_VC:
// Resolve from vct for sd-jwt
matchingConfigs = configs.stream()
.filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), credentialRequestVO.getVct()))
.collect(Collectors.toList());
break;
case JWT_VC:
case LDP_VC:
// Will detach this when each format provides logic on how to resolve from definition.
matchingConfigs = configs.stream()
.filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), credentialRequestVO.getCredentialDefinition()))
.collect(Collectors.toList());
break;
default:
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
}
if (matchingConfigs.isEmpty()) {
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG));
}
return matchingConfigs.iterator().next();
}
private AuthenticatedClientSessionModel getAuthenticatedClientSession() {
AuthenticationManager.AuthResult authResult = getAuthResult();
UserSessionModel userSessionModel = authResult.getSession();
@ -493,18 +403,20 @@ public class OID4VCIssuerEndpoint {
* @param credentialRequestVO the credential request
* @return the signed credential
*/
private Object getCredential(AuthenticationManager.AuthResult authResult, SupportedCredentialConfiguration credentialConfig, CredentialRequest credentialRequestVO) {
private Object getCredential(AuthenticationManager.AuthResult authResult,
SupportedCredentialConfiguration credentialConfig,
CredentialRequest credentialRequestVO) {
// Get the client scope model from the credential configuration
ClientScopeModel clientScopeModel = getClientScopeModel(credentialConfig);
CredentialScopeModel credentialScopeModel = getClientScopeModel(credentialConfig);
// Get the protocol mappers from the client scope
List<OID4VCMapper> protocolMappers = clientScopeModel.getProtocolMappersStream()
List<OID4VCMapper> protocolMappers = credentialScopeModel.getProtocolMappersStream()
.map(pm -> {
if (session.getProvider(ProtocolMapper.class, pm.getProtocolMapper()) instanceof OID4VCMapper mapperFactory) {
ProtocolMapper protocolMapper = mapperFactory.create(session);
if (protocolMapper instanceof OID4VCMapper oid4VCMapper) {
oid4VCMapper.setMapperModel(pm);
oid4VCMapper.setMapperModel(pm, credentialScopeModel.getFormat());
return oid4VCMapper;
}
}
@ -520,8 +432,8 @@ public class OID4VCIssuerEndpoint {
enforceKeyBindingIfProofProvided(vcIssuanceContext);
// Retrieve matching credential signer
String format = credentialRequestVO.getFormat();
CredentialSigner<?> credentialSigner = session.getProvider(CredentialSigner.class, format);
CredentialSigner<?> credentialSigner = session.getProvider(CredentialSigner.class,
credentialConfig.getFormat());
return Optional.ofNullable(credentialSigner)
.map(signer -> signer.signCredential(
@ -529,11 +441,11 @@ public class OID4VCIssuerEndpoint {
credentialConfig.getCredentialBuildConfig()
))
.orElseThrow(() -> new BadRequestException(
String.format("No signer found for format '%s'.", format)
String.format("No signer found for format '%s'.", credentialConfig.getFormat())
));
}
private ClientScopeModel getClientScopeModel(SupportedCredentialConfiguration credentialConfig) {
private CredentialScopeModel getClientScopeModel(SupportedCredentialConfiguration credentialConfig) {
// Get the current client from the session
ClientModel clientModel = session.getContext().getClient();
@ -545,16 +457,7 @@ public class OID4VCIssuerEndpoint {
throw new BadRequestException("Client scope not found for the specified scope: " + credentialConfig.getScope());
}
return clientScopeModel;
}
private List<ProtocolMapperModel> getProtocolMappers(List<OID4VCClient> oid4VCClients) {
return oid4VCClients.stream()
.map(OID4VCClient::getClientDid)
.map(this::getClient)
.flatMap(ProtocolMapperContainerModel::getProtocolMappersStream)
.toList();
return new CredentialScopeModel(clientScopeModel);
}
private String generateCodeForSession(int expiration, AuthenticatedClientSessionModel clientSession) {
@ -600,19 +503,6 @@ public class OID4VCIssuerEndpoint {
.build();
}
private ClientModel getClient(String clientId) {
return session.clients().getClientByClientId(session.getContext().getRealm(), clientId);
}
private List<OID4VCClient> getOID4VCClientsFromSession() {
return session.clients().getClientsStream(session.getContext().getRealm())
.filter(clientModel -> clientModel.getProtocol() != null)
.filter(clientModel -> clientModel.getProtocol()
.equals(OID4VCLoginProtocolFactory.PROTOCOL_ID))
.map(clientModel -> OID4VCClientRegistrationProvider.fromClientAttributes(clientModel.getClientId(), clientModel.getAttributes()))
.toList();
}
// builds the unsigned credential by applying all protocol mappers.
private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig,
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) {
@ -623,22 +513,18 @@ public class OID4VCIssuerEndpoint {
Map<String, Object> subjectClaims = new HashMap<>();
protocolMappers
.stream()
.filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope()))
.forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, authResult.getSession()));
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
protocolMappers
.stream()
.filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope()))
.forEach(mapper -> mapper.setClaimsForCredential(vc, authResult.getSession()));
LOGGER.debugf("The credential to sign is: %s", vc);
// Build format-specific credential
CredentialBody credentialBody = findCredentialBuilder(credentialConfig)
.buildCredentialBody(vc, credentialConfig.getCredentialBuildConfig());
CredentialBody credentialBody = this.findCredentialBuilder(credentialConfig)
.buildCredentialBody(vc, credentialConfig.getCredentialBuildConfig());
return new VCIssuanceContext()
.setAuthResult(authResult)

View File

@ -18,21 +18,17 @@
package org.keycloak.protocol.oid4vc.issuance;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.models.KeyManager;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProvider;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory;
import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSigner;
import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerFactory;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.services.Urls;
import org.keycloak.urls.UrlType;
@ -41,12 +37,8 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jboss.logging.Logger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -58,8 +50,11 @@ import java.util.stream.Collectors;
*/
public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
public static final String VC_KEY = "vc";
private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerWellKnownProvider.class);
private final KeycloakSession keycloakSession;
protected final KeycloakSession keycloakSession;
public OID4VCIssuerWellKnownProvider(KeycloakSession keycloakSession) {
this.keycloakSession = keycloakSession;
@ -136,41 +131,31 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
* and the credentials supported by the clients available in the session.
*/
public static Map<String, SupportedCredentialConfiguration> getSupportedCredentials(KeycloakSession keycloakSession) {
List<String> globalSupportedSigningAlgorithms = getSupportedSignatureAlgorithms(keycloakSession);
RealmModel realm = keycloakSession.getContext().getRealm();
List<String> supportedFormats = getSupportedFormats(keycloakSession);
// Retrieve signature algorithms
List<String> supportedAlgorithms = getSupportedSignatureAlgorithms(keycloakSession);
// Retrieving attributes from client definition.
// This will be removed when token production is migrated.
Map<String, SupportedCredentialConfiguration> clientAttributes = keycloakSession.getContext()
.getRealm()
.getClientsStream()
.filter(cm -> cm.getProtocol() != null)
.filter(cm -> cm.getProtocol().equals(OID4VCLoginProtocolFactory.PROTOCOL_ID))
.map(cm -> OID4VCClientRegistrationProvider.fromClientAttributes(cm.getClientId(), cm.getAttributes()))
.map(OID4VCClient::getSupportedVCTypes)
.flatMap(List::stream)
.filter(sc -> supportedFormats.contains(sc.getFormat()))
.distinct()
.peek(sc -> sc.setCredentialSigningAlgValuesSupported(supportedAlgorithms))
.collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1));
// Retrieving attributes from the realm
Map<String, SupportedCredentialConfiguration> realmAttr = fromRealmAttributes(realm.getAttributes())
.stream()
.filter(sc -> supportedFormats.contains(sc.getFormat()))
.distinct()
.peek(sc -> sc.setCredentialSigningAlgValuesSupported(supportedAlgorithms))
.collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1));
Map<String, SupportedCredentialConfiguration> supportedCredentialConfigurations =
keycloakSession.clientScopes()
.getClientScopesByProtocol(realm, Oid4VciConstants.OID4VC_PROTOCOL)
.map(CredentialScopeModel::new)
.map(clientScope -> {
return SupportedCredentialConfiguration.parse(keycloakSession,
clientScope,
globalSupportedSigningAlgorithms
);
})
.collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1));
// Aggregating attributes. Having realm attributes take preference.
Map<String, SupportedCredentialConfiguration> aggregatedAttr = new HashMap<>(clientAttributes);
aggregatedAttr.putAll(realmAttr);
return aggregatedAttr;
return supportedCredentialConfigurations;
}
public static SupportedCredentialConfiguration toSupportedCredentialConfiguration(KeycloakSession keycloakSession,
CredentialScopeModel credentialModel) {
List<String> globalSupportedSigningAlgorithms = getSupportedSignatureAlgorithms(keycloakSession);
return SupportedCredentialConfiguration.parse(keycloakSession,
credentialModel,
globalSupportedSigningAlgorithms);
}
/**
@ -198,60 +183,6 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
return getIssuer(context) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + OID4VCIssuerEndpoint.CREDENTIAL_PATH;
}
public static final String VC_KEY = "vc";
public static List<SupportedCredentialConfiguration> fromRealmAttributes(Map<String, String> realmAttributes) {
Set<String> supportedCredentialIds = new HashSet<>();
Map<String, String> attributes = new HashMap<>();
realmAttributes.forEach((entryKey, value) -> {
if (!entryKey.startsWith(VC_KEY)) {
return;
}
String key = entryKey.substring((VC_KEY + ".").length());
supportedCredentialIds.add(key.split("\\.")[0]);
attributes.put(key, value);
});
return supportedCredentialIds
.stream()
.map(id -> SupportedCredentialConfiguration.fromDotNotation(id, attributes))
.toList();
}
/**
* Returns credential formats supported.
* <p></p>
* Supported credential formats are identified on the criterion of a joint availability
* of a credential builder (as a configured component) AND a credential signer.
*/
public static List<String> getSupportedFormats(KeycloakSession keycloakSession) {
RealmModel realm = keycloakSession.getContext().getRealm();
KeycloakSessionFactory keycloakSessionFactory = keycloakSession.getKeycloakSessionFactory();
List<String> supportedFormatsByBuilders = realm
.getComponentsStream(realm.getId(), CredentialBuilder.class.getName())
.map(cm -> keycloakSessionFactory.getProviderFactory(CredentialBuilder.class, cm.getProviderId()))
.filter(CredentialBuilderFactory.class::isInstance)
.map(CredentialBuilderFactory.class::cast)
.map(CredentialBuilderFactory::getSupportedFormat)
.toList();
List<String> supportedFormatsBySigners = keycloakSession
.getKeycloakSessionFactory()
.getProviderFactoriesStream(CredentialSigner.class)
.filter(CredentialSignerFactory.class::isInstance)
.map(CredentialSignerFactory.class::cast)
.map(CredentialSignerFactory::getSupportedFormat)
.toList();
// Supported formats must have a builder AND a signer
List<String> supportedFormats = new ArrayList<>(supportedFormatsByBuilders);
supportedFormats.retainAll(supportedFormatsBySigners);
return supportedFormats;
}
public static List<String> getSupportedSignatureAlgorithms(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
KeyManager keyManager = session.keys();
@ -263,4 +194,5 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
.distinct()
.collect(Collectors.toList());
}
}

View File

@ -17,8 +17,6 @@
package org.keycloak.protocol.oid4vc.issuance.credentialbuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import java.net.URI;
@ -27,7 +25,6 @@ import java.util.UUID;
public class CredentialBuilderUtils {
private static final String ISSUER_DID_REALM_ATTRIBUTE_KEY = "issuerDid";
private static final String ID_TEMPLATE = "urn:uuid:%s";
// retrieve the credential id from the given VC or generate one.
@ -36,9 +33,4 @@ public class CredentialBuilderUtils {
.orElse(URI.create(String.format(ID_TEMPLATE, UUID.randomUUID())))
.toString();
}
public static Optional<String> getIssuerDid(KeycloakSession keycloakSession) {
RealmModel realm = keycloakSession.getContext().getRealm();
return Optional.ofNullable(realm.getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY));
}
}

View File

@ -24,7 +24,6 @@ import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import java.net.URI;
import java.time.Instant;
import java.util.Optional;
@ -33,11 +32,9 @@ public class JwtCredentialBuilder implements CredentialBuilder {
private static final String VC_CLAIM_KEY = "vc";
private static final String ID_CLAIM_KEY = "id";
private final String credentialIssuer;
private final TimeProvider timeProvider;
public JwtCredentialBuilder(String credentialIssuer, TimeProvider timeProvider) {
this.credentialIssuer = credentialIssuer;
public JwtCredentialBuilder(TimeProvider timeProvider) {
this.timeProvider = timeProvider;
}
@ -52,7 +49,7 @@ public class JwtCredentialBuilder implements CredentialBuilder {
CredentialBuildConfig credentialBuildConfig
) throws CredentialBuilderException {
// Populate the issuer field of the VC
verifiableCredential.setIssuer(URI.create(credentialIssuer));
verifiableCredential.setIssuer(credentialBuildConfig.getCredentialIssuer());
// Get the issuance date from the credential. Since nbf is mandatory, we set it to the current time if not
// provided

View File

@ -50,9 +50,6 @@ public class JwtCredentialBuilderFactory implements CredentialBuilderFactory {
@Override
public CredentialBuilder create(KeycloakSession session, ComponentModel model) {
String credentialIssuer = CredentialBuilderUtils.getIssuerDid(session)
.orElseThrow(() -> new CredentialBuilderException("No issuerDid configured."));
return new JwtCredentialBuilder(credentialIssuer, new OffsetTimeProvider());
return new JwtCredentialBuilder(new OffsetTimeProvider());
}
}

View File

@ -31,10 +31,7 @@ import java.net.URI;
*/
public class LDCredentialBuilder implements CredentialBuilder {
private final String credentialIssuer;
public LDCredentialBuilder(String credentialIssuer) {
this.credentialIssuer = credentialIssuer;
public LDCredentialBuilder() {
}
@Override
@ -49,7 +46,7 @@ public class LDCredentialBuilder implements CredentialBuilder {
) throws CredentialBuilderException {
// The default credential format is basically this format,
// so not much is to be done.
verifiableCredential.setIssuer(URI.create(credentialIssuer));
verifiableCredential.setIssuer(credentialBuildConfig.getCredentialIssuer());
return new LDCredentialBody(verifiableCredential);
}
}

View File

@ -49,9 +49,6 @@ public class LDCredentialBuilderFactory implements CredentialBuilderFactory {
@Override
public CredentialBuilder create(KeycloakSession session, ComponentModel model) {
String credentialIssuer = CredentialBuilderUtils.getIssuerDid(session)
.orElseThrow(() -> new CredentialBuilderException("No issuerDid configured."));
return new LDCredentialBuilder(credentialIssuer);
return new LDCredentialBuilder();
}
}

View File

@ -34,10 +34,7 @@ public class SdJwtCredentialBuilder implements CredentialBuilder {
public static final String ISSUER_CLAIM = "iss";
public static final String VERIFIABLE_CREDENTIAL_TYPE_CLAIM = "vct";
private final String credentialIssuer;
public SdJwtCredentialBuilder(String credentialIssuer) {
this.credentialIssuer = credentialIssuer;
public SdJwtCredentialBuilder() {
}
@Override
@ -58,7 +55,7 @@ public class SdJwtCredentialBuilder implements CredentialBuilder {
DisclosureSpec.Builder disclosureSpecBuilder = DisclosureSpec.builder();
claimSet.entrySet()
.stream()
.filter(entry -> !credentialBuildConfig.getVisibleClaims().contains(entry.getKey()))
.filter(entry -> !credentialBuildConfig.getSdJwtVisibleClaims().contains(entry.getKey()))
.forEach(entry -> {
if (entry instanceof List<?> listValue) {
// FIXME: Unreachable branch. The intent was probably to check `entry.getValue()`,
@ -75,7 +72,7 @@ public class SdJwtCredentialBuilder implements CredentialBuilder {
});
// Populate configured fields (necessarily visible)
claimSet.put(ISSUER_CLAIM, credentialIssuer);
claimSet.put(ISSUER_CLAIM, credentialBuildConfig.getCredentialIssuer());
claimSet.put(VERIFIABLE_CREDENTIAL_TYPE_CLAIM, credentialBuildConfig.getCredentialType());
// jti, nbf, iat and exp are all optional. So need to be set by a protocol mapper if needed.

View File

@ -50,11 +50,6 @@ public class SdJwtCredentialBuilderFactory implements CredentialBuilderFactory {
@Override
public CredentialBuilder create(KeycloakSession session, ComponentModel model) {
// Use the credential issuer URI advertised on the metadata endpoint by default.
// An issuer DID configured at the realm level overrides that value.
String credentialIssuer = CredentialBuilderUtils.getIssuerDid(session)
.orElse(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()));
return new SdJwtCredentialBuilder(credentialIssuer);
return new SdJwtCredentialBuilder();
}
}

View File

@ -32,7 +32,7 @@ import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.oid4vci.Oid4VciConstants;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.model.JwtCNonce;
import org.keycloak.representations.JsonWebToken;

View File

@ -59,6 +59,11 @@ public class JwtProofValidator extends AbstractProofValidator {
super(keycloakSession);
}
@Override
public String getProofType() {
return ProofType.JWT;
}
public JWK validateProof(VCIssuanceContext vcIssuanceContext) throws VCIssuerException {
try {
return validateJwtProof(vcIssuanceContext);
@ -129,7 +134,7 @@ public class JwtProofValidator extends AbstractProofValidator {
return Optional.ofNullable(vcIssuanceContext.getCredentialConfig())
.map(SupportedCredentialConfiguration::getProofTypesSupported)
.flatMap(proofTypesSupported -> {
Optional.ofNullable(proofTypesSupported.getJwt())
Optional.ofNullable(proofTypesSupported.getSupportedProofTypes().get("jwt"))
.orElseThrow(() -> new VCIssuerException("SD-JWT supports only jwt proof type."));
Proof proofObject = vcIssuanceContext.getCredentialRequest().getProof();
@ -170,8 +175,9 @@ public class JwtProofValidator extends AbstractProofValidator {
// The Algorithm enum class does not list the none value anyway.
Optional.ofNullable(vcIssuanceContext.getCredentialConfig())
.map(SupportedCredentialConfiguration::getProofTypesSupported)
.map(ProofTypesSupported::getJwt)
.map(ProofTypeJWT::getProofSigningAlgValuesSupported)
.map(ProofTypesSupported::getSupportedProofTypes)
.map(proofTypeData -> proofTypeData.get("jwt"))
.map(ProofTypesSupported.SupportedProofTypeData::getSigningAlgorithmsSupported)
.filter(supportedAlgs -> supportedAlgs.contains(jwsHeader.getAlgorithm().name()))
.orElseThrow(() -> new VCIssuerException("Proof signature algorithm not supported: " + jwsHeader.getAlgorithm().name()));

View File

@ -28,6 +28,8 @@ public interface ProofValidator extends Provider {
default void close() {
}
String getProofType();
/**
* Validates a client-provided key binding proof.
*

View File

@ -17,6 +17,7 @@
package org.keycloak.protocol.oid4vc.issuance.mappers;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.ProtocolMapper;
@ -27,6 +28,7 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
@ -57,6 +59,21 @@ public class OID4VCContextMapper extends OID4VCMapper {
return CONFIG_PROPERTIES;
}
/**
* this claim is not added by default to the metadata
*/
@Override
public boolean includeInMetadata() {
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
.map(Boolean::parseBoolean)
.orElse(false);
}
@Override
public List<String> getMetadataAttributePath() {
return List.of(TYPE_KEY);
}
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
UserSessionModel userSessionModel) {
// remove duplicates
@ -92,4 +109,4 @@ public class OID4VCContextMapper extends OID4VCMapper {
public String getId() {
return MAPPER_ID;
}
}
}

View File

@ -17,6 +17,8 @@
package org.keycloak.protocol.oid4vc.issuance.mappers;
import org.apache.commons.collections4.ListUtils;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.ProtocolMapper;
@ -37,14 +39,13 @@ import java.util.UUID;
public class OID4VCGeneratedIdMapper extends OID4VCMapper {
public static final String MAPPER_ID = "oid4vc-generated-id-mapper";
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
private static final String SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT = "id";
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
static {
ProviderConfigProperty idPropertyNameConfig = new ProviderConfigProperty();
idPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
idPropertyNameConfig.setName(CLAIM_NAME);
idPropertyNameConfig.setLabel("ID Property Name");
idPropertyNameConfig.setHelpText("Name of the property to contain the generated id.");
idPropertyNameConfig.setDefaultValue(SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT);
@ -57,6 +58,24 @@ public class OID4VCGeneratedIdMapper extends OID4VCMapper {
return CONFIG_PROPERTIES;
}
/**
* this claim is not added by default to the metadata
*/
@Override
public boolean includeInMetadata() {
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
.map(Boolean::parseBoolean)
.orElse(false);
}
@Override
public List<String> getMetadataAttributePath() {
String property = Optional.ofNullable(mapperModel.getConfig())
.map(config -> config.get(CLAIM_NAME))
.orElse(SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT);
return ListUtils.union(getAttributePrefix(), List.of(property));
}
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
UserSessionModel userSessionModel) {
// nothing to do for the mapper.
@ -64,12 +83,10 @@ public class OID4VCGeneratedIdMapper extends OID4VCMapper {
@Override
public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) {
String property = Optional.ofNullable(mapperModel.getConfig())
.map(config -> config.get(SUBJECT_PROPERTY_CONFIG_KEY))
.orElse(SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT);
// Assign a generated ID
claims.put(property, String.format("urn:uuid:%s", UUID.randomUUID()));
List<String> attributePath = getMetadataAttributePath();
String propertyName = attributePath.get(attributePath.size() - 1);
claims.put(propertyName, String.format("urn:uuid:%s", UUID.randomUUID()));
}
@Override

View File

@ -16,12 +16,15 @@
*/
package org.keycloak.protocol.oid4vc.issuance.mappers;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.provider.ProviderConfigProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@ -47,9 +50,6 @@ public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper {
public static final String MAPPER_ID = "oid4vc-issued-at-time-claim-mapper";
// Omit value if defaults to "iat"
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
// We will use the java.time.temporal.ChronoUnit enum values to help flatten down the time.
// Omit property if no truncation.
public static final String TRUNCATE_TO_TIME_UNIT_KEY = "truncateToTimeUnit";
@ -59,10 +59,11 @@ public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper {
public static final String VALUE_SOURCE = "valueSource";
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
private static final Logger log = LoggerFactory.getLogger(OID4VCIssuedAtTimeClaimMapper.class);
static {
ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
subjectPropertyNameConfig.setName(CLAIM_NAME);
subjectPropertyNameConfig.setLabel("Time Claim Name");
subjectPropertyNameConfig.setHelpText("Name of this time claim. Default is iat");
subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
@ -92,8 +93,29 @@ public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper {
return CONFIG_PROPERTIES;
}
/**
* this claim is not added by default to the metadata
*/
@Override
public boolean includeInMetadata() {
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
.map(Boolean::parseBoolean)
.orElse(false);
}
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
UserSessionModel userSessionModel) {
// Set the value
List<String> attributePath = getMetadataAttributePath();
String propertyName = attributePath.get(attributePath.size() - 1);
if (propertyName == null) {
log.error("Invalid configuration: missing config-property '{}' for mapper '{}' of type '{}'. Mapper is ignored.",
CLAIM_NAME,
mapperModel.getName(),
MAPPER_ID);
return;
}
Instant iat = Optional.ofNullable(mapperModel.getConfig())
.flatMap(config -> Optional.ofNullable(config.get(VALUE_SOURCE)))
.filter(valueSource -> Objects.equals(valueSource, "COMPUTE"))
@ -104,15 +126,11 @@ public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper {
// truncate is possible. Return iat if not.
Instant iatTrunc = Optional.ofNullable(mapperModel.getConfig())
.flatMap(config -> Optional.ofNullable(config.get(TRUNCATE_TO_TIME_UNIT_KEY)))
.filter(i -> i.isEmpty())
.filter(String::isEmpty)
.map(ChronoUnit::valueOf)
.map(iat::truncatedTo)
.orElse(iat);
// Set the value
String propertyName = Optional.ofNullable(mapperModel.getConfig())
.map(config -> config.get(SUBJECT_PROPERTY_CONFIG_KEY))
.orElse("iat");
CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject();
credentialSubject.setClaims(propertyName, iatTrunc.getEpochSecond());
}

View File

@ -17,18 +17,22 @@
package org.keycloak.protocol.oid4vc.issuance.mappers;
import org.apache.commons.collections4.ListUtils;
import org.keycloak.Config;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -41,23 +45,11 @@ import java.util.stream.Stream;
*/
public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentProviderFactory {
protected static final String SUPPORTED_CREDENTIALS_KEY = "supportedCredentialTypes";
protected ProtocolMapperModel mapperModel;
public static final String CLAIM_NAME = "claim.name";
public static final String USER_ATTRIBUTE_KEY = "userAttribute";
private static final List<ProviderConfigProperty> OID4VC_CONFIG_PROPERTIES = new ArrayList<>();
static {
ProviderConfigProperty supportedCredentialsConfig = new ProviderConfigProperty();
supportedCredentialsConfig.setType(ProviderConfigProperty.STRING_TYPE);
supportedCredentialsConfig.setLabel("Supported Credential Types");
supportedCredentialsConfig.setDefaultValue("VerifiableCredential");
supportedCredentialsConfig.setHelpText(
"Types of Credentials to apply the mapper. Needs to be a comma-separated list.");
supportedCredentialsConfig.setName(SUPPORTED_CREDENTIALS_KEY);
OID4VC_CONFIG_PROPERTIES.clear();
OID4VC_CONFIG_PROPERTIES.add(supportedCredentialsConfig);
}
protected ProtocolMapperModel mapperModel;
protected String format;
protected abstract List<ProviderConfigProperty> getIndividualConfigProperties();
@ -66,11 +58,43 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
return Stream.concat(OID4VC_CONFIG_PROPERTIES.stream(), getIndividualConfigProperties().stream()).toList();
}
public OID4VCMapper setMapperModel(ProtocolMapperModel mapperModel) {
public OID4VCMapper setMapperModel(ProtocolMapperModel mapperModel, String format) {
this.mapperModel = mapperModel;
this.format = format;
return this;
}
/**
* some specific claims should not be added into the metadata. Examples are jti, sub, iss etc. Since we have the
* possibility to add these credentials with specific claims we should also be able to exclude these specific
* attributes from the metadata
*/
public boolean includeInMetadata() {
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
.map(Boolean::parseBoolean)
.orElse(true);
}
/**
* must return ordered list of attribute-names as they are added into the credential. This is required for the
* metadata endpoint to add the appropriate path-attributes into the claim's description.
*
* @return the attribute path that is being mapped into the credential
*/
public List<String> getMetadataAttributePath() {
final String claimName = mapperModel.getConfig().get(CLAIM_NAME);
final String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY);
return ListUtils.union(getAttributePrefix(),
List.of(Optional.ofNullable(claimName).orElse(userAttributeName)));
}
protected List<String> getAttributePrefix() {
return switch (Optional.ofNullable(format).orElse("")) {
case Format.JWT_VC, Format.LDP_VC -> List.of(Oid4VciConstants.CREDENTIAL_SUBJECT);
default -> Collections.emptyList();
};
}
@Override
public String getProtocol() {
return OID4VCLoginProtocolFactory.PROTOCOL_ID;
@ -94,20 +118,6 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
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 credentialScope type of the VerifiableCredential that should be checked
* @return true if it is supported
*/
public boolean isScopeSupported(String credentialScope) {
var optionalScopes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY));
if (optionalScopes.isEmpty()) {
return false;
}
return Arrays.asList(optionalScopes.get().split(",")).contains(credentialScope);
}
/**
* Set the claims to credential, like f.e. the context
*/

View File

@ -36,14 +36,13 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper {
public static final String MAPPER_ID = "oid4vc-static-claim-mapper";
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
public static final String STATIC_CLAIM_KEY = "staticValue";
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
static {
ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
subjectPropertyNameConfig.setName(CLAIM_NAME);
subjectPropertyNameConfig.setLabel("Static Claim Property Name");
subjectPropertyNameConfig.setHelpText("Name of the property to contain the static value.");
subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
@ -69,7 +68,8 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper {
@Override
public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) {
String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
List<String> attributePath = getMetadataAttributePath();
String propertyName = attributePath.get(attributePath.size() - 1);
String staticValue = mapperModel.getConfig().get(STATIC_CLAIM_KEY);
claims.put(propertyName, staticValue);
}
@ -93,4 +93,4 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper {
public String getId() {
return MAPPER_ID;
}
}
}

View File

@ -17,6 +17,7 @@
package org.keycloak.protocol.oid4vc.issuance.mappers;
import org.apache.commons.collections4.ListUtils;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
@ -39,13 +40,12 @@ import java.util.UUID;
public class OID4VCSubjectIdMapper extends OID4VCMapper {
public static final String MAPPER_ID = "oid4vc-subject-id-mapper";
public static final String ID_KEY = "subjectIdProperty";
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
static {
ProviderConfigProperty idPropertyNameConfig = new ProviderConfigProperty();
idPropertyNameConfig.setName(ID_KEY);
idPropertyNameConfig.setName(CLAIM_NAME);
idPropertyNameConfig.setLabel("ID Property Name");
idPropertyNameConfig.setHelpText("Name of the property to contain the id.");
idPropertyNameConfig.setDefaultValue("id");
@ -58,12 +58,16 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
return CONFIG_PROPERTIES;
}
@Override
public List<String> getMetadataAttributePath() {
return ListUtils.union(getAttributePrefix(), List.of("id"));
}
public static ProtocolMapperModel create(String name, String subjectId) {
var mapperModel = new ProtocolMapperModel();
mapperModel.setName(name);
Map<String, String> configMap = new HashMap<>();
configMap.put(ID_KEY, subjectId);
configMap.put(SUPPORTED_CREDENTIALS_KEY, "VerifiableCredential");
configMap.put(CLAIM_NAME, subjectId);
mapperModel.setConfig(configMap);
mapperModel.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
mapperModel.setProtocolMapper(MAPPER_ID);
@ -77,7 +81,11 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
@Override
public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) {
claims.put("id", mapperModel.getConfig().getOrDefault(ID_KEY, String.format("urn:uuid:%s", UUID.randomUUID())));
List<String> attributePath = getMetadataAttributePath();
String propertyName = attributePath.get(attributePath.size() - 1);
claims.put(propertyName,
mapperModel.getConfig().getOrDefault(OID4VCMapper.CLAIM_NAME,
String.format("urn:uuid:%s", UUID.randomUUID())));
}
@Override
@ -99,4 +107,4 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
public String getId() {
return MAPPER_ID;
}
}
}

View File

@ -18,7 +18,7 @@
package org.keycloak.protocol.oid4vc.issuance.mappers;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.collections4.ListUtils;
import org.jboss.logging.Logger;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
@ -30,12 +30,14 @@ import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.model.Role;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.util.JsonSerialization;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@ -47,17 +49,15 @@ import java.util.stream.Collectors;
public class OID4VCTargetRoleMapper extends OID4VCMapper {
private static final Logger LOGGER = Logger.getLogger(OID4VCTargetRoleMapper.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public static final String DEFAULT_CLAIM_NAME = "roles";
public static final String MAPPER_ID = "oid4vc-target-role-mapper";
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
public static final String CLIENT_CONFIG_KEY = "clientId";
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
static {
ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
subjectPropertyNameConfig.setName(CLAIM_NAME);
subjectPropertyNameConfig.setLabel("Roles Property Name");
subjectPropertyNameConfig.setHelpText("Property to add the roles to in the credential subject.");
subjectPropertyNameConfig.setDefaultValue("roles");
@ -65,11 +65,28 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
CONFIG_PROPERTIES.add(subjectPropertyNameConfig);
}
private final KeycloakSession keycloakSession;
public OID4VCTargetRoleMapper() {
this.keycloakSession = null;
}
public OID4VCTargetRoleMapper(KeycloakSession keycloakSession) {
this.keycloakSession = keycloakSession;
}
@Override
protected List<ProviderConfigProperty> getIndividualConfigProperties() {
return CONFIG_PROPERTIES;
}
@Override
public List<String> getMetadataAttributePath() {
return ListUtils.union(getAttributePrefix(),
List.of(Optional.ofNullable(mapperModel.getConfig().get(CLAIM_NAME))
.orElse(DEFAULT_CLAIM_NAME)));
}
@Override
public String getDisplayType() {
return "Target-Role Mapper";
@ -80,12 +97,11 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
return "Map the assigned role to the credential subject, providing the client id as the target.";
}
public static ProtocolMapperModel create(String clientId, String name) {
public static ProtocolMapperModel create(String name) {
var mapperModel = new ProtocolMapperModel();
mapperModel.setName(name);
Map<String, String> configMap = new HashMap<>();
configMap.put(SUBJECT_PROPERTY_CONFIG_KEY, "roles");
configMap.put(CLIENT_CONFIG_KEY, clientId);
configMap.put(CLAIM_NAME, DEFAULT_CLAIM_NAME);
mapperModel.setConfig(configMap);
mapperModel.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
mapperModel.setProtocolMapper(MAPPER_ID);
@ -94,7 +110,7 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
@Override
public ProtocolMapper create(KeycloakSession session) {
return new OID4VCTargetRoleMapper();
return new OID4VCTargetRoleMapper(session);
}
@Override
@ -111,18 +127,19 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
@Override
public void setClaimsForSubject(Map<String, Object> claims,
UserSessionModel userSessionModel) {
String client = mapperModel.getConfig().get(CLIENT_CONFIG_KEY);
String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
ClientModel clientModel = userSessionModel.getRealm().getClientByClientId(client);
List<String> attributePath = getMetadataAttributePath();
String propertyName = attributePath.get(attributePath.size() - 1);
ClientModel clientModel = keycloakSession.getContext().getClient();
if (clientModel == null) {
LOGGER.warnf("Client %s not found in realm.", client);
LOGGER.warnf("Client %s not found.", clientModel.getClientId());
return;
}
// Retrieve only the roles assigned to the user for this specific client
List<RoleModel> userRoles = userSessionModel.getUser().getClientRoleMappingsStream(clientModel).toList();
if (userRoles.isEmpty()) {
LOGGER.debugf("No roles assigned to client '%s'. Skipping claim assignment.", client);
LOGGER.debugf("No roles assigned to client '%s'. Skipping claim assignment.",
clientModel.getClientId());
return;
}
@ -130,11 +147,12 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
ClientRoleModel clientRoleModel = new ClientRoleModel(clientModel.getClientId(), userRoles);
Role rolesClaim = toRolesClaim(clientRoleModel);
if (rolesClaim.getNames().isEmpty()) {
LOGGER.debugf("No valid role names found for client '%s'. Skipping claim assignment.", client);
LOGGER.debugf("No valid role names found for client '%s'. Skipping claim assignment.",
clientModel.getClientId());
return;
}
Map<String, Object> modelMap = OBJECT_MAPPER.convertValue(rolesClaim, new TypeReference<>() {});
Map<String, Object> modelMap = JsonSerialization.mapper.convertValue(rolesClaim, new TypeReference<>() {});
Object existingProperty = claims.get(propertyName);
if (existingProperty == null) {
@ -148,11 +166,11 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
rolesProperty.add(modelMap);
} else {
LOGGER.warnf("Claim '%s' contains incompatible types. Expected Set<Map<String, Object>>, found '%s'. Skipping role assignment for client '%s'.",
propertyName, existingProperty.getClass().getSimpleName(), client);
propertyName, existingProperty.getClass().getSimpleName(), clientModel.getClientId());
}
} else {
LOGGER.warnf("Claim '%s' is of type '%s', expected Set. Skipping role assignment for client '%s'.",
propertyName, existingProperty.getClass().getSimpleName(), client);
propertyName, existingProperty.getClass().getSimpleName(), clientModel.getClientId());
}
}

View File

@ -17,6 +17,7 @@
package org.keycloak.protocol.oid4vc.issuance.mappers;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.ProtocolMapper;
@ -57,6 +58,21 @@ public class OID4VCTypeMapper extends OID4VCMapper {
return CONFIG_PROPERTIES;
}
/**
* this claim is not added by default to the metadata
*/
@Override
public boolean includeInMetadata() {
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
.map(Boolean::parseBoolean)
.orElse(false);
}
@Override
public List<String> getMetadataAttributePath() {
return List.of("type");
}
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
UserSessionModel userSessionModel) {
// remove duplicates
@ -92,4 +108,4 @@ public class OID4VCTypeMapper extends OID4VCMapper {
public String getId() {
return MAPPER_ID;
}
}
}

View File

@ -43,17 +43,16 @@ import java.util.Optional;
public class OID4VCUserAttributeMapper extends OID4VCMapper {
public static final String MAPPER_ID = "oid4vc-user-attribute-mapper";
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
public static final String USER_ATTRIBUTE_KEY = "userAttribute";
public static final String AGGREGATE_ATTRIBUTES_KEY = "aggregateAttributes";
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
static {
ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
subjectPropertyNameConfig.setLabel("Attribute Property Name");
subjectPropertyNameConfig.setHelpText("Property to add the user attribute to in the credential subject.");
subjectPropertyNameConfig.setName(CLAIM_NAME);
subjectPropertyNameConfig.setLabel("Claim Name");
subjectPropertyNameConfig.setHelpText("The name of the claim added to the credential subject that is extracted " +
"from the user attributes.");
subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
CONFIG_PROPERTIES.add(subjectPropertyNameConfig);
@ -87,7 +86,8 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
@Override
public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) {
String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
List<String> attributePath = getMetadataAttributePath();
String propertyName = attributePath.get(attributePath.size() - 1);
String userAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY);
boolean aggregateAttributes = Optional.ofNullable(mapperModel.getConfig().get(AGGREGATE_ATTRIBUTES_KEY))
.map(Boolean::parseBoolean).orElse(false);
@ -105,7 +105,7 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
var mapperModel = new ProtocolMapperModel();
mapperModel.setName(mapperName);
Map<String, String> configMap = new HashMap<>();
configMap.put(SUBJECT_PROPERTY_CONFIG_KEY, propertyName);
configMap.put(CLAIM_NAME, propertyName);
configMap.put(USER_ATTRIBUTE_KEY, userAttribute);
configMap.put(AGGREGATE_ATTRIBUTES_KEY, Boolean.toString(aggregateAttributes));
mapperModel.setConfig(configMap);
@ -133,4 +133,4 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
public String getId() {
return MAPPER_ID;
}
}
}

View File

@ -16,29 +16,104 @@
*/
package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
import java.util.List;
import java.util.Optional;
/**
* Holding metadata on a claim of verifiable credential.
* <p>
* See: <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2">openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2</a>
* See: <a
* href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2">openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2</a>
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Claim {
/**
* the claims name, which is not part of the underlying JSON structure
*/
@JsonIgnore
private String name;
@JsonProperty("path")
private List<String> path;
@JsonProperty("mandatory")
private Boolean mandatory;
@JsonProperty("value_type")
private String valueType;
@JsonProperty("display")
private List<ClaimDisplay> display;
public Boolean getMandatory() {
return mandatory;
public static Optional<Claim> parse(KeycloakSession keycloakSession,
String credentialFormat,
Oid4vcProtocolMapperModel protocolMapper) {
try {
Claim claim = new Claim();
ProtocolMapper protocolMapperImpl = keycloakSession.getProvider(ProtocolMapper.class,
protocolMapper.getProtocolMapper());
if (!(protocolMapperImpl instanceof OID4VCMapper)) {
return Optional.empty();
}
OID4VCMapper mapper = (OID4VCMapper) protocolMapperImpl;
mapper.setMapperModel(protocolMapper, credentialFormat);
if (!mapper.includeInMetadata()) {
return Optional.empty();
}
claim.setName(String.join(".", mapper.getMetadataAttributePath()));
claim.setPath(mapper.getMetadataAttributePath());
claim.setMandatory(protocolMapper.isMandatory());
String displayString = protocolMapper.getDisplay();
if (StringUtil.isNotBlank(displayString)) {
TypeReference<List<ClaimDisplay>> typeReference = new TypeReference<>() {};
List<ClaimDisplay> claimDisplayList = JsonSerialization.mapper.readValue(displayString, typeReference);
claim.setDisplay(claimDisplayList);
}
return Optional.of(claim);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public String getName() {
return name;
}
public Claim setName(String name) {
this.name = name;
return this;
}
public List<String> getPath() {
return path;
}
public Claim setPath(List<String> path) {
this.path = path;
return this;
}
public boolean isMandatory() {
return Optional.ofNullable(mandatory).orElse(false);
}
public Claim setMandatory(Boolean mandatory) {
@ -46,15 +121,6 @@ public class Claim {
return this;
}
public String getValueType() {
return valueType;
}
public Claim setValueType(String valueType) {
this.valueType = valueType;
return this;
}
public List<ClaimDisplay> getDisplay() {
return display;
}

View File

@ -18,6 +18,10 @@ package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.keycloak.util.JsonSerialization;
import java.util.Objects;
/**
*
@ -48,4 +52,30 @@ public class ClaimDisplay {
this.locale = locale;
return this;
}
@Override
public final boolean equals(Object object) {
if (!(object instanceof ClaimDisplay that)) {
return false;
}
return Objects.equals(name, that.name) && Objects.equals(locale, that.locale);
}
@Override
public int hashCode() {
int result = Objects.hashCode(name);
result = 31 * result + Objects.hashCode(locale);
return result;
}
@Override
public String toString() {
try {
return JsonSerialization.mapper.writeValueAsString(this);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -16,15 +16,27 @@
*/
package org.keycloak.protocol.oid4vc.model;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.Optional;
/**
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
public class Claims extends HashMap<String, Claim> {
public class Claims extends ArrayList<Claim> {
public static Claims parse(KeycloakSession keycloakSession, CredentialScopeModel credentialScope) {
Claims claims = new Claims();
credentialScope.getOid4vcProtocolMappersStream().forEach(protocolMapper -> {
Optional<Claim> claim = Claim.parse(keycloakSession, credentialScope.getFormat(), protocolMapper);
claim.ifPresent(claims::add);
});
return claims;
}
public String toJsonString(){
try {

View File

@ -17,17 +17,14 @@
package org.keycloak.protocol.oid4vc.model;
import java.util.Arrays;
import java.util.HashMap;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import static org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration.CREDENTIAL_BUILD_CONFIG_KEY;
import static org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration.DOT_SEPARATOR;
import static org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration.VERIFIABLE_CREDENTIAL_TYPE_KEY;
/**
* Define credential-specific configurations for its builder.
*
@ -47,7 +44,8 @@ public class CredentialBuildConfig {
private static final String SIGNING_ALGORITHM_KEY = "signing_algorithm";
private static final String LDP_PROOF_TYPE_KEY = "ldp_proof_type";
// This is saved here to facilitate dot notation reconstruction
private String credentialIssuer;
private String credentialId;
//-- Proper building configuration fields --//
@ -63,7 +61,7 @@ public class CredentialBuildConfig {
private String hashAlgorithm;
// List of claims to stay disclosed in the SD-JWT.
private List<String> visibleClaims;
private List<String> sdJwtVisibleClaims;
// The number of decoys to be added to the SD-JWT.
private Integer numberOfDecoys;
@ -87,6 +85,33 @@ public class CredentialBuildConfig {
// Needs to fit the provided signing key.
private String ldpProofType;
public static CredentialBuildConfig parse(KeycloakSession keycloakSession,
SupportedCredentialConfiguration credentialConfiguration,
CredentialScopeModel credentialModel) {
final String credentialIssuer = Optional.ofNullable(credentialModel.getIssuerDid()).orElse(
OID4VCIssuerWellKnownProvider.getIssuer(keycloakSession.getContext()));
return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer)
.setCredentialId(credentialConfiguration.getId())
.setCredentialType(credentialConfiguration.getVct())
.setTokenJwsType(credentialModel.getTokenJwsType())
.setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys())
.setSigningKeyId(credentialModel.getSigningKeyId())
.setSigningAlgorithm(credentialConfiguration.getCredentialSigningAlgValuesSupported()
.get(0))
.setHashAlgorithm(credentialModel.getHashAlgorithm())
.setSdJwtVisibleClaims(credentialModel.getSdJwtVisibleClaims());
}
public String getCredentialIssuer() {
return credentialIssuer;
}
public CredentialBuildConfig setCredentialIssuer(String credentialIssuer) {
this.credentialIssuer = credentialIssuer;
return this;
}
public String getCredentialId() {
return credentialId;
}
@ -114,12 +139,12 @@ public class CredentialBuildConfig {
return this;
}
public List<String> getVisibleClaims() {
return visibleClaims;
public List<String> getSdJwtVisibleClaims() {
return sdJwtVisibleClaims;
}
public CredentialBuildConfig setVisibleClaims(List<String> visibleClaims) {
this.visibleClaims = visibleClaims;
public CredentialBuildConfig setSdJwtVisibleClaims(List<String> sdJwtVisibleClaims) {
this.sdJwtVisibleClaims = sdJwtVisibleClaims;
return this;
}
@ -177,96 +202,39 @@ public class CredentialBuildConfig {
return this;
}
public Map<String, String> toDotNotation() {
Map<String, String> dotNotation = new HashMap<>();
// vct is skipped because it is not expected nester under CREDENTIAL_BUILD_CONFIG_KEY
String prefix = getDotNotationPrefix(credentialId);
Optional.ofNullable(tokenJwsType)
.ifPresent(tokenJwsType -> dotNotation.put(prefix + TOKEN_JWS_TYPE_KEY, tokenJwsType));
Optional.ofNullable(hashAlgorithm)
.ifPresent(hashAlgorithm -> dotNotation.put(prefix + HASH_ALGORITHM_KEY, hashAlgorithm));
Optional.ofNullable(numberOfDecoys)
.ifPresent(numberOfDecoys ->
dotNotation.put(prefix + NUMBER_OF_DECOYS_KEY, String.valueOf(numberOfDecoys)));
Optional.ofNullable(visibleClaims)
.ifPresent(claims -> dotNotation.put(prefix + VISIBLE_CLAIMS_KEY,
String.join(MULTIVALUED_STRING_SEPARATOR, claims)));
Optional.ofNullable(signingKeyId)
.ifPresent(signingKeyId -> dotNotation.put(prefix + SIGNING_KEY_ID_KEY, signingKeyId));
Optional.ofNullable(overrideKeyId)
.ifPresent(overrideKeyId -> dotNotation.put(prefix + OVERRIDE_KEY_ID_KEY, overrideKeyId));
Optional.ofNullable(signingAlgorithm)
.ifPresent(signingAlgorithm -> dotNotation.put(prefix + SIGNING_ALGORITHM_KEY, signingAlgorithm));
Optional.ofNullable(ldpProofType)
.ifPresent(ldpProofType -> dotNotation.put(prefix + LDP_PROOF_TYPE_KEY, ldpProofType));
return dotNotation;
}
public static CredentialBuildConfig fromDotNotation(String credentialId, Map<String, String> dotNotated) {
String prefix = getDotNotationPrefix(credentialId);
if (dotNotated.keySet().stream().noneMatch(key -> key.startsWith(prefix))) {
return null;
}
// Start populating config
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialId(credentialId);
// No need to redefine `vct` under CREDENTIAL_BUILD_CONFIG_KEY
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY))
.ifPresent(credentialBuildConfig::setCredentialType);
// These other fields are nested under CREDENTIAL_BUILD_CONFIG_KEY
Optional.ofNullable(dotNotated.get(prefix + TOKEN_JWS_TYPE_KEY))
.ifPresent(credentialBuildConfig::setTokenJwsType);
Optional.ofNullable(dotNotated.get(prefix + HASH_ALGORITHM_KEY))
.ifPresent(credentialBuildConfig::setHashAlgorithm);
Optional.ofNullable(dotNotated.get(prefix + NUMBER_OF_DECOYS_KEY))
.map(Integer::parseInt)
.ifPresent(credentialBuildConfig::setNumberOfDecoys);
Optional.ofNullable(dotNotated.get(prefix + VISIBLE_CLAIMS_KEY))
.map(cbms -> cbms.split(MULTIVALUED_STRING_SEPARATOR))
.map(Arrays::asList)
.ifPresent(credentialBuildConfig::setVisibleClaims);
Optional.ofNullable(dotNotated.get(prefix + SIGNING_KEY_ID_KEY))
.ifPresent(credentialBuildConfig::setSigningKeyId);
Optional.ofNullable(dotNotated.get(prefix + OVERRIDE_KEY_ID_KEY))
.ifPresent(credentialBuildConfig::setOverrideKeyId);
Optional.ofNullable(dotNotated.get(prefix + SIGNING_ALGORITHM_KEY))
.ifPresent(credentialBuildConfig::setSigningAlgorithm);
Optional.ofNullable(dotNotated.get(prefix + LDP_PROOF_TYPE_KEY))
.ifPresent(credentialBuildConfig::setLdpProofType);
return credentialBuildConfig;
}
private static String getDotNotationPrefix(String credentialId) {
return credentialId + DOT_SEPARATOR + CREDENTIAL_BUILD_CONFIG_KEY + DOT_SEPARATOR;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CredentialBuildConfig that = (CredentialBuildConfig) o;
return Objects.equals(credentialId, that.credentialId) && Objects.equals(credentialType, that.credentialType) && Objects.equals(tokenJwsType, that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals(visibleClaims, that.visibleClaims) && Objects.equals(numberOfDecoys, that.numberOfDecoys) && Objects.equals(signingKeyId, that.signingKeyId) && Objects.equals(overrideKeyId, that.overrideKeyId) && Objects.equals(signingAlgorithm, that.signingAlgorithm) && Objects.equals(ldpProofType, that.ldpProofType);
return Objects.equals(credentialId, that.credentialId) && Objects.equals(credentialType,
that.credentialType) && Objects.equals(
tokenJwsType,
that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals(
sdJwtVisibleClaims,
that.sdJwtVisibleClaims) && Objects.equals(
numberOfDecoys,
that.numberOfDecoys) && Objects.equals(signingKeyId, that.signingKeyId) && Objects.equals(overrideKeyId,
that.overrideKeyId) && Objects.equals(
signingAlgorithm,
that.signingAlgorithm) && Objects.equals(ldpProofType, that.ldpProofType);
}
@Override
public int hashCode() {
return Objects.hash(credentialId, credentialType, tokenJwsType, hashAlgorithm, visibleClaims, numberOfDecoys, signingKeyId, overrideKeyId, signingAlgorithm, ldpProofType);
return Objects.hash(credentialId,
credentialType,
tokenJwsType,
hashAlgorithm,
sdJwtVisibleClaims,
numberOfDecoys,
signingKeyId,
overrideKeyId,
signingAlgorithm,
ldpProofType);
}
}

View File

@ -18,11 +18,11 @@ package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.util.JsonSerialization;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Pojo to represent a CredentialDefinition for internal handling
@ -35,7 +35,18 @@ public class CredentialDefinition {
@JsonProperty("@context")
private List<String> context;
private List<String> type = new ArrayList<>();
private CredentialSubject credentialSubject = new CredentialSubject();
public static CredentialDefinition parse(CredentialScopeModel credentialModel) {
List<String> contexts = Optional.of(credentialModel.getVcContexts())
.filter(list -> !list.isEmpty())
.orElseGet(() -> new ArrayList<>(List.of(credentialModel.getName())));
List<String> types = Optional.ofNullable(credentialModel.getSupportedCredentialTypes())
.filter(list -> !list.isEmpty())
.orElseGet(() -> new ArrayList<>(List.of(credentialModel.getName())));
return new CredentialDefinition().setContext(contexts)
.setType(types);
}
public List<String> getContext() {
return context;
@ -54,29 +65,4 @@ public class CredentialDefinition {
this.type = type;
return this;
}
public CredentialSubject getCredentialSubject() {
return credentialSubject;
}
public CredentialDefinition setCredentialSubject(CredentialSubject credentialSubject) {
this.credentialSubject = credentialSubject;
return this;
}
public String toJsonString() {
try {
return JsonSerialization.writeValueAsString(this);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static CredentialDefinition fromJsonString(String jsonString) {
try {
return JsonSerialization.readValue(jsonString, CredentialDefinition.class);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -19,6 +19,14 @@ package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.util.JsonSerialization;
import java.util.Map;
import java.util.Optional;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@ -31,7 +39,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CredentialRequest {
private String format;
@JsonProperty("credential_configuration_id")
private String credentialConfigurationId;
@JsonProperty("credential_identifier")
private String credentialIdentifier;
@ -44,23 +53,10 @@ public class CredentialRequest {
})
private Proof proof;
// I have the choice of either defining format specific fields here, or adding a generic structure,
// opening room for spamming the server. I will prefer having format specific fields.
private String vct;
// See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-3
@JsonProperty("credential_definition")
private CredentialDefinition credentialDefinition;
public String getFormat() {
return format;
}
public CredentialRequest setFormat(String format) {
this.format = format;
return this;
}
public String getCredentialIdentifier() {
return credentialIdentifier;
}
@ -70,6 +66,15 @@ public class CredentialRequest {
return this;
}
public String getCredentialConfigurationId() {
return credentialConfigurationId;
}
public CredentialRequest setCredentialConfigurationId(String credentialConfigurationId) {
this.credentialConfigurationId = credentialConfigurationId;
return this;
}
public Proof getProof() {
return proof;
}
@ -79,15 +84,6 @@ public class CredentialRequest {
return this;
}
public String getVct() {
return vct;
}
public CredentialRequest setVct(String vct) {
this.vct = vct;
return this;
}
public CredentialDefinition getCredentialDefinition() {
return credentialDefinition;
}
@ -96,4 +92,30 @@ public class CredentialRequest {
this.credentialDefinition = credentialDefinition;
return this;
}
public Optional<CredentialScopeModel> findCredentialScope(KeycloakSession keycloakSession) {
Map<String, String> searchAttributeMap =
Optional.ofNullable(credentialConfigurationId)
.map(credentialIdentifier -> {
return Map.of(CredentialScopeModel.CONFIGURATION_ID, credentialConfigurationId);
}).orElseGet(() -> {
return Map.of(CredentialScopeModel.CREDENTIAL_IDENTIFIER, credentialIdentifier);
});
RealmModel currentRealm = keycloakSession.getContext().getRealm();
final boolean useOrExpression = false;
return keycloakSession.clientScopes()
.getClientScopesByAttributes(currentRealm, searchAttributeMap, useOrExpression)
.map(CredentialScopeModel::new)
.findAny();
}
@Override
public String toString() {
try {
return JsonSerialization.mapper.writeValueAsString(this);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -17,11 +17,13 @@
package org.keycloak.protocol.oid4vc.model;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.keycloak.util.JsonSerialization;
import java.util.ArrayList;
import java.util.List;
/**
* Represents a CredentialResponse according to the OID4VCI Spec
@ -76,6 +78,7 @@ public class CredentialResponse {
return this;
}
/**
* Inner class to represent a single credential object within the credentials array.
*/

View File

@ -20,9 +20,16 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.List;
/**
* Represents a DisplayObject, as used in the OID4VCI Credentials Issuer Metadata
@ -38,6 +45,8 @@ import java.io.IOException;
)
public class DisplayObject {
private static final Logger LOGGER = LoggerFactory.getLogger(DisplayObject.class);
@JsonIgnore
private static final String NAME_KEY = "name";
@JsonIgnore
@ -69,6 +78,23 @@ public class DisplayObject {
@JsonProperty(DisplayObject.TEXT_COLOR_KEY)
private String textColor;
public static List<DisplayObject> parse(CredentialScopeModel credentialScope) {
String display = credentialScope.getVcDisplay();
if (StringUtil.isBlank(display)) {
return null;
}
TypeReference<List<DisplayObject>> typeReference = new TypeReference<>() {};
try {
return JsonSerialization.mapper.readValue(display, typeReference);
} catch (JsonProcessingException e) {
// lets say we have an invalid value we should not kill the whole execution if just the display value is
// broken
LOGGER.debug(e.getMessage(), e);
LOGGER.info(String.format("Failed to parse display-metadata for credential: %s", credentialScope.getName()),
e.getMessage());
return null;
}
}
public String getName() {
return name;
@ -165,4 +191,13 @@ public class DisplayObject {
result = 31 * result + (getTextColor() != null ? getTextColor().hashCode() : 0);
return result;
}
@Override
public String toString() {
try {
return JsonSerialization.mapper.writeValueAsString(this);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -33,7 +33,7 @@ public enum ErrorType {
INVALID_PROOF("invalid_proof"),
INVALID_ENCRYPTION_PARAMETER("invalid_encryption_parameters"),
MISSING_CREDENTIAL_CONFIG("missing_credential_config"),
MISSING_CREDENTIAL_CONFIG_AND_FORMAT("missing_credential_config_format");
MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID("missing_credential_identifier_and_configuration_id");
private final String value;

View File

@ -1,132 +0,0 @@
/*
* 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.model;
import java.util.Collections;
import java.util.List;
/**
* Pojo, containing all information required to create a VCClient.
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public class OID4VCClient {
/**
* Id of the client.
*/
private String id;
/**
* Did of the target/client, will be used as client-id
*/
private String clientDid;
/**
* Comma-separated list of supported credentials types
*/
private List<SupportedCredentialConfiguration> supportedVCTypes;
/**
* Description of the client, will f.e. be displayed in the admin-console
*/
private String description;
/**
* Human-readable name of the client
*/
private String name;
public OID4VCClient() {
}
public OID4VCClient(String id, String clientDid, List<SupportedCredentialConfiguration> supportedVCTypes, String description, String name) {
this.id = id;
this.clientDid = clientDid;
this.supportedVCTypes = supportedVCTypes;
this.description = description;
this.name = name;
}
public String getId() {
return id;
}
public OID4VCClient setId(String id) {
this.id = id;
return this;
}
public String getClientDid() {
return clientDid;
}
public OID4VCClient setClientDid(String clientDid) {
this.clientDid = clientDid;
return this;
}
public List<SupportedCredentialConfiguration> getSupportedVCTypes() {
return supportedVCTypes;
}
public OID4VCClient setSupportedVCTypes(List<SupportedCredentialConfiguration> supportedVCTypes) {
this.supportedVCTypes = Collections.unmodifiableList(supportedVCTypes);
return this;
}
public String getDescription() {
return description;
}
public OID4VCClient setDescription(String description) {
this.description = description;
return this;
}
public String getName() {
return name;
}
public OID4VCClient setName(String name) {
this.name = name;
return this;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OID4VCClient that)) return false;
if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;
if (getClientDid() != null ? !getClientDid().equals(that.getClientDid()) : that.getClientDid() != null)
return false;
if (getSupportedVCTypes() != null ? !getSupportedVCTypes().equals(that.getSupportedVCTypes()) : that.getSupportedVCTypes() != null)
return false;
if (getDescription() != null ? !getDescription().equals(that.getDescription()) : that.getDescription() != null)
return false;
return getName() != null ? getName().equals(that.getName()) : that.getName() == null;
}
@Override
public int hashCode() {
int result = getId() != null ? getId().hashCode() : 0;
result = 31 * result + (getClientDid() != null ? getClientDid().hashCode() : 0);
result = 31 * result + (getSupportedVCTypes() != null ? getSupportedVCTypes().hashCode() : 0);
result = 31 * result + (getDescription() != null ? getDescription().hashCode() : 0);
result = 31 * result + (getName() != null ? getName().hashCode() : 0);
return result;
}
}

View File

@ -16,11 +16,19 @@
*/
package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
@ -30,39 +38,23 @@ import java.util.Objects;
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ProofTypesSupported {
@JsonProperty("jwt")
private ProofTypeJWT jwt;
@JsonProperty("ldp_vp")
private ProofTypeLdpVp ldpVp;
protected Map<String, SupportedProofTypeData> supportedProofTypes = new HashMap<>();
public ProofTypeJWT getJwt() {
return jwt;
public static ProofTypesSupported parse(KeycloakSession keycloakSession,
List<String> globalSupportedSigningAlgorithms) {
ProofTypesSupported proofTypesSupported = new ProofTypesSupported();
keycloakSession.getAllProviders(ProofValidator.class).forEach(proofValidator -> {
String type = proofValidator.getProofType();
KeyAttestationRequired keyAttestationRequired = null; // TODO
SupportedProofTypeData supportedProofTypeData = new SupportedProofTypeData(globalSupportedSigningAlgorithms,
keyAttestationRequired);
proofTypesSupported.getSupportedProofTypes().put(type, supportedProofTypeData);
});
return proofTypesSupported;
}
public ProofTypesSupported setJwt(ProofTypeJWT jwt) {
this.jwt = jwt;
return this;
}
public ProofTypeLdpVp getLdpVp() {
return ldpVp;
}
public ProofTypesSupported setLdpVp(ProofTypeLdpVp ldpVp) {
this.ldpVp = ldpVp;
return this;
}
public String toJsonString(){
try {
return JsonSerialization.writeValueAsString(this);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static ProofTypesSupported fromJsonString(String jsonString){
public static ProofTypesSupported fromJsonString(String jsonString) {
try {
return JsonSerialization.readValue(jsonString, ProofTypesSupported.class);
} catch (IOException e) {
@ -70,16 +62,141 @@ public class ProofTypesSupported {
}
}
@JsonAnyGetter
public Map<String, SupportedProofTypeData> getSupportedProofTypes() {
return supportedProofTypes;
}
@JsonAnySetter
public ProofTypesSupported setSupportedProofTypes(String name, SupportedProofTypeData value) {
supportedProofTypes.put(name, value);
return this;
}
public String toJsonString() {
try {
return JsonSerialization.writeValueAsString(this);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ProofTypesSupported that = (ProofTypesSupported) o;
return Objects.equals(jwt, that.jwt) && Objects.equals(ldpVp, that.ldpVp);
public final boolean equals(Object o) {
if (!(o instanceof ProofTypesSupported that)) {
return false;
}
return Objects.equals(supportedProofTypes, that.supportedProofTypes);
}
@Override
public int hashCode() {
return Objects.hash(jwt, ldpVp);
return Objects.hashCode(supportedProofTypes);
}
public static class SupportedProofTypeData {
@JsonProperty("proof_signing_alg_values_supported")
private List<String> signingAlgorithmsSupported;
@JsonProperty("key_attestations_required")
private KeyAttestationRequired keyAttestationRequired;
public SupportedProofTypeData() {
}
public SupportedProofTypeData(List<String> signingAlgorithmsSupported,
KeyAttestationRequired keyAttestationRequired) {
this.signingAlgorithmsSupported = signingAlgorithmsSupported;
this.keyAttestationRequired = keyAttestationRequired;
}
public List<String> getSigningAlgorithmsSupported() {
return signingAlgorithmsSupported;
}
public SupportedProofTypeData setSigningAlgorithmsSupported(List<String> signingAlgorithmsSupported) {
this.signingAlgorithmsSupported = signingAlgorithmsSupported;
return this;
}
public KeyAttestationRequired getKeyAttestationRequired() {
return keyAttestationRequired;
}
public SupportedProofTypeData setKeyAttestationRequired(KeyAttestationRequired keyAttestationRequired) {
this.keyAttestationRequired = keyAttestationRequired;
return this;
}
@Override
public final boolean equals(Object o) {
if (!(o instanceof SupportedProofTypeData that)) {
return false;
}
return Objects.equals(signingAlgorithmsSupported,
that.signingAlgorithmsSupported) && Objects.equals(keyAttestationRequired,
that.keyAttestationRequired);
}
@Override
public int hashCode() {
int result = Objects.hashCode(signingAlgorithmsSupported);
result = 31 * result + Objects.hashCode(keyAttestationRequired);
return result;
}
}
public static class KeyAttestationRequired {
@JsonProperty("key_storage")
private List<String> keyStorage = new ArrayList<>();
@JsonProperty("user_authentication")
private List<String> userAuthentication = new ArrayList<>();
public KeyAttestationRequired() {
}
public KeyAttestationRequired(List<String> keyStorage, List<String> userAuthentication) {
this.keyStorage = keyStorage;
this.userAuthentication = userAuthentication;
}
public List<String> getKeyStorage() {
return keyStorage;
}
public KeyAttestationRequired setKeyStorage(List<String> keyStorage) {
this.keyStorage = keyStorage;
return this;
}
public List<String> getUserAuthentication() {
return userAuthentication;
}
public KeyAttestationRequired setUserAuthentication(List<String> userAuthentication) {
this.userAuthentication = userAuthentication;
return this;
}
@Override
public final boolean equals(Object o) {
if (!(o instanceof KeyAttestationRequired that)) {
return false;
}
return Objects.equals(keyStorage, that.keyStorage) && Objects.equals(userAuthentication,
that.userAuthentication);
}
@Override
public int hashCode() {
int result = Objects.hashCode(keyStorage);
result = 31 * result + Objects.hashCode(userAuthentication);
return result;
}
}
}

View File

@ -19,15 +19,14 @@ package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.commons.collections4.ListUtils;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import java.util.Arrays;
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 java.util.stream.Collectors;
/**
* A supported credential, as used in the Credentials Issuer Metadata in OID4VCI
@ -45,8 +44,6 @@ public class SupportedCredentialConfiguration {
@JsonIgnore
private static final String CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY = "cryptographic_binding_methods_supported";
@JsonIgnore
private static final String CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY = "cryptographic_suites_supported";
@JsonIgnore
private static final String CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY = "credential_signing_alg_values_supported";
@JsonIgnore
private static final String DISPLAY_KEY = "display";
@ -72,9 +69,6 @@ public class SupportedCredentialConfiguration {
@JsonProperty(CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY)
private List<String> cryptographicBindingMethodsSupported;
@JsonProperty(CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY)
private List<String> cryptographicSuitesSupported;
@JsonProperty(CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY)
private List<String> credentialSigningAlgValuesSupported;
@ -98,20 +92,69 @@ public class SupportedCredentialConfiguration {
@JsonIgnore
private CredentialBuildConfig credentialBuildConfig;
public String getFormat() {
return format;
/**
* @param credentialScope The scope that holds the credentials configuration
* @param globalSupportedSigningAlgorithms added as a parameter to avoid reading the global config from the session
* for each credential
* @return the credentials configuration that was entered into the ClientScope
*/
public static SupportedCredentialConfiguration parse(KeycloakSession keycloakSession,
CredentialScopeModel credentialScope,
List<String> globalSupportedSigningAlgorithms) {
SupportedCredentialConfiguration credentialConfiguration = new SupportedCredentialConfiguration();
String credentialConfigurationId = Optional.ofNullable(credentialScope.getCredentialConfigurationId())
.orElse(credentialScope.getName());
credentialConfiguration.setId(credentialConfigurationId);
credentialConfiguration.setScope(credentialScope.getName());
String format = Optional.ofNullable(credentialScope.getFormat()).orElse(Format.SD_JWT_VC);
credentialConfiguration.setFormat(format);
String vct = Optional.ofNullable(credentialScope.getVct()).orElse(credentialScope.getName());
credentialConfiguration.setVct(vct);
CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope);
credentialConfiguration.setCredentialDefinition(credentialDefinition);
ProofTypesSupported proofTypesSupported = ProofTypesSupported.parse(keycloakSession,
globalSupportedSigningAlgorithms);
credentialConfiguration.setProofTypesSupported(proofTypesSupported);
List<String> signingAlgsSupported = credentialScope.getSigningAlgsSupported();
signingAlgsSupported = signingAlgsSupported.isEmpty() ? globalSupportedSigningAlgorithms :
// if the config has listed different algorithms than supported by keycloak we must use the
// intersection of the configuration with the actual supported algorithms.
ListUtils.intersection(signingAlgsSupported, globalSupportedSigningAlgorithms);
credentialConfiguration.setCredentialSigningAlgValuesSupported(signingAlgsSupported);
// TODO resolve value dynamically from provider implementations?
String bindingMethodsSupported = CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT;
credentialConfiguration.setCryptographicBindingMethodsSupported(List.of(bindingMethodsSupported));
credentialConfiguration.setDisplay(DisplayObject.parse(credentialScope));
CredentialBuildConfig credentialBuildConfig = CredentialBuildConfig.parse(keycloakSession,
credentialConfiguration,
credentialScope);
credentialConfiguration.setCredentialBuildConfig(credentialBuildConfig);
Claims claims = Claims.parse(keycloakSession, credentialScope);
credentialConfiguration.setClaims(claims);
return credentialConfiguration;
}
/**
* Return the verifiable credential type. Sort of confusing in the specification.
* For sdjwt, we have a "vct" claim.
* See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-6
*
* For iso mdl (not yet supported) we have a "doctype"
* See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-5
*
* For jwt_vc and ldp_vc, we will be inferring from the "credential_definition"
* See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-3
* Return the verifiable credential type. Sort of confusing in the specification. For sdjwt, we have a "vct" claim.
* See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-6
* <p>
* For iso mdl (not yet supported) we have a "doctype" See:
* https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-5
* <p>
* For jwt_vc and ldp_vc, we will be inferring from the "credential_definition" See:
* https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-3
*
* @return
*/
@ -126,6 +169,10 @@ public class SupportedCredentialConfiguration {
return CredentialConfigId.from(id);
}
public String getFormat() {
return format;
}
public SupportedCredentialConfiguration setFormat(String format) {
this.format = format;
return this;
@ -149,15 +196,6 @@ public class SupportedCredentialConfiguration {
return this;
}
public List<String> getCryptographicSuitesSupported() {
return cryptographicSuitesSupported;
}
public SupportedCredentialConfiguration setCryptographicSuitesSupported(List<String> cryptographicSuitesSupported) {
this.cryptographicSuitesSupported = Collections.unmodifiableList(cryptographicSuitesSupported);
return this;
}
public List<DisplayObject> getDisplay() {
return display;
}
@ -172,9 +210,6 @@ public class SupportedCredentialConfiguration {
}
public SupportedCredentialConfiguration setId(String id) {
if (id.contains(".")) {
throw new IllegalArgumentException("dots are not supported as part of the supported credentials id.");
}
this.id = id;
return this;
}
@ -233,90 +268,16 @@ public class SupportedCredentialConfiguration {
return this;
}
public Map<String, String> toDotNotation() {
Map<String, String> dotNotation = new HashMap<>();
Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format));
Optional.ofNullable(vct).ifPresent(vct -> dotNotation.put(id + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY, vct));
Optional.ofNullable(scope).ifPresent(scope -> dotNotation.put(id + DOT_SEPARATOR + SCOPE_KEY, scope));
Optional.ofNullable(cryptographicBindingMethodsSupported).ifPresent(types ->
dotNotation.put(id + DOT_SEPARATOR + CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY, String.join(",", cryptographicBindingMethodsSupported)));
Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types ->
dotNotation.put(id + DOT_SEPARATOR + CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY, String.join(",", cryptographicSuitesSupported)));
Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types ->
dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY, String.join(",", credentialSigningAlgValuesSupported)));
Optional.ofNullable(claims).ifPresent(c -> dotNotation.put(id + DOT_SEPARATOR + CLAIMS_KEY, c.toJsonString()));
Optional.ofNullable(credentialDefinition).ifPresent(cdef -> dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_DEFINITION_KEY, cdef.toJsonString()));
Optional.ofNullable(display)
.ifPresent(d -> d.stream()
.filter(Objects::nonNull)
.forEach(o -> dotNotation.put(id + DOT_SEPARATOR + DISPLAY_KEY + DOT_SEPARATOR + d.indexOf(o), o.toJsonString())));
Optional.ofNullable(proofTypesSupported)
.ifPresent(p -> dotNotation.put(id + DOT_SEPARATOR + PROOF_TYPES_SUPPORTED_KEY, p.toJsonString()));
Optional.ofNullable(credentialBuildConfig)
.ifPresent(p -> dotNotation.putAll(credentialBuildConfig.toDotNotation()));
return dotNotation;
}
public static SupportedCredentialConfiguration fromDotNotation(String credentialId, Map<String, String> dotNotated) {
SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration().setId(credentialId);
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + FORMAT_KEY)).ifPresent(supportedCredentialConfiguration::setFormat);
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY)).ifPresent(supportedCredentialConfiguration::setVct);
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + SCOPE_KEY)).ifPresent(supportedCredentialConfiguration::setScope);
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY))
.map(cbms -> cbms.split(","))
.map(Arrays::asList)
.ifPresent(supportedCredentialConfiguration::setCryptographicBindingMethodsSupported);
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY))
.map(css -> css.split(","))
.map(Arrays::asList)
.ifPresent(supportedCredentialConfiguration::setCryptographicSuitesSupported);
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY))
.map(css -> css.split(","))
.map(Arrays::asList)
.ifPresent(supportedCredentialConfiguration::setCredentialSigningAlgValuesSupported);
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CLAIMS_KEY))
.map(Claims::fromJsonString)
.ifPresent(supportedCredentialConfiguration::setClaims);
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CREDENTIAL_DEFINITION_KEY))
.map(CredentialDefinition::fromJsonString)
.ifPresent(supportedCredentialConfiguration::setCredentialDefinition);
String displayKeyPrefix = credentialId + DOT_SEPARATOR + DISPLAY_KEY + DOT_SEPARATOR;
List<DisplayObject> displayList = dotNotated.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(displayKeyPrefix))
.sorted(Map.Entry.comparingByKey())
.map(entry -> DisplayObject.fromJsonString(entry.getValue()))
.collect(Collectors.toList());
if (!displayList.isEmpty()){
supportedCredentialConfiguration.setDisplay(displayList);
}
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + PROOF_TYPES_SUPPORTED_KEY))
.map(ProofTypesSupported::fromJsonString)
.ifPresent(supportedCredentialConfiguration::setProofTypesSupported);
Optional.ofNullable(CredentialBuildConfig.fromDotNotation(credentialId, dotNotated))
.ifPresent(supportedCredentialConfiguration::setCredentialBuildConfig);
return supportedCredentialConfiguration;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SupportedCredentialConfiguration that = (SupportedCredentialConfiguration) o;
return Objects.equals(id, that.id) && Objects.equals(format, that.format) && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(cryptographicSuitesSupported, that.cryptographicSuitesSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(credentialDefinition, that.credentialDefinition) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims) && Objects.equals(credentialBuildConfig, that.credentialBuildConfig);
return Objects.equals(id, that.id) && Objects.equals(format, that.format) && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(credentialDefinition, that.credentialDefinition) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims) && Objects.equals(credentialBuildConfig, that.credentialBuildConfig);
}
@Override
public int hashCode() {
return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, cryptographicSuitesSupported, credentialSigningAlgValuesSupported, display, vct, credentialDefinition, proofTypesSupported, claims, credentialBuildConfig);
return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, credentialSigningAlgValuesSupported, display, vct, credentialDefinition, proofTypesSupported, claims, credentialBuildConfig);
}
}

View File

@ -17,7 +17,6 @@
package org.keycloak.services.resources.admin;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@ -128,6 +127,10 @@ public class ClientScopeResource {
auth.clients().requireManageClientScopes();
validateDynamicScopeUpdate(rep);
try {
LoginProtocolFactory loginProtocolFactory = //
(LoginProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class,
clientScope.getProtocol());
Optional.ofNullable(loginProtocolFactory).ifPresent(lp -> lp.addClientScopeDefaults(rep));
RepresentationToModel.updateClientScope(rep, clientScope);
adminEvent.operation(OperationType.UPDATE).resourcePath(session.getContext().getUri()).representation(rep).success();

View File

@ -34,6 +34,8 @@ import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.RepresentationToModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.resources.KeycloakOpenAPI;
@ -49,6 +51,7 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Optional;
import java.util.stream.Stream;
/**
@ -120,11 +123,15 @@ public class ClientScopesResource {
ClientScopeResource.validateClientScopeProtocol(session, rep.getProtocol());
ClientScopeResource.validateDynamicClientScope(rep);
try {
ClientScopeModel clientModel = RepresentationToModel.createClientScope(realm, rep);
LoginProtocolFactory loginProtocolFactory = //
(LoginProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class,
rep.getProtocol());
Optional.ofNullable(loginProtocolFactory).ifPresent(lp -> lp.addClientScopeDefaults(rep));
ClientScopeModel clientScope = RepresentationToModel.createClientScope(realm, rep);
adminEvent.operation(OperationType.CREATE).resourcePath(session.getContext().getUri(), clientModel.getId()).representation(rep).success();
adminEvent.operation(OperationType.CREATE).resourcePath(session.getContext().getUri(), clientScope.getId()).representation(rep).success();
return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(clientModel.getId()).build()).build();
return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(clientScope.getId()).build()).build();
} catch (ModelDuplicateException e) {
throw ErrorResponse.exists("Client Scope " + rep.getName() + " already exists");
}

View File

@ -19,4 +19,3 @@ org.keycloak.services.clientregistration.DefaultClientRegistrationProviderFactor
org.keycloak.services.clientregistration.oidc.OIDCClientRegistrationProviderFactory
org.keycloak.services.clientregistration.AdapterInstallationClientRegistrationProviderFactory
org.keycloak.protocol.saml.clientregistration.EntityDescriptorClientRegistrationProviderFactory
org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProviderFactory

View File

@ -1,150 +0,0 @@
/*
* 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;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig;
import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
import org.keycloak.protocol.oid4vc.model.ProofTypeJWT;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
@RunWith(Parameterized.class)
public class OID4VCClientRegistrationProviderTest {
@Parameterized.Parameters(name = "{0}")
public static Collection<Object[]> parameters() {
return Arrays.asList(new Object[][]{
{
"Single Supported Credential with format and single-type.",
Map.of(
"vc.credential-id.format", Format.JWT_VC,
"vc.credential-id.scope", "VerifiableCredential"),
new OID4VCClient(null, "did:web:test.org",
List.of(new SupportedCredentialConfiguration()
.setId("credential-id")
.setFormat(Format.JWT_VC)
.setScope("VerifiableCredential")),
null, null)
},
{
"Single Supported Credential with format and multi-type.",
Map.of(
"vc.credential-id.format", Format.JWT_VC,
"vc.credential-id.scope", "AnotherCredential"),
new OID4VCClient(null, "did:web:test.org",
List.of(new SupportedCredentialConfiguration()
.setId("credential-id")
.setFormat(Format.JWT_VC)
.setScope("AnotherCredential")),
null, null)
},
{
"Single Supported Credential with format, multi-type and a display object.",
Map.of(
"vc.credential-id.format", Format.JWT_VC,
"vc.credential-id.scope", "AnotherCredential",
"vc.credential-id.display.0", "{\"name\":\"Another\",\"locale\":\"en\"}"),
new OID4VCClient(null, "did:web:test.org",
List.of(new SupportedCredentialConfiguration()
.setId("credential-id")
.setFormat(Format.JWT_VC)
.setDisplay(Arrays.asList(new DisplayObject().setLocale("en").setName("Another")))
.setScope("AnotherCredential")),
null, null)
},
{
"Multiple Supported Credentials.",
Map.of(
"vc.first-id.format", Format.JWT_VC,
"vc.first-id.scope", "AnotherCredential",
"vc.first-id.display.0", "{\"name\":\"First\",\"locale\":\"en\"}",
"vc.second-id.format", Format.SD_JWT_VC,
"vc.second-id.scope", "MyType",
"vc.second-id.display.0", "{\"name\":\"Second Credential\",\"locale\":\"de\"}",
"vc.second-id.proof_types_supported","{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"),
new OID4VCClient(null, "did:web:test.org",
List.of(new SupportedCredentialConfiguration()
.setId("first-id")
.setFormat(Format.JWT_VC)
.setDisplay(Arrays.asList(new DisplayObject().setLocale("en").setName("First")))
.setScope("AnotherCredential"),
new SupportedCredentialConfiguration()
.setId("second-id")
.setFormat(Format.SD_JWT_VC)
.setDisplay(Arrays.asList(new DisplayObject().setLocale("de").setName("Second Credential")))
.setScope("MyType")
.setProofTypesSupported(new ProofTypesSupported().setJwt(new ProofTypeJWT().setProofSigningAlgValuesSupported(Arrays.asList("ES256"))))),
null, null)
},
{
"Single Supported Credential with credential build config.",
Map.of(
"vc.credential-id.format", Format.JWT_VC,
"vc.credential-id.scope", "VerifiableCredential",
"vc.credential-id.credential_build_config.token_jws_type", "JWT"
),
new OID4VCClient(null, "did:web:test.org",
List.of(new SupportedCredentialConfiguration()
.setId("credential-id")
.setFormat(Format.JWT_VC)
.setScope("VerifiableCredential")
.setCredentialBuildConfig(
new CredentialBuildConfig()
.setCredentialId("credential-id")
.setTokenJwsType("JWT"))),
null, null)
},
});
}
private Map<String, String> clientAttributes;
private OID4VCClient oid4VCClient;
public OID4VCClientRegistrationProviderTest(String name, Map<String, String> clientAttributes, OID4VCClient oid4VCClient) {
this.clientAttributes = clientAttributes;
this.oid4VCClient = oid4VCClient;
}
@Test
public void testToClientRepresentation() {
Map<String, String> translatedAttributes = OID4VCClientRegistrationProvider.toClientRepresentation(oid4VCClient).getAttributes();
assertEquals("The client should have been translated into the correct clientRepresentation.", clientAttributes.entrySet().size(), translatedAttributes.size());
clientAttributes.forEach((key, value) ->
assertEquals("The client should have been translated into the correct clientRepresentation.", clientAttributes.get(key), translatedAttributes.get(key)));
}
@Test
public void testFromClientAttributes() {
assertEquals("The client should have been correctly build from the client representation",
oid4VCClient,
OID4VCClientRegistrationProvider.fromClientAttributes("did:web:test.org", clientAttributes));
}
}

View File

@ -16,10 +16,7 @@
*/
package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import org.keycloak.util.JsonSerialization;
import static org.junit.Assert.*;
@ -28,39 +25,34 @@ import static org.junit.Assert.*;
*/
public class ClaimsTest {
@Test
public void toJsonString() throws JsonProcessingException {
Claims claims = new Claims();
claims.put("firstName", new Claim());
claims.put("lastName", new Claim());
claims.put("email", new Claim());
String jsonString = claims.toJsonString();
JsonNode jsonNode = JsonSerialization.mapper.readTree(jsonString);
assertNotNull(jsonNode.get("firstName"));
assertNotNull(jsonNode.get("lastName"));
assertNotNull(jsonNode.get("email"));
}
@Test
public void fromJsonString() {
final String serializeForm = "{ \"firstName\": {}, \"lastName\": {}, \"email\": {} }";
final String serializeForm = "[{\"path\": [\"given_name\"], \"mandatory\": true," +
"\"display\": [{\"name\": \"First Name\", \"locale\": \"en-EN\"}]}," +
"{\"path\": [\"family_name\"], \"mandatory\": false," +
"\"display\": [{\"name\": \"Nachname\", \"locale\": \"de-DE\"}]}," +
"{\"path\": [\"email\"], \"mandatory\": true," +
"\"display\": [{\"name\": \"E-Mail\", \"locale\": \"en-EN\"}]}]";
Claims claims = Claims.fromJsonString(serializeForm);
assertNotNull(claims);
assertNotNull(claims.get("firstName"));
assertNotNull(claims.get("lastName"));
assertNotNull(claims.get("email"));
}
assertEquals(3, claims.size());
assertEquals(1, claims.get(0).getPath().size());
assertEquals("given_name", claims.get(0).getPath().get(0));
assertTrue(claims.get(0).isMandatory());
assertEquals(1, claims.get(0).getDisplay().size());
assertEquals("First Name", claims.get(0).getDisplay().get(0).getName());
assertEquals("en-EN", claims.get(0).getDisplay().get(0).getLocale());
@Test
public void fromJsonStringDeepClaim() {
final String serializeForm = "{ \"firstName\": {\"mandatory\":false}, \"lastName\": {\"mandatory\":false}, \"email\": {\"mandatory\":true} }";
Claims claims = Claims.fromJsonString(serializeForm);
assertNotNull(claims);
assertNotNull(claims.get("firstName"));
assertFalse(claims.get("firstName").getMandatory());
assertNotNull(claims.get("lastName"));
assertFalse(claims.get("lastName").getMandatory());
assertNotNull(claims.get("email"));
assertTrue(claims.get("email").getMandatory());
assertEquals(1, claims.get(1).getPath().size());
assertEquals("family_name", claims.get(1).getPath().get(0));
assertFalse(claims.get(1).isMandatory());
assertEquals("Nachname", claims.get(1).getDisplay().get(0).getName());
assertEquals("de-DE", claims.get(1).getDisplay().get(0).getLocale());
assertEquals(1, claims.get(2).getPath().size());
assertEquals("email", claims.get(2).getPath().get(0));
assertTrue(claims.get(2).isMandatory());
assertEquals("E-Mail", claims.get(2).getDisplay().get(0).getName());
assertEquals("en-EN", claims.get(2).getDisplay().get(0).getLocale());
}
}

View File

@ -0,0 +1,31 @@
/*
* 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.testframework.server;
import org.keycloak.common.Profile;
/**
* @author Pascal Knüppel
*/
public class DefaultServerConfigWithOid4Vci extends DefaultKeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return super.configure(config).features(Profile.Feature.OID4VC_VCI);
}
}

View File

@ -762,6 +762,7 @@ public class ClientScopeTest extends AbstractClientScopeTest {
Assertions.assertNotNull(location);
clientScopeId = location.substring(location.lastIndexOf("/") + 1);
} finally {
Assertions.assertNotNull(clientScopeId);
// cleanup
clientScopes().get(clientScopeId).remove();
}

View File

@ -0,0 +1,94 @@
/*
* 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.tests.admin.client;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.server.DefaultServerConfigWithOid4Vci;
import java.util.Map;
import java.util.Optional;
/**
* @author Pascal Knüppel
*/
@KeycloakIntegrationTest(config = DefaultServerConfigWithOid4Vci.class)
public class ClientScopeTestOid4Vci extends AbstractClientScopeTest {
@DisplayName("Verify default values are correctly set")
@Test
public void testDefaultOid4VciClientScopeAttributes() {
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName("test-client-scope");
clientScope.setDescription("test-client-scope-description");
clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
clientScope.setAttributes(Map.of("test-attribute", "test-value"));
String clientScopeId = null;
try (Response response = clientScopes().create(clientScope)) {
Assertions.assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
String location = (String) Optional.ofNullable(response.getHeaders().get(HttpHeaders.LOCATION))
.map(list -> list.get(0))
.orElse(null);
Assertions.assertNotNull(location);
clientScopeId = location.substring(location.lastIndexOf("/") + 1);
ClientScopeRepresentation createdClientScope = clientScopes().get(clientScopeId).toRepresentation();
Assertions.assertNotNull(createdClientScope);
Assertions.assertEquals(CredentialScopeModel.SD_JWT_VISIBLE_CLAIMS_DEFAULT,
createdClientScope.getAttributes().get(CredentialScopeModel.SD_JWT_VISIBLE_CLAIMS));
Assertions.assertEquals(String.valueOf(CredentialScopeModel.SD_JWT_DECOYS_DEFAULT),
createdClientScope.getAttributes().get(CredentialScopeModel.SD_JWT_NUMBER_OF_DECOYS));
Assertions.assertEquals(CredentialScopeModel.FORMAT_DEFAULT,
createdClientScope.getAttributes().get(CredentialScopeModel.FORMAT));
Assertions.assertEquals(CredentialScopeModel.HASH_ALGORITHM_DEFAULT,
createdClientScope.getAttributes().get(CredentialScopeModel.HASH_ALGORITHM));
Assertions.assertEquals(CredentialScopeModel.TOKEN_TYPE_DEFAULT,
createdClientScope.getAttributes().get(CredentialScopeModel.TOKEN_JWS_TYPE));
Assertions.assertEquals(String.valueOf(CredentialScopeModel.EXPIRY_IN_SECONDS_DEFAULT),
createdClientScope.getAttributes().get(CredentialScopeModel.EXPIRY_IN_SECONDS));
Assertions.assertEquals(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT,
createdClientScope.getAttributes().get(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS));
Assertions.assertEquals(clientScope.getName(),
createdClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
Assertions.assertEquals(clientScope.getName(),
createdClientScope.getAttributes().get(CredentialScopeModel.CREDENTIAL_IDENTIFIER));
Assertions.assertEquals(clientScope.getName(),
createdClientScope.getAttributes().get(CredentialScopeModel.TYPES));
Assertions.assertEquals(clientScope.getName(),
createdClientScope.getAttributes().get(CredentialScopeModel.CONTEXTS));
Assertions.assertEquals(clientScope.getName(),
createdClientScope.getAttributes().get(CredentialScopeModel.VCT));
Assertions.assertEquals(clientScope.getName(),
createdClientScope.getAttributes().get(CredentialScopeModel.ISSUER_DID));
} finally {
Assertions.assertNotNull(clientScopeId);
// cleanup
clientScopes().get(clientScopeId).remove();
}
}
}

View File

@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBody;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
@ -41,7 +42,7 @@ import static org.junit.Assert.assertEquals;
public class JwtCredentialBuilderTest extends CredentialBuilderTest {
TimeProvider timeProvider = new StaticTimeProvider(1000);
JwtCredentialBuilder builder = new JwtCredentialBuilder(TEST_DID.toString(), timeProvider);
JwtCredentialBuilder builder = new JwtCredentialBuilder(timeProvider);
@Test
public void shouldBuildJwtCredentialSuccessfully() throws Exception {
@ -84,7 +85,7 @@ public class JwtCredentialBuilderTest extends CredentialBuilderTest {
private JsonNode parseCredentialSubject(JWSInput jwsInput) throws JWSInputException {
JsonNode payload = jwsInput.readJsonContent(JsonNode.class);
return payload.get("vc").get("credentialSubject");
return payload.get("vc").get(Oid4VciConstants.CREDENTIAL_SUBJECT);
}
private Map<String, Object> exampleCredentialClaims() {

View File

@ -90,14 +90,15 @@ public class SdJwtCredentialBuilderTest extends CredentialBuilderTest {
throws VerificationException {
String issuerDid = TEST_DID.toString();
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(issuerDid)
.setCredentialType("https://credentials.example.com/test-credential")
.setTokenJwsType("example+sd-jwt")
.setHashAlgorithm("sha-256")
.setNumberOfDecoys(decoys)
.setVisibleClaims(visibleClaims);
.setSdJwtVisibleClaims(visibleClaims);
VerifiableCredential testCredential = getTestCredential(claims);
SdJwtCredentialBody sdJwtCredentialBody = new SdJwtCredentialBuilder(issuerDid)
SdJwtCredentialBody sdJwtCredentialBody = new SdJwtCredentialBuilder()
.buildCredentialBody(testCredential, credentialBuildConfig);
String sdJwtString = sdJwtCredentialBody.sign(exampleSigner());

View File

@ -161,6 +161,7 @@ public class JwtCredentialSignerTest extends OID4VCTest {
public static void testSignJwtCredential(
KeycloakSession session, String signingKeyId, String algorithm, Map<String, Object> claims) {
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(TEST_DID.toString())
.setTokenJwsType("JWT")
.setSigningKeyId(signingKeyId)
.setSigningAlgorithm(algorithm);
@ -169,7 +170,6 @@ public class JwtCredentialSignerTest extends OID4VCTest {
VerifiableCredential testCredential = getTestCredential(claims);
JwtCredentialBuilder builder = new JwtCredentialBuilder(
TEST_DID.toString(),
new StaticTimeProvider(1000)
);

View File

@ -169,6 +169,7 @@ public class LDCredentialSignerTest extends OID4VCTest {
KeycloakSession session, String signingKeyId, Map<String, Object> claims,
String overrideKeyId, String ldpProofType) {
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(TEST_DID.toString())
.setTokenJwsType("JWT")
.setSigningKeyId(signingKeyId)
.setSigningAlgorithm("EdDSA")
@ -179,7 +180,7 @@ public class LDCredentialSignerTest extends OID4VCTest {
session, new StaticTimeProvider(1000));
VerifiableCredential testCredential = getTestCredential(claims);
LDCredentialBody ldCredentialBody = new LDCredentialBuilder(TEST_DID.toString())
LDCredentialBody ldCredentialBody = new LDCredentialBuilder()
.buildCredentialBody(testCredential, credentialBuildConfig);
VerifiableCredential verifiableCredential = ldCredentialSigner

View File

@ -25,6 +25,7 @@ import jakarta.ws.rs.core.UriBuilder;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
@ -40,7 +41,10 @@ import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
@ -48,21 +52,24 @@ import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactor
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentExportRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resources.RealmsResource;
@ -75,12 +82,15 @@ import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
@ -97,187 +107,31 @@ import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.client
public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
protected static final TimeProvider TIME_PROVIDER = new OID4VCTest.StaticTimeProvider(1000);
protected static final String sdJwtCredentialVct = "https://credentials.example.com/SD-JWT-Credential";
protected static ClientScopeRepresentation sdJwtTypeCredentialClientScope;
protected static ClientScopeRepresentation jwtTypeCredentialClientScope;
protected static ClientScopeRepresentation minimalJwtTypeCredentialClientScope;
protected CloseableHttpClient httpClient;
public static String verifiableCredentialScopeName = "VerifiableCredential";
public static String testCredentialScopeName = "test-credential";
@Before
public void setup() {
CryptoIntegration.init(this.getClass().getClassLoader());
httpClient = HttpClientBuilder.create().build();
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
// Register the optional client scopes
String verifiableCredentialScopeId = registerOptionalClientScope(verifiableCredentialScopeName, client.getClientId());
String testCredentialScopeId = registerOptionalClientScope(testCredentialScopeName, client.getClientId());
// Assign the registered optional client scopes to the client
assignOptionalClientScopeToClient(verifiableCredentialScopeId, client.getClientId());
assignOptionalClientScopeToClient(testCredentialScopeId, client.getClientId());
}
protected String getBearerToken(OAuthClient oAuthClient) {
AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john", "password");
return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode()).getAccessToken();
}
private ClientResource findClientByClientId(RealmResource realm, String clientId) {
for (ClientRepresentation c : realm.clients().findAll()) {
if (clientId.equals(c.getClientId())) {
return realm.clients().get(c.getId());
}
}
return null;
}
private String registerOptionalClientScope(String scopeName, String clientId) {
// Check if the client scope already exists
List<ClientScopeRepresentation> existingScopes = testRealm().clientScopes().findAll();
for (ClientScopeRepresentation existingScope : existingScopes) {
if (existingScope.getName().equals(scopeName)) {
return existingScope.getId(); // Reuse existing scope
}
}
// Create a new ClientScope if not found
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName(scopeName);
clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Response res = testRealm().clientScopes().create(clientScope);
String scopeId = ApiUtil.getCreatedId(res);
getCleanup().addClientScopeId(scopeId); // Automatically removed when a test method is finished.
res.close();
// Add protocol mappers to the ClientScope
addProtocolMappersToClientScope(scopeId, scopeName, clientId);
return scopeId;
}
private void assignOptionalClientScopeToClient(String scopeId, String clientId) {
ClientResource clientResource = findClientByClientId(testRealm(), clientId);
clientResource.addOptionalClientScope(scopeId);
}
private void addCredentialConfigurationIdToClient(String clientId, String credentialConfigurationId, String format, String scope) {
ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0);
ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId());
clientRepresentation.setAttributes(Map.of(
"vc." + credentialConfigurationId + ".format", format,
"vc." + credentialConfigurationId + ".scope", scope));
clientResource.update(clientRepresentation);
}
private void removeCredentialConfigurationIdToClient(String clientId) {
ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0);
ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId());
clientRepresentation.setAttributes(Map.of());
clientResource.update(clientRepresentation);
}
private void logoutUser(String clientId, String username) {
UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(TEST_REALM_NAME), username);
user.logout();
}
private void testCredentialIssuanceWithAuthZCodeFlow(Consumer<Map<String, String>> c) throws Exception {
// use pre-registered client for this test class whose clientId is "test-app" defined in testrealm.json
String testClientId = clientId;
// use supported values by Credential Issuer Metadata
String testCredentialConfigurationId = "test-credential";
String testScope = "VerifiableCredential";
String testFormat = Format.JWT_VC;
// register optional client scope
String scopeId = registerOptionalClientScope(testScope, testClientId);
// assign registered optional client scope
assignOptionalClientScopeToClient(scopeId, testClientId); // pre-registered client for this test class
// add credential configuration id to a client as client attributes
addCredentialConfigurationIdToClient(testClientId, testCredentialConfigurationId, testFormat, testScope);
c.accept(Map.of(
"clientId", testClientId,
"credentialConfigurationId", testCredentialConfigurationId,
"scope", testScope,
"format", testFormat)
);
// clean-up
logoutUser(testClientId, "john");
removeCredentialConfigurationIdToClient(testClientId);
oauth.clientId(null);
}
// Tests the AuthZCode complete flow without scope from
// 1. Get authorization code without scope specified by wallet
// 2. Using the code to get access token
// 3. Get the credential configuration id from issuer metadata at .wellKnown
// 4. With the access token, get the credential
protected void testCredentialIssuanceWithAuthZCodeFlow(BiFunction<String, String, String> f, Consumer<Map<String, Object>> c) throws Exception {
testCredentialIssuanceWithAuthZCodeFlow(m -> {
String testClientId = m.get("clientId");
String testScope = m.get("scope");
String testFormat = m.get("format");
String testCredentialConfigurationId = m.get("credentialConfigurationId");
try (Client client = AdminClientUtil.createResteasyClient()) {
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder).build(TEST_REALM_NAME, OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri);
// 1. Get authoriZation code without scope specified by wallet
// 2. Using the code to get accesstoken
String token = f.apply(testClientId, testScope);
// 3. Get the credential configuration id from issuer metadata at .wellKnown
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class), CredentialIssuer.class);
assertEquals(200, discoveryResponse.getStatus());
assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer());
assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint());
// 4. With the access token, get the credential
try (Client clientForCredentialRequest = AdminClientUtil.createResteasyClient()) {
UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint());
URI credentialUri = credentialUriBuilder.build();
WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri);
CredentialRequest request = new CredentialRequest();
request.setFormat(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat());
request.setCredentialIdentifier(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId());
assertEquals(testFormat, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat().toString());
assertEquals(testCredentialConfigurationId, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId());
c.accept(Map.of(
"accessToken", token,
"credentialTarget", credentialTarget,
"credentialRequest", request
));
}
}
} catch (IOException e) {
Assert.fail();
}
});
}
protected ClientRepresentation client;
protected static String prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) {
AuthenticationManager.AuthResult authResult = authenticator.authenticate();
UserSessionModel userSessionModel = authResult.getSession();
AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId());
AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient(
authResult.getClient().getId());
String codeId = SecretGenerator.getInstance().randomString();
String nonce = SecretGenerator.getInstance().randomString();
OAuth2Code oAuth2Code = new OAuth2Code(codeId, Time.currentTime() + 6000, nonce, CREDENTIAL_OFFER_URI_CODE_SCOPE, null, null, null, null,
authenticatedClientSessionModel.getUserSession().getId());
OAuth2Code oAuth2Code = new OAuth2Code(codeId,
Time.currentTime() + 6000,
nonce,
CREDENTIAL_OFFER_URI_CODE_SCOPE,
null,
null,
null,
null,
authenticatedClientSessionModel.getUserSession().getId());
String oauthCode = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oAuth2Code);
@ -285,15 +139,17 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return oauthCode;
}
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) {
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session,
AppAuthManager.BearerTokenAuthenticator authenticator) {
JwtCredentialBuilder jwtCredentialBuilder = new JwtCredentialBuilder(
TEST_DID.toString(),
new StaticTimeProvider(1000));
new StaticTimeProvider(1000));
SdJwtCredentialBuilder sdJwtCredentialBuilder = new SdJwtCredentialBuilder();
return prepareIssuerEndpoint(
session,
authenticator,
Map.of(jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder)
Map.of(jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder,
sdJwtCredentialBuilder.getSupportedFormat(), sdJwtCredentialBuilder)
);
}
@ -307,35 +163,271 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
credentialBuilders,
authenticator,
TIME_PROVIDER,
30,
true);
30);
}
@Before
public void setup() {
CryptoIntegration.init(this.getClass().getClassLoader());
httpClient = HttpClientBuilder.create().build();
client = testRealm().clients().findByClientId(clientId).get(0);
// Register the optional client scopes
sdJwtTypeCredentialClientScope = registerOptionalClientScope(sdJwtTypeCredentialScopeName,
null,
sdJwtTypeCredentialConfigurationIdName,
sdJwtTypeCredentialScopeName,
sdJwtCredentialVct,
Format.SD_JWT_VC,
null);
jwtTypeCredentialClientScope = registerOptionalClientScope(jwtTypeCredentialScopeName,
TEST_DID.toString(),
jwtTypeCredentialConfigurationIdName,
jwtTypeCredentialScopeName,
null,
Format.JWT_VC,
TEST_CREDENTIAL_MAPPERS_FILE);
minimalJwtTypeCredentialClientScope = registerOptionalClientScope("vc-with-minimal-config",
null,
null,
null,
null,
null,
null);
// Assign the registered optional client scopes to the client
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), client.getClientId());
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), client.getClientId());
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), client.getClientId());
}
protected String getBearerToken(OAuthClient oAuthClient) {
return getBearerToken(oAuthClient, null);
}
protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client) {
return getBearerToken(oAuthClient, client, null);
}
protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client, String credentialScopeName) {
if (client != null) {
oAuthClient.client(client.getClientId(), client.getSecret());
}
if (credentialScopeName != null) {
oAuthClient.scope(credentialScopeName);
}
AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john",
"password");
return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode()).getAccessToken();
}
private ClientResource findClientByClientId(RealmResource realm, String clientId) {
for (ClientRepresentation c : realm.clients().findAll()) {
if (clientId.equals(c.getClientId())) {
return realm.clients().get(c.getId());
}
}
return null;
}
private ClientScopeRepresentation registerOptionalClientScope(String scopeName,
String issuerDid,
String credentialConfigurationId,
String credentialIdentifier,
String vct,
String format,
String protocolMapperReferenceFile) {
// Check if the client scope already exists
List<ClientScopeRepresentation> existingScopes = testRealm().clientScopes().findAll();
for (ClientScopeRepresentation existingScope : existingScopes) {
if (existingScope.getName().equals(scopeName)) {
return existingScope; // Reuse existing scope
}
}
// Create a new ClientScope if not found
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName(scopeName);
clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
Map<String, String> attributes =
new HashMap<>(Map.of(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "true",
CredentialScopeModel.EXPIRY_IN_SECONDS, "15"));
BiConsumer<String, String> addAttribute = (attributeName, value) -> {
if (value != null) {
attributes.put(attributeName, value);
}
};
addAttribute.accept(CredentialScopeModel.ISSUER_DID, issuerDid);
addAttribute.accept(CredentialScopeModel.CONFIGURATION_ID, credentialConfigurationId);
addAttribute.accept(CredentialScopeModel.CREDENTIAL_IDENTIFIER, credentialIdentifier);
addAttribute.accept(CredentialScopeModel.FORMAT, format);
addAttribute.accept(CredentialScopeModel.VCT, Optional.ofNullable(vct).orElse(credentialIdentifier));
if (credentialConfigurationId != null) {
String vcDisplay;
try {
vcDisplay = JsonSerialization.writeValueAsString(List.of(new DisplayObject().setName(credentialConfigurationId)
.setLocale("en-EN"),
new DisplayObject().setName(credentialConfigurationId)
.setLocale("de-DE")));
} catch (IOException e) {
throw new RuntimeException(e);
}
addAttribute.accept(CredentialScopeModel.VC_DISPLAY, vcDisplay);
}
clientScope.setAttributes(attributes);
Response res = testRealm().clientScopes().create(clientScope);
String scopeId = ApiUtil.getCreatedId(res);
getCleanup().addClientScopeId(scopeId); // Automatically removed when a test method is finished.
res.close();
clientScope.setId(scopeId);
// Add protocol mappers to the ClientScope
List<ProtocolMapperRepresentation> protocolMappers;
if (protocolMapperReferenceFile == null) {
protocolMappers = getProtocolMappers(scopeName);
addProtocolMappersToClientScope(clientScope, protocolMappers);
}
else {
protocolMappers = resolveProtocolMappers(protocolMapperReferenceFile);
protocolMappers.add(getStaticClaimMapper(scopeName));
addProtocolMappersToClientScope(clientScope, protocolMappers);
}
clientScope.setProtocolMappers(protocolMappers);
return clientScope;
}
private List<ProtocolMapperRepresentation> resolveProtocolMappers(String protocolMapperReferenceFile) {
if (protocolMapperReferenceFile == null) {
return null;
}
try (InputStream inputStream = getClass().getResourceAsStream(protocolMapperReferenceFile)) {
return JsonSerialization.mapper.readValue(inputStream,
ClientScopeRepresentation.class).getProtocolMappers();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void assignOptionalClientScopeToClient(String scopeId, String clientId) {
ClientResource clientResource = findClientByClientId(testRealm(), clientId);
clientResource.addOptionalClientScope(scopeId);
}
private void logoutUser(String clientId, String username) {
UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(TEST_REALM_NAME), username);
user.logout();
}
// Tests the AuthZCode complete flow without scope from
// 1. Get authorization code without scope specified by wallet
// 2. Using the code to get access token
// 3. Get the credential configuration id from issuer metadata at .wellKnown
// 4. With the access token, get the credential
protected void testCredentialIssuanceWithAuthZCodeFlow(ClientScopeRepresentation clientScope,
BiFunction<String, String, String> f,
Consumer<Map<String, Object>> c) {
String testClientId = client.getClientId();
String testScope = clientScope.getName();
String testFormat = clientScope.getAttributes().get(CredentialScopeModel.FORMAT);
String testCredentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
try (Client client = AdminClientUtil.createResteasyClient()) {
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder)
.build(TEST_REALM_NAME,
OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri);
// 1. Get authoriZation code without scope specified by wallet
// 2. Using the code to get accesstoken
String token = f.apply(testClientId, testScope);
// 3. Get the credential configuration id from issuer metadata at .wellKnown
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class),
CredentialIssuer.class);
assertEquals(200, discoveryResponse.getStatus());
assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer());
assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint());
// 4. With the access token, get the credential
try (Client clientForCredentialRequest = AdminClientUtil.createResteasyClient()) {
UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint());
URI credentialUri = credentialUriBuilder.build();
WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri);
CredentialRequest request = new CredentialRequest();
request.setCredentialConfigurationId(oid4vciIssuerConfig.getCredentialsSupported()
.get(testCredentialConfigurationId)
.getId());
assertEquals(testFormat,
oid4vciIssuerConfig.getCredentialsSupported()
.get(testCredentialConfigurationId)
.getFormat());
assertEquals(testCredentialConfigurationId,
oid4vciIssuerConfig.getCredentialsSupported()
.get(testCredentialConfigurationId)
.getId());
c.accept(Map.of(
"accessToken", token,
"credentialTarget", credentialTarget,
"credentialRequest", request
));
}
}
} catch (IOException e) {
Assert.fail();
}
}
protected String getBasePath(String realm) {
return getRealmPath(realm) + "/protocol/oid4vc/";
}
private String getRealmPath(String realm) {
protected String getRealmPath(String realm) {
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realm;
}
protected void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential, CredentialResponseHandler responseHandler) throws IOException, VerificationException {
protected void requestCredential(String token,
String credentialEndpoint,
SupportedCredentialConfiguration offeredCredential,
CredentialResponseHandler responseHandler,
ClientScopeRepresentation expectedClientScope) throws IOException, VerificationException {
CredentialRequest request = new CredentialRequest();
request.setFormat(offeredCredential.getFormat());
request.setCredentialIdentifier(offeredCredential.getId());
request.setCredentialConfigurationId(offeredCredential.getId());
StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request), ContentType.APPLICATION_JSON);
StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request),
ContentType.APPLICATION_JSON);
HttpPost postCredential = new HttpPost(credentialEndpoint);
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.setEntity(stringEntity);
CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential);
assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode());
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
CredentialResponse credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class);
CredentialResponse credentialResponse;
try (CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode());
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class);
}
// Use response handler to customize checks based on formats.
responseHandler.handleCredentialResponse(credentialResponse);
responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope);
}
public CredentialIssuer getCredentialIssuerMetadata() {
final String endpoint = getRealmPath(TEST_REALM_NAME) + "/.well-known/openid-credential-issuer";
HttpGet getMetadataRequest = new HttpGet(endpoint);
try (CloseableHttpResponse metadataResponse = httpClient.execute(getMetadataRequest)) {
assertEquals(HttpStatus.SC_OK, metadataResponse.getStatusLine().getStatusCode());
String s = IOUtils.toString(metadataResponse.getEntity().getContent(), StandardCharsets.UTF_8);
return JsonSerialization.readValue(s, CredentialIssuer.class);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
@Override
@ -345,13 +437,12 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
}
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider());
testRealm.getComponents().addAll("org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder", getCredentialBuilderProviders());
// Find existing client representation
ClientRepresentation existingClient = testRealm.getClients().stream()
.filter(client -> client.getClientId().equals(clientId))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
.filter(client -> client.getClientId().equals(clientId))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
// Add role to existing client
if (testRealm.getRoles() != null) {
@ -365,22 +456,17 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return mergedRoles;
}
);
} else {
}
else {
testRealm.getRoles()
.setClient(Map.of(existingClient.getClientId(), List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
}
if (testRealm.getUsers() != null) {
testRealm.getUsers().add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"))));
} else {
testRealm.setUsers(List.of(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole")))));
.setClient(Map.of(existingClient.getClientId(),
List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
}
if (testRealm.getAttributes() == null) {
testRealm.setAttributes(new HashMap<>());
}
testRealm.getAttributes().put("issuerDid", TEST_DID.toString());
testRealm.getAttributes().putAll(getCredentialDefinitionAttributes());
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
.orElse(new ArrayList<>());
realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"))));
testRealm.setUsers(realmUsers);
}
protected void withCausePropagation(Runnable r) throws Throwable {
@ -398,16 +484,9 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return getRsaKeyProvider(RSA_KEY);
}
protected List<ComponentExportRepresentation> getCredentialBuilderProviders() {
return List.of(getCredentialBuilderProvider(Format.JWT_VC));
}
protected Map<String, String> getCredentialDefinitionAttributes() {
return getTestCredentialDefinitionAttributes();
}
protected static class CredentialResponseHandler {
protected void handleCredentialResponse(CredentialResponse credentialResponse) throws VerificationException {
protected void handleCredentialResponse(CredentialResponse credentialResponse,
ClientScopeRepresentation clientScope) throws VerificationException {
assertNotNull("The credentials array should be present in the response.", credentialResponse.getCredentials());
assertFalse("The credentials array should not be empty.", credentialResponse.getCredentials().isEmpty());
@ -415,14 +494,21 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
CredentialResponse.Credential credentialObj = credentialResponse.getCredentials().get(0);
assertNotNull("The first credential in the array should not be null.", credentialObj);
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(),
JsonWebToken.class).getToken();
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
assertEquals(List.of("VerifiableCredential"), credential.getType());
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get(
"vc"), VerifiableCredential.class);
assertEquals(List.of(clientScope.getName()), credential.getType());
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential"));
assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
assertTrue("The static claim should be set.",
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
assertEquals("The static claim should be set.",
clientScope.getName(),
credential.getCredentialSubject().getClaims().get("scope-name"));
assertFalse("Only mappers supported for the requested type should have been evaluated.",
credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
}
}
}

View File

@ -17,148 +17,293 @@
package org.keycloak.testsuite.oid4vc.issuance.signing;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.crypto.Algorithm;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.protocol.oid4vc.model.ClaimDisplay;
import org.keycloak.protocol.oid4vc.model.Claims;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.arquillian.SuiteContext;
import org.keycloak.testsuite.client.KeycloakTestingClient;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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.keycloak.testsuite.AbstractTestRealmKeycloakTest.TEST_REALM_NAME;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCTest.RSA_KEY;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCTest.TEST_DID;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCTest.getCredentialBuilderProvider;
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCTest.getRsaKeyProvider;
@RunWith(Enclosed.class)
public class OID4VCIssuerWellKnownProviderTest {
public static class TestAttributesOverride extends OID4VCTest {
@Test
public void testRealmAttributesOverrideClientAttributes() {
OID4VCIssuerWellKnownProviderTest
.testCredentialConfig(suiteContext, testingClient);
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
ClientRepresentation testClient = getTestClient("did:web:test.org");
Map<String, String> clientAttributes = new HashMap<>(getTestCredentialDefinitionAttributes());
Map<String, String> realmAttributes = new HashMap<>();
// We'll change the client attributes and put the correct value in the realm
// attributes and expect the test to work.
clientAttributes.put("vc.test-credential.expiry_in_s", "20");
realmAttributes.put("vc.test-credential.expiry_in_s", "100");
OID4VCIssuerWellKnownProviderTest
.configureTestRealm(testClient, testRealm, clientAttributes, realmAttributes);
}
public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
Map<String, String> attributes = Optional.ofNullable(testRealm.getAttributes()).orElseGet(HashMap::new);
attributes.put("credential_response_encryption.alg_values_supported", "[\"RSA-OAEP\"]");
attributes.put("credential_response_encryption.enc_values_supported", "[\"A256GCM\"]");
attributes.put("credential_response_encryption.encryption_required", "true");
attributes.put("batch_credential_issuance.batch_size", "10");
attributes.put("signed_metadata", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc");
testRealm.setAttributes(attributes);
super.configureTestRealm(testRealm);
}
public static class TestCredentialDefinitionInClientAttributes extends OID4VCTest {
/**
* this test will use the configured scopes {@link #jwtTypeCredentialClientScope} and
* {@link #sdJwtTypeCredentialClientScope} to verify that the metadata endpoint is presenting the expected data
*/
@Test
public void testMetaDataEndpointIsCorrectlySetup() {
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
@Test
public void testCredentialConfig() {
OID4VCIssuerWellKnownProviderTest
.testCredentialConfig(suiteContext, testingClient);
}
Assert.assertEquals(getRealmPath(TEST_REALM_NAME), credentialIssuer.getCredentialIssuer());
Assert.assertEquals(getBasePath(TEST_REALM_NAME) + OID4VCIssuerEndpoint.CREDENTIAL_PATH,
credentialIssuer.getCredentialEndpoint());
Assert.assertNull("Display was not configured", credentialIssuer.getDisplay());
Assert.assertEquals("Authorization Server should have the realm-address.",
1,
credentialIssuer.getAuthorizationServers().size());
Assert.assertEquals("Authorization Server should point to the realm-address.",
getRealmPath(TEST_REALM_NAME),
credentialIssuer.getAuthorizationServers().get(0));
@Test
public void testCredentialIssuerMetadataFields() {
String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME;
KeycloakTestingClient testingClient = this.testingClient;
// Check credential_response_encryption
CredentialIssuer.CredentialResponseEncryption encryption = credentialIssuer.getCredentialResponseEncryption();
Assert.assertNotNull("credential_response_encryption should be present", encryption);
Assert.assertEquals(List.of("RSA-OAEP"), encryption.getAlgValuesSupported());
Assert.assertEquals(List.of("A256GCM"), encryption.getEncValuesSupported());
Assert.assertTrue("encryption_required should be true", encryption.getEncryptionRequired());
testingClient
.server(TEST_REALM_NAME)
.run(session -> {
OID4VCIssuerWellKnownProvider provider = new OID4VCIssuerWellKnownProvider(session);
Object config = provider.getConfig();
assertTrue("Should return CredentialIssuer", config instanceof CredentialIssuer);
CredentialIssuer issuer = (CredentialIssuer) config;
// Check batch_credential_issuance
CredentialIssuer.BatchCredentialIssuance batch = credentialIssuer.getBatchCredentialIssuance();
Assert.assertNotNull("batch_credential_issuance should be present", batch);
Assert.assertEquals(Integer.valueOf(10), batch.getBatchSize());
// Check credential_response_encryption
CredentialIssuer.CredentialResponseEncryption encryption = issuer.getCredentialResponseEncryption();
assertNotNull("credential_response_encryption should be present", encryption);
assertEquals(List.of("RSA-OAEP"), encryption.getAlgValuesSupported());
assertEquals(List.of("A256GCM"), encryption.getEncValuesSupported());
assertTrue("encryption_required should be true", encryption.getEncryptionRequired());
// Check signed_metadata
Assert.assertEquals(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc",
credentialIssuer.getSignedMetadata()
);
// Check batch_credential_issuance
CredentialIssuer.BatchCredentialIssuance batch = issuer.getBatchCredentialIssuance();
assertNotNull("batch_credential_issuance should be present", batch);
assertEquals(Integer.valueOf(10), batch.getBatchSize());
// Check signed_metadata
assertEquals(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc",
issuer.getSignedMetadata()
);
});
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
Map<String, String> clientAttributes = new HashMap<>(getTestCredentialDefinitionAttributes());
Map<String, String> realmAttributes = new HashMap<>();
OID4VCIssuerWellKnownProviderTest
.configureTestRealm(
getTestClient("did:web:test.org"),
testRealm,
clientAttributes,
realmAttributes
);
for (ClientScopeRepresentation clientScope : List.of(jwtTypeCredentialClientScope,
sdJwtTypeCredentialClientScope,
minimalJwtTypeCredentialClientScope)) {
compareMetadataToClientScope(credentialIssuer, clientScope);
}
}
public static class TestCredentialDefinitionInRealmAttributes extends OID4VCTest {
/**
* this test will make sure that the default values are correctly added into the metadata endpoint
*/
@Test
public void testMinimalJwtCredentialHardcodedTest()
{
ClientScopeRepresentation clientScope = minimalJwtTypeCredentialClientScope;
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported()
.get(clientScope.getName());
Assert.assertNotNull(supportedConfig);
Assert.assertEquals(Format.SD_JWT_VC, supportedConfig.getFormat());
Assert.assertEquals(clientScope.getName(), supportedConfig.getScope());
Assert.assertEquals(1, supportedConfig.getCredentialDefinition().getType().size());
Assert.assertEquals(clientScope.getName(), supportedConfig.getCredentialDefinition().getType().get(0));
Assert.assertEquals(1, supportedConfig.getCredentialDefinition().getContext().size());
Assert.assertEquals(clientScope.getName(), supportedConfig.getCredentialDefinition().getContext().get(0));
Assert.assertNull(supportedConfig.getDisplay());
Assert.assertEquals(clientScope.getName(), supportedConfig.getScope());
@Test
public void testCredentialConfig() {
OID4VCIssuerWellKnownProviderTest
.testCredentialConfig(suiteContext, testingClient);
compareClaims(supportedConfig.getFormat(), supportedConfig.getClaims(), clientScope.getProtocolMappers());
}
private void compareMetadataToClientScope(CredentialIssuer credentialIssuer, ClientScopeRepresentation clientScope) {
String credentialConfigurationId = Optional.ofNullable(clientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID))
.orElse(clientScope.getName());
SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported()
.get(credentialConfigurationId);
Assert.assertNotNull("Configuration of type '" + credentialConfigurationId + "' must be present",
supportedConfig);
Assert.assertEquals(credentialConfigurationId, supportedConfig.getId());
String expectedFormat = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.FORMAT))
.orElse(Format.SD_JWT_VC);
Assert.assertEquals(expectedFormat, supportedConfig.getFormat());
Assert.assertEquals(clientScope.getName(), supportedConfig.getScope());
{
// TODO this is still hardcoded
Assert.assertEquals(1, supportedConfig.getCryptographicBindingMethodsSupported().size());
Assert.assertEquals(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT,
supportedConfig.getCryptographicBindingMethodsSupported().get(0));
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
Map<String, String> realmAttributes = new HashMap<>(getTestCredentialDefinitionAttributes());
Map<String, String> clientAttributes = new HashMap<>();
OID4VCIssuerWellKnownProviderTest.configureTestRealm(
getTestClient("did:web:test.org"),
testRealm,
clientAttributes,
realmAttributes
);
compareDisplay(supportedConfig, clientScope);
String expectedVct = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.VCT))
.orElse(clientScope.getName());
Assert.assertEquals(expectedVct, supportedConfig.getVct());
Assert.assertNotNull(supportedConfig.getCredentialDefinition());
Assert.assertNotNull(supportedConfig.getCredentialDefinition().getType());
List<String> credentialDefinitionTypes = Optional.ofNullable(clientScope.getAttributes()
.get(CredentialScopeModel.TYPES))
.map(s -> s.split(","))
.map(Arrays::asList)
.orElseGet(() -> List.of(clientScope.getName()));
Assert.assertEquals(credentialDefinitionTypes.size(),
supportedConfig.getCredentialDefinition().getType().size());
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
List<String> credentialDefinitionContexts = Optional.ofNullable(clientScope.getAttributes()
.get(CredentialScopeModel.CONTEXTS))
.map(s -> s.split(","))
.map(Arrays::asList)
.orElseGet(() -> List.of(clientScope.getName()));
Assert.assertEquals(credentialDefinitionContexts.size(),
supportedConfig.getCredentialDefinition().getContext().size());
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
List<String> signingAlgsSupported = new ArrayList<>(supportedConfig.getCredentialSigningAlgValuesSupported());
String proofTypesSupportedString = supportedConfig.getProofTypesSupported().toJsonString();
try {
withCausePropagation(() -> testingClient.server(TEST_REALM_NAME).run((session -> {
ProofTypesSupported expectedProofTypesSupported = ProofTypesSupported.parse(session,
List.of(Algorithm.RS256));
Assert.assertEquals(expectedProofTypesSupported,
ProofTypesSupported.fromJsonString(proofTypesSupportedString));
List<String> expectedSigningAlgs = OID4VCIssuerWellKnownProvider.getSupportedSignatureAlgorithms(session);
MatcherAssert.assertThat(signingAlgsSupported,
Matchers.containsInAnyOrder(expectedSigningAlgs.toArray()));
})));
} catch (Throwable e) {
throw new RuntimeException(e);
}
compareClaims(expectedFormat, supportedConfig.getClaims(), clientScope.getProtocolMappers());
}
private void compareDisplay(SupportedCredentialConfiguration supportedConfig, ClientScopeRepresentation clientScope) {
String display = clientScope.getAttributes().get(CredentialScopeModel.VC_DISPLAY);
if (StringUtil.isBlank(display)) {
Assert.assertNull(supportedConfig.getDisplay());
return;
}
List<DisplayObject> expectedDisplayObjectList;
try {
expectedDisplayObjectList = JsonSerialization.mapper.readValue(display, new TypeReference<>() {
});
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
Assert.assertEquals(expectedDisplayObjectList.size(), supportedConfig.getDisplay().size());
MatcherAssert.assertThat("Must contain all expected display-objects",
supportedConfig.getDisplay(),
Matchers.containsInAnyOrder(expectedDisplayObjectList.toArray()));
}
/**
* each claim representation from the metadata is based on a protocol-mapper which we compare here
*/
private void compareClaims(String credentialFormat,
Claims originalClaims,
List<ProtocolMapperRepresentation> originalProtocolMappers) {
// the data must be serializable to transfer them to the server, so we convert the data to strings
String claimsString = originalClaims.toJsonString();
String protocolMappersString = toJsonString(originalProtocolMappers);
try {
withCausePropagation(() -> testingClient.server(TEST_REALM_NAME).run((session -> {
Claims actualClaims = fromJsonString(claimsString, Claims.class);
List<ProtocolMapperRepresentation> protocolMappers = fromJsonString(protocolMappersString,
new SerializableProtocolMapperReference());
// check only protocol-mappers of type oid4vc
protocolMappers = protocolMappers.stream().filter(protocolMapper -> {
return OID4VCLoginProtocolFactory.PROTOCOL_ID.equals(protocolMapper.getProtocol());
}).toList();
for (ProtocolMapperRepresentation protocolMapper : protocolMappers) {
OID4VCMapper mapper = (OID4VCMapper) session.getProvider(ProtocolMapper.class,
protocolMapper.getProtocolMapper());
ProtocolMapperModel protocolMapperModel = new ProtocolMapperModel();
protocolMapperModel.setConfig(protocolMapper.getConfig());
mapper.setMapperModel(protocolMapperModel, credentialFormat);
Claim claim = actualClaims.stream()
.filter(c -> c.getPath().equals(mapper.getMetadataAttributePath()))
.findFirst().orElse(null);
if (mapper.includeInMetadata()) {
Assert.assertNotNull("There should be a claim matching the protocol-mappers config!", claim);
}
else {
Assert.assertNull("This claim should not be included in the metadata-config!", claim);
// no other checks to do for this claim
continue;
}
Assert.assertEquals(claim.isMandatory(),
Optional.ofNullable(protocolMapper.getConfig()
.get(Oid4vcProtocolMapperModel.MANDATORY))
.map(Boolean::parseBoolean)
.orElse(false));
String expectedDisplayString = protocolMapper.getConfig().get(Oid4vcProtocolMapperModel.DISPLAY);
List<ClaimDisplay> expectedDisplayList = fromJsonString(expectedDisplayString,
new SerializableClaimDisplayReference());
List<ClaimDisplay> actualDisplayList = claim.getDisplay();
if (expectedDisplayList == null) {
Assert.assertNull(actualDisplayList);
}
else {
Assert.assertEquals(expectedDisplayList.size(), actualDisplayList.size());
MatcherAssert.assertThat(actualDisplayList,
Matchers.containsInAnyOrder(expectedDisplayList.toArray()));
}
}
})));
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
public static void configureTestRealm(
ClientRepresentation testClient,
RealmRepresentation testRealm,
Map<String, String> clientAttributes,
Map<String, String> realmAttributes
) {
realmAttributes.put("credential_response_encryption.alg_values_supported", "[\"RSA-OAEP\"]");
realmAttributes.put("credential_response_encryption.enc_values_supported", "[\"A256GCM\"]");
realmAttributes.put("credential_response_encryption.encryption_required", "true");
realmAttributes.put("batch_credential_issuance.batch_size", "10");
realmAttributes.put("signed_metadata", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc"); // example JWT
testClient.setAttributes(new HashMap<>(clientAttributes));
testRealm.setAttributes(new HashMap<>(realmAttributes));
extendConfigureTestRealm(testRealm, testClient);
/**
* a jackson type-reference that can be used in the run-server-block
*/
public static class SerializableProtocolMapperReference extends TypeReference<List<ProtocolMapperRepresentation>>
implements Serializable {
}
/**
* a jackson type-reference that can be used in the run-server-block
*/
public static class SerializableClaimDisplayReference extends TypeReference<List<ClaimDisplay>>
implements Serializable {
}
public static void testCredentialConfig(SuiteContext suiteContext, KeycloakTestingClient testingClient) {
@ -171,21 +316,17 @@ public class OID4VCIssuerWellKnownProviderTest {
.run((session -> {
OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session);
Object issuerConfig = oid4VCIssuerWellKnownProvider.getConfig();
assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer);
Assert.assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer);
CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig;
assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer());
assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint());
assertEquals("The correct deferred_credential_endpoint should be included.", expectedDeferredEndpoint, credentialIssuer.getDeferredCredentialEndpoint());
assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential"));
assertEquals("The test-credential should offer type VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope());
assertEquals("The test-credential should be offered in the jwt-vc format.", Format.JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat());
assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims());
assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName"));
assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory());
assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName());
// moved sd-jwt specific config to org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getConfig
Assert.assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer());
Assert.assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint());
Assert.assertEquals("The correct deferred_credential_endpoint should be included.", expectedDeferredEndpoint, credentialIssuer.getDeferredCredentialEndpoint());
Assert.assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
Assert.assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
Assert.assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential"));
Assert.assertEquals("The test-credential should offer type VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope());
Assert.assertEquals("The test-credential should be offered in the jwt-vc format.", Format.JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat());
Assert.assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims());
}));
}

View File

@ -34,9 +34,14 @@ import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.VerificationException;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.protocol.oid4vc.model.ClaimDisplay;
import org.keycloak.protocol.oid4vc.model.Claims;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
@ -46,26 +51,29 @@ import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.sdjwt.vp.SdJwtVP;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@ -116,26 +124,32 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testGetCredentialOfferURI() {
String token = getBearerToken(oauth);
testingClient
.server(TEST_REALM_NAME)
.run((session) -> {
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
final String scopeName = jwtTypeCredentialClientScope.getName();
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0);
testingClient.server(TEST_REALM_NAME).run((session) -> {
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(
session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus());
CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(), CredentialOfferURI.class);
assertNotNull("A nonce should be included.", credentialOfferURI.getNonce());
assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer());
} catch (Exception e) {
throw new RuntimeException(e);
}
});
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId,
OfferUriType.URI,
0,
0);
assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus());
CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(),
CredentialOfferURI.class);
assertNotNull("A nonce should be included.", credentialOfferURI.getNonce());
assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer());
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
// ----- getCredentialOffer
@ -241,7 +255,6 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
authenticator.setTokenString(null);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response response = issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential"));
assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType());
}));
@ -258,47 +271,38 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
authenticator.setTokenString("token");
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential"));
}));
});
}
@Test(expected = BadRequestException.class)
public void testRequestCredentialUnsupportedFormat() throws Throwable {
String token = getBearerToken(oauth);
withCausePropagation(() -> {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.SD_JWT_VC)
.setCredentialIdentifier("test-credential"));
}));
});
}
@Test(expected = BadRequestException.class)
@Test
public void testRequestCredentialNoMatchingCredentialBuilder() throws Throwable {
String token = getBearerToken(oauth);
withCausePropagation(() ->
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
// Prepare the issue endpoint with no credential builders.
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
try {
withCausePropagation(() -> {
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = //
new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential"));
}))
);
// Prepare the issue endpoint with no credential builders.
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
CredentialRequest credentialRequest = //
new CredentialRequest().setCredentialConfigurationId(credentialConfigurationId);
issuerEndpoint.requestCredential(credentialRequest);
}));
});
Assert.fail("Should have thrown an exception");
}catch (Exception e) {
Assert.assertTrue(e instanceof BadRequestException);
Assert.assertEquals("No credential builder found for format jwt_vc", e.getMessage());
}
}
@Test(expected = BadRequestException.class)
@ -312,7 +316,6 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.requestCredential(new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("no-such-credential"));
}));
});
@ -320,7 +323,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredential() {
String token = getBearerToken(oauth);
final String scopeName = jwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
@ -328,19 +332,56 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential");
.setCredentialIdentifier(scopeName);
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus());
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK,
credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(), JsonWebToken.class).getToken();
CredentialResponse credentialResponseVO = JsonSerialization.mapper
.convertValue(credentialResponse.getEntity(),
CredentialResponse.class);
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(),
JsonWebToken.class).getToken();
assertNotNull("A valid credential string should have been responded", jsonWebToken);
assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc"));
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential"));
assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
assertNotNull("The credentials should be included at the vc-claim.",
jsonWebToken.getOtherClaims().get("vc"));
VerifiableCredential credential =
JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"),
VerifiableCredential.class);
assertTrue("The static claim should be set.",
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
assertEquals("The static claim should be set.",
scopeName,
credential.getCredentialSubject().getClaims().get("scope-name"));
assertFalse("Only mappers supported for the requested type should have been evaluated.",
credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
}));
}
@Test
public void testRequestCredentialWithConfigurationIdNotSet() {
final String scopeName = minimalJwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK,
credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper
.convertValue(credentialResponse.getEntity(),
CredentialResponse.class);
SdJwtVP sdJwtVP = SdJwtVP.of((String)credentialResponseVO.getCredentials().get(0).getCredential());
assertNotNull("A valid credential string should have been responded", sdJwtVP);
}));
}
@ -354,14 +395,20 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testCredentialIssuance() throws Exception {
String token = getBearerToken(oauth);
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
// 1. Retrieving the credential-offer-uri
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential");
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME)
+ "credential-offer-uri?credential_configuration_id="
+ credentialConfigurationId);
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode());
assertEquals("A valid offer uri should be returned",
HttpStatus.SC_OK,
credentialOfferURIResponse.getStatusLine().getStatusCode());
String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8);
CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class);
@ -408,7 +455,11 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
.forEach(supportedCredential -> {
try {
requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential, new CredentialResponseHandler());
requestCredential(theToken,
credentialIssuer.getCredentialEndpoint(),
supportedCredential,
new CredentialResponseHandler(),
jwtTypeCredentialClientScope);
} catch (IOException e) {
fail("Was not able to get the credential.");
} catch (VerificationException e) {
@ -418,47 +469,52 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
}
@Test
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception {
// Set the realm attribute for the required scope
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
RealmRepresentation rep = realm.toRepresentation();
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
attributes.put("vc.test-credential.scope", "VerifiableCredential");
rep.setAttributes(attributes);
realm.update(rep);
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() {
BiFunction<String, String, String> getAccessToken = (testClientId, testScope) -> {
return getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
};
testCredentialIssuanceWithAuthZCodeFlow(
(testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("VerifiableCredential")),
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
assertEquals("Credential identifier should match", "test-credential", credentialRequest.getCredentialIdentifier());
Consumer<Map<String, Object>> sendCredentialRequest = m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
assertEquals("Credential configuration id should match",
jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID),
credentialRequest.getCredentialConfigurationId());
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
if (response.getStatus() != 200) {
String errorBody = response.readEntity(String.class);
System.out.println("Error Response: " + errorBody);
}
assertEquals(200, response.getStatus());
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class), CredentialResponse.class);
try (Response response = credentialTarget.request()
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
.post(Entity.json(credentialRequest))) {
if (response.getStatus() != 200) {
String errorBody = response.readEntity(String.class);
System.out.println("Error Response: " + errorBody);
}
assertEquals(200, response.getStatus());
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),
CredentialResponse.class);
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(), JsonWebToken.class).getToken();
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(),
JsonWebToken.class).getToken();
assertEquals(TEST_DID.toString(), jsonWebToken.getIssuer());
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
assertEquals(TEST_TYPES, credential.getType());
assertEquals(TEST_DID, credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
} catch (IOException | VerificationException e) {
Assert.fail("Failed to process credential response: " + e.getMessage());
}
});
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims()
.get("vc"),
VerifiableCredential.class);
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
assertEquals(TEST_DID, credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
} catch (VerificationException | IOException e) {
throw new RuntimeException(e);
}
};
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope, getAccessToken, sendCredentialRequest);
}
@Test
public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception {
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope
testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, (testClientId, testScope) ->
getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")),// set registered different scope
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
@ -472,7 +528,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception {
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope
testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope,
(testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)),// no scope
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
@ -484,74 +541,46 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
});
}
/**
* The accessToken references the scope "test-credential" but we ask for the credential "VerifiableCredential"
* in the CredentialRequest
*/
@Test
public void testCredentialIssuanceWithRealmScopeUnmatched() throws Exception {
// Set the realm attribute for the required scope
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
RealmRepresentation rep = realm.toRepresentation();
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
attributes.put("vc.test-credential.scope", "VerifiableCredential");
rep.setAttributes(attributes);
realm.update(rep);
public void testCredentialIssuanceWithScopeUnmatched() {
BiFunction<String, String, String> getAccessToken = (testClientId, testScope) -> {
return getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
};
// Run the flow with a non-matching scope
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")),
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
Consumer<Map<String, Object>> sendCredentialRequest = m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
assertEquals(400, response.getStatus());
String errorJson = response.readEntity(String.class);
assertNotNull("Error response should not be null", errorJson);
assertTrue("Error response should mention UNSUPPORTED_CREDENTIAL_TYPE or scope",
errorJson.contains("UNSUPPORTED_CREDENTIAL_TYPE") || errorJson.contains("scope"));
}
});
}
try (Response response = credentialTarget.request()
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
.post(Entity.json(credentialRequest))) {
assertEquals(400, response.getStatus());
String errorJson = response.readEntity(String.class);
assertNotNull("Error response should not be null", errorJson);
assertTrue("Error response should mention UNSUPPORTED_CREDENTIAL_TYPE or scope",
errorJson.contains("UNSUPPORTED_CREDENTIAL_TYPE") || errorJson.contains("scope"));
}
};
@Test
public void testCredentialIssuanceWithRealmScopeMissing() throws Exception {
// Remove the realm attribute for the required scope
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
RealmRepresentation rep = realm.toRepresentation();
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
attributes.remove("vc.test-credential.scope");
rep.setAttributes(attributes);
realm.update(rep);
// Run the flow with a scope in the access token, but no realm attribute
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("VerifiableCredential")),
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
assertEquals(400, response.getStatus());
String errorJson = response.readEntity(String.class);
Map<String, Object> errorMap = JsonSerialization.readValue(errorJson, Map.class);
assertTrue("Error should contain 'error' field", errorMap.containsKey("error"));
assertEquals("UNSUPPORTED_CREDENTIAL_TYPE", errorMap.get("error"));
assertEquals("Scope check failure", errorMap.get("error_description"));
} catch (IOException e) {
throw new RuntimeException(e);
}
});
testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, getAccessToken, sendCredentialRequest);
}
@Test
public void testRequestCredentialWithNotificationId() {
String token = getBearerToken(oauth);
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
final String scopeName = jwtTypeCredentialClientScope.getName();
testingClient.server(TEST_REALM_NAME).run((session) -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.JWT_VC)
.setCredentialIdentifier("test-credential");
CredentialRequest credentialRequest = new CredentialRequest().setCredentialIdentifier(scopeName);
// First credential request
Response response1 = issuerEndpoint.requestCredential(credentialRequest);
@ -569,4 +598,161 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
assertNotEquals("Notification IDs should be unique", credentialResponse1.getNotificationId(), credentialResponse2.getNotificationId());
});
}
/**
* This is testing the configuration exposed by OID4VCIssuerWellKnownProvider based on the client and signing config setup here.
*/
@Test
public void testGetJwtVcConfigFromMetadata() {
final String scopeName = jwtTypeCredentialClientScope.getName();
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
final String verifiableCredentialType = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.VCT);
String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME;
String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential";
String expectedNonceEndpoint = expectedIssuer + "/protocol/oid4vc/" + OID4VCIssuerEndpoint.NONCE_PATH;
final String expectedAuthorizationServer = expectedIssuer;
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session);
Object issuerConfig = oid4VCIssuerWellKnownProvider.getConfig();
assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer);
CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig;
assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer());
assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint());
assertEquals("The correct nonce endpoint should be included.",
expectedNonceEndpoint,
credentialIssuer.getNonceEndpoint());
assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
assertTrue("The jwt_vc-credential should be supported.",
credentialIssuer.getCredentialsSupported()
.containsKey(credentialConfigurationId));
SupportedCredentialConfiguration jwtVcConfig =
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId);
assertEquals("The jwt_vc-credential should offer type test-credential",
scopeName,
jwtVcConfig.getScope());
assertEquals("The jwt_vc-credential should be offered in the jwt_vc format.",
Format.JWT_VC,
jwtVcConfig.getFormat());
Claims jwtVcClaims = jwtVcConfig.getClaims();
assertNotNull("The jwt_vc-credential can optionally provide a claims claim.",
jwtVcClaims);
assertEquals(5, jwtVcClaims.size());
{
Claim claim = jwtVcClaims.get(0);
assertEquals("The jwt_vc-credential claim credentialSubject.given_name is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.given_name is present.",
"given_name",
claim.getPath().get(1));
assertFalse("The jwt_vc-credential claim credentialSubject.given_name is not mandatory.",
claim.isMandatory());
assertNotNull("The jwt_vc-credential claim credentialSubject.given_name has display configured",
claim.getDisplay());
assertEquals(15, claim.getDisplay().size());
for (ClaimDisplay givenNameDisplay : claim.getDisplay()) {
assertNotNull(givenNameDisplay.getName());
assertNotNull(givenNameDisplay.getLocale());
}
}
{
Claim claim = jwtVcClaims.get(1);
assertEquals("The jwt_vc-credential claim credentialSubject.family_name is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.family_name is present.",
"family_name",
claim.getPath().get(1));
assertFalse("The jwt_vc-credential claim credentialSubject.family_name is not mandatory.",
claim.isMandatory());
assertNotNull("The jwt_vc-credential claim credentialSubject.family_name has display configured",
claim.getDisplay());
assertEquals(15, claim.getDisplay().size());
for (ClaimDisplay familyNameDisplay : claim.getDisplay()) {
assertNotNull(familyNameDisplay.getName());
assertNotNull(familyNameDisplay.getLocale());
}
}
{
Claim claim = jwtVcClaims.get(2);
assertEquals("The jwt_vc-credential claim credentialSubject.birthdate is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.birthdate is present.",
"birthdate",
claim.getPath().get(1));
assertFalse("The jwt_vc-credential claim credentialSubject.birthdate is not mandatory.",
claim.isMandatory());
assertNotNull("The jwt_vc-credential claim credentialSubject.birthdate has display configured",
claim.getDisplay());
assertEquals(15, claim.getDisplay().size());
for (ClaimDisplay birthDateDisplay : claim.getDisplay()) {
assertNotNull(birthDateDisplay.getName());
assertNotNull(birthDateDisplay.getLocale());
}
}
{
Claim claim = jwtVcClaims.get(3);
assertEquals("The jwt_vc-credential claim credentialSubject.email is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.email is present.",
"email",
claim.getPath().get(1));
assertFalse("The jwt_vc-credential claim credentialSubject.email is not mandatory.",
claim.isMandatory());
assertNotNull("The jwt_vc-credential claim credentialSubject.email has display configured",
claim.getDisplay());
assertEquals(15, claim.getDisplay().size());
for (ClaimDisplay birthDateDisplay : claim.getDisplay()) {
assertNotNull(birthDateDisplay.getName());
assertNotNull(birthDateDisplay.getLocale());
}
}
{
Claim claim = jwtVcClaims.get(4);
assertEquals("The jwt_vc-credential claim credentialSubject.scope-name is present.",
Oid4VciConstants.CREDENTIAL_SUBJECT,
claim.getPath().get(0));
assertEquals("The jwt_vc-credential claim credentialSubject.scope-name is present.",
"scope-name",
claim.getPath().get(1));
assertFalse("The jwt_vc-credential claim credentialSubject.scope-name is not mandatory.",
claim.isMandatory());
assertNull("The jwt_vc-credential claim credentialSubject.scope-name has no display configured",
claim.getDisplay());
}
assertEquals("The jwt_vc-credential should offer vct",
verifiableCredentialType,
jwtVcConfig.getVct());
// We are offering key binding only for identity credential
assertTrue("The jwt_vc-credential should contain a cryptographic binding method supported named jwk",
jwtVcConfig.getCryptographicBindingMethodsSupported()
.contains(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT));
assertTrue("The jwt_vc-credential should contain a credential signing algorithm named RS256",
jwtVcConfig.getCredentialSigningAlgValuesSupported().contains("RS256"));
assertTrue("The jwt_vc-credential should support a proof of type jwt with signing algorithm RS256",
credentialIssuer.getCredentialsSupported()
.get(credentialConfigurationId)
.getProofTypesSupported()
.getSupportedProofTypes()
.get("jwt")
.getSigningAlgorithmsSupported()
.contains("RS256"));
assertEquals("The jwt_vc-credential should display as Test Credential",
credentialConfigurationId,
jwtVcConfig.getDisplay().get(0).getName());
}));
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* 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");
@ -20,7 +20,6 @@ import com.fasterxml.jackson.databind.JsonNode;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.apache.commons.collections4.map.HashedMap;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.http.HttpStatus;
@ -36,14 +35,20 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Base64Url;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.oid4vci.Oid4VciConstants;
import org.keycloak.models.RealmModel;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder;
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCGeneratedIdMapper;
import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.protocol.oid4vc.model.Claims;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
@ -52,9 +57,11 @@ import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.JwtProof;
import org.keycloak.protocol.oid4vc.model.Proof;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentExportRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.sdjwt.vp.SdJwtVP;
@ -73,6 +80,7 @@ import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@ -85,50 +93,71 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestTestCredential() {
String token = getBearerToken(oauth);
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
testingClient
.server(TEST_REALM_NAME)
.run(session -> testRequestTestCredential(session, token, null));
.run(session -> {
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, null);
});
}
@Test
public void testRequestTestCredentialWithKeybinding() {
String cNonce = getCNonce();
String token = getBearerToken(oauth);
testingClient.server(TEST_REALM_NAME)
.run((session -> {
JwtProof proof = new JwtProof()
.setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
SdJwtVP sdJwtVP = testRequestTestCredential(session, token, proof);
assertNotNull("A cnf claim must be attached to the credential", sdJwtVP.getCnfClaim());
}));
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
JwtProof proof = new JwtProof()
.setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
SdJwtVP sdJwtVP = testRequestTestCredential(session, clientScope, token, proof);
assertNotNull("A cnf claim must be attached to the credential", sdJwtVP.getCnfClaim());
}));
}
@Test
public void testRequestTestCredentialWithInvalidKeybinding() throws Throwable {
try {
String cNonce = getCNonce();
String token = getBearerToken(oauth);
withCausePropagation(() -> testingClient
.server(TEST_REALM_NAME)
.run((session -> {
JwtProof proof = new JwtProof()
.setJwt(generateInvalidJwtProof(getCredentialIssuer(session), cNonce));
String cNonce = getCNonce();
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
testRequestTestCredential(session, token, proof);
})));
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
try {
withCausePropagation(() -> {
testingClient.server(TEST_REALM_NAME).run((session -> {
JwtProof proof = new JwtProof()
.setJwt(generateInvalidJwtProof(getCredentialIssuer(session), cNonce));
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, proof);
}));
});
Assert.fail("Should have thrown an exception");
} catch (BadRequestException ex) {
Assert.assertEquals("Could not validate provided proof", ex.getMessage());
Assert.assertEquals("Could not verify signature of provided proof", ex.getCause().getMessage());
}
}
@Test
public void testProofOfPossessionWithMissingAudience() throws Throwable {
try {
String token = getBearerToken(oauth);
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
withCausePropagation(() -> testingClient
.server(TEST_REALM_NAME)
.run((session -> {
@ -140,7 +169,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
nonceEndpoint));
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
testRequestTestCredential(session, token, proof);
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, proof);
})));
Assert.fail("Should have thrown an exception");
} catch (BadRequestException ex) {
@ -155,7 +186,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testProofOfPossessionWithIllegalSourceEndpoint() throws Throwable {
try {
String token = getBearerToken(oauth);
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
withCausePropagation(() -> testingClient
.server(TEST_REALM_NAME)
.run((session -> {
@ -166,7 +199,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint), null);
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
testRequestTestCredential(session, token, proof);
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, proof);
})));
Assert.fail("Should have thrown an exception");
} catch (BadRequestException ex) {
@ -181,7 +216,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testProofOfPossessionWithExpiredState() throws Throwable {
try {
String token = getBearerToken(oauth);
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
withCausePropagation(() -> testingClient
.server(TEST_REALM_NAME)
.run((session -> {
@ -196,7 +233,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, nonceEndpoint));
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
testRequestTestCredential(session, token, proof);
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
ClientScopeRepresentation.class);
testRequestTestCredential(session, clientScope, token, proof);
} finally {
// make sure other tests are not affected by the changed realm-attribute
session.getContext().getRealm().removeAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS);
@ -214,24 +253,28 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
return OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
}
private static SdJwtVP testRequestTestCredential(KeycloakSession session, String token, Proof proof)
private static SdJwtVP testRequestTestCredential(KeycloakSession session, ClientScopeRepresentation clientScope,
String token, Proof proof)
throws VerificationException {
String vct = "https://credentials.example.com/test-credential";
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
CredentialRequest credentialRequest = new CredentialRequest()
.setFormat(Format.SD_JWT_VC)
.setVct(vct)
.setCredentialConfigurationId(credentialConfigurationId)
.setProof(proof);
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus());
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK,
credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
new TestCredentialResponseHandler(vct).handleCredentialResponse(credentialResponseVO);
CredentialResponse credentialResponseVO = JsonSerialization.mapper.convertValue(credentialResponse.getEntity(),
CredentialResponse.class);
new TestCredentialResponseHandler(sdJwtCredentialVct).handleCredentialResponse(credentialResponseVO,
clientScope);
// Get the credential from the credentials array
return SdJwtVP.of(credentialResponseVO.getCredentials().get(0).getCredential().toString());
@ -247,10 +290,14 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testCredentialIssuance() throws Exception {
String token = getBearerToken(oauth);
ClientScopeRepresentation clientScope = sdJwtTypeCredentialClientScope;
String token = getBearerToken(oauth, client, clientScope.getName());
// 1. Retrieving the credential-offer-uri
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential");
final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) +
"credential-offer-uri?credential_configuration_id=" +
credentialConfigurationId);
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
@ -297,14 +344,18 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
String theToken = accessTokenResponse.getAccessToken();
final String vct = "https://credentials.example.com/test-credential";
final String vct = clientScope.getAttributes().get(CredentialScopeModel.VCT);
// 6. Get the credential
credentialsOffer.getCredentialConfigurationIds().stream()
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
.forEach(supportedCredential -> {
try {
requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential, new TestCredentialResponseHandler(vct));
requestCredential(theToken,
credentialIssuer.getCredentialEndpoint(),
supportedCredential,
new TestCredentialResponseHandler(vct),
sdJwtTypeCredentialClientScope);
} catch (IOException e) {
fail("Was not able to get the credential.");
} catch (VerificationException e) {
@ -317,7 +368,12 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
* This is testing the configuration exposed by OID4VCIssuerWellKnownProvider based on the client and signing config setup here.
*/
@Test
public void getConfig() {
public void testGetSdJwtConfigFromMetadata() {
final String scopeName = sdJwtTypeCredentialClientScope.getName();
final String credentialConfigurationId = sdJwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
final String verifiableCredentialType = sdJwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.VCT);
String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME;
String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential";
String expectedNonceEndpoint = expectedIssuer + "/protocol/oid4vc/" + OID4VCIssuerEndpoint.NONCE_PATH;
@ -336,106 +392,151 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
credentialIssuer.getNonceEndpoint());
assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential"));
assertEquals("The test-credential should offer type test-credential", "test-credential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope());
assertEquals("The test-credential should be offered in the sd-jwt format.", Format.SD_JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat());
assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims());
assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName"));
assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory());
assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName());
assertEquals("The test-credential should offer vct VerifiableCredential", "https://credentials.example.com/test-credential", credentialIssuer.getCredentialsSupported().get("test-credential").getVct());
assertTrue("The sd-jwt-credential should be supported.",
credentialIssuer.getCredentialsSupported().containsKey(credentialConfigurationId));
SupportedCredentialConfiguration jwtVcConfig =
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId);
assertEquals("The sd-jwt-credential should offer type test-credential",
scopeName,
jwtVcConfig.getScope());
assertEquals("The sd-jwt-credential should be offered in the jwt_vc format.",
Format.SD_JWT_VC,
jwtVcConfig.getFormat());
assertNotNull("The sd-jwt-credential can optionally provide a claims claim.",
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId)
.getClaims());
Claims jwtVcClaims = jwtVcConfig.getClaims();
assertNotNull("The sd-jwt-credential can optionally provide a claims claim.",
jwtVcClaims);
assertEquals(5, jwtVcClaims.size());
{
Claim claim = jwtVcClaims.get(0);
assertEquals("The sd-jwt-credential claim roles is present.",
"roles",
claim.getPath().get(0));
assertFalse("The sd-jwt-credential claim roles is not mandatory.",
claim.isMandatory());
assertNull("The sd-jwt-credential claim roles has no display configured",
claim.getDisplay());
}
{
Claim claim = jwtVcClaims.get(1);
assertEquals("The sd-jwt-credential claim email is present.",
"email",
claim.getPath().get(0));
assertFalse("The sd-jwt-credential claim email is not mandatory.",
claim.isMandatory());
assertNull("The sd-jwt-credential claim email has no display configured",
claim.getDisplay());
}
{
Claim claim = jwtVcClaims.get(2);
assertEquals("The sd-jwt-credential claim firstName is present.",
"firstName",
claim.getPath().get(0));
assertFalse("The sd-jwt-credential claim firstName is not mandatory.",
claim.isMandatory());
assertNull("The sd-jwt-credential claim firstName has no display configured",
claim.getDisplay());
}
{
Claim claim = jwtVcClaims.get(3);
assertEquals("The sd-jwt-credential claim lastName is present.",
"lastName",
claim.getPath().get(0));
assertFalse("The sd-jwt-credential claim lastName is not mandatory.",
claim.isMandatory());
assertNull("The sd-jwt-credential claim lastName has no display configured",
claim.getDisplay());
}
{
Claim claim = jwtVcClaims.get(4);
assertEquals("The sd-jwt-credential claim scope-name is present.",
"scope-name",
claim.getPath().get(0));
assertFalse("The sd-jwt-credential claim scope-name is not mandatory.",
claim.isMandatory());
assertNull("The sd-jwt-credential claim scope-name has no display configured",
claim.getDisplay());
}
assertEquals("The sd-jwt-credential should offer vct",
verifiableCredentialType,
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId).getVct());
// We are offering key binding only for identity credential
assertTrue("The IdentityCredential should contain a cryptographic binding method supported named jwk", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCryptographicBindingMethodsSupported().contains("jwk"));
assertTrue("The IdentityCredential should contain a credential signing algorithm named ES256", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCredentialSigningAlgValuesSupported().contains("ES256"));
assertEquals("The IdentityCredential should display as Test Credential", "Identity Credential", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getDisplay().get(0).getName());
assertTrue("The IdentityCredential should support a proof of type jwt with signing algorithm ES256", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getProofTypesSupported().getJwt().getProofSigningAlgValuesSupported().contains("ES256"));
assertTrue("The sd-jwt-credential should contain a cryptographic binding method supported named jwk",
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId)
.getCryptographicBindingMethodsSupported()
.contains(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT));
assertTrue("The sd-jwt-credential should contain a credential signing algorithm named ES256",
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId)
.getCredentialSigningAlgValuesSupported().contains("ES256"));
assertTrue("The sd-jwt-credential should support a proof of type jwt with signing algorithm ES256",
credentialIssuer.getCredentialsSupported()
.get(credentialConfigurationId)
.getProofTypesSupported()
.getSupportedProofTypes()
.get("jwt")
.getSigningAlgorithmsSupported()
.contains("ES256"));
assertEquals("The sd-jwt-credential should display as Test Credential",
credentialConfigurationId,
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId)
.getDisplay().get(0).getName());
}));
}
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) {
String issuerDid = "did:web:issuer.org";
SdJwtCredentialBuilder testSdJwtCredentialBuilder = new SdJwtCredentialBuilder(issuerDid);
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session,
AppAuthManager.BearerTokenAuthenticator authenticator) {
JwtCredentialBuilder testJwtCredentialBuilder = new JwtCredentialBuilder(new StaticTimeProvider(5));
SdJwtCredentialBuilder testSdJwtCredentialBuilder = new SdJwtCredentialBuilder();
return new OID4VCIssuerEndpoint(
session,
Map.of(
testSdJwtCredentialBuilder.getSupportedFormat(), testSdJwtCredentialBuilder
testSdJwtCredentialBuilder.getSupportedFormat(), testSdJwtCredentialBuilder,
testJwtCredentialBuilder.getSupportedFormat(), testJwtCredentialBuilder
),
authenticator,
TIME_PROVIDER,
30,
true);
30);
}
private static final String JTI_KEY = "jti";
public static ProtocolMapperRepresentation getJtiGeneratedIdMapper(String supportedCredentialTypes) {
public static ProtocolMapperRepresentation getJtiGeneratedIdMapper() {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("generated-id-mapper");
protocolMapperRepresentation.setProtocol("oid4vc");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-generated-id-mapper");
protocolMapperRepresentation.setConfig(Map.of(
OID4VCGeneratedIdMapper.SUBJECT_PROPERTY_CONFIG_KEY, JTI_KEY,
"supportedCredentialTypes", supportedCredentialTypes
OID4VCGeneratedIdMapper.CLAIM_NAME, JTI_KEY
));
return protocolMapperRepresentation;
}
public static ClientScopeModel createCredentialScope(KeycloakSession session) {
RealmModel realmModel = session.getContext().getRealm();
ClientScopeModel credentialScope = session.clientScopes()
.addClientScope(realmModel, jwtTypeCredentialScopeName);
credentialScope.setAttribute(CredentialScopeModel.CREDENTIAL_IDENTIFIER,
jwtTypeCredentialScopeName);
credentialScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
return credentialScope;
}
@Override
protected ComponentExportRepresentation getKeyProvider() {
return getEcKeyProvider();
}
@Override
protected List<ComponentExportRepresentation> getCredentialBuilderProviders() {
return List.of(getCredentialBuilderProvider(Format.SD_JWT_VC));
}
@Override
protected Map<String, String> getCredentialDefinitionAttributes() {
Map<String, String> testCredentialAttributes = Map.ofEntries(
Map.entry("vc.test-credential.expiry_in_s", "1800"),
Map.entry("vc.test-credential.format", Format.SD_JWT_VC),
Map.entry("vc.test-credential.scope", "test-credential"),
Map.entry("vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }"),
Map.entry("vc.test-credential.vct", "https://credentials.example.com/test-credential"),
Map.entry("vc.test-credential.credential_signing_alg_values_supported", "ES256,ES384"),
Map.entry("vc.test-credential.display.0", "{\n \"name\": \"Test Credential\"\n}"),
Map.entry("vc.test-credential.cryptographic_binding_methods_supported", "jwk"),
Map.entry("vc.test-credential.proof_types_supported", "{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"),
Map.entry("vc.test-credential.credential_build_config.token_jws_type", "example+sd-jwt"),
Map.entry("vc.test-credential.credential_build_config.hash_algorithm", "sha-256"),
Map.entry("vc.test-credential.credential_build_config.visible_claims", "iat,nbf,jti"),
Map.entry("vc.test-credential.credential_build_config.decoys", "2"),
Map.entry("vc.test-credential.credential_build_config.signing_algorithm", "ES256")
);
Map<String, String> identityCredentialAttributes = Map.ofEntries(
Map.entry("vc.IdentityCredential.expiry_in_s", "31536000"),
Map.entry("vc.IdentityCredential.format", Format.SD_JWT_VC),
Map.entry("vc.IdentityCredential.scope", "identity_credential"),
Map.entry("vc.IdentityCredential.vct", "https://credentials.example.com/identity_credential"),
Map.entry("vc.IdentityCredential.cryptographic_binding_methods_supported", "jwk"),
Map.entry("vc.IdentityCredential.credential_signing_alg_values_supported", "ES256,ES384"),
Map.entry("vc.IdentityCredential.claims", "{\"given_name\":{\"display\":[{\"name\":\"الاسم الشخصي\",\"locale\":\"ar\"},{\"name\":\"Vorname\",\"locale\":\"de\"},{\"name\":\"Given Name\",\"locale\":\"en\"},{\"name\":\"Nombre\",\"locale\":\"es\"},{\"name\":\"نام\",\"locale\":\"fa\"},{\"name\":\"Etunimi\",\"locale\":\"fi\"},{\"name\":\"Prénom\",\"locale\":\"fr\"},{\"name\":\"पहचानी गई नाम\",\"locale\":\"hi\"},{\"name\":\"Nome\",\"locale\":\"it\"},{\"name\":\"\",\"locale\":\"ja\"},{\"name\":\"Овог нэр\",\"locale\":\"mn\"},{\"name\":\"Voornaam\",\"locale\":\"nl\"},{\"name\":\"Nome Próprio\",\"locale\":\"pt\"},{\"name\":\"Förnamn\",\"locale\":\"sv\"},{\"name\":\"مسلمان نام\",\"locale\":\"ur\"}]},\"family_name\":{\"display\":[{\"name\":\"اسم العائلة\",\"locale\":\"ar\"},{\"name\":\"Nachname\",\"locale\":\"de\"},{\"name\":\"Family Name\",\"locale\":\"en\"},{\"name\":\"Apellido\",\"locale\":\"es\"},{\"name\":\"نام خانوادگی\",\"locale\":\"fa\"},{\"name\":\"Sukunimi\",\"locale\":\"fi\"},{\"name\":\"Nom de famille\",\"locale\":\"fr\"},{\"name\":\"परिवार का नाम\",\"locale\":\"hi\"},{\"name\":\"Cognome\",\"locale\":\"it\"},{\"name\":\"\",\"locale\":\"ja\"},{\"name\":\"өөрийн нэр\",\"locale\":\"mn\"},{\"name\":\"Achternaam\",\"locale\":\"nl\"},{\"name\":\"Sobrenome\",\"locale\":\"pt\"},{\"name\":\"Efternamn\",\"locale\":\"sv\"},{\"name\":\"خاندانی نام\",\"locale\":\"ur\"}]},\"birthdate\":{\"display\":[{\"name\":\"تاريخ الميلاد\",\"locale\":\"ar\"},{\"name\":\"Geburtsdatum\",\"locale\":\"de\"},{\"name\":\"Date of Birth\",\"locale\":\"en\"},{\"name\":\"Fecha de Nacimiento\",\"locale\":\"es\"},{\"name\":\"تاریخ تولد\",\"locale\":\"fa\"},{\"name\":\"Syntymäaika\",\"locale\":\"fi\"},{\"name\":\"Date de naissance\",\"locale\":\"fr\"},{\"name\":\"जन्म की तारीख\",\"locale\":\"hi\"},{\"name\":\"Data di nascita\",\"locale\":\"it\"},{\"name\":\"生年月日\",\"locale\":\"ja\"},{\"name\":\"төрсөн өдөр\",\"locale\":\"mn\"},{\"name\":\"Geboortedatum\",\"locale\":\"nl\"},{\"name\":\"Data de Nascimento\",\"locale\":\"pt\"},{\"name\":\"Födelsedatum\",\"locale\":\"sv\"},{\"name\":\"تاریخ پیدائش\",\"locale\":\"ur\"}]}}"),
Map.entry("vc.IdentityCredential.display.0", "{\"name\": \"Identity Credential\"}"),
Map.entry("vc.IdentityCredential.proof_types_supported", "{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"),
Map.entry("vc.IdentityCredential.credential_build_config.token_jws_type", "example+sd-jwt"),
Map.entry("vc.IdentityCredential.credential_build_config.hash_algorithm", "sha-256"),
Map.entry("vc.IdentityCredential.credential_build_config.visible_claims", "iat,nbf,jti"),
Map.entry("vc.IdentityCredential.credential_build_config.decoys", "0"),
Map.entry("vc.IdentityCredential.credential_build_config.signing_algorithm", "ES256")
);
HashedMap<String, String> allAttributes = new HashedMap<>();
allAttributes.putAll(testCredentialAttributes);
allAttributes.putAll(identityCredentialAttributes);
return allAttributes;
}
static class TestCredentialResponseHandler extends CredentialResponseHandler {
final String vct;
@ -444,7 +545,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
}
@Override
protected void handleCredentialResponse(CredentialResponse credentialResponse) throws VerificationException {
protected void handleCredentialResponse(CredentialResponse credentialResponse, ClientScopeRepresentation clientScope) throws VerificationException {
// SDJWT have a special format.
SdJwtVP sdJwtVP = SdJwtVP.of(credentialResponse.getCredentials().get(0).getCredential().toString());
JsonWebToken jsonWebToken = TokenVerifier.create(sdJwtVP.getIssuerSignedJWT().toJws(), JsonWebToken.class).getToken();
@ -470,8 +571,11 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
assertTrue("The credentials should include the lastName claim.", disclosureMap.containsKey("lastName"));
assertEquals("lastName claim incorrectly mapped.", "Doe", disclosureMap.get("lastName").get(2).asText());
assertTrue("The credentials should include the roles claim.", disclosureMap.containsKey("roles"));
assertTrue("The credentials should include the test-credential claim.", disclosureMap.containsKey("test-credential"));
assertTrue("lastName claim incorrectly mapped.", disclosureMap.get("test-credential").get(2).asBoolean());
assertTrue("The credentials should include the scope-name claim.",
disclosureMap.containsKey("scope-name"));
assertEquals("The credentials should include the scope-name claims correct value.",
clientScope.getName(),
disclosureMap.get("scope-name").get(2).textValue());
assertTrue("The credentials should include the email claim.", disclosureMap.containsKey("email"));
assertEquals("email claim incorrectly mapped.", "john@email.cz", disclosureMap.get("email").get(2).asText());

View File

@ -22,6 +22,7 @@ import jakarta.ws.rs.client.Invocation;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import com.fasterxml.jackson.core.type.TypeReference;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.apache.http.HttpStatus;
@ -35,6 +36,7 @@ import org.keycloak.common.util.CertificateUtils;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.PemUtils;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.crypto.ECDSASignatureSignerContext;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
@ -49,11 +51,11 @@ import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.NonceResponse;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentExportRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
@ -101,6 +103,14 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
protected static final KeyWrapper RSA_KEY = getRsaKey();
protected static final String sdJwtTypeCredentialScopeName = "sd-jwt-credential";
protected static final String sdJwtTypeCredentialConfigurationIdName = "sd-jwt-credential-config-id";
protected static final String jwtTypeCredentialScopeName = "jwt-credential";
protected static final String jwtTypeCredentialConfigurationIdName = "jwt-credential-config-id";
protected static final String TEST_CREDENTIAL_MAPPERS_FILE = "/oid4vc/test-credential-mappers.json";
protected static CredentialSubject getCredentialSubject(Map<String, Object> claims) {
CredentialSubject credentialSubject = new CredentialSubject();
claims.forEach(credentialSubject::setClaims);
@ -215,19 +225,6 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
return clientRepresentation;
}
public static Map<String, String> getTestCredentialDefinitionAttributes() {
return Map.of(
"vc.test-credential.expiry_in_s", "100",
"vc.test-credential.format", Format.JWT_VC,
"vc.test-credential.scope", "VerifiableCredential",
"vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }",
"vc.test-credential.display.0","{\n \"name\": \"Test Credential\"\n}",
"vc.test-credential.credential_build_config.token_jws_type", "JWT",
"vc.test-credential.credential_build_config.signing_algorithm", "RS256"
// Moved sd-jwt specific attributes to: org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getTestCredentialSigningProvider
);
}
protected ComponentExportRepresentation getEdDSAKeyProvider() {
ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation();
componentExportRepresentation.setName("eddsa-generated");
@ -255,86 +252,69 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
return componentExportRepresentation;
}
public void addProtocolMappersToClientScope(String scopeId, String scopeName, String clientId) {
List<ProtocolMapperRepresentation> protocolMappers = getProtocolMappers(scopeName, clientId);
public void addProtocolMappersToClientScope(ClientScopeRepresentation clientScope,
List<ProtocolMapperRepresentation> protocolMappers) {
String scopeId = clientScope.getId();
String scopeName = clientScope.getName();
if (!protocolMappers.isEmpty()) {
ClientScopeResource clientScopeResource = testRealm().clientScopes().get(scopeId);
ProtocolMappersResource protocolMappersResource = clientScopeResource.getProtocolMappers();
if (protocolMappers.isEmpty()) {
return;
}
for (ProtocolMapperRepresentation protocolMapper : protocolMappers) {
Response response = protocolMappersResource.createMapper(protocolMapper);
if (response.getStatus() != 201) {
LOGGER.errorf("Failed to create protocol mapper: {} for scope: {}", protocolMapper, scopeName);
}
ClientScopeResource clientScopeResource = testRealm().clientScopes().get(scopeId);
ProtocolMappersResource protocolMappersResource = clientScopeResource.getProtocolMappers();
for (ProtocolMapperRepresentation protocolMapper : protocolMappers) {
Response response = protocolMappersResource.createMapper(protocolMapper);
if (response.getStatus() != 201) {
LOGGER.errorf("Failed to create protocol mapper: {} for scope: {}", protocolMapper, scopeName);
}
}
}
private List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName, String clientId) {
final String TEST_CREDENTIAL = "test-credential";
final String VERIFIABLE_CREDENTIAL = "VerifiableCredential";
return switch (scopeName) {
case TEST_CREDENTIAL -> List.of(
getRoleMapper(clientId, TEST_CREDENTIAL),
getUserAttributeMapper("email", "email", TEST_CREDENTIAL),
getUserAttributeMapper("firstName", "firstName", TEST_CREDENTIAL),
getUserAttributeMapper("lastName", "lastName", TEST_CREDENTIAL),
getJtiGeneratedIdMapper(TEST_CREDENTIAL),
getStaticClaimMapper(TEST_CREDENTIAL, TEST_CREDENTIAL),
getIssuedAtTimeMapper(null, ChronoUnit.HOURS.name(), "COMPUTE", TEST_CREDENTIAL),
getIssuedAtTimeMapper("nbf", null, "COMPUTE", TEST_CREDENTIAL)
);
case VERIFIABLE_CREDENTIAL -> List.of(
getRoleMapper(clientId, VERIFIABLE_CREDENTIAL),
getUserAttributeMapper("email", "email", VERIFIABLE_CREDENTIAL),
getIdMapper(VERIFIABLE_CREDENTIAL),
getStaticClaimMapper(scopeName, VERIFIABLE_CREDENTIAL)
);
default -> List.of(); // No mappers for unknown scopes
};
public List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName) {
return List.of(
getRoleMapper(),
getUserAttributeMapper("email", "email"),
getUserAttributeMapper("firstName", "firstName"),
getUserAttributeMapper("lastName", "lastName"),
getJtiGeneratedIdMapper(),
getStaticClaimMapper(scopeName),
getIssuedAtTimeMapper("iat", ChronoUnit.HOURS.name(), "COMPUTE"),
getIssuedAtTimeMapper("nbf", null, "COMPUTE"));
}
public static ProtocolMapperRepresentation getRoleMapper(String clientId, String supportedCredentialTypes) {
public static ProtocolMapperRepresentation getRoleMapper() {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("role-mapper");
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocol("oid4vc");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocolMapper("oid4vc-target-role-mapper");
protocolMapperRepresentation.setConfig(
Map.of(
"subjectProperty", "roles",
"clientId", clientId,
"supportedCredentialTypes", supportedCredentialTypes)
Map.of("claim.name", "roles")
);
return protocolMapperRepresentation;
}
public static ProtocolMapperRepresentation getIdMapper(String supportedCredentialTypes) {
public static ProtocolMapperRepresentation getIdMapper() {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("id-mapper");
protocolMapperRepresentation.setProtocol("oid4vc");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper");
protocolMapperRepresentation.setConfig(
Map.of(
"supportedCredentialTypes", supportedCredentialTypes)
);
protocolMapperRepresentation.setConfig(Map.of());
return protocolMapperRepresentation;
}
public static ProtocolMapperRepresentation getStaticClaimMapper(String scope, String supportedCredentialTypes) {
public static ProtocolMapperRepresentation getStaticClaimMapper(String scopeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocol("oid4vc");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper");
protocolMapperRepresentation.setConfig(
Map.of(
"subjectProperty", scope,
"staticValue", "true",
"supportedCredentialTypes", supportedCredentialTypes)
Map.of("claim.name", "scope-name",
"staticValue", scopeName)
);
return protocolMapperRepresentation;
}
@ -413,32 +393,30 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
}
}
protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName, String supportedCredentialTypes) {
protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + attributeName + "-mapper");
protocolMapperRepresentation.setProtocol("oid4vc");
protocolMapperRepresentation.setName(attributeName + "-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper");
protocolMapperRepresentation.setConfig(
Map.of(
"subjectProperty", subjectProperty,
"userAttribute", attributeName,
"supportedCredentialTypes", supportedCredentialTypes)
"claim.name", subjectProperty,
"userAttribute", attributeName)
);
return protocolMapperRepresentation;
}
protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource, String supportedCredentialTypes) {
protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + subjectProperty + "-oid4vc-issued-at-time-claim-mapper");
protocolMapperRepresentation.setProtocol("oid4vc");
protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-issued-at-time-claim-mapper");
Map<String, String> configMap = new HashMap<>();
configMap.put("supportedCredentialTypes", supportedCredentialTypes);
Optional.ofNullable(subjectProperty)
.ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.SUBJECT_PROPERTY_CONFIG_KEY, value));
.ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.CLAIM_NAME, value));
Optional.ofNullable(truncateToTimeUnit)
.ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.TRUNCATE_TO_TIME_UNIT_KEY, value));
Optional.ofNullable(valueSource)
@ -527,4 +505,37 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
throw new RuntimeException(e);
}
}
public static <T> T fromJsonString(String representation, Class<T> clazz)
{
try {
return JsonSerialization.readValue(representation, clazz);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static <T> T fromJsonString(String representation, TypeReference<T> typeReference)
{
if (representation == null) {
return null;
}
try {
return JsonSerialization.readValue(representation, typeReference);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static String toJsonString(Object object)
{
if (object == null) {
return null;
}
try {
return JsonSerialization.writeValueAsString(object);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -199,11 +199,12 @@ public class SdJwtCredentialSignerTest extends OID4VCTest {
public static void testSignSDJwtCredential(KeycloakSession session, String signingKeyId, String overrideKeyId, String
algorithm, Map<String, Object> claims, int decoys, List<String> visibleClaims) {
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
.setCredentialIssuer(TEST_DID.toString())
.setCredentialType("https://credentials.example.com/test-credential")
.setTokenJwsType("example+sd-jwt")
.setHashAlgorithm("sha-256")
.setNumberOfDecoys(decoys)
.setVisibleClaims(visibleClaims)
.setSdJwtVisibleClaims(visibleClaims)
.setSigningKeyId(signingKeyId)
.setSigningAlgorithm(algorithm)
.setOverrideKeyId(overrideKeyId);
@ -211,7 +212,7 @@ public class SdJwtCredentialSignerTest extends OID4VCTest {
SdJwtCredentialSigner sdJwtCredentialSigner = new SdJwtCredentialSigner(session);
VerifiableCredential testCredential = getTestCredential(claims);
SdJwtCredentialBody sdJwtCredentialBody = new SdJwtCredentialBuilder("did:web:test.org")
SdJwtCredentialBody sdJwtCredentialBody = new SdJwtCredentialBuilder()
.buildCredentialBody(testCredential, credentialBuildConfig);
String sdJwt = sdJwtCredentialSigner.signCredential(sdJwtCredentialBody, credentialBuildConfig);

View File

@ -1310,8 +1310,7 @@
"protocolMapper": "oid4vc-subject-id-mapper",
"consentRequired": false,
"config": {
"supportedCredentialTypes": "VerifiableCredential",
"subjectIdProperty": "id"
"claim.name": "id"
}
},
{
@ -1321,7 +1320,7 @@
"protocolMapper": "oid4vc-user-attribute-mapper",
"consentRequired": false,
"config": {
"subjectProperty": "email",
"claim.name": "email",
"userAttribute": "email",
"aggregateAttributes": "false"
}
@ -1333,7 +1332,7 @@
"protocolMapper": "oid4vc-user-attribute-mapper",
"consentRequired": false,
"config": {
"subjectProperty": "familyName",
"claim.name": "familyName",
"userAttribute": "lastName",
"aggregateAttributes": "false"
}
@ -1345,7 +1344,7 @@
"protocolMapper": "oid4vc-target-role-mapper",
"consentRequired": false,
"config": {
"subjectProperty": "roles",
"claim.name": "roles",
"clientId": "id"
}
},
@ -1356,7 +1355,7 @@
"protocolMapper": "oid4vc-user-attribute-mapper",
"consentRequired": false,
"config": {
"subjectProperty": "firstName",
"claim.name": "firstName",
"userAttribute": "firstName",
"aggregateAttributes": "false"
}
@ -2510,4 +2509,4 @@
"clientPolicies": {
"policies": []
}
}
}

View File

@ -0,0 +1,56 @@
{
"protocolMappers": [
{
"name": "generated-id-mapper",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-generated-id-mapper",
"config": {
"claim.name": "jti"
}
},
{
"name": "givenName",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-user-attribute-mapper",
"config": {
"claim.name": "given_name",
"userAttribute": "firstName",
"vc.mandatory": "false",
"vc.display": "[{\"name\": \"الاسم الشخصي\", \"locale\": \"ar-SA\"}, {\"name\": \"Vorname\", \"locale\": \"de-DE\"}, {\"name\": \"Given Name\", \"locale\": \"en-US\"}, {\"name\": \"Nombre\", \"locale\": \"es-ES\"}, {\"name\": \"نام\", \"locale\": \"fa-IR\"}, {\"name\": \"Etunimi\", \"locale\": \"fi-FI\"}, {\"name\": \"Prénom\", \"locale\": \"fr-FR\"}, {\"name\": \"पहचानी गई नाम\", \"locale\": \"hi-IN\"}, {\"name\": \"Nome\", \"locale\": \"it-IT\"}, {\"name\": \"名\", \"locale\": \"ja-JP\"}, {\"name\": \"Овог нэр\", \"locale\": \"mn-MN\"}, {\"name\": \"Voornaam\", \"locale\": \"nl-NL\"}, {\"name\": \"Nome Próprio\", \"locale\": \"pt-PT\"}, {\"name\": \"Förnamn\", \"locale\": \"sv-SE\"}, {\"name\": \"مسلمان نام\", \"locale\": \"ur-PK\"}]"
}
},
{
"name": "familyName",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-user-attribute-mapper",
"config": {
"claim.name": "family_name",
"userProperty": "lastName",
"vc.mandatory": "false",
"vc.display": "[{\"name\": \"اسم العائلة\", \"locale\": \"ar-SA\"}, {\"name\": \"Nachname\", \"locale\": \"de-DE\"}, {\"name\": \"Family Name\", \"locale\": \"en-US\"}, {\"name\": \"Apellido\", \"locale\": \"es-ES\"}, {\"name\": \"نام خانوادگی\", \"locale\": \"fa-IR\"}, {\"name\": \"Sukunimi\", \"locale\": \"fi-FI\"}, {\"name\": \"Nom de famille\", \"locale\": \"fr-FR\"}, {\"name\": \"परिवार का नाम\", \"locale\": \"hi-IN\"}, {\"name\": \"Cognome\", \"locale\": \"it-IT\"}, {\"name\": \"姓\", \"locale\": \"ja-JP\"}, {\"name\": \"өөрийн нэр\", \"locale\": \"mn-MN\"}, {\"name\": \"Achternaam\", \"locale\": \"nl-NL\"}, {\"name\": \"Sobrenome\", \"locale\": \"pt-PT\"}, {\"name\": \"Efternamn\", \"locale\": \"sv-SE\"}, {\"name\": \"خاندانی نام\", \"locale\": \"ur-PK\"}]"
}
},
{
"name": "birthdate",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-user-attribute-mapper",
"config": {
"userAttribute": "birthdate",
"claim.name": "birthdate",
"vc.mandatory": "false",
"vc.display": "[{\"name\": \"تاريخ الميلاد\", \"locale\": \"ar-SA\"}, {\"name\": \"Geburtsdatum\", \"locale\": \"de-DE\"}, {\"name\": \"Date of Birth\", \"locale\": \"en-US\"}, {\"name\": \"Fecha de Nacimiento\", \"locale\": \"es-ES\"}, {\"name\": \"تاریخ تولد\", \"locale\": \"fa-IR\"}, {\"name\": \"Syntymäaika\", \"locale\": \"fi-FI\"}, {\"name\": \"Date de naissance\", \"locale\": \"fr-FR\"}, {\"name\": \"जन्म की तारीख\", \"locale\": \"hi-IN\"}, {\"name\": \"Data di nascita\", \"locale\": \"it-IT\"}, {\"name\": \"生年月日\", \"locale\": \"ja-JP\"}, {\"name\": \"төрсөн өдөр\", \"locale\": \"mn-MN\"}, {\"name\": \"Geboortedatum\", \"locale\": \"nl-NL\"}, {\"name\": \"Data de Nascimento\", \"locale\": \"pt-PT\"}, {\"name\": \"Födelsedatum\", \"locale\": \"sv-SE\"}, {\"name\": \"تاریخ پیدائش\", \"locale\": \"ur-PK\"}]"
}
},
{
"name": "email",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-user-attribute-mapper",
"config": {
"claim.name": "email",
"userAttribute": "email",
"vc.mandatory": "false",
"vc.display": "[{\"name\": \"البريد الإلكتروني\", \"locale\": \"ar-SA\"}, {\"name\": \"E-Mail\", \"locale\": \"de-DE\"}, {\"name\": \"Email\", \"locale\": \"en-US\"}, {\"name\": \"Correo electrónico\", \"locale\": \"es-ES\"}, {\"name\": \"ایمیل\", \"locale\": \"fa-IR\"}, {\"name\": \"Sähköposti\", \"locale\": \"fi-FI\"}, {\"name\": \"E-mail\", \"locale\": \"fr-FR\"}, {\"name\": \"ईमेल\", \"locale\": \"hi-IN\"}, {\"name\": \"Email\", \"locale\": \"it-IT\"}, {\"name\": \"メール\", \"locale\": \"ja-JP\"}, {\"name\": \"И-мэйл\", \"locale\": \"mn-MN\"}, {\"name\": \"E-mail\", \"locale\": \"nl-NL\"}, {\"name\": \"Email\", \"locale\": \"pt-PT\"}, {\"name\": \"E-post\", \"locale\": \"sv-SE\"}, {\"name\": \"ای میل\", \"locale\": \"ur-PK\"}]"
}
}
]
}