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

wistefan / keycloak-vc-issuer / #424

14 Nov 2023 02:06PM UTC coverage: 41.163% (-7.7%) from 48.895%
#424

push

wistefan
use protocol mappers

354 of 860 relevant lines covered (41.16%)

0.41 hits per line

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

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

3
import com.danubetech.verifiablecredentials.CredentialSubject;
4
import com.danubetech.verifiablecredentials.VerifiableCredential;
5
import com.danubetech.verifiablecredentials.jsonld.VerifiableCredentialContexts;
6
import com.fasterxml.jackson.core.JsonProcessingException;
7
import com.fasterxml.jackson.core.type.TypeReference;
8
import com.fasterxml.jackson.databind.ObjectMapper;
9
import foundation.identity.jsonld.JsonLDObject;
10
import io.swagger.annotations.ApiOperation;
11
import io.swagger.annotations.ApiResponse;
12
import io.swagger.annotations.ApiResponses;
13
import jakarta.ws.rs.BadRequestException;
14
import jakarta.ws.rs.Consumes;
15
import jakarta.ws.rs.FormParam;
16
import jakarta.ws.rs.GET;
17
import jakarta.ws.rs.NotAuthorizedException;
18
import jakarta.ws.rs.NotFoundException;
19
import jakarta.ws.rs.OPTIONS;
20
import jakarta.ws.rs.POST;
21
import jakarta.ws.rs.Path;
22
import jakarta.ws.rs.PathParam;
23
import jakarta.ws.rs.Produces;
24
import jakarta.ws.rs.QueryParam;
25
import jakarta.ws.rs.WebApplicationException;
26
import jakarta.ws.rs.core.MediaType;
27
import jakarta.ws.rs.core.Response;
28
import lombok.Getter;
29
import lombok.RequiredArgsConstructor;
30
import org.fiware.keycloak.mappers.SIOP2Mapper;
31
import org.fiware.keycloak.mappers.SIOP2MapperFactory;
32
import org.fiware.keycloak.model.CredentialOfferURI;
33
import org.fiware.keycloak.model.ErrorResponse;
34
import org.fiware.keycloak.model.ErrorType;
35
import org.fiware.keycloak.model.Role;
36
import org.fiware.keycloak.model.SupportedCredential;
37
import org.fiware.keycloak.model.TokenResponse;
38
import org.fiware.keycloak.model.walt.FormatObject;
39
import org.fiware.keycloak.oidcvc.model.CredentialIssuerVO;
40
import org.fiware.keycloak.oidcvc.model.CredentialRequestVO;
41
import org.fiware.keycloak.oidcvc.model.CredentialResponseVO;
42
import org.fiware.keycloak.oidcvc.model.CredentialsOfferVO;
43
import org.fiware.keycloak.oidcvc.model.ErrorResponseVO;
44
import org.fiware.keycloak.oidcvc.model.FormatVO;
45
import org.fiware.keycloak.oidcvc.model.PreAuthorizedGrantVO;
46
import org.fiware.keycloak.oidcvc.model.PreAuthorizedVO;
47
import org.fiware.keycloak.oidcvc.model.ProofTypeVO;
48
import org.fiware.keycloak.oidcvc.model.ProofVO;
49
import org.fiware.keycloak.oidcvc.model.SupportedCredentialVO;
50
import org.fiware.keycloak.signing.JWTSigningService;
51
import org.fiware.keycloak.signing.LDSigningService;
52
import org.fiware.keycloak.signing.SigningServiceException;
53
import org.fiware.keycloak.signing.VCSigningService;
54
import org.jboss.logging.Logger;
55
import org.json.JSONObject;
56
import org.keycloak.OAuth2Constants;
57
import org.keycloak.common.util.Time;
58
import org.keycloak.events.EventBuilder;
59
import org.keycloak.models.AuthenticatedClientSessionModel;
60
import org.keycloak.models.ClientModel;
61
import org.keycloak.models.KeycloakContext;
62
import org.keycloak.models.KeycloakSession;
63
import org.keycloak.models.ProtocolMapperContainerModel;
64
import org.keycloak.models.ProtocolMapperModel;
65
import org.keycloak.models.RoleModel;
66
import org.keycloak.models.UserModel;
67
import org.keycloak.models.UserSessionModel;
68
import org.keycloak.protocol.oidc.OIDCWellKnownProvider;
69
import org.keycloak.protocol.oidc.TokenManager;
70
import org.keycloak.protocol.oidc.utils.OAuth2Code;
71
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
72
import org.keycloak.representations.AccessToken;
73
import org.keycloak.representations.JsonWebToken;
74
import org.keycloak.services.managers.AppAuthManager;
75
import org.keycloak.services.managers.AuthenticationManager;
76
import org.keycloak.services.resource.RealmResourceProvider;
77
import org.keycloak.services.util.DefaultClientSessionContext;
78
import org.keycloak.urls.UrlType;
79

