• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wistefan / keycloak-vc-issuer / #357

18 Aug 2023 01:49PM UTC coverage: 39.92% (+0.08%) from 39.84%
#357

push

pulledtim
remove useless mount

299 of 749 relevant lines covered (39.92%)

0.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.75
src/main/java/org/fiware/keycloak/VCIssuerRealmResourceProvider.java
1
package org.fiware.keycloak;
2

3
import com.fasterxml.jackson.core.JsonProcessingException;
4
import com.fasterxml.jackson.core.type.TypeReference;
5
import com.fasterxml.jackson.databind.ObjectMapper;
6
import id.walt.custodian.WaltIdCustodian;
7
import id.walt.sdjwt.JwtVerificationResult;
8
import id.walt.servicematrix.BaseService;
9
import id.walt.servicematrix.ServiceRegistry;
10
import id.walt.servicematrix.utils.ReflectionUtils;
11
import id.walt.services.crypto.SunCryptoService;
12
import id.walt.services.jwt.WaltIdJwtService;
13
import id.walt.services.key.WaltIdKeyService;
14
import id.walt.services.vc.WaltIdJsonLdCredentialService;
15
import id.walt.services.vc.WaltIdJwtCredentialService;
16
import io.swagger.annotations.ApiOperation;
17
import io.swagger.annotations.ApiResponse;
18
import io.swagger.annotations.ApiResponses;
19
import kotlin.reflect.KClass;
20
import lombok.Getter;
21
import lombok.RequiredArgsConstructor;
22
import org.fiware.keycloak.model.ErrorResponse;
23
import org.fiware.keycloak.model.ErrorType;
24
import org.fiware.keycloak.model.Role;
25
import org.fiware.keycloak.model.SupportedCredential;
26
import org.fiware.keycloak.model.TokenResponse;
27
import org.fiware.keycloak.model.VCClaims;
28
import org.fiware.keycloak.model.VCConfig;
29
import org.fiware.keycloak.model.VCData;
30
import org.fiware.keycloak.model.VCRequest;
31
import org.fiware.keycloak.model.walt.CredentialDisplay;
32
import org.fiware.keycloak.model.walt.CredentialMetadata;
33
import org.fiware.keycloak.model.walt.FormatObject;
34
import org.fiware.keycloak.model.walt.IssuerDisplay;
35
import org.fiware.keycloak.model.walt.ProofType;
36
import org.fiware.keycloak.oidcvc.model.CredentialIssuerVO;
37
import org.fiware.keycloak.oidcvc.model.CredentialRequestVO;
38
import org.fiware.keycloak.oidcvc.model.CredentialResponseVO;
39
import org.fiware.keycloak.oidcvc.model.CredentialsOfferVO;
40
import org.fiware.keycloak.oidcvc.model.DisplayObjectVO;
41
import org.fiware.keycloak.oidcvc.model.ErrorResponseVO;
42
import org.fiware.keycloak.oidcvc.model.FormatVO;
43
import org.fiware.keycloak.oidcvc.model.PreAuthorizedGrantVO;
44
import org.fiware.keycloak.oidcvc.model.PreAuthorizedVO;
45
import org.fiware.keycloak.oidcvc.model.ProofTypeVO;
46
import org.fiware.keycloak.oidcvc.model.ProofVO;
47
import org.fiware.keycloak.oidcvc.model.SupportedCredentialVO;
48
import org.jboss.logging.Logger;
49
import org.keycloak.OAuth2Constants;
50
import org.keycloak.common.util.Time;
51
import org.keycloak.events.EventBuilder;
52
import org.keycloak.models.AuthenticatedClientSessionModel;
53
import org.keycloak.models.ClientModel;
54
import org.keycloak.models.KeycloakContext;
55
import org.keycloak.models.KeycloakSession;
56
import org.keycloak.models.RoleModel;
57
import org.keycloak.models.UserModel;
58
import org.keycloak.models.UserSessionModel;
59
import org.keycloak.protocol.oidc.OIDCWellKnownProvider;
60
import org.keycloak.protocol.oidc.TokenManager;
61
import org.keycloak.protocol.oidc.utils.OAuth2Code;
62
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
63
import org.keycloak.representations.AccessToken;
64
import org.keycloak.representations.JsonWebToken;
65
import org.keycloak.services.managers.AppAuthManager;
66
import org.keycloak.services.managers.AuthenticationManager;
67
import org.keycloak.services.resource.RealmResourceProvider;
68
import org.keycloak.services.util.DefaultClientSessionContext;
69
import org.keycloak.urls.UrlType;
70

71
import javax.validation.constraints.NotNull;
72
import javax.ws.rs.BadRequestException;
73
import javax.ws.rs.Consumes;
74
import javax.ws.rs.FormParam;
75
import javax.ws.rs.GET;
76
import javax.ws.rs.NotAuthorizedException;
77
import javax.ws.rs.NotFoundException;
78
import javax.ws.rs.OPTIONS;
79
import javax.ws.rs.POST;
80
import javax.ws.rs.Path;
81
import javax.ws.rs.PathParam;
82
import javax.ws.rs.Produces;
83
import javax.ws.rs.QueryParam;
84
import javax.ws.rs.WebApplicationException;
85
import javax.ws.rs.core.MediaType;
86
import javax.ws.rs.core.Response;
87
import java.time.Clock;
88
import java.time.Duration;
89
import java.time.Instant;
90
import java.time.ZoneId;
91
import java.time.ZoneOffset;
92
import java.time.format.DateTimeFormatter;
93
import java.time.temporal.ChronoUnit;
94
import java.util.ArrayList;
95
import java.util.Arrays;
96
import java.util.List;
97
import java.util.Map;
98
import java.util.Objects;
99
import java.util.Optional;
100
import java.util.Set;
101
import java.util.UUID;
102
import java.util.stream.Collectors;
103

