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

wistefan / keycloak-vc-issuer / #408

28 Aug 2023 10:33AM UTC coverage: 38.403% (-0.05%) from 38.452%
#408

push

web-flow
Truncated time to milliseconds (#30)

303 of 789 relevant lines covered (38.4%)

0.38 hits per line

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

52.5
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.model.walt.CredentialOfferURI;
37
import org.fiware.keycloak.oidcvc.model.CredentialIssuerVO;
38
import org.fiware.keycloak.oidcvc.model.CredentialRequestVO;
39
import org.fiware.keycloak.oidcvc.model.CredentialResponseVO;
40
import org.fiware.keycloak.oidcvc.model.CredentialsOfferVO;
41
import org.fiware.keycloak.oidcvc.model.DisplayObjectVO;
42
import org.fiware.keycloak.oidcvc.model.ErrorResponseVO;
43
import org.fiware.keycloak.oidcvc.model.FormatVO;
44
import org.fiware.keycloak.oidcvc.model.PreAuthorizedGrantVO;
45
import org.fiware.keycloak.oidcvc.model.PreAuthorizedVO;
46
import org.fiware.keycloak.oidcvc.model.ProofTypeVO;
47
import org.fiware.keycloak.oidcvc.model.ProofVO;
48
import org.fiware.keycloak.oidcvc.model.SupportedCredentialVO;
49
import org.jboss.logging.Logger;
50
import org.keycloak.OAuth2Constants;
51
import org.keycloak.common.util.Time;
52
import org.keycloak.events.EventBuilder;
53
import org.keycloak.models.AuthenticatedClientSessionModel;
54
import org.keycloak.models.ClientModel;
55
import org.keycloak.models.KeycloakContext;
56
import org.keycloak.models.KeycloakSession;
57
import org.keycloak.models.RoleModel;
58
import org.keycloak.models.UserModel;
59
import org.keycloak.models.UserSessionModel;
60
import org.keycloak.protocol.oidc.OIDCWellKnownProvider;
61
import org.keycloak.protocol.oidc.TokenManager;
62
import org.keycloak.protocol.oidc.utils.OAuth2Code;
63
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
64
import org.keycloak.representations.AccessToken;
65
import org.keycloak.representations.JsonWebToken;
66
import org.keycloak.services.managers.AppAuthManager;
67
import org.keycloak.services.managers.AuthenticationManager;
68
import org.keycloak.services.resource.RealmResourceProvider;
69
import org.keycloak.services.util.DefaultClientSessionContext;
70
import org.keycloak.urls.UrlType;
71

72
import javax.validation.constraints.NotNull;
73
import javax.ws.rs.BadRequestException;
74
import javax.ws.rs.Consumes;
75
import javax.ws.rs.FormParam;
76
import javax.ws.rs.GET;
77
import javax.ws.rs.NotAuthorizedException;
78
import javax.ws.rs.NotFoundException;
79
import javax.ws.rs.OPTIONS;
80
import javax.ws.rs.POST;
81
import javax.ws.rs.Path;
82
import javax.ws.rs.PathParam;
83
import javax.ws.rs.Produces;
84
import javax.ws.rs.QueryParam;
85
import javax.ws.rs.WebApplicationException;
86
import javax.ws.rs.core.MediaType;
87
import javax.ws.rs.core.Response;
88
import java.time.*;
89
import java.time.format.DateTimeFormatter;
90
import java.time.temporal.ChronoUnit;
91
import java.util.ArrayList;
92
import java.util.Arrays;
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

103
/**
104
 * Realm-Resource to provide functionality for issuing VerifiableCredentials to users, depending on their roles in
105
 * registered SIOP-2 clients
106
 */
107
public class VCIssuerRealmResourceProvider implements RealmResourceProvider {
108

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

113
        public static final String LD_PROOF_TYPE = "LD_PROOF";
114
        public static final String CREDENTIAL_PATH = "credential";
115
        public static final String TYPE_VERIFIABLE_CREDENTIAL = "VerifiableCredential";
116
        public static final String GRANT_TYPE_PRE_AUTHORIZED_CODE = "urn:ietf:params:oauth:grant-type:pre-authorized_code";
117
        private static final String ACCESS_CONTROL_HEADER = "Access-Control-Allow-Origin";
118

119
        private final KeycloakSession session;
120
        public static final String SUBJECT_DID = "subjectDid";
121
        private final String issuerDid;
122
        private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
123
        private final WaltIdClient waltIdClient;
124
        private final ObjectMapper objectMapper;
125
        private final Clock clock;
126

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

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

161
        @Override
162
        public Object getResource() {
163
                return this;
×
164
        }
165

166
        @Override
167
        public void close() {
168
                // no specific resources to close.
169
        }
×
170

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

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

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

199
                return getCredentialsFromModels(getClientModelsFromSession());
1✔
200
        }
201

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

213
        // return the current usermodel
214
        private UserModel getUserModel(WebApplicationException errorResponse) {
215
                return getAuthResult(errorResponse).getUser();
1✔
216
        }
217

218
        // return the current usersession model
219
        private UserSessionModel getUserSessionModel() {
220
                return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN))).getSession();
