From 5a8a006f7fe0dd87797fa54dca027b315c161958 Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Tue, 12 May 2026 12:26:44 +0200 Subject: [PATCH 1/2] verifiable credential in account console Closes #48576 Signed-off-by: Giuseppe Graziano --- js/apps/account-ui/public/content.json | 10 +- js/apps/account-ui/src/api.ts | 56 +------ js/apps/account-ui/src/api/methods.ts | 26 ++++ js/apps/account-ui/src/api/representations.ts | 15 +- js/apps/account-ui/src/index.ts | 1 - js/apps/account-ui/src/oid4vci/Oid4Vci.tsx | 137 ------------------ js/apps/account-ui/src/routes.tsx | 16 +- .../verifiable-credentials/CredentialRow.tsx | 133 +++++++++++++++++ .../VerifiableCredentials.tsx | 86 +++++++++++ .../org/keycloak/models/AccountRoles.java | 2 + .../services/managers/RealmManager.java | 8 + .../resources/account/AccountConsole.java | 6 +- .../resources/account/AccountRestService.java | 6 + .../AccountVerifiableCredentialResource.java | 115 +++++++++++++++ .../account/AccountRestServiceRolesTest.java | 55 ++++++- .../account/messages/messages_en.properties | 14 ++ 16 files changed, 463 insertions(+), 223 deletions(-) delete mode 100644 js/apps/account-ui/src/oid4vci/Oid4Vci.tsx create mode 100644 js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx create mode 100644 js/apps/account-ui/src/verifiable-credentials/VerifiableCredentials.tsx create mode 100644 services/src/main/java/org/keycloak/services/resources/account/AccountVerifiableCredentialResource.java diff --git a/js/apps/account-ui/public/content.json b/js/apps/account-ui/public/content.json index 2bcfcbd1acd3..9691ff4811e9 100644 --- a/js/apps/account-ui/public/content.json +++ b/js/apps/account-ui/public/content.json @@ -17,6 +17,11 @@ "path": "applications", "isVisible": "isViewApplicationsEnabled" }, + { + "label": "verifiableCredentials", + "path": "verifiable-credentials", + "isVisible": "isOid4VciEnabled" + }, { "label": "groups", "path": "groups", @@ -31,10 +36,5 @@ "label": "resources", "path": "resources", "isVisible": "isMyResourcesEnabled" - }, - { - "label": "oid4vci", - "path": "oid4vci", - "isVisible":"isOid4VciEnabled" } ] diff --git a/js/apps/account-ui/src/api.ts b/js/apps/account-ui/src/api.ts index e0b8f3fe215b..0ef5850e7cf7 100644 --- a/js/apps/account-ui/src/api.ts +++ b/js/apps/account-ui/src/api.ts @@ -6,15 +6,8 @@ import { import { CallOptions } from "./api/methods"; import { Links, parseLinks } from "./api/parse-links"; import { parseResponse } from "./api/parse-response"; -import { - CredentialsIssuer, - Permission, - Resource, - Scope, - SupportedCredentialConfiguration, -} from "./api/representations"; +import { Permission, Resource, Scope } from "./api/representations"; import { request } from "./api/request"; -import { joinPath } from "./utils/joinPath"; export const fetchResources = async ( { signal, context }: CallOptions, @@ -72,50 +65,3 @@ function checkResponse(response: T) { if (!response) throw new Error("Could not fetch"); return response; } - -export async function getIssuer(context: KeycloakContext) { - const response = await request( - joinPath( - "/realms/", - context.environment.realm, - "/.well-known/openid-credential-issuer", - ), - context, - {}, - new URL( - joinPath( - context.environment.serverBaseUrl, - "/realms/", - context.environment.realm, - "/.well-known/openid-credential-issuer", - ), - ), - ); - return parseResponse(response); -} - -export async function requestVCOffer( - context: KeycloakContext, - supportedCredentialConfiguration: SupportedCredentialConfiguration, - credentialsIssuer: CredentialsIssuer, -) { - const response = await request( - "/protocol/oid4vc/create-credential-offer", - context, - { - searchParams: { - credential_configuration_id: supportedCredentialConfiguration.id, - type: "qr-code", - width: "500", - height: "500", - }, - }, - new URL( - joinPath( - credentialsIssuer.credential_issuer + - "/protocol/oid4vc/create-credential-offer", - ), - ), - ); - return response.blob(); -} diff --git a/js/apps/account-ui/src/api/methods.ts b/js/apps/account-ui/src/api/methods.ts index a47c00c8adf0..769d56ecc32d 100644 --- a/js/apps/account-ui/src/api/methods.ts +++ b/js/apps/account-ui/src/api/methods.ts @@ -13,6 +13,7 @@ import { LinkedAccountRepresentation, Permission, UserRepresentation, + UserVerifiableCredentialRepresentation, } from "./representations"; import { request } from "./request"; @@ -153,3 +154,28 @@ export async function getUserOrganizations({ signal, context }: CallOptions) { const response = await request("/organizations", context, { signal }); return parseResponse(response); } + +export async function getVerifiableCredentials({ + signal, + context, +}: CallOptions): Promise { + const response = await request("/verifiable-credentials", context, { + signal, + }); + return parseResponse(response); +} + +export async function deleteVerifiableCredential( + context: KeycloakContext, + credentialScopeName: string, +): Promise { + const response = await request( + `/verifiable-credentials/${credentialScopeName}`, + context, + { method: "DELETE" }, + ); + if (!response.ok) { + const error = await parseResponse(response); + throw error; + } +} diff --git a/js/apps/account-ui/src/api/representations.ts b/js/apps/account-ui/src/api/representations.ts index 2b4f04328181..881678fc6a00 100644 --- a/js/apps/account-ui/src/api/representations.ts +++ b/js/apps/account-ui/src/api/representations.ts @@ -212,17 +212,4 @@ export interface Group { path: string; } -export interface SupportedCredentialConfiguration { - id: string; - format: string; - scope: string; -} -export interface CredentialsIssuer { - credential_issuer: string; - credential_endpoint: string; - authorization_servers: string[]; - credential_configurations_supported: Record< - string, - SupportedCredentialConfiguration - >; -} +export type { default as UserVerifiableCredentialRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/userVerifiableCredentialRepresentation"; diff --git a/js/apps/account-ui/src/index.ts b/js/apps/account-ui/src/index.ts index 4ae1156b457c..29d62a2f60a6 100644 --- a/js/apps/account-ui/src/index.ts +++ b/js/apps/account-ui/src/index.ts @@ -39,7 +39,6 @@ export { Resources } from "./resources/Resources"; export { ResourcesTab } from "./resources/ResourcesTab"; export { ResourceToolbar } from "./resources/ResourceToolbar"; export { SharedWith } from "./resources/SharedWith"; -export { Oid4Vci } from "./oid4vci/Oid4Vci"; export { Organizations } from "./organizations/Organizations"; export { ShareTheResource } from "./resources/ShareTheResource"; export { diff --git a/js/apps/account-ui/src/oid4vci/Oid4Vci.tsx b/js/apps/account-ui/src/oid4vci/Oid4Vci.tsx deleted file mode 100644 index 248e62d9be9d..000000000000 --- a/js/apps/account-ui/src/oid4vci/Oid4Vci.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useEnvironment } from "@keycloak/keycloak-ui-shared"; -import { - ActionList, - ActionListItem, - List, - ListItem, - MenuToggle, - MenuToggleElement, - PageSection, - PageSectionVariants, - Select, - SelectList, - SelectOption, -} from "@patternfly/react-core"; -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { getIssuer, requestVCOffer } from "../api"; -import { CredentialsIssuer } from "../api/representations"; -import { Page } from "../components/page/Page"; -import { usePromise } from "../utils/usePromise"; - -export const Oid4Vci = () => { - const context = useEnvironment(); - - const { t } = useTranslation(); - - const initialSelected = t("verifiableCredentialsSelectionDefault"); - - const [selected, setSelected] = useState(initialSelected); - const [qrCode, setQrCode] = useState(""); - const [isOpen, setIsOpen] = useState(false); - const [offerQRVisible, setOfferQRVisible] = useState(false); - const [credentialsIssuer, setCredentialsIssuer] = - useState(); - - usePromise(() => getIssuer(context), setCredentialsIssuer); - - const selectOptions = useMemo(() => { - if (typeof credentialsIssuer !== "undefined") { - return credentialsIssuer.credential_configurations_supported; - } - return {}; - }, [credentialsIssuer]); - - const dropdownItems = useMemo(() => { - if (typeof selectOptions !== "undefined") { - return Array.from(Object.keys(selectOptions)); - } - return []; - }, [selectOptions]); - - useEffect(() => { - if (initialSelected !== selected && credentialsIssuer !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - requestVCOffer(context, selectOptions[selected], credentialsIssuer).then( - (blob) => { - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = function () { - const result = reader.result; - if (typeof result === "string") { - setQrCode(result); - setOfferQRVisible(true); - setIsOpen(false); - } - }; - }, - ); - } - }, [selected]); - - const onToggleClick = () => { - setIsOpen(!isOpen); - }; - - const toggle = (toggleRef: React.Ref) => ( - - {selected} - - ); - - return ( - - - - - - - - - {offerQRVisible && ( - - - - )} - - - - - - ); -}; - -export default Oid4Vci; diff --git a/js/apps/account-ui/src/routes.tsx b/js/apps/account-ui/src/routes.tsx index de89e7efab74..8af5e16b39aa 100644 --- a/js/apps/account-ui/src/routes.tsx +++ b/js/apps/account-ui/src/routes.tsx @@ -9,8 +9,10 @@ const Applications = lazy(() => import("./applications/Applications")); const Groups = lazy(() => import("./groups/Groups")); const PersonalInfo = lazy(() => import("./personal-info/PersonalInfo")); const Resources = lazy(() => import("./resources/Resources")); +const VerifiableCredentials = lazy( + () => import("./verifiable-credentials/VerifiableCredentials"), +); const ContentComponent = lazy(() => import("./content/ContentComponent")); -const Oid4Vci = lazy(() => import("./oid4vci/Oid4Vci")); export const DeviceActivityRoute: RouteObject = { path: "account-security/device-activity", @@ -27,6 +29,11 @@ export const SigningInRoute: RouteObject = { element: , }; +export const VerifiableCredentialsRoute: RouteObject = { + path: "verifiable-credentials", + element: , +}; + export const ApplicationsRoute: RouteObject = { path: "applications", element: , @@ -62,20 +69,15 @@ export const OrganizationsRoute: RouteObject = { element: , }; -export const Oid4VciRoute: RouteObject = { - path: "oid4vci", - element: , -}; - export const routes: RouteObject[] = [ PersonalInfoRoute, DeviceActivityRoute, LinkedAccountsRoute, SigningInRoute, + VerifiableCredentialsRoute, ApplicationsRoute, GroupsRoute, OrganizationsRoute, ResourcesRoute, ContentRoute, - Oid4VciRoute, ]; diff --git a/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx b/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx new file mode 100644 index 000000000000..63c8708cedb5 --- /dev/null +++ b/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx @@ -0,0 +1,133 @@ +import { + ContinueCancelModal, + useEnvironment, +} from "@keycloak/keycloak-ui-shared"; +import { + Button, + DataListAction, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + Flex, + FlexItem, +} from "@patternfly/react-core"; +import { ExternalLinkAltIcon } from "@patternfly/react-icons"; +import { useTranslation } from "react-i18next"; + +import { deleteVerifiableCredential } from "../api/methods"; +import { UserVerifiableCredentialRepresentation } from "../api/representations"; +import { formatDate, FORMAT_DATE_ONLY } from "../utils/formatDate"; +import { useAccountAlerts } from "../utils/useAccountAlerts"; + +type CredentialRowProps = { + credential: UserVerifiableCredentialRepresentation; + refresh: () => void; +}; + +export const CredentialRow = ({ credential, refresh }: CredentialRowProps) => { + const { t } = useTranslation(); + const context = useEnvironment(); + const { addAlert, addError } = useAccountAlerts(); + + const hasManageRole = () => { + const token = context.keycloak.tokenParsed; + const accountRoles = token?.resource_access?.["account"]?.roles || []; + return accountRoles.includes("manage-verifiable-credentials"); + }; + + const handleDelete = async () => { + try { + await deleteVerifiableCredential( + context, + credential.credentialScopeName!, + ); + addAlert(t("credentialDeletedSuccess")); + refresh(); + } catch (error) { + addError("credentialDeleteError", error); + } + }; + + const handleIssueToWallet = async () => { + try { + // Construct the AIA action parameter + const config = { + credential_configuration_id: credential.credentialScopeName, + client_id: "account-console", + pre_authorized: false, + }; + + // Base64 encode the config + const encodedConfig = btoa(JSON.stringify(config)); + + // Trigger AIA flow using keycloak.login() + await context.keycloak.login({ + action: `verifiable_credential_offer:${encodedConfig}`, + }); + } catch (error) { + addError("credentialIssuanceError", error); + } + }; + + return ( + + + + {credential.credentialScopeName} + , + + {credential.createdDate + ? formatDate( + new Date(credential.createdDate), + undefined, + FORMAT_DATE_ONLY, + ) + : "—"} + , + ]} + /> + + + + + + {hasManageRole() && ( + + + {t("deleteCredentialConfirm", { + credentialName: credential.credentialScopeName, + })} + + + )} + + + + + ); +}; diff --git a/js/apps/account-ui/src/verifiable-credentials/VerifiableCredentials.tsx b/js/apps/account-ui/src/verifiable-credentials/VerifiableCredentials.tsx new file mode 100644 index 000000000000..05ac0e1c820a --- /dev/null +++ b/js/apps/account-ui/src/verifiable-credentials/VerifiableCredentials.tsx @@ -0,0 +1,86 @@ +import { useEnvironment } from "@keycloak/keycloak-ui-shared"; +import { + DataList, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + Stack, + StackItem, + Title, +} from "@patternfly/react-core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getVerifiableCredentials } from "../api/methods"; +import { UserVerifiableCredentialRepresentation } from "../api/representations"; +import { EmptyRow } from "../components/datalist/EmptyRow"; +import { Page } from "../components/page/Page"; +import { usePromise } from "../utils/usePromise"; +import { CredentialRow } from "./CredentialRow"; + +export const VerifiableCredentials = () => { + const { t } = useTranslation(); + const context = useEnvironment(); + const [credentials, setCredentials] = useState< + UserVerifiableCredentialRepresentation[] + >([]); + const [key, setKey] = useState(1); + const refresh = () => setKey(key + 1); + + usePromise( + (signal) => getVerifiableCredentials({ signal, context }), + (data) => setCredentials(data), + [key], + ); + + return ( + + + + + {t("myVerifiableCredentials")} + + + + + + {t("credentialScopeName")} + , + + {t("credentialCreatedDate")} + , + ]} + /> + + + {credentials.length > 0 ? ( + credentials.map((credential) => ( + + )) + ) : ( + + )} + + + + + ); +}; + +export default VerifiableCredentials; diff --git a/server-spi-private/src/main/java/org/keycloak/models/AccountRoles.java b/server-spi-private/src/main/java/org/keycloak/models/AccountRoles.java index f607664418dc..15ab53bde408 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/AccountRoles.java +++ b/server-spi-private/src/main/java/org/keycloak/models/AccountRoles.java @@ -30,6 +30,8 @@ public interface AccountRoles { String MANAGE_CONSENT = "manage-consent"; String DELETE_ACCOUNT = "delete-account"; String VIEW_GROUPS = "view-groups"; + String VIEW_VERIFIABLE_CREDENTIALS = "view-verifiable-credentials"; + String MANAGE_VERIFIABLE_CREDENTIALS = "manage-verifiable-credentials"; String[] DEFAULT = {VIEW_PROFILE, MANAGE_ACCOUNT}; diff --git a/services/src/main/java/org/keycloak/services/managers/RealmManager.java b/services/src/main/java/org/keycloak/services/managers/RealmManager.java index 1b6efc39761f..d95594300ab1 100755 --- a/services/src/main/java/org/keycloak/services/managers/RealmManager.java +++ b/services/src/main/java/org/keycloak/services/managers/RealmManager.java @@ -507,6 +507,14 @@ private void setupAccountManagement(RealmModel realm) { RoleModel viewGroups = accountClient.addRole(AccountRoles.VIEW_GROUPS); viewGroups.setDescription("${role_" + AccountRoles.VIEW_GROUPS + "}"); + if (Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) { + RoleModel viewVerifiableCredentials = accountClient.addRole(AccountRoles.VIEW_VERIFIABLE_CREDENTIALS); + viewVerifiableCredentials.setDescription("${role_" + AccountRoles.VIEW_VERIFIABLE_CREDENTIALS + "}"); + RoleModel manageVerifiableCredentials = accountClient.addRole(AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); + manageVerifiableCredentials.setDescription("${role_" + AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS + "}"); + manageVerifiableCredentials.addCompositeRole(viewVerifiableCredentials); + } + KeycloakModelUtils.setupDeleteAccount(accountClient); ClientModel accountConsoleClient = realm.getClientByClientId(Constants.ACCOUNT_CONSOLE_CLIENT_ID); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java index 7d1e5d7fae6a..af0f92151bf3 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java @@ -184,6 +184,7 @@ protected Response renderAccountConsole() throws IOException, FreeMarkerExceptio boolean deleteAccountAllowed = false; boolean isViewGroupsEnabled = false; boolean isViewApplicationsEnabled = false; + boolean isOid4VciEnabled = false; if (user != null) { AccountRoleChecker roleChecker = new AccountRoleChecker(session, realm, user); // the 'manage-account' role works on the API level (for the 'account' client) as some kind of composite role @@ -191,6 +192,9 @@ protected Response renderAccountConsole() throws IOException, FreeMarkerExceptio isViewGroupsEnabled = roleChecker.hasOneOfRole(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_GROUPS) && user.getGroupsCount() > 0; isViewApplicationsEnabled = roleChecker.hasOneOfRole(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_APPLICATIONS); + isOid4VciEnabled = roleChecker.hasOneOfRole(AccountRoles.VIEW_VERIFIABLE_CREDENTIALS) + && Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI) + && realm.isVerifiableCredentialsEnabled(); } map.put("deleteAccountAllowed", deleteAccountAllowed); @@ -198,7 +202,7 @@ protected Response renderAccountConsole() throws IOException, FreeMarkerExceptio map.put("isViewApplicationsEnabled", isViewApplicationsEnabled); map.put("isViewGroupsEnabled", isViewGroupsEnabled); map.put("isViewOrganizationsEnabled", realm.isOrganizationsEnabled()); - map.put("isOid4VciEnabled", realm.isVerifiableCredentialsEnabled()); + map.put("isOid4VciEnabled", isOid4VciEnabled); map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)); map.put("updateEmailActionEnabled", UpdateEmail.isEnabled(realm)); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index 11bd08ec1ea5..385b9c35c9a7 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -243,6 +243,12 @@ public OrganizationsResource organizations() { return new OrganizationsResource(session, auth, user); } + @Path("/verifiable-credentials") + public AccountVerifiableCredentialResource verifiableCredentials() { + checkAccountApiEnabled(); + return new AccountVerifiableCredentialResource(session, auth, user); + } + private ClientRepresentation modelToRepresentation(ClientModel model, List inUseClients, List offlineClients, Map consents) { ClientRepresentation representation = new ClientRepresentation(); representation.setClientId(model.getClientId()); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountVerifiableCredentialResource.java b/services/src/main/java/org/keycloak/services/resources/account/AccountVerifiableCredentialResource.java new file mode 100644 index 000000000000..5420ffcd05de --- /dev/null +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountVerifiableCredentialResource.java @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.services.resources.account; + +import java.util.List; + +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.keycloak.common.Profile; +import org.keycloak.models.AccountRoles; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.representations.idm.oid4vc.UserVerifiableCredentialRepresentation; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.cors.Cors; +import org.keycloak.services.managers.Auth; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.NoCache; + +public class AccountVerifiableCredentialResource { + + private static final Logger logger = Logger.getLogger(AccountVerifiableCredentialResource.class); + + private final KeycloakSession session; + private final Auth auth; + private final UserModel user; + private final RealmModel realm; + + public AccountVerifiableCredentialResource(KeycloakSession session, Auth auth, UserModel user) { + this.session = session; + this.auth = auth; + this.user = user; + this.realm = auth.getRealm(); + } + + /** + * Get list of verifiable credentials for the authenticated user + * + * @return list of user's verifiable credentials + */ + @GET + @Path("/") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + public Response getCredentials() { + auth.requireOneOf(AccountRoles.VIEW_VERIFIABLE_CREDENTIALS, AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); + checkOid4VCIEnabled(); + + List credentials = session.users() + .getVerifiableCredentialsByUser(user.getId()) + .map(ModelToRepresentation::toRepresentation) + .toList(); + + return Cors.builder() + .auth() + .checkAllowedOrigins(auth.getToken()) + .add(Response.ok(credentials)); + } + + /** + * Revoke a specific verifiable credential for the authenticated user + * + * @param credentialScopeName the credential scope name to revoke + * @return 204 No Content on success + */ + @DELETE + @Path("/{credentialScopeName}") + public Response revokeCredential(@PathParam("credentialScopeName") String credentialScopeName) { + auth.require(AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); + checkOid4VCIEnabled(); + + boolean removed = session.users().removeVerifiableCredential(user.getId(), credentialScopeName); + if (!removed) { + logger.warn(String.format("Verifiable credential '%s' not found for user '%s' in the realm '%s'.", + credentialScopeName, user.getUsername(), realm.getName())); + throw new NotFoundException("Verifiable credential not found"); + } + + return Cors.builder().auth().checkAllowedOrigins(auth.getToken()).add(Response.noContent()); + } + + private void checkOid4VCIEnabled() { + if (!Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI)) { + throw ErrorResponse.error("Feature " + Profile.Feature.OID4VC_VCI.getKey() + " not enabled", Response.Status.BAD_REQUEST); + } + if (!realm.isVerifiableCredentialsEnabled()) { + throw ErrorResponse.error("Verifiable credentials not enabled for the realm", Response.Status.BAD_REQUEST); + } + } + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceRolesTest.java b/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceRolesTest.java index c2505ce0e100..b101df970193 100644 --- a/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceRolesTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceRolesTest.java @@ -12,6 +12,7 @@ import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.common.Profile; import org.keycloak.models.AccountRoles; import org.keycloak.models.Constants; import org.keycloak.representations.idm.RoleRepresentation; @@ -24,6 +25,8 @@ import org.keycloak.testframework.realm.RealmBuilder; import org.keycloak.testframework.realm.RealmConfig; import org.keycloak.testframework.realm.UserBuilder; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; import org.keycloak.tests.utils.admin.AdminApiUtil; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.PkceGenerator; @@ -48,7 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -@KeycloakIntegrationTest +@KeycloakIntegrationTest(config = AccountRestServiceRolesTest.AccountRolesServerConfig.class) public class AccountRestServiceRolesTest { private static final String PASSWORD = "password"; @@ -76,6 +79,14 @@ public void groupsEndpointAccess() throws IOException { assertEndpointStatus("no-access-user", "groups", 403); } + @Test + public void verifiableCredentialsEndpointAccess() throws IOException { + addAccountConsoleScopeMapping(AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); + assertEndpointStatus("view-verifiable-credentials-user", "verifiable-credentials", 200); + assertEndpointStatus("manage-verifiable-credentials-user", "verifiable-credentials", 200); + assertEndpointStatus("no-access-user", "verifiable-credentials", 403); + } + @Test public void accountConsoleFeaturesForManageAccountUser() throws IOException { assertAccountConsoleFeatures("manage-account-user", true, true); @@ -103,6 +114,24 @@ public void accountConsoleFeaturesForNoAccessUser() throws IOException { assertAccountConsoleFeatures("no-access-user", false, false); } + @Test + public void accountConsoleFeaturesForViewVerifiableCredentialsUser() throws IOException { + // view-verifiable-credentials is not in the account-console default scope + addAccountConsoleScopeMapping(AccountRoles.VIEW_VERIFIABLE_CREDENTIALS); + JsonNode features = getAccountConsoleFeatures("view-verifiable-credentials-user"); + assertEquals(true, features.get("isOid4VciEnabled").asBoolean(), + "isOid4VciEnabled should be true for view-verifiable-credentials-user"); + } + + @Test + public void accountConsoleFeaturesForManageVerifiableCredentialsUser() throws IOException { + // manage-verifiable-credentials is not in the account-console default scope + addAccountConsoleScopeMapping(AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); + JsonNode features = getAccountConsoleFeatures("manage-verifiable-credentials-user"); + assertEquals(true, features.get("isOid4VciEnabled").asBoolean(), + "isOid4VciEnabled should be true for manage-verifiable-credentials-user"); + } + private void addAccountConsoleScopeMapping(String roleName) { ClientResource accountConsole = AdminApiUtil.findClientByClientId(realm.admin(), Constants.ACCOUNT_CONSOLE_CLIENT_ID); ClientResource accountManagementClient = AdminApiUtil.findClientByClientId(realm.admin(), Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); @@ -218,7 +247,10 @@ private void assertEndpointStatus(String username, String endpoint, int expected public static class AccountRolesRealmConfig implements RealmConfig { @Override public RealmBuilder configure(RealmBuilder realm) { - return realm.groups("test-group").users( + return realm + .attribute("verifiableCredentialsEnabled", "true") + .groups("test-group") + .users( UserBuilder.create("manage-account-user") .name("Manage", "Account") .email("manage-account@localhost") @@ -245,7 +277,24 @@ public RealmBuilder configure(RealmBuilder realm) { .name("No", "Access") .email("no-access@localhost") .password(PASSWORD) - .clientRoles(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID, AccountRoles.VIEW_PROFILE)); + .clientRoles(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID, AccountRoles.VIEW_PROFILE), + UserBuilder.create("view-verifiable-credentials-user") + .name("View", "VerifiableCredentials") + .email("view-verifiable-credentials@localhost") + .password(PASSWORD) + .clientRoles(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID, AccountRoles.VIEW_VERIFIABLE_CREDENTIALS, AccountRoles.VIEW_PROFILE), + UserBuilder.create("manage-verifiable-credentials-user") + .name("Manage", "VerifiableCredentials") + .email("manage-verifiable-credentials@localhost") + .password(PASSWORD) + .clientRoles(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID, AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS, AccountRoles.VIEW_PROFILE)); + } + } + + public static class AccountRolesServerConfig implements KeycloakServerConfig { + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Profile.Feature.OID4VC_VCI); } } } diff --git a/themes/src/main/resources/theme/base/account/messages/messages_en.properties b/themes/src/main/resources/theme/base/account/messages/messages_en.properties index ce47f64142da..d1518ac0c130 100755 --- a/themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -401,3 +401,17 @@ error-user-attribute-read-only=The field {0} is read only. error-username-invalid-character=Username contains invalid character. error-person-name-invalid-character=Name contains invalid character. error-non-ascii-local-part-email=Local part of the address must contain only ASCII characters. + +# Verifiable Credentials +verifiableCredentials=Verifiable Credentials +verifiableCredentialsDescription=View and manage your verifiable credentials +myVerifiableCredentials=My Verifiable Credentials +noVerifiableCredentials=You have no verifiable credentials yet +credentialDeletedSuccess=Verifiable credential deleted successfully +credentialDeleteError=Failed to delete verifiable credential +credentialIssuanceError=Failed to request credential issuance +deleteCredential=Delete Credential +deleteCredentialConfirm=Are you sure you want to delete the credential "{{credentialName}}"? +credentialScopeName=Credential Type +credentialCreatedDate=Created +issueToWallet=Issue to Wallet From 110ccabb0ab6d8fa4cd5e941778082e58a51b192 Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Wed, 13 May 2026 09:33:59 +0200 Subject: [PATCH 2/2] verifiable credential in account console Closes #48576 Signed-off-by: Giuseppe Graziano --- .../account-ui/src/verifiable-credentials/CredentialRow.tsx | 6 ++++-- .../keycloak/services/resources/account/AccountConsole.java | 4 +--- .../account/AccountVerifiableCredentialResource.java | 4 ++-- .../keycloak/tests/account/AccountRestServiceRolesTest.java | 3 +++ 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx b/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx index 63c8708cedb5..b31797340d29 100644 --- a/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx +++ b/js/apps/account-ui/src/verifiable-credentials/CredentialRow.tsx @@ -33,7 +33,10 @@ export const CredentialRow = ({ credential, refresh }: CredentialRowProps) => { const hasManageRole = () => { const token = context.keycloak.tokenParsed; const accountRoles = token?.resource_access?.["account"]?.roles || []; - return accountRoles.includes("manage-verifiable-credentials"); + return ( + accountRoles.includes("manage-account") || + accountRoles.includes("manage-verifiable-credentials") + ); }; const handleDelete = async () => { @@ -54,7 +57,6 @@ export const CredentialRow = ({ credential, refresh }: CredentialRowProps) => { // Construct the AIA action parameter const config = { credential_configuration_id: credential.credentialScopeName, - client_id: "account-console", pre_authorized: false, }; diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java index af0f92151bf3..b3184225f1ed 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java @@ -192,9 +192,7 @@ protected Response renderAccountConsole() throws IOException, FreeMarkerExceptio isViewGroupsEnabled = roleChecker.hasOneOfRole(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_GROUPS) && user.getGroupsCount() > 0; isViewApplicationsEnabled = roleChecker.hasOneOfRole(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_APPLICATIONS); - isOid4VciEnabled = roleChecker.hasOneOfRole(AccountRoles.VIEW_VERIFIABLE_CREDENTIALS) - && Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI) - && realm.isVerifiableCredentialsEnabled(); + isOid4VciEnabled = Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI) && realm.isVerifiableCredentialsEnabled() && roleChecker.hasOneOfRole(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_VERIFIABLE_CREDENTIALS); } map.put("deleteAccountAllowed", deleteAccountAllowed); diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountVerifiableCredentialResource.java b/services/src/main/java/org/keycloak/services/resources/account/AccountVerifiableCredentialResource.java index 5420ffcd05de..dce00d069b46 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountVerifiableCredentialResource.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountVerifiableCredentialResource.java @@ -67,7 +67,7 @@ public AccountVerifiableCredentialResource(KeycloakSession session, Auth auth, U @Produces(MediaType.APPLICATION_JSON) @NoCache public Response getCredentials() { - auth.requireOneOf(AccountRoles.VIEW_VERIFIABLE_CREDENTIALS, AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); + auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_VERIFIABLE_CREDENTIALS, AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); checkOid4VCIEnabled(); List credentials = session.users() @@ -90,7 +90,7 @@ public Response getCredentials() { @DELETE @Path("/{credentialScopeName}") public Response revokeCredential(@PathParam("credentialScopeName") String credentialScopeName) { - auth.require(AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); + auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); checkOid4VCIEnabled(); boolean removed = session.users().removeVerifiableCredential(user.getId(), credentialScopeName); diff --git a/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceRolesTest.java b/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceRolesTest.java index b101df970193..42caba270310 100644 --- a/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceRolesTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/account/AccountRestServiceRolesTest.java @@ -81,6 +81,7 @@ public void groupsEndpointAccess() throws IOException { @Test public void verifiableCredentialsEndpointAccess() throws IOException { + assertEndpointStatus("manage-account-user", "verifiable-credentials", 200); addAccountConsoleScopeMapping(AccountRoles.MANAGE_VERIFIABLE_CREDENTIALS); assertEndpointStatus("view-verifiable-credentials-user", "verifiable-credentials", 200); assertEndpointStatus("manage-verifiable-credentials-user", "verifiable-credentials", 200); @@ -90,6 +91,8 @@ public void verifiableCredentialsEndpointAccess() throws IOException { @Test public void accountConsoleFeaturesForManageAccountUser() throws IOException { assertAccountConsoleFeatures("manage-account-user", true, true); + JsonNode features = getAccountConsoleFeatures("manage-account-user"); + assertTrue(features.get("isOid4VciEnabled").asBoolean(), "isOid4VciEnabled should be true for manage-account-user"); } @Test