104
import static org.fiware.keycloak.SIOP2ClientRegistrationProvider.VC_TYPES_PREFIX;
105

106
/**
107
 * Realm-Resource to provide functionality for issuing VerfiableCredentials to users, depending on there roles in
108
 * registered SIOP-2 clients
109
 */
110
public class VCIssuerRealmResourceProvider implements RealmResourceProvider {
111

112
        private static final Logger LOGGER = Logger.getLogger(VCIssuerRealmResourceProvider.class);
1✔
113
        private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_DATE_TIME
1✔
114
                        .withZone(ZoneId.of(ZoneOffset.UTC.getId()));
1✔
115

116
        public static final String LD_PROOF_TYPE = "LD_PROOF";
117
        public static final String CREDENTIAL_PATH = "credential";
118
        public static final String TOKEN_PATH = "token";
119
        public static final String TYPE_VERIFIABLE_CREDENTIAL = "VerifiableCredential";
120
        public static final String GRANT_TYPE_PRE_AUTHORIZED_CODE = "urn:ietf:params:oauth:grant-type:pre-authorized_code";
121
        private static final String ACCESS_CONTROL_HEADER = "Access-Control-Allow-Origin";
122

123
        private final KeycloakSession session;
124
        public static final String SUBJECT_DID = "subjectDid";
125
        private final String issuerDid;
126
        private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
127
        private final WaltIdClient waltIdClient;
128
        private final ObjectMapper objectMapper;
129
        private final Clock clock;
130

131
        public VCIssuerRealmResourceProvider(KeycloakSession session, String issuerDid, WaltIdClient waltIdClient,
132
                        AppAuthManager.BearerTokenAuthenticator authenticator,
133
                        ObjectMapper objectMapper, Clock clock) {
1✔
134
                this.session = session;
1✔
135
                this.issuerDid = issuerDid;
1✔
136
                this.waltIdClient = waltIdClient;
1✔
137
                this.bearerTokenAuthenticator = authenticator;
1✔
138
                this.objectMapper = objectMapper;
1✔
139
                this.clock = clock;
1✔
140
                registerServices();
1✔
141
        }
1✔
142

143
        // register services used by the waltid ssikit
144
        private void registerServices() {
145
                ServiceRegistry.INSTANCE.registerService(WaltIdJsonLdCredentialService.Companion.getService(),
1✔
146
                                (KClass<? extends BaseService>) ReflectionUtils.INSTANCE.getKClassByName(
1✔
147
                                                "id.walt.services.vc.JsonLdCredentialService"));
148
                ServiceRegistry.INSTANCE.registerService(WaltIdJwtCredentialService.Companion.getService(),
1✔
149
                                (KClass<? extends BaseService>) ReflectionUtils.INSTANCE.getKClassByName(
1✔
150
                                                "id.walt.services.vc.JwtCredentialService"));
151
                ServiceRegistry.INSTANCE.registerService(SunCryptoService.Companion.getService(),
1✔
152
                                (KClass<? extends BaseService>) ReflectionUtils.INSTANCE.getKClassByName(
1✔
153
                                                "id.walt.services.crypto.CryptoService"));
154
                ServiceRegistry.INSTANCE.registerService(WaltIdKeyService.Companion.getService(),
1✔
155
                                (KClass<? extends BaseService>) ReflectionUtils.INSTANCE.getKClassByName(
1✔
156
                                                "id.walt.services.key.KeyService"));
157
                ServiceRegistry.INSTANCE.registerService(WaltIdJwtService.Companion.getService(),
1✔
158
                                (KClass<? extends BaseService>) ReflectionUtils.INSTANCE.getKClassByName(
1✔
159
                                                "id.walt.services.jwt.JwtService"));
160
                ServiceRegistry.INSTANCE.registerService(WaltIdCustodian.Companion.getService(),
1✔
161
                                (KClass<? extends BaseService>) ReflectionUtils.INSTANCE.getKClassByName(
1✔
162
                                                "id.walt.custodian.Custodian"));
163
        }
1✔
164

165
        @Override
166
        public Object getResource() {
167
                return this;
×
168
        }
169

170
        @Override
171
        public void close() {
172
                // no specific resources to close.
173
        }
×
174

175
        /**
176
         * Returns the did used by Keycloak to issue credentials
177
         *
178
         * @return the did
179
         */
180
        @GET
181
        @Path("/issuer")
182
        @Produces(MediaType.TEXT_PLAIN)
183
        public Response getIssuerDid() {
184
                return Response.ok().entity(issuerDid).header(ACCESS_CONTROL_HEADER, "*").build();
×
185
        }
186

187
        /**
188
         * Returns a list of types supported by this realm-resource. Will evaluate all registered SIOP-2 clients and return
189
         * there supported types. A user can request credentials for all of them.
190
         *
191
         * @return the list of supported VC-Types by this realm.
192
         */
193
        @GET
194
        @Path("{issuer-did}/types")
195
        @Produces(MediaType.APPLICATION_JSON)
196
        public List<SupportedCredential> getTypes(@PathParam("issuer-did") String issuerDidParam) {
197
                assertIssuerDid(issuerDidParam);
1✔
198
                UserModel userModel = getUserModel(
1✔
199
                                new NotAuthorizedException("Types is only available to authorized users."));
200

201
                LOGGER.debugf("User is {}", userModel.getId());
1✔
202

203
                return getCredentialsFromModels(getClientModelsFromSession());
1✔
204
        }
205

206
        // filter the client models for supported verifable credentials
207
        private List<SupportedCredential> getCredentialsFromModels(List<ClientModel> clientModels) {
208
                return List.copyOf(clientModels.stream()
1✔
209
                                .map(ClientModel::getAttributes)
1✔
210
                                .filter(Objects::nonNull)
1✔
211
                                .flatMap(attrs -> attrs.entrySet().stream())
1✔
212
                                .filter(attr -> attr.getKey().startsWith(VC_TYPES_PREFIX))
1✔
213
                                .flatMap(entry -> mapAttributeEntryToSc(entry).stream())
1✔
214
                                .collect(Collectors.toSet()));
1✔
215
        }
216

217
        // return the current usermodel
218
        private UserModel getUserModel(WebApplicationException errorResponse) {
219
                return getAuthResult(errorResponse).getUser();
1✔
220
        }
221

222
        // return the current usersession model
223
        private UserSessionModel getUserSessionModel() {
224
                return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN))).getSession();