80
import javax.validation.constraints.NotNull;
81
import java.net.URI;
82
import java.time.Clock;
83
import java.time.Duration;
84
import java.time.Instant;
85
import java.time.ZoneId;
86
import java.time.ZoneOffset;
87
import java.time.format.DateTimeFormatter;
88
import java.time.temporal.ChronoUnit;
89
import java.util.ArrayList;
90
import java.util.Arrays;
91
import java.util.Date;
92
import java.util.HashMap;
93
import java.util.List;
94
import java.util.Map;
95
import java.util.Objects;
96
import java.util.Optional;
97
import java.util.Set;
98
import java.util.UUID;
99
import java.util.stream.Collectors;
100

101
import static org.fiware.keycloak.SIOP2ClientRegistrationProvider.VC_TYPES_PREFIX;
102
import static org.fiware.keycloak.oidcvc.model.FormatVO.JWT_VC;
103
import static org.fiware.keycloak.oidcvc.model.FormatVO.JWT_VC_JSON;
104
import static org.fiware.keycloak.oidcvc.model.FormatVO.JWT_VC_JSON_LD;
105
import static org.fiware.keycloak.oidcvc.model.FormatVO.LDP_VC;
106

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

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

117
        public static final String LD_PROOF_TYPE = "LD_PROOF";
118
        public static final String CREDENTIAL_PATH = "credential";
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 AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
126
        private final ObjectMapper objectMapper;
127
        private final Clock clock;
128

129
        private final String issuerDid;
130

131
        private final Map<FormatVO, VCSigningService> signingServiceMap = new HashMap<>();
1✔
132

133
        public VCIssuerRealmResourceProvider(KeycloakSession session,
134
                        String issuerDid,
135
                        String keyPath,
136
                        AppAuthManager.BearerTokenAuthenticator authenticator,
137
                        ObjectMapper objectMapper, Clock clock) {
1✔
138
                this.session = session;
1✔
139
                this.bearerTokenAuthenticator = authenticator;
1✔
140
                this.objectMapper = objectMapper;
1✔
141
                this.clock = clock;
1✔
142
                this.issuerDid = issuerDid;
1✔
143
                try {
144
                        var jwtSigningService = new JWTSigningService(keyPath);
1✔
145
                        signingServiceMap.put(JWT_VC, jwtSigningService);
1✔
146
                } catch (SigningServiceException e) {
×
147
                        LOGGER.warn("Was not able to initialize JWT SigningService, jwt credentials are not supported.", e);
×
148
                }
1✔
149
                try {
150
                        var ldSigningService = new LDSigningService(keyPath, clock);
1✔
151
                        signingServiceMap.put(FormatVO.LDP_VC, ldSigningService);
1✔
152
                } catch (SigningServiceException e) {
×
153
                        LOGGER.warn("Was not able to initialize LD SigningService, ld credentials are not supported.", e);
×
154
                }
1✔
155
        }
1✔
156

157
        @Override
158
        public Object getResource() {
159
                return this;
×
160
        }
161

162
        @Override
163
        public void close() {
164
                // no specific resources to close.
165
        }
×
166

167
        /**
168
         * Returns the did used by Keycloak to issue credentials
169
         *
170
         * @return the did
171
         */
172
        @GET
173
        @Path("/issuer")
174
        @Produces(MediaType.TEXT_PLAIN)
175
        public Response getIssuerDid() {
176
                return Response.ok().entity(issuerDid).header(ACCESS_CONTROL_HEADER, "*").build();
×
177
        }
178

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

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

195
                return getCredentialsFromModels(getClientModelsFromSession());
1✔
196
        }
197

198
        // filter the client models for supported verifable credentials
199
        private List<SupportedCredential> getCredentialsFromModels(List<ClientModel> clientModels) {
200
                return List.copyOf(clientModels.stream()
1✔
201
                                .map(ClientModel::getAttributes)
1✔
202
                                .filter(Objects::nonNull)
1✔
203
                                .flatMap(attrs -> attrs.entrySet().stream())
1✔
204
                                .filter(attr -> attr.getKey().startsWith(VC_TYPES_PREFIX))
1✔
205
                                .flatMap(entry -> mapAttributeEntryToSc(entry).stream())
1✔
206
                                .collect(Collectors.toSet()));
1✔
207
        }
208

209
        // return the current usermodel