×
221
        }
222

223
        private AuthenticationManager.AuthResult getAuthResult() {
224
                return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)));
×
225
        }
226

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

236
        private UserModel getUserModel() {
237
                return getUserModel(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)));
1✔
238
        }
239

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

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

260
                KeycloakContext currentContext = session.getContext();
1✔
261

262
                return Response.ok().entity(new CredentialIssuerVO()
1✔
263
                                                .credentialIssuer(getIssuer())
1✔
264
                                                .credentialEndpoint(getCredentialEndpoint())
1✔
265
                                                .credentialsSupported(getSupportedCredentials(currentContext)))
1✔
266
                                .header(ACCESS_CONTROL_HEADER, "*").build();
1✔
267
        }
268

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

279
        private String getCredentialEndpoint() {
280

281
                return getIssuer() + "/" + CREDENTIAL_PATH;
1✔
282
        }
283

284
        private String getIssuer() {
285
                return String.format("%s/%s/%s", getRealmResourcePath(),
1✔
286
                                VCIssuerRealmResourceProviderFactory.ID,
287
                                issuerDid);
288
        }
289

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

306
                List<String> supportedGrantTypes = Optional.ofNullable(configAsMap.get("grant_types_supported"))
×
307
                                .map(grantTypesObject -> objectMapper.convertValue(
×
308
                                                grantTypesObject, new TypeReference<List<String>>() {
×
309
                                                })).orElse(new ArrayList<>());
×
310
                // newly invented by OIDC4VCI and supported by this implementation
311
                supportedGrantTypes.add(GRANT_TYPE_PRE_AUTHORIZED_CODE);
×
312
                configAsMap.put("grant_types_supported", supportedGrantTypes);
×
313
                configAsMap.put("token_endpoint", getIssuer() + "/token");
×
314
                configAsMap.put("credential_endpoint", getCredentialEndpoint());
×
315
                IssuerDisplay issuerDisplay = new IssuerDisplay();
×
316
                issuerDisplay.display.add(
×
317
                                new DisplayObjectVO()
318
                                                .name(String.format("Keycloak-Credentials Issuer - %s", issuerDid))
×
319
                                                .locale("en_US"));
×
320
                configAsMap.put("credential_issuer", issuerDisplay);
×
321

322
                CredentialMetadata credentialMetadata = new CredentialMetadata();
×
323
                credentialMetadata.setDisplay(List.of(new CredentialDisplay("Verifiable Credential")));
×
324
                FormatObject ldpVC = new FormatObject(new ArrayList<>());
×
325
                FormatObject jwtVC = new FormatObject(new ArrayList<>());
×
326

327
                getCredentialsFromModels(session.getContext().getRealm().getClientsStream().collect(Collectors.toList()))
×
328
                                .forEach(supportedCredential -> {
×
329
                                        if (supportedCredential.getFormat() == FormatVO.LDP_VC) {
×
330
                                                ldpVC.getTypes().add(supportedCredential.getType());
×
331
                                        } else {
332
                                                jwtVC.getTypes().add(supportedCredential.getType());
×
333
                                        }
334
                                });
×
335
                credentialMetadata.setFormats(Map.of(FormatVO.LDP_VC.toString(), ldpVC, FormatVO.JWT_VC.toString(), jwtVC));
×
336
                configAsMap.put("credentials_supported", Map.of(TYPE_VERIFIABLE_CREDENTIAL, credentialMetadata));
×
337
                return Response.ok()
×
338
                                .entity(configAsMap)
×
339
                                .header(ACCESS_CONTROL_HEADER, "*")
×
340
                                .build();
×
341
        }
342

343
        /**
344
         * Provides URI to the OIDC4VCI compliant credentials offer
345
         */
346
        @GET
347
        @Path("{issuer-did}/credential-offer-uri")
348
        @Produces({ MediaType.APPLICATION_JSON })