×
225
        }
226

227
        private AuthenticationManager.AuthResult getAuthResult() {
228
                return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)));
×
229
        }
230

231
        // get the auth result from the authentication manager
232
        private AuthenticationManager.AuthResult getAuthResult(WebApplicationException errorResponse) {
233
                AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate();
1✔
234
                if (authResult == null) {
1✔
235
                        throw errorResponse;
1✔
236
                }
237
                return authResult;
1✔
238
        }
239

240
        private UserModel getUserModel() {
241
                return getUserModel(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)));
1✔
242
        }
243

244
        // assert that the given string is the configured issuer did
245
        private void assertIssuerDid(String requestedIssuerDid) {
246
                if (!requestedIssuerDid.equals(issuerDid)) {
1✔
247
                        throw new NotFoundException("No such issuer exists.");
×
248
                }
249
        }
1✔
250

251
        /**
252
         * Returns the meta data of the issuer.
253
         */
254
        @GET
255
        @Path("{issuer-did}/.well-known/openid-credential-issuer")
256
        @Produces({ MediaType.APPLICATION_JSON })
257
        @ApiOperation(value = "Return the issuer metadata", notes = "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-", tags = {})
258
        @ApiResponses(value = {
259
                        @ApiResponse(code = 200, message = "The credentials issuer metadata", response = CredentialIssuerVO.class) })
260
        public Response getIssuerMetadata(@PathParam("issuer-did") String issuerDidParam) {
261
                LOGGER.info("Retrieve issuer meta data");
1✔
262
                assertIssuerDid(issuerDidParam);
1✔
263

264
                KeycloakContext currentContext = session.getContext();
1✔
265

266
                return Response.ok().entity(new CredentialIssuerVO()
1✔
267
                                                .credentialIssuer(getIssuer())
1✔
268
                                                .credentialEndpoint(getCredentialEndpoint())
1✔
269
                                                .credentialsSupported(getSupportedCredentials(currentContext)))
1✔
270
                                .header(ACCESS_CONTROL_HEADER, "*").build();
1✔
271
        }
272

273
        private String getRealmResourcePath() {
274
                KeycloakContext currentContext = session.getContext();
1✔
275
                String realm = currentContext.getRealm().getId();
1✔
276
                String backendUrl = currentContext.getUri(UrlType.BACKEND).getBaseUri().toString();
1✔
277
                if (backendUrl.endsWith("/")) {
1✔
278
                        return String.format("%srealms/%s", backendUrl, realm);
×
279
                }
280
                return String.format("%s/realms/%s", backendUrl, realm);
1✔
281
        }
282

283
        private String getCredentialEndpoint() {
284

285
                return getIssuer() + "/" + CREDENTIAL_PATH;
1✔
286
        }
287

288
        private String getTokenEndpoint() {
289

290
                return getIssuer() + "/" + TOKEN_PATH;
×
291
        }
292

293
        private String getIssuer() {
294
                return String.format("%s/%s/%s", getRealmResourcePath(),
1✔
295
                                VCIssuerRealmResourceProviderFactory.ID,
296
                                issuerDid);
297
        }
298

299
        /**
300
         * Returns the openid-configuration of the issuer.
301
         * OIDC4VCI wallets expect the openid-configuration below the issuers root, thus we provide it here in addition to its standard keycloak path.
302
         */
303
        @GET
304
        @Path("{issuer-did}/.well-known/openid-configuration")
305
        @Produces({ MediaType.APPLICATION_JSON })
