mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -03:30
[OID4VCI] Move realm attributes to clientScope and protocol-mappers (#39768)
fixes #39527 Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de> Signed-off-by: Captain-P-Goldfish <captain.p.goldfish@gmx.de>
This commit is contained in:
parent
66ffce6661
commit
f39a37d8d1
@ -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 <<include.in.token.scope, Attribute table: include.in.token.scope>>.
|
||||
- Most of the named attributes above are optional. See below: <<client-scope-attribute-breakdown,Attribute Breakdown>>.
|
||||
- You can determine the appropriate `protocolMapper` names by first creating them through the Web Administration Console and then retrieving their definitions via the Admin REST API.
|
||||
|
||||
==== Attribute Breakdown - ClientScope [[client-scope-attribute-breakdown]]
|
||||
|
||||
[cols="1,1,2", options="header"]
|
||||
|===
|
||||
| Property
|
||||
| Required
|
||||
| Description / Default
|
||||
|
||||
| `name`
|
||||
| required
|
||||
| Name of the client scope.
|
||||
|
||||
| `protocol`
|
||||
| required
|
||||
| Protocol used by the client scope. Use `oid4vc` for OpenID for Verifiable Credential Issuance, which is an OAuth2 extension (like `openid-connect`).
|
||||
|
||||
| `include.in.token.scope`
|
||||
| required
|
||||
| [[include.in.token.scope]] This value MUST be `true`. It ensures that the 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**:
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@ -1439,6 +1439,17 @@ public class RealmCacheSession implements CacheRealmProvider {
|
||||
getClientScopesStream(realm).map(ClientScopeModel::getId).forEach(id -> removeClientScope(realm, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientScopeModel> getClientScopesByProtocol(RealmModel realm, String protocol) {
|
||||
return getClientScopeDelegate().getClientScopesByProtocol(realm, protocol);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientScopeModel> getClientScopesByAttributes(RealmModel realm, Map<String, String> searchMap,
|
||||
boolean useOr) {
|
||||
return getClientScopeDelegate().getClientScopesByAttributes(realm, searchMap, useOr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) {
|
||||
getClientDelegate().addClientScopes(realm, client, clientScopes, defaultScope);
|
||||
@ -1471,13 +1482,16 @@ public class RealmCacheSession implements CacheRealmProvider {
|
||||
return model;
|
||||
}
|
||||
Map<String, ClientScopeModel> assignedScopes = new HashMap<>();
|
||||
|
||||
List<String> acceptedClientProtocols = KeycloakModelUtils.getAcceptedClientScopeProtocols(client);
|
||||
for (String id : query.getClientScopes()) {
|
||||
ClientScopeModel clientScope = session.clientScopes().getClientScopeById(realm, id);
|
||||
if (clientScope == null) {
|
||||
invalidations.add(cacheKey);
|
||||
return getClientDelegate().getClientScopes(realm, client, defaultScopes);
|
||||
}
|
||||
if (clientScope.getProtocol().equals((client.getProtocol() == null) ? "openid-connect" : client.getProtocol())) {
|
||||
|
||||
if (acceptedClientProtocols.contains(clientScope.getProtocol())) {
|
||||
assignedScopes.put(clientScope.getName(), clientScope);
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,9 +38,12 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.hibernate.Session;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.authorization.fgap.AdminPermissionsSchema;
|
||||
@ -1229,17 +1232,98 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
|
||||
realm.getClientScopesStream().map(ClientScopeModel::getId).forEach(id -> this.removeClientScope(realm, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientScopeModel> getClientScopesByProtocol(RealmModel realm, String protocol)
|
||||
{
|
||||
TypedQuery<ClientScopeEntity> query = em.createNamedQuery("getClientScopesByProtocol",
|
||||
ClientScopeEntity.class)
|
||||
.setParameter("realm", realm.getId())
|
||||
.setParameter("protocol", protocol);
|
||||
return query.getResultStream()
|
||||
.map(entity -> new ClientScopeAdapter(realm, em, session, entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* This method filters clientScopes by specific attributes. To do this, it will generate the sql-statement
|
||||
* dynamically based on the given search-parameters.<br />
|
||||
* This method prevents SQL-Injections by adding dynamic parameters into the SQL-statement and resolves them
|
||||
* later by using the JPA query function {@code query.setParameter(dynamicParamName, actualValue)}.<br/>
|
||||
* Here is an example of a generated statement:
|
||||
* <pre>
|
||||
* {@code
|
||||
* SELECT distinct C FROM ClientScopeEntity C
|
||||
* inner join ClientScopeAttributeEntity CA0 on C.id = CA0.clientScope.id
|
||||
* and CA0.name = :a3e8d01932c104f0ab79441d34884bada
|
||||
* WHERE C.realmId = :realmId
|
||||
* and CA0.value = :acedd0bedc7264a2fb524a37814f7aaa1
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* @param realm Realm.
|
||||
* @param searchMap a key-value map that holds the attribute names and values to search for.
|
||||
* @param useOr If the search-params should be combined with or-expressions or and-expressions
|
||||
* @return a stream of clientScopes matching the given criteria
|
||||
*/
|
||||
@Override
|
||||
public Stream<ClientScopeModel> getClientScopesByAttributes(RealmModel realm, Map<String, String> searchMap,
|
||||
boolean useOr) {
|
||||
// we build this specific query dynamically, but we enter the parameters as keys to avoid SQL injections.
|
||||
StringBuilder jpql = new StringBuilder("SELECT distinct C FROM ClientScopeEntity C");
|
||||
List<String> keys = new ArrayList<>(searchMap.keySet());
|
||||
Map<String, String> dynamicParameterNameMap = new HashMap<>();
|
||||
Map<String, String> dynamicParameterValueMap = new HashMap<>();
|
||||
StringBuilder whereClauseExtension = new StringBuilder();
|
||||
// I am using an indexed for-loop because I need the index for dynamic jpql references
|
||||
for (int i = 0; i < keys.size(); i++) {
|
||||
String key = keys.get(i);
|
||||
String value = searchMap.get(key);
|
||||
Supplier<String> generateDynamicParameterName = () -> {
|
||||
return "a" /* dynamic params must start with a letter */
|
||||
+ UUID.randomUUID().toString().replaceAll("-","");
|
||||
};
|
||||
final String dynamicParameterName = generateDynamicParameterName.get();
|
||||
final String dynamicParameterValue = generateDynamicParameterName.get();
|
||||
dynamicParameterNameMap.put(dynamicParameterName, key);
|
||||
dynamicParameterValueMap.put(dynamicParameterValue, value);
|
||||
jpql.append('\n')
|
||||
.append("""
|
||||
inner join ClientScopeAttributeEntity CA%1$s on C.id = CA%1$s.clientScope.id
|
||||
and CA%1$s.name = :%2$s
|
||||
""".stripIndent().strip().formatted(i, dynamicParameterName));
|
||||
whereClauseExtension.append('\n');
|
||||
if (useOr) {
|
||||
whereClauseExtension.append("or");
|
||||
}else {
|
||||
whereClauseExtension.append("and");
|
||||
}
|
||||
whereClauseExtension.append(" CA%1$s.value = :%2$s".formatted(i, dynamicParameterValue));
|
||||
}
|
||||
|
||||
jpql.append('\n').append(" WHERE C.realmId = :realmId").append(whereClauseExtension);
|
||||
logger.debugf("Filter for clientScopes with query:\n%s", jpql);
|
||||
TypedQuery<ClientScopeEntity> query = em.createQuery(jpql.toString(), ClientScopeEntity.class);
|
||||
dynamicParameterNameMap.forEach(query::setParameter);
|
||||
dynamicParameterValueMap.forEach(query::setParameter);
|
||||
return query.setParameter("realmId", realm.getId())
|
||||
.getResultStream().map(scope -> new ClientScopeAdapter(realm, em, session, scope));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope) {
|
||||
// Defaults to openid-connect
|
||||
String clientProtocol = client.getProtocol() == null ? OIDCLoginProtocol.LOGIN_PROTOCOL : client.getProtocol();
|
||||
List<String> acceptedClientProtocols = KeycloakModelUtils.getAcceptedClientScopeProtocols(client);
|
||||
|
||||
Map<String, ClientScopeModel> existingClientScopes = getClientScopes(realm, client, true);
|
||||
existingClientScopes.putAll(getClientScopes(realm, client, false));
|
||||
|
||||
clientScopes.stream()
|
||||
.filter(clientScope -> ! existingClientScopes.containsKey(clientScope.getName()))
|
||||
.filter(clientScope -> Objects.equals(clientScope.getProtocol(), clientProtocol))
|
||||
.filter(clientScope -> !existingClientScopes.containsKey(clientScope.getName()))
|
||||
.filter(clientScope -> {
|
||||
if (clientScope.getProtocol() == null) {
|
||||
// set default protocol if not set. Otherwise, we will get a NullPointer
|
||||
clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||
}
|
||||
return acceptedClientProtocols.contains(clientScope.getProtocol());
|
||||
})
|
||||
.forEach(clientScope -> {
|
||||
ClientScopeClientMappingEntity entity = new ClientScopeClientMappingEntity();
|
||||
entity.setClientScopeId(clientScope.getId());
|
||||
@ -1274,8 +1358,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
|
||||
|
||||
@Override
|
||||
public Map<String, ClientScopeModel> getClientScopes(RealmModel realm, ClientModel client, boolean defaultScope) {
|
||||
// Defaults to openid-connect
|
||||
String clientProtocol = client.getProtocol() == null ? OIDCLoginProtocol.LOGIN_PROTOCOL : client.getProtocol();
|
||||
List<String> acceptedClientProtocols = KeycloakModelUtils.getAcceptedClientScopeProtocols(client);
|
||||
|
||||
TypedQuery<String> query = em.createNamedQuery("clientScopeClientMappingIdsByClient", String.class);
|
||||
query.setParameter("clientId", client.getId());
|
||||
@ -1284,7 +1367,7 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
|
||||
return closing(query.getResultStream())
|
||||
.map(clientScopeId -> session.clientScopes().getClientScopeById(realm, clientScopeId))
|
||||
.filter(Objects::nonNull)
|
||||
.filter(clientScope -> Objects.equals(clientScope.getProtocol(), clientProtocol))
|
||||
.filter(clientScope -> acceptedClientProtocols.contains(clientScope.getProtocol()))
|
||||
.collect(Collectors.toMap(ClientScopeModel::getName, Function.identity()));
|
||||
}
|
||||
@Override
|
||||
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
*/
|
||||
package org.keycloak.storage;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.ClientScopeProvider;
|
||||
@ -74,6 +75,17 @@ public class ClientScopeStorageManager extends AbstractStorageManager<ClientScop
|
||||
localStorage().removeClientScopes(realm);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientScopeModel> getClientScopesByProtocol(RealmModel realm, String protocol) {
|
||||
return localStorage().getClientScopesByProtocol(realm, protocol);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientScopeModel> getClientScopesByAttributes(RealmModel realm, Map<String, String> searchMap,
|
||||
boolean useOr) {
|
||||
return localStorage().getClientScopesByAttributes(realm, searchMap, useOr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,489 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.models.oid4vci;
|
||||
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.keycloak.constants.Oid4VciConstants.OID4VC_PROTOCOL;
|
||||
|
||||
/**
|
||||
* This class acts as delegate for a {@link ClientScopeModel} implementation and adds additional functionality for
|
||||
* OpenId4VC credentials
|
||||
*
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public class CredentialScopeModel implements ClientScopeModel {
|
||||
|
||||
|
||||
public static final String SD_JWT_VISIBLE_CLAIMS_DEFAULT = "id,iat,nbf,exp,jti";
|
||||
public static final int SD_JWT_DECOYS_DEFAULT = 10;
|
||||
public static final String FORMAT_DEFAULT = "vc+sd-jwt";
|
||||
public static final String HASH_ALGORITHM_DEFAULT = "SHA-256";
|
||||
public static final String TOKEN_TYPE_DEFAULT = "JWS";
|
||||
public static final int EXPIRY_IN_SECONDS_DEFAULT = 31536000;
|
||||
public static final String CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT = "jwk";
|
||||
|
||||
/**
|
||||
* the credential configuration id as provided in the metadata endpoint
|
||||
*/
|
||||
public static final String ISSUER_DID = "vc.issuer_did";
|
||||
public static final String CONFIGURATION_ID = "vc.credential_configuration_id";
|
||||
public static final String CREDENTIAL_IDENTIFIER = "vc.credential_identifier";
|
||||
public static final String FORMAT = "vc.format";
|
||||
public static final String EXPIRY_IN_SECONDS = "vc.expiry_in_seconds";
|
||||
public static final String VCT = "vc.verifiable_credential_type";
|
||||
|
||||
/**
|
||||
* the value that is added into the "types"-attribute of a verifiable credential
|
||||
*/
|
||||
public static final String TYPES = "vc.supported_credential_types";
|
||||
|
||||
/**
|
||||
* the value that is entered into the "@contexts"-attribute of a verifiable credential
|
||||
*/
|
||||
public static final String CONTEXTS = "vc.credential_contexts";
|
||||
|
||||
/**
|
||||
* if the credential is only meant for specific signing algorithms the global default list can be overridden here.
|
||||
* The global default list is retrieved from the available keys in the realm.
|
||||
*/
|
||||
public static final String SIGNING_ALG_VALUES_SUPPORTED = "vc.proof_signing_alg_values_supported";
|
||||
|
||||
/**
|
||||
* if the credential is only meant for specific cryptographic binding algorithms the global default list can be
|
||||
* overridden here. The global default list is retrieved from the available keys in the realm.
|
||||
*/
|
||||
public static final String CRYPTOGRAPHIC_BINDING_METHODS = "vc.cryptographic_binding_methods_supported";
|
||||
|
||||
/**
|
||||
* an optional configuration that can be used to select a specific key for signing the credential
|
||||
*/
|
||||
public static final String SIGNING_KEY_ID = "vc.signing_key_id";
|
||||
|
||||
/**
|
||||
* an optional attribute for the metadata endpoint
|
||||
*/
|
||||
public static final String VC_DISPLAY = "vc.display";
|
||||
|
||||
/**
|
||||
* this attribute holds a customizable value for the number of decoys to use in a SD-JWT credential
|
||||
*/
|
||||
public static final String SD_JWT_NUMBER_OF_DECOYS = "vc.sd_jwt.number_of_decoys";
|
||||
|
||||
/**
|
||||
* an optional attribute that tells us which attributes should be added into the SD-JWT body.
|
||||
*/
|
||||
public static final String SD_JWT_VISIBLE_CLAIMS = "vc.credential_build_config.sd_jwt.visible_claims";
|
||||
|
||||
/**
|
||||
* an optional configuration that can be used to select a specific hash algorithm
|
||||
*/
|
||||
public static final String HASH_ALGORITHM = "vc.credential_build_config.hash_algorithm";
|
||||
|
||||
/**
|
||||
* this attribute holds the 'typ' value that will be added into the JWS header of the credential.
|
||||
*/
|
||||
public static final String TOKEN_JWS_TYPE = "vc.credential_build_config.token_jws_type";
|
||||
|
||||
/**
|
||||
* this configuration property can be used to enforce specific claims to be included in the metadata, if they
|
||||
* would normally not and vice versa
|
||||
*/
|
||||
public static final String INCLUDE_IN_METADATA = "vc.include_in_metadata";
|
||||
|
||||
|
||||
/**
|
||||
* the actual object that is represented by this scope
|
||||
*/
|
||||
private final ClientScopeModel clientScope;
|
||||
|
||||
public CredentialScopeModel(ClientScopeModel clientScope) {
|
||||
this.clientScope = clientScope;
|
||||
assert OID4VC_PROTOCOL.equals(clientScope.getProtocol());
|
||||
}
|
||||
|
||||
public String getIssuerDid() {
|
||||
return clientScope.getAttribute(ISSUER_DID);
|
||||
}
|
||||
|
||||
public void setIssuerDid(String issuerDid) {
|
||||
clientScope.setAttribute(ISSUER_DID, issuerDid);
|
||||
}
|
||||
|
||||
public String getScope() {
|
||||
return clientScope.getName();
|
||||
}
|
||||
|
||||
public String getCredentialConfigurationId() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(CONFIGURATION_ID)).orElse(getName());
|
||||
}
|
||||
|
||||
public void setCredentialConfigurationId(String credentialConfigurationId) {
|
||||
clientScope.setAttribute(CONFIGURATION_ID, Optional.ofNullable(credentialConfigurationId).orElse(getName()));
|
||||
}
|
||||
|
||||
public String getCredentialIdentifier() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(CREDENTIAL_IDENTIFIER)).orElse(getName());
|
||||
}
|
||||
|
||||
public void setCredentialIdentifier(String credentialIdentifier) {
|
||||
clientScope.setAttribute(CREDENTIAL_IDENTIFIER, Optional.ofNullable(credentialIdentifier).orElse(getName()));
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(FORMAT)).orElse(FORMAT_DEFAULT);
|
||||
}
|
||||
|
||||
public void setFormat(String credentialFormat) {
|
||||
clientScope.setAttribute(FORMAT, Optional.ofNullable(credentialFormat).orElse(FORMAT_DEFAULT));
|
||||
}
|
||||
|
||||
public Integer getExpiryInSeconds() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(EXPIRY_IN_SECONDS)).map(Integer::parseInt)
|
||||
.orElse(EXPIRY_IN_SECONDS_DEFAULT);
|
||||
}
|
||||
|
||||
public void setExpiryInSeconds(Integer expiryInSeconds) {
|
||||
clientScope.setAttribute(EXPIRY_IN_SECONDS,
|
||||
Optional.ofNullable(expiryInSeconds).map(String::valueOf)
|
||||
.orElse(String.valueOf(EXPIRY_IN_SECONDS_DEFAULT)));
|
||||
}
|
||||
|
||||
public int getSdJwtNumberOfDecoys() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(SD_JWT_NUMBER_OF_DECOYS)).map(Integer::parseInt)
|
||||
.orElse(SD_JWT_DECOYS_DEFAULT);
|
||||
}
|
||||
|
||||
public void setSdJwtNumberOfDecoys(Integer sdJwtNumberOfDecoys) {
|
||||
clientScope.setAttribute(SD_JWT_NUMBER_OF_DECOYS,
|
||||
Optional.ofNullable(sdJwtNumberOfDecoys).map(String::valueOf)
|
||||
.orElse(String.valueOf(SD_JWT_DECOYS_DEFAULT)));
|
||||
}
|
||||
|
||||
public String getVct() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(VCT)).orElse(getName());
|
||||
}
|
||||
|
||||
public void setVct(String vct) {
|
||||
clientScope.setAttribute(VCT, Optional.ofNullable(vct).orElse(getName()));
|
||||
}
|
||||
|
||||
public String getTokenJwsType() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(TOKEN_JWS_TYPE)).orElse(TOKEN_TYPE_DEFAULT);
|
||||
}
|
||||
|
||||
public void setTokenJwsType(String tokenJwsType) {
|
||||
clientScope.setAttribute(TOKEN_JWS_TYPE, Optional.ofNullable(tokenJwsType).orElse(TOKEN_TYPE_DEFAULT));
|
||||
}
|
||||
|
||||
public String getSigningKeyId() {
|
||||
return clientScope.getAttribute(SIGNING_KEY_ID);
|
||||
}
|
||||
|
||||
public void setSigningKeyId(String signingKeyId) {
|
||||
clientScope.setAttribute(SIGNING_KEY_ID, signingKeyId);
|
||||
}
|
||||
|
||||
public String getHashAlgorithm() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(HASH_ALGORITHM)).orElse(HASH_ALGORITHM_DEFAULT);
|
||||
}
|
||||
|
||||
public void setHashAlgorithm(String hashAlgorithm) {
|
||||
clientScope.setAttribute(HASH_ALGORITHM, hashAlgorithm);
|
||||
}
|
||||
|
||||
public List<String> getSupportedCredentialTypes() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(TYPES))
|
||||
.map(s -> s.split(","))
|
||||
.map(Arrays::asList)
|
||||
.orElse(Collections.singletonList(getName()));
|
||||
}
|
||||
|
||||
public void setSupportedCredentialTypes(String supportedCredentialTypes) {
|
||||
clientScope.setAttribute(TYPES, Optional.ofNullable(supportedCredentialTypes).orElse(getName()));
|
||||
}
|
||||
|
||||
public void setSupportedCredentialTypes(List<String> supportedCredentialTypes) {
|
||||
clientScope.setAttribute(TYPES, String.join(",", supportedCredentialTypes));
|
||||
}
|
||||
|
||||
public List<String> getVcContexts() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(CONTEXTS))
|
||||
.map(s -> s.split(","))
|
||||
.map(Arrays::asList)
|
||||
.orElse(Collections.singletonList(getName()));
|
||||
}
|
||||
|
||||
public void setVcContexts(String vcContexts) {
|
||||
clientScope.setAttribute(CONTEXTS, Optional.ofNullable(vcContexts).orElse(getName()));
|
||||
}
|
||||
|
||||
public void setVcContexts(List<String> vcContexts) {
|
||||
clientScope.setAttribute(CONTEXTS, String.join(",", vcContexts));
|
||||
}
|
||||
|
||||
public List<String> getSigningAlgsSupported() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(SIGNING_ALG_VALUES_SUPPORTED))
|
||||
.map(s -> s.split(","))
|
||||
.map(Arrays::asList)
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
|
||||
public void setSigningAlgsSupported(String signingAlgsSupported) {
|
||||
clientScope.setAttribute(SIGNING_ALG_VALUES_SUPPORTED, signingAlgsSupported);
|
||||
}
|
||||
|
||||
public void setSigningAlgsSupported(List<String> signingAlgsSupported) {
|
||||
clientScope.setAttribute(SIGNING_ALG_VALUES_SUPPORTED,
|
||||
String.join(",", signingAlgsSupported));
|
||||
}
|
||||
|
||||
public List<String> getCryptographicBindingMethods() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(CRYPTOGRAPHIC_BINDING_METHODS))
|
||||
.map(s -> s.split(","))
|
||||
.map(Arrays::asList)
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
|
||||
public void setCryptographicBindingMethods(String cryptographicBindingMethods) {
|
||||
clientScope.setAttribute(CRYPTOGRAPHIC_BINDING_METHODS, cryptographicBindingMethods);
|
||||
}
|
||||
|
||||
public void setCryptographicBindingMethods(List<String> cryptographicBindingMethods) {
|
||||
clientScope.setAttribute(CRYPTOGRAPHIC_BINDING_METHODS,
|
||||
String.join(",", cryptographicBindingMethods));
|
||||
}
|
||||
|
||||
public List<String> getSdJwtVisibleClaims() {
|
||||
return Optional.ofNullable(clientScope.getAttribute(SD_JWT_VISIBLE_CLAIMS))
|
||||
.map(s -> s.split(","))
|
||||
.map(Arrays::asList)
|
||||
.orElse(List.of(SD_JWT_VISIBLE_CLAIMS_DEFAULT.split(",")));
|
||||
}
|
||||
|
||||
public void setSdJwtVisibleClaims(String sdJwtVisibleClaims) {
|
||||
clientScope.setAttribute(SD_JWT_VISIBLE_CLAIMS, Optional.ofNullable(sdJwtVisibleClaims)
|
||||
.orElse(SD_JWT_VISIBLE_CLAIMS_DEFAULT));
|
||||
}
|
||||
|
||||
public void setSdJwtVisibleClaims(List<String> sdJwtVisibleClaims) {
|
||||
clientScope.setAttribute(SD_JWT_VISIBLE_CLAIMS,
|
||||
String.join(",", sdJwtVisibleClaims));
|
||||
}
|
||||
|
||||
public String getVcDisplay() {
|
||||
return clientScope.getAttribute(VC_DISPLAY);
|
||||
}
|
||||
|
||||
public void setVcDisplay(String vcDisplay) {
|
||||
clientScope.setAttribute(VC_DISPLAY, vcDisplay);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return clientScope.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return clientScope.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setName(String name) {
|
||||
clientScope.setName(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RealmModel getRealm() {
|
||||
return clientScope.getRealm();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return clientScope.getDescription();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDescription(String description) {
|
||||
clientScope.setDescription(description);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProtocol() {
|
||||
return clientScope.getProtocol();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setProtocol(String protocol) {
|
||||
clientScope.setProtocol(protocol);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAttribute(String name, String value) {
|
||||
clientScope.setAttribute(name, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAttribute(String name) {
|
||||
clientScope.removeAttribute(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAttribute(String name) {
|
||||
return clientScope.getAttribute(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getAttributes() {
|
||||
return clientScope.getAttributes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDisplayOnConsentScreen() {
|
||||
return clientScope.isDisplayOnConsentScreen();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDisplayOnConsentScreen(boolean displayOnConsentScreen) {
|
||||
clientScope.setDisplayOnConsentScreen(displayOnConsentScreen);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConsentScreenText() {
|
||||
return clientScope.getConsentScreenText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConsentScreenText(String consentScreenText) {
|
||||
clientScope.setConsentScreenText(consentScreenText);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGuiOrder() {
|
||||
return clientScope.getGuiOrder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setGuiOrder(String guiOrder) {
|
||||
clientScope.setGuiOrder(guiOrder);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isIncludeInTokenScope() {
|
||||
return clientScope.isIncludeInTokenScope();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setIncludeInTokenScope(boolean includeInTokenScope) {
|
||||
clientScope.setIncludeInTokenScope(includeInTokenScope);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDynamicScope() {
|
||||
return clientScope.isDynamicScope();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setIsDynamicScope(boolean isDynamicScope) {
|
||||
clientScope.setIsDynamicScope(isDynamicScope);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDynamicScopeRegexp() {
|
||||
return clientScope.getDynamicScopeRegexp();
|
||||
}
|
||||
|
||||
public Stream<Oid4vcProtocolMapperModel> getOid4vcProtocolMappersStream() {
|
||||
return clientScope.getProtocolMappersStream().filter(pm -> {
|
||||
return Oid4VciConstants.OID4VC_PROTOCOL.equals(pm.getProtocol());
|
||||
}).map(Oid4vcProtocolMapperModel::new);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ProtocolMapperModel> getProtocolMappersStream() {
|
||||
return clientScope.getProtocolMappersStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) {
|
||||
return clientScope.addProtocolMapper(model);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeProtocolMapper(ProtocolMapperModel mapping) {
|
||||
clientScope.removeProtocolMapper(mapping);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateProtocolMapper(ProtocolMapperModel mapping) {
|
||||
clientScope.updateProtocolMapper(mapping);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProtocolMapperModel getProtocolMapperById(String id) {
|
||||
return clientScope.getProtocolMapperById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) {
|
||||
return clientScope.getProtocolMapperByName(protocol, name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RoleModel> getScopeMappingsStream() {
|
||||
return clientScope.getScopeMappingsStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RoleModel> getRealmScopeMappingsStream() {
|
||||
return clientScope.getRealmScopeMappingsStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addScopeMapping(RoleModel role) {
|
||||
clientScope.addScopeMapping(role);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteScopeMapping(RoleModel role) {
|
||||
clientScope.deleteScopeMapping(role);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasDirectScope(RoleModel role) {
|
||||
return clientScope.hasDirectScope(role);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasScope(RoleModel role) {
|
||||
return clientScope.hasScope(role);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.models.oid4vci;
|
||||
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* This class acts as delegate for a {@link ProtocolMapperModel} implementation and adds additional functionality for
|
||||
* OpenId4VC credentials
|
||||
*
|
||||
* @author Pascal Knüppel
|
||||
*/
|
||||
public class Oid4vcProtocolMapperModel extends ProtocolMapperModel {
|
||||
|
||||
public static final String PATH = "claim.name"; // TODO discuss if we can rename this.
|
||||
// Renaming it would break existing installations
|
||||
public static final String MANDATORY = "vc.mandatory";
|
||||
public static final String DISPLAY = "vc.display";
|
||||
|
||||
private final ProtocolMapperModel protocolMapper;
|
||||
|
||||
public Oid4vcProtocolMapperModel(ProtocolMapperModel protocolMapper) {
|
||||
this.protocolMapper = protocolMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the path of the attribute where it can be extracted
|
||||
*/
|
||||
public List<String> getPath()
|
||||
{
|
||||
return Optional.ofNullable(protocolMapper.getConfig().get(PATH))
|
||||
.map(s -> s.split("\\."))
|
||||
.map(Arrays::asList)
|
||||
.orElse(Collections.emptyList());
|
||||
}
|
||||
|
||||
public void setPath(List<String> path) {
|
||||
protocolMapper.getConfig().put(PATH, Optional.ofNullable(path)
|
||||
.map(l -> String.join(".", l))
|
||||
.orElse(null));
|
||||
}
|
||||
|
||||
public boolean isMandatory()
|
||||
{
|
||||
return Optional.ofNullable(protocolMapper.getConfig().get(MANDATORY)).map(Boolean::valueOf).orElse(false);
|
||||
}
|
||||
|
||||
public void setMandatory(Boolean mandatory)
|
||||
{
|
||||
if (mandatory == null) {
|
||||
protocolMapper.getConfig().remove(MANDATORY);
|
||||
}else {
|
||||
protocolMapper.getConfig().put(MANDATORY, String.valueOf(mandatory));
|
||||
}
|
||||
}
|
||||
|
||||
public String getDisplay()
|
||||
{
|
||||
return protocolMapper.getConfig().get(DISPLAY);
|
||||
}
|
||||
|
||||
public void setDisplay(String display)
|
||||
{
|
||||
protocolMapper.getConfig().put(DISPLAY, display);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return protocolMapper.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setId(String id) {
|
||||
protocolMapper.setId(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return protocolMapper.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setName(String name) {
|
||||
protocolMapper.setName(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProtocol() {
|
||||
return protocolMapper.getProtocol();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setProtocol(String protocol) {
|
||||
protocolMapper.setProtocol(protocol);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProtocolMapper() {
|
||||
return protocolMapper.getProtocolMapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setProtocolMapper(String protocolMapper) {
|
||||
this.protocolMapper.setProtocolMapper(protocolMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getConfig() {
|
||||
return protocolMapper.getConfig();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConfig(Map<String, String> config) {
|
||||
protocolMapper.setConfig(config);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
return protocolMapper.equals(obj);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return protocolMapper.hashCode();
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,7 @@ import org.keycloak.common.util.PemUtils;
|
||||
import org.keycloak.common.util.SecretGenerator;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.crypto.Algorithm;
|
||||
import org.keycloak.deployment.DeployedConfigurationsManager;
|
||||
import org.keycloak.models.AccountRoles;
|
||||
@ -1228,4 +1229,17 @@ public final class KeycloakModelUtils {
|
||||
context.setRealm(currentRealm);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the list of protocols accepted for the given client.
|
||||
*/
|
||||
public static List<String> getAcceptedClientScopeProtocols(ClientModel client) {
|
||||
List<String> acceptedClientProtocols;
|
||||
if (client.getProtocol() == null || "openid-connect".equals(client.getProtocol())) {
|
||||
acceptedClientProtocols = List.of("openid-connect", Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
}else {
|
||||
acceptedClientProtocols = List.of(client.getProtocol());
|
||||
}
|
||||
return acceptedClientProtocols;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -24,6 +24,8 @@ import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@ -59,4 +61,8 @@ public interface LoginProtocolFactory extends ProviderFactory<LoginProtocol> {
|
||||
*/
|
||||
void setupClientDefaults(ClientRepresentation rep, ClientModel newClient);
|
||||
|
||||
/**
|
||||
* Add default values to {@link ClientScopeRepresentation}s that refer to the specific login-protocol
|
||||
*/
|
||||
void addClientScopeDefaults(ClientScopeRepresentation clientModel);
|
||||
}
|
||||
|
||||
@ -19,10 +19,8 @@ package org.keycloak.models;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.storage.client.ClientLookupProvider;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
@ -105,19 +103,19 @@ public interface ClientProvider extends ClientLookupProvider, Provider {
|
||||
void removeClients(RealmModel realm);
|
||||
|
||||
/**
|
||||
* Assign clientScopes to the client. Add as default scopes (if parameter 'defaultScope' is true)
|
||||
* Assign clientScopes to the client. Add as default scopes (if parameter 'defaultScope' is true)
|
||||
* or optional scopes (if parameter 'defaultScope' is false)
|
||||
*
|
||||
*
|
||||
* @param realm Realm.
|
||||
* @param client Client.
|
||||
* @param clientScopes to be assigned
|
||||
* @param defaultScope if true the scopes are assigned as default, or optional in case of false
|
||||
* @param defaultScope if true the scopes are assigned as default, or optional in case of false
|
||||
*/
|
||||
void addClientScopes(RealmModel realm, ClientModel client, Set<ClientScopeModel> clientScopes, boolean defaultScope);
|
||||
|
||||
/**
|
||||
* Unassign clientScope from the client.
|
||||
*
|
||||
* Unassign clientScope from the client.
|
||||
*
|
||||
* @param realm Realm.
|
||||
* @param client Client.
|
||||
* @param clientScope to be unassigned
|
||||
@ -144,4 +142,5 @@ public interface ClientProvider extends ClientLookupProvider, Provider {
|
||||
*/
|
||||
@Deprecated
|
||||
Map<ClientModel, Set<String>> getAllRedirectUrisOfEnabledClients(RealmModel realm);
|
||||
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
*/
|
||||
package org.keycloak.models;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.storage.clientscope.ClientScopeLookupProvider;
|
||||
@ -34,7 +35,7 @@ public interface ClientScopeProvider extends Provider, ClientScopeLookupProvider
|
||||
|
||||
/**
|
||||
* Creates new client scope with given {@code name} to the given realm.
|
||||
* Spaces in {@code name} will be replaced by underscore so that scope name
|
||||
* Spaces in {@code name} will be replaced by underscore so that scope name
|
||||
* can be used as value of scope parameter. The internal ID will be created automatically.
|
||||
* @param realm Realm owning this client scope.
|
||||
* @param name String name of the client scope.
|
||||
@ -47,7 +48,7 @@ public interface ClientScopeProvider extends Provider, ClientScopeLookupProvider
|
||||
|
||||
/**
|
||||
* Creates new client scope with given internal ID and {@code name} to the given realm.
|
||||
* Spaces in {@code name} will be replaced by underscore so that scope name
|
||||
* Spaces in {@code name} will be replaced by underscore so that scope name
|
||||
* can be used as value of scope parameter.
|
||||
* @param realm Realm owning this client scope.
|
||||
* @param id Internal ID of the client scope or {@code null} if one is to be created by the underlying store
|
||||
@ -73,4 +74,21 @@ public interface ClientScopeProvider extends Provider, ClientScopeLookupProvider
|
||||
* @param realm Realm.
|
||||
*/
|
||||
void removeClientScopes(RealmModel realm);
|
||||
|
||||
/**
|
||||
* Must retrieve all client scopes of the given realm that are use the given protocol.
|
||||
*
|
||||
* @param realm the realm to retrieve the client scopes from.
|
||||
* @param protocol the protocol expected from the clientScope
|
||||
*/
|
||||
Stream<ClientScopeModel> getClientScopesByProtocol(RealmModel realm, String protocol);
|
||||
|
||||
/**
|
||||
* Allows us to filter for scopes by specific attributes
|
||||
*
|
||||
* @param realm Realm.
|
||||
* @param searchMap a key-value map that holds the attribute names and values to search for.
|
||||
* @param useOr If the search-params should be combined with or-expressions or and-expressions
|
||||
*/
|
||||
Stream<ClientScopeModel> getClientScopesByAttributes(RealmModel realm, Map<String, String> searchMap, boolean useOr);
|
||||
}
|
||||
|
||||
@ -1,169 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc;
|
||||
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider.VC_KEY;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.services.ErrorResponseException;
|
||||
import org.keycloak.services.clientregistration.AbstractClientRegistrationProvider;
|
||||
import org.keycloak.services.clientregistration.DefaultClientRegistrationContext;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
||||
/**
|
||||
* Provides the client-registration functionality for OID4VC-clients.
|
||||
*
|
||||
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
||||
*/
|
||||
public class OID4VCClientRegistrationProvider extends AbstractClientRegistrationProvider {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(OID4VCClientRegistrationProvider.class);
|
||||
|
||||
public OID4VCClientRegistrationProvider(KeycloakSession session) {
|
||||
super(session);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Response createOID4VCClient(OID4VCClient client) {
|
||||
ClientRepresentation clientRepresentation = toClientRepresentation(client);
|
||||
validate(clientRepresentation);
|
||||
|
||||
ClientRepresentation cr = create(
|
||||
new DefaultClientRegistrationContext(session, clientRepresentation, this));
|
||||
URI uri = session.getContext().getUri().getAbsolutePathBuilder().path(cr.getClientId()).build();
|
||||
return Response.created(uri).entity(cr).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{clientId}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Response updateOID4VCClient(@PathParam("clientId") String clientDid, OID4VCClient client) {
|
||||
client.setClientDid(clientDid);
|
||||
ClientRepresentation clientRepresentation = toClientRepresentation(client);
|
||||
validate(clientRepresentation);
|
||||
clientRepresentation = update(clientDid,
|
||||
new DefaultClientRegistrationContext(session, clientRepresentation, this));
|
||||
return Response.ok(clientRepresentation).build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{clientId}")
|
||||
public Response deleteOID4VCClient(@PathParam("clientId") String clientDid) {
|
||||
delete(clientDid);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the clientRepresentation to fulfill the requirement of an OID4VC client
|
||||
*/
|
||||
public static void validate(ClientRepresentation client) {
|
||||
String did = client.getClientId();
|
||||
if (did == null) {
|
||||
throw new ErrorResponseException("no_did", "A client did needs to be configured for OID4VC clients",
|
||||
Response.Status.BAD_REQUEST);
|
||||
}
|
||||
if (!did.startsWith("did:")) {
|
||||
throw new ErrorResponseException("invalid_did", "The client id is not a did.",
|
||||
Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an incoming {@link OID4VCClient} into a keycloak native {@link ClientRepresentation}.
|
||||
*
|
||||
* @param oid4VCClient pojo, containing the oid4vc client parameters
|
||||
* @return a clientRepresentation
|
||||
*/
|
||||
protected static ClientRepresentation toClientRepresentation(OID4VCClient oid4VCClient) {
|
||||
ClientRepresentation clientRepresentation = new ClientRepresentation();
|
||||
clientRepresentation.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
|
||||
|
||||
clientRepresentation.setId(Optional.ofNullable(oid4VCClient.getId()).orElse(UUID.randomUUID().toString()));
|
||||
clientRepresentation.setClientId(oid4VCClient.getClientDid());
|
||||
// only add non-null parameters
|
||||
Optional.ofNullable(oid4VCClient.getDescription()).ifPresent(clientRepresentation::setDescription);
|
||||
Optional.ofNullable(oid4VCClient.getName()).ifPresent(clientRepresentation::setName);
|
||||
|
||||
|
||||
Map<String, String> clientAttributes = oid4VCClient.getSupportedVCTypes()
|
||||
.stream()
|
||||
.map(SupportedCredentialConfiguration::toDotNotation)
|
||||
.flatMap(dotNotated -> dotNotated.entrySet().stream())
|
||||
.collect(Collectors.toMap(entry -> VC_KEY + "." + entry.getKey(), Map.Entry::getValue, (e1, e2) -> e1));
|
||||
|
||||
if (!clientAttributes.isEmpty()) {
|
||||
clientRepresentation.setAttributes(clientAttributes);
|
||||
}
|
||||
|
||||
|
||||
LOGGER.debugf("Generated client representation {}.", clientRepresentation);
|
||||
return clientRepresentation;
|
||||
}
|
||||
|
||||
public static OID4VCClient fromClientAttributes(String clientId, Map<String, String> clientAttributes) {
|
||||
|
||||
OID4VCClient oid4VCClient = new OID4VCClient()
|
||||
.setClientDid(clientId);
|
||||
|
||||
Set<String> supportedCredentialIds = new HashSet<>();
|
||||
Map<String, String> attributes = new HashMap<>();
|
||||
clientAttributes
|
||||
.entrySet()
|
||||
.forEach(entry -> {
|
||||
if (!entry.getKey().startsWith(VC_KEY)) {
|
||||
return;
|
||||
}
|
||||
String key = entry.getKey().substring((VC_KEY + ".").length());
|
||||
supportedCredentialIds.add(key.split("\\.")[0]);
|
||||
attributes.put(key, entry.getValue());
|
||||
});
|
||||
|
||||
|
||||
List<SupportedCredentialConfiguration> supportedCredentialConfigurations = supportedCredentialIds
|
||||
.stream()
|
||||
.map(id -> SupportedCredentialConfiguration.fromDotNotation(id, attributes))
|
||||
.toList();
|
||||
|
||||
return oid4VCClient.setSupportedVCTypes(supportedCredentialConfigurations);
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.services.clientregistration.ClientRegistrationProvider;
|
||||
import org.keycloak.services.clientregistration.ClientRegistrationProviderFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Implementation of the {@link ClientRegistrationProviderFactory} to integrate the OID4VC protocols with
|
||||
* Keycloak's client-registration.
|
||||
* <p>
|
||||
* {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html}
|
||||
*
|
||||
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
||||
*/
|
||||
public class OID4VCClientRegistrationProviderFactory implements ClientRegistrationProviderFactory, OID4VCEnvironmentProviderFactory {
|
||||
|
||||
@Override
|
||||
public ClientRegistrationProvider create(KeycloakSession session) {
|
||||
return new OID4VCClientRegistrationProvider(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
// no config required
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
// nothing to do post init
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// no resources to close
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return OID4VCLoginProtocolFactory.PROTOCOL_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigMetadata() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -37,23 +37,20 @@ import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.util.SecretGenerator;
|
||||
import org.keycloak.component.ComponentFactory;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.ProtocolMapperContainerModel;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProvider;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBody;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
|
||||
@ -67,7 +64,6 @@ import org.keycloak.protocol.oid4vc.model.ErrorResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.ErrorType;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.NonceResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
|
||||
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
|
||||
@ -78,7 +74,6 @@ import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||
import org.keycloak.protocol.oidc.utils.OAuth2Code;
|
||||
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.cors.Cors;
|
||||
@ -99,12 +94,6 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.keycloak.protocol.oid4vc.model.Format.JWT_VC;
|
||||
import static org.keycloak.protocol.oid4vc.model.Format.LDP_VC;
|
||||
import static org.keycloak.protocol.oid4vc.model.Format.SD_JWT_VC;
|
||||
import static org.keycloak.protocol.oid4vc.model.Format.SUPPORTED_FORMATS;
|
||||
|
||||
/**
|
||||
* Provides the (REST-)endpoints required for the OID4VCI protocol.
|
||||
@ -146,19 +135,16 @@ public class OID4VCIssuerEndpoint {
|
||||
*/
|
||||
private final Map<String, CredentialBuilder> credentialBuilders;
|
||||
|
||||
private final boolean isIgnoreScopeCheck;
|
||||
|
||||
public OID4VCIssuerEndpoint(KeycloakSession session,
|
||||
Map<String, CredentialBuilder> credentialBuilders,
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator,
|
||||
TimeProvider timeProvider, int preAuthorizedCodeLifeSpan,
|
||||
boolean isIgnoreScopeCheck) {
|
||||
TimeProvider timeProvider,
|
||||
int preAuthorizedCodeLifeSpan) {
|
||||
this.session = session;
|
||||
this.bearerTokenAuthenticator = authenticator;
|
||||
this.timeProvider = timeProvider;
|
||||
this.credentialBuilders = credentialBuilders;
|
||||
this.preAuthorizedCodeLifeSpan = preAuthorizedCodeLifeSpan;
|
||||
this.isIgnoreScopeCheck = isIgnoreScopeCheck;
|
||||
}
|
||||
|
||||
public OID4VCIssuerEndpoint(KeycloakSession keycloakSession) {
|
||||
@ -172,7 +158,6 @@ public class OID4VCIssuerEndpoint {
|
||||
this.preAuthorizedCodeLifeSpan = Optional.ofNullable(realm.getAttribute(CODE_LIFESPAN_REALM_ATTRIBUTE_KEY))
|
||||
.map(Integer::valueOf)
|
||||
.orElse(DEFAULT_CODE_LIFESPAN_S);
|
||||
this.isIgnoreScopeCheck = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -182,25 +167,11 @@ public class OID4VCIssuerEndpoint {
|
||||
*/
|
||||
private Map<String, CredentialBuilder> loadCredentialBuilders(KeycloakSession keycloakSession) {
|
||||
KeycloakSessionFactory keycloakSessionFactory = keycloakSession.getKeycloakSessionFactory();
|
||||
RealmModel realm = keycloakSession.getContext().getRealm();
|
||||
Stream<ComponentModel> componentModels = realm.getComponentsStream(
|
||||
realm.getId(), CredentialBuilder.class.getName());
|
||||
|
||||
return componentModels.map(componentModel -> {
|
||||
ProviderFactory<CredentialBuilder> providerFactory = keycloakSessionFactory
|
||||
.getProviderFactory(CredentialBuilder.class, componentModel.getProviderId());
|
||||
|
||||
if (!(providerFactory instanceof ComponentFactory<?, ?>)) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Component %s is unexpectedly not a ComponentFactory",
|
||||
componentModel.getProviderId()
|
||||
));
|
||||
}
|
||||
|
||||
var componentFactory = (ComponentFactory<CredentialBuilder, CredentialBuilder>) providerFactory;
|
||||
return componentFactory.create(keycloakSession, componentModel);
|
||||
})
|
||||
.collect(Collectors.toMap(CredentialBuilder::getSupportedFormat, component -> component));
|
||||
return keycloakSessionFactory.getProviderFactoriesStream(CredentialBuilder.class)
|
||||
.map(factory -> (CredentialBuilderFactory) factory)
|
||||
.map(factory -> factory.create(keycloakSession, null))
|
||||
.collect(Collectors.toMap(CredentialBuilder::getSupportedFormat,
|
||||
credentialBuilder -> credentialBuilder));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -323,36 +294,31 @@ public class OID4VCIssuerEndpoint {
|
||||
.build();
|
||||
}
|
||||
|
||||
private void checkScope(CredentialRequest credentialRequestVO) {
|
||||
private void checkScope(CredentialScopeModel requestedCredential) {
|
||||
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
|
||||
String vcIssuanceFlow = clientSession.getNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
|
||||
|
||||
if (vcIssuanceFlow == null || !vcIssuanceFlow.equals(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)) {
|
||||
// Authorization Code Flow
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
|
||||
|
||||
String scope = realm.getAttribute("vc." + credentialIdentifier + ".scope");
|
||||
|
||||
AccessToken accessToken = bearerTokenAuthenticator.authenticate().getToken();
|
||||
if (scope == null || Arrays.stream(accessToken.getScope().split(" "))
|
||||
.noneMatch(tokenScope -> tokenScope.equals(scope))) {
|
||||
LOGGER.debugf("Scope check failure: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
|
||||
credentialIdentifier, scope, accessToken.getScope());
|
||||
if (Arrays.stream(accessToken.getScope().split(" "))
|
||||
.noneMatch(tokenScope -> tokenScope.equals(requestedCredential.getScope()))) {
|
||||
LOGGER.debugf("Scope check failure: required scope = %s, " +
|
||||
"scope in access token = %s.",
|
||||
requestedCredential.getName(), accessToken.getScope());
|
||||
throw new CorsErrorResponseException(cors,
|
||||
ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(),
|
||||
"Scope check failure",
|
||||
Response.Status.BAD_REQUEST);
|
||||
} else {
|
||||
LOGGER.debugf("Scope check success: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
|
||||
credentialIdentifier, scope, accessToken.getScope());
|
||||
LOGGER.debugf("Scope check success: required scope = %s, #" +
|
||||
"scope in access token = %s.",
|
||||
requestedCredential.getScope(), accessToken.getScope());
|
||||
}
|
||||
} else {
|
||||
clientSession.removeNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a verifiable credential
|
||||
*/
|
||||
@ -369,96 +335,40 @@ public class OID4VCIssuerEndpoint {
|
||||
// do first to fail fast on auth
|
||||
AuthenticationManager.AuthResult authResult = getAuthResult();
|
||||
|
||||
if (!isIgnoreScopeCheck) {
|
||||
checkScope(credentialRequestVO);
|
||||
}
|
||||
|
||||
// Both Format and identifier are optional.
|
||||
// If the credential_identifier is present, Format can't be present. But this implementation will
|
||||
// tolerate the presence of both, waiting for clarity in specifications.
|
||||
// This implementation will privilege the presence of the credential config identifier.
|
||||
String requestedCredentialId = credentialRequestVO.getCredentialIdentifier();
|
||||
String requestedFormat = credentialRequestVO.getFormat();
|
||||
// Both credential_configuration_id and credential_identifier are optional.
|
||||
// If the credential_configuration_id is present, credential_identifier can't be present.
|
||||
// But this implementation will tolerate the presence of both, waiting for clarity in specifications.
|
||||
// This implementation will privilege the presence of the credential_configuration_id.
|
||||
String requestedCredentialConfigurationId = credentialRequestVO.getCredentialConfigurationId();
|
||||
String requestedCredentialIdentifier = credentialRequestVO.getCredentialIdentifier();
|
||||
|
||||
// Check if at least one of both is available.
|
||||
if (requestedCredentialId == null && requestedFormat == null) {
|
||||
LOGGER.debugf("Missing both configuration id and requested format. At least one shall be specified.");
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG_AND_FORMAT));
|
||||
if (requestedCredentialConfigurationId == null && requestedCredentialIdentifier == null) {
|
||||
LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. " +
|
||||
"At least one must be specified.");
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
|
||||
}
|
||||
|
||||
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session);
|
||||
CredentialScopeModel requestedCredential = credentialRequestVO.findCredentialScope(session).orElseThrow(() -> {
|
||||
LOGGER.debugf("Credential for request '%s' not found.",
|
||||
credentialRequestVO.toString());
|
||||
return new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
|
||||
});
|
||||
|
||||
// resolve from identifier first
|
||||
SupportedCredentialConfiguration supportedCredentialConfiguration = null;
|
||||
if (requestedCredentialId != null) {
|
||||
supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId);
|
||||
if (supportedCredentialConfiguration == null) {
|
||||
LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
|
||||
}
|
||||
// Then for format. We know spec does not allow both parameter. But we are tolerant if you send both
|
||||
// Was found by id, check that the format matches.
|
||||
if (requestedFormat != null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())) {
|
||||
LOGGER.debugf("Credential with configuration id %s does not support requested format %s, but supports %s.", requestedCredentialId, requestedFormat, supportedCredentialConfiguration.getFormat());
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
|
||||
}
|
||||
}
|
||||
checkScope(requestedCredential);
|
||||
|
||||
if (supportedCredentialConfiguration == null && requestedFormat != null) {
|
||||
// Search by format
|
||||
supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat);
|
||||
if (supportedCredentialConfiguration == null) {
|
||||
LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
|
||||
}
|
||||
}
|
||||
SupportedCredentialConfiguration supportedCredential =
|
||||
OID4VCIssuerWellKnownProvider.toSupportedCredentialConfiguration(session, requestedCredential);
|
||||
|
||||
Object theCredential = getCredential(authResult, supportedCredential, credentialRequestVO);
|
||||
|
||||
CredentialResponse responseVO = new CredentialResponse();
|
||||
|
||||
Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO);
|
||||
if (SUPPORTED_FORMATS.contains(requestedFormat)) {
|
||||
responseVO
|
||||
responseVO
|
||||
.addCredential(theCredential)
|
||||
.setNotificationId(generateNotificationId());
|
||||
} else {
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
|
||||
}
|
||||
return Response.ok().entity(responseVO).build();
|
||||
}
|
||||
|
||||
private SupportedCredentialConfiguration getSupportedCredentialConfiguration(CredentialRequest credentialRequestVO, Map<String, SupportedCredentialConfiguration> supportedCredentials, String requestedFormat) {
|
||||
// 1. Format resolver
|
||||
List<SupportedCredentialConfiguration> configs = supportedCredentials.values().stream()
|
||||
.filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(), requestedFormat))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<SupportedCredentialConfiguration> matchingConfigs;
|
||||
|
||||
switch (requestedFormat) {
|
||||
case SD_JWT_VC:
|
||||
// Resolve from vct for sd-jwt
|
||||
matchingConfigs = configs.stream()
|
||||
.filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), credentialRequestVO.getVct()))
|
||||
.collect(Collectors.toList());
|
||||
break;
|
||||
case JWT_VC:
|
||||
case LDP_VC:
|
||||
// Will detach this when each format provides logic on how to resolve from definition.
|
||||
matchingConfigs = configs.stream()
|
||||
.filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), credentialRequestVO.getCredentialDefinition()))
|
||||
.collect(Collectors.toList());
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
|
||||
}
|
||||
|
||||
if (matchingConfigs.isEmpty()) {
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG));
|
||||
}
|
||||
|
||||
return matchingConfigs.iterator().next();
|
||||
}
|
||||
|
||||
private AuthenticatedClientSessionModel getAuthenticatedClientSession() {
|
||||
AuthenticationManager.AuthResult authResult = getAuthResult();
|
||||
UserSessionModel userSessionModel = authResult.getSession();
|
||||
@ -493,18 +403,20 @@ public class OID4VCIssuerEndpoint {
|
||||
* @param credentialRequestVO the credential request
|
||||
* @return the signed credential
|
||||
*/
|
||||
private Object getCredential(AuthenticationManager.AuthResult authResult, SupportedCredentialConfiguration credentialConfig, CredentialRequest credentialRequestVO) {
|
||||
private Object getCredential(AuthenticationManager.AuthResult authResult,
|
||||
SupportedCredentialConfiguration credentialConfig,
|
||||
CredentialRequest credentialRequestVO) {
|
||||
|
||||
// Get the client scope model from the credential configuration
|
||||
ClientScopeModel clientScopeModel = getClientScopeModel(credentialConfig);
|
||||
CredentialScopeModel credentialScopeModel = getClientScopeModel(credentialConfig);
|
||||
|
||||
// Get the protocol mappers from the client scope
|
||||
List<OID4VCMapper> protocolMappers = clientScopeModel.getProtocolMappersStream()
|
||||
List<OID4VCMapper> protocolMappers = credentialScopeModel.getProtocolMappersStream()
|
||||
.map(pm -> {
|
||||
if (session.getProvider(ProtocolMapper.class, pm.getProtocolMapper()) instanceof OID4VCMapper mapperFactory) {
|
||||
ProtocolMapper protocolMapper = mapperFactory.create(session);
|
||||
if (protocolMapper instanceof OID4VCMapper oid4VCMapper) {
|
||||
oid4VCMapper.setMapperModel(pm);
|
||||
oid4VCMapper.setMapperModel(pm, credentialScopeModel.getFormat());
|
||||
return oid4VCMapper;
|
||||
}
|
||||
}
|
||||
@ -520,8 +432,8 @@ public class OID4VCIssuerEndpoint {
|
||||
enforceKeyBindingIfProofProvided(vcIssuanceContext);
|
||||
|
||||
// Retrieve matching credential signer
|
||||
String format = credentialRequestVO.getFormat();
|
||||
CredentialSigner<?> credentialSigner = session.getProvider(CredentialSigner.class, format);
|
||||
CredentialSigner<?> credentialSigner = session.getProvider(CredentialSigner.class,
|
||||
credentialConfig.getFormat());
|
||||
|
||||
return Optional.ofNullable(credentialSigner)
|
||||
.map(signer -> signer.signCredential(
|
||||
@ -529,11 +441,11 @@ public class OID4VCIssuerEndpoint {
|
||||
credentialConfig.getCredentialBuildConfig()
|
||||
))
|
||||
.orElseThrow(() -> new BadRequestException(
|
||||
String.format("No signer found for format '%s'.", format)
|
||||
String.format("No signer found for format '%s'.", credentialConfig.getFormat())
|
||||
));
|
||||
}
|
||||
|
||||
private ClientScopeModel getClientScopeModel(SupportedCredentialConfiguration credentialConfig) {
|
||||
private CredentialScopeModel getClientScopeModel(SupportedCredentialConfiguration credentialConfig) {
|
||||
// Get the current client from the session
|
||||
ClientModel clientModel = session.getContext().getClient();
|
||||
|
||||
@ -545,16 +457,7 @@ public class OID4VCIssuerEndpoint {
|
||||
throw new BadRequestException("Client scope not found for the specified scope: " + credentialConfig.getScope());
|
||||
}
|
||||
|
||||
return clientScopeModel;
|
||||
}
|
||||
|
||||
private List<ProtocolMapperModel> getProtocolMappers(List<OID4VCClient> oid4VCClients) {
|
||||
|
||||
return oid4VCClients.stream()
|
||||
.map(OID4VCClient::getClientDid)
|
||||
.map(this::getClient)
|
||||
.flatMap(ProtocolMapperContainerModel::getProtocolMappersStream)
|
||||
.toList();
|
||||
return new CredentialScopeModel(clientScopeModel);
|
||||
}
|
||||
|
||||
private String generateCodeForSession(int expiration, AuthenticatedClientSessionModel clientSession) {
|
||||
@ -600,19 +503,6 @@ public class OID4VCIssuerEndpoint {
|
||||
.build();
|
||||
}
|
||||
|
||||
private ClientModel getClient(String clientId) {
|
||||
return session.clients().getClientByClientId(session.getContext().getRealm(), clientId);
|
||||
}
|
||||
|
||||
private List<OID4VCClient> getOID4VCClientsFromSession() {
|
||||
return session.clients().getClientsStream(session.getContext().getRealm())
|
||||
.filter(clientModel -> clientModel.getProtocol() != null)
|
||||
.filter(clientModel -> clientModel.getProtocol()
|
||||
.equals(OID4VCLoginProtocolFactory.PROTOCOL_ID))
|
||||
.map(clientModel -> OID4VCClientRegistrationProvider.fromClientAttributes(clientModel.getClientId(), clientModel.getAttributes()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// builds the unsigned credential by applying all protocol mappers.
|
||||
private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig,
|
||||
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) {
|
||||
@ -623,22 +513,18 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
Map<String, Object> subjectClaims = new HashMap<>();
|
||||
protocolMappers
|
||||
.stream()
|
||||
.filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope()))
|
||||
.forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, authResult.getSession()));
|
||||
|
||||
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
|
||||
|
||||
protocolMappers
|
||||
.stream()
|
||||
.filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope()))
|
||||
.forEach(mapper -> mapper.setClaimsForCredential(vc, authResult.getSession()));
|
||||
|
||||
LOGGER.debugf("The credential to sign is: %s", vc);
|
||||
|
||||
// Build format-specific credential
|
||||
CredentialBody credentialBody = findCredentialBuilder(credentialConfig)
|
||||
.buildCredentialBody(vc, credentialConfig.getCredentialBuildConfig());
|
||||
CredentialBody credentialBody = this.findCredentialBuilder(credentialConfig)
|
||||
.buildCredentialBody(vc, credentialConfig.getCredentialBuildConfig());
|
||||
|
||||
return new VCIssuanceContext()
|
||||
.setAuthResult(authResult)
|
||||
|
||||
@ -18,21 +18,17 @@
|
||||
package org.keycloak.protocol.oid4vc.issuance;
|
||||
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.crypto.KeyUse;
|
||||
import org.keycloak.crypto.KeyWrapper;
|
||||
import org.keycloak.models.KeyManager;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProvider;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilderFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSigner;
|
||||
import org.keycloak.protocol.oid4vc.issuance.signing.CredentialSignerFactory;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.services.Urls;
|
||||
import org.keycloak.urls.UrlType;
|
||||
@ -41,12 +37,8 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -58,8 +50,11 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
|
||||
|
||||
public static final String VC_KEY = "vc";
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerWellKnownProvider.class);
|
||||
private final KeycloakSession keycloakSession;
|
||||
|
||||
protected final KeycloakSession keycloakSession;
|
||||
|
||||
public OID4VCIssuerWellKnownProvider(KeycloakSession keycloakSession) {
|
||||
this.keycloakSession = keycloakSession;
|
||||
@ -136,41 +131,31 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
|
||||
* and the credentials supported by the clients available in the session.
|
||||
*/
|
||||
public static Map<String, SupportedCredentialConfiguration> getSupportedCredentials(KeycloakSession keycloakSession) {
|
||||
List<String> globalSupportedSigningAlgorithms = getSupportedSignatureAlgorithms(keycloakSession);
|
||||
|
||||
RealmModel realm = keycloakSession.getContext().getRealm();
|
||||
List<String> supportedFormats = getSupportedFormats(keycloakSession);
|
||||
|
||||
// Retrieve signature algorithms
|
||||
List<String> supportedAlgorithms = getSupportedSignatureAlgorithms(keycloakSession);
|
||||
|
||||
// Retrieving attributes from client definition.
|
||||
// This will be removed when token production is migrated.
|
||||
Map<String, SupportedCredentialConfiguration> clientAttributes = keycloakSession.getContext()
|
||||
.getRealm()
|
||||
.getClientsStream()
|
||||
.filter(cm -> cm.getProtocol() != null)
|
||||
.filter(cm -> cm.getProtocol().equals(OID4VCLoginProtocolFactory.PROTOCOL_ID))
|
||||
.map(cm -> OID4VCClientRegistrationProvider.fromClientAttributes(cm.getClientId(), cm.getAttributes()))
|
||||
.map(OID4VCClient::getSupportedVCTypes)
|
||||
.flatMap(List::stream)
|
||||
.filter(sc -> supportedFormats.contains(sc.getFormat()))
|
||||
.distinct()
|
||||
.peek(sc -> sc.setCredentialSigningAlgValuesSupported(supportedAlgorithms))
|
||||
.collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1));
|
||||
|
||||
|
||||
// Retrieving attributes from the realm
|
||||
Map<String, SupportedCredentialConfiguration> realmAttr = fromRealmAttributes(realm.getAttributes())
|
||||
.stream()
|
||||
.filter(sc -> supportedFormats.contains(sc.getFormat()))
|
||||
.distinct()
|
||||
.peek(sc -> sc.setCredentialSigningAlgValuesSupported(supportedAlgorithms))
|
||||
.collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1));
|
||||
Map<String, SupportedCredentialConfiguration> supportedCredentialConfigurations =
|
||||
keycloakSession.clientScopes()
|
||||
.getClientScopesByProtocol(realm, Oid4VciConstants.OID4VC_PROTOCOL)
|
||||
.map(CredentialScopeModel::new)
|
||||
.map(clientScope -> {
|
||||
return SupportedCredentialConfiguration.parse(keycloakSession,
|
||||
clientScope,
|
||||
globalSupportedSigningAlgorithms
|
||||
);
|
||||
})
|
||||
.collect(Collectors.toMap(SupportedCredentialConfiguration::getId, sc -> sc, (sc1, sc2) -> sc1));
|
||||
|
||||
// Aggregating attributes. Having realm attributes take preference.
|
||||
Map<String, SupportedCredentialConfiguration> aggregatedAttr = new HashMap<>(clientAttributes);
|
||||
aggregatedAttr.putAll(realmAttr);
|
||||
return aggregatedAttr;
|
||||
return supportedCredentialConfigurations;
|
||||
}
|
||||
|
||||
public static SupportedCredentialConfiguration toSupportedCredentialConfiguration(KeycloakSession keycloakSession,
|
||||
CredentialScopeModel credentialModel) {
|
||||
List<String> globalSupportedSigningAlgorithms = getSupportedSignatureAlgorithms(keycloakSession);
|
||||
return SupportedCredentialConfiguration.parse(keycloakSession,
|
||||
credentialModel,
|
||||
globalSupportedSigningAlgorithms);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -198,60 +183,6 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
|
||||
return getIssuer(context) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + OID4VCIssuerEndpoint.CREDENTIAL_PATH;
|
||||
}
|
||||
|
||||
public static final String VC_KEY = "vc";
|
||||
|
||||
public static List<SupportedCredentialConfiguration> fromRealmAttributes(Map<String, String> realmAttributes) {
|
||||
|
||||
Set<String> supportedCredentialIds = new HashSet<>();
|
||||
Map<String, String> attributes = new HashMap<>();
|
||||
realmAttributes.forEach((entryKey, value) -> {
|
||||
if (!entryKey.startsWith(VC_KEY)) {
|
||||
return;
|
||||
}
|
||||
String key = entryKey.substring((VC_KEY + ".").length());
|
||||
supportedCredentialIds.add(key.split("\\.")[0]);
|
||||
attributes.put(key, value);
|
||||
});
|
||||
|
||||
return supportedCredentialIds
|
||||
.stream()
|
||||
.map(id -> SupportedCredentialConfiguration.fromDotNotation(id, attributes))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns credential formats supported.
|
||||
* <p></p>
|
||||
* Supported credential formats are identified on the criterion of a joint availability
|
||||
* of a credential builder (as a configured component) AND a credential signer.
|
||||
*/
|
||||
public static List<String> getSupportedFormats(KeycloakSession keycloakSession) {
|
||||
RealmModel realm = keycloakSession.getContext().getRealm();
|
||||
KeycloakSessionFactory keycloakSessionFactory = keycloakSession.getKeycloakSessionFactory();
|
||||
|
||||
List<String> supportedFormatsByBuilders = realm
|
||||
.getComponentsStream(realm.getId(), CredentialBuilder.class.getName())
|
||||
.map(cm -> keycloakSessionFactory.getProviderFactory(CredentialBuilder.class, cm.getProviderId()))
|
||||
.filter(CredentialBuilderFactory.class::isInstance)
|
||||
.map(CredentialBuilderFactory.class::cast)
|
||||
.map(CredentialBuilderFactory::getSupportedFormat)
|
||||
.toList();
|
||||
|
||||
List<String> supportedFormatsBySigners = keycloakSession
|
||||
.getKeycloakSessionFactory()
|
||||
.getProviderFactoriesStream(CredentialSigner.class)
|
||||
.filter(CredentialSignerFactory.class::isInstance)
|
||||
.map(CredentialSignerFactory.class::cast)
|
||||
.map(CredentialSignerFactory::getSupportedFormat)
|
||||
.toList();
|
||||
|
||||
// Supported formats must have a builder AND a signer
|
||||
List<String> supportedFormats = new ArrayList<>(supportedFormatsByBuilders);
|
||||
supportedFormats.retainAll(supportedFormatsBySigners);
|
||||
|
||||
return supportedFormats;
|
||||
}
|
||||
|
||||
public static List<String> getSupportedSignatureAlgorithms(KeycloakSession session) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
KeyManager keyManager = session.keys();
|
||||
@ -263,4 +194,5 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -17,8 +17,6 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.credentialbuilder;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
|
||||
import java.net.URI;
|
||||
@ -27,7 +25,6 @@ import java.util.UUID;
|
||||
|
||||
public class CredentialBuilderUtils {
|
||||
|
||||
private static final String ISSUER_DID_REALM_ATTRIBUTE_KEY = "issuerDid";
|
||||
private static final String ID_TEMPLATE = "urn:uuid:%s";
|
||||
|
||||
// retrieve the credential id from the given VC or generate one.
|
||||
@ -36,9 +33,4 @@ public class CredentialBuilderUtils {
|
||||
.orElse(URI.create(String.format(ID_TEMPLATE, UUID.randomUUID())))
|
||||
.toString();
|
||||
}
|
||||
|
||||
public static Optional<String> getIssuerDid(KeycloakSession keycloakSession) {
|
||||
RealmModel realm = keycloakSession.getContext().getRealm();
|
||||
return Optional.ofNullable(realm.getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()));
|
||||
|
||||
|
||||
@ -28,6 +28,8 @@ public interface ProofValidator extends Provider {
|
||||
default void close() {
|
||||
}
|
||||
|
||||
String getProofType();
|
||||
|
||||
/**
|
||||
* Validates a client-provided key binding proof.
|
||||
*
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.mappers;
|
||||
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
@ -27,6 +28,7 @@ import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@ -57,6 +59,21 @@ public class OID4VCContextMapper extends OID4VCMapper {
|
||||
return CONFIG_PROPERTIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* this claim is not added by default to the metadata
|
||||
*/
|
||||
@Override
|
||||
public boolean includeInMetadata() {
|
||||
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
|
||||
.map(Boolean::parseBoolean)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getMetadataAttributePath() {
|
||||
return List.of(TYPE_KEY);
|
||||
}
|
||||
|
||||
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
|
||||
UserSessionModel userSessionModel) {
|
||||
// remove duplicates
|
||||
@ -92,4 +109,4 @@ public class OID4VCContextMapper extends OID4VCMapper {
|
||||
public String getId() {
|
||||
return MAPPER_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.mappers;
|
||||
|
||||
import org.apache.commons.collections4.ListUtils;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
@ -37,14 +39,13 @@ import java.util.UUID;
|
||||
public class OID4VCGeneratedIdMapper extends OID4VCMapper {
|
||||
|
||||
public static final String MAPPER_ID = "oid4vc-generated-id-mapper";
|
||||
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
|
||||
private static final String SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT = "id";
|
||||
|
||||
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
|
||||
|
||||
static {
|
||||
ProviderConfigProperty idPropertyNameConfig = new ProviderConfigProperty();
|
||||
idPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
|
||||
idPropertyNameConfig.setName(CLAIM_NAME);
|
||||
idPropertyNameConfig.setLabel("ID Property Name");
|
||||
idPropertyNameConfig.setHelpText("Name of the property to contain the generated id.");
|
||||
idPropertyNameConfig.setDefaultValue(SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT);
|
||||
@ -57,6 +58,24 @@ public class OID4VCGeneratedIdMapper extends OID4VCMapper {
|
||||
return CONFIG_PROPERTIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* this claim is not added by default to the metadata
|
||||
*/
|
||||
@Override
|
||||
public boolean includeInMetadata() {
|
||||
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
|
||||
.map(Boolean::parseBoolean)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getMetadataAttributePath() {
|
||||
String property = Optional.ofNullable(mapperModel.getConfig())
|
||||
.map(config -> config.get(CLAIM_NAME))
|
||||
.orElse(SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT);
|
||||
return ListUtils.union(getAttributePrefix(), List.of(property));
|
||||
}
|
||||
|
||||
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
|
||||
UserSessionModel userSessionModel) {
|
||||
// nothing to do for the mapper.
|
||||
@ -64,12 +83,10 @@ public class OID4VCGeneratedIdMapper extends OID4VCMapper {
|
||||
|
||||
@Override
|
||||
public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) {
|
||||
String property = Optional.ofNullable(mapperModel.getConfig())
|
||||
.map(config -> config.get(SUBJECT_PROPERTY_CONFIG_KEY))
|
||||
.orElse(SUBJECT_PROPERTY_CONFIG_KEY_DEFAULT);
|
||||
|
||||
// Assign a generated ID
|
||||
claims.put(property, String.format("urn:uuid:%s", UUID.randomUUID()));
|
||||
List<String> attributePath = getMetadataAttributePath();
|
||||
String propertyName = attributePath.get(attributePath.size() - 1);
|
||||
claims.put(propertyName, String.format("urn:uuid:%s", UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -16,12 +16,15 @@
|
||||
*/
|
||||
package org.keycloak.protocol.oid4vc.issuance.mappers;
|
||||
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
@ -47,9 +50,6 @@ public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper {
|
||||
|
||||
public static final String MAPPER_ID = "oid4vc-issued-at-time-claim-mapper";
|
||||
|
||||
// Omit value if defaults to "iat"
|
||||
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
|
||||
|
||||
// We will use the java.time.temporal.ChronoUnit enum values to help flatten down the time.
|
||||
// Omit property if no truncation.
|
||||
public static final String TRUNCATE_TO_TIME_UNIT_KEY = "truncateToTimeUnit";
|
||||
@ -59,10 +59,11 @@ public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper {
|
||||
public static final String VALUE_SOURCE = "valueSource";
|
||||
|
||||
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
|
||||
private static final Logger log = LoggerFactory.getLogger(OID4VCIssuedAtTimeClaimMapper.class);
|
||||
|
||||
static {
|
||||
ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
|
||||
subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
|
||||
subjectPropertyNameConfig.setName(CLAIM_NAME);
|
||||
subjectPropertyNameConfig.setLabel("Time Claim Name");
|
||||
subjectPropertyNameConfig.setHelpText("Name of this time claim. Default is iat");
|
||||
subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
|
||||
@ -92,8 +93,29 @@ public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper {
|
||||
return CONFIG_PROPERTIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* this claim is not added by default to the metadata
|
||||
*/
|
||||
@Override
|
||||
public boolean includeInMetadata() {
|
||||
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
|
||||
.map(Boolean::parseBoolean)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
|
||||
UserSessionModel userSessionModel) {
|
||||
// Set the value
|
||||
List<String> attributePath = getMetadataAttributePath();
|
||||
String propertyName = attributePath.get(attributePath.size() - 1);
|
||||
if (propertyName == null) {
|
||||
log.error("Invalid configuration: missing config-property '{}' for mapper '{}' of type '{}'. Mapper is ignored.",
|
||||
CLAIM_NAME,
|
||||
mapperModel.getName(),
|
||||
MAPPER_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
Instant iat = Optional.ofNullable(mapperModel.getConfig())
|
||||
.flatMap(config -> Optional.ofNullable(config.get(VALUE_SOURCE)))
|
||||
.filter(valueSource -> Objects.equals(valueSource, "COMPUTE"))
|
||||
@ -104,15 +126,11 @@ public class OID4VCIssuedAtTimeClaimMapper extends OID4VCMapper {
|
||||
// truncate is possible. Return iat if not.
|
||||
Instant iatTrunc = Optional.ofNullable(mapperModel.getConfig())
|
||||
.flatMap(config -> Optional.ofNullable(config.get(TRUNCATE_TO_TIME_UNIT_KEY)))
|
||||
.filter(i -> i.isEmpty())
|
||||
.filter(String::isEmpty)
|
||||
.map(ChronoUnit::valueOf)
|
||||
.map(iat::truncatedTo)
|
||||
.orElse(iat);
|
||||
|
||||
// Set the value
|
||||
String propertyName = Optional.ofNullable(mapperModel.getConfig())
|
||||
.map(config -> config.get(SUBJECT_PROPERTY_CONFIG_KEY))
|
||||
.orElse("iat");
|
||||
CredentialSubject credentialSubject = verifiableCredential.getCredentialSubject();
|
||||
credentialSubject.setClaims(propertyName, iatTrunc.getEpochSecond());
|
||||
}
|
||||
|
||||
@ -17,18 +17,22 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.mappers;
|
||||
|
||||
import org.apache.commons.collections4.ListUtils;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@ -41,23 +45,11 @@ import java.util.stream.Stream;
|
||||
*/
|
||||
public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentProviderFactory {
|
||||
|
||||
protected static final String SUPPORTED_CREDENTIALS_KEY = "supportedCredentialTypes";
|
||||
|
||||
protected ProtocolMapperModel mapperModel;
|
||||
|
||||
public static final String CLAIM_NAME = "claim.name";
|
||||
public static final String USER_ATTRIBUTE_KEY = "userAttribute";
|
||||
private static final List<ProviderConfigProperty> OID4VC_CONFIG_PROPERTIES = new ArrayList<>();
|
||||
|
||||
static {
|
||||
ProviderConfigProperty supportedCredentialsConfig = new ProviderConfigProperty();
|
||||
supportedCredentialsConfig.setType(ProviderConfigProperty.STRING_TYPE);
|
||||
supportedCredentialsConfig.setLabel("Supported Credential Types");
|
||||
supportedCredentialsConfig.setDefaultValue("VerifiableCredential");
|
||||
supportedCredentialsConfig.setHelpText(
|
||||
"Types of Credentials to apply the mapper. Needs to be a comma-separated list.");
|
||||
supportedCredentialsConfig.setName(SUPPORTED_CREDENTIALS_KEY);
|
||||
OID4VC_CONFIG_PROPERTIES.clear();
|
||||
OID4VC_CONFIG_PROPERTIES.add(supportedCredentialsConfig);
|
||||
}
|
||||
protected ProtocolMapperModel mapperModel;
|
||||
protected String format;
|
||||
|
||||
protected abstract List<ProviderConfigProperty> getIndividualConfigProperties();
|
||||
|
||||
@ -66,11 +58,43 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
|
||||
return Stream.concat(OID4VC_CONFIG_PROPERTIES.stream(), getIndividualConfigProperties().stream()).toList();
|
||||
}
|
||||
|
||||
public OID4VCMapper setMapperModel(ProtocolMapperModel mapperModel) {
|
||||
public OID4VCMapper setMapperModel(ProtocolMapperModel mapperModel, String format) {
|
||||
this.mapperModel = mapperModel;
|
||||
this.format = format;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* some specific claims should not be added into the metadata. Examples are jti, sub, iss etc. Since we have the
|
||||
* possibility to add these credentials with specific claims we should also be able to exclude these specific
|
||||
* attributes from the metadata
|
||||
*/
|
||||
public boolean includeInMetadata() {
|
||||
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
|
||||
.map(Boolean::parseBoolean)
|
||||
.orElse(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* must return ordered list of attribute-names as they are added into the credential. This is required for the
|
||||
* metadata endpoint to add the appropriate path-attributes into the claim's description.
|
||||
*
|
||||
* @return the attribute path that is being mapped into the credential
|
||||
*/
|
||||
public List<String> getMetadataAttributePath() {
|
||||
final String claimName = mapperModel.getConfig().get(CLAIM_NAME);
|
||||
final String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY);
|
||||
return ListUtils.union(getAttributePrefix(),
|
||||
List.of(Optional.ofNullable(claimName).orElse(userAttributeName)));
|
||||
}
|
||||
|
||||
protected List<String> getAttributePrefix() {
|
||||
return switch (Optional.ofNullable(format).orElse("")) {
|
||||
case Format.JWT_VC, Format.LDP_VC -> List.of(Oid4VciConstants.CREDENTIAL_SUBJECT);
|
||||
default -> Collections.emptyList();
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProtocol() {
|
||||
return OID4VCLoginProtocolFactory.PROTOCOL_ID;
|
||||
@ -94,20 +118,6 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
|
||||
public void close() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the mapper supports the given credential type. Allows to configure them not only per client, but also per VC Type.
|
||||
*
|
||||
* @param credentialScope type of the VerifiableCredential that should be checked
|
||||
* @return true if it is supported
|
||||
*/
|
||||
public boolean isScopeSupported(String credentialScope) {
|
||||
var optionalScopes = Optional.ofNullable(mapperModel.getConfig().get(SUPPORTED_CREDENTIALS_KEY));
|
||||
if (optionalScopes.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.asList(optionalScopes.get().split(",")).contains(credentialScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the claims to credential, like f.e. the context
|
||||
*/
|
||||
|
||||
@ -36,14 +36,13 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper {
|
||||
|
||||
public static final String MAPPER_ID = "oid4vc-static-claim-mapper";
|
||||
|
||||
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
|
||||
public static final String STATIC_CLAIM_KEY = "staticValue";
|
||||
|
||||
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
|
||||
|
||||
static {
|
||||
ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
|
||||
subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
|
||||
subjectPropertyNameConfig.setName(CLAIM_NAME);
|
||||
subjectPropertyNameConfig.setLabel("Static Claim Property Name");
|
||||
subjectPropertyNameConfig.setHelpText("Name of the property to contain the static value.");
|
||||
subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
|
||||
@ -69,7 +68,8 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper {
|
||||
|
||||
@Override
|
||||
public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) {
|
||||
String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
|
||||
List<String> attributePath = getMetadataAttributePath();
|
||||
String propertyName = attributePath.get(attributePath.size() - 1);
|
||||
String staticValue = mapperModel.getConfig().get(STATIC_CLAIM_KEY);
|
||||
claims.put(propertyName, staticValue);
|
||||
}
|
||||
@ -93,4 +93,4 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper {
|
||||
public String getId() {
|
||||
return MAPPER_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.mappers;
|
||||
|
||||
import org.apache.commons.collections4.ListUtils;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
@ -39,13 +40,12 @@ import java.util.UUID;
|
||||
public class OID4VCSubjectIdMapper extends OID4VCMapper {
|
||||
|
||||
public static final String MAPPER_ID = "oid4vc-subject-id-mapper";
|
||||
public static final String ID_KEY = "subjectIdProperty";
|
||||
|
||||
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
|
||||
|
||||
static {
|
||||
ProviderConfigProperty idPropertyNameConfig = new ProviderConfigProperty();
|
||||
idPropertyNameConfig.setName(ID_KEY);
|
||||
idPropertyNameConfig.setName(CLAIM_NAME);
|
||||
idPropertyNameConfig.setLabel("ID Property Name");
|
||||
idPropertyNameConfig.setHelpText("Name of the property to contain the id.");
|
||||
idPropertyNameConfig.setDefaultValue("id");
|
||||
@ -58,12 +58,16 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
|
||||
return CONFIG_PROPERTIES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getMetadataAttributePath() {
|
||||
return ListUtils.union(getAttributePrefix(), List.of("id"));
|
||||
}
|
||||
|
||||
public static ProtocolMapperModel create(String name, String subjectId) {
|
||||
var mapperModel = new ProtocolMapperModel();
|
||||
mapperModel.setName(name);
|
||||
Map<String, String> configMap = new HashMap<>();
|
||||
configMap.put(ID_KEY, subjectId);
|
||||
configMap.put(SUPPORTED_CREDENTIALS_KEY, "VerifiableCredential");
|
||||
configMap.put(CLAIM_NAME, subjectId);
|
||||
mapperModel.setConfig(configMap);
|
||||
mapperModel.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
|
||||
mapperModel.setProtocolMapper(MAPPER_ID);
|
||||
@ -77,7 +81,11 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
|
||||
|
||||
@Override
|
||||
public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) {
|
||||
claims.put("id", mapperModel.getConfig().getOrDefault(ID_KEY, String.format("urn:uuid:%s", UUID.randomUUID())));
|
||||
List<String> attributePath = getMetadataAttributePath();
|
||||
String propertyName = attributePath.get(attributePath.size() - 1);
|
||||
claims.put(propertyName,
|
||||
mapperModel.getConfig().getOrDefault(OID4VCMapper.CLAIM_NAME,
|
||||
String.format("urn:uuid:%s", UUID.randomUUID())));
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -99,4 +107,4 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
|
||||
public String getId() {
|
||||
return MAPPER_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
package org.keycloak.protocol.oid4vc.issuance.mappers;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.commons.collections4.ListUtils;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
@ -30,12 +30,14 @@ import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.oid4vc.model.Role;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ -47,17 +49,15 @@ import java.util.stream.Collectors;
|
||||
public class OID4VCTargetRoleMapper extends OID4VCMapper {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(OID4VCTargetRoleMapper.class);
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
public static final String DEFAULT_CLAIM_NAME = "roles";
|
||||
public static final String MAPPER_ID = "oid4vc-target-role-mapper";
|
||||
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
|
||||
public static final String CLIENT_CONFIG_KEY = "clientId";
|
||||
|
||||
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
|
||||
|
||||
static {
|
||||
ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
|
||||
subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
|
||||
subjectPropertyNameConfig.setName(CLAIM_NAME);
|
||||
subjectPropertyNameConfig.setLabel("Roles Property Name");
|
||||
subjectPropertyNameConfig.setHelpText("Property to add the roles to in the credential subject.");
|
||||
subjectPropertyNameConfig.setDefaultValue("roles");
|
||||
@ -65,11 +65,28 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
|
||||
CONFIG_PROPERTIES.add(subjectPropertyNameConfig);
|
||||
}
|
||||
|
||||
private final KeycloakSession keycloakSession;
|
||||
|
||||
public OID4VCTargetRoleMapper() {
|
||||
this.keycloakSession = null;
|
||||
}
|
||||
|
||||
public OID4VCTargetRoleMapper(KeycloakSession keycloakSession) {
|
||||
this.keycloakSession = keycloakSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<ProviderConfigProperty> getIndividualConfigProperties() {
|
||||
return CONFIG_PROPERTIES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getMetadataAttributePath() {
|
||||
return ListUtils.union(getAttributePrefix(),
|
||||
List.of(Optional.ofNullable(mapperModel.getConfig().get(CLAIM_NAME))
|
||||
.orElse(DEFAULT_CLAIM_NAME)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayType() {
|
||||
return "Target-Role Mapper";
|
||||
@ -80,12 +97,11 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
|
||||
return "Map the assigned role to the credential subject, providing the client id as the target.";
|
||||
}
|
||||
|
||||
public static ProtocolMapperModel create(String clientId, String name) {
|
||||
public static ProtocolMapperModel create(String name) {
|
||||
var mapperModel = new ProtocolMapperModel();
|
||||
mapperModel.setName(name);
|
||||
Map<String, String> configMap = new HashMap<>();
|
||||
configMap.put(SUBJECT_PROPERTY_CONFIG_KEY, "roles");
|
||||
configMap.put(CLIENT_CONFIG_KEY, clientId);
|
||||
configMap.put(CLAIM_NAME, DEFAULT_CLAIM_NAME);
|
||||
mapperModel.setConfig(configMap);
|
||||
mapperModel.setProtocol(OID4VCLoginProtocolFactory.PROTOCOL_ID);
|
||||
mapperModel.setProtocolMapper(MAPPER_ID);
|
||||
@ -94,7 +110,7 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
|
||||
|
||||
@Override
|
||||
public ProtocolMapper create(KeycloakSession session) {
|
||||
return new OID4VCTargetRoleMapper();
|
||||
return new OID4VCTargetRoleMapper(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -111,18 +127,19 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
|
||||
@Override
|
||||
public void setClaimsForSubject(Map<String, Object> claims,
|
||||
UserSessionModel userSessionModel) {
|
||||
String client = mapperModel.getConfig().get(CLIENT_CONFIG_KEY);
|
||||
String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
|
||||
ClientModel clientModel = userSessionModel.getRealm().getClientByClientId(client);
|
||||
List<String> attributePath = getMetadataAttributePath();
|
||||
String propertyName = attributePath.get(attributePath.size() - 1);
|
||||
ClientModel clientModel = keycloakSession.getContext().getClient();
|
||||
if (clientModel == null) {
|
||||
LOGGER.warnf("Client %s not found in realm.", client);
|
||||
LOGGER.warnf("Client %s not found.", clientModel.getClientId());
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve only the roles assigned to the user for this specific client
|
||||
List<RoleModel> userRoles = userSessionModel.getUser().getClientRoleMappingsStream(clientModel).toList();
|
||||
if (userRoles.isEmpty()) {
|
||||
LOGGER.debugf("No roles assigned to client '%s'. Skipping claim assignment.", client);
|
||||
LOGGER.debugf("No roles assigned to client '%s'. Skipping claim assignment.",
|
||||
clientModel.getClientId());
|
||||
return;
|
||||
}
|
||||
|
||||
@ -130,11 +147,12 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
|
||||
ClientRoleModel clientRoleModel = new ClientRoleModel(clientModel.getClientId(), userRoles);
|
||||
Role rolesClaim = toRolesClaim(clientRoleModel);
|
||||
if (rolesClaim.getNames().isEmpty()) {
|
||||
LOGGER.debugf("No valid role names found for client '%s'. Skipping claim assignment.", client);
|
||||
LOGGER.debugf("No valid role names found for client '%s'. Skipping claim assignment.",
|
||||
clientModel.getClientId());
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> modelMap = OBJECT_MAPPER.convertValue(rolesClaim, new TypeReference<>() {});
|
||||
Map<String, Object> modelMap = JsonSerialization.mapper.convertValue(rolesClaim, new TypeReference<>() {});
|
||||
|
||||
Object existingProperty = claims.get(propertyName);
|
||||
if (existingProperty == null) {
|
||||
@ -148,11 +166,11 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
|
||||
rolesProperty.add(modelMap);
|
||||
} else {
|
||||
LOGGER.warnf("Claim '%s' contains incompatible types. Expected Set<Map<String, Object>>, found '%s'. Skipping role assignment for client '%s'.",
|
||||
propertyName, existingProperty.getClass().getSimpleName(), client);
|
||||
propertyName, existingProperty.getClass().getSimpleName(), clientModel.getClientId());
|
||||
}
|
||||
} else {
|
||||
LOGGER.warnf("Claim '%s' is of type '%s', expected Set. Skipping role assignment for client '%s'.",
|
||||
propertyName, existingProperty.getClass().getSimpleName(), client);
|
||||
propertyName, existingProperty.getClass().getSimpleName(), clientModel.getClientId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.issuance.mappers;
|
||||
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
@ -57,6 +58,21 @@ public class OID4VCTypeMapper extends OID4VCMapper {
|
||||
return CONFIG_PROPERTIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* this claim is not added by default to the metadata
|
||||
*/
|
||||
@Override
|
||||
public boolean includeInMetadata() {
|
||||
return Optional.ofNullable(mapperModel.getConfig().get(CredentialScopeModel.INCLUDE_IN_METADATA))
|
||||
.map(Boolean::parseBoolean)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getMetadataAttributePath() {
|
||||
return List.of("type");
|
||||
}
|
||||
|
||||
public void setClaimsForCredential(VerifiableCredential verifiableCredential,
|
||||
UserSessionModel userSessionModel) {
|
||||
// remove duplicates
|
||||
@ -92,4 +108,4 @@ public class OID4VCTypeMapper extends OID4VCMapper {
|
||||
public String getId() {
|
||||
return MAPPER_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,17 +43,16 @@ import java.util.Optional;
|
||||
public class OID4VCUserAttributeMapper extends OID4VCMapper {
|
||||
|
||||
public static final String MAPPER_ID = "oid4vc-user-attribute-mapper";
|
||||
public static final String SUBJECT_PROPERTY_CONFIG_KEY = "subjectProperty";
|
||||
public static final String USER_ATTRIBUTE_KEY = "userAttribute";
|
||||
public static final String AGGREGATE_ATTRIBUTES_KEY = "aggregateAttributes";
|
||||
|
||||
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<>();
|
||||
|
||||
static {
|
||||
ProviderConfigProperty subjectPropertyNameConfig = new ProviderConfigProperty();
|
||||
subjectPropertyNameConfig.setName(SUBJECT_PROPERTY_CONFIG_KEY);
|
||||
subjectPropertyNameConfig.setLabel("Attribute Property Name");
|
||||
subjectPropertyNameConfig.setHelpText("Property to add the user attribute to in the credential subject.");
|
||||
subjectPropertyNameConfig.setName(CLAIM_NAME);
|
||||
subjectPropertyNameConfig.setLabel("Claim Name");
|
||||
subjectPropertyNameConfig.setHelpText("The name of the claim added to the credential subject that is extracted " +
|
||||
"from the user attributes.");
|
||||
subjectPropertyNameConfig.setType(ProviderConfigProperty.STRING_TYPE);
|
||||
CONFIG_PROPERTIES.add(subjectPropertyNameConfig);
|
||||
|
||||
@ -87,7 +86,8 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
|
||||
|
||||
@Override
|
||||
public void setClaimsForSubject(Map<String, Object> claims, UserSessionModel userSessionModel) {
|
||||
String propertyName = mapperModel.getConfig().get(SUBJECT_PROPERTY_CONFIG_KEY);
|
||||
List<String> attributePath = getMetadataAttributePath();
|
||||
String propertyName = attributePath.get(attributePath.size() - 1);
|
||||
String userAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY);
|
||||
boolean aggregateAttributes = Optional.ofNullable(mapperModel.getConfig().get(AGGREGATE_ATTRIBUTES_KEY))
|
||||
.map(Boolean::parseBoolean).orElse(false);
|
||||
@ -105,7 +105,7 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
|
||||
var mapperModel = new ProtocolMapperModel();
|
||||
mapperModel.setName(mapperName);
|
||||
Map<String, String> configMap = new HashMap<>();
|
||||
configMap.put(SUBJECT_PROPERTY_CONFIG_KEY, propertyName);
|
||||
configMap.put(CLAIM_NAME, propertyName);
|
||||
configMap.put(USER_ATTRIBUTE_KEY, userAttribute);
|
||||
configMap.put(AGGREGATE_ATTRIBUTES_KEY, Boolean.toString(aggregateAttributes));
|
||||
mapperModel.setConfig(configMap);
|
||||
@ -133,4 +133,4 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
|
||||
public String getId() {
|
||||
return MAPPER_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,29 +16,104 @@
|
||||
*/
|
||||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Holding metadata on a claim of verifiable credential.
|
||||
* <p>
|
||||
* See: <a href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2">openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2</a>
|
||||
* See: <a
|
||||
* href="https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2">openid-4-verifiable-credential-issuance-1_0.html#appendix-A.2.2</a>
|
||||
*
|
||||
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class Claim {
|
||||
|
||||
/**
|
||||
* the claims name, which is not part of the underlying JSON structure
|
||||
*/
|
||||
@JsonIgnore
|
||||
private String name;
|
||||
|
||||
@JsonProperty("path")
|
||||
private List<String> path;
|
||||
|
||||
@JsonProperty("mandatory")
|
||||
private Boolean mandatory;
|
||||
@JsonProperty("value_type")
|
||||
private String valueType;
|
||||
|
||||
@JsonProperty("display")
|
||||
private List<ClaimDisplay> display;
|
||||
|
||||
public Boolean getMandatory() {
|
||||
return mandatory;
|
||||
public static Optional<Claim> parse(KeycloakSession keycloakSession,
|
||||
String credentialFormat,
|
||||
Oid4vcProtocolMapperModel protocolMapper) {
|
||||
try {
|
||||
Claim claim = new Claim();
|
||||
ProtocolMapper protocolMapperImpl = keycloakSession.getProvider(ProtocolMapper.class,
|
||||
protocolMapper.getProtocolMapper());
|
||||
if (!(protocolMapperImpl instanceof OID4VCMapper)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
OID4VCMapper mapper = (OID4VCMapper) protocolMapperImpl;
|
||||
mapper.setMapperModel(protocolMapper, credentialFormat);
|
||||
|
||||
if (!mapper.includeInMetadata()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
claim.setName(String.join(".", mapper.getMetadataAttributePath()));
|
||||
|
||||
claim.setPath(mapper.getMetadataAttributePath());
|
||||
claim.setMandatory(protocolMapper.isMandatory());
|
||||
|
||||
String displayString = protocolMapper.getDisplay();
|
||||
if (StringUtil.isNotBlank(displayString)) {
|
||||
TypeReference<List<ClaimDisplay>> typeReference = new TypeReference<>() {};
|
||||
List<ClaimDisplay> claimDisplayList = JsonSerialization.mapper.readValue(displayString, typeReference);
|
||||
claim.setDisplay(claimDisplayList);
|
||||
}
|
||||
|
||||
return Optional.of(claim);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Claim setName(String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public List<String> getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public Claim setPath(List<String> path) {
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isMandatory() {
|
||||
return Optional.ofNullable(mandatory).orElse(false);
|
||||
}
|
||||
|
||||
public Claim setMandatory(Boolean mandatory) {
|
||||
@ -46,15 +121,6 @@ public class Claim {
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getValueType() {
|
||||
return valueType;
|
||||
}
|
||||
|
||||
public Claim setValueType(String valueType) {
|
||||
this.valueType = valueType;
|
||||
return this;
|
||||
}
|
||||
|
||||
public List<ClaimDisplay> getDisplay() {
|
||||
return display;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,15 +16,27 @@
|
||||
*/
|
||||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
|
||||
*/
|
||||
public class Claims extends HashMap<String, Claim> {
|
||||
public class Claims extends ArrayList<Claim> {
|
||||
|
||||
public static Claims parse(KeycloakSession keycloakSession, CredentialScopeModel credentialScope) {
|
||||
Claims claims = new Claims();
|
||||
credentialScope.getOid4vcProtocolMappersStream().forEach(protocolMapper -> {
|
||||
Optional<Claim> claim = Claim.parse(keycloakSession, credentialScope.getFormat(), protocolMapper);
|
||||
claim.ifPresent(claims::add);
|
||||
});
|
||||
return claims;
|
||||
}
|
||||
|
||||
public String toJsonString(){
|
||||
try {
|
||||
|
||||
@ -17,17 +17,14 @@
|
||||
|
||||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration.CREDENTIAL_BUILD_CONFIG_KEY;
|
||||
import static org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration.DOT_SEPARATOR;
|
||||
import static org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration.VERIFIABLE_CREDENTIAL_TYPE_KEY;
|
||||
|
||||
/**
|
||||
* Define credential-specific configurations for its builder.
|
||||
*
|
||||
@ -47,7 +44,8 @@ public class CredentialBuildConfig {
|
||||
private static final String SIGNING_ALGORITHM_KEY = "signing_algorithm";
|
||||
private static final String LDP_PROOF_TYPE_KEY = "ldp_proof_type";
|
||||
|
||||
// This is saved here to facilitate dot notation reconstruction
|
||||
private String credentialIssuer;
|
||||
|
||||
private String credentialId;
|
||||
|
||||
//-- Proper building configuration fields --//
|
||||
@ -63,7 +61,7 @@ public class CredentialBuildConfig {
|
||||
private String hashAlgorithm;
|
||||
|
||||
// List of claims to stay disclosed in the SD-JWT.
|
||||
private List<String> visibleClaims;
|
||||
private List<String> sdJwtVisibleClaims;
|
||||
|
||||
// The number of decoys to be added to the SD-JWT.
|
||||
private Integer numberOfDecoys;
|
||||
@ -87,6 +85,33 @@ public class CredentialBuildConfig {
|
||||
// Needs to fit the provided signing key.
|
||||
private String ldpProofType;
|
||||
|
||||
public static CredentialBuildConfig parse(KeycloakSession keycloakSession,
|
||||
SupportedCredentialConfiguration credentialConfiguration,
|
||||
CredentialScopeModel credentialModel) {
|
||||
final String credentialIssuer = Optional.ofNullable(credentialModel.getIssuerDid()).orElse(
|
||||
OID4VCIssuerWellKnownProvider.getIssuer(keycloakSession.getContext()));
|
||||
|
||||
return new CredentialBuildConfig().setCredentialIssuer(credentialIssuer)
|
||||
.setCredentialId(credentialConfiguration.getId())
|
||||
.setCredentialType(credentialConfiguration.getVct())
|
||||
.setTokenJwsType(credentialModel.getTokenJwsType())
|
||||
.setNumberOfDecoys(credentialModel.getSdJwtNumberOfDecoys())
|
||||
.setSigningKeyId(credentialModel.getSigningKeyId())
|
||||
.setSigningAlgorithm(credentialConfiguration.getCredentialSigningAlgValuesSupported()
|
||||
.get(0))
|
||||
.setHashAlgorithm(credentialModel.getHashAlgorithm())
|
||||
.setSdJwtVisibleClaims(credentialModel.getSdJwtVisibleClaims());
|
||||
}
|
||||
|
||||
public String getCredentialIssuer() {
|
||||
return credentialIssuer;
|
||||
}
|
||||
|
||||
public CredentialBuildConfig setCredentialIssuer(String credentialIssuer) {
|
||||
this.credentialIssuer = credentialIssuer;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getCredentialId() {
|
||||
return credentialId;
|
||||
}
|
||||
@ -114,12 +139,12 @@ public class CredentialBuildConfig {
|
||||
return this;
|
||||
}
|
||||
|
||||
public List<String> getVisibleClaims() {
|
||||
return visibleClaims;
|
||||
public List<String> getSdJwtVisibleClaims() {
|
||||
return sdJwtVisibleClaims;
|
||||
}
|
||||
|
||||
public CredentialBuildConfig setVisibleClaims(List<String> visibleClaims) {
|
||||
this.visibleClaims = visibleClaims;
|
||||
public CredentialBuildConfig setSdJwtVisibleClaims(List<String> sdJwtVisibleClaims) {
|
||||
this.sdJwtVisibleClaims = sdJwtVisibleClaims;
|
||||
return this;
|
||||
}
|
||||
|
||||
@ -177,96 +202,39 @@ public class CredentialBuildConfig {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Map<String, String> toDotNotation() {
|
||||
Map<String, String> dotNotation = new HashMap<>();
|
||||
|
||||
// vct is skipped because it is not expected nester under CREDENTIAL_BUILD_CONFIG_KEY
|
||||
|
||||
String prefix = getDotNotationPrefix(credentialId);
|
||||
|
||||
Optional.ofNullable(tokenJwsType)
|
||||
.ifPresent(tokenJwsType -> dotNotation.put(prefix + TOKEN_JWS_TYPE_KEY, tokenJwsType));
|
||||
Optional.ofNullable(hashAlgorithm)
|
||||
.ifPresent(hashAlgorithm -> dotNotation.put(prefix + HASH_ALGORITHM_KEY, hashAlgorithm));
|
||||
|
||||
Optional.ofNullable(numberOfDecoys)
|
||||
.ifPresent(numberOfDecoys ->
|
||||
dotNotation.put(prefix + NUMBER_OF_DECOYS_KEY, String.valueOf(numberOfDecoys)));
|
||||
|
||||
Optional.ofNullable(visibleClaims)
|
||||
.ifPresent(claims -> dotNotation.put(prefix + VISIBLE_CLAIMS_KEY,
|
||||
String.join(MULTIVALUED_STRING_SEPARATOR, claims)));
|
||||
|
||||
Optional.ofNullable(signingKeyId)
|
||||
.ifPresent(signingKeyId -> dotNotation.put(prefix + SIGNING_KEY_ID_KEY, signingKeyId));
|
||||
Optional.ofNullable(overrideKeyId)
|
||||
.ifPresent(overrideKeyId -> dotNotation.put(prefix + OVERRIDE_KEY_ID_KEY, overrideKeyId));
|
||||
Optional.ofNullable(signingAlgorithm)
|
||||
.ifPresent(signingAlgorithm -> dotNotation.put(prefix + SIGNING_ALGORITHM_KEY, signingAlgorithm));
|
||||
Optional.ofNullable(ldpProofType)
|
||||
.ifPresent(ldpProofType -> dotNotation.put(prefix + LDP_PROOF_TYPE_KEY, ldpProofType));
|
||||
|
||||
return dotNotation;
|
||||
}
|
||||
|
||||
public static CredentialBuildConfig fromDotNotation(String credentialId, Map<String, String> dotNotated) {
|
||||
String prefix = getDotNotationPrefix(credentialId);
|
||||
if (dotNotated.keySet().stream().noneMatch(key -> key.startsWith(prefix))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Start populating config
|
||||
|
||||
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
|
||||
.setCredentialId(credentialId);
|
||||
|
||||
// No need to redefine `vct` under CREDENTIAL_BUILD_CONFIG_KEY
|
||||
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY))
|
||||
.ifPresent(credentialBuildConfig::setCredentialType);
|
||||
|
||||
// These other fields are nested under CREDENTIAL_BUILD_CONFIG_KEY
|
||||
|
||||
Optional.ofNullable(dotNotated.get(prefix + TOKEN_JWS_TYPE_KEY))
|
||||
.ifPresent(credentialBuildConfig::setTokenJwsType);
|
||||
Optional.ofNullable(dotNotated.get(prefix + HASH_ALGORITHM_KEY))
|
||||
.ifPresent(credentialBuildConfig::setHashAlgorithm);
|
||||
|
||||
Optional.ofNullable(dotNotated.get(prefix + NUMBER_OF_DECOYS_KEY))
|
||||
.map(Integer::parseInt)
|
||||
.ifPresent(credentialBuildConfig::setNumberOfDecoys);
|
||||
|
||||
Optional.ofNullable(dotNotated.get(prefix + VISIBLE_CLAIMS_KEY))
|
||||
.map(cbms -> cbms.split(MULTIVALUED_STRING_SEPARATOR))
|
||||
.map(Arrays::asList)
|
||||
.ifPresent(credentialBuildConfig::setVisibleClaims);
|
||||
|
||||
Optional.ofNullable(dotNotated.get(prefix + SIGNING_KEY_ID_KEY))
|
||||
.ifPresent(credentialBuildConfig::setSigningKeyId);
|
||||
Optional.ofNullable(dotNotated.get(prefix + OVERRIDE_KEY_ID_KEY))
|
||||
.ifPresent(credentialBuildConfig::setOverrideKeyId);
|
||||
Optional.ofNullable(dotNotated.get(prefix + SIGNING_ALGORITHM_KEY))
|
||||
.ifPresent(credentialBuildConfig::setSigningAlgorithm);
|
||||
Optional.ofNullable(dotNotated.get(prefix + LDP_PROOF_TYPE_KEY))
|
||||
.ifPresent(credentialBuildConfig::setLdpProofType);
|
||||
|
||||
return credentialBuildConfig;
|
||||
}
|
||||
|
||||
private static String getDotNotationPrefix(String credentialId) {
|
||||
return credentialId + DOT_SEPARATOR + CREDENTIAL_BUILD_CONFIG_KEY + DOT_SEPARATOR;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
CredentialBuildConfig that = (CredentialBuildConfig) o;
|
||||
return Objects.equals(credentialId, that.credentialId) && Objects.equals(credentialType, that.credentialType) && Objects.equals(tokenJwsType, that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals(visibleClaims, that.visibleClaims) && Objects.equals(numberOfDecoys, that.numberOfDecoys) && Objects.equals(signingKeyId, that.signingKeyId) && Objects.equals(overrideKeyId, that.overrideKeyId) && Objects.equals(signingAlgorithm, that.signingAlgorithm) && Objects.equals(ldpProofType, that.ldpProofType);
|
||||
return Objects.equals(credentialId, that.credentialId) && Objects.equals(credentialType,
|
||||
that.credentialType) && Objects.equals(
|
||||
tokenJwsType,
|
||||
that.tokenJwsType) && Objects.equals(hashAlgorithm, that.hashAlgorithm) && Objects.equals(
|
||||
sdJwtVisibleClaims,
|
||||
that.sdJwtVisibleClaims) && Objects.equals(
|
||||
numberOfDecoys,
|
||||
that.numberOfDecoys) && Objects.equals(signingKeyId, that.signingKeyId) && Objects.equals(overrideKeyId,
|
||||
that.overrideKeyId) && Objects.equals(
|
||||
signingAlgorithm,
|
||||
that.signingAlgorithm) && Objects.equals(ldpProofType, that.ldpProofType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(credentialId, credentialType, tokenJwsType, hashAlgorithm, visibleClaims, numberOfDecoys, signingKeyId, overrideKeyId, signingAlgorithm, ldpProofType);
|
||||
return Objects.hash(credentialId,
|
||||
credentialType,
|
||||
tokenJwsType,
|
||||
hashAlgorithm,
|
||||
sdJwtVisibleClaims,
|
||||
numberOfDecoys,
|
||||
signingKeyId,
|
||||
overrideKeyId,
|
||||
signingAlgorithm,
|
||||
ldpProofType);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,11 +18,11 @@ package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Pojo to represent a CredentialDefinition for internal handling
|
||||
@ -35,7 +35,18 @@ public class CredentialDefinition {
|
||||
@JsonProperty("@context")
|
||||
private List<String> context;
|
||||
private List<String> type = new ArrayList<>();
|
||||
private CredentialSubject credentialSubject = new CredentialSubject();
|
||||
|
||||
public static CredentialDefinition parse(CredentialScopeModel credentialModel) {
|
||||
List<String> contexts = Optional.of(credentialModel.getVcContexts())
|
||||
.filter(list -> !list.isEmpty())
|
||||
.orElseGet(() -> new ArrayList<>(List.of(credentialModel.getName())));
|
||||
List<String> types = Optional.ofNullable(credentialModel.getSupportedCredentialTypes())
|
||||
.filter(list -> !list.isEmpty())
|
||||
.orElseGet(() -> new ArrayList<>(List.of(credentialModel.getName())));
|
||||
|
||||
return new CredentialDefinition().setContext(contexts)
|
||||
.setType(types);
|
||||
}
|
||||
|
||||
public List<String> getContext() {
|
||||
return context;
|
||||
@ -54,29 +65,4 @@ public class CredentialDefinition {
|
||||
this.type = type;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CredentialSubject getCredentialSubject() {
|
||||
return credentialSubject;
|
||||
}
|
||||
|
||||
public CredentialDefinition setCredentialSubject(CredentialSubject credentialSubject) {
|
||||
this.credentialSubject = credentialSubject;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String toJsonString() {
|
||||
try {
|
||||
return JsonSerialization.writeValueAsString(this);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static CredentialDefinition fromJsonString(String jsonString) {
|
||||
try {
|
||||
return JsonSerialization.readValue(jsonString, CredentialDefinition.class);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,14 @@ package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
|
||||
@ -31,7 +39,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class CredentialRequest {
|
||||
|
||||
private String format;
|
||||
@JsonProperty("credential_configuration_id")
|
||||
private String credentialConfigurationId;
|
||||
|
||||
@JsonProperty("credential_identifier")
|
||||
private String credentialIdentifier;
|
||||
@ -44,23 +53,10 @@ public class CredentialRequest {
|
||||
})
|
||||
private Proof proof;
|
||||
|
||||
// I have the choice of either defining format specific fields here, or adding a generic structure,
|
||||
// opening room for spamming the server. I will prefer having format specific fields.
|
||||
private String vct;
|
||||
|
||||
// See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-format-identifier-3
|
||||
@JsonProperty("credential_definition")
|
||||
private CredentialDefinition credentialDefinition;
|
||||
|
||||
public String getFormat() {
|
||||
return format;
|
||||
}
|
||||
|
||||
public CredentialRequest setFormat(String format) {
|
||||
this.format = format;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getCredentialIdentifier() {
|
||||
return credentialIdentifier;
|
||||
}
|
||||
@ -70,6 +66,15 @@ public class CredentialRequest {
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getCredentialConfigurationId() {
|
||||
return credentialConfigurationId;
|
||||
}
|
||||
|
||||
public CredentialRequest setCredentialConfigurationId(String credentialConfigurationId) {
|
||||
this.credentialConfigurationId = credentialConfigurationId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Proof getProof() {
|
||||
return proof;
|
||||
}
|
||||
@ -79,15 +84,6 @@ public class CredentialRequest {
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getVct() {
|
||||
return vct;
|
||||
}
|
||||
|
||||
public CredentialRequest setVct(String vct) {
|
||||
this.vct = vct;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CredentialDefinition getCredentialDefinition() {
|
||||
return credentialDefinition;
|
||||
}
|
||||
@ -96,4 +92,30 @@ public class CredentialRequest {
|
||||
this.credentialDefinition = credentialDefinition;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Optional<CredentialScopeModel> findCredentialScope(KeycloakSession keycloakSession) {
|
||||
Map<String, String> searchAttributeMap =
|
||||
Optional.ofNullable(credentialConfigurationId)
|
||||
.map(credentialIdentifier -> {
|
||||
return Map.of(CredentialScopeModel.CONFIGURATION_ID, credentialConfigurationId);
|
||||
}).orElseGet(() -> {
|
||||
return Map.of(CredentialScopeModel.CREDENTIAL_IDENTIFIER, credentialIdentifier);
|
||||
});
|
||||
|
||||
RealmModel currentRealm = keycloakSession.getContext().getRealm();
|
||||
final boolean useOrExpression = false;
|
||||
return keycloakSession.clientScopes()
|
||||
.getClientScopesByAttributes(currentRealm, searchAttributeMap, useOrExpression)
|
||||
.map(CredentialScopeModel::new)
|
||||
.findAny();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
try {
|
||||
return JsonSerialization.mapper.writeValueAsString(this);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -20,9 +20,16 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents a DisplayObject, as used in the OID4VCI Credentials Issuer Metadata
|
||||
@ -38,6 +45,8 @@ import java.io.IOException;
|
||||
)
|
||||
public class DisplayObject {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(DisplayObject.class);
|
||||
|
||||
@JsonIgnore
|
||||
private static final String NAME_KEY = "name";
|
||||
@JsonIgnore
|
||||
@ -69,6 +78,23 @@ public class DisplayObject {
|
||||
@JsonProperty(DisplayObject.TEXT_COLOR_KEY)
|
||||
private String textColor;
|
||||
|
||||
public static List<DisplayObject> parse(CredentialScopeModel credentialScope) {
|
||||
String display = credentialScope.getVcDisplay();
|
||||
if (StringUtil.isBlank(display)) {
|
||||
return null;
|
||||
}
|
||||
TypeReference<List<DisplayObject>> typeReference = new TypeReference<>() {};
|
||||
try {
|
||||
return JsonSerialization.mapper.readValue(display, typeReference);
|
||||
} catch (JsonProcessingException e) {
|
||||
// lets say we have an invalid value we should not kill the whole execution if just the display value is
|
||||
// broken
|
||||
LOGGER.debug(e.getMessage(), e);
|
||||
LOGGER.info(String.format("Failed to parse display-metadata for credential: %s", credentialScope.getName()),
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
@ -165,4 +191,13 @@ public class DisplayObject {
|
||||
result = 31 * result + (getTextColor() != null ? getTextColor().hashCode() : 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
try {
|
||||
return JsonSerialization.mapper.writeValueAsString(this);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Pojo, containing all information required to create a VCClient.
|
||||
*
|
||||
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
||||
*/
|
||||
public class OID4VCClient {
|
||||
|
||||
/**
|
||||
* Id of the client.
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* Did of the target/client, will be used as client-id
|
||||
*/
|
||||
private String clientDid;
|
||||
/**
|
||||
* Comma-separated list of supported credentials types
|
||||
*/
|
||||
private List<SupportedCredentialConfiguration> supportedVCTypes;
|
||||
/**
|
||||
* Description of the client, will f.e. be displayed in the admin-console
|
||||
*/
|
||||
private String description;
|
||||
/**
|
||||
* Human-readable name of the client
|
||||
*/
|
||||
private String name;
|
||||
|
||||
public OID4VCClient() {
|
||||
}
|
||||
|
||||
public OID4VCClient(String id, String clientDid, List<SupportedCredentialConfiguration> supportedVCTypes, String description, String name) {
|
||||
this.id = id;
|
||||
this.clientDid = clientDid;
|
||||
this.supportedVCTypes = supportedVCTypes;
|
||||
this.description = description;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public OID4VCClient setId(String id) {
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getClientDid() {
|
||||
return clientDid;
|
||||
}
|
||||
|
||||
public OID4VCClient setClientDid(String clientDid) {
|
||||
this.clientDid = clientDid;
|
||||
return this;
|
||||
}
|
||||
|
||||
public List<SupportedCredentialConfiguration> getSupportedVCTypes() {
|
||||
return supportedVCTypes;
|
||||
}
|
||||
|
||||
public OID4VCClient setSupportedVCTypes(List<SupportedCredentialConfiguration> supportedVCTypes) {
|
||||
this.supportedVCTypes = Collections.unmodifiableList(supportedVCTypes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public OID4VCClient setDescription(String description) {
|
||||
this.description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public OID4VCClient setName(String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof OID4VCClient that)) return false;
|
||||
|
||||
if (getId() != null ? !getId().equals(that.getId()) : that.getId() != null) return false;
|
||||
if (getClientDid() != null ? !getClientDid().equals(that.getClientDid()) : that.getClientDid() != null)
|
||||
return false;
|
||||
if (getSupportedVCTypes() != null ? !getSupportedVCTypes().equals(that.getSupportedVCTypes()) : that.getSupportedVCTypes() != null)
|
||||
return false;
|
||||
if (getDescription() != null ? !getDescription().equals(that.getDescription()) : that.getDescription() != null)
|
||||
return false;
|
||||
return getName() != null ? getName().equals(that.getName()) : that.getName() == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = getId() != null ? getId().hashCode() : 0;
|
||||
result = 31 * result + (getClientDid() != null ? getClientDid().hashCode() : 0);
|
||||
result = 31 * result + (getSupportedVCTypes() != null ? getSupportedVCTypes().hashCode() : 0);
|
||||
result = 31 * result + (getDescription() != null ? getDescription().hashCode() : 0);
|
||||
result = 31 * result + (getName() != null ? getName().hashCode() : 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -16,11 +16,19 @@
|
||||
*/
|
||||
package org.keycloak.protocol.oid4vc.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.ProofValidator;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
@ -30,39 +38,23 @@ import java.util.Objects;
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ProofTypesSupported {
|
||||
@JsonProperty("jwt")
|
||||
private ProofTypeJWT jwt;
|
||||
|
||||
@JsonProperty("ldp_vp")
|
||||
private ProofTypeLdpVp ldpVp;
|
||||
protected Map<String, SupportedProofTypeData> supportedProofTypes = new HashMap<>();
|
||||
|
||||
public ProofTypeJWT getJwt() {
|
||||
return jwt;
|
||||
public static ProofTypesSupported parse(KeycloakSession keycloakSession,
|
||||
List<String> globalSupportedSigningAlgorithms) {
|
||||
ProofTypesSupported proofTypesSupported = new ProofTypesSupported();
|
||||
keycloakSession.getAllProviders(ProofValidator.class).forEach(proofValidator -> {
|
||||
String type = proofValidator.getProofType();
|
||||
KeyAttestationRequired keyAttestationRequired = null; // TODO
|
||||
SupportedProofTypeData supportedProofTypeData = new SupportedProofTypeData(globalSupportedSigningAlgorithms,
|
||||
keyAttestationRequired);
|
||||
proofTypesSupported.getSupportedProofTypes().put(type, supportedProofTypeData);
|
||||
});
|
||||
return proofTypesSupported;
|
||||
}
|
||||
|
||||
public ProofTypesSupported setJwt(ProofTypeJWT jwt) {
|
||||
this.jwt = jwt;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ProofTypeLdpVp getLdpVp() {
|
||||
return ldpVp;
|
||||
}
|
||||
|
||||
public ProofTypesSupported setLdpVp(ProofTypeLdpVp ldpVp) {
|
||||
this.ldpVp = ldpVp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String toJsonString(){
|
||||
try {
|
||||
return JsonSerialization.writeValueAsString(this);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static ProofTypesSupported fromJsonString(String jsonString){
|
||||
public static ProofTypesSupported fromJsonString(String jsonString) {
|
||||
try {
|
||||
return JsonSerialization.readValue(jsonString, ProofTypesSupported.class);
|
||||
} catch (IOException e) {
|
||||
@ -70,16 +62,141 @@ public class ProofTypesSupported {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonAnyGetter
|
||||
public Map<String, SupportedProofTypeData> getSupportedProofTypes() {
|
||||
return supportedProofTypes;
|
||||
}
|
||||
|
||||
@JsonAnySetter
|
||||
public ProofTypesSupported setSupportedProofTypes(String name, SupportedProofTypeData value) {
|
||||
supportedProofTypes.put(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public String toJsonString() {
|
||||
try {
|
||||
return JsonSerialization.writeValueAsString(this);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ProofTypesSupported that = (ProofTypesSupported) o;
|
||||
return Objects.equals(jwt, that.jwt) && Objects.equals(ldpVp, that.ldpVp);
|
||||
public final boolean equals(Object o) {
|
||||
if (!(o instanceof ProofTypesSupported that)) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(supportedProofTypes, that.supportedProofTypes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(jwt, ldpVp);
|
||||
return Objects.hashCode(supportedProofTypes);
|
||||
}
|
||||
|
||||
public static class SupportedProofTypeData {
|
||||
|
||||
@JsonProperty("proof_signing_alg_values_supported")
|
||||
private List<String> signingAlgorithmsSupported;
|
||||
|
||||
@JsonProperty("key_attestations_required")
|
||||
private KeyAttestationRequired keyAttestationRequired;
|
||||
|
||||
public SupportedProofTypeData() {
|
||||
}
|
||||
|
||||
public SupportedProofTypeData(List<String> signingAlgorithmsSupported,
|
||||
KeyAttestationRequired keyAttestationRequired) {
|
||||
this.signingAlgorithmsSupported = signingAlgorithmsSupported;
|
||||
this.keyAttestationRequired = keyAttestationRequired;
|
||||
}
|
||||
|
||||
public List<String> getSigningAlgorithmsSupported() {
|
||||
return signingAlgorithmsSupported;
|
||||
}
|
||||
|
||||
public SupportedProofTypeData setSigningAlgorithmsSupported(List<String> signingAlgorithmsSupported) {
|
||||
this.signingAlgorithmsSupported = signingAlgorithmsSupported;
|
||||
return this;
|
||||
}
|
||||
|
||||
public KeyAttestationRequired getKeyAttestationRequired() {
|
||||
return keyAttestationRequired;
|
||||
}
|
||||
|
||||
public SupportedProofTypeData setKeyAttestationRequired(KeyAttestationRequired keyAttestationRequired) {
|
||||
this.keyAttestationRequired = keyAttestationRequired;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final boolean equals(Object o) {
|
||||
if (!(o instanceof SupportedProofTypeData that)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Objects.equals(signingAlgorithmsSupported,
|
||||
that.signingAlgorithmsSupported) && Objects.equals(keyAttestationRequired,
|
||||
that.keyAttestationRequired);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Objects.hashCode(signingAlgorithmsSupported);
|
||||
result = 31 * result + Objects.hashCode(keyAttestationRequired);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static class KeyAttestationRequired {
|
||||
|
||||
@JsonProperty("key_storage")
|
||||
private List<String> keyStorage = new ArrayList<>();
|
||||
|
||||
@JsonProperty("user_authentication")
|
||||
private List<String> userAuthentication = new ArrayList<>();
|
||||
|
||||
public KeyAttestationRequired() {
|
||||
}
|
||||
|
||||
public KeyAttestationRequired(List<String> keyStorage, List<String> userAuthentication) {
|
||||
this.keyStorage = keyStorage;
|
||||
this.userAuthentication = userAuthentication;
|
||||
}
|
||||
|
||||
public List<String> getKeyStorage() {
|
||||
return keyStorage;
|
||||
}
|
||||
|
||||
public KeyAttestationRequired setKeyStorage(List<String> keyStorage) {
|
||||
this.keyStorage = keyStorage;
|
||||
return this;
|
||||
}
|
||||
|
||||
public List<String> getUserAuthentication() {
|
||||
return userAuthentication;
|
||||
}
|
||||
|
||||
public KeyAttestationRequired setUserAuthentication(List<String> userAuthentication) {
|
||||
this.userAuthentication = userAuthentication;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final boolean equals(Object o) {
|
||||
if (!(o instanceof KeyAttestationRequired that)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Objects.equals(keyStorage, that.keyStorage) && Objects.equals(userAuthentication,
|
||||
that.userAuthentication);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Objects.hashCode(keyStorage);
|
||||
result = 31 * result + Objects.hashCode(userAuthentication);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,15 +19,14 @@ package org.keycloak.protocol.oid4vc.model;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.apache.commons.collections4.ListUtils;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* A supported credential, as used in the Credentials Issuer Metadata in OID4VCI
|
||||
@ -45,8 +44,6 @@ public class SupportedCredentialConfiguration {
|
||||
@JsonIgnore
|
||||
private static final String CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY = "cryptographic_binding_methods_supported";
|
||||
@JsonIgnore
|
||||
private static final String CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY = "cryptographic_suites_supported";
|
||||
@JsonIgnore
|
||||
private static final String CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY = "credential_signing_alg_values_supported";
|
||||
@JsonIgnore
|
||||
private static final String DISPLAY_KEY = "display";
|
||||
@ -72,9 +69,6 @@ public class SupportedCredentialConfiguration {
|
||||
@JsonProperty(CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY)
|
||||
private List<String> cryptographicBindingMethodsSupported;
|
||||
|
||||
@JsonProperty(CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY)
|
||||
private List<String> cryptographicSuitesSupported;
|
||||
|
||||
@JsonProperty(CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY)
|
||||
private List<String> credentialSigningAlgValuesSupported;
|
||||
|
||||
@ -98,20 +92,69 @@ public class SupportedCredentialConfiguration {
|
||||
@JsonIgnore
|
||||
private CredentialBuildConfig credentialBuildConfig;
|
||||
|
||||
public String getFormat() {
|
||||
return format;
|
||||
/**
|
||||
* @param credentialScope The scope that holds the credentials configuration
|
||||
* @param globalSupportedSigningAlgorithms added as a parameter to avoid reading the global config from the session
|
||||
* for each credential
|
||||
* @return the credentials configuration that was entered into the ClientScope
|
||||
*/
|
||||
public static SupportedCredentialConfiguration parse(KeycloakSession keycloakSession,
|
||||
CredentialScopeModel credentialScope,
|
||||
List<String> globalSupportedSigningAlgorithms) {
|
||||
SupportedCredentialConfiguration credentialConfiguration = new SupportedCredentialConfiguration();
|
||||
|
||||
String credentialConfigurationId = Optional.ofNullable(credentialScope.getCredentialConfigurationId())
|
||||
.orElse(credentialScope.getName());
|
||||
credentialConfiguration.setId(credentialConfigurationId);
|
||||
|
||||
credentialConfiguration.setScope(credentialScope.getName());
|
||||
|
||||
String format = Optional.ofNullable(credentialScope.getFormat()).orElse(Format.SD_JWT_VC);
|
||||
credentialConfiguration.setFormat(format);
|
||||
|
||||
String vct = Optional.ofNullable(credentialScope.getVct()).orElse(credentialScope.getName());
|
||||
credentialConfiguration.setVct(vct);
|
||||
|
||||
CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope);
|
||||
credentialConfiguration.setCredentialDefinition(credentialDefinition);
|
||||
|
||||
ProofTypesSupported proofTypesSupported = ProofTypesSupported.parse(keycloakSession,
|
||||
globalSupportedSigningAlgorithms);
|
||||
credentialConfiguration.setProofTypesSupported(proofTypesSupported);
|
||||
|
||||
List<String> signingAlgsSupported = credentialScope.getSigningAlgsSupported();
|
||||
signingAlgsSupported = signingAlgsSupported.isEmpty() ? globalSupportedSigningAlgorithms :
|
||||
// if the config has listed different algorithms than supported by keycloak we must use the
|
||||
// intersection of the configuration with the actual supported algorithms.
|
||||
ListUtils.intersection(signingAlgsSupported, globalSupportedSigningAlgorithms);
|
||||
credentialConfiguration.setCredentialSigningAlgValuesSupported(signingAlgsSupported);
|
||||
|
||||
// TODO resolve value dynamically from provider implementations?
|
||||
String bindingMethodsSupported = CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT;
|
||||
credentialConfiguration.setCryptographicBindingMethodsSupported(List.of(bindingMethodsSupported));
|
||||
|
||||
credentialConfiguration.setDisplay(DisplayObject.parse(credentialScope));
|
||||
|
||||
CredentialBuildConfig credentialBuildConfig = CredentialBuildConfig.parse(keycloakSession,
|
||||
credentialConfiguration,
|
||||
credentialScope);
|
||||
credentialConfiguration.setCredentialBuildConfig(credentialBuildConfig);
|
||||
|
||||
Claims claims = Claims.parse(keycloakSession, credentialScope);
|
||||
credentialConfiguration.setClaims(claims);
|
||||
|
||||
return credentialConfiguration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the verifiable credential type. Sort of confusing in the specification.
|
||||
* For sdjwt, we have a "vct" claim.
|
||||
* See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-6
|
||||
*
|
||||
* For iso mdl (not yet supported) we have a "doctype"
|
||||
* See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-5
|
||||
*
|
||||
* For jwt_vc and ldp_vc, we will be inferring from the "credential_definition"
|
||||
* See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-3
|
||||
* Return the verifiable credential type. Sort of confusing in the specification. For sdjwt, we have a "vct" claim.
|
||||
* See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-6
|
||||
* <p>
|
||||
* For iso mdl (not yet supported) we have a "doctype" See:
|
||||
* https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-5
|
||||
* <p>
|
||||
* For jwt_vc and ldp_vc, we will be inferring from the "credential_definition" See:
|
||||
* https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-3
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@ -126,6 +169,10 @@ public class SupportedCredentialConfiguration {
|
||||
return CredentialConfigId.from(id);
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return format;
|
||||
}
|
||||
|
||||
public SupportedCredentialConfiguration setFormat(String format) {
|
||||
this.format = format;
|
||||
return this;
|
||||
@ -149,15 +196,6 @@ public class SupportedCredentialConfiguration {
|
||||
return this;
|
||||
}
|
||||
|
||||
public List<String> getCryptographicSuitesSupported() {
|
||||
return cryptographicSuitesSupported;
|
||||
}
|
||||
|
||||
public SupportedCredentialConfiguration setCryptographicSuitesSupported(List<String> cryptographicSuitesSupported) {
|
||||
this.cryptographicSuitesSupported = Collections.unmodifiableList(cryptographicSuitesSupported);
|
||||
return this;
|
||||
}
|
||||
|
||||
public List<DisplayObject> getDisplay() {
|
||||
return display;
|
||||
}
|
||||
@ -172,9 +210,6 @@ public class SupportedCredentialConfiguration {
|
||||
}
|
||||
|
||||
public SupportedCredentialConfiguration setId(String id) {
|
||||
if (id.contains(".")) {
|
||||
throw new IllegalArgumentException("dots are not supported as part of the supported credentials id.");
|
||||
}
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
@ -233,90 +268,16 @@ public class SupportedCredentialConfiguration {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Map<String, String> toDotNotation() {
|
||||
Map<String, String> dotNotation = new HashMap<>();
|
||||
Optional.ofNullable(format).ifPresent(format -> dotNotation.put(id + DOT_SEPARATOR + FORMAT_KEY, format));
|
||||
Optional.ofNullable(vct).ifPresent(vct -> dotNotation.put(id + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY, vct));
|
||||
Optional.ofNullable(scope).ifPresent(scope -> dotNotation.put(id + DOT_SEPARATOR + SCOPE_KEY, scope));
|
||||
Optional.ofNullable(cryptographicBindingMethodsSupported).ifPresent(types ->
|
||||
dotNotation.put(id + DOT_SEPARATOR + CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY, String.join(",", cryptographicBindingMethodsSupported)));
|
||||
Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types ->
|
||||
dotNotation.put(id + DOT_SEPARATOR + CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY, String.join(",", cryptographicSuitesSupported)));
|
||||
Optional.ofNullable(cryptographicSuitesSupported).ifPresent(types ->
|
||||
dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY, String.join(",", credentialSigningAlgValuesSupported)));
|
||||
Optional.ofNullable(claims).ifPresent(c -> dotNotation.put(id + DOT_SEPARATOR + CLAIMS_KEY, c.toJsonString()));
|
||||
Optional.ofNullable(credentialDefinition).ifPresent(cdef -> dotNotation.put(id + DOT_SEPARATOR + CREDENTIAL_DEFINITION_KEY, cdef.toJsonString()));
|
||||
|
||||
Optional.ofNullable(display)
|
||||
.ifPresent(d -> d.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(o -> dotNotation.put(id + DOT_SEPARATOR + DISPLAY_KEY + DOT_SEPARATOR + d.indexOf(o), o.toJsonString())));
|
||||
|
||||
Optional.ofNullable(proofTypesSupported)
|
||||
.ifPresent(p -> dotNotation.put(id + DOT_SEPARATOR + PROOF_TYPES_SUPPORTED_KEY, p.toJsonString()));
|
||||
|
||||
Optional.ofNullable(credentialBuildConfig)
|
||||
.ifPresent(p -> dotNotation.putAll(credentialBuildConfig.toDotNotation()));
|
||||
|
||||
return dotNotation;
|
||||
}
|
||||
|
||||
public static SupportedCredentialConfiguration fromDotNotation(String credentialId, Map<String, String> dotNotated) {
|
||||
|
||||
SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration().setId(credentialId);
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + FORMAT_KEY)).ifPresent(supportedCredentialConfiguration::setFormat);
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + VERIFIABLE_CREDENTIAL_TYPE_KEY)).ifPresent(supportedCredentialConfiguration::setVct);
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + SCOPE_KEY)).ifPresent(supportedCredentialConfiguration::setScope);
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CRYPTOGRAPHIC_BINDING_METHODS_SUPPORTED_KEY))
|
||||
.map(cbms -> cbms.split(","))
|
||||
.map(Arrays::asList)
|
||||
.ifPresent(supportedCredentialConfiguration::setCryptographicBindingMethodsSupported);
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CRYPTOGRAPHIC_SUITES_SUPPORTED_KEY))
|
||||
.map(css -> css.split(","))
|
||||
.map(Arrays::asList)
|
||||
.ifPresent(supportedCredentialConfiguration::setCryptographicSuitesSupported);
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CREDENTIAL_SIGNING_ALG_VALUES_SUPPORTED_KEY))
|
||||
.map(css -> css.split(","))
|
||||
.map(Arrays::asList)
|
||||
.ifPresent(supportedCredentialConfiguration::setCredentialSigningAlgValuesSupported);
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CLAIMS_KEY))
|
||||
.map(Claims::fromJsonString)
|
||||
.ifPresent(supportedCredentialConfiguration::setClaims);
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + CREDENTIAL_DEFINITION_KEY))
|
||||
.map(CredentialDefinition::fromJsonString)
|
||||
.ifPresent(supportedCredentialConfiguration::setCredentialDefinition);
|
||||
|
||||
String displayKeyPrefix = credentialId + DOT_SEPARATOR + DISPLAY_KEY + DOT_SEPARATOR;
|
||||
List<DisplayObject> displayList = dotNotated.entrySet().stream()
|
||||
.filter(entry -> entry.getKey().startsWith(displayKeyPrefix))
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.map(entry -> DisplayObject.fromJsonString(entry.getValue()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!displayList.isEmpty()){
|
||||
supportedCredentialConfiguration.setDisplay(displayList);
|
||||
}
|
||||
|
||||
Optional.ofNullable(dotNotated.get(credentialId + DOT_SEPARATOR + PROOF_TYPES_SUPPORTED_KEY))
|
||||
.map(ProofTypesSupported::fromJsonString)
|
||||
.ifPresent(supportedCredentialConfiguration::setProofTypesSupported);
|
||||
|
||||
Optional.ofNullable(CredentialBuildConfig.fromDotNotation(credentialId, dotNotated))
|
||||
.ifPresent(supportedCredentialConfiguration::setCredentialBuildConfig);
|
||||
|
||||
return supportedCredentialConfiguration;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
SupportedCredentialConfiguration that = (SupportedCredentialConfiguration) o;
|
||||
return Objects.equals(id, that.id) && Objects.equals(format, that.format) && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(cryptographicSuitesSupported, that.cryptographicSuitesSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(credentialDefinition, that.credentialDefinition) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims) && Objects.equals(credentialBuildConfig, that.credentialBuildConfig);
|
||||
return Objects.equals(id, that.id) && Objects.equals(format, that.format) && Objects.equals(scope, that.scope) && Objects.equals(cryptographicBindingMethodsSupported, that.cryptographicBindingMethodsSupported) && Objects.equals(credentialSigningAlgValuesSupported, that.credentialSigningAlgValuesSupported) && Objects.equals(display, that.display) && Objects.equals(vct, that.vct) && Objects.equals(credentialDefinition, that.credentialDefinition) && Objects.equals(proofTypesSupported, that.proofTypesSupported) && Objects.equals(claims, that.claims) && Objects.equals(credentialBuildConfig, that.credentialBuildConfig);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, cryptographicSuitesSupported, credentialSigningAlgValuesSupported, display, vct, credentialDefinition, proofTypesSupported, claims, credentialBuildConfig);
|
||||
return Objects.hash(id, format, scope, cryptographicBindingMethodsSupported, credentialSigningAlgValuesSupported, display, vct, credentialDefinition, proofTypesSupported, claims, credentialBuildConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -1,150 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.protocol.oid4vc;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.runners.Parameterized;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig;
|
||||
import org.keycloak.protocol.oid4vc.model.DisplayObject;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
|
||||
import org.keycloak.protocol.oid4vc.model.ProofTypeJWT;
|
||||
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
@RunWith(Parameterized.class)
|
||||
public class OID4VCClientRegistrationProviderTest {
|
||||
|
||||
@Parameterized.Parameters(name = "{0}")
|
||||
public static Collection<Object[]> parameters() {
|
||||
return Arrays.asList(new Object[][]{
|
||||
{
|
||||
"Single Supported Credential with format and single-type.",
|
||||
Map.of(
|
||||
"vc.credential-id.format", Format.JWT_VC,
|
||||
"vc.credential-id.scope", "VerifiableCredential"),
|
||||
new OID4VCClient(null, "did:web:test.org",
|
||||
List.of(new SupportedCredentialConfiguration()
|
||||
.setId("credential-id")
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setScope("VerifiableCredential")),
|
||||
null, null)
|
||||
},
|
||||
{
|
||||
"Single Supported Credential with format and multi-type.",
|
||||
Map.of(
|
||||
"vc.credential-id.format", Format.JWT_VC,
|
||||
"vc.credential-id.scope", "AnotherCredential"),
|
||||
new OID4VCClient(null, "did:web:test.org",
|
||||
List.of(new SupportedCredentialConfiguration()
|
||||
.setId("credential-id")
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setScope("AnotherCredential")),
|
||||
null, null)
|
||||
},
|
||||
{
|
||||
"Single Supported Credential with format, multi-type and a display object.",
|
||||
Map.of(
|
||||
"vc.credential-id.format", Format.JWT_VC,
|
||||
"vc.credential-id.scope", "AnotherCredential",
|
||||
"vc.credential-id.display.0", "{\"name\":\"Another\",\"locale\":\"en\"}"),
|
||||
new OID4VCClient(null, "did:web:test.org",
|
||||
List.of(new SupportedCredentialConfiguration()
|
||||
.setId("credential-id")
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setDisplay(Arrays.asList(new DisplayObject().setLocale("en").setName("Another")))
|
||||
.setScope("AnotherCredential")),
|
||||
null, null)
|
||||
},
|
||||
{
|
||||
"Multiple Supported Credentials.",
|
||||
Map.of(
|
||||
"vc.first-id.format", Format.JWT_VC,
|
||||
"vc.first-id.scope", "AnotherCredential",
|
||||
"vc.first-id.display.0", "{\"name\":\"First\",\"locale\":\"en\"}",
|
||||
"vc.second-id.format", Format.SD_JWT_VC,
|
||||
"vc.second-id.scope", "MyType",
|
||||
"vc.second-id.display.0", "{\"name\":\"Second Credential\",\"locale\":\"de\"}",
|
||||
"vc.second-id.proof_types_supported","{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"),
|
||||
new OID4VCClient(null, "did:web:test.org",
|
||||
List.of(new SupportedCredentialConfiguration()
|
||||
.setId("first-id")
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setDisplay(Arrays.asList(new DisplayObject().setLocale("en").setName("First")))
|
||||
.setScope("AnotherCredential"),
|
||||
new SupportedCredentialConfiguration()
|
||||
.setId("second-id")
|
||||
.setFormat(Format.SD_JWT_VC)
|
||||
.setDisplay(Arrays.asList(new DisplayObject().setLocale("de").setName("Second Credential")))
|
||||
.setScope("MyType")
|
||||
.setProofTypesSupported(new ProofTypesSupported().setJwt(new ProofTypeJWT().setProofSigningAlgValuesSupported(Arrays.asList("ES256"))))),
|
||||
null, null)
|
||||
},
|
||||
{
|
||||
"Single Supported Credential with credential build config.",
|
||||
Map.of(
|
||||
"vc.credential-id.format", Format.JWT_VC,
|
||||
"vc.credential-id.scope", "VerifiableCredential",
|
||||
"vc.credential-id.credential_build_config.token_jws_type", "JWT"
|
||||
),
|
||||
new OID4VCClient(null, "did:web:test.org",
|
||||
List.of(new SupportedCredentialConfiguration()
|
||||
.setId("credential-id")
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setScope("VerifiableCredential")
|
||||
.setCredentialBuildConfig(
|
||||
new CredentialBuildConfig()
|
||||
.setCredentialId("credential-id")
|
||||
.setTokenJwsType("JWT"))),
|
||||
null, null)
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, String> clientAttributes;
|
||||
private OID4VCClient oid4VCClient;
|
||||
|
||||
public OID4VCClientRegistrationProviderTest(String name, Map<String, String> clientAttributes, OID4VCClient oid4VCClient) {
|
||||
this.clientAttributes = clientAttributes;
|
||||
this.oid4VCClient = oid4VCClient;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testToClientRepresentation() {
|
||||
Map<String, String> translatedAttributes = OID4VCClientRegistrationProvider.toClientRepresentation(oid4VCClient).getAttributes();
|
||||
|
||||
assertEquals("The client should have been translated into the correct clientRepresentation.", clientAttributes.entrySet().size(), translatedAttributes.size());
|
||||
clientAttributes.forEach((key, value) ->
|
||||
assertEquals("The client should have been translated into the correct clientRepresentation.", clientAttributes.get(key), translatedAttributes.get(key)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFromClientAttributes() {
|
||||
assertEquals("The client should have been correctly build from the client representation",
|
||||
oid4VCClient,
|
||||
OID4VCClientRegistrationProvider.fromClientAttributes("did:web:test.org", clientAttributes));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
import org.keycloak.jose.jws.JWSInputException;
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBody;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
|
||||
@ -41,7 +42,7 @@ import static org.junit.Assert.assertEquals;
|
||||
public class JwtCredentialBuilderTest extends CredentialBuilderTest {
|
||||
|
||||
TimeProvider timeProvider = new StaticTimeProvider(1000);
|
||||
JwtCredentialBuilder builder = new JwtCredentialBuilder(TEST_DID.toString(), timeProvider);
|
||||
JwtCredentialBuilder builder = new JwtCredentialBuilder(timeProvider);
|
||||
|
||||
@Test
|
||||
public void shouldBuildJwtCredentialSuccessfully() throws Exception {
|
||||
@ -84,7 +85,7 @@ public class JwtCredentialBuilderTest extends CredentialBuilderTest {
|
||||
|
||||
private JsonNode parseCredentialSubject(JWSInput jwsInput) throws JWSInputException {
|
||||
JsonNode payload = jwsInput.readJsonContent(JsonNode.class);
|
||||
return payload.get("vc").get("credentialSubject");
|
||||
return payload.get("vc").get(Oid4VciConstants.CREDENTIAL_SUBJECT);
|
||||
}
|
||||
|
||||
private Map<String, Object> exampleCredentialClaims() {
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -161,6 +161,7 @@ public class JwtCredentialSignerTest extends OID4VCTest {
|
||||
public static void testSignJwtCredential(
|
||||
KeycloakSession session, String signingKeyId, String algorithm, Map<String, Object> claims) {
|
||||
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
|
||||
.setCredentialIssuer(TEST_DID.toString())
|
||||
.setTokenJwsType("JWT")
|
||||
.setSigningKeyId(signingKeyId)
|
||||
.setSigningAlgorithm(algorithm);
|
||||
@ -169,7 +170,6 @@ public class JwtCredentialSignerTest extends OID4VCTest {
|
||||
|
||||
VerifiableCredential testCredential = getTestCredential(claims);
|
||||
JwtCredentialBuilder builder = new JwtCredentialBuilder(
|
||||
TEST_DID.toString(),
|
||||
new StaticTimeProvider(1000)
|
||||
);
|
||||
|
||||
|
||||
@ -169,6 +169,7 @@ public class LDCredentialSignerTest extends OID4VCTest {
|
||||
KeycloakSession session, String signingKeyId, Map<String, Object> claims,
|
||||
String overrideKeyId, String ldpProofType) {
|
||||
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
|
||||
.setCredentialIssuer(TEST_DID.toString())
|
||||
.setTokenJwsType("JWT")
|
||||
.setSigningKeyId(signingKeyId)
|
||||
.setSigningAlgorithm("EdDSA")
|
||||
@ -179,7 +180,7 @@ public class LDCredentialSignerTest extends OID4VCTest {
|
||||
session, new StaticTimeProvider(1000));
|
||||
|
||||
VerifiableCredential testCredential = getTestCredential(claims);
|
||||
LDCredentialBody ldCredentialBody = new LDCredentialBuilder(TEST_DID.toString())
|
||||
LDCredentialBody ldCredentialBody = new LDCredentialBuilder()
|
||||
.buildCredentialBody(testCredential, credentialBuildConfig);
|
||||
|
||||
VerifiableCredential verifiableCredential = ldCredentialSigner
|
||||
|
||||
@ -25,6 +25,7 @@ import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.ContentType;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
@ -40,7 +41,10 @@ import org.keycloak.common.crypto.CryptoIntegration;
|
||||
import org.keycloak.common.util.MultivaluedHashMap;
|
||||
import org.keycloak.common.util.SecretGenerator;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
@ -48,21 +52,24 @@ import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactor
|
||||
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.DisplayObject;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.utils.OAuth2Code;
|
||||
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||
import org.keycloak.representations.idm.ComponentExportRepresentation;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.RoleRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
import org.keycloak.services.managers.AuthenticationManager;
|
||||
import org.keycloak.services.resources.RealmsResource;
|
||||
@ -75,12 +82,15 @@ import org.keycloak.testsuite.util.oauth.OAuthClient;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@ -97,187 +107,31 @@ import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.client
|
||||
public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
|
||||
protected static final TimeProvider TIME_PROVIDER = new OID4VCTest.StaticTimeProvider(1000);
|
||||
protected static final String sdJwtCredentialVct = "https://credentials.example.com/SD-JWT-Credential";
|
||||
|
||||
protected static ClientScopeRepresentation sdJwtTypeCredentialClientScope;
|
||||
protected static ClientScopeRepresentation jwtTypeCredentialClientScope;
|
||||
protected static ClientScopeRepresentation minimalJwtTypeCredentialClientScope;
|
||||
|
||||
protected CloseableHttpClient httpClient;
|
||||
public static String verifiableCredentialScopeName = "VerifiableCredential";
|
||||
public static String testCredentialScopeName = "test-credential";
|
||||
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
CryptoIntegration.init(this.getClass().getClassLoader());
|
||||
httpClient = HttpClientBuilder.create().build();
|
||||
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
|
||||
|
||||
// Register the optional client scopes
|
||||
String verifiableCredentialScopeId = registerOptionalClientScope(verifiableCredentialScopeName, client.getClientId());
|
||||
String testCredentialScopeId = registerOptionalClientScope(testCredentialScopeName, client.getClientId());
|
||||
|
||||
// Assign the registered optional client scopes to the client
|
||||
assignOptionalClientScopeToClient(verifiableCredentialScopeId, client.getClientId());
|
||||
assignOptionalClientScopeToClient(testCredentialScopeId, client.getClientId());
|
||||
}
|
||||
|
||||
|
||||
protected String getBearerToken(OAuthClient oAuthClient) {
|
||||
AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john", "password");
|
||||
return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode()).getAccessToken();
|
||||
}
|
||||
|
||||
private ClientResource findClientByClientId(RealmResource realm, String clientId) {
|
||||
for (ClientRepresentation c : realm.clients().findAll()) {
|
||||
if (clientId.equals(c.getClientId())) {
|
||||
return realm.clients().get(c.getId());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String registerOptionalClientScope(String scopeName, String clientId) {
|
||||
// Check if the client scope already exists
|
||||
List<ClientScopeRepresentation> existingScopes = testRealm().clientScopes().findAll();
|
||||
for (ClientScopeRepresentation existingScope : existingScopes) {
|
||||
if (existingScope.getName().equals(scopeName)) {
|
||||
return existingScope.getId(); // Reuse existing scope
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new ClientScope if not found
|
||||
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
|
||||
clientScope.setName(scopeName);
|
||||
clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||
|
||||
Response res = testRealm().clientScopes().create(clientScope);
|
||||
String scopeId = ApiUtil.getCreatedId(res);
|
||||
getCleanup().addClientScopeId(scopeId); // Automatically removed when a test method is finished.
|
||||
res.close();
|
||||
|
||||
// Add protocol mappers to the ClientScope
|
||||
addProtocolMappersToClientScope(scopeId, scopeName, clientId);
|
||||
|
||||
return scopeId;
|
||||
}
|
||||
|
||||
private void assignOptionalClientScopeToClient(String scopeId, String clientId) {
|
||||
ClientResource clientResource = findClientByClientId(testRealm(), clientId);
|
||||
clientResource.addOptionalClientScope(scopeId);
|
||||
}
|
||||
|
||||
private void addCredentialConfigurationIdToClient(String clientId, String credentialConfigurationId, String format, String scope) {
|
||||
ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0);
|
||||
ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId());
|
||||
|
||||
clientRepresentation.setAttributes(Map.of(
|
||||
"vc." + credentialConfigurationId + ".format", format,
|
||||
"vc." + credentialConfigurationId + ".scope", scope));
|
||||
|
||||
clientResource.update(clientRepresentation);
|
||||
}
|
||||
|
||||
private void removeCredentialConfigurationIdToClient(String clientId) {
|
||||
ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0);
|
||||
ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId());
|
||||
clientRepresentation.setAttributes(Map.of());
|
||||
clientResource.update(clientRepresentation);
|
||||
}
|
||||
|
||||
private void logoutUser(String clientId, String username) {
|
||||
UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(TEST_REALM_NAME), username);
|
||||
user.logout();
|
||||
}
|
||||
|
||||
private void testCredentialIssuanceWithAuthZCodeFlow(Consumer<Map<String, String>> c) throws Exception {
|
||||
// use pre-registered client for this test class whose clientId is "test-app" defined in testrealm.json
|
||||
String testClientId = clientId;
|
||||
|
||||
// use supported values by Credential Issuer Metadata
|
||||
String testCredentialConfigurationId = "test-credential";
|
||||
String testScope = "VerifiableCredential";
|
||||
String testFormat = Format.JWT_VC;
|
||||
|
||||
// register optional client scope
|
||||
String scopeId = registerOptionalClientScope(testScope, testClientId);
|
||||
|
||||
// assign registered optional client scope
|
||||
assignOptionalClientScopeToClient(scopeId, testClientId); // pre-registered client for this test class
|
||||
|
||||
// add credential configuration id to a client as client attributes
|
||||
addCredentialConfigurationIdToClient(testClientId, testCredentialConfigurationId, testFormat, testScope);
|
||||
|
||||
c.accept(Map.of(
|
||||
"clientId", testClientId,
|
||||
"credentialConfigurationId", testCredentialConfigurationId,
|
||||
"scope", testScope,
|
||||
"format", testFormat)
|
||||
);
|
||||
// clean-up
|
||||
logoutUser(testClientId, "john");
|
||||
removeCredentialConfigurationIdToClient(testClientId);
|
||||
oauth.clientId(null);
|
||||
}
|
||||
|
||||
// Tests the AuthZCode complete flow without scope from
|
||||
// 1. Get authorization code without scope specified by wallet
|
||||
// 2. Using the code to get access token
|
||||
// 3. Get the credential configuration id from issuer metadata at .wellKnown
|
||||
// 4. With the access token, get the credential
|
||||
protected void testCredentialIssuanceWithAuthZCodeFlow(BiFunction<String, String, String> f, Consumer<Map<String, Object>> c) throws Exception {
|
||||
testCredentialIssuanceWithAuthZCodeFlow(m -> {
|
||||
String testClientId = m.get("clientId");
|
||||
String testScope = m.get("scope");
|
||||
String testFormat = m.get("format");
|
||||
String testCredentialConfigurationId = m.get("credentialConfigurationId");
|
||||
|
||||
try (Client client = AdminClientUtil.createResteasyClient()) {
|
||||
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
|
||||
URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder).build(TEST_REALM_NAME, OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
|
||||
WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri);
|
||||
|
||||
// 1. Get authoriZation code without scope specified by wallet
|
||||
// 2. Using the code to get accesstoken
|
||||
String token = f.apply(testClientId, testScope);
|
||||
|
||||
// 3. Get the credential configuration id from issuer metadata at .wellKnown
|
||||
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
|
||||
CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class), CredentialIssuer.class);
|
||||
assertEquals(200, discoveryResponse.getStatus());
|
||||
assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer());
|
||||
assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint());
|
||||
|
||||
// 4. With the access token, get the credential
|
||||
try (Client clientForCredentialRequest = AdminClientUtil.createResteasyClient()) {
|
||||
UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint());
|
||||
URI credentialUri = credentialUriBuilder.build();
|
||||
WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri);
|
||||
|
||||
CredentialRequest request = new CredentialRequest();
|
||||
request.setFormat(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat());
|
||||
request.setCredentialIdentifier(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId());
|
||||
|
||||
assertEquals(testFormat, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat().toString());
|
||||
assertEquals(testCredentialConfigurationId, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId());
|
||||
|
||||
c.accept(Map.of(
|
||||
"accessToken", token,
|
||||
"credentialTarget", credentialTarget,
|
||||
"credentialRequest", request
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Assert.fail();
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
protected ClientRepresentation client;
|
||||
|
||||
protected static String prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) {
|
||||
AuthenticationManager.AuthResult authResult = authenticator.authenticate();
|
||||
UserSessionModel userSessionModel = authResult.getSession();
|
||||
AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId());
|
||||
AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient(
|
||||
authResult.getClient().getId());
|
||||
String codeId = SecretGenerator.getInstance().randomString();
|
||||
String nonce = SecretGenerator.getInstance().randomString();
|
||||
OAuth2Code oAuth2Code = new OAuth2Code(codeId, Time.currentTime() + 6000, nonce, CREDENTIAL_OFFER_URI_CODE_SCOPE, null, null, null, null,
|
||||
authenticatedClientSessionModel.getUserSession().getId());
|
||||
OAuth2Code oAuth2Code = new OAuth2Code(codeId,
|
||||
Time.currentTime() + 6000,
|
||||
nonce,
|
||||
CREDENTIAL_OFFER_URI_CODE_SCOPE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
authenticatedClientSessionModel.getUserSession().getId());
|
||||
|
||||
String oauthCode = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oAuth2Code);
|
||||
|
||||
@ -285,15 +139,17 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
return oauthCode;
|
||||
}
|
||||
|
||||
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) {
|
||||
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session,
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator) {
|
||||
JwtCredentialBuilder jwtCredentialBuilder = new JwtCredentialBuilder(
|
||||
TEST_DID.toString(),
|
||||
new StaticTimeProvider(1000));
|
||||
new StaticTimeProvider(1000));
|
||||
SdJwtCredentialBuilder sdJwtCredentialBuilder = new SdJwtCredentialBuilder();
|
||||
|
||||
return prepareIssuerEndpoint(
|
||||
session,
|
||||
authenticator,
|
||||
Map.of(jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder)
|
||||
Map.of(jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder,
|
||||
sdJwtCredentialBuilder.getSupportedFormat(), sdJwtCredentialBuilder)
|
||||
);
|
||||
}
|
||||
|
||||
@ -307,35 +163,271 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
credentialBuilders,
|
||||
authenticator,
|
||||
TIME_PROVIDER,
|
||||
30,
|
||||
true);
|
||||
30);
|
||||
}
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
CryptoIntegration.init(this.getClass().getClassLoader());
|
||||
httpClient = HttpClientBuilder.create().build();
|
||||
client = testRealm().clients().findByClientId(clientId).get(0);
|
||||
|
||||
// Register the optional client scopes
|
||||
sdJwtTypeCredentialClientScope = registerOptionalClientScope(sdJwtTypeCredentialScopeName,
|
||||
null,
|
||||
sdJwtTypeCredentialConfigurationIdName,
|
||||
sdJwtTypeCredentialScopeName,
|
||||
sdJwtCredentialVct,
|
||||
Format.SD_JWT_VC,
|
||||
null);
|
||||
jwtTypeCredentialClientScope = registerOptionalClientScope(jwtTypeCredentialScopeName,
|
||||
TEST_DID.toString(),
|
||||
jwtTypeCredentialConfigurationIdName,
|
||||
jwtTypeCredentialScopeName,
|
||||
null,
|
||||
Format.JWT_VC,
|
||||
TEST_CREDENTIAL_MAPPERS_FILE);
|
||||
minimalJwtTypeCredentialClientScope = registerOptionalClientScope("vc-with-minimal-config",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
// Assign the registered optional client scopes to the client
|
||||
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), client.getClientId());
|
||||
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), client.getClientId());
|
||||
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), client.getClientId());
|
||||
}
|
||||
|
||||
protected String getBearerToken(OAuthClient oAuthClient) {
|
||||
return getBearerToken(oAuthClient, null);
|
||||
}
|
||||
|
||||
protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client) {
|
||||
return getBearerToken(oAuthClient, client, null);
|
||||
}
|
||||
|
||||
protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client, String credentialScopeName) {
|
||||
if (client != null) {
|
||||
oAuthClient.client(client.getClientId(), client.getSecret());
|
||||
}
|
||||
if (credentialScopeName != null) {
|
||||
oAuthClient.scope(credentialScopeName);
|
||||
}
|
||||
AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john",
|
||||
"password");
|
||||
return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode()).getAccessToken();
|
||||
}
|
||||
|
||||
private ClientResource findClientByClientId(RealmResource realm, String clientId) {
|
||||
for (ClientRepresentation c : realm.clients().findAll()) {
|
||||
if (clientId.equals(c.getClientId())) {
|
||||
return realm.clients().get(c.getId());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ClientScopeRepresentation registerOptionalClientScope(String scopeName,
|
||||
String issuerDid,
|
||||
String credentialConfigurationId,
|
||||
String credentialIdentifier,
|
||||
String vct,
|
||||
String format,
|
||||
String protocolMapperReferenceFile) {
|
||||
// Check if the client scope already exists
|
||||
List<ClientScopeRepresentation> existingScopes = testRealm().clientScopes().findAll();
|
||||
for (ClientScopeRepresentation existingScope : existingScopes) {
|
||||
if (existingScope.getName().equals(scopeName)) {
|
||||
return existingScope; // Reuse existing scope
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new ClientScope if not found
|
||||
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
|
||||
clientScope.setName(scopeName);
|
||||
clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
Map<String, String> attributes =
|
||||
new HashMap<>(Map.of(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "true",
|
||||
CredentialScopeModel.EXPIRY_IN_SECONDS, "15"));
|
||||
BiConsumer<String, String> addAttribute = (attributeName, value) -> {
|
||||
if (value != null) {
|
||||
attributes.put(attributeName, value);
|
||||
}
|
||||
};
|
||||
addAttribute.accept(CredentialScopeModel.ISSUER_DID, issuerDid);
|
||||
addAttribute.accept(CredentialScopeModel.CONFIGURATION_ID, credentialConfigurationId);
|
||||
addAttribute.accept(CredentialScopeModel.CREDENTIAL_IDENTIFIER, credentialIdentifier);
|
||||
addAttribute.accept(CredentialScopeModel.FORMAT, format);
|
||||
addAttribute.accept(CredentialScopeModel.VCT, Optional.ofNullable(vct).orElse(credentialIdentifier));
|
||||
if (credentialConfigurationId != null) {
|
||||
String vcDisplay;
|
||||
try {
|
||||
vcDisplay = JsonSerialization.writeValueAsString(List.of(new DisplayObject().setName(credentialConfigurationId)
|
||||
.setLocale("en-EN"),
|
||||
new DisplayObject().setName(credentialConfigurationId)
|
||||
.setLocale("de-DE")));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
addAttribute.accept(CredentialScopeModel.VC_DISPLAY, vcDisplay);
|
||||
}
|
||||
clientScope.setAttributes(attributes);
|
||||
|
||||
Response res = testRealm().clientScopes().create(clientScope);
|
||||
String scopeId = ApiUtil.getCreatedId(res);
|
||||
getCleanup().addClientScopeId(scopeId); // Automatically removed when a test method is finished.
|
||||
res.close();
|
||||
|
||||
clientScope.setId(scopeId);
|
||||
|
||||
// Add protocol mappers to the ClientScope
|
||||
List<ProtocolMapperRepresentation> protocolMappers;
|
||||
if (protocolMapperReferenceFile == null) {
|
||||
protocolMappers = getProtocolMappers(scopeName);
|
||||
addProtocolMappersToClientScope(clientScope, protocolMappers);
|
||||
}
|
||||
else {
|
||||
protocolMappers = resolveProtocolMappers(protocolMapperReferenceFile);
|
||||
protocolMappers.add(getStaticClaimMapper(scopeName));
|
||||
addProtocolMappersToClientScope(clientScope, protocolMappers);
|
||||
}
|
||||
clientScope.setProtocolMappers(protocolMappers);
|
||||
return clientScope;
|
||||
}
|
||||
|
||||
private List<ProtocolMapperRepresentation> resolveProtocolMappers(String protocolMapperReferenceFile) {
|
||||
if (protocolMapperReferenceFile == null) {
|
||||
return null;
|
||||
}
|
||||
try (InputStream inputStream = getClass().getResourceAsStream(protocolMapperReferenceFile)) {
|
||||
return JsonSerialization.mapper.readValue(inputStream,
|
||||
ClientScopeRepresentation.class).getProtocolMappers();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void assignOptionalClientScopeToClient(String scopeId, String clientId) {
|
||||
ClientResource clientResource = findClientByClientId(testRealm(), clientId);
|
||||
clientResource.addOptionalClientScope(scopeId);
|
||||
}
|
||||
|
||||
private void logoutUser(String clientId, String username) {
|
||||
UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(TEST_REALM_NAME), username);
|
||||
user.logout();
|
||||
}
|
||||
|
||||
// Tests the AuthZCode complete flow without scope from
|
||||
// 1. Get authorization code without scope specified by wallet
|
||||
// 2. Using the code to get access token
|
||||
// 3. Get the credential configuration id from issuer metadata at .wellKnown
|
||||
// 4. With the access token, get the credential
|
||||
protected void testCredentialIssuanceWithAuthZCodeFlow(ClientScopeRepresentation clientScope,
|
||||
BiFunction<String, String, String> f,
|
||||
Consumer<Map<String, Object>> c) {
|
||||
String testClientId = client.getClientId();
|
||||
String testScope = clientScope.getName();
|
||||
String testFormat = clientScope.getAttributes().get(CredentialScopeModel.FORMAT);
|
||||
String testCredentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
|
||||
try (Client client = AdminClientUtil.createResteasyClient()) {
|
||||
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
|
||||
URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder)
|
||||
.build(TEST_REALM_NAME,
|
||||
OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
|
||||
WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri);
|
||||
|
||||
// 1. Get authoriZation code without scope specified by wallet
|
||||
// 2. Using the code to get accesstoken
|
||||
String token = f.apply(testClientId, testScope);
|
||||
|
||||
// 3. Get the credential configuration id from issuer metadata at .wellKnown
|
||||
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
|
||||
CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class),
|
||||
CredentialIssuer.class);
|
||||
assertEquals(200, discoveryResponse.getStatus());
|
||||
assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer());
|
||||
assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint());
|
||||
|
||||
// 4. With the access token, get the credential
|
||||
try (Client clientForCredentialRequest = AdminClientUtil.createResteasyClient()) {
|
||||
UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint());
|
||||
URI credentialUri = credentialUriBuilder.build();
|
||||
WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri);
|
||||
|
||||
CredentialRequest request = new CredentialRequest();
|
||||
request.setCredentialConfigurationId(oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getId());
|
||||
|
||||
assertEquals(testFormat,
|
||||
oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getFormat());
|
||||
assertEquals(testCredentialConfigurationId,
|
||||
oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getId());
|
||||
|
||||
c.accept(Map.of(
|
||||
"accessToken", token,
|
||||
"credentialTarget", credentialTarget,
|
||||
"credentialRequest", request
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Assert.fail();
|
||||
}
|
||||
}
|
||||
|
||||
protected String getBasePath(String realm) {
|
||||
return getRealmPath(realm) + "/protocol/oid4vc/";
|
||||
}
|
||||
|
||||
private String getRealmPath(String realm) {
|
||||
protected String getRealmPath(String realm) {
|
||||
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realm;
|
||||
}
|
||||
|
||||
protected void requestOffer(String token, String credentialEndpoint, SupportedCredentialConfiguration offeredCredential, CredentialResponseHandler responseHandler) throws IOException, VerificationException {
|
||||
protected void requestCredential(String token,
|
||||
String credentialEndpoint,
|
||||
SupportedCredentialConfiguration offeredCredential,
|
||||
CredentialResponseHandler responseHandler,
|
||||
ClientScopeRepresentation expectedClientScope) throws IOException, VerificationException {
|
||||
CredentialRequest request = new CredentialRequest();
|
||||
request.setFormat(offeredCredential.getFormat());
|
||||
request.setCredentialIdentifier(offeredCredential.getId());
|
||||
request.setCredentialConfigurationId(offeredCredential.getId());
|
||||
|
||||
StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request), ContentType.APPLICATION_JSON);
|
||||
StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request),
|
||||
ContentType.APPLICATION_JSON);
|
||||
|
||||
HttpPost postCredential = new HttpPost(credentialEndpoint);
|
||||
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
postCredential.setEntity(stringEntity);
|
||||
CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential);
|
||||
assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode());
|
||||
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
|
||||
CredentialResponse credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class);
|
||||
|
||||
CredentialResponse credentialResponse;
|
||||
try (CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential)) {
|
||||
assertEquals(HttpStatus.SC_OK, credentialRequestResponse.getStatusLine().getStatusCode());
|
||||
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
|
||||
credentialResponse = JsonSerialization.readValue(s, CredentialResponse.class);
|
||||
}
|
||||
|
||||
// Use response handler to customize checks based on formats.
|
||||
responseHandler.handleCredentialResponse(credentialResponse);
|
||||
responseHandler.handleCredentialResponse(credentialResponse, expectedClientScope);
|
||||
}
|
||||
|
||||
public CredentialIssuer getCredentialIssuerMetadata() {
|
||||
final String endpoint = getRealmPath(TEST_REALM_NAME) + "/.well-known/openid-credential-issuer";
|
||||
HttpGet getMetadataRequest = new HttpGet(endpoint);
|
||||
try (CloseableHttpResponse metadataResponse = httpClient.execute(getMetadataRequest)) {
|
||||
assertEquals(HttpStatus.SC_OK, metadataResponse.getStatusLine().getStatusCode());
|
||||
String s = IOUtils.toString(metadataResponse.getEntity().getContent(), StandardCharsets.UTF_8);
|
||||
return JsonSerialization.readValue(s, CredentialIssuer.class);
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -345,13 +437,12 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
}
|
||||
|
||||
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getKeyProvider());
|
||||
testRealm.getComponents().addAll("org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder", getCredentialBuilderProviders());
|
||||
|
||||
// Find existing client representation
|
||||
ClientRepresentation existingClient = testRealm.getClients().stream()
|
||||
.filter(client -> client.getClientId().equals(clientId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
|
||||
.filter(client -> client.getClientId().equals(clientId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
|
||||
|
||||
// Add role to existing client
|
||||
if (testRealm.getRoles() != null) {
|
||||
@ -365,22 +456,17 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
return mergedRoles;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
testRealm.getRoles()
|
||||
.setClient(Map.of(existingClient.getClientId(), List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
|
||||
}
|
||||
if (testRealm.getUsers() != null) {
|
||||
testRealm.getUsers().add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"))));
|
||||
} else {
|
||||
testRealm.setUsers(List.of(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole")))));
|
||||
.setClient(Map.of(existingClient.getClientId(),
|
||||
List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
|
||||
}
|
||||
|
||||
if (testRealm.getAttributes() == null) {
|
||||
testRealm.setAttributes(new HashMap<>());
|
||||
}
|
||||
|
||||
testRealm.getAttributes().put("issuerDid", TEST_DID.toString());
|
||||
testRealm.getAttributes().putAll(getCredentialDefinitionAttributes());
|
||||
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
|
||||
.orElse(new ArrayList<>());
|
||||
realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"))));
|
||||
testRealm.setUsers(realmUsers);
|
||||
}
|
||||
|
||||
protected void withCausePropagation(Runnable r) throws Throwable {
|
||||
@ -398,16 +484,9 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
return getRsaKeyProvider(RSA_KEY);
|
||||
}
|
||||
|
||||
protected List<ComponentExportRepresentation> getCredentialBuilderProviders() {
|
||||
return List.of(getCredentialBuilderProvider(Format.JWT_VC));
|
||||
}
|
||||
|
||||
protected Map<String, String> getCredentialDefinitionAttributes() {
|
||||
return getTestCredentialDefinitionAttributes();
|
||||
}
|
||||
|
||||
protected static class CredentialResponseHandler {
|
||||
protected void handleCredentialResponse(CredentialResponse credentialResponse) throws VerificationException {
|
||||
protected void handleCredentialResponse(CredentialResponse credentialResponse,
|
||||
ClientScopeRepresentation clientScope) throws VerificationException {
|
||||
assertNotNull("The credentials array should be present in the response.", credentialResponse.getCredentials());
|
||||
assertFalse("The credentials array should not be empty.", credentialResponse.getCredentials().isEmpty());
|
||||
|
||||
@ -415,14 +494,21 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
CredentialResponse.Credential credentialObj = credentialResponse.getCredentials().get(0);
|
||||
assertNotNull("The first credential in the array should not be null.", credentialObj);
|
||||
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(),
|
||||
JsonWebToken.class).getToken();
|
||||
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
|
||||
assertEquals(List.of("VerifiableCredential"), credential.getType());
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get(
|
||||
"vc"), VerifiableCredential.class);
|
||||
assertEquals(List.of(clientScope.getName()), credential.getType());
|
||||
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
|
||||
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
|
||||
assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential"));
|
||||
assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
|
||||
assertTrue("The static claim should be set.",
|
||||
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
|
||||
assertEquals("The static claim should be set.",
|
||||
clientScope.getName(),
|
||||
credential.getCredentialSubject().getClaims().get("scope-name"));
|
||||
assertFalse("Only mappers supported for the requested type should have been evaluated.",
|
||||
credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,148 +17,293 @@
|
||||
|
||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.hamcrest.MatcherAssert;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.junit.experimental.runners.Enclosed;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.keycloak.common.util.MultivaluedHashMap;
|
||||
import org.keycloak.crypto.Algorithm;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.oid4vci.Oid4vcProtocolMapperModel;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
|
||||
import org.keycloak.protocol.oid4vc.model.Claim;
|
||||
import org.keycloak.protocol.oid4vc.model.ClaimDisplay;
|
||||
import org.keycloak.protocol.oid4vc.model.Claims;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.DisplayObject;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.testsuite.arquillian.SuiteContext;
|
||||
import org.keycloak.testsuite.client.KeycloakTestingClient;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.keycloak.testsuite.AbstractTestRealmKeycloakTest.TEST_REALM_NAME;
|
||||
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCTest.RSA_KEY;
|
||||
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCTest.TEST_DID;
|
||||
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCTest.getCredentialBuilderProvider;
|
||||
import static org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCTest.getRsaKeyProvider;
|
||||
|
||||
@RunWith(Enclosed.class)
|
||||
public class OID4VCIssuerWellKnownProviderTest {
|
||||
|
||||
public static class TestAttributesOverride extends OID4VCTest {
|
||||
@Test
|
||||
public void testRealmAttributesOverrideClientAttributes() {
|
||||
OID4VCIssuerWellKnownProviderTest
|
||||
.testCredentialConfig(suiteContext, testingClient);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
ClientRepresentation testClient = getTestClient("did:web:test.org");
|
||||
Map<String, String> clientAttributes = new HashMap<>(getTestCredentialDefinitionAttributes());
|
||||
Map<String, String> realmAttributes = new HashMap<>();
|
||||
// We'll change the client attributes and put the correct value in the realm
|
||||
// attributes and expect the test to work.
|
||||
clientAttributes.put("vc.test-credential.expiry_in_s", "20");
|
||||
realmAttributes.put("vc.test-credential.expiry_in_s", "100");
|
||||
OID4VCIssuerWellKnownProviderTest
|
||||
.configureTestRealm(testClient, testRealm, clientAttributes, realmAttributes);
|
||||
}
|
||||
public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
Map<String, String> attributes = Optional.ofNullable(testRealm.getAttributes()).orElseGet(HashMap::new);
|
||||
attributes.put("credential_response_encryption.alg_values_supported", "[\"RSA-OAEP\"]");
|
||||
attributes.put("credential_response_encryption.enc_values_supported", "[\"A256GCM\"]");
|
||||
attributes.put("credential_response_encryption.encryption_required", "true");
|
||||
attributes.put("batch_credential_issuance.batch_size", "10");
|
||||
attributes.put("signed_metadata", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc");
|
||||
testRealm.setAttributes(attributes);
|
||||
super.configureTestRealm(testRealm);
|
||||
}
|
||||
|
||||
public static class TestCredentialDefinitionInClientAttributes extends OID4VCTest {
|
||||
/**
|
||||
* this test will use the configured scopes {@link #jwtTypeCredentialClientScope} and
|
||||
* {@link #sdJwtTypeCredentialClientScope} to verify that the metadata endpoint is presenting the expected data
|
||||
*/
|
||||
@Test
|
||||
public void testMetaDataEndpointIsCorrectlySetup() {
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
|
||||
@Test
|
||||
public void testCredentialConfig() {
|
||||
OID4VCIssuerWellKnownProviderTest
|
||||
.testCredentialConfig(suiteContext, testingClient);
|
||||
}
|
||||
Assert.assertEquals(getRealmPath(TEST_REALM_NAME), credentialIssuer.getCredentialIssuer());
|
||||
Assert.assertEquals(getBasePath(TEST_REALM_NAME) + OID4VCIssuerEndpoint.CREDENTIAL_PATH,
|
||||
credentialIssuer.getCredentialEndpoint());
|
||||
Assert.assertNull("Display was not configured", credentialIssuer.getDisplay());
|
||||
Assert.assertEquals("Authorization Server should have the realm-address.",
|
||||
1,
|
||||
credentialIssuer.getAuthorizationServers().size());
|
||||
Assert.assertEquals("Authorization Server should point to the realm-address.",
|
||||
getRealmPath(TEST_REALM_NAME),
|
||||
credentialIssuer.getAuthorizationServers().get(0));
|
||||
|
||||
@Test
|
||||
public void testCredentialIssuerMetadataFields() {
|
||||
String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME;
|
||||
KeycloakTestingClient testingClient = this.testingClient;
|
||||
// Check credential_response_encryption
|
||||
CredentialIssuer.CredentialResponseEncryption encryption = credentialIssuer.getCredentialResponseEncryption();
|
||||
Assert.assertNotNull("credential_response_encryption should be present", encryption);
|
||||
Assert.assertEquals(List.of("RSA-OAEP"), encryption.getAlgValuesSupported());
|
||||
Assert.assertEquals(List.of("A256GCM"), encryption.getEncValuesSupported());
|
||||
Assert.assertTrue("encryption_required should be true", encryption.getEncryptionRequired());
|
||||
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run(session -> {
|
||||
OID4VCIssuerWellKnownProvider provider = new OID4VCIssuerWellKnownProvider(session);
|
||||
Object config = provider.getConfig();
|
||||
assertTrue("Should return CredentialIssuer", config instanceof CredentialIssuer);
|
||||
CredentialIssuer issuer = (CredentialIssuer) config;
|
||||
// Check batch_credential_issuance
|
||||
CredentialIssuer.BatchCredentialIssuance batch = credentialIssuer.getBatchCredentialIssuance();
|
||||
Assert.assertNotNull("batch_credential_issuance should be present", batch);
|
||||
Assert.assertEquals(Integer.valueOf(10), batch.getBatchSize());
|
||||
|
||||
// Check credential_response_encryption
|
||||
CredentialIssuer.CredentialResponseEncryption encryption = issuer.getCredentialResponseEncryption();
|
||||
assertNotNull("credential_response_encryption should be present", encryption);
|
||||
assertEquals(List.of("RSA-OAEP"), encryption.getAlgValuesSupported());
|
||||
assertEquals(List.of("A256GCM"), encryption.getEncValuesSupported());
|
||||
assertTrue("encryption_required should be true", encryption.getEncryptionRequired());
|
||||
// Check signed_metadata
|
||||
Assert.assertEquals(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc",
|
||||
credentialIssuer.getSignedMetadata()
|
||||
);
|
||||
|
||||
// Check batch_credential_issuance
|
||||
CredentialIssuer.BatchCredentialIssuance batch = issuer.getBatchCredentialIssuance();
|
||||
assertNotNull("batch_credential_issuance should be present", batch);
|
||||
assertEquals(Integer.valueOf(10), batch.getBatchSize());
|
||||
|
||||
// Check signed_metadata
|
||||
assertEquals(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc",
|
||||
issuer.getSignedMetadata()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
Map<String, String> clientAttributes = new HashMap<>(getTestCredentialDefinitionAttributes());
|
||||
Map<String, String> realmAttributes = new HashMap<>();
|
||||
OID4VCIssuerWellKnownProviderTest
|
||||
.configureTestRealm(
|
||||
getTestClient("did:web:test.org"),
|
||||
testRealm,
|
||||
clientAttributes,
|
||||
realmAttributes
|
||||
);
|
||||
for (ClientScopeRepresentation clientScope : List.of(jwtTypeCredentialClientScope,
|
||||
sdJwtTypeCredentialClientScope,
|
||||
minimalJwtTypeCredentialClientScope)) {
|
||||
compareMetadataToClientScope(credentialIssuer, clientScope);
|
||||
}
|
||||
}
|
||||
|
||||
public static class TestCredentialDefinitionInRealmAttributes extends OID4VCTest {
|
||||
/**
|
||||
* this test will make sure that the default values are correctly added into the metadata endpoint
|
||||
*/
|
||||
@Test
|
||||
public void testMinimalJwtCredentialHardcodedTest()
|
||||
{
|
||||
ClientScopeRepresentation clientScope = minimalJwtTypeCredentialClientScope;
|
||||
CredentialIssuer credentialIssuer = getCredentialIssuerMetadata();
|
||||
SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported()
|
||||
.get(clientScope.getName());
|
||||
Assert.assertNotNull(supportedConfig);
|
||||
Assert.assertEquals(Format.SD_JWT_VC, supportedConfig.getFormat());
|
||||
Assert.assertEquals(clientScope.getName(), supportedConfig.getScope());
|
||||
Assert.assertEquals(1, supportedConfig.getCredentialDefinition().getType().size());
|
||||
Assert.assertEquals(clientScope.getName(), supportedConfig.getCredentialDefinition().getType().get(0));
|
||||
Assert.assertEquals(1, supportedConfig.getCredentialDefinition().getContext().size());
|
||||
Assert.assertEquals(clientScope.getName(), supportedConfig.getCredentialDefinition().getContext().get(0));
|
||||
Assert.assertNull(supportedConfig.getDisplay());
|
||||
Assert.assertEquals(clientScope.getName(), supportedConfig.getScope());
|
||||
|
||||
@Test
|
||||
public void testCredentialConfig() {
|
||||
OID4VCIssuerWellKnownProviderTest
|
||||
.testCredentialConfig(suiteContext, testingClient);
|
||||
compareClaims(supportedConfig.getFormat(), supportedConfig.getClaims(), clientScope.getProtocolMappers());
|
||||
}
|
||||
|
||||
private void compareMetadataToClientScope(CredentialIssuer credentialIssuer, ClientScopeRepresentation clientScope) {
|
||||
String credentialConfigurationId = Optional.ofNullable(clientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONFIGURATION_ID))
|
||||
.orElse(clientScope.getName());
|
||||
SupportedCredentialConfiguration supportedConfig = credentialIssuer.getCredentialsSupported()
|
||||
.get(credentialConfigurationId);
|
||||
Assert.assertNotNull("Configuration of type '" + credentialConfigurationId + "' must be present",
|
||||
supportedConfig);
|
||||
Assert.assertEquals(credentialConfigurationId, supportedConfig.getId());
|
||||
|
||||
String expectedFormat = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.FORMAT))
|
||||
.orElse(Format.SD_JWT_VC);
|
||||
Assert.assertEquals(expectedFormat, supportedConfig.getFormat());
|
||||
|
||||
Assert.assertEquals(clientScope.getName(), supportedConfig.getScope());
|
||||
{
|
||||
// TODO this is still hardcoded
|
||||
Assert.assertEquals(1, supportedConfig.getCryptographicBindingMethodsSupported().size());
|
||||
Assert.assertEquals(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT,
|
||||
supportedConfig.getCryptographicBindingMethodsSupported().get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
Map<String, String> realmAttributes = new HashMap<>(getTestCredentialDefinitionAttributes());
|
||||
Map<String, String> clientAttributes = new HashMap<>();
|
||||
OID4VCIssuerWellKnownProviderTest.configureTestRealm(
|
||||
getTestClient("did:web:test.org"),
|
||||
testRealm,
|
||||
clientAttributes,
|
||||
realmAttributes
|
||||
);
|
||||
compareDisplay(supportedConfig, clientScope);
|
||||
|
||||
String expectedVct = Optional.ofNullable(clientScope.getAttributes().get(CredentialScopeModel.VCT))
|
||||
.orElse(clientScope.getName());
|
||||
Assert.assertEquals(expectedVct, supportedConfig.getVct());
|
||||
|
||||
Assert.assertNotNull(supportedConfig.getCredentialDefinition());
|
||||
Assert.assertNotNull(supportedConfig.getCredentialDefinition().getType());
|
||||
List<String> credentialDefinitionTypes = Optional.ofNullable(clientScope.getAttributes()
|
||||
.get(CredentialScopeModel.TYPES))
|
||||
.map(s -> s.split(","))
|
||||
.map(Arrays::asList)
|
||||
.orElseGet(() -> List.of(clientScope.getName()));
|
||||
Assert.assertEquals(credentialDefinitionTypes.size(),
|
||||
supportedConfig.getCredentialDefinition().getType().size());
|
||||
|
||||
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
|
||||
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
|
||||
List<String> credentialDefinitionContexts = Optional.ofNullable(clientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONTEXTS))
|
||||
.map(s -> s.split(","))
|
||||
.map(Arrays::asList)
|
||||
.orElseGet(() -> List.of(clientScope.getName()));
|
||||
Assert.assertEquals(credentialDefinitionContexts.size(),
|
||||
supportedConfig.getCredentialDefinition().getContext().size());
|
||||
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
|
||||
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
|
||||
|
||||
List<String> signingAlgsSupported = new ArrayList<>(supportedConfig.getCredentialSigningAlgValuesSupported());
|
||||
String proofTypesSupportedString = supportedConfig.getProofTypesSupported().toJsonString();
|
||||
|
||||
try {
|
||||
withCausePropagation(() -> testingClient.server(TEST_REALM_NAME).run((session -> {
|
||||
ProofTypesSupported expectedProofTypesSupported = ProofTypesSupported.parse(session,
|
||||
List.of(Algorithm.RS256));
|
||||
Assert.assertEquals(expectedProofTypesSupported,
|
||||
ProofTypesSupported.fromJsonString(proofTypesSupportedString));
|
||||
|
||||
List<String> expectedSigningAlgs = OID4VCIssuerWellKnownProvider.getSupportedSignatureAlgorithms(session);
|
||||
MatcherAssert.assertThat(signingAlgsSupported,
|
||||
Matchers.containsInAnyOrder(expectedSigningAlgs.toArray()));
|
||||
|
||||
})));
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
compareClaims(expectedFormat, supportedConfig.getClaims(), clientScope.getProtocolMappers());
|
||||
}
|
||||
|
||||
private void compareDisplay(SupportedCredentialConfiguration supportedConfig, ClientScopeRepresentation clientScope) {
|
||||
String display = clientScope.getAttributes().get(CredentialScopeModel.VC_DISPLAY);
|
||||
if (StringUtil.isBlank(display)) {
|
||||
Assert.assertNull(supportedConfig.getDisplay());
|
||||
return;
|
||||
}
|
||||
List<DisplayObject> expectedDisplayObjectList;
|
||||
try {
|
||||
expectedDisplayObjectList = JsonSerialization.mapper.readValue(display, new TypeReference<>() {
|
||||
});
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
Assert.assertEquals(expectedDisplayObjectList.size(), supportedConfig.getDisplay().size());
|
||||
MatcherAssert.assertThat("Must contain all expected display-objects",
|
||||
supportedConfig.getDisplay(),
|
||||
Matchers.containsInAnyOrder(expectedDisplayObjectList.toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
* each claim representation from the metadata is based on a protocol-mapper which we compare here
|
||||
*/
|
||||
private void compareClaims(String credentialFormat,
|
||||
Claims originalClaims,
|
||||
List<ProtocolMapperRepresentation> originalProtocolMappers) {
|
||||
// the data must be serializable to transfer them to the server, so we convert the data to strings
|
||||
String claimsString = originalClaims.toJsonString();
|
||||
String protocolMappersString = toJsonString(originalProtocolMappers);
|
||||
|
||||
try {
|
||||
withCausePropagation(() -> testingClient.server(TEST_REALM_NAME).run((session -> {
|
||||
Claims actualClaims = fromJsonString(claimsString, Claims.class);
|
||||
List<ProtocolMapperRepresentation> protocolMappers = fromJsonString(protocolMappersString,
|
||||
new SerializableProtocolMapperReference());
|
||||
// check only protocol-mappers of type oid4vc
|
||||
protocolMappers = protocolMappers.stream().filter(protocolMapper -> {
|
||||
return OID4VCLoginProtocolFactory.PROTOCOL_ID.equals(protocolMapper.getProtocol());
|
||||
}).toList();
|
||||
|
||||
for (ProtocolMapperRepresentation protocolMapper : protocolMappers) {
|
||||
OID4VCMapper mapper = (OID4VCMapper) session.getProvider(ProtocolMapper.class,
|
||||
protocolMapper.getProtocolMapper());
|
||||
ProtocolMapperModel protocolMapperModel = new ProtocolMapperModel();
|
||||
protocolMapperModel.setConfig(protocolMapper.getConfig());
|
||||
mapper.setMapperModel(protocolMapperModel, credentialFormat);
|
||||
Claim claim = actualClaims.stream()
|
||||
.filter(c -> c.getPath().equals(mapper.getMetadataAttributePath()))
|
||||
.findFirst().orElse(null);
|
||||
if (mapper.includeInMetadata()) {
|
||||
Assert.assertNotNull("There should be a claim matching the protocol-mappers config!", claim);
|
||||
}
|
||||
else {
|
||||
Assert.assertNull("This claim should not be included in the metadata-config!", claim);
|
||||
// no other checks to do for this claim
|
||||
continue;
|
||||
}
|
||||
Assert.assertEquals(claim.isMandatory(),
|
||||
Optional.ofNullable(protocolMapper.getConfig()
|
||||
.get(Oid4vcProtocolMapperModel.MANDATORY))
|
||||
.map(Boolean::parseBoolean)
|
||||
.orElse(false));
|
||||
String expectedDisplayString = protocolMapper.getConfig().get(Oid4vcProtocolMapperModel.DISPLAY);
|
||||
List<ClaimDisplay> expectedDisplayList = fromJsonString(expectedDisplayString,
|
||||
new SerializableClaimDisplayReference());
|
||||
List<ClaimDisplay> actualDisplayList = claim.getDisplay();
|
||||
if (expectedDisplayList == null) {
|
||||
Assert.assertNull(actualDisplayList);
|
||||
}
|
||||
else {
|
||||
Assert.assertEquals(expectedDisplayList.size(), actualDisplayList.size());
|
||||
MatcherAssert.assertThat(actualDisplayList,
|
||||
Matchers.containsInAnyOrder(expectedDisplayList.toArray()));
|
||||
}
|
||||
}
|
||||
})));
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void configureTestRealm(
|
||||
ClientRepresentation testClient,
|
||||
RealmRepresentation testRealm,
|
||||
Map<String, String> clientAttributes,
|
||||
Map<String, String> realmAttributes
|
||||
) {
|
||||
realmAttributes.put("credential_response_encryption.alg_values_supported", "[\"RSA-OAEP\"]");
|
||||
realmAttributes.put("credential_response_encryption.enc_values_supported", "[\"A256GCM\"]");
|
||||
realmAttributes.put("credential_response_encryption.encryption_required", "true");
|
||||
realmAttributes.put("batch_credential_issuance.batch_size", "10");
|
||||
realmAttributes.put("signed_metadata", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.XYZ123abc"); // example JWT
|
||||
testClient.setAttributes(new HashMap<>(clientAttributes));
|
||||
testRealm.setAttributes(new HashMap<>(realmAttributes));
|
||||
extendConfigureTestRealm(testRealm, testClient);
|
||||
/**
|
||||
* a jackson type-reference that can be used in the run-server-block
|
||||
*/
|
||||
public static class SerializableProtocolMapperReference extends TypeReference<List<ProtocolMapperRepresentation>>
|
||||
implements Serializable {
|
||||
}
|
||||
|
||||
/**
|
||||
* a jackson type-reference that can be used in the run-server-block
|
||||
*/
|
||||
public static class SerializableClaimDisplayReference extends TypeReference<List<ClaimDisplay>>
|
||||
implements Serializable {
|
||||
}
|
||||
|
||||
public static void testCredentialConfig(SuiteContext suiteContext, KeycloakTestingClient testingClient) {
|
||||
@ -171,21 +316,17 @@ public class OID4VCIssuerWellKnownProviderTest {
|
||||
.run((session -> {
|
||||
OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session);
|
||||
Object issuerConfig = oid4VCIssuerWellKnownProvider.getConfig();
|
||||
assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer);
|
||||
Assert.assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer);
|
||||
CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig;
|
||||
assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer());
|
||||
assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint());
|
||||
assertEquals("The correct deferred_credential_endpoint should be included.", expectedDeferredEndpoint, credentialIssuer.getDeferredCredentialEndpoint());
|
||||
assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
|
||||
assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
|
||||
assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential"));
|
||||
assertEquals("The test-credential should offer type VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope());
|
||||
assertEquals("The test-credential should be offered in the jwt-vc format.", Format.JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat());
|
||||
assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims());
|
||||
assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName"));
|
||||
assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory());
|
||||
assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName());
|
||||
// moved sd-jwt specific config to org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getConfig
|
||||
Assert.assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer());
|
||||
Assert.assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint());
|
||||
Assert.assertEquals("The correct deferred_credential_endpoint should be included.", expectedDeferredEndpoint, credentialIssuer.getDeferredCredentialEndpoint());
|
||||
Assert.assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
|
||||
Assert.assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
|
||||
Assert.assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential"));
|
||||
Assert.assertEquals("The test-credential should offer type VerifiableCredential", "VerifiableCredential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope());
|
||||
Assert.assertEquals("The test-credential should be offered in the jwt-vc format.", Format.JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat());
|
||||
Assert.assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims());
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -34,9 +34,14 @@ import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.admin.client.resource.RealmResource;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.model.Claim;
|
||||
import org.keycloak.protocol.oid4vc.model.ClaimDisplay;
|
||||
import org.keycloak.protocol.oid4vc.model.Claims;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
@ -46,26 +51,29 @@ import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.sdjwt.vp.SdJwtVP;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
@ -116,26 +124,32 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Test
|
||||
public void testGetCredentialOfferURI() {
|
||||
String token = getBearerToken(oauth);
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session) -> {
|
||||
try {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
|
||||
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0);
|
||||
testingClient.server(TEST_REALM_NAME).run((session) -> {
|
||||
try {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(
|
||||
session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus());
|
||||
CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(), CredentialOfferURI.class);
|
||||
assertNotNull("A nonce should be included.", credentialOfferURI.getNonce());
|
||||
assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId,
|
||||
OfferUriType.URI,
|
||||
0,
|
||||
0);
|
||||
|
||||
assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus());
|
||||
CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(),
|
||||
CredentialOfferURI.class);
|
||||
assertNotNull("A nonce should be included.", credentialOfferURI.getNonce());
|
||||
assertNotNull("The issuer uri should be provided.", credentialOfferURI.getIssuer());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----- getCredentialOffer
|
||||
@ -241,7 +255,6 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
authenticator.setTokenString(null);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
Response response = issuerEndpoint.requestCredential(new CredentialRequest()
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setCredentialIdentifier("test-credential"));
|
||||
assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType());
|
||||
}));
|
||||
@ -258,47 +271,38 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
authenticator.setTokenString("token");
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
issuerEndpoint.requestCredential(new CredentialRequest()
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setCredentialIdentifier("test-credential"));
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
public void testRequestCredentialUnsupportedFormat() throws Throwable {
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() -> {
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
issuerEndpoint.requestCredential(new CredentialRequest()
|
||||
.setFormat(Format.SD_JWT_VC)
|
||||
.setCredentialIdentifier("test-credential"));
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
@Test
|
||||
public void testRequestCredentialNoMatchingCredentialBuilder() throws Throwable {
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() ->
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
|
||||
// Prepare the issue endpoint with no credential builders.
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
|
||||
try {
|
||||
withCausePropagation(() -> {
|
||||
testingClient.server(TEST_REALM_NAME).run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = //
|
||||
new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
|
||||
issuerEndpoint.requestCredential(new CredentialRequest()
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setCredentialIdentifier("test-credential"));
|
||||
}))
|
||||
);
|
||||
// Prepare the issue endpoint with no credential builders.
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
|
||||
|
||||
CredentialRequest credentialRequest = //
|
||||
new CredentialRequest().setCredentialConfigurationId(credentialConfigurationId);
|
||||
issuerEndpoint.requestCredential(credentialRequest);
|
||||
}));
|
||||
});
|
||||
Assert.fail("Should have thrown an exception");
|
||||
}catch (Exception e) {
|
||||
Assert.assertTrue(e instanceof BadRequestException);
|
||||
Assert.assertEquals("No credential builder found for format jwt_vc", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
@ -312,7 +316,6 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
issuerEndpoint.requestCredential(new CredentialRequest()
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setCredentialIdentifier("no-such-credential"));
|
||||
}));
|
||||
});
|
||||
@ -320,7 +323,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Test
|
||||
public void testRequestCredential() {
|
||||
String token = getBearerToken(oauth);
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
@ -328,19 +332,56 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setCredentialIdentifier("test-credential");
|
||||
.setCredentialIdentifier(scopeName);
|
||||
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
|
||||
assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus());
|
||||
assertEquals("The credential request should be answered successfully.",
|
||||
HttpStatus.SC_OK,
|
||||
credentialResponse.getStatus());
|
||||
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
|
||||
CredentialResponse credentialResponseVO = JsonSerialization.mapper.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(), JsonWebToken.class).getToken();
|
||||
CredentialResponse credentialResponseVO = JsonSerialization.mapper
|
||||
.convertValue(credentialResponse.getEntity(),
|
||||
CredentialResponse.class);
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponseVO.getCredentials().get(0).getCredential(),
|
||||
JsonWebToken.class).getToken();
|
||||
|
||||
assertNotNull("A valid credential string should have been responded", jsonWebToken);
|
||||
assertNotNull("The credentials should be included at the vc-claim.", jsonWebToken.getOtherClaims().get("vc"));
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
|
||||
assertTrue("The static claim should be set.", credential.getCredentialSubject().getClaims().containsKey("VerifiableCredential"));
|
||||
assertFalse("Only mappers supported for the requested type should have been evaluated.", credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
|
||||
assertNotNull("The credentials should be included at the vc-claim.",
|
||||
jsonWebToken.getOtherClaims().get("vc"));
|
||||
VerifiableCredential credential =
|
||||
JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"),
|
||||
VerifiableCredential.class);
|
||||
assertTrue("The static claim should be set.",
|
||||
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
|
||||
assertEquals("The static claim should be set.",
|
||||
scopeName,
|
||||
credential.getCredentialSubject().getClaims().get("scope-name"));
|
||||
assertFalse("Only mappers supported for the requested type should have been evaluated.",
|
||||
credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRequestCredentialWithConfigurationIdNotSet() {
|
||||
final String scopeName = minimalJwtTypeCredentialClientScope.getName();
|
||||
String token = getBearerToken(oauth, client, scopeName);
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialIdentifier(scopeName);
|
||||
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
|
||||
assertEquals("The credential request should be answered successfully.",
|
||||
HttpStatus.SC_OK,
|
||||
credentialResponse.getStatus());
|
||||
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
|
||||
CredentialResponse credentialResponseVO = JsonSerialization.mapper
|
||||
.convertValue(credentialResponse.getEntity(),
|
||||
CredentialResponse.class);
|
||||
SdJwtVP sdJwtVP = SdJwtVP.of((String)credentialResponseVO.getCredentials().get(0).getCredential());
|
||||
assertNotNull("A valid credential string should have been responded", sdJwtVP);
|
||||
}));
|
||||
}
|
||||
|
||||
@ -354,14 +395,20 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
@Test
|
||||
public void testCredentialIssuance() throws Exception {
|
||||
|
||||
String token = getBearerToken(oauth);
|
||||
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
|
||||
|
||||
// 1. Retrieving the credential-offer-uri
|
||||
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential");
|
||||
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME)
|
||||
+ "credential-offer-uri?credential_configuration_id="
|
||||
+ credentialConfigurationId);
|
||||
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
|
||||
|
||||
assertEquals("A valid offer uri should be returned", HttpStatus.SC_OK, credentialOfferURIResponse.getStatusLine().getStatusCode());
|
||||
assertEquals("A valid offer uri should be returned",
|
||||
HttpStatus.SC_OK,
|
||||
credentialOfferURIResponse.getStatusLine().getStatusCode());
|
||||
String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8);
|
||||
CredentialOfferURI credentialOfferURI = JsonSerialization.readValue(s, CredentialOfferURI.class);
|
||||
|
||||
@ -408,7 +455,11 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
|
||||
.forEach(supportedCredential -> {
|
||||
try {
|
||||
requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential, new CredentialResponseHandler());
|
||||
requestCredential(theToken,
|
||||
credentialIssuer.getCredentialEndpoint(),
|
||||
supportedCredential,
|
||||
new CredentialResponseHandler(),
|
||||
jwtTypeCredentialClientScope);
|
||||
} catch (IOException e) {
|
||||
fail("Was not able to get the credential.");
|
||||
} catch (VerificationException e) {
|
||||
@ -418,47 +469,52 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception {
|
||||
// Set the realm attribute for the required scope
|
||||
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
|
||||
RealmRepresentation rep = realm.toRepresentation();
|
||||
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
|
||||
attributes.put("vc.test-credential.scope", "VerifiableCredential");
|
||||
rep.setAttributes(attributes);
|
||||
realm.update(rep);
|
||||
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() {
|
||||
BiFunction<String, String, String> getAccessToken = (testClientId, testScope) -> {
|
||||
return getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
|
||||
};
|
||||
|
||||
testCredentialIssuanceWithAuthZCodeFlow(
|
||||
(testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("VerifiableCredential")),
|
||||
m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
|
||||
assertEquals("Credential identifier should match", "test-credential", credentialRequest.getCredentialIdentifier());
|
||||
Consumer<Map<String, Object>> sendCredentialRequest = m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
|
||||
assertEquals("Credential configuration id should match",
|
||||
jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID),
|
||||
credentialRequest.getCredentialConfigurationId());
|
||||
|
||||
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
|
||||
if (response.getStatus() != 200) {
|
||||
String errorBody = response.readEntity(String.class);
|
||||
System.out.println("Error Response: " + errorBody);
|
||||
}
|
||||
assertEquals(200, response.getStatus());
|
||||
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class), CredentialResponse.class);
|
||||
try (Response response = credentialTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
|
||||
.post(Entity.json(credentialRequest))) {
|
||||
if (response.getStatus() != 200) {
|
||||
String errorBody = response.readEntity(String.class);
|
||||
System.out.println("Error Response: " + errorBody);
|
||||
}
|
||||
assertEquals(200, response.getStatus());
|
||||
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),
|
||||
CredentialResponse.class);
|
||||
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(), JsonWebToken.class).getToken();
|
||||
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredentials().get(0).getCredential(),
|
||||
JsonWebToken.class).getToken();
|
||||
assertEquals(TEST_DID.toString(), jsonWebToken.getIssuer());
|
||||
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get("vc"), VerifiableCredential.class);
|
||||
assertEquals(TEST_TYPES, credential.getType());
|
||||
assertEquals(TEST_DID, credential.getIssuer());
|
||||
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
|
||||
} catch (IOException | VerificationException e) {
|
||||
Assert.fail("Failed to process credential response: " + e.getMessage());
|
||||
}
|
||||
});
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims()
|
||||
.get("vc"),
|
||||
VerifiableCredential.class);
|
||||
assertEquals(List.of(jwtTypeCredentialClientScope.getName()), credential.getType());
|
||||
assertEquals(TEST_DID, credential.getIssuer());
|
||||
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
|
||||
} catch (VerificationException | IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
|
||||
testCredentialIssuanceWithAuthZCodeFlow(jwtTypeCredentialClientScope, getAccessToken, sendCredentialRequest);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() throws Exception {
|
||||
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope
|
||||
testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, (testClientId, testScope) ->
|
||||
getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")),// set registered different scope
|
||||
m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
@ -472,7 +528,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Test
|
||||
public void testCredentialIssuanceWithAuthZCodeSWithoutScope() throws Exception {
|
||||
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope
|
||||
testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope,
|
||||
(testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)),// no scope
|
||||
m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
@ -484,74 +541,46 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The accessToken references the scope "test-credential" but we ask for the credential "VerifiableCredential"
|
||||
* in the CredentialRequest
|
||||
*/
|
||||
@Test
|
||||
public void testCredentialIssuanceWithRealmScopeUnmatched() throws Exception {
|
||||
// Set the realm attribute for the required scope
|
||||
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
|
||||
RealmRepresentation rep = realm.toRepresentation();
|
||||
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
|
||||
attributes.put("vc.test-credential.scope", "VerifiableCredential");
|
||||
rep.setAttributes(attributes);
|
||||
realm.update(rep);
|
||||
public void testCredentialIssuanceWithScopeUnmatched() {
|
||||
BiFunction<String, String, String> getAccessToken = (testClientId, testScope) -> {
|
||||
return getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
|
||||
};
|
||||
|
||||
// Run the flow with a non-matching scope
|
||||
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")),
|
||||
m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
|
||||
Consumer<Map<String, Object>> sendCredentialRequest = m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
|
||||
|
||||
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
|
||||
assertEquals(400, response.getStatus());
|
||||
String errorJson = response.readEntity(String.class);
|
||||
assertNotNull("Error response should not be null", errorJson);
|
||||
assertTrue("Error response should mention UNSUPPORTED_CREDENTIAL_TYPE or scope",
|
||||
errorJson.contains("UNSUPPORTED_CREDENTIAL_TYPE") || errorJson.contains("scope"));
|
||||
}
|
||||
});
|
||||
}
|
||||
try (Response response = credentialTarget.request()
|
||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken)
|
||||
.post(Entity.json(credentialRequest))) {
|
||||
assertEquals(400, response.getStatus());
|
||||
String errorJson = response.readEntity(String.class);
|
||||
assertNotNull("Error response should not be null", errorJson);
|
||||
assertTrue("Error response should mention UNSUPPORTED_CREDENTIAL_TYPE or scope",
|
||||
errorJson.contains("UNSUPPORTED_CREDENTIAL_TYPE") || errorJson.contains("scope"));
|
||||
}
|
||||
};
|
||||
|
||||
@Test
|
||||
public void testCredentialIssuanceWithRealmScopeMissing() throws Exception {
|
||||
// Remove the realm attribute for the required scope
|
||||
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
|
||||
RealmRepresentation rep = realm.toRepresentation();
|
||||
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
|
||||
attributes.remove("vc.test-credential.scope");
|
||||
rep.setAttributes(attributes);
|
||||
realm.update(rep);
|
||||
|
||||
// Run the flow with a scope in the access token, but no realm attribute
|
||||
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("VerifiableCredential")),
|
||||
m -> {
|
||||
String accessToken = (String) m.get("accessToken");
|
||||
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
|
||||
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
|
||||
|
||||
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
|
||||
assertEquals(400, response.getStatus());
|
||||
String errorJson = response.readEntity(String.class);
|
||||
Map<String, Object> errorMap = JsonSerialization.readValue(errorJson, Map.class);
|
||||
assertTrue("Error should contain 'error' field", errorMap.containsKey("error"));
|
||||
assertEquals("UNSUPPORTED_CREDENTIAL_TYPE", errorMap.get("error"));
|
||||
assertEquals("Scope check failure", errorMap.get("error_description"));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
testCredentialIssuanceWithAuthZCodeFlow(sdJwtTypeCredentialClientScope, getAccessToken, sendCredentialRequest);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRequestCredentialWithNotificationId() {
|
||||
String token = getBearerToken(oauth);
|
||||
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
|
||||
testingClient.server(TEST_REALM_NAME).run((session) -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setFormat(Format.JWT_VC)
|
||||
.setCredentialIdentifier("test-credential");
|
||||
CredentialRequest credentialRequest = new CredentialRequest().setCredentialIdentifier(scopeName);
|
||||
|
||||
// First credential request
|
||||
Response response1 = issuerEndpoint.requestCredential(credentialRequest);
|
||||
@ -569,4 +598,161 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
assertNotEquals("Notification IDs should be unique", credentialResponse1.getNotificationId(), credentialResponse2.getNotificationId());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This is testing the configuration exposed by OID4VCIssuerWellKnownProvider based on the client and signing config setup here.
|
||||
*/
|
||||
@Test
|
||||
public void testGetJwtVcConfigFromMetadata() {
|
||||
final String scopeName = jwtTypeCredentialClientScope.getName();
|
||||
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
final String verifiableCredentialType = jwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.VCT);
|
||||
String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME;
|
||||
String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential";
|
||||
String expectedNonceEndpoint = expectedIssuer + "/protocol/oid4vc/" + OID4VCIssuerEndpoint.NONCE_PATH;
|
||||
final String expectedAuthorizationServer = expectedIssuer;
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
OID4VCIssuerWellKnownProvider oid4VCIssuerWellKnownProvider = new OID4VCIssuerWellKnownProvider(session);
|
||||
Object issuerConfig = oid4VCIssuerWellKnownProvider.getConfig();
|
||||
assertTrue("Valid credential-issuer metadata should be returned.", issuerConfig instanceof CredentialIssuer);
|
||||
CredentialIssuer credentialIssuer = (CredentialIssuer) issuerConfig;
|
||||
assertEquals("The correct issuer should be included.", expectedIssuer, credentialIssuer.getCredentialIssuer());
|
||||
assertEquals("The correct credentials endpoint should be included.", expectedCredentialsEndpoint, credentialIssuer.getCredentialEndpoint());
|
||||
assertEquals("The correct nonce endpoint should be included.",
|
||||
expectedNonceEndpoint,
|
||||
credentialIssuer.getNonceEndpoint());
|
||||
assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
|
||||
assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
|
||||
|
||||
assertTrue("The jwt_vc-credential should be supported.",
|
||||
credentialIssuer.getCredentialsSupported()
|
||||
.containsKey(credentialConfigurationId));
|
||||
|
||||
SupportedCredentialConfiguration jwtVcConfig =
|
||||
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId);
|
||||
assertEquals("The jwt_vc-credential should offer type test-credential",
|
||||
scopeName,
|
||||
jwtVcConfig.getScope());
|
||||
assertEquals("The jwt_vc-credential should be offered in the jwt_vc format.",
|
||||
Format.JWT_VC,
|
||||
jwtVcConfig.getFormat());
|
||||
|
||||
Claims jwtVcClaims = jwtVcConfig.getClaims();
|
||||
assertNotNull("The jwt_vc-credential can optionally provide a claims claim.",
|
||||
jwtVcClaims);
|
||||
|
||||
assertEquals(5, jwtVcClaims.size());
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(0);
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.given_name is present.",
|
||||
Oid4VciConstants.CREDENTIAL_SUBJECT,
|
||||
claim.getPath().get(0));
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.given_name is present.",
|
||||
"given_name",
|
||||
claim.getPath().get(1));
|
||||
assertFalse("The jwt_vc-credential claim credentialSubject.given_name is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNotNull("The jwt_vc-credential claim credentialSubject.given_name has display configured",
|
||||
claim.getDisplay());
|
||||
assertEquals(15, claim.getDisplay().size());
|
||||
for (ClaimDisplay givenNameDisplay : claim.getDisplay()) {
|
||||
assertNotNull(givenNameDisplay.getName());
|
||||
assertNotNull(givenNameDisplay.getLocale());
|
||||
}
|
||||
}
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(1);
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.family_name is present.",
|
||||
Oid4VciConstants.CREDENTIAL_SUBJECT,
|
||||
claim.getPath().get(0));
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.family_name is present.",
|
||||
"family_name",
|
||||
claim.getPath().get(1));
|
||||
assertFalse("The jwt_vc-credential claim credentialSubject.family_name is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNotNull("The jwt_vc-credential claim credentialSubject.family_name has display configured",
|
||||
claim.getDisplay());
|
||||
assertEquals(15, claim.getDisplay().size());
|
||||
for (ClaimDisplay familyNameDisplay : claim.getDisplay()) {
|
||||
assertNotNull(familyNameDisplay.getName());
|
||||
assertNotNull(familyNameDisplay.getLocale());
|
||||
}
|
||||
}
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(2);
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.birthdate is present.",
|
||||
Oid4VciConstants.CREDENTIAL_SUBJECT,
|
||||
claim.getPath().get(0));
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.birthdate is present.",
|
||||
"birthdate",
|
||||
claim.getPath().get(1));
|
||||
assertFalse("The jwt_vc-credential claim credentialSubject.birthdate is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNotNull("The jwt_vc-credential claim credentialSubject.birthdate has display configured",
|
||||
claim.getDisplay());
|
||||
assertEquals(15, claim.getDisplay().size());
|
||||
for (ClaimDisplay birthDateDisplay : claim.getDisplay()) {
|
||||
assertNotNull(birthDateDisplay.getName());
|
||||
assertNotNull(birthDateDisplay.getLocale());
|
||||
}
|
||||
}
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(3);
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.email is present.",
|
||||
Oid4VciConstants.CREDENTIAL_SUBJECT,
|
||||
claim.getPath().get(0));
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.email is present.",
|
||||
"email",
|
||||
claim.getPath().get(1));
|
||||
assertFalse("The jwt_vc-credential claim credentialSubject.email is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNotNull("The jwt_vc-credential claim credentialSubject.email has display configured",
|
||||
claim.getDisplay());
|
||||
assertEquals(15, claim.getDisplay().size());
|
||||
for (ClaimDisplay birthDateDisplay : claim.getDisplay()) {
|
||||
assertNotNull(birthDateDisplay.getName());
|
||||
assertNotNull(birthDateDisplay.getLocale());
|
||||
}
|
||||
}
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(4);
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.scope-name is present.",
|
||||
Oid4VciConstants.CREDENTIAL_SUBJECT,
|
||||
claim.getPath().get(0));
|
||||
assertEquals("The jwt_vc-credential claim credentialSubject.scope-name is present.",
|
||||
"scope-name",
|
||||
claim.getPath().get(1));
|
||||
assertFalse("The jwt_vc-credential claim credentialSubject.scope-name is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNull("The jwt_vc-credential claim credentialSubject.scope-name has no display configured",
|
||||
claim.getDisplay());
|
||||
}
|
||||
|
||||
assertEquals("The jwt_vc-credential should offer vct",
|
||||
verifiableCredentialType,
|
||||
jwtVcConfig.getVct());
|
||||
|
||||
// We are offering key binding only for identity credential
|
||||
assertTrue("The jwt_vc-credential should contain a cryptographic binding method supported named jwk",
|
||||
jwtVcConfig.getCryptographicBindingMethodsSupported()
|
||||
.contains(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT));
|
||||
assertTrue("The jwt_vc-credential should contain a credential signing algorithm named RS256",
|
||||
jwtVcConfig.getCredentialSigningAlgValuesSupported().contains("RS256"));
|
||||
assertTrue("The jwt_vc-credential should support a proof of type jwt with signing algorithm RS256",
|
||||
credentialIssuer.getCredentialsSupported()
|
||||
.get(credentialConfigurationId)
|
||||
.getProofTypesSupported()
|
||||
.getSupportedProofTypes()
|
||||
.get("jwt")
|
||||
.getSigningAlgorithmsSupported()
|
||||
.contains("RS256"));
|
||||
assertEquals("The jwt_vc-credential should display as Test Credential",
|
||||
credentialConfigurationId,
|
||||
jwtVcConfig.getDisplay().get(0).getName());
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2024 Red Hat, Inc. and/or its affiliates
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@ -20,7 +20,6 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.apache.commons.collections4.map.HashedMap;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.http.HttpStatus;
|
||||
@ -36,14 +35,20 @@ import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.oid4vci.CredentialScopeModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.oid4vci.Oid4VciConstants;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.JwtCredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.SdJwtCredentialBuilder;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
|
||||
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCGeneratedIdMapper;
|
||||
import org.keycloak.protocol.oid4vc.model.Claim;
|
||||
import org.keycloak.protocol.oid4vc.model.Claims;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
@ -52,9 +57,11 @@ import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.JwtProof;
|
||||
import org.keycloak.protocol.oid4vc.model.Proof;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||
import org.keycloak.representations.idm.ComponentExportRepresentation;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
import org.keycloak.sdjwt.vp.SdJwtVP;
|
||||
@ -73,6 +80,7 @@ import java.util.stream.Collectors;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
@ -85,50 +93,71 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Test
|
||||
public void testRequestTestCredential() {
|
||||
String token = getBearerToken(oauth);
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run(session -> testRequestTestCredential(session, token, null));
|
||||
.run(session -> {
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
testRequestTestCredential(session, clientScope, token, null);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRequestTestCredentialWithKeybinding() {
|
||||
String cNonce = getCNonce();
|
||||
String token = getBearerToken(oauth);
|
||||
testingClient.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
JwtProof proof = new JwtProof()
|
||||
.setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
|
||||
SdJwtVP sdJwtVP = testRequestTestCredential(session, token, proof);
|
||||
assertNotNull("A cnf claim must be attached to the credential", sdJwtVP.getCnfClaim());
|
||||
}));
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
JwtProof proof = new JwtProof()
|
||||
.setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
|
||||
SdJwtVP sdJwtVP = testRequestTestCredential(session, clientScope, token, proof);
|
||||
assertNotNull("A cnf claim must be attached to the credential", sdJwtVP.getCnfClaim());
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRequestTestCredentialWithInvalidKeybinding() throws Throwable {
|
||||
try {
|
||||
String cNonce = getCNonce();
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() -> testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
JwtProof proof = new JwtProof()
|
||||
.setJwt(generateInvalidJwtProof(getCredentialIssuer(session), cNonce));
|
||||
String cNonce = getCNonce();
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
|
||||
testRequestTestCredential(session, token, proof);
|
||||
})));
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
try {
|
||||
withCausePropagation(() -> {
|
||||
testingClient.server(TEST_REALM_NAME).run((session -> {
|
||||
JwtProof proof = new JwtProof()
|
||||
.setJwt(generateInvalidJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
|
||||
testRequestTestCredential(session, clientScope, token, proof);
|
||||
}));
|
||||
});
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
Assert.assertEquals("Could not validate provided proof", ex.getMessage());
|
||||
Assert.assertEquals("Could not verify signature of provided proof", ex.getCause().getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProofOfPossessionWithMissingAudience() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth);
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
withCausePropagation(() -> testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
@ -140,7 +169,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
nonceEndpoint));
|
||||
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
testRequestTestCredential(session, token, proof);
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
testRequestTestCredential(session, clientScope, token, proof);
|
||||
})));
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
@ -155,7 +186,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
@Test
|
||||
public void testProofOfPossessionWithIllegalSourceEndpoint() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth);
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
withCausePropagation(() -> testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
@ -166,7 +199,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint), null);
|
||||
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
testRequestTestCredential(session, token, proof);
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
testRequestTestCredential(session, clientScope, token, proof);
|
||||
})));
|
||||
Assert.fail("Should have thrown an exception");
|
||||
} catch (BadRequestException ex) {
|
||||
@ -181,7 +216,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
@Test
|
||||
public void testProofOfPossessionWithExpiredState() throws Throwable {
|
||||
try {
|
||||
String token = getBearerToken(oauth);
|
||||
String token = getBearerToken(oauth, client, sdJwtTypeCredentialClientScope.getName());
|
||||
final String clientScopeString = toJsonString(sdJwtTypeCredentialClientScope);
|
||||
|
||||
withCausePropagation(() -> testingClient
|
||||
.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
@ -196,7 +233,9 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, nonceEndpoint));
|
||||
Proof proof = new JwtProof().setJwt(generateJwtProof(getCredentialIssuer(session), cNonce));
|
||||
|
||||
testRequestTestCredential(session, token, proof);
|
||||
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString,
|
||||
ClientScopeRepresentation.class);
|
||||
testRequestTestCredential(session, clientScope, token, proof);
|
||||
} finally {
|
||||
// make sure other tests are not affected by the changed realm-attribute
|
||||
session.getContext().getRealm().removeAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS);
|
||||
@ -214,24 +253,28 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
return OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
|
||||
}
|
||||
|
||||
private static SdJwtVP testRequestTestCredential(KeycloakSession session, String token, Proof proof)
|
||||
private static SdJwtVP testRequestTestCredential(KeycloakSession session, ClientScopeRepresentation clientScope,
|
||||
String token, Proof proof)
|
||||
throws VerificationException {
|
||||
String vct = "https://credentials.example.com/test-credential";
|
||||
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setFormat(Format.SD_JWT_VC)
|
||||
.setVct(vct)
|
||||
.setCredentialConfigurationId(credentialConfigurationId)
|
||||
.setProof(proof);
|
||||
|
||||
Response credentialResponse = issuerEndpoint.requestCredential(credentialRequest);
|
||||
assertEquals("The credential request should be answered successfully.", HttpStatus.SC_OK, credentialResponse.getStatus());
|
||||
assertEquals("The credential request should be answered successfully.",
|
||||
HttpStatus.SC_OK,
|
||||
credentialResponse.getStatus());
|
||||
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
|
||||
CredentialResponse credentialResponseVO = JsonSerialization.mapper.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
|
||||
new TestCredentialResponseHandler(vct).handleCredentialResponse(credentialResponseVO);
|
||||
CredentialResponse credentialResponseVO = JsonSerialization.mapper.convertValue(credentialResponse.getEntity(),
|
||||
CredentialResponse.class);
|
||||
new TestCredentialResponseHandler(sdJwtCredentialVct).handleCredentialResponse(credentialResponseVO,
|
||||
clientScope);
|
||||
|
||||
// Get the credential from the credentials array
|
||||
return SdJwtVP.of(credentialResponseVO.getCredentials().get(0).getCredential().toString());
|
||||
@ -247,10 +290,14 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
@Test
|
||||
public void testCredentialIssuance() throws Exception {
|
||||
|
||||
String token = getBearerToken(oauth);
|
||||
ClientScopeRepresentation clientScope = sdJwtTypeCredentialClientScope;
|
||||
String token = getBearerToken(oauth, client, clientScope.getName());
|
||||
|
||||
// 1. Retrieving the credential-offer-uri
|
||||
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=test-credential");
|
||||
final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) +
|
||||
"credential-offer-uri?credential_configuration_id=" +
|
||||
credentialConfigurationId);
|
||||
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
|
||||
|
||||
@ -297,14 +344,18 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
assertEquals(HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
|
||||
String theToken = accessTokenResponse.getAccessToken();
|
||||
|
||||
final String vct = "https://credentials.example.com/test-credential";
|
||||
final String vct = clientScope.getAttributes().get(CredentialScopeModel.VCT);
|
||||
|
||||
// 6. Get the credential
|
||||
credentialsOffer.getCredentialConfigurationIds().stream()
|
||||
.map(offeredCredentialId -> credentialIssuer.getCredentialsSupported().get(offeredCredentialId))
|
||||
.forEach(supportedCredential -> {
|
||||
try {
|
||||
requestOffer(theToken, credentialIssuer.getCredentialEndpoint(), supportedCredential, new TestCredentialResponseHandler(vct));
|
||||
requestCredential(theToken,
|
||||
credentialIssuer.getCredentialEndpoint(),
|
||||
supportedCredential,
|
||||
new TestCredentialResponseHandler(vct),
|
||||
sdJwtTypeCredentialClientScope);
|
||||
} catch (IOException e) {
|
||||
fail("Was not able to get the credential.");
|
||||
} catch (VerificationException e) {
|
||||
@ -317,7 +368,12 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
* This is testing the configuration exposed by OID4VCIssuerWellKnownProvider based on the client and signing config setup here.
|
||||
*/
|
||||
@Test
|
||||
public void getConfig() {
|
||||
public void testGetSdJwtConfigFromMetadata() {
|
||||
final String scopeName = sdJwtTypeCredentialClientScope.getName();
|
||||
final String credentialConfigurationId = sdJwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.CONFIGURATION_ID);
|
||||
final String verifiableCredentialType = sdJwtTypeCredentialClientScope.getAttributes()
|
||||
.get(CredentialScopeModel.VCT);
|
||||
String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME;
|
||||
String expectedCredentialsEndpoint = expectedIssuer + "/protocol/oid4vc/credential";
|
||||
String expectedNonceEndpoint = expectedIssuer + "/protocol/oid4vc/" + OID4VCIssuerEndpoint.NONCE_PATH;
|
||||
@ -336,106 +392,151 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
credentialIssuer.getNonceEndpoint());
|
||||
assertEquals("Since the authorization server is equal to the issuer, just 1 should be returned.", 1, credentialIssuer.getAuthorizationServers().size());
|
||||
assertEquals("The expected server should have been returned.", expectedAuthorizationServer, credentialIssuer.getAuthorizationServers().get(0));
|
||||
assertTrue("The test-credential should be supported.", credentialIssuer.getCredentialsSupported().containsKey("test-credential"));
|
||||
assertEquals("The test-credential should offer type test-credential", "test-credential", credentialIssuer.getCredentialsSupported().get("test-credential").getScope());
|
||||
assertEquals("The test-credential should be offered in the sd-jwt format.", Format.SD_JWT_VC, credentialIssuer.getCredentialsSupported().get("test-credential").getFormat());
|
||||
assertNotNull("The test-credential can optionally provide a claims claim.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims());
|
||||
assertNotNull("The test-credential claim firstName is present.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName"));
|
||||
assertFalse("The test-credential claim firstName is not mandatory.", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getMandatory());
|
||||
assertEquals("The test-credential claim firstName shall be displayed as First Name", "First Name", credentialIssuer.getCredentialsSupported().get("test-credential").getClaims().get("firstName").getDisplay().get(0).getName());
|
||||
assertEquals("The test-credential should offer vct VerifiableCredential", "https://credentials.example.com/test-credential", credentialIssuer.getCredentialsSupported().get("test-credential").getVct());
|
||||
|
||||
assertTrue("The sd-jwt-credential should be supported.",
|
||||
credentialIssuer.getCredentialsSupported().containsKey(credentialConfigurationId));
|
||||
|
||||
SupportedCredentialConfiguration jwtVcConfig =
|
||||
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId);
|
||||
assertEquals("The sd-jwt-credential should offer type test-credential",
|
||||
scopeName,
|
||||
jwtVcConfig.getScope());
|
||||
assertEquals("The sd-jwt-credential should be offered in the jwt_vc format.",
|
||||
Format.SD_JWT_VC,
|
||||
jwtVcConfig.getFormat());
|
||||
|
||||
assertNotNull("The sd-jwt-credential can optionally provide a claims claim.",
|
||||
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId)
|
||||
.getClaims());
|
||||
|
||||
Claims jwtVcClaims = jwtVcConfig.getClaims();
|
||||
assertNotNull("The sd-jwt-credential can optionally provide a claims claim.",
|
||||
jwtVcClaims);
|
||||
|
||||
assertEquals(5, jwtVcClaims.size());
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(0);
|
||||
assertEquals("The sd-jwt-credential claim roles is present.",
|
||||
"roles",
|
||||
claim.getPath().get(0));
|
||||
assertFalse("The sd-jwt-credential claim roles is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNull("The sd-jwt-credential claim roles has no display configured",
|
||||
claim.getDisplay());
|
||||
}
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(1);
|
||||
assertEquals("The sd-jwt-credential claim email is present.",
|
||||
"email",
|
||||
claim.getPath().get(0));
|
||||
assertFalse("The sd-jwt-credential claim email is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNull("The sd-jwt-credential claim email has no display configured",
|
||||
claim.getDisplay());
|
||||
}
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(2);
|
||||
assertEquals("The sd-jwt-credential claim firstName is present.",
|
||||
"firstName",
|
||||
claim.getPath().get(0));
|
||||
assertFalse("The sd-jwt-credential claim firstName is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNull("The sd-jwt-credential claim firstName has no display configured",
|
||||
claim.getDisplay());
|
||||
}
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(3);
|
||||
assertEquals("The sd-jwt-credential claim lastName is present.",
|
||||
"lastName",
|
||||
claim.getPath().get(0));
|
||||
assertFalse("The sd-jwt-credential claim lastName is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNull("The sd-jwt-credential claim lastName has no display configured",
|
||||
claim.getDisplay());
|
||||
}
|
||||
{
|
||||
Claim claim = jwtVcClaims.get(4);
|
||||
assertEquals("The sd-jwt-credential claim scope-name is present.",
|
||||
"scope-name",
|
||||
claim.getPath().get(0));
|
||||
assertFalse("The sd-jwt-credential claim scope-name is not mandatory.",
|
||||
claim.isMandatory());
|
||||
assertNull("The sd-jwt-credential claim scope-name has no display configured",
|
||||
claim.getDisplay());
|
||||
}
|
||||
|
||||
assertEquals("The sd-jwt-credential should offer vct",
|
||||
verifiableCredentialType,
|
||||
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId).getVct());
|
||||
|
||||
// We are offering key binding only for identity credential
|
||||
assertTrue("The IdentityCredential should contain a cryptographic binding method supported named jwk", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCryptographicBindingMethodsSupported().contains("jwk"));
|
||||
assertTrue("The IdentityCredential should contain a credential signing algorithm named ES256", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getCredentialSigningAlgValuesSupported().contains("ES256"));
|
||||
assertEquals("The IdentityCredential should display as Test Credential", "Identity Credential", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getDisplay().get(0).getName());
|
||||
assertTrue("The IdentityCredential should support a proof of type jwt with signing algorithm ES256", credentialIssuer.getCredentialsSupported().get("IdentityCredential").getProofTypesSupported().getJwt().getProofSigningAlgValuesSupported().contains("ES256"));
|
||||
assertTrue("The sd-jwt-credential should contain a cryptographic binding method supported named jwk",
|
||||
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId)
|
||||
.getCryptographicBindingMethodsSupported()
|
||||
.contains(CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT));
|
||||
assertTrue("The sd-jwt-credential should contain a credential signing algorithm named ES256",
|
||||
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId)
|
||||
.getCredentialSigningAlgValuesSupported().contains("ES256"));
|
||||
assertTrue("The sd-jwt-credential should support a proof of type jwt with signing algorithm ES256",
|
||||
credentialIssuer.getCredentialsSupported()
|
||||
.get(credentialConfigurationId)
|
||||
.getProofTypesSupported()
|
||||
.getSupportedProofTypes()
|
||||
.get("jwt")
|
||||
.getSigningAlgorithmsSupported()
|
||||
.contains("ES256"));
|
||||
assertEquals("The sd-jwt-credential should display as Test Credential",
|
||||
credentialConfigurationId,
|
||||
credentialIssuer.getCredentialsSupported().get(credentialConfigurationId)
|
||||
.getDisplay().get(0).getName());
|
||||
}));
|
||||
}
|
||||
|
||||
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) {
|
||||
String issuerDid = "did:web:issuer.org";
|
||||
SdJwtCredentialBuilder testSdJwtCredentialBuilder = new SdJwtCredentialBuilder(issuerDid);
|
||||
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session,
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator) {
|
||||
JwtCredentialBuilder testJwtCredentialBuilder = new JwtCredentialBuilder(new StaticTimeProvider(5));
|
||||
SdJwtCredentialBuilder testSdJwtCredentialBuilder = new SdJwtCredentialBuilder();
|
||||
|
||||
return new OID4VCIssuerEndpoint(
|
||||
session,
|
||||
Map.of(
|
||||
testSdJwtCredentialBuilder.getSupportedFormat(), testSdJwtCredentialBuilder
|
||||
testSdJwtCredentialBuilder.getSupportedFormat(), testSdJwtCredentialBuilder,
|
||||
testJwtCredentialBuilder.getSupportedFormat(), testJwtCredentialBuilder
|
||||
),
|
||||
authenticator,
|
||||
TIME_PROVIDER,
|
||||
30,
|
||||
true);
|
||||
30);
|
||||
}
|
||||
|
||||
private static final String JTI_KEY = "jti";
|
||||
|
||||
public static ProtocolMapperRepresentation getJtiGeneratedIdMapper(String supportedCredentialTypes) {
|
||||
public static ProtocolMapperRepresentation getJtiGeneratedIdMapper() {
|
||||
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
||||
protocolMapperRepresentation.setName("generated-id-mapper");
|
||||
protocolMapperRepresentation.setProtocol("oid4vc");
|
||||
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
|
||||
protocolMapperRepresentation.setProtocolMapper("oid4vc-generated-id-mapper");
|
||||
protocolMapperRepresentation.setConfig(Map.of(
|
||||
OID4VCGeneratedIdMapper.SUBJECT_PROPERTY_CONFIG_KEY, JTI_KEY,
|
||||
"supportedCredentialTypes", supportedCredentialTypes
|
||||
OID4VCGeneratedIdMapper.CLAIM_NAME, JTI_KEY
|
||||
));
|
||||
return protocolMapperRepresentation;
|
||||
}
|
||||
|
||||
public static ClientScopeModel createCredentialScope(KeycloakSession session) {
|
||||
RealmModel realmModel = session.getContext().getRealm();
|
||||
ClientScopeModel credentialScope = session.clientScopes()
|
||||
.addClientScope(realmModel, jwtTypeCredentialScopeName);
|
||||
credentialScope.setAttribute(CredentialScopeModel.CREDENTIAL_IDENTIFIER,
|
||||
jwtTypeCredentialScopeName);
|
||||
credentialScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
return credentialScope;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ComponentExportRepresentation getKeyProvider() {
|
||||
return getEcKeyProvider();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<ComponentExportRepresentation> getCredentialBuilderProviders() {
|
||||
return List.of(getCredentialBuilderProvider(Format.SD_JWT_VC));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, String> getCredentialDefinitionAttributes() {
|
||||
Map<String, String> testCredentialAttributes = Map.ofEntries(
|
||||
Map.entry("vc.test-credential.expiry_in_s", "1800"),
|
||||
Map.entry("vc.test-credential.format", Format.SD_JWT_VC),
|
||||
Map.entry("vc.test-credential.scope", "test-credential"),
|
||||
Map.entry("vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }"),
|
||||
Map.entry("vc.test-credential.vct", "https://credentials.example.com/test-credential"),
|
||||
Map.entry("vc.test-credential.credential_signing_alg_values_supported", "ES256,ES384"),
|
||||
Map.entry("vc.test-credential.display.0", "{\n \"name\": \"Test Credential\"\n}"),
|
||||
Map.entry("vc.test-credential.cryptographic_binding_methods_supported", "jwk"),
|
||||
Map.entry("vc.test-credential.proof_types_supported", "{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"),
|
||||
Map.entry("vc.test-credential.credential_build_config.token_jws_type", "example+sd-jwt"),
|
||||
Map.entry("vc.test-credential.credential_build_config.hash_algorithm", "sha-256"),
|
||||
Map.entry("vc.test-credential.credential_build_config.visible_claims", "iat,nbf,jti"),
|
||||
Map.entry("vc.test-credential.credential_build_config.decoys", "2"),
|
||||
Map.entry("vc.test-credential.credential_build_config.signing_algorithm", "ES256")
|
||||
);
|
||||
|
||||
Map<String, String> identityCredentialAttributes = Map.ofEntries(
|
||||
Map.entry("vc.IdentityCredential.expiry_in_s", "31536000"),
|
||||
Map.entry("vc.IdentityCredential.format", Format.SD_JWT_VC),
|
||||
Map.entry("vc.IdentityCredential.scope", "identity_credential"),
|
||||
Map.entry("vc.IdentityCredential.vct", "https://credentials.example.com/identity_credential"),
|
||||
Map.entry("vc.IdentityCredential.cryptographic_binding_methods_supported", "jwk"),
|
||||
Map.entry("vc.IdentityCredential.credential_signing_alg_values_supported", "ES256,ES384"),
|
||||
Map.entry("vc.IdentityCredential.claims", "{\"given_name\":{\"display\":[{\"name\":\"الاسم الشخصي\",\"locale\":\"ar\"},{\"name\":\"Vorname\",\"locale\":\"de\"},{\"name\":\"Given Name\",\"locale\":\"en\"},{\"name\":\"Nombre\",\"locale\":\"es\"},{\"name\":\"نام\",\"locale\":\"fa\"},{\"name\":\"Etunimi\",\"locale\":\"fi\"},{\"name\":\"Prénom\",\"locale\":\"fr\"},{\"name\":\"पहचानी गई नाम\",\"locale\":\"hi\"},{\"name\":\"Nome\",\"locale\":\"it\"},{\"name\":\"名\",\"locale\":\"ja\"},{\"name\":\"Овог нэр\",\"locale\":\"mn\"},{\"name\":\"Voornaam\",\"locale\":\"nl\"},{\"name\":\"Nome Próprio\",\"locale\":\"pt\"},{\"name\":\"Förnamn\",\"locale\":\"sv\"},{\"name\":\"مسلمان نام\",\"locale\":\"ur\"}]},\"family_name\":{\"display\":[{\"name\":\"اسم العائلة\",\"locale\":\"ar\"},{\"name\":\"Nachname\",\"locale\":\"de\"},{\"name\":\"Family Name\",\"locale\":\"en\"},{\"name\":\"Apellido\",\"locale\":\"es\"},{\"name\":\"نام خانوادگی\",\"locale\":\"fa\"},{\"name\":\"Sukunimi\",\"locale\":\"fi\"},{\"name\":\"Nom de famille\",\"locale\":\"fr\"},{\"name\":\"परिवार का नाम\",\"locale\":\"hi\"},{\"name\":\"Cognome\",\"locale\":\"it\"},{\"name\":\"姓\",\"locale\":\"ja\"},{\"name\":\"өөрийн нэр\",\"locale\":\"mn\"},{\"name\":\"Achternaam\",\"locale\":\"nl\"},{\"name\":\"Sobrenome\",\"locale\":\"pt\"},{\"name\":\"Efternamn\",\"locale\":\"sv\"},{\"name\":\"خاندانی نام\",\"locale\":\"ur\"}]},\"birthdate\":{\"display\":[{\"name\":\"تاريخ الميلاد\",\"locale\":\"ar\"},{\"name\":\"Geburtsdatum\",\"locale\":\"de\"},{\"name\":\"Date of Birth\",\"locale\":\"en\"},{\"name\":\"Fecha de Nacimiento\",\"locale\":\"es\"},{\"name\":\"تاریخ تولد\",\"locale\":\"fa\"},{\"name\":\"Syntymäaika\",\"locale\":\"fi\"},{\"name\":\"Date de naissance\",\"locale\":\"fr\"},{\"name\":\"जन्म की तारीख\",\"locale\":\"hi\"},{\"name\":\"Data di nascita\",\"locale\":\"it\"},{\"name\":\"生年月日\",\"locale\":\"ja\"},{\"name\":\"төрсөн өдөр\",\"locale\":\"mn\"},{\"name\":\"Geboortedatum\",\"locale\":\"nl\"},{\"name\":\"Data de Nascimento\",\"locale\":\"pt\"},{\"name\":\"Födelsedatum\",\"locale\":\"sv\"},{\"name\":\"تاریخ پیدائش\",\"locale\":\"ur\"}]}}"),
|
||||
Map.entry("vc.IdentityCredential.display.0", "{\"name\": \"Identity Credential\"}"),
|
||||
Map.entry("vc.IdentityCredential.proof_types_supported", "{\"jwt\":{\"proof_signing_alg_values_supported\":[\"ES256\"]}}"),
|
||||
Map.entry("vc.IdentityCredential.credential_build_config.token_jws_type", "example+sd-jwt"),
|
||||
Map.entry("vc.IdentityCredential.credential_build_config.hash_algorithm", "sha-256"),
|
||||
Map.entry("vc.IdentityCredential.credential_build_config.visible_claims", "iat,nbf,jti"),
|
||||
Map.entry("vc.IdentityCredential.credential_build_config.decoys", "0"),
|
||||
Map.entry("vc.IdentityCredential.credential_build_config.signing_algorithm", "ES256")
|
||||
);
|
||||
|
||||
HashedMap<String, String> allAttributes = new HashedMap<>();
|
||||
allAttributes.putAll(testCredentialAttributes);
|
||||
allAttributes.putAll(identityCredentialAttributes);
|
||||
|
||||
return allAttributes;
|
||||
}
|
||||
|
||||
static class TestCredentialResponseHandler extends CredentialResponseHandler {
|
||||
final String vct;
|
||||
|
||||
@ -444,7 +545,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleCredentialResponse(CredentialResponse credentialResponse) throws VerificationException {
|
||||
protected void handleCredentialResponse(CredentialResponse credentialResponse, ClientScopeRepresentation clientScope) throws VerificationException {
|
||||
// SDJWT have a special format.
|
||||
SdJwtVP sdJwtVP = SdJwtVP.of(credentialResponse.getCredentials().get(0).getCredential().toString());
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create(sdJwtVP.getIssuerSignedJWT().toJws(), JsonWebToken.class).getToken();
|
||||
@ -470,8 +571,11 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
assertTrue("The credentials should include the lastName claim.", disclosureMap.containsKey("lastName"));
|
||||
assertEquals("lastName claim incorrectly mapped.", "Doe", disclosureMap.get("lastName").get(2).asText());
|
||||
assertTrue("The credentials should include the roles claim.", disclosureMap.containsKey("roles"));
|
||||
assertTrue("The credentials should include the test-credential claim.", disclosureMap.containsKey("test-credential"));
|
||||
assertTrue("lastName claim incorrectly mapped.", disclosureMap.get("test-credential").get(2).asBoolean());
|
||||
assertTrue("The credentials should include the scope-name claim.",
|
||||
disclosureMap.containsKey("scope-name"));
|
||||
assertEquals("The credentials should include the scope-name claims correct value.",
|
||||
clientScope.getName(),
|
||||
disclosureMap.get("scope-name").get(2).textValue());
|
||||
assertTrue("The credentials should include the email claim.", disclosureMap.containsKey("email"));
|
||||
assertEquals("email claim incorrectly mapped.", "john@email.cz", disclosureMap.get("email").get(2).asText());
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ import jakarta.ws.rs.client.Invocation;
|
||||
import jakarta.ws.rs.client.WebTarget;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.apache.http.HttpStatus;
|
||||
@ -35,6 +36,7 @@ import org.keycloak.common.util.CertificateUtils;
|
||||
import org.keycloak.common.util.KeyUtils;
|
||||
import org.keycloak.common.util.MultivaluedHashMap;
|
||||
import org.keycloak.common.util.PemUtils;
|
||||
import org.keycloak.constants.Oid4VciConstants;
|
||||
import org.keycloak.crypto.ECDSASignatureSignerContext;
|
||||
import org.keycloak.crypto.KeyUse;
|
||||
import org.keycloak.crypto.KeyWrapper;
|
||||
@ -49,11 +51,11 @@ import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
|
||||
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator;
|
||||
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.NonceResponse;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
||||
import org.keycloak.representations.idm.ComponentExportRepresentation;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
import org.keycloak.representations.idm.RoleRepresentation;
|
||||
@ -101,6 +103,14 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||
|
||||
protected static final KeyWrapper RSA_KEY = getRsaKey();
|
||||
|
||||
protected static final String sdJwtTypeCredentialScopeName = "sd-jwt-credential";
|
||||
protected static final String sdJwtTypeCredentialConfigurationIdName = "sd-jwt-credential-config-id";
|
||||
|
||||
protected static final String jwtTypeCredentialScopeName = "jwt-credential";
|
||||
protected static final String jwtTypeCredentialConfigurationIdName = "jwt-credential-config-id";
|
||||
|
||||
protected static final String TEST_CREDENTIAL_MAPPERS_FILE = "/oid4vc/test-credential-mappers.json";
|
||||
|
||||
protected static CredentialSubject getCredentialSubject(Map<String, Object> claims) {
|
||||
CredentialSubject credentialSubject = new CredentialSubject();
|
||||
claims.forEach(credentialSubject::setClaims);
|
||||
@ -215,19 +225,6 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||
return clientRepresentation;
|
||||
}
|
||||
|
||||
public static Map<String, String> getTestCredentialDefinitionAttributes() {
|
||||
return Map.of(
|
||||
"vc.test-credential.expiry_in_s", "100",
|
||||
"vc.test-credential.format", Format.JWT_VC,
|
||||
"vc.test-credential.scope", "VerifiableCredential",
|
||||
"vc.test-credential.claims", "{ \"firstName\": {\"mandatory\": false, \"display\": [{\"name\": \"First Name\", \"locale\": \"en-US\"}, {\"name\": \"名前\", \"locale\": \"ja-JP\"}]}, \"lastName\": {\"mandatory\": false}, \"email\": {\"mandatory\": false} }",
|
||||
"vc.test-credential.display.0","{\n \"name\": \"Test Credential\"\n}",
|
||||
"vc.test-credential.credential_build_config.token_jws_type", "JWT",
|
||||
"vc.test-credential.credential_build_config.signing_algorithm", "RS256"
|
||||
// Moved sd-jwt specific attributes to: org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCSdJwtIssuingEndpointTest.getTestCredentialSigningProvider
|
||||
);
|
||||
}
|
||||
|
||||
protected ComponentExportRepresentation getEdDSAKeyProvider() {
|
||||
ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation();
|
||||
componentExportRepresentation.setName("eddsa-generated");
|
||||
@ -255,86 +252,69 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||
return componentExportRepresentation;
|
||||
}
|
||||
|
||||
public void addProtocolMappersToClientScope(String scopeId, String scopeName, String clientId) {
|
||||
List<ProtocolMapperRepresentation> protocolMappers = getProtocolMappers(scopeName, clientId);
|
||||
public void addProtocolMappersToClientScope(ClientScopeRepresentation clientScope,
|
||||
List<ProtocolMapperRepresentation> protocolMappers) {
|
||||
String scopeId = clientScope.getId();
|
||||
String scopeName = clientScope.getName();
|
||||
|
||||
if (!protocolMappers.isEmpty()) {
|
||||
ClientScopeResource clientScopeResource = testRealm().clientScopes().get(scopeId);
|
||||
ProtocolMappersResource protocolMappersResource = clientScopeResource.getProtocolMappers();
|
||||
if (protocolMappers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (ProtocolMapperRepresentation protocolMapper : protocolMappers) {
|
||||
Response response = protocolMappersResource.createMapper(protocolMapper);
|
||||
if (response.getStatus() != 201) {
|
||||
LOGGER.errorf("Failed to create protocol mapper: {} for scope: {}", protocolMapper, scopeName);
|
||||
}
|
||||
ClientScopeResource clientScopeResource = testRealm().clientScopes().get(scopeId);
|
||||
ProtocolMappersResource protocolMappersResource = clientScopeResource.getProtocolMappers();
|
||||
|
||||
for (ProtocolMapperRepresentation protocolMapper : protocolMappers) {
|
||||
Response response = protocolMappersResource.createMapper(protocolMapper);
|
||||
if (response.getStatus() != 201) {
|
||||
LOGGER.errorf("Failed to create protocol mapper: {} for scope: {}", protocolMapper, scopeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName, String clientId) {
|
||||
final String TEST_CREDENTIAL = "test-credential";
|
||||
final String VERIFIABLE_CREDENTIAL = "VerifiableCredential";
|
||||
|
||||
return switch (scopeName) {
|
||||
case TEST_CREDENTIAL -> List.of(
|
||||
getRoleMapper(clientId, TEST_CREDENTIAL),
|
||||
getUserAttributeMapper("email", "email", TEST_CREDENTIAL),
|
||||
getUserAttributeMapper("firstName", "firstName", TEST_CREDENTIAL),
|
||||
getUserAttributeMapper("lastName", "lastName", TEST_CREDENTIAL),
|
||||
getJtiGeneratedIdMapper(TEST_CREDENTIAL),
|
||||
getStaticClaimMapper(TEST_CREDENTIAL, TEST_CREDENTIAL),
|
||||
getIssuedAtTimeMapper(null, ChronoUnit.HOURS.name(), "COMPUTE", TEST_CREDENTIAL),
|
||||
getIssuedAtTimeMapper("nbf", null, "COMPUTE", TEST_CREDENTIAL)
|
||||
);
|
||||
case VERIFIABLE_CREDENTIAL -> List.of(
|
||||
getRoleMapper(clientId, VERIFIABLE_CREDENTIAL),
|
||||
getUserAttributeMapper("email", "email", VERIFIABLE_CREDENTIAL),
|
||||
getIdMapper(VERIFIABLE_CREDENTIAL),
|
||||
getStaticClaimMapper(scopeName, VERIFIABLE_CREDENTIAL)
|
||||
);
|
||||
default -> List.of(); // No mappers for unknown scopes
|
||||
};
|
||||
public List<ProtocolMapperRepresentation> getProtocolMappers(String scopeName) {
|
||||
return List.of(
|
||||
getRoleMapper(),
|
||||
getUserAttributeMapper("email", "email"),
|
||||
getUserAttributeMapper("firstName", "firstName"),
|
||||
getUserAttributeMapper("lastName", "lastName"),
|
||||
getJtiGeneratedIdMapper(),
|
||||
getStaticClaimMapper(scopeName),
|
||||
getIssuedAtTimeMapper("iat", ChronoUnit.HOURS.name(), "COMPUTE"),
|
||||
getIssuedAtTimeMapper("nbf", null, "COMPUTE"));
|
||||
}
|
||||
|
||||
public static ProtocolMapperRepresentation getRoleMapper(String clientId, String supportedCredentialTypes) {
|
||||
public static ProtocolMapperRepresentation getRoleMapper() {
|
||||
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
||||
protocolMapperRepresentation.setName("role-mapper");
|
||||
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
|
||||
protocolMapperRepresentation.setProtocol("oid4vc");
|
||||
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
protocolMapperRepresentation.setProtocolMapper("oid4vc-target-role-mapper");
|
||||
protocolMapperRepresentation.setConfig(
|
||||
Map.of(
|
||||
"subjectProperty", "roles",
|
||||
"clientId", clientId,
|
||||
"supportedCredentialTypes", supportedCredentialTypes)
|
||||
Map.of("claim.name", "roles")
|
||||
);
|
||||
return protocolMapperRepresentation;
|
||||
}
|
||||
|
||||
public static ProtocolMapperRepresentation getIdMapper(String supportedCredentialTypes) {
|
||||
public static ProtocolMapperRepresentation getIdMapper() {
|
||||
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
||||
protocolMapperRepresentation.setName("id-mapper");
|
||||
protocolMapperRepresentation.setProtocol("oid4vc");
|
||||
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
|
||||
protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper");
|
||||
protocolMapperRepresentation.setConfig(
|
||||
Map.of(
|
||||
"supportedCredentialTypes", supportedCredentialTypes)
|
||||
);
|
||||
protocolMapperRepresentation.setConfig(Map.of());
|
||||
return protocolMapperRepresentation;
|
||||
}
|
||||
|
||||
public static ProtocolMapperRepresentation getStaticClaimMapper(String scope, String supportedCredentialTypes) {
|
||||
public static ProtocolMapperRepresentation getStaticClaimMapper(String scopeName) {
|
||||
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
||||
protocolMapperRepresentation.setName(UUID.randomUUID().toString());
|
||||
protocolMapperRepresentation.setProtocol("oid4vc");
|
||||
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
|
||||
protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper");
|
||||
protocolMapperRepresentation.setConfig(
|
||||
Map.of(
|
||||
"subjectProperty", scope,
|
||||
"staticValue", "true",
|
||||
"supportedCredentialTypes", supportedCredentialTypes)
|
||||
Map.of("claim.name", "scope-name",
|
||||
"staticValue", scopeName)
|
||||
);
|
||||
return protocolMapperRepresentation;
|
||||
}
|
||||
@ -413,32 +393,30 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||
}
|
||||
}
|
||||
|
||||
protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName, String supportedCredentialTypes) {
|
||||
protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName) {
|
||||
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
||||
protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + attributeName + "-mapper");
|
||||
protocolMapperRepresentation.setProtocol("oid4vc");
|
||||
protocolMapperRepresentation.setName(attributeName + "-mapper");
|
||||
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
|
||||
protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper");
|
||||
protocolMapperRepresentation.setConfig(
|
||||
Map.of(
|
||||
"subjectProperty", subjectProperty,
|
||||
"userAttribute", attributeName,
|
||||
"supportedCredentialTypes", supportedCredentialTypes)
|
||||
"claim.name", subjectProperty,
|
||||
"userAttribute", attributeName)
|
||||
);
|
||||
return protocolMapperRepresentation;
|
||||
}
|
||||
|
||||
protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource, String supportedCredentialTypes) {
|
||||
protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) {
|
||||
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
|
||||
protocolMapperRepresentation.setName(supportedCredentialTypes + "-" + subjectProperty + "-oid4vc-issued-at-time-claim-mapper");
|
||||
protocolMapperRepresentation.setProtocol("oid4vc");
|
||||
protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper");
|
||||
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
|
||||
protocolMapperRepresentation.setProtocolMapper("oid4vc-issued-at-time-claim-mapper");
|
||||
|
||||
Map<String, String> configMap = new HashMap<>();
|
||||
configMap.put("supportedCredentialTypes", supportedCredentialTypes);
|
||||
Optional.ofNullable(subjectProperty)
|
||||
.ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.SUBJECT_PROPERTY_CONFIG_KEY, value));
|
||||
.ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.CLAIM_NAME, value));
|
||||
Optional.ofNullable(truncateToTimeUnit)
|
||||
.ifPresent(value -> configMap.put(OID4VCIssuedAtTimeClaimMapper.TRUNCATE_TO_TIME_UNIT_KEY, value));
|
||||
Optional.ofNullable(valueSource)
|
||||
@ -527,4 +505,37 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T fromJsonString(String representation, Class<T> clazz)
|
||||
{
|
||||
try {
|
||||
return JsonSerialization.readValue(representation, clazz);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> T fromJsonString(String representation, TypeReference<T> typeReference)
|
||||
{
|
||||
if (representation == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JsonSerialization.readValue(representation, typeReference);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String toJsonString(Object object)
|
||||
{
|
||||
if (object == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JsonSerialization.writeValueAsString(object);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -199,11 +199,12 @@ public class SdJwtCredentialSignerTest extends OID4VCTest {
|
||||
public static void testSignSDJwtCredential(KeycloakSession session, String signingKeyId, String overrideKeyId, String
|
||||
algorithm, Map<String, Object> claims, int decoys, List<String> visibleClaims) {
|
||||
CredentialBuildConfig credentialBuildConfig = new CredentialBuildConfig()
|
||||
.setCredentialIssuer(TEST_DID.toString())
|
||||
.setCredentialType("https://credentials.example.com/test-credential")
|
||||
.setTokenJwsType("example+sd-jwt")
|
||||
.setHashAlgorithm("sha-256")
|
||||
.setNumberOfDecoys(decoys)
|
||||
.setVisibleClaims(visibleClaims)
|
||||
.setSdJwtVisibleClaims(visibleClaims)
|
||||
.setSigningKeyId(signingKeyId)
|
||||
.setSigningAlgorithm(algorithm)
|
||||
.setOverrideKeyId(overrideKeyId);
|
||||
@ -211,7 +212,7 @@ public class SdJwtCredentialSignerTest extends OID4VCTest {
|
||||
SdJwtCredentialSigner sdJwtCredentialSigner = new SdJwtCredentialSigner(session);
|
||||
|
||||
VerifiableCredential testCredential = getTestCredential(claims);
|
||||
SdJwtCredentialBody sdJwtCredentialBody = new SdJwtCredentialBuilder("did:web:test.org")
|
||||
SdJwtCredentialBody sdJwtCredentialBody = new SdJwtCredentialBuilder()
|
||||
.buildCredentialBody(testCredential, credentialBuildConfig);
|
||||
|
||||
String sdJwt = sdJwtCredentialSigner.signCredential(sdJwtCredentialBody, credentialBuildConfig);
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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\"}]"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user