349
        public Response getCredentialOfferURI(@PathParam("issuer-did") String issuerDidParam,
350
                        @QueryParam("type") String vcType, @QueryParam("format") FormatVO format) {
351

352
                LOGGER.infof("Get an offer for %s - %s", vcType, format);
×
353
                assertIssuerDid(issuerDidParam);
×
354
                // workaround to support implementations not differentiating json & json-ld
355
                if (format == FormatVO.JWT_VC) {
×
356
                        // validate that the user is able to get the offered credentials
357
                        getClientsOfType(vcType, FormatVO.JWT_VC_JSON);
×
358
                } else {
359
                        getClientsOfType(vcType, format);
×
360
                }
361

362
                SupportedCredential offeredCredential = new SupportedCredential(vcType, format);
×
363
                Instant now = clock.instant();
×
364
                JsonWebToken token = new JsonWebToken()
×
365
                                .id(UUID.randomUUID().toString())
×
366
                                .subject(getUserModel().getId())
×
367
                                .nbf(now.getEpochSecond())
×
368
                                //maybe configurable in the future, needs to be short lived
369
                                .exp(now.plus(Duration.of(30, ChronoUnit.SECONDS)).getEpochSecond());
×
370
                token.setOtherClaims("offeredCredential", new SupportedCredential(vcType, format));
×
371

372
                String nonce = generateAuthorizationCode();
×
373

374
                AuthenticationManager.AuthResult authResult = getAuthResult();
×
375
                UserSessionModel userSessionModel = getUserSessionModel();
×
376

377
                AuthenticatedClientSessionModel clientSession = userSessionModel.
×
378
                                getAuthenticatedClientSessionByClient(
×
379
                                                authResult.getClient().getId());
×
380
                try {
381
                        clientSession.setNote(nonce, objectMapper.writeValueAsString(offeredCredential));
×
382
                } catch (JsonProcessingException e) {
×
383
                        LOGGER.errorf("Could not convert POJO to JSON: %s", e.getMessage());
×
384
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_REQUEST));
×
385
                }
×
386

387
                CredentialOfferURI credentialOfferURI = new CredentialOfferURI(getIssuer(), nonce);
×
388

389
                LOGGER.infof("Responding with nonce: %s", nonce);
×
390
                return Response.ok()
×
391
                                .entity(credentialOfferURI)
×
392
                                .header(ACCESS_CONTROL_HEADER, "*")
×
393
                                .build();
×
394

395
        }
396

397
        /**
398
         * Provides an OIDC4VCI compliant credentials offer
399
         */
400
        @GET
401
        @Path("{issuer-did}/credential-offer/{nonce}")
402
        @Produces({ MediaType.APPLICATION_JSON })
403
        public Response getCredentialOffer(@PathParam("issuer-did") String issuerDidParam,
404
                                                                           @PathParam("nonce") String nonce) {
405
                        LOGGER.infof("Get an offer from issuer %s for nonce %s", issuerDidParam, nonce);
×
406
                assertIssuerDid(issuerDidParam);
×
407

408
                OAuth2CodeParser.ParseResult result = parseAuthorizationCode(nonce);
×
409

410
                SupportedCredential offeredCredential;
411
                try {
412
                        offeredCredential = objectMapper.readValue(result.getClientSession().getNote(nonce),
×
413
                                        SupportedCredential.class);
414
                        LOGGER.infof("Creating an offer for %s - %s", offeredCredential.getType(),
×
415
                                        offeredCredential.getFormat());
×
416
                        result.getClientSession().removeNote(nonce);
×
417
                } catch (JsonProcessingException e) {
×
418
                        LOGGER.errorf("Could not convert JSON to POJO: %s", e);
×
419
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_REQUEST));
×
420
                }
×
421

422
        String preAuthorizedCode = generateAuthorizationCodeForClientSession(result.getClientSession());
×
423
                CredentialsOfferVO theOffer = new CredentialsOfferVO()
×
424
                                .credentialIssuer(getIssuer())
×
425
                                .credentials(List.of(offeredCredential))
×
426
                                .grants(new PreAuthorizedGrantVO().
×
427
                                                urnColonIetfColonParamsColonOauthColonGrantTypeColonPreAuthorizedCode(
×
428
                                                                new PreAuthorizedVO().preAuthorizedCode(preAuthorizedCode)
×
429
                                                                                .userPinRequired(false)));
×
430

431
                LOGGER.infof("Responding with offer: %s", theOffer);
×
432
                return Response.ok()
×
433
                                .entity(theOffer)
×
434
                                .header(ACCESS_CONTROL_HEADER, "*")
×
435
                                .build();
×
436
        }
437

438
        /**
439
         * Token endpoint, as defined by the standard. Allows to exchange the pre-authorized-code with an access-token
440
         */
441
        @POST
442
        @Path("{issuer-did}/token")
443
        @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