306
        public Response getOIDCConfig(@PathParam("issuer-did") String issuerDidParam) {
307
                LOGGER.info("Get OIDC config.");
×
308
                assertIssuerDid(issuerDidParam);
×
309
                // some wallets use the openid-config well-known to also gather the issuer metadata. In
310
                // the future(when everyone uses .well-known/openid-credential-issuer), that can be removed.
311
                Map<String, Object> configAsMap = objectMapper.convertValue(
×
312
                                new OIDCWellKnownProvider(session, null, false).getConfig(),
×
313
                                Map.class);
314

315
                List<String> supportedGrantTypes = Optional.ofNullable(configAsMap.get("grant_types_supported"))
×
316
                                .map(grantTypesObject -> objectMapper.convertValue(
×
317
                                                grantTypesObject, new TypeReference<List<String>>() {
×
318
                                                })).orElse(new ArrayList<>());
×
319
                // newly invented by OIDC4VCI and supported by this implementation
320
                supportedGrantTypes.add(GRANT_TYPE_PRE_AUTHORIZED_CODE);
×
321
                configAsMap.put("grant_types_supported", supportedGrantTypes);
×
322
                configAsMap.put("token_endpoint", getIssuer() + "/token");
×
323
                configAsMap.put("credential_endpoint", getCredentialEndpoint());
×
324
                IssuerDisplay issuerDisplay = new IssuerDisplay();
×
325
                issuerDisplay.display.add(
×
326
                                new DisplayObjectVO()
327
                                                .name(String.format("Keycloak-Credentials Issuer - %s", issuerDid))
×
328
                                                .locale("en_US"));
×
329
                configAsMap.put("credential_issuer", issuerDisplay);
×
330

331
                CredentialMetadata credentialMetadata = new CredentialMetadata();
×
332
                credentialMetadata.setDisplay(List.of(new CredentialDisplay("Verifiable Credential")));
×
333
                FormatObject ldpVC = new FormatObject(new ArrayList<>());
×
334
                FormatObject jwtVC = new FormatObject(new ArrayList<>());
×
335

336
                getCredentialsFromModels(session.getContext().getRealm().getClientsStream().collect(Collectors.toList()))
×
337
                                .forEach(supportedCredential -> {
×
338
                                        if (supportedCredential.getFormat() == FormatVO.LDP_VC) {
×
339
                                                ldpVC.getTypes().add(supportedCredential.getType());
×
340
                                        } else {
341
                                                jwtVC.getTypes().add(supportedCredential.getType());
×
342
                                        }
343
                                });
×
344
                credentialMetadata.setFormats(Map.of(FormatVO.LDP_VC.toString(), ldpVC, FormatVO.JWT_VC.toString(), jwtVC));
×
345
                configAsMap.put("credentials_supported", Map.of(TYPE_VERIFIABLE_CREDENTIAL, credentialMetadata));
×
346
                return Response.ok()
×
347
                                .entity(configAsMap)
×
348
                                .header(ACCESS_CONTROL_HEADER, "*")
×
349
                                .build();
×
350
        }
351

352
        /**
353
         * Provides an OIDC4VCI compliant credentials offer
354
         */
355
        @GET
356
        @Path("{issuer-did}/credential-offer")
357
        @Produces({ MediaType.APPLICATION_JSON })
358
        public Response getCredentialOffer(@PathParam("issuer-did") String issuerDidParam,
359
                        @QueryParam("type") String vcType, @QueryParam("format") FormatVO format) {
360

361
                LOGGER.infof("Get an offer for %s - %s", vcType, format);
×
362
                assertIssuerDid(issuerDidParam);
×
363
                // workaround to support implementations not differentiating json & json-ld
364
                if (format == FormatVO.JWT_VC) {
×
365
                        // validate that the user is able to get the offered credentials
366
                        getClientsOfType(vcType, FormatVO.JWT_VC_JSON);
×
367
                } else {
368
                        getClientsOfType(vcType, format);
×
369
                }
370

371
                SupportedCredential offeredCredential = new SupportedCredential(vcType, format);
×
372
                Instant now = clock.instant();
×
373
                JsonWebToken token = new JsonWebToken()
×
374
                                .id(UUID.randomUUID().toString())
×
375
                                .subject(getUserModel().getId())
×
376
                                .nbf(now.getEpochSecond())
×
377
                                //maybe configurable in the future, needs to be short lived
378
                                .exp(now.plus(Duration.of(30, ChronoUnit.SECONDS)).getEpochSecond());
×
379
                token.setOtherClaims("offeredCredential", new SupportedCredential(vcType, format));
×
380

381
                CredentialsOfferVO theOffer = new CredentialsOfferVO()
×
382
                                .credentialIssuer(getIssuer())
×
383
                                .credentials(List.of(offeredCredential))
×
384
                                .grants(new PreAuthorizedGrantVO().
×
385
                                                urnColonIetfColonParamsColonOauthColonGrantTypeColonPreAuthorizedCode(
×
386
                                                                new PreAuthorizedVO().preAuthorizedCode(generateAuthorizationCode())
×
387
                                                                                .userPinRequired(false)));
×
388
                LOGGER.infof("Responding with offer: %s", theOffer);
×
389
                return Response.ok()
×
390
                                .entity(theOffer)
×
391
                                .header(ACCESS_CONTROL_HEADER, "*")
×
392
                                .build();
×
393

394
        }
395

396
        /**
397
         * Token endpoint, as defined by the standard. Allows to exchange the pre-authorized-code with an access-token
398
         */
399
        @POST
400
        @Path("{issuer-did}/token")
401
        @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
402
        public Response exchangeToken(@PathParam("issuer-did") String issuerDidParam,
403
                        @FormParam("grant_type") String grantType,
404
                        @FormParam("code") String code,
405
                        @FormParam("pre-authorized_code") String preauth) {
406
                assertIssuerDid(issuerDidParam);
×
407
                LOGGER.infof("Received token request %s - %s - %s.", grantType, code, preauth);
×
408

409
                if (Optional.ofNullable(grantType).map(gt -> !gt.equals(GRANT_TYPE_PRE_AUTHORIZED_CODE))
×
410
                                .orElse(preauth == null)) {
×
411
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
×
412
                }
413
                // some (not fully OIDC4VCI compatible) wallets send the preauthorized code as an alternative parameter
414
                String codeToUse = Optional.ofNullable(code).orElse(preauth);
×
415

416
                EventBuilder eventBuilder = new EventBuilder(session.getContext().getRealm(), session,
×
417
                                session.getContext().getConnection());
×
418
                OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, codeToUse,
×
419
                                session.getContext().getRealm(),
×
420
                                eventBuilder);
421
                if (result.isExpiredCode() || result.isIllegalCode()) {
×
422
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
×
423
                }