210
        private UserModel getUserModel(WebApplicationException errorResponse) {
211
                return getAuthResult(errorResponse).getUser();
1✔
212
        }
213

214
        // return the current usersession model
215
        private UserSessionModel getUserSessionModel() {
216
                return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN))).getSession();
1✔
217
        }
218

219
        private AuthenticationManager.AuthResult getAuthResult() {
220
                return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)));
×
221
        }
222

223
        // get the auth result from the authentication manager
224
        private AuthenticationManager.AuthResult getAuthResult(WebApplicationException errorResponse) {
225
                AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate();
1✔
226
                if (authResult == null) {
1✔
227
                        throw errorResponse;
1✔
228
                }
229
                return authResult;
1✔
230
        }
231

232
        private UserModel getUserModel() {
233
                return getUserModel(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)));
×
234
        }
235

236
        // assert that the given string is the configured issuer did
237
        private void assertIssuerDid(String requestedIssuerDid) {
238
                if (!requestedIssuerDid.equals(issuerDid)) {
1✔
239
                        throw new NotFoundException("No such issuer exists.");
×
240
                }
241
        }
1✔
242

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

256
                KeycloakContext currentContext = session.getContext();
1✔
257

258
                return Response.ok().entity(new CredentialIssuerVO()
1✔
259
                                                .credentialIssuer(getIssuer())
1✔
260
                                                .credentialEndpoint(getCredentialEndpoint())
1✔
261
                                                .credentialsSupported(getSupportedCredentials(currentContext)))
1✔
262
                                .header(ACCESS_CONTROL_HEADER, "*").build();
1✔
263
        }
264

265
        private String getRealmResourcePath() {
266
                KeycloakContext currentContext = session.getContext();
1✔
267
                String realm = currentContext.getRealm().getId();
1✔
268
                String backendUrl = currentContext.getUri(UrlType.BACKEND).getBaseUri().toString();
1✔
269
                if (backendUrl.endsWith("/")) {
1✔
270
                        return String.format("%srealms/%s", backendUrl, realm);
×
271
                }
272
                return String.format("%s/realms/%s", backendUrl, realm);
1✔
273
        }
274

275
        private String getCredentialEndpoint() {
276
                return getIssuer() + "/" + CREDENTIAL_PATH;
1✔
277
        }
278

279
        private String getIssuer() {
280
                return String.format("%s/%s/%s", getRealmResourcePath(),
1✔
281
                                VCIssuerRealmResourceProviderFactory.ID,
282
                                issuerDid);
283
        }
284

285
        /**
286
         * Returns the openid-configuration of the issuer.
287
         * OIDC4VCI wallets expect the openid-configuration below the issuers root, thus we provide it here in addition to its standard keycloak path.
288
         */
289
        @GET
290
        @Path("{issuer-did}/.well-known/openid-configuration")
291
        @Produces({ MediaType.APPLICATION_JSON })
292
        public Response getOIDCConfig(@PathParam("issuer-did") String issuerDidParam) {
293
                LOGGER.info("Get OIDC config.");
×
294
                assertIssuerDid(issuerDidParam);
×
295
                // some wallets use the openid-config well-known to also gather the issuer metadata. In
296
                // the future(when everyone uses .well-known/openid-credential-issuer), that can be removed.
297
                Map<String, Object> configAsMap = objectMapper.convertValue(
×
298
                                new OIDCWellKnownProvider(session, null, false).getConfig(),
×
299
                                Map.class);
300

301
                List<String> supportedGrantTypes = Optional.ofNullable(configAsMap.get("grant_types_supported"))
×
302
                                .map(grantTypesObject -> objectMapper.convertValue(
×
303
                                                grantTypesObject, new TypeReference<List<String>>() {
×
304
                                                })).orElse(new ArrayList<>());
×
305
                // newly invented by OIDC4VCI and supported by this implementation
306
                supportedGrantTypes.add(GRANT_TYPE_PRE_AUTHORIZED_CODE);
×
307
                configAsMap.put("grant_types_supported", supportedGrantTypes);
×
308
                configAsMap.put("token_endpoint", getIssuer() + "/token");
×
309
                configAsMap.put("credential_endpoint", getCredentialEndpoint());
×
310

311
                FormatObject ldpVC = new FormatObject(new ArrayList<>());
×
312
                FormatObject jwtVC = new FormatObject(new ArrayList<>());
×
313

314
                getCredentialsFromModels(session.getContext().getRealm().getClientsStream().collect(Collectors.toList()))
×
315
                                .forEach(supportedCredential -> {
×
316
                                        if (supportedCredential.getFormat() == FormatVO.LDP_VC) {
×
317
                                                ldpVC.getTypes().add(supportedCredential.getType());
×
318
                                        } else {
319
                                                jwtVC.getTypes().add(supportedCredential.getType());
×
320
                                        }
321
                                });
×
322
                return Response.ok()
×
323
                                .entity(configAsMap)
×
324
                                .header(ACCESS_CONTROL_HEADER, "*")
×
325
                                .build();
×
326
        }