444
        public Response exchangeToken(@PathParam("issuer-did") String issuerDidParam,
445
                        @FormParam("grant_type") String grantType,
446
                        @FormParam("code") String code,
447
                        @FormParam("pre-authorized_code") String preauth) {
448
                assertIssuerDid(issuerDidParam);
×
449
                LOGGER.infof("Received token request %s - %s - %s.", grantType, code, preauth);
×
450

451
                if (Optional.ofNullable(grantType).map(gt -> !gt.equals(GRANT_TYPE_PRE_AUTHORIZED_CODE))
×
452
                                .orElse(preauth == null)) {
×
453
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
×
454
                }
455
                // some (not fully OIDC4VCI compatible) wallets send the preauthorized code as an alternative parameter
456
                String codeToUse = Optional.ofNullable(code).orElse(preauth);
×
457

458
                OAuth2CodeParser.ParseResult result = parseAuthorizationCode(codeToUse);
×
459
                AccessToken accessToken = new TokenManager().createClientAccessToken(session,
×
460
                                result.getClientSession().getRealm(),
×
461
                                result.getClientSession().getClient(),
×
462
                                result.getClientSession().getUserSession().getUser(),
×
463
                                result.getClientSession().getUserSession(),
×
464
                                DefaultClientSessionContext.fromClientSessionAndScopeParameter(result.getClientSession(),
×
465
                                                OAuth2Constants.SCOPE_OPENID, session));
466

467
                String encryptedToken = session.tokens().encodeAndEncrypt(accessToken);
×
468
                String tokenType = "bearer";
×
469
                long expiresIn = accessToken.getExp() - Time.currentTime();
×
470

471
                LOGGER.infof("Successfully returned the token: %s.", encryptedToken);
×
472
                return Response.ok().entity(new TokenResponse(encryptedToken, tokenType, expiresIn, null, null))
×
473
                                .header(ACCESS_CONTROL_HEADER, "*")
×
474
                                .build();
×
475
        }
476

477
        private OAuth2CodeParser.ParseResult parseAuthorizationCode(String codeToUse) throws BadRequestException {
478
                EventBuilder eventBuilder = new EventBuilder(session.getContext().getRealm(), session,
×
479
                                session.getContext().getConnection());
×
480
                OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, codeToUse,
×
481
                                session.getContext().getRealm(),
×
482
                                eventBuilder);
483
                if (result.isExpiredCode() || result.isIllegalCode()) {
×
484
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
×
485
                }
486
                return result;
×
487
        }
488

489
        private String generateAuthorizationCode() {
490
                AuthenticationManager.AuthResult authResult = getAuthResult();
×
491
                UserSessionModel userSessionModel = getUserSessionModel();
×
492
                AuthenticatedClientSessionModel clientSessionModel = userSessionModel.
×
493
                                getAuthenticatedClientSessionByClient(authResult.getClient().getId());
×
494
                return generateAuthorizationCodeForClientSession(clientSessionModel);
×
495
        }
496

497
        private String generateAuthorizationCodeForClientSession(AuthenticatedClientSessionModel clientSessionModel) {
498
                int expiration = Time.currentTime() + clientSessionModel.getUserSession().getRealm().getAccessCodeLifespan();
×
499

500
                String codeId = UUID.randomUUID().toString();
×
501
                String nonce = UUID.randomUUID().toString();
×
502
                OAuth2Code oAuth2Code = new OAuth2Code(codeId, expiration, nonce, null, null, null, null,
×
503
                                clientSessionModel.getUserSession().getId());
×
504

505
                return OAuth2CodeParser.persistCode(session, clientSessionModel, oAuth2Code);
×
506
        }
507

508
        private Response getErrorResponse(ErrorType errorType) {
509
                return Response.status(Response.Status.BAD_REQUEST).entity(new ErrorResponse(errorType.getValue())).build();
1✔
510
        }
511

512
        /**
513
         * Options endpoint to serve the cors-preflight requests.
514
         * Since we cannot know the address of the requesting wallets in advance, we have to accept all origins.
515
         */
516
        @OPTIONS
517
        @Path("{any: .*}")
518
        public Response optionCorsResponse() {
519
                return Response.ok().header(ACCESS_CONTROL_HEADER, "*")
×
520
                                .header("Access-Control-Allow-Methods", "POST,GET,OPTIONS")
×
521
                                .header("Access-Control-Allow-Headers", "Content-Type,Authorization")
×
522
                                .build();
×
523
        }
524

525
        /**
526
         * Returns a verifiable credential of the given type, containing the information and roles assigned to the
527
         * authenticated user.
528
         * In order to support the often used retrieval method by wallets, the token can also be provided as a
529
         * query-parameter. If the parameter is empty, the token is taken from the authorization-header.
530
         *
531
         * @param vcType type of the VerifiableCredential to be returend.
532
         * @param token  optional JWT to be used instead of retrieving it from the header.
533
         * @return the vc.
534
         */
535
        @GET
536
        @Path("{issuer-did}/")
537
        @Produces(MediaType.APPLICATION_JSON)