424
                AccessToken accessToken = new TokenManager().createClientAccessToken(session,
×
425
                                result.getClientSession().getRealm(),
×
426
                                result.getClientSession().getClient(),
×
427
                                result.getClientSession().getUserSession().getUser(),
×
428
                                result.getClientSession().getUserSession(),
×
429
                                DefaultClientSessionContext.fromClientSessionAndScopeParameter(result.getClientSession(),
×
430
                                                OAuth2Constants.SCOPE_OPENID, session));
431

432
                String encryptedToken = session.tokens().encodeAndEncrypt(accessToken);
×
433
                String tokenType = "bearer";
×
434
                long expiresIn = accessToken.getExp() - Time.currentTime();
×
435

436
                LOGGER.infof("Successfully returned the token: %s.", encryptedToken);
×
437
                return Response.ok().entity(new TokenResponse(encryptedToken, tokenType, expiresIn, null, null))
×
438
                                .header(ACCESS_CONTROL_HEADER, "*")
×
439
                                .build();
×
440
        }
441

442
        private String generateAuthorizationCode() {
443

444
                AuthenticationManager.AuthResult authResult = getAuthResult();
×
445
                UserSessionModel userSessionModel = getUserSessionModel();
×
446

447
                AuthenticatedClientSessionModel clientSessionModel = userSessionModel.
×
448
                                getAuthenticatedClientSessionByClient(
×
449
                                                authResult.getClient().getId());
×
450
                int expiration = Time.currentTime() + getUserSessionModel().getRealm().getAccessCodeLifespan();
×
451

452
                String codeId = UUID.randomUUID().toString();
×
453
                String nonce = UUID.randomUUID().toString();
×
454
                OAuth2Code oAuth2Code = new OAuth2Code(codeId, expiration, nonce, null, null, null, null,
×
455
                                userSessionModel.getId());
×
456

457
                return OAuth2CodeParser.persistCode(session, clientSessionModel, oAuth2Code);
×
458
        }
459

460
        private Response getErrorResponse(ErrorType errorType) {
461
                return Response.status(Response.Status.BAD_REQUEST).entity(new ErrorResponse(errorType.getValue())).build();
1✔
462
        }
463

464
        /**
465
         * Options endpoint to serve the cors-preflight requests.
466
         * 
467
         * Since we cannot know the address of the requesting wallets in advance, we have to accept all origins.
468
         */
469
        @OPTIONS
470
        @Path("{any: .*}")
471
        public Response optionCorsResponse() {
472
                return Response.ok().header(ACCESS_CONTROL_HEADER, "*")
×
473
                                .header("Access-Control-Allow-Methods", "POST,GET,OPTIONS")
×
474
                                .header("Access-Control-Allow-Headers", "Content-Type,Authorization")
×
475
                                .build();
×
476
        }
477

478
        /**
479
         * Returns a verifiable credential of the given type, containing the information and roles assigned to the
480
         * authenticated user.
481
         * In order to support the often used retrieval method by wallets, the token can also be provided as a
482
         * query-parameter. If the parameter is empty, the token is taken from the authorization-header.
483
         *
484
         * @param vcType type of the VerifiableCredential to be returend.
485
         * @param token  optional JWT to be used instead of retrieving it from the header.
486
         * @return the vc.
487
         */
488
        @GET
489
        @Path("{issuer-did}/")
490
        @Produces(MediaType.APPLICATION_JSON)
491
        public Response issueVerifiableCredential(@PathParam("issuer-did") String issuerDidParam,
492
                        @QueryParam("type") String vcType, @QueryParam("token") String
493
                        token) {
494
                LOGGER.debugf("Get a VC of type %s. Token parameter is %s.", vcType, token);
1✔
495
                assertIssuerDid(issuerDidParam);
1✔
496
                return Response.ok().
1✔
497
                                entity(getCredential(vcType, FormatVO.LDP_VC, token)).
1✔
498
                                header(ACCESS_CONTROL_HEADER, "*").
1✔
499
                                build();
1✔
500
        }
501

502
        /**
503
         * Requests a credential from the issuer
504
         */
505
        @POST
506
        @Path("{issuer-did}/" + CREDENTIAL_PATH)
507
        @Consumes({ "application/json" })
508
        @Produces({ "application/json" })
509
        @ApiOperation(value = "Request a credential from the issuer", notes = "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request", tags = {})
510
        @ApiResponses(value = {
511
                        @ApiResponse(code = 200, message = "Credential Response can be Synchronous or Deferred. The Credential Issuer MAY be able to immediately issue a requested Credential and send it to the Client. In other cases, the Credential Issuer MAY NOT be able to immediately issue a requested Credential and would want to send an acceptance_token parameter to the Client to be used later to receive a Credential when it is ready.", response = CredentialResponseVO.class),
512
                        @ApiResponse(code = 400, message = "When the Credential Request is invalid or unauthorized, the Credential Issuer responds the error response", response = ErrorResponseVO.class) })
513
        public Response requestCredential(@PathParam("issuer-did") String issuerDidParam,
514
                        CredentialRequestVO credentialRequestVO) {
515
                assertIssuerDid(issuerDidParam);
×
516
                LOGGER.infof("Received credentials request %s.", credentialRequestVO);
×
517

518
                List<String> types = new ArrayList<>(Objects.requireNonNull(Optional.ofNullable(credentialRequestVO.getTypes())
×
519
                                .orElseGet(() -> {
×
520
                                        try {
521
                                                return objectMapper.readValue(credentialRequestVO.getType(), new TypeReference<List<String>>() {
×
522
                                                });
523
                                        } catch (JsonProcessingException e) {
×
524
                                                LOGGER.warnf("Was not able to read the type parameter: %s", credentialRequestVO.getType(), e);
×
525
                                                return null;
×
526
                                        }
527
                                })));
528

529
                // remove the static type
530
                types.remove(TYPE_VERIFIABLE_CREDENTIAL);
×
531

532
                if (types.size() != 1) {
×
533
                        LOGGER.infof("Credential request contained multiple types. Req: %s", credentialRequestVO);
×
534
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_REQUEST));
×
535
                }
536
                if (credentialRequestVO.getProof() != null) {
×
537
                        validateProof(credentialRequestVO.getProof());
×
538
                }
539
                FormatVO requestedFormat = credentialRequestVO.getFormat();
×
540
                // workaround to support implementations not differentiating json & json-ld
541
                if (requestedFormat == FormatVO.JWT_VC) {
×
542
                        requestedFormat = FormatVO.JWT_VC_JSON;
×
543
                }
544

545
                String vcType = types.get(0);
×
546

547
                CredentialResponseVO responseVO = new CredentialResponseVO();
×
548
                // keep the originally requested here.
549
                responseVO.format(credentialRequestVO.getFormat());
×
550

551
                String credentialString = getCredential(vcType, credentialRequestVO.getFormat(), null);
×
552
                switch (requestedFormat) {
×
553
                        case LDP_VC: {
554
                                try {
555
                                        // formats the string to an object and to valid json
556
                                        Object credentialObject = objectMapper.readValue(credentialString, Object.class);
×
557
                                        responseVO.setCredential(credentialObject);
×
558
                                } catch (JsonProcessingException e) {
×
559
                                        LOGGER.warnf("Was not able to format credential %s.", credentialString, e);
×
560
                                        throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
×
561
                                }
×
562
                                break;
563
                        }
564
                        case JWT_VC_JSON: {
565
                                responseVO.setCredential(credentialString);
×
566
                                break;
×
567
                        }
568
                        default: {
569
                                LOGGER.infof("Credential with unsupported format %s was requested.", requestedFormat.toString());
×
570
                                throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
×
571
                        }
572

573
                }
574
                return Response.ok().entity(responseVO)
×
575
                                .header(ACCESS_CONTROL_HEADER, "*").build();
×
576
        }
577

578
        private void validateProof(ProofVO proofVO) {
579
                if (proofVO.getProofType() != ProofTypeVO.JWT) {
×
580
                        LOGGER.warn("We currently only support JWT proofs.");
×
581
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_OR_MISSING_PROOF));
×
582
                }
583
                var jwtService = WaltIdJwtService.Companion.getService();
×
584
                JwtVerificationResult verificationResult = jwtService.verify(proofVO.getJwt());
×
585
                if (!verificationResult.getVerified()) {
×
586
                        LOGGER.warnf("Signature of the provided jwt-proof was not valid: %s", proofVO.getJwt());
×
587
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_OR_MISSING_PROOF));
×
588
                }
589
        }
×
590

591
        private String getCredential(String vcType, FormatVO format, String token) {
592
                UserModel userModel = getUserFromSession(Optional.ofNullable(token));
1✔
593

594
                List<ClientModel> clients = getClientsOfType(vcType, format);
1✔
595

596
                // get the smallest expiry, to not generate VCs with to long lifetimes.
597
                Optional<Long> optionalMinExpiry = clients.stream()
1✔
598
                                .map(ClientModel::getAttributes)
1✔
599
                                .filter(Objects::nonNull)
1✔
600
                                .map(attributes -> attributes.get(SIOP2ClientRegistrationProvider.EXPIRY_IN_MIN))
1✔
601
                                .filter(Objects::nonNull)
1✔
602
                                .map(Long::parseLong)
1✔
603
                                .sorted()
1✔
604
                                .findFirst();
1✔
605
                optionalMinExpiry.ifPresentOrElse(
1✔
606
                                minExpiry -> LOGGER.debugf("The min expiry is %d.", minExpiry),
×
607
                                () -> LOGGER.debugf("No min-expiry found. VC will not expire."));
1✔
608

609
                Set<Role> roles = clients.stream()
1✔
610
                                .map(cm -> new ClientRoleModel(cm.getClientId(),
1✔
611
                                                userModel.getClientRoleMappingsStream(cm).collect(Collectors.toList())))
1✔
612
                                .map(this::toRolesClaim)
1✔
613
                                .filter(role -> !role.getNames().isEmpty())
1✔
614
                                .collect(Collectors.toSet());
1✔
615

616
                ProofType proofType = ProofType.JWT;
1✔
617
                if (format == FormatVO.LDP_VC) {
1✔
618
                        proofType = ProofType.LD_PROOF;
1✔
619
                }
620

621
                VCRequest vcRequest = getVCRequest(vcType, proofType, userModel, clients, roles, optionalMinExpiry);
1✔
622
                LOGGER.infof("Request is %s.", vcRequest);
1✔
623
                return waltIdClient.getVCFromWaltId(vcRequest);
1✔
624

625
        }
626

627
        @NotNull
628
        private List<ClientModel> getClientsOfType(String vcType, FormatVO format) {
629
                LOGGER.debugf("Retrieve all clients of type %s, supporting format %s", vcType, format.toString());
1✔
630
                if (format == FormatVO.JWT_VC) {
1✔
631
                        // backward compat
632
                        format = FormatVO.JWT_VC_JSON;
×
633
                }
634
                String formatString = format.toString();
1✔
635
                Optional.ofNullable(vcType).filter(type -> !type.isEmpty()).orElseThrow(() -> {
1✔
636
                        LOGGER.info("No VC type was provided.");
×
637
                        return new BadRequestException("No VerifiableCredential-Type was provided in the request.");
×
638
                });
639

640
                String prefixedType = String.format("%s%s", VC_TYPES_PREFIX, vcType);
1✔
641
                LOGGER.infof("Looking for client supporting %s with format %s", prefixedType, formatString);
1✔
642
                List<ClientModel> vcClients = getClientModelsFromSession().stream()
1✔
643
                                .filter(clientModel -> Optional.ofNullable(clientModel.getAttributes())
1✔
644
                                                .filter(attributes -> attributes.containsKey(prefixedType))
1✔
645
                                                .filter(attributes -> Arrays.asList(attributes.get(prefixedType).split(","))
1✔
646
                                                                .contains(formatString))
1✔
647
                                                .isPresent())
1✔
648
                                .collect(Collectors.toList());
1✔
649

650
                if (vcClients.isEmpty()) {
1✔
651
                        LOGGER.infof("No SIOP-2-Client supporting type %s registered.", vcType);
1✔
652
                        throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
1✔
653
                }
654
                return vcClients;
1✔
655
        }
656

657
        @NotNull
658
        private UserModel getUserFromSession(Optional<String> optionalToken) {
659
                LOGGER.debugf("Extract user form session. Realm in context is %s.", session.getContext().getRealm());
1✔
660
                // set the token in the context if its specifically provided. If empty, the authorization header will
661
                // automatically be evaluated
662
                optionalToken.ifPresent(bearerTokenAuthenticator::setTokenString);
1✔
663

664
                UserModel userModel = getUserModel();
1✔
665
                LOGGER.debugf("Authorized user is %s.", userModel.getId());
1✔
666
                return userModel;
1✔
667
        }
668

669
        @NotNull
670
        private List<ClientModel> getClientModelsFromSession() {
671
                return session.clients().getClientsStream(session.getContext().getRealm())
1✔
672
                                .filter(clientModel -> clientModel.getProtocol() != null)
1✔
673
                                .filter(clientModel -> clientModel.getProtocol().equals(SIOP2LoginProtocolFactory.PROTOCOL_ID))
1✔
674
                                .collect(Collectors.toList());
1✔
675
        }
676

677
        @NotNull
678
        private Role toRolesClaim(ClientRoleModel crm) {
679
                Set<String> roleNames = crm
1✔
680
                                .getRoleModels()
1✔
681
                                .stream()
1✔
682
                                .map(RoleModel::getName)
1✔
683
                                .collect(Collectors.toSet());
1✔
684
                return new Role(roleNames, crm.getClientId());
1✔
685
        }
686

687
        @NotNull
688
        private VCRequest getVCRequest(String vcType, ProofType proofType, UserModel userModel, List<ClientModel> clients,
689
                        Set<Role> roles,
690
                        Optional<Long> optionalMinExpiry) {
691
                // only include non-null & non-empty claims
692
                var claimsBuilder = VCClaims.builder();
1✔
693

694
                LOGGER.infof("Will set roles %s", roles);
1✔
695
                List<String> claims = getClaimsToSet(vcType, clients);
1✔
696
                LOGGER.infof("Will set %s", claims);
1✔
697
                if (claims.contains("email")) {
1✔
698
                        Optional.ofNullable(userModel.getEmail()).filter(email -> !email.isEmpty()).ifPresent(claimsBuilder::email);
1✔
699
                }
700
                if (claims.contains("firstName")) {
1✔
701
                        Optional.ofNullable(userModel.getFirstName()).filter(firstName -> !firstName.isEmpty())
1✔
702
                                        .ifPresent(claimsBuilder::firstName);
1✔
703
                }
704
                if (claims.contains("familyName")) {
1✔
705
                        Optional.ofNullable(userModel.getLastName()).filter(lastName -> !lastName.isEmpty())
1✔
706
                                        .ifPresent(claimsBuilder::familyName);
1✔
707
                }
708
                if (claims.contains("roles")) {
1✔
709
                        Optional.ofNullable(roles).filter(rolesList -> !rolesList.isEmpty()).ifPresent(claimsBuilder::roles);
1✔
710
                }
711
                Map<String, String> additionalClaims = getAdditionalClaims(clients).map(claimsMap ->
1✔
712
                                claimsMap.entrySet().stream().filter(entry -> claims.contains(entry.getKey()))
1✔
713
                                                .collect(Collectors.toMap(
1✔
714
                                                                Map.Entry::getKey, Map.Entry::getValue))
715
                ).orElse(Map.of());
1✔
716

717
                var vcConfigBuilder = VCConfig.builder();
1✔
718
                if (additionalClaims.containsKey(SUBJECT_DID)) {
1✔
719
                        LOGGER.infof("Set subject did to %s", additionalClaims.get(SUBJECT_DID));
×
720
                        vcConfigBuilder.subjectDid(additionalClaims.get(SUBJECT_DID));
×
721
                        additionalClaims.remove(SUBJECT_DID);
×
722
                } else {
723
                        // we have to set something
724
                        vcConfigBuilder.subjectDid(UUID.randomUUID().toString());
1✔
725
                }
726

727
                claimsBuilder.additionalClaims(additionalClaims);
1✔
728
                VCClaims vcClaims = claimsBuilder.build();
1✔
729
                vcConfigBuilder.issuerDid(issuerDid)
1✔
730
                                .proofType(proofType.toString());
1✔
731
                //TODO: reintroduce when walt api is fixed
732
                //                optionalMinExpiry
733
                //                                .map(minExpiry -> Clock.systemUTC()
734
                //                                                .instant()
735
                //                                                .plus(Duration.of(minExpiry, ChronoUnit.MINUTES)))
736
                //                                .map(FORMATTER::format)
737
                //                                .ifPresent(vcConfigBuilder::expirationDate);
738
                VCConfig vcConfig = vcConfigBuilder.build();
1✔
739
                LOGGER.debugf("VC config is %s", vcConfig);
1✔
740
                return VCRequest.builder().templateId(vcType)
1✔
741
                                .config(vcConfig)
1✔
742
                                .credentialData(VCData.builder()
1✔
743
                                                .credentialSubject(vcClaims)
1✔
744
                                                .build())
1✔
745
                                .build();
1✔
746
        }
747

748
        @NotNull
749
        private List<String> getClaimsToSet(String credentialType, List<ClientModel> clients) {
750
                String claims = clients.stream()
1✔
751
                                .map(ClientModel::getAttributes)
1✔
752
                                .filter(Objects::nonNull)
1✔
753
                                .map(Map::entrySet)
1✔
754
                                .flatMap(Set::stream)
1✔
755
                                // get the claims
756
                                .filter(entry -> entry.getKey().equals(String.format("%s_%s", credentialType, "claims")))
1✔
757
                                .findFirst()
1✔
758
                                .map(Map.Entry::getValue)
1✔
759
                                .orElse("");
1✔
760
                LOGGER.infof("Should set %s for %s.", claims, credentialType);
1✔
761
                return Arrays.asList(claims.split(","));
1✔
762

763
        }
764

765
        @NotNull
766
        private Optional<Map<String, String>> getAdditionalClaims(List<ClientModel> clients) {
767
                Map<String, String> additionalClaims = clients.stream()
1✔
768
                                .map(ClientModel::getAttributes)
1✔
769
                                .filter(Objects::nonNull)
1✔
770
                                .map(Map::entrySet)
1✔
771
                                .flatMap(Set::stream)
1✔
772
                                // only include the claims explicitly intended for vc
773
                                .filter(entry -> entry.getKey().startsWith(SIOP2ClientRegistrationProvider.VC_CLAIMS_PREFIX))
1✔
774
                                .collect(
1✔
775
                                                Collectors.toMap(
1✔
776
                                                                // remove the prefix before sending it
777
                                                                entry -> entry.getKey()
1✔
778
                                                                                .replaceFirst(SIOP2ClientRegistrationProvider.VC_CLAIMS_PREFIX, ""),
1✔
779
                                                                // value is taken untouched if its unique
780
                                                                Map.Entry::getValue,
781
                                                                // if multiple values for the same key exist, we add them comma separated.
782
                                                                // this needs to be improved, once more requirements are known.
783
                                                                (entry1, entry2) -> {
784
                                                                        if (entry1.equals(entry2) || entry1.contains(entry2)) {
1✔
785
                                                                                return entry1;
1✔
786
                                                                        } else {
787
                                                                                return String.format("%s,%s", entry1, entry2);
×
788
                                                                        }
789
                                                                }
790
                                                ));
791
                if (additionalClaims.isEmpty()) {
1✔
792
                        return Optional.empty();
1✔
793
                } else {
794
                        return Optional.of(additionalClaims);
1✔
795
                }
796
        }
797

798
        private List<SupportedCredentialVO> getSupportedCredentials(KeycloakContext context) {
799

800
                return context.getRealm().getClientsStream()
1✔
801
                                .flatMap(cm -> cm.getAttributes().entrySet().stream())
1✔
802
                                .filter(entry -> entry.getKey().startsWith(VC_TYPES_PREFIX))
1✔
803
                                .flatMap(entry -> mapAttributeEntryToScVO(entry).stream())
1✔
804
                                .collect(Collectors.toList());
1✔
805

806
        }
807

808
        private List<SupportedCredential> mapAttributeEntryToSc(Map.Entry<String, String> typesEntry) {
809
                String type = typesEntry.getKey().replaceFirst(VC_TYPES_PREFIX, "");
1✔
810
                Set<FormatVO> supportedFormats = getFormatsFromString(typesEntry.getValue());
1✔
811
                return supportedFormats.stream().map(formatVO -> new SupportedCredential(type, formatVO))
1✔
812
                                .collect(Collectors.toList());
1✔
813
        }
814

815
        private List<SupportedCredentialVO> mapAttributeEntryToScVO(Map.Entry<String, String> typesEntry) {
816
                String type = typesEntry.getKey().replaceFirst(VC_TYPES_PREFIX, "");
1✔
817
                Set<FormatVO> supportedFormats = getFormatsFromString(typesEntry.getValue());
1✔
818
                return supportedFormats.stream().map(formatVO -> {
1✔
819
                                        String id = buildIdFromType(formatVO, type);
1✔
820
                                        return new SupportedCredentialVO()
1✔
821
                                                        .id(id)
1✔
822
                                                        .format(formatVO)
1✔
823
                                                        .types(List.of(type))
1✔
824
                                                        .cryptographicBindingMethodsSupported(List.of("did"))
1✔
825
                                                        .cryptographicSuitesSupported(List.of("Ed25519Signature2018"));
1✔
826
                                }
827
                ).collect(Collectors.toList());
1✔
828
        }
829

830
        private String buildIdFromType(FormatVO formatVO, String type) {
831
                return String.format("%s_%s", type, formatVO.toString());
1✔
832
        }
833

834
        private Set<FormatVO> getFormatsFromString(String formatString) {
835
                return Arrays.stream(formatString.split(",")).map(FormatVO::fromString).collect(Collectors.toSet());
1✔
836
        }
837

838
        @Getter
839
        @RequiredArgsConstructor
1✔
840
        private static class ClientRoleModel {
841
                private final String clientId;
1✔
842
                private final List<RoleModel> roleModels;
1✔
843
        }
844
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc