diff --git a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc index 3a580ae0c63..de285d885ea 100644 --- a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc +++ b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc @@ -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 credential’s 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 <>. +- Most of the named attributes above are optional. See below: <>. +- 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 scope’s 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 issuer’s 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**: diff --git a/js/apps/account-ui/test/realms/verifiable-credentials-realm.json b/js/apps/account-ui/test/realms/verifiable-credentials-realm.json index 891a988e261..b30e18d1b45 100644 --- a/js/apps/account-ui/test/realms/verifiable-credentials-realm.json +++ b/js/apps/account-ui/test/realms/verifiable-credentials-realm.json @@ -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" } } ], diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java index a1d5d1f0ec9..d5f4342cf0c 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmCacheSession.java @@ -1439,6 +1439,17 @@ public class RealmCacheSession implements CacheRealmProvider { getClientScopesStream(realm).map(ClientScopeModel::getId).forEach(id -> removeClientScope(realm, id)); } + @Override + public Stream getClientScopesByProtocol(RealmModel realm, String protocol) { + return getClientScopeDelegate().getClientScopesByProtocol(realm, protocol); + } + + @Override + public Stream getClientScopesByAttributes(RealmModel realm, Map searchMap, + boolean useOr) { + return getClientScopeDelegate().getClientScopesByAttributes(realm, searchMap, useOr); + } + @Override public void addClientScopes(RealmModel realm, ClientModel client, Set clientScopes, boolean defaultScope) { getClientDelegate().addClientScopes(realm, client, clientScopes, defaultScope); @@ -1471,13 +1482,16 @@ public class RealmCacheSession implements CacheRealmProvider { return model; } Map assignedScopes = new HashMap<>(); + + List 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); } } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java index 74bfa05e32f..b3fa534ede1 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaRealmProvider.java @@ -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 getClientScopesByProtocol(RealmModel realm, String protocol) + { + TypedQuery 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.
+ * 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)}.
+ * Here is an example of a generated statement: + *
+     *     {@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
+     *     }
+     * 
+ * + * @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 getClientScopesByAttributes(RealmModel realm, Map 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 keys = new ArrayList<>(searchMap.keySet()); + Map dynamicParameterNameMap = new HashMap<>(); + Map 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 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 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 clientScopes, boolean defaultScope) { - // Defaults to openid-connect - String clientProtocol = client.getProtocol() == null ? OIDCLoginProtocol.LOGIN_PROTOCOL : client.getProtocol(); + List acceptedClientProtocols = KeycloakModelUtils.getAcceptedClientScopeProtocols(client); Map 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 getClientScopes(RealmModel realm, ClientModel client, boolean defaultScope) { - // Defaults to openid-connect - String clientProtocol = client.getProtocol() == null ? OIDCLoginProtocol.LOGIN_PROTOCOL : client.getProtocol(); + List acceptedClientProtocols = KeycloakModelUtils.getAcceptedClientScopeProtocols(client); TypedQuery 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 diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientScopeEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientScopeEntity.java index 1c7551adc01..f40adf6e440 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientScopeEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientScopeEntity.java @@ -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 { diff --git a/model/storage-private/src/main/java/org/keycloak/storage/ClientScopeStorageManager.java b/model/storage-private/src/main/java/org/keycloak/storage/ClientScopeStorageManager.java index 439ebbd5561..2e72b6a8cc1 100644 --- a/model/storage-private/src/main/java/org/keycloak/storage/ClientScopeStorageManager.java +++ b/model/storage-private/src/main/java/org/keycloak/storage/ClientScopeStorageManager.java @@ -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 getClientScopesByProtocol(RealmModel realm, String protocol) { + return localStorage().getClientScopesByProtocol(realm, protocol); + } + + @Override + public Stream getClientScopesByAttributes(RealmModel realm, Map searchMap, + boolean useOr) { + return localStorage().getClientScopesByAttributes(realm, searchMap, useOr); + } + @Override public void close() { } diff --git a/services/src/main/java/org/keycloak/oid4vci/Oid4VciConstants.java b/server-spi-private/src/main/java/org/keycloak/constants/Oid4VciConstants.java similarity index 80% rename from services/src/main/java/org/keycloak/oid4vci/Oid4VciConstants.java rename to server-spi-private/src/main/java/org/keycloak/constants/Oid4VciConstants.java index 3fe7b6fa32d..07b64bf75fc 100644 --- a/services/src/main/java/org/keycloak/oid4vci/Oid4VciConstants.java +++ b/server-spi-private/src/main/java/org/keycloak/constants/Oid4VciConstants.java @@ -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() { + } } diff --git a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java new file mode 100644 index 00000000000..1dd68ce5697 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java @@ -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 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 supportedCredentialTypes) { + clientScope.setAttribute(TYPES, String.join(",", supportedCredentialTypes)); + } + + public List 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 vcContexts) { + clientScope.setAttribute(CONTEXTS, String.join(",", vcContexts)); + } + + public List 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 signingAlgsSupported) { + clientScope.setAttribute(SIGNING_ALG_VALUES_SUPPORTED, + String.join(",", signingAlgsSupported)); + } + + public List 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 cryptographicBindingMethods) { + clientScope.setAttribute(CRYPTOGRAPHIC_BINDING_METHODS, + String.join(",", cryptographicBindingMethods)); + } + + public List 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 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 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 getOid4vcProtocolMappersStream() { + return clientScope.getProtocolMappersStream().filter(pm -> { + return Oid4VciConstants.OID4VC_PROTOCOL.equals(pm.getProtocol()); + }).map(Oid4vcProtocolMapperModel::new); + } + + @Override + public Stream 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 getScopeMappingsStream() { + return clientScope.getScopeMappingsStream(); + } + + @Override + public Stream 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); + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/Oid4vcProtocolMapperModel.java b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/Oid4vcProtocolMapperModel.java new file mode 100644 index 00000000000..9062427fb07 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/Oid4vcProtocolMapperModel.java @@ -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 getPath() + { + return Optional.ofNullable(protocolMapper.getConfig().get(PATH)) + .map(s -> s.split("\\.")) + .map(Arrays::asList) + .orElse(Collections.emptyList()); + } + + public void setPath(List 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 getConfig() { + return protocolMapper.getConfig(); + } + + @Override + public void setConfig(Map config) { + protocolMapper.setConfig(config); + } + + @Override + public boolean equals(Object obj) { + return protocolMapper.equals(obj); + } + + @Override + public int hashCode() { + return protocolMapper.hashCode(); + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java index 2bed9e075d5..30b139ca8d7 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java @@ -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 getAcceptedClientScopeProtocols(ClientModel client) { + List 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; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 811016ee380..6b8a7da1112 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -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) { diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/AbstractLoginProtocolFactory.java b/server-spi-private/src/main/java/org/keycloak/protocol/AbstractLoginProtocolFactory.java index af56344f6e6..661ab1034c2 100755 --- a/server-spi-private/src/main/java/org/keycloak/protocol/AbstractLoginProtocolFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/AbstractLoginProtocolFactory.java @@ -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() { diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocolFactory.java b/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocolFactory.java index 56af0a8b388..d748aff4de1 100755 --- a/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocolFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocolFactory.java @@ -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 { */ void setupClientDefaults(ClientRepresentation rep, ClientModel newClient); + /** + * Add default values to {@link ClientScopeRepresentation}s that refer to the specific login-protocol + */ + void addClientScopeDefaults(ClientScopeRepresentation clientModel); } diff --git a/server-spi/src/main/java/org/keycloak/models/ClientProvider.java b/server-spi/src/main/java/org/keycloak/models/ClientProvider.java index 1459d278cc7..e43e61af592 100644 --- a/server-spi/src/main/java/org/keycloak/models/ClientProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientProvider.java @@ -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 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> getAllRedirectUrisOfEnabledClients(RealmModel realm); + } diff --git a/server-spi/src/main/java/org/keycloak/models/ClientScopeProvider.java b/server-spi/src/main/java/org/keycloak/models/ClientScopeProvider.java index f147acd3f07..b8bd7176247 100644 --- a/server-spi/src/main/java/org/keycloak/models/ClientScopeProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientScopeProvider.java @@ -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 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 getClientScopesByAttributes(RealmModel realm, Map searchMap, boolean useOr); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProvider.java deleted file mode 100644 index 0eced4d9e7b..00000000000 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProvider.java +++ /dev/null @@ -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 Stefan Wiedemann - */ -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 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 clientAttributes) { - - OID4VCClient oid4VCClient = new OID4VCClient() - .setClientDid(clientId); - - Set supportedCredentialIds = new HashSet<>(); - Map 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 supportedCredentialConfigurations = supportedCredentialIds - .stream() - .map(id -> SupportedCredentialConfiguration.fromDotNotation(id, attributes)) - .toList(); - - return oid4VCClient.setSupportedVCTypes(supportedCredentialConfigurations); - } -} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderFactory.java deleted file mode 100644 index f63cb0fdd24..00000000000 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderFactory.java +++ /dev/null @@ -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. - *

- * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html} - * - * @author Stefan Wiedemann - */ -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 getConfigMetadata() { - return List.of(); - } -} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java index a1ce9af29dd..386292dd368 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -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; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 767700810a3..21e1032de60 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -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 credentialBuilders; - private final boolean isIgnoreScopeCheck; - public OID4VCIssuerEndpoint(KeycloakSession session, Map 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 loadCredentialBuilders(KeycloakSession keycloakSession) { KeycloakSessionFactory keycloakSessionFactory = keycloakSession.getKeycloakSessionFactory(); - RealmModel realm = keycloakSession.getContext().getRealm(); - Stream componentModels = realm.getComponentsStream( - realm.getId(), CredentialBuilder.class.getName()); - - return componentModels.map(componentModel -> { - ProviderFactory 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) 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 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 supportedCredentials, String requestedFormat) { - // 1. Format resolver - List configs = supportedCredentials.values().stream() - .filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(), requestedFormat)) - .collect(Collectors.toList()); - - List 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 protocolMappers = clientScopeModel.getProtocolMappersStream() + List 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 getProtocolMappers(List 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 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 protocolMappers, SupportedCredentialConfiguration credentialConfig, AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) { @@ -623,22 +513,18 @@ public class OID4VCIssuerEndpoint { Map 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) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java index fb86f823428..fae80ad145e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java @@ -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 getSupportedCredentials(KeycloakSession keycloakSession) { + List globalSupportedSigningAlgorithms = getSupportedSignatureAlgorithms(keycloakSession); RealmModel realm = keycloakSession.getContext().getRealm(); - List supportedFormats = getSupportedFormats(keycloakSession); - - // Retrieve signature algorithms - List supportedAlgorithms = getSupportedSignatureAlgorithms(keycloakSession); - - // Retrieving attributes from client definition. - // This will be removed when token production is migrated. - Map 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 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 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 aggregatedAttr = new HashMap<>(clientAttributes); - aggregatedAttr.putAll(realmAttr); - return aggregatedAttr; + return supportedCredentialConfigurations; + } + + public static SupportedCredentialConfiguration toSupportedCredentialConfiguration(KeycloakSession keycloakSession, + CredentialScopeModel credentialModel) { + List 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 fromRealmAttributes(Map realmAttributes) { - - Set supportedCredentialIds = new HashSet<>(); - Map 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. - *

- * 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 getSupportedFormats(KeycloakSession keycloakSession) { - RealmModel realm = keycloakSession.getContext().getRealm(); - KeycloakSessionFactory keycloakSessionFactory = keycloakSession.getKeycloakSessionFactory(); - - List 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 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 supportedFormats = new ArrayList<>(supportedFormatsByBuilders); - supportedFormats.retainAll(supportedFormatsBySigners); - - return supportedFormats; - } - public static List 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()); } + } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilderUtils.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilderUtils.java index 00d8f00c66b..a0bbfb24ad0 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilderUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/CredentialBuilderUtils.java @@ -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 getIssuerDid(KeycloakSession keycloakSession) { - RealmModel realm = keycloakSession.getContext().getRealm(); - return Optional.ofNullable(realm.getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY)); - } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java index e965c71e8a2..a0b47ccf401 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java @@ -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 diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderFactory.java index 395bd57138a..2eaf5ceb66c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderFactory.java @@ -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()); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java index fcd8219a6aa..32021c3368b 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java @@ -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); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilderFactory.java index 1a841b0779a..777a5fbb028 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilderFactory.java @@ -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(); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java index 745d410e68d..e299ceeadc4 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilder.java @@ -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. diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderFactory.java index 91f5c57c189..fd3f7736b03 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderFactory.java @@ -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(); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtCNonceHandler.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtCNonceHandler.java index 42d88fd0a3a..3edf5bd6069 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtCNonceHandler.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtCNonceHandler.java @@ -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; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java index 3125bfa4825..72d6c3ed652 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/JwtProofValidator.java @@ -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())); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/ProofValidator.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/ProofValidator.java index 9238efc67b5..a0739e83947 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/ProofValidator.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/ProofValidator.java @@ -28,6 +28,8 @@ public interface ProofValidator extends Provider { default void close() { } + String getProofType(); + /** * Validates a client-provided key binding proof. * diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java index dc6dcccea3e..1559990e514 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCContextMapper.java @@ -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 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; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java index 9cca842c775..5ba3b591fc9 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java @@ -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 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 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 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 attributePath = getMetadataAttributePath(); + String propertyName = attributePath.get(attributePath.size() - 1); + claims.put(propertyName, String.format("urn:uuid:%s", UUID.randomUUID())); } @Override diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java index 1484032d4d8..e86655b6863 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCIssuedAtTimeClaimMapper.java @@ -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 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 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()); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java index 67ff39da5ee..4b060533d8a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java @@ -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 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 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 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 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 */ diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java index 49250da8dbd..d6b3c149420 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java @@ -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 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 claims, UserSessionModel userSessionModel) { - String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY); + List 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; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java index 9fffe451844..1801cdcd1f8 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java @@ -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 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 getMetadataAttributePath() { + return ListUtils.union(getAttributePrefix(), List.of("id")); + } + public static ProtocolMapperModel create(String name, String subjectId) { var mapperModel = new ProtocolMapperModel(); mapperModel.setName(name); Map 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 claims, UserSessionModel userSessionModel) { - claims.put("id", mapperModel.getConfig().getOrDefault(ID_KEY, String.format("urn:uuid:%s", UUID.randomUUID()))); + List 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; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java index 3f4ec8019a5..b6847ca516f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java @@ -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 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 getIndividualConfigProperties() { return CONFIG_PROPERTIES; } + @Override + public List 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 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 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 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 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 modelMap = OBJECT_MAPPER.convertValue(rolesClaim, new TypeReference<>() {}); + Map 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>, 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()); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java index e1c66ddf077..f532b304645 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTypeMapper.java @@ -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 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; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java index f796d27cae9..0df73f1c8ff 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java @@ -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 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 claims, UserSessionModel userSessionModel) { - String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY); + List 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 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; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java index e66c5687298..e4f77b0df9f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java @@ -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. *