538
        public Response issueVerifiableCredential(@PathParam("issuer-did") String issuerDidParam,
539
                        @QueryParam("type") String vcType, @QueryParam("token") String
540
                        token) {
541
                LOGGER.debugf("Get a VC of type %s. Token parameter is %s.", vcType, token);
1✔
542
                assertIssuerDid(issuerDidParam);
1✔
543
                return Response.ok().
1✔
544
                                entity(getCredential(vcType, FormatVO.LDP_VC, token)).
1✔
545
                                header(ACCESS_CONTROL_HEADER, "*").
1✔
546
                                build();
1✔
547
        }
548

549
        /**
550
         * Requests a credential from the issuer
551
         */
552
        @POST
553
        @Path("{issuer-did}/" + CREDENTIAL_PATH)
554
        @Consumes({ "application/json" })
555
        @Produces({ "application/json" })
556
        @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 = {})
557
        @ApiResponses(value = {
558
                        @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),
559
                        @ApiResponse(code = 400, message = "When the Credential Request is invalid or unauthorized, the Credential Issuer responds the error response", response = ErrorResponseVO.class) })
560
        public Response requestCredential(@PathParam("issuer-did") String issuerDidParam,
561
                        CredentialRequestVO credentialRequestVO) {
562
                assertIssuerDid(issuerDidParam);
×
563
                LOGGER.infof("Received credentials request %s.", credentialRequestVO);
×
564

565
                List<String> types = new ArrayList<>(Objects.requireNonNull(Optional.ofNullable(credentialRequestVO.getTypes())
×
566
                                .orElseGet(() -> {
×
567
                                        try {
568
                                                return objectMapper.readValue(credentialRequestVO.getType(), new TypeReference<>() {
×
569
                        });
570
                                        } catch (JsonProcessingException e) {
×
571
                                                LOGGER.warnf("Was not able to read the type parameter: %s", credentialRequestVO.getType(), e);
×
572
                                                return null;
×
573
                                        }
574
                                })));
575

576
                // remove the static type
577
                types.remove(TYPE_VERIFIABLE_CREDENTIAL);
×
578

579
                if (types.size() != 1) {
×
580
                        LOGGER.infof("Credential request contained multiple types. Req: %s", credentialRequestVO);
×
581
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_REQUEST));
×
582
                }
583
                if (credentialRequestVO.getProof() != null) {
×
584
                        validateProof(credentialRequestVO.getProof());
×
585
                }
586
                FormatVO requestedFormat = credentialRequestVO.getFormat();
×
587
                // workaround to support implementations not differentiating json & json-ld
588
                if (requestedFormat == FormatVO.JWT_VC) {
×
589
                        requestedFormat = FormatVO.JWT_VC_JSON;
×
590
                }
591

592
                String vcType = types.get(0);
×
593

594
                CredentialResponseVO responseVO = new CredentialResponseVO();
×
595
                // keep the originally requested here.
596
                responseVO.format(credentialRequestVO.getFormat());
×
597

598
                String credentialString = getCredential(vcType, credentialRequestVO.getFormat(), null);
×
599
                switch (requestedFormat) {
×
600
                        case LDP_VC: {
601
                                try {
602
                                        // formats the string to an object and to valid json
603
                                        Object credentialObject = objectMapper.readValue(credentialString, Object.class);
×
604
                                        responseVO.setCredential(credentialObject);
×
605
                                } catch (JsonProcessingException e) {
×
606
                                        LOGGER.warnf("Was not able to format credential %s.", credentialString, e);
×
607
                                        throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
×
608
                                }
×
609
                                break;
610
                        }
611
                        case JWT_VC_JSON: {
612
                                responseVO.setCredential(credentialString);
×
613
                                break;
×
614
                        }
615
                        default: {
616
                                LOGGER.infof("Credential with unsupported format %s was requested.", requestedFormat.toString());
×
617
                                throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
×
618
                        }
619

620
                }
621
                return Response.ok().entity(responseVO)
×
622
                                .header(ACCESS_CONTROL_HEADER, "*").build();
×
623
        }
624

625
        private void validateProof(ProofVO proofVO) {
626
                if (proofVO.getProofType() != ProofTypeVO.JWT) {
×
627
                        LOGGER.warn("We currently only support JWT proofs.");
×
628
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_OR_MISSING_PROOF));
×
629
                }
630
                var jwtService = WaltIdJwtService.Companion.getService();
×
631
                JwtVerificationResult verificationResult = jwtService.verify(proofVO.getJwt());
×
632
                if (!verificationResult.getVerified()) {
×
633
                        LOGGER.warnf("Signature of the provided jwt-proof was not valid: %s", proofVO.getJwt());
×
634
                        throw new BadRequestException(getErrorResponse(ErrorType.INVALID_OR_MISSING_PROOF));
×
635
                }
636
        }
×
637

638
        private String getCredential(String vcType, FormatVO format, String token) {
639
                UserModel userModel = getUserFromSession(Optional.ofNullable(token));
1✔
640

641
                List<ClientModel> clients = getClientsOfType(vcType, format);
1✔
642

643
                // get the smallest expiry, to not generate VCs with to long lifetimes.
644
                Optional<Long> optionalMinExpiry = clients.stream()
1✔
645
                                .map(ClientModel::getAttributes)
1✔
646
                                .filter(Objects::nonNull)
1✔
647
                                .map(attributes -> attributes.get(SIOP2ClientRegistrationProvider.EXPIRY_IN_MIN))
1✔
648
                                .filter(Objects::nonNull)
1✔
649
                                .map(Long::parseLong)
1✔
650
                                .sorted()
1✔
651
                                .findFirst();
1✔
652
                optionalMinExpiry.ifPresentOrElse(
1✔
653
                                minExpiry -> LOGGER.debugf("The min expiry is %d.", minExpiry),
×
654
                                () -> LOGGER.debugf("No min-expiry found. VC will not expire."));
1✔
655

656
                Set<Role> roles = clients.stream()
1✔
657
                                .map(cm -> new ClientRoleModel(cm.getClientId(),
1✔
658
                                                userModel.getClientRoleMappingsStream(cm).collect(Collectors.toList())))
1✔
659
                                .map(this::toRolesClaim)
1✔
660
                                .filter(role -> !role.getNames().isEmpty())
1✔
661
                                .collect(Collectors.toSet());
1✔
662

663
                ProofType proofType = ProofType.JWT;
1✔
664
                if (format == FormatVO.LDP_VC) {
1✔
665
                        proofType = ProofType.LD_PROOF;
1✔
666
                }
667

668
                VCRequest vcRequest = getVCRequest(vcType, proofType, userModel, clients, roles, optionalMinExpiry);
1✔
669
                LOGGER.infof("Request is %s.", vcRequest);
1✔
670
                return waltIdClient.getVCFromWaltId(vcRequest);
1✔
671

672
        }
673

674
        @NotNull
675
        private List<ClientModel> getClientsOfType(String vcType, FormatVO format) {
676
                LOGGER.debugf("Retrieve all clients of type %s, supporting format %s", vcType, format.toString());
1✔
677
                if (format == FormatVO.JWT_VC) {
1✔
678
                        // backward compat
679
                        format = FormatVO.JWT_VC_JSON;
×
680
                }
681
                String formatString = format.toString();
1✔
682
                Optional.ofNullable(vcType).filter(type -> !type.isEmpty()).orElseThrow(() -> {
1✔
683
                        LOGGER.info("No VC type was provided.");
×
684
                        return new BadRequestException("No VerifiableCredential-Type was provided in the request.");
×
685
                });
686

687
                String prefixedType = String.format("%s%s", VC_TYPES_PREFIX, vcType);
1✔
688
                LOGGER.infof("Looking for client supporting %s with format %s", prefixedType, formatString);
1✔
689
                List<ClientModel> vcClients = getClientModelsFromSession().stream()
1✔
690
                                .filter(clientModel -> Optional.ofNullable(clientModel.getAttributes())
1✔
691
                                                .filter(attributes -> attributes.containsKey(prefixedType))
1✔
692
                                                .filter(attributes -> Arrays.asList(attributes.get(prefixedType).split(","))
1✔
693
                                                                .contains(formatString))
1✔
694
                                                .isPresent())
1✔
695
                                .collect(Collectors.toList());
1✔
696

697
                if (vcClients.isEmpty()) {
1✔
698
                        LOGGER.infof("No SIOP-2-Client supporting type %s registered.", vcType);
1✔
699
                        throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
1✔
700
                }
701
                return vcClients;
1✔
702
        }
703

704
        @NotNull
705
        private UserModel getUserFromSession(Optional<String> optionalToken) {
706
                LOGGER.debugf("Extract user form session. Realm in context is %s.", session.getContext().getRealm());
1✔
707
                // set the token in the context if its specifically provided. If empty, the authorization header will
708
                // automatically be evaluated
709
                optionalToken.ifPresent(bearerTokenAuthenticator::setTokenString);
1✔
710

711
                UserModel userModel = getUserModel();
1✔
712
                LOGGER.debugf("Authorized user is %s.", userModel.getId());
1✔
713
                return userModel;
1✔
714
        }
715

716
        @NotNull
717
        private List<ClientModel> getClientModelsFromSession() {
718
                return session.clients().getClientsStream(session.getContext().getRealm())
1✔
719
                                .filter(clientModel -> clientModel.getProtocol() != null)
1✔
720
                                .filter(clientModel -> clientModel.getProtocol().equals(SIOP2LoginProtocolFactory.PROTOCOL_ID))
1✔
721
                                .collect(Collectors.toList());
1✔
722
        }
723

724
        @NotNull
725
        private Role toRolesClaim(ClientRoleModel crm) {
726
                Set<String> roleNames = crm
1✔
727
                                .getRoleModels()
1✔
728
                                .stream()
1✔
729
                                .map(RoleModel::getName)
1✔
730
                                .collect(Collectors.toSet());
1✔
731
                return new Role(roleNames, crm.getClientId());
1✔
732
        }
733

734
        @NotNull
735
        private VCRequest getVCRequest(String vcType, ProofType proofType, UserModel userModel, List<ClientModel> clients,
736
                        Set<Role> roles,
737
                        Optional<Long> optionalMinExpiry) {
738
                // only include non-null & non-empty claims
739
                var claimsBuilder = VCClaims.builder();
1✔
740

741
                LOGGER.infof("Will set roles %s", roles);
1✔
742
                List<String> claims = getClaimsToSet(vcType, clients);
1✔
743
                LOGGER.infof("Will set %s", claims);
1✔
744
                if (claims.contains("email")) {
1✔
745
                        Optional.ofNullable(userModel.getEmail()).filter(email -> !email.isEmpty()).ifPresent(claimsBuilder::email);
1✔
746
                }
747
                if (claims.contains("firstName")) {
1✔
748
                        Optional.ofNullable(userModel.getFirstName()).filter(firstName -> !firstName.isEmpty())
1✔
749
                                        .ifPresent(claimsBuilder::firstName);
1✔
750
                }
751
                if (claims.contains("familyName")) {
1✔
752
                        Optional.ofNullable(userModel.getLastName()).filter(lastName -> !lastName.isEmpty())
1✔
753
                                        .ifPresent(claimsBuilder::familyName);
1✔
754
                }
755
                if (claims.contains("roles")) {
1✔
756
                        Optional.ofNullable(roles).filter(rolesList -> !rolesList.isEmpty()).ifPresent(claimsBuilder::roles);
1✔
757
                }
758
                Map<String, String> additionalClaims = getAdditionalClaims(clients).map(claimsMap ->
1✔
759
                                claimsMap.entrySet().stream().filter(entry -> claims.contains(entry.getKey()))
1✔
760
                                                .collect(Collectors.toMap(
1✔
761
                                                                Map.Entry::getKey, Map.Entry::getValue))
762
                ).orElse(Map.of());
1✔
763

764
                var vcConfigBuilder = VCConfig.builder();
1✔
765
                if (additionalClaims.containsKey(SUBJECT_DID)) {
1✔
766
                        LOGGER.infof("Set subject did to %s", additionalClaims.get(SUBJECT_DID));
×
767
                        vcConfigBuilder.subjectDid(additionalClaims.get(SUBJECT_DID));
×
768
                        additionalClaims.remove(SUBJECT_DID);
×
769
                } else {
770
                        // we have to set something
771
                        vcConfigBuilder.subjectDid(UUID.randomUUID().toString());
1✔
772
                }
773

774
                claimsBuilder.additionalClaims(additionalClaims);
1✔
775
                VCClaims vcClaims = claimsBuilder.build();
1✔
776
                vcConfigBuilder.issuerDid(issuerDid)
1✔
777
                                .proofType(proofType.toString());
1✔
778

779
                optionalMinExpiry
1✔
780
                                .map(minExpiry -> Clock.systemUTC()
1✔
781
                                                .instant()
×
782
                                                .plus(Duration.of(minExpiry, ChronoUnit.MINUTES))
×
783
                                                .truncatedTo(ChronoUnit.MILLIS))
×
784
                                .map(FORMATTER::format)
1✔
785
                                .ifPresent(vcConfigBuilder::expirationDate);
1✔
786

787
                VCConfig vcConfig = vcConfigBuilder.build();
1✔
788
                LOGGER.debugf("VC config is %s", vcConfig);
1✔
789
                return VCRequest.builder().templateId(vcType)
1✔
790
                                .config(vcConfig)
1✔
791
                                .credentialData(VCData.builder()
1✔
792
                                                .credentialSubject(vcClaims)
1✔
793
                                                .build())
1✔
794
                                .build();
1✔
795
        }
796

797
        @NotNull
798
        private List<String> getClaimsToSet(String credentialType, List<ClientModel> clients) {
799
                String claims = clients.stream()
1✔
800
                                .map(ClientModel::getAttributes)
1✔
801
                                .filter(Objects::nonNull)
1✔
802
                                .map(Map::entrySet)
1✔
803
                                .flatMap(Set::stream)
1✔
804
                                // get the claims
805
                                .filter(entry -> entry.getKey().equals(String.format("%s_%s", credentialType, "claims")))
1✔
806
                                .findFirst()
1✔
807
                                .map(Map.Entry::getValue)
1✔
808
                                .orElse("");
1✔
809
                LOGGER.infof("Should set %s for %s.", claims, credentialType);
1✔
810
                return Arrays.asList(claims.split(","));
1✔
811

812
        }
813

814
        @NotNull
815
        private Optional<Map<String, String>> getAdditionalClaims(List<ClientModel> clients) {
816
                Map<String, String> additionalClaims = clients.stream()
1✔
817
                                .map(ClientModel::getAttributes)
1✔
818
                                .filter(Objects::nonNull)
1✔
819
                                .map(Map::entrySet)
1✔
820
                                .flatMap(Set::stream)
1✔
821
                                // only include the claims explicitly intended for vc
822
                                .filter(entry -> entry.getKey().startsWith(SIOP2ClientRegistrationProvider.VC_CLAIMS_PREFIX))
1✔
823
                                .collect(
1✔
824
                                                Collectors.toMap(
1✔
825
                                                                // remove the prefix before sending it
826
                                                                entry -> entry.getKey()
1✔
827
                                                                                .replaceFirst(SIOP2ClientRegistrationProvider.VC_CLAIMS_PREFIX, ""),
1✔
828
                                                                // value is taken untouched if its unique
829
                                                                Map.Entry::getValue,
830
                                                                // if multiple values for the same key exist, we add them comma separated.
831
                                                                // this needs to be improved, once more requirements are known.
832
                                                                (entry1, entry2) -> {
833
                                                                        if (entry1.equals(entry2) || entry1.contains(entry2)) {
1✔
834
                                                                                return entry1;
1✔
835
                                                                        } else {
836
                                                                                return String.format("%s,%s", entry1, entry2);
×
837
                                                                        }
838
                                                                }
839
                                                ));
840
                if (additionalClaims.isEmpty()) {
1✔
841
                        return Optional.empty();
1✔
842
                } else {
843
                        return Optional.of(additionalClaims);
1✔
844
                }
845
        }
846

847
        private List<SupportedCredentialVO> getSupportedCredentials(KeycloakContext context) {
848

849
                return context.getRealm().getClientsStream()
1✔
850
                                .flatMap(cm -> cm.getAttributes().entrySet().stream())
1✔
851
                                .filter(entry -> entry.getKey().startsWith(VC_TYPES_PREFIX))
1✔
852
                                .flatMap(entry -> mapAttributeEntryToScVO(entry).stream())
1✔
853
                                .collect(Collectors.toList());
1✔
854

855
        }
856

857
        private List<SupportedCredential> mapAttributeEntryToSc(Map.Entry<String, String> typesEntry) {
858
                String type = typesEntry.getKey().replaceFirst(VC_TYPES_PREFIX, "");
1✔
859
                Set<FormatVO> supportedFormats = getFormatsFromString(typesEntry.getValue());
1✔
860
                return supportedFormats.stream().map(formatVO -> new SupportedCredential(type, formatVO))
1✔
861
                                .collect(Collectors.toList());
1✔
862
        }
863

864
        private List<SupportedCredentialVO> mapAttributeEntryToScVO(Map.Entry<String, String> typesEntry) {
865
                String type = typesEntry.getKey().replaceFirst(VC_TYPES_PREFIX, "");
1✔
866
                Set<FormatVO> supportedFormats = getFormatsFromString(typesEntry.getValue());
1✔
867
                return supportedFormats.stream().map(formatVO -> {
1✔
868
                                        String id = buildIdFromType(formatVO, type);
1✔
869
                                        return new SupportedCredentialVO()
1✔
870
                                                        .id(id)
1✔
871
                                                        .format(formatVO)
1✔
872
                                                        .types(List.of(type))
1✔
873
                                                        .cryptographicBindingMethodsSupported(List.of("did"))
1✔
874
                                                        .cryptographicSuitesSupported(List.of("Ed25519Signature2018"));
1✔
875
                                }
876
                ).collect(Collectors.toList());
1✔
877
        }
878

879
        private String buildIdFromType(FormatVO formatVO, String type) {
880
                return String.format("%s_%s", type, formatVO.toString());
1✔
881
        }
882

883
        private Set<FormatVO> getFormatsFromString(String formatString) {
884
                return Arrays.stream(formatString.split(",")).map(FormatVO::fromString).collect(Collectors.toSet());
1✔
885
        }
886

887
        @Getter
888
        @RequiredArgsConstructor
1✔
889
        private static class ClientRoleModel {
890
                private final String clientId;
1✔
891
                private final List<RoleModel> roleModels;
1✔
892
        }
893
}
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

© 2025 Coveralls, Inc