327

328
        /**
329
         * Provides URI to the OIDC4VCI compliant credentials offer
330
         */
331
        @GET
332
        @Path("{issuer-did}/credential-offer-uri")
333
        @Produces({ MediaType.APPLICATION_JSON })
334
        public Response getCredentialOfferURI(@PathParam("issuer-did") String issuerDidParam,
335
                        @QueryParam("type") String vcType, @QueryParam("format") FormatVO format) {
336

337
                LOGGER.infof("Get an offer for %s - %s", vcType, format);
×
338
                assertIssuerDid(issuerDidParam);
×
339
                // workaround to support implementations not differentiating json & json-ld
340
                if (format == JWT_VC) {
×
341
                        // validate that the user is able to get the offered credentials
342
                        getClientsOfType(vcType, FormatVO.JWT_VC_JSON);
×
343
                } else {
344
                        getClientsOfType(vcType, format);
×
345
                }
346

347
                SupportedCredential offeredCredential = new SupportedCredential(vcType, format);
×
348
                Instant now = clock.instant();
×
349
                JsonWebToken token = new JsonWebToken()
×
350
                                .id(UUID.randomUUID().toString())
×
351
                                .subject(getUserModel().getId())
×
352
                                .nbf(now.getEpochSecond())
×
353
                                //maybe configurable in the future, needs to be short lived
354
                                .exp(now.plus(Duration.of(30, ChronoUnit.SECONDS)).getEpochSecond());
×
355
                token.setOtherClaims("offeredCredential", new SupportedCredential(vcType, format));
×
356

357
                String nonce = generateAuthorizationCode();
×
358

359
                AuthenticationManager.AuthResult authResult = getAuthResult();
×
360
                UserSessionModel userSessionModel = getUserSessionModel();
×
361

362
                AuthenticatedClientSessionModel clientSession = userSessionModel.
×
363
                                getAuthenticatedClientSessionByClient(
×
364
                                                authResult.getClient().getId());
×
365
                try {
366
                        clientSession.setNote(nonce, objectMapper.writeValueAsString(offeredCredential));
×
367
                } catch (JsonProcessingException e) {
×
368
                        LOGGER.errorf("Could not convert POJO to JSON: %s", e.getMessage());
×
369
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_REQUEST));
×
370
                }
×
371

372
                CredentialOfferURI credentialOfferURI = new CredentialOfferURI(getIssuer(), nonce);
×
373

374
                LOGGER.infof("Responding with nonce: %s", nonce);
×
375
                return Response.ok()
×
376
                                .entity(credentialOfferURI)
×
377
                                .header(ACCESS_CONTROL_HEADER, "*")
×
378
                                .build();
×
379

380
        }
381

382
        /**
383
         * Provides an OIDC4VCI compliant credentials offer
384
         */
385
        @GET
386
        @Path("{issuer-did}/credential-offer/{nonce}")
387
        @Produces({ MediaType.APPLICATION_JSON })
388
        public Response getCredentialOffer(@PathParam("issuer-did") String issuerDidParam,
389
                        @PathParam("nonce") String nonce) {
390
                LOGGER.infof("Get an offer from issuer %s for nonce %s", issuerDidParam, nonce);
×
391
                assertIssuerDid(issuerDidParam);
×
392

393
                OAuth2CodeParser.ParseResult result = parseAuthorizationCode(nonce);
×
394

395
                SupportedCredential offeredCredential;
396
                try {
397
                        offeredCredential = objectMapper.readValue(result.getClientSession().getNote(nonce),
×
398
                                        SupportedCredential.class);
399
                        LOGGER.infof("Creating an offer for %s - %s", offeredCredential.getType(),
×
400
                                        offeredCredential.getFormat());
×
401
                        result.getClientSession().removeNote(nonce);
×
402
                } catch (JsonProcessingException e) {
×
403
                        LOGGER.errorf("Could not convert JSON to POJO: %s", e);
×
404
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_REQUEST));
×
405
                }
×
406

407
                String preAuthorizedCode = generateAuthorizationCodeForClientSession(result.getClientSession());
×
408
                CredentialsOfferVO theOffer = new CredentialsOfferVO()
×
409
                                .credentialIssuer(getIssuer())
×
410
                                .credentials(List.of(offeredCredential))
×
411
                                .grants(new PreAuthorizedGrantVO().
×
412
                                                urnColonIetfColonParamsColonOauthColonGrantTypeColonPreAuthorizedCode(
×
413
                                                                new PreAuthorizedVO().preAuthorizedCode(preAuthorizedCode)
×
414
                                                                                .userPinRequired(false)));
×
415

416
                LOGGER.infof("Responding with offer: %s", theOffer);
×
417
                return Response.ok()
×
418
                                .entity(theOffer)
×
419
                                .header(ACCESS_CONTROL_HEADER, "*")
×
420
                                .build();
×
421
        }
422

423
        /**
424
         * Token endpoint, as defined by the standard. Allows to exchange the pre-authorized-code with an access-token
425
         */
426
        @POST
427
        @Path("{issuer-did}/token")
428
        @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
429
        public Response exchangeToken(@PathParam("issuer-did") String issuerDidParam,
430
                        @FormParam("grant_type") String grantType,
431
                        @FormParam("code") String code,
432
                        @FormParam("pre-authorized_code") String preauth) {
433
                assertIssuerDid(issuerDidParam);
×
434
                LOGGER.infof("Received token request %s - %s - %s.", grantType, code, preauth);
×
435

436
                if (Optional.ofNullable(grantType).map(gt -> !gt.equals(GRANT_TYPE_PRE_AUTHORIZED_CODE))
×
437
                                .orElse(preauth == null)) {
×
438
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
×
439
                }
440
                // some (not fully OIDC4VCI compatible) wallets send the preauthorized code as an alternative parameter
441
                String codeToUse = Optional.ofNullable(code).orElse(preauth);
×
442

443
                OAuth2CodeParser.ParseResult result = parseAuthorizationCode(codeToUse);
×
444
                AccessToken accessToken = new TokenManager().createClientAccessToken(session,
×
445
                                result.getClientSession().getRealm(),
×
446
                                result.getClientSession().getClient(),
×
447
                                result.getClientSession().getUserSession().getUser(),
×
448
                                result.getClientSession().getUserSession(),
×
449
                                DefaultClientSessionContext.fromClientSessionAndScopeParameter(result.getClientSession(),
×
450
                                                OAuth2Constants.SCOPE_OPENID, session));
451

452
                String encryptedToken = session.tokens().encodeAndEncrypt(accessToken);
×
453
                String tokenType = "bearer";
×
454
                long expiresIn = accessToken.getExp() - Time.currentTime();
×
455

456
                LOGGER.infof("Successfully returned the token: %s.", encryptedToken);
×
457
                return Response.ok().entity(new TokenResponse(encryptedToken, tokenType, expiresIn, null, null))
×
458
                                .header(ACCESS_CONTROL_HEADER, "*")
×
459
                                .build();
×
460
        }
461

462
        private OAuth2CodeParser.ParseResult parseAuthorizationCode(String codeToUse) throws BadRequestException {
463
                EventBuilder eventBuilder = new EventBuilder(session.getContext().getRealm(), session,
×
464
                                session.getContext().getConnection());
×
465
                OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, codeToUse,
×
466
                                session.getContext().getRealm(),
×
467
                                eventBuilder);
468
                if (result.isExpiredCode() || result.isIllegalCode()) {
×
469
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
×
470
                }
471
                return result;
×
472
        }
473

474
        private String generateAuthorizationCode() {
475
                AuthenticationManager.AuthResult authResult = getAuthResult();
×
476
                UserSessionModel userSessionModel = getUserSessionModel();
×
477
                AuthenticatedClientSessionModel clientSessionModel = userSessionModel.
×
478
                                getAuthenticatedClientSessionByClient(authResult.getClient().getId());
×
479
                return generateAuthorizationCodeForClientSession(clientSessionModel);
×
480
        }
481

482
        private String generateAuthorizationCodeForClientSession(AuthenticatedClientSessionModel clientSessionModel) {
483
                int expiration = Time.currentTime() + clientSessionModel.getUserSession().getRealm().getAccessCodeLifespan();
×
484

485
                String codeId = UUID.randomUUID().toString();
×
486
                String nonce = UUID.randomUUID().toString();
×
487
                OAuth2Code oAuth2Code = new OAuth2Code(codeId, expiration, nonce, null, null, null, null,
×
488
                                clientSessionModel.getUserSession().getId());
×
489

490
                return OAuth2CodeParser.persistCode(session, clientSessionModel, oAuth2Code);
×
491
        }
492

493
        private Response getErrorResponse(ErrorType errorType) {
494
                return Response.status(Response.Status.BAD_REQUEST).entity(new ErrorResponse(errorType.getValue())).build();
1✔
495
        }
496

497
        /**
498
         * Options endpoint to serve the cors-preflight requests.
499
         * Since we cannot know the address of the requesting wallets in advance, we have to accept all origins.
500
         */
501
        @OPTIONS
502
        @Path("{any: .*}")
503
        public Response optionCorsResponse() {
504
                return Response.ok().header(ACCESS_CONTROL_HEADER, "*")
×
505
                                .header("Access-Control-Allow-Methods", "POST,GET,OPTIONS")
×
506
                                .header("Access-Control-Allow-Headers", "Content-Type,Authorization")
×
507
                                .build();
×
508
        }
509

510
        /**
511
         * Returns a verifiable credential of the given type, containing the information and roles assigned to the
512
         * authenticated user.
513
         * In order to support the often used retrieval method by wallets, the token can also be provided as a
514
         * query-parameter. If the parameter is empty, the token is taken from the authorization-header.
515
         *
516
         * @param vcType type of the VerifiableCredential to be returend.
517
         * @param token  optional JWT to be used instead of retrieving it from the header.
518
         * @return the vc.
519
         */
520
        @GET
521
        @Path("{issuer-did}/")
522
        @Produces(MediaType.APPLICATION_JSON)
523
        public Response issueVerifiableCredential(@PathParam("issuer-did") String issuerDidParam,
524
                        @QueryParam("type") String vcType, @QueryParam("token") String
525
                        token) {
526
                LOGGER.debugf("Get a VC of type %s. Token parameter is %s.", vcType, token);
1✔
527
                assertIssuerDid(issuerDidParam);
1✔
528
                if (token != null) {
1✔
529
                        // authenticate with the token
530
                        bearerTokenAuthenticator.setTokenString(token);
1✔
531
                }
532
                return Response.ok().
1✔
533
                                entity(getCredential(vcType, FormatVO.LDP_VC)).
1✔
534
                                header(ACCESS_CONTROL_HEADER, "*").
1✔
535
                                build();
1✔
536
        }
537

538
        /**
539
         * Requests a credential from the issuer
540
         */
541
        @POST
542
        @Path("{issuer-did}/" + CREDENTIAL_PATH)
543
        @Consumes({ "application/json" })
544
        @Produces({ "application/json" })
545
        @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 = {})
546
        @ApiResponses(value = {
547
                        @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),
548
                        @ApiResponse(code = 400, message = "When the Credential Request is invalid or unauthorized, the Credential Issuer responds the error response", response = ErrorResponseVO.class) })
549
        public Response requestCredential(@PathParam("issuer-did") String issuerDidParam,
550
                        CredentialRequestVO credentialRequestVO) {
551
                assertIssuerDid(issuerDidParam);
×
552
                LOGGER.infof("Received credentials request %s.", credentialRequestVO);
×
553

554
                List<String> types = new ArrayList<>(Objects.requireNonNull(Optional.ofNullable(credentialRequestVO.getTypes())
×
555
                                .orElseGet(() -> {
×
556
                                        try {
557
                                                return objectMapper.readValue(credentialRequestVO.getType(), new TypeReference<>() {
×
558
                                                });
559
                                        } catch (JsonProcessingException e) {
×
560
                                                LOGGER.warnf("Was not able to read the type parameter: %s", credentialRequestVO.getType(), e);
×
561
                                                return null;
×
562
                                        }
563
                                })));
564

565
                // remove the static type
566
                types.remove(TYPE_VERIFIABLE_CREDENTIAL);
×
567

568
                if (types.size() != 1) {
×
569
                        LOGGER.infof("Credential request contained multiple types. Req: %s", credentialRequestVO);
×
570
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_REQUEST));
×
571
                }
572
                if (credentialRequestVO.getProof() != null) {
×
573
                        validateProof(credentialRequestVO.getProof());
×
574
                }
575
                FormatVO requestedFormat = credentialRequestVO.getFormat();
×
576
                // workaround to support implementations not differentiating json & json-ld
577
                if (requestedFormat == JWT_VC) {
×
578
                        requestedFormat = FormatVO.JWT_VC_JSON;
×
579
                }
580

581
                String vcType = types.get(0);
×
582

583
                CredentialResponseVO responseVO = new CredentialResponseVO();
×
584
                // keep the originally requested here.
585
                responseVO.format(credentialRequestVO.getFormat());
×
586

587
                Object theCredential = getCredential(vcType, credentialRequestVO.getFormat());
×
588
                switch (requestedFormat) {
×
589
                        case LDP_VC -> responseVO.setCredential(theCredential);
×
590
                        case JWT_VC_JSON -> responseVO.setCredential(theCredential);
×
591
                        default -> throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
×
592
                }
593
                return Response.ok().entity(responseVO)
×
594
                                .header(ACCESS_CONTROL_HEADER, "*").build();
×
595
        }
596

597
        protected void validateProof(ProofVO proofVO) {
598
                if (proofVO.getProofType() != ProofTypeVO.JWT) {
×
599
                        LOGGER.warn("We currently only support JWT proofs.");
×
600
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_OR_MISSING_PROOF));
×
601
                }
602
                //TODO: validate proof
603
        }
×
604

605
        protected Object getCredential(String vcType, FormatVO format) {
606
                // do first to fail fast on auth
607
                UserSessionModel userSessionModel = getUserSessionModel();
1✔
608
                List<ClientModel> clients = getClientsOfType(vcType, format);
1✔
609
                List<SIOP2Mapper> protocolMappers = getProtocolMappers(clients)
1✔
610
                                .stream()
1✔
611
                                .map(SIOP2MapperFactory::createSiop2Mapper)
1✔
612
                                .toList();
1✔
613

614
                // get the smallest expiry, to not generate VCs with to long lifetimes.
615
                Optional<Long> optionalMinExpiry = clients.stream()
1✔
616
                                .map(ClientModel::getAttributes)
1✔
617
                                .filter(Objects::nonNull)
1✔
618
                                .map(attributes -> attributes.get(SIOP2ClientRegistrationProvider.EXPIRY_IN_MIN))
1✔
619
                                .filter(Objects::nonNull)
1✔
620
                                .map(Long::parseLong)
1✔
621
                                .sorted()
1✔
622
                                .findFirst();
1✔
623
                optionalMinExpiry.ifPresentOrElse(
1✔
624
                                minExpiry -> LOGGER.debugf("The min expiry is %d.", minExpiry),
×
625
                                () -> LOGGER.debugf("No min-expiry found. VC will not expire."));
1✔
626

627
                var credentialToSign = getVCToSign(protocolMappers, vcType, userSessionModel);
1✔
628

629
                return switch (format) {
1✔
630
                        case LDP_VC -> signingServiceMap.get(FormatVO.LDP_VC).signCredential(credentialToSign);
1✔
631
                        case JWT_VC, JWT_VC_JSON_LD, JWT_VC_JSON -> signingServiceMap.get(JWT_VC)
1✔
632
                                        .signCredential(credentialToSign);
1✔
633
                        default -> throw new IllegalArgumentException(
×
634
                                        String.format("Requested format %s is not supported.", format));
×
635
                };
636
        }
637

638
        private List<ProtocolMapperModel> getProtocolMappers(List<ClientModel> clientModels) {
639
                return clientModels.stream()
1✔
640
                                .flatMap(ProtocolMapperContainerModel::getProtocolMappersStream)
1✔
641
                                .toList();
1✔
642

643
        }
644

645
        @NotNull
646
        private List<ClientModel> getClientsOfType(String vcType, FormatVO format) {
647
                LOGGER.debugf("Retrieve all clients of type %s, supporting format %s", vcType, format.toString());
1✔
648

649
                List<String> formatStrings = switch (format) {
1✔
650
                        case LDP_VC -> List.of(LDP_VC.toString());
1✔
651
                        case JWT_VC, JWT_VC_JSON -> List.of(JWT_VC.toString(), JWT_VC_JSON.toString());
1✔
652
                        case JWT_VC_JSON_LD -> List.of(JWT_VC.toString(), JWT_VC_JSON_LD.toString());
1✔
653

654
                };
655

656
                Optional.ofNullable(vcType).filter(type -> !type.isEmpty()).orElseThrow(() -> {
1✔
657
                        LOGGER.info("No VC type was provided.");
×
658
                        return new BadRequestException("No VerifiableCredential-Type was provided in the request.");
×
659
                });
660

661
                String prefixedType = String.format("%s%s", VC_TYPES_PREFIX, vcType);
1✔
662
                LOGGER.infof("Looking for client supporting %s with format %s", prefixedType, formatStrings);
1✔
663
                List<ClientModel> vcClients = getClientModelsFromSession().stream()
1✔
664
                                .filter(clientModel -> Optional.ofNullable(clientModel.getAttributes())
1✔
665
                                                .filter(attributes -> attributes.containsKey(prefixedType))
1✔
666
                                                .filter(attributes -> formatStrings.stream()
1✔
667
                                                                .anyMatch(formatString -> Arrays.asList(attributes.get(prefixedType).split(","))
1✔
668
                                                                                .contains(formatString)))
1✔
669
                                                .isPresent())
1✔
670
                                .toList();
1✔
671

672
                if (vcClients.isEmpty()) {
1✔
673
                        LOGGER.infof("No SIOP-2-Client supporting type %s registered.", vcType);
1✔
674
                        throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
1✔
675
                }
676
                return vcClients;
1✔
677
        }
678

679
        @NotNull
680
        private UserModel getUserFromSession() {
681
                LOGGER.debugf("Extract user form session. Realm in context is %s.", session.getContext().getRealm());
×
682

683
                UserModel userModel = getUserModel();
×
684
                LOGGER.debugf("Authorized user is %s.", userModel.getId());
×
685
                return userModel;
×
686
        }
687

688
        @NotNull
689
        private List<ClientModel> getClientModelsFromSession() {
690
                return session.clients().getClientsStream(session.getContext().getRealm())
1✔
691
                                .filter(clientModel -> clientModel.getProtocol() != null)
1✔
692
                                .filter(clientModel -> clientModel.getProtocol().equals(SIOP2LoginProtocolFactory.PROTOCOL_ID))
1✔
693
                                .toList();
1✔
694
        }
695

696
        @NotNull
697
        private Role toRolesClaim(ClientRoleModel crm) {
698
                Set<String> roleNames = crm
×
699
                                .getRoleModels()
×
700
                                .stream()
×
701
                                .map(RoleModel::getName)
×
702
                                .collect(Collectors.toSet());
×
703
                return new Role(roleNames, crm.getClientId());
×
704
        }
705

706
        @NotNull
707
        private VerifiableCredential getVCToSign(List<SIOP2Mapper> protocolMappers, String vcType,
708
                        UserSessionModel userSessionModel) {
709

710
                var subjectBuilder = CredentialSubject.builder();
1✔
711

712
                Map<String, Object> subjectClaims = new HashMap<>();
1✔
713

714
                protocolMappers
1✔
715
                                .forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, userSessionModel));
1✔
716

717
                LOGGER.infof("Will set %s", subjectClaims);
1✔
718
                subjectBuilder.claims(subjectClaims);
1✔
719

720
                CredentialSubject subject = subjectBuilder.build();
1✔
721

722
                var credentialBuilder = VerifiableCredential.builder()
1✔
723
                                .types(List.of(vcType))
1✔
724
                                .context(VerifiableCredentialContexts.JSONLD_CONTEXT_W3C_2018_CREDENTIALS_V1)
1✔
725
                                .id(URI.create(String.format("urn:uuid:%s", UUID.randomUUID())))
1✔
726
                                .issuer(URI.create(issuerDid))
1✔
727
                                .issuanceDate(Date.from(clock.instant()))
1✔
728
                                .credentialSubject(subject);
1✔
729
                // use the mappers after the default
730
                protocolMappers
1✔
731
                                .forEach(mapper -> mapper.setClaimsForCredential(credentialBuilder, userSessionModel));
1✔
732

733
                // TODO: replace with expiry mapper
734
                //                optionalMinExpiry
735
                //                                .map(minExpiry -> Clock.systemUTC()
736
                //                                                .instant()
737
                //                                                .plus(Duration.of(minExpiry, ChronoUnit.MINUTES)))
738
                //                                .map(Date::from)
739
                //                                .ifPresent(credentialBuilder::expirationDate);
740

741
                return credentialBuilder.build();
1✔
742
        }
743

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

759
        }
760

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

794
        private List<SupportedCredentialVO> getSupportedCredentials(KeycloakContext context) {
795

796
                return context.getRealm().getClientsStream()
1✔
797
                                .flatMap(cm -> cm.getAttributes().entrySet().stream())
1✔
798
                                .filter(entry -> entry.getKey().startsWith(VC_TYPES_PREFIX))
1✔
799
                                .flatMap(entry -> mapAttributeEntryToScVO(entry).stream())
1✔
800
                                .collect(Collectors.toList());
1✔
801

802
        }
803

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

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

826
        private String buildIdFromType(FormatVO formatVO, String type) {
827
                return String.format("%s_%s", type, formatVO.toString());
1✔
828
        }
829

830
        private Set<FormatVO> getFormatsFromString(String formatString) {
831
                return Arrays.stream(formatString.split(",")).map(FormatVO::fromString).collect(Collectors.toSet());
1✔
832
        }
833

834
        @Getter
835
        @RequiredArgsConstructor
×
836
        private static class ClientRoleModel {
837
                private final String clientId;
×
838
                private final List<RoleModel> roleModels;
×
839
        }
840
}
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