- * See: openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2 + * See: openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2 * * @author Francis Pouatcha */ +@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 path; + @JsonProperty("mandatory") private Boolean mandatory; - @JsonProperty("value_type") - private String valueType; + @JsonProperty("display") private List display; - public Boolean getMandatory() { - return mandatory; + public static Optional 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> typeReference = new TypeReference<>() {}; + List 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 getPath() { + return path; + } + + public Claim setPath(List 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 getDisplay() { return display; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ClaimDisplay.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ClaimDisplay.java index 83c9ca5a622..7fc2baed97d 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ClaimDisplay.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ClaimDisplay.java @@ -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); + } + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claims.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claims.java index 308295a2dea..ab295899c84 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claims.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claims.java @@ -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 Francis Pouatcha */ -public class Claims extends HashMap { +public class Claims extends ArrayList { + + public static Claims parse(KeycloakSession keycloakSession, CredentialScopeModel credentialScope) { + Claims claims = new Claims(); + credentialScope.getOid4vcProtocolMappersStream().forEach(protocolMapper -> { + Optional claim = Claim.parse(keycloakSession, credentialScope.getFormat(), protocolMapper); + claim.ifPresent(claims::add); + }); + return claims; + } public String toJsonString(){ try { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java index a71dd9101ea..504944538b1 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialBuildConfig.java @@ -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 visibleClaims; + private List 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 getVisibleClaims() { - return visibleClaims; + public List getSdJwtVisibleClaims() { + return sdJwtVisibleClaims; } - public CredentialBuildConfig setVisibleClaims(List visibleClaims) { - this.visibleClaims = visibleClaims; + public CredentialBuildConfig setSdJwtVisibleClaims(List sdJwtVisibleClaims) { + this.sdJwtVisibleClaims = sdJwtVisibleClaims; return this; } @@ -177,96 +202,39 @@ public class CredentialBuildConfig { return this; } - public Map toDotNotation() { - Map 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 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); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java index 05383eb564a..bc21ae23384 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialDefinition.java @@ -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 context; private List type = new ArrayList<>(); - private CredentialSubject credentialSubject = new CredentialSubject(); + + public static CredentialDefinition parse(CredentialScopeModel credentialModel) { + List contexts = Optional.of(credentialModel.getVcContexts()) + .filter(list -> !list.isEmpty()) + .orElseGet(() -> new ArrayList<>(List.of(credentialModel.getName()))); + List 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 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); - } - } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java index 543c9206bac..fe5e15e602c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialRequest.java @@ -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 findCredentialScope(KeycloakSession keycloakSession) { + Map 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); + } + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponse.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponse.java index f297bf8cb25..292e75ca98d 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponse.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialResponse.java @@ -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. */ diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java index df51baf4ad6..d55a91a07ec 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/DisplayObject.java @@ -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 parse(CredentialScopeModel credentialScope) { + String display = credentialScope.getVcDisplay(); + if (StringUtil.isBlank(display)) { + return null; + } + TypeReference> 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); + } + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java index 5cb13c3644a..5241df30711 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ErrorType.java @@ -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; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/OID4VCClient.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/OID4VCClient.java deleted file mode 100644 index 996314ca518..00000000000 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/OID4VCClient.java +++ /dev/null @@ -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 Stefan Wiedemann - */ -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 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 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 getSupportedVCTypes() { - return supportedVCTypes; - } - - public OID4VCClient setSupportedVCTypes(List 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; - } -} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java index bb686c8c661..95cdc058a89 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/ProofTypesSupported.java @@ -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 supportedProofTypes = new HashMap<>(); - public ProofTypeJWT getJwt() { - return jwt; + public static ProofTypesSupported parse(KeycloakSession keycloakSession, + List 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 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 signingAlgorithmsSupported; + + @JsonProperty("key_attestations_required") + private KeyAttestationRequired keyAttestationRequired; + + public SupportedProofTypeData() { + } + + public SupportedProofTypeData(List signingAlgorithmsSupported, + KeyAttestationRequired keyAttestationRequired) { + this.signingAlgorithmsSupported = signingAlgorithmsSupported; + this.keyAttestationRequired = keyAttestationRequired; + } + + public List getSigningAlgorithmsSupported() { + return signingAlgorithmsSupported; + } + + public SupportedProofTypeData setSigningAlgorithmsSupported(List 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 keyStorage = new ArrayList<>(); + + @JsonProperty("user_authentication") + private List userAuthentication = new ArrayList<>(); + + public KeyAttestationRequired() { + } + + public KeyAttestationRequired(List keyStorage, List userAuthentication) { + this.keyStorage = keyStorage; + this.userAuthentication = userAuthentication; + } + + public List getKeyStorage() { + return keyStorage; + } + + public KeyAttestationRequired setKeyStorage(List keyStorage) { + this.keyStorage = keyStorage; + return this; + } + + public List getUserAuthentication() { + return userAuthentication; + } + + public KeyAttestationRequired setUserAuthentication(List 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; + } } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java index 297d494a0a5..2fe077a4598 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/SupportedCredentialConfiguration.java @@ -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 cryptographicBindingMethodsSupported; - @JsonProperty(CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY) - private List cryptographicSuitesSupported; - @JsonProperty(CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY) private List 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 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 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 + *

+ * 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 */ @@ -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 getCryptographicSuitesSupported() { - return cryptographicSuitesSupported; - } - - public SupportedCredentialConfiguration setCryptographicSuitesSupported(List cryptographicSuitesSupported) { - this.cryptographicSuitesSupported = Collections.unmodifiableList(cryptographicSuitesSupported); - return this; - } - public List 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 toDotNotation() { - Map 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 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 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); } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeResource.java index ce4aa7e8ff3..2312eda04cc 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeResource.java @@ -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(); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java index 6ef01fdb6a4..8f184ab006d 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java @@ -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"); } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientregistration.ClientRegistrationProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientregistration.ClientRegistrationProviderFactory index eb55687c238..5fa53cb48ed 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientregistration.ClientRegistrationProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientregistration.ClientRegistrationProviderFactory @@ -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 \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java deleted file mode 100644 index 793729c3dae..00000000000 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/OID4VCClientRegistrationProviderTest.java +++ /dev/null @@ -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 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 clientAttributes; - private OID4VCClient oid4VCClient; - - public OID4VCClientRegistrationProviderTest(String name, Map clientAttributes, OID4VCClient oid4VCClient) { - this.clientAttributes = clientAttributes; - this.oid4VCClient = oid4VCClient; - } - - @Test - public void testToClientRepresentation() { - Map 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)); - } - -} diff --git a/services/src/test/java/org/keycloak/protocol/oid4vc/model/ClaimsTest.java b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ClaimsTest.java index a9d012eb2a9..a4e33a01a94 100644 --- a/services/src/test/java/org/keycloak/protocol/oid4vc/model/ClaimsTest.java +++ b/services/src/test/java/org/keycloak/protocol/oid4vc/model/ClaimsTest.java @@ -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()); } } diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/server/DefaultServerConfigWithOid4Vci.java b/test-framework/core/src/main/java/org/keycloak/testframework/server/DefaultServerConfigWithOid4Vci.java new file mode 100644 index 00000000000..a883657b59e --- /dev/null +++ b/test-framework/core/src/main/java/org/keycloak/testframework/server/DefaultServerConfigWithOid4Vci.java @@ -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); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTest.java index 022acd7549d..f5f0e36c649 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTest.java @@ -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(); } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java new file mode 100644 index 00000000000..fd5bccb9d37 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java @@ -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(); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientScopeTests.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientScopeTests.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java index 8d89122ab94..034730f6c47 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/JwtCredentialBuilderTest.java @@ -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 exampleCredentialClaims() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java index 4651cae65bc..30ded9064fc 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/SdJwtCredentialBuilderTest.java @@ -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()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtCredentialSignerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtCredentialSignerTest.java index 33e40e354c1..52342720ca2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtCredentialSignerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtCredentialSignerTest.java @@ -161,6 +161,7 @@ public class JwtCredentialSignerTest extends OID4VCTest { public static void testSignJwtCredential( KeycloakSession session, String signingKeyId, String algorithm, Map 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) ); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDCredentialSignerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDCredentialSignerTest.java index 4dcd031e867..79f748698ef 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDCredentialSignerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDCredentialSignerTest.java @@ -169,6 +169,7 @@ public class LDCredentialSignerTest extends OID4VCTest { KeycloakSession session, String signingKeyId, Map 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 diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index 032018aea49..79f66539a64 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -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 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> 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 f, Consumer> 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 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 attributes = + new HashMap<>(Map.of(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "true", + CredentialScopeModel.EXPIRY_IN_SECONDS, "15")); + BiConsumer 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 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 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 f, + Consumer> 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 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 getCredentialBuilderProviders() { - return List.of(getCredentialBuilderProvider(Format.JWT_VC)); - } - - protected Map 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")); } } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 0831bcef729..bcc4c1f10c6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -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 clientAttributes = new HashMap<>(getTestCredentialDefinitionAttributes()); - Map 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 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 clientAttributes = new HashMap<>(getTestCredentialDefinitionAttributes()); - Map 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 realmAttributes = new HashMap<>(getTestCredentialDefinitionAttributes()); - Map 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 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 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 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 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 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 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 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 expectedDisplayList = fromJsonString(expectedDisplayString, + new SerializableClaimDisplayReference()); + List 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 clientAttributes, - Map 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> + implements Serializable { + } + + /** + * a jackson type-reference that can be used in the run-server-block + */ + public static class SerializableClaimDisplayReference extends TypeReference> + 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()); })); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index 24c3edd42d2..bfd819a7552 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -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 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 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> 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 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 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> 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 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 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()); + })); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java index 9b13e1c68fe..60910424a33 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointTest.java @@ -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 getCredentialBuilderProviders() { - return List.of(getCredentialBuilderProvider(Format.SD_JWT_VC)); - } - - @Override - protected Map getCredentialDefinitionAttributes() { - Map 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 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 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()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java index 386b80ae8f3..51b23a84af4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCTest.java @@ -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 claims) { CredentialSubject credentialSubject = new CredentialSubject(); claims.forEach(credentialSubject::setClaims); @@ -215,19 +225,6 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest { return clientRepresentation; } - public static Map 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 protocolMappers = getProtocolMappers(scopeName, clientId); + public void addProtocolMappersToClientScope(ClientScopeRepresentation clientScope, + List 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 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 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 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 fromJsonString(String representation, Class clazz) + { + try { + return JsonSerialization.readValue(representation, clazz); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static T fromJsonString(String representation, TypeReference 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); + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java index 535481bb569..1453c90a0d1 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java @@ -199,11 +199,12 @@ public class SdJwtCredentialSignerTest extends OID4VCTest { public static void testSignSDJwtCredential(KeycloakSession session, String signingKeyId, String overrideKeyId, String algorithm, Map claims, int decoys, List 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); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/model/acr-values-import-bug.json b/testsuite/integration-arquillian/tests/base/src/test/resources/model/acr-values-import-bug.json index 7801b36ba4e..907a59f2182 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/model/acr-values-import-bug.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/model/acr-values-import-bug.json @@ -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": [] } -} \ No newline at end of file +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json b/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json new file mode 100644 index 00000000000..80415600009 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/oid4vc/test-credential-mappers.json @@ -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\"}]" + } + } + ] +}