diff --git a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx index 262bd07baf8..70747a25155 100644 --- a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx +++ b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx @@ -62,7 +62,7 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { const form = useForm({ mode: "onChange" }); const { control, handleSubmit, setValue, formState } = form; const { isDirty, isValid } = formState; - const { realm } = useRealm(); + const { realm, realmRepresentation } = useRealm(); const providers = useLoginProviders(); const serverInfo = useServerInfo(); @@ -152,7 +152,9 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { }); const isOid4vcProtocol = selectedProtocol === OID4VC_PROTOCOL; - const isOid4vcEnabled = isFeatureEnabled(Feature.OpenId4VCI); + const isOid4vcEnabled = + isFeatureEnabled(Feature.OpenId4VCI) && + realmRepresentation?.verifiableCredentialsEnabled; const isNotSaml = selectedProtocol != "saml"; const setDynamicRegex = (value: string, append: boolean) => diff --git a/js/apps/admin-ui/src/clients/AdvancedTab.tsx b/js/apps/admin-ui/src/clients/AdvancedTab.tsx index 51dbe72c4d3..51ae75440a6 100644 --- a/js/apps/admin-ui/src/clients/AdvancedTab.tsx +++ b/js/apps/admin-ui/src/clients/AdvancedTab.tsx @@ -16,6 +16,7 @@ import { FineGrainOpenIdConnect } from "./advanced/FineGrainOpenIdConnect"; import { FineGrainSamlEndpointConfig } from "./advanced/FineGrainSamlEndpointConfig"; import { OpenIdConnectCompatibilityModes } from "./advanced/OpenIdConnectCompatibilityModes"; import { OpenIdVerifiableCredentials } from "./advanced/OpenIdVerifiableCredentials"; +import { useRealm } from "../context/realm-context/RealmContext"; import { PROTOCOL_OIDC, PROTOCOL_OID4VC } from "./constants"; export const parseResult = ( @@ -53,6 +54,7 @@ export type AdvancedProps = { export const AdvancedTab = ({ save, client }: AdvancedProps) => { const { t } = useTranslation(); + const { realmRepresentation } = useRealm(); const isFeatureEnabled = useIsFeatureEnabled(); const { setValue } = useFormContext(); @@ -207,7 +209,8 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => { title: t("openIdVerifiableCredentials"), isHidden: (protocol !== PROTOCOL_OIDC && protocol !== PROTOCOL_OID4VC) || - !isFeatureEnabled(Feature.OpenId4VCI), + !isFeatureEnabled(Feature.OpenId4VCI) || + !realmRepresentation?.verifiableCredentialsEnabled, panel: ( <> diff --git a/js/apps/admin-ui/src/clients/add/GeneralSettings.tsx b/js/apps/admin-ui/src/clients/add/GeneralSettings.tsx index b08cc852fc4..2878f274d92 100644 --- a/js/apps/admin-ui/src/clients/add/GeneralSettings.tsx +++ b/js/apps/admin-ui/src/clients/add/GeneralSettings.tsx @@ -2,13 +2,20 @@ import { useTranslation } from "react-i18next"; import { SelectControl } from "@keycloak/keycloak-ui-shared"; import { FormAccess } from "../../components/form/FormAccess"; import { useLoginProviders } from "../../context/server-info/ServerInfoProvider"; +import { useRealm } from "../../context/realm-context/RealmContext"; import { ClientDescription } from "../ClientDescription"; import { getProtocolName } from "../utils"; +import { PROTOCOL_OID4VC } from "../constants"; export const GeneralSettings = () => { const { t } = useTranslation(); + const { realmRepresentation } = useRealm(); const providers = useLoginProviders(); + const filteredProviders = realmRepresentation?.verifiableCredentialsEnabled + ? providers + : providers.filter((provider) => provider !== PROTOCOL_OID4VC); + return ( { controller={{ defaultValue: "", }} - options={providers.map((option) => ({ + options={filteredProviders.map((option) => ({ key: option, value: getProtocolName(t, option), }))} diff --git a/js/apps/admin-ui/src/realm-settings/TokensTab.tsx b/js/apps/admin-ui/src/realm-settings/TokensTab.tsx index ec58222bb97..a0aa28561cc 100644 --- a/js/apps/admin-ui/src/realm-settings/TokensTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/TokensTab.tsx @@ -667,7 +667,9 @@ export const RealmSettingsTokensTab = ({ }, { title: t("oid4vciAttributes"), - isHidden: !isFeatureEnabled(Feature.OpenId4VCI), + isHidden: + !isFeatureEnabled(Feature.OpenId4VCI) || + !realm.verifiableCredentialsEnabled, panel: ( { test("should display OID4VCI fields when protocol is selected", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScope(page, testBed); await expect(page.locator("#kc-protocol")).toBeVisible(); @@ -134,7 +137,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { }); test("should save and persist OID4VCI field values", async ({ page }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); const testClientScopeName = `oid4vci-test-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; await createClientScopeAndSelectProtocolAndFormat( @@ -218,7 +223,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should show OID4VCI protocol when global feature is enabled", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScope(page, testBed); await expect(page.locator("#kc-protocol")).toBeVisible(); @@ -268,7 +275,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should handle OID4VCI protocol selection correctly", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScope(page, testBed); await expect(page.locator("#kc-protocol")).toBeVisible(); @@ -302,7 +311,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should only show supported format options (dc+sd-jwt and jwt_vc)", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScopeAndSelectProtocolAndFormat(page, testBed); await page.locator("#kc-vc-format").click(); @@ -322,7 +333,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should show format-specific fields for SD-JWT format", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScopeAndSelectProtocolAndFormat( page, testBed, @@ -343,7 +356,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should show format-specific fields for JWT VC format", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScopeAndSelectProtocolAndFormat( page, testBed, @@ -364,7 +379,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should save and persist new OID4VCI field values for SD-JWT format", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); const testClientScopeName = `oid4vci-sdjwt-test-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; await createClientScopeAndSelectProtocolAndFormat( @@ -427,7 +444,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should omit optional OID4VCI fields when left blank", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); const testClientScopeName = `oid4vci-blank-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; await createClientScopeAndSelectProtocolAndFormat( @@ -454,7 +473,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should conditionally show/hide fields when format changes", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScopeAndSelectProtocolAndFormat( page, testBed, @@ -502,7 +523,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { }); test("should show token_jws_type for all formats", async ({ page }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScopeAndSelectProtocolAndFormat( page, testBed, @@ -520,7 +543,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should display signing algorithm dropdown with available algorithms", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScopeAndSelectProtocolAndFormat( page, testBed, @@ -538,7 +563,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should display hash algorithm dropdown with available algorithms", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await createClientScopeAndSelectProtocolAndFormat( page, testBed, @@ -555,7 +582,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { }); test("should save and persist hash algorithm value", async ({ page }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); const testClientScopeName = `oid4vci-hash-alg-test-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; await createClientScopeAndSelectProtocolAndFormat( @@ -588,7 +617,9 @@ test.describe("OID4VCI Client Scope Functionality", () => { test("should default to SHA-256 when hash algorithm is not set", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); const testClientScopeName = `oid4vci-hash-default-test-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; await createClientScopeAndSelectProtocolAndFormat( @@ -611,4 +642,32 @@ test.describe("OID4VCI Client Scope Functionality", () => { "SHA-256", ); }); + + test("should not offer OID4VCI protocol when verifiable credentials are disabled for the realm", async ({ + page, + }) => { + await using testBed = await createTestBed(); + + await adminClient.updateRealm(testBed.realm, { + verifiableCredentialsEnabled: false, + }); + + try { + await createClientScope(page, testBed); + + await expect(page.locator("#kc-protocol")).toBeVisible(); + await page.locator("#kc-protocol").click(); + + await expect( + page.getByRole("option", { + name: "OpenID for Verifiable Credentials", + }), + ).toHaveCount(0); + } finally { + // Re-enable verifiable credentials so other tests see the default behavior + await adminClient.updateRealm(testBed.realm, { + verifiableCredentialsEnabled: true, + }); + } + }); }); diff --git a/js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts b/js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts index 9f75efbc5d9..18ded2776b6 100644 --- a/js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts +++ b/js/apps/admin-ui/test/client-scope/oid4vci-mappers.spec.ts @@ -86,7 +86,7 @@ test.describe("OID4VCI Protocol Mapper Configuration", () => { let testBed: Awaited>; test.beforeEach(async ({ page }) => { - testBed = await createTestBed(); + testBed = await createTestBed({ verifiableCredentialsEnabled: true }); await login(page, { to: toClientScopes({ realm: testBed.realm }) }); }); diff --git a/js/apps/admin-ui/test/clients/advanced.spec.ts b/js/apps/admin-ui/test/clients/advanced.spec.ts index d98a3f1845a..745e8cc3169 100644 --- a/js/apps/admin-ui/test/clients/advanced.spec.ts +++ b/js/apps/admin-ui/test/clients/advanced.spec.ts @@ -138,7 +138,9 @@ test.describe.serial("OpenID for Verifiable Credentials", () => { const realmName = `oid4vci-test-${uuidv4()}`; const clientIdOpenIdConnect = `client-oidc-${uuidv4()}`; test.beforeAll(async () => { - await adminClient.createRealm(realmName, {}); + await adminClient.createRealm(realmName, { + verifiableCredentialsEnabled: true, + }); await adminClient.createClient({ clientId: clientIdOpenIdConnect, realm: realmName, @@ -183,5 +185,27 @@ test.describe.serial("OpenID for Verifiable Credentials", () => { await expect(toggleSwitch).toBeHidden(); } }); + + test("should hide OID4VC section when verifiable credentials are disabled for the realm", async ({ + page, + }) => { + await adminClient.updateRealm(realmName, { + verifiableCredentialsEnabled: false, + }); + + await page.reload(); + await page.waitForSelector('[data-testid="advancedTab"]', { + state: "visible", + timeout: 10000, + }); + await page.getByTestId("advancedTab").click(); + + const toggleSwitch = page.locator("#attributes\\.oid4vci🍺enabled"); + await expect(toggleSwitch).toBeHidden(); + + await adminClient.updateRealm(realmName, { + verifiableCredentialsEnabled: true, + }); + }); }); }); diff --git a/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.spec.ts b/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.spec.ts index 5a2571751d9..330276a5ab2 100644 --- a/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.spec.ts +++ b/js/apps/admin-ui/test/clients/assign-oid4vci-client-scope.spec.ts @@ -9,7 +9,9 @@ import { toClients } from "../../src/clients/routes/Clients.tsx"; import { createClient, continueNext, save as saveClient } from "./utils.ts"; test("OIDC client can assign OID4VCI client scopes", async ({ page }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toClients({ realm: testBed.realm }) }); await goToRealm(page, testBed.realm); diff --git a/js/apps/admin-ui/test/realm-settings/oid4vci-attributes.spec.ts b/js/apps/admin-ui/test/realm-settings/oid4vci-attributes.spec.ts index 6510bc942e2..d19b76a6e84 100644 --- a/js/apps/admin-ui/test/realm-settings/oid4vci-attributes.spec.ts +++ b/js/apps/admin-ui/test/realm-settings/oid4vci-attributes.spec.ts @@ -7,10 +7,36 @@ import { SERVER_URL, ROOT_PATH } from "../utils/constants.ts"; import { login } from "../utils/login.js"; import { selectItem } from "../utils/form.ts"; -test("OID4VCI section visibility and jump link in Tokens tab", async ({ +test("OID4VCI section is hidden in Tokens tab when verifiable credentials are disabled", async ({ page, }) => { await using testBed = await createTestBed(); + + await adminClient.updateRealm(testBed.realm, { + verifiableCredentialsEnabled: false, + }); + + try { + await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); + + const tokensTab = page.getByTestId("rs-tokens-tab"); + await tokensTab.click(); + + const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes"); + await expect(oid4vciJumpLink).toBeHidden(); + } finally { + await adminClient.updateRealm(testBed.realm, { + verifiableCredentialsEnabled: true, + }); + } +}); + +test("OID4VCI section visibility and jump link in Tokens tab", async ({ + page, +}) => { + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); @@ -29,7 +55,9 @@ test("OID4VCI section visibility and jump link in Tokens tab", async ({ test("should render fields and save values with correct attribute keys", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); @@ -63,7 +91,9 @@ test("should render fields and save values with correct attribute keys", async ( }); test("should persist values after page refresh", async ({ page }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); @@ -115,7 +145,9 @@ test("should persist values after page refresh", async ({ page }) => { }); test("should validate form fields and save valid values", async ({ page }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); @@ -173,7 +205,9 @@ test("should validate form fields and save valid values", async ({ page }) => { test("should show validation error for values below minimum threshold", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); @@ -205,7 +239,9 @@ test("should show validation error for values below minimum threshold", async ({ test("should save signed metadata, encryption, and batch issuance settings", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); @@ -262,7 +298,9 @@ test("should save signed metadata, encryption, and batch issuance settings", asy test("should save time-based correlation mitigation settings", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); @@ -308,7 +346,9 @@ test("should save time-based correlation mitigation settings", async ({ }); test("should save Deflate Compression setting", async ({ page }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); @@ -352,7 +392,9 @@ test("should save Deflate Compression setting", async ({ page }) => { test("should save Attestation Trust settings (Trusted Keys and IDs)", async ({ page, }) => { - await using testBed = await createTestBed(); + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); await login(page, { to: toRealmSettings({ realm: testBed.realm }) }); const tokensTab = page.getByTestId("rs-tokens-tab"); diff --git a/js/apps/admin-ui/test/realm-settings/tokens.spec.ts b/js/apps/admin-ui/test/realm-settings/tokens.spec.ts index 5fa1bb19899..d2540f60421 100644 --- a/js/apps/admin-ui/test/realm-settings/tokens.spec.ts +++ b/js/apps/admin-ui/test/realm-settings/tokens.spec.ts @@ -16,7 +16,9 @@ import { test.describe.serial("Realm Settings - Tokens", () => { const realmName = `tokens-realm-settings-${uuid()}`; - test.beforeAll(() => adminClient.createRealm(realmName)); + test.beforeAll(() => + adminClient.createRealm(realmName, { verifiableCredentialsEnabled: true }), + ); test.afterAll(() => adminClient.deleteRealm(realmName)); test.beforeEach(async ({ page }) => { diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocolFactory.java b/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocolFactory.java index 3333d26a72b..b120a05e551 100755 --- a/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocolFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/LoginProtocolFactory.java @@ -19,8 +19,11 @@ package org.keycloak.protocol; import java.util.Map; +import jakarta.ws.rs.WebApplicationException; + import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; @@ -64,5 +67,22 @@ public interface LoginProtocolFactory extends ProviderFactory { /** * Add default values to {@link ClientScopeRepresentation}s that refer to the specific login-protocol */ - void addClientScopeDefaults(ClientScopeRepresentation clientModel); + void addClientScopeDefaults(ClientScopeRepresentation clientScope); + + /** + * Invoked during client-scope creation or update to add additional validation hooks specific to target protocol. May throw errorResponseException in case + * + * @param session Keycloak session + * @param clientScope client scope to create or update + * @throws WebApplicationException or some of it's subclass if validation fails + */ + default void validateClientScope(KeycloakSession session, ClientScopeRepresentation clientScope) throws WebApplicationException { + } + + /** + * Test if the clientScope is valid for particular client. Usually called during protocol requests + */ + default boolean isValidClientScope(KeycloakSession session, ClientModel client, ClientScopeModel clientScope) { + return true; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java index 9649d925809..fafb8ee8ae0 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -20,6 +20,8 @@ package org.keycloak.protocol.oid4vc; import java.util.HashMap; import java.util.Map; +import jakarta.ws.rs.core.Response; + import org.keycloak.Config; import org.keycloak.constants.OID4VCIConstants; import org.keycloak.events.EventBuilder; @@ -41,6 +43,8 @@ import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCUserAttributeMapper; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ErrorResponseException; import org.jboss.logging.Logger; @@ -165,6 +169,23 @@ public class OID4VCLoginProtocolFactory implements LoginProtocolFactory, OID4VCE clientScope.getAttributes().computeIfAbsent(EXPIRY_IN_SECONDS, k -> String.valueOf(EXPIRY_IN_SECONDS_DEFAULT)); } + @Override + public void validateClientScope(KeycloakSession session, ClientScopeRepresentation clientScope) throws ErrorResponseException { + LoginProtocolFactory.super.validateClientScope(session, clientScope); + + RealmModel realm = session.getContext().getRealm(); + if (!realm.isVerifiableCredentialsEnabled()) { + throw ErrorResponse.error( + "OID4VC protocol cannot be used when Verifiable Credentials is disabled for the realm", + Response.Status.BAD_REQUEST); + } + } + + @Override + public boolean isValidClientScope(KeycloakSession session, ClientModel client, ClientScopeModel clientScope) { + return session.getContext().getRealm().isVerifiableCredentialsEnabled(); + } + @Override public LoginProtocol create(KeycloakSession session) { return null; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 9708adf7aae..ca6a6478d9d 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -248,6 +248,28 @@ public class OID4VCIssuerEndpoint { credentialBuilder -> credentialBuilder)); } + /** + * Validates whether OID4VCI functionality is enabled for the realm. + *

+ * If the realm setting is disabled, this method logs the status and throws a + * {@link CorsErrorResponseException} with an appropriate error message. + *

+ * + * @throws CorsErrorResponseException if OID4VCI is disabled for the realm. + */ + private void checkIsOid4vciEnabled() { + RealmModel realm = session.getContext().getRealm(); + if (!realm.isVerifiableCredentialsEnabled()) { + LOGGER.debugf("OID4VCI functionality is disabled for realm '%s'. Verifiable Credentials switch is off.", realm.getName()); + throw new CorsErrorResponseException( + cors != null ? cors : Cors.builder().allowAllOrigins(), + Errors.INVALID_CLIENT, + "OID4VCI functionality is disabled for this realm", + Response.Status.FORBIDDEN + ); + } + } + /** * Validates whether the authenticated client is enabled for OID4VCI features. *

@@ -287,6 +309,8 @@ public class OID4VCIssuerEndpoint { @Produces({MediaType.APPLICATION_JSON}) @Path(NONCE_PATH) public Response getCNonce() { + checkIsOid4vciEnabled(); + RealmModel realm = session.getContext().getRealm(); EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection()); eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_NONCE_REQUEST); @@ -385,6 +409,7 @@ public class OID4VCIssuerEndpoint { @QueryParam("width") @DefaultValue("200") int width, @QueryParam("height") @DefaultValue("200") int height ) { + checkIsOid4vciEnabled(); configureCors(true); AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession(); @@ -568,6 +593,7 @@ public class OID4VCIssuerEndpoint { @Produces(MediaType.APPLICATION_JSON) @Path(CREDENTIAL_OFFER_PATH + "{nonce}") public Response getCredentialOffer(@PathParam("nonce") String nonce) { + checkIsOid4vciEnabled(); configureCors(false); if (nonce == null) { @@ -655,6 +681,7 @@ public class OID4VCIssuerEndpoint { @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_JWT}) @Path(CREDENTIAL_PATH) public Response requestCredential(String requestPayload) { + checkIsOid4vciEnabled(); LOGGER.debugf("Received credentials request with payload: %s", requestPayload); RealmModel realm = session.getContext().getRealm(); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java index 1259b864669..42a2a6544ca 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriInfo; @@ -103,6 +104,11 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider { @Override public Object getConfig() { + RealmModel realm = keycloakSession.getContext().getRealm(); + if (!realm.isVerifiableCredentialsEnabled()) { + LOGGER.debugf("OID4VCI functionality is disabled for realm '%s'. Verifiable Credentials switch is off.", realm.getName()); + throw new NotFoundException("OID4VCI functionality is disabled for this realm"); + } CredentialIssuer issuer = getIssuerMetadata(); return getMetadataResponse(issuer, keycloakSession); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 0bc8a4182a5..90c4fe9d878 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -28,6 +28,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; import java.util.function.BinaryOperator; import java.util.function.Function; @@ -86,6 +87,8 @@ import org.keycloak.models.utils.RoleUtils; import org.keycloak.models.utils.SessionExpirationUtils; import org.keycloak.organization.protocol.mappers.oidc.OrganizationMembershipMapper; import org.keycloak.organization.protocol.mappers.oidc.OrganizationScope; +import org.keycloak.protocol.LoginProtocol; +import org.keycloak.protocol.LoginProtocolFactory; import org.keycloak.protocol.ProtocolMapper; import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.oidc.encode.AccessTokenContext; @@ -697,8 +700,10 @@ public class TokenManager { /** * Check that all the ClientScopes that have been parsed into authorization_resources are actually in the requested scopes * otherwise, the scope wasn't parsed correctly + *

+ * * @param scopes - * @param authorizationRequestContext + * @param authorizationRequestContext authorizationRequestContext. It is not null just if dynamic scopes feature is enabled * @param client * @return */ @@ -727,11 +732,23 @@ public class TokenManager { Set clientScopes; if (authorizationRequestContext == null) { - // only true when dynamic scopes feature is enabled + AtomicBoolean anyInvalid = new AtomicBoolean(false); + clientScopes = getRequestedClientScopes(session, scopes, client, user) .filter(((Predicate) ClientModel.class::isInstance).negate()) + .peek(clientScope -> { + LoginProtocolFactory factory = (LoginProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class, clientScope.getProtocol()); + if (factory != null && !factory.isValidClientScope(session, client, clientScope)) { + logger.debugf("Requested scope '%s' invalid for client '%s'", clientScope.getName(), client.getClientId()); + anyInvalid.set(true); + } + }) .map(ClientScopeModel::getName) .collect(Collectors.toSet()); + + if (anyInvalid.get()) { + return false; + } } else { List details = Optional.ofNullable(authorizationRequestContext.getAuthorizationDetailEntries()).orElse(List.of()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java index dfa12c38f56..0a41a2feb76 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java @@ -60,6 +60,15 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase { LOGGER.debug("Process grant request for preauthorized."); setContext(context); + // Check if OID4VCI functionality is enabled for the realm + if (!realm.isVerifiableCredentialsEnabled()) { + LOGGER.debugf("OID4VCI functionality is disabled for realm '%s'. Verifiable Credentials switch is off.", realm.getName()); + event.error(Errors.INVALID_CLIENT); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, + "OID4VCI functionality is disabled for this realm", + Response.Status.FORBIDDEN); + } + // See: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-request String code = formParams.getFirst(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM); diff --git a/services/src/main/java/org/keycloak/services/managers/ClientManager.java b/services/src/main/java/org/keycloak/services/managers/ClientManager.java index f33cfbf157f..abe9c4685da 100644 --- a/services/src/main/java/org/keycloak/services/managers/ClientManager.java +++ b/services/src/main/java/org/keycloak/services/managers/ClientManager.java @@ -33,6 +33,7 @@ import org.keycloak.common.util.Time; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelValidationException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserManager; import org.keycloak.models.UserModel; @@ -85,6 +86,9 @@ public class ClientManager { if (rep.getProtocol() != null) { LoginProtocolFactory providerFactory = (LoginProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class, rep.getProtocol()); + if (providerFactory == null) { + throw new ModelValidationException("Invalid protocol: " + rep.getProtocol()); + } providerFactory.setupClientDefaults(rep, client); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeResource.java index 95ef19ac7fb..8a0167d98c9 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeResource.java @@ -45,7 +45,6 @@ import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocolFactory; -import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.saml.common.util.StringUtil; import org.keycloak.services.ErrorResponse; @@ -126,6 +125,7 @@ public class ClientScopeResource { }) public Response update(final ClientScopeRepresentation rep) { auth.clients().requireManageClientScopes(); + ClientScopeResource.validateClientScope(session, rep); validateDynamicScopeUpdate(rep); try { LoginProtocolFactory loginProtocolFactory = // @@ -247,14 +247,27 @@ public class ClientScopeResource { .map(type -> (LoginProtocolFactory) type) .map(LoginProtocolFactory::getId) .collect(Collectors.toSet()); - // the OID4VC protocol is not registered to prevent it from being displayed in the client-details ui - acceptedProtocols.add(OID4VCLoginProtocolFactory.PROTOCOL_ID); if (protocol == null || !acceptedProtocols.contains(protocol)) { throw ErrorResponse.error("Unexpected protocol", Response.Status.BAD_REQUEST); } } + /** + * Validates client scope during creation or update + * + * @param session the Keycloak session + * @param clientScope clientScope to validate + * @throws ErrorResponseException if error happens during client-scope validation + */ + public static void validateClientScope(KeycloakSession session, ClientScopeRepresentation clientScope) + throws ErrorResponseException { + LoginProtocolFactory factory = (LoginProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class, clientScope.getProtocol()); + if (factory != null) { + factory.validateClientScope(session, clientScope); + } + } + /** * Makes sure that an update that makes a Client Scope Dynamic is rejected if the Client Scope is assigned to a client * as a default scope. diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java index 2d0a74cb05c..671e23a5162 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopesResource.java @@ -122,6 +122,7 @@ public class ClientScopesResource { auth.clients().requireManageClientScopes(); ClientScopeResource.validateClientScopeName(rep.getName()); ClientScopeResource.validateClientScopeProtocol(session, rep.getProtocol()); + ClientScopeResource.validateClientScope(session, rep); ClientScopeResource.validateDynamicClientScope(rep); try { LoginProtocolFactory loginProtocolFactory = // diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTest.java index 6efe7955350..1451db5c606 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTest.java @@ -744,7 +744,7 @@ public class ClientScopeTest extends AbstractClientScopeTest { @DisplayName("Create ClientScope with protocol:") @ParameterizedTest - @ValueSource(strings = {"openid-connect", "saml", "oid4vc"}) + @ValueSource(strings = {"openid-connect", "saml"}) public void createClientScopeWithOpenIdProtocol(String protocol) { createClientScope(protocol); } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java index c35ca90e350..8cd04e8b377 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java @@ -19,20 +19,24 @@ package org.keycloak.tests.admin.client; import java.util.Map; -import java.util.Optional; -import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.core.Response; +import org.keycloak.admin.client.resource.ClientScopeResource; import org.keycloak.common.Profile; import org.keycloak.constants.OID4VCIConstants; import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.server.KeycloakServerConfig; import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.testframework.util.ApiUtil; +import org.junit.Assert; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -42,6 +46,13 @@ import org.junit.jupiter.api.Test; @KeycloakIntegrationTest(config = ClientScopeTestOid4Vci.DefaultServerConfigWithOid4Vci.class) public class ClientScopeTestOid4Vci extends AbstractClientScopeTest { + @BeforeEach + public void enableVerifiableCredentialsInRealm() { + // Enable verifiable credentials on the realm + // This is required in addition to the server feature being enabled + managedRealm.updateWithCleanup(r -> r.update(rep -> rep.setVerifiableCredentialsEnabled(true))); + } + @DisplayName("Verify default values are correctly set") @Test public void testDefaultOid4VciClientScopeAttributes() { @@ -54,11 +65,7 @@ public class ClientScopeTestOid4Vci extends AbstractClientScopeTest { String clientScopeId = null; try (Response response = clientScopes().create(clientScope)) { Assertions.assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); - String location = (String) Optional.ofNullable(response.getHeaders().get(HttpHeaders.LOCATION)) - .map(list -> list.get(0)) - .orElse(null); - Assertions.assertNotNull(location); - clientScopeId = location.substring(location.lastIndexOf("/") + 1); + clientScopeId = ApiUtil.getCreatedId(response); ClientScopeRepresentation createdClientScope = clientScopes().get(clientScopeId).toRepresentation(); Assertions.assertNotNull(createdClientScope); @@ -85,9 +92,9 @@ public class ClientScopeTestOid4Vci extends AbstractClientScopeTest { Assertions.assertEquals(clientScope.getName(), createdClientScope.getAttributes().get(CredentialScopeModel.CONTEXTS)); Assertions.assertEquals(clientScope.getName(), - createdClientScope.getAttributes().get(CredentialScopeModel.VCT)); - Assertions.assertEquals(clientScope.getName(), - createdClientScope.getAttributes().get(CredentialScopeModel.ISSUER_DID)); + createdClientScope.getAttributes().get(CredentialScopeModel.VCT)); + // Note: ISSUER_DID is intentionally not set by default, as there's no sensible default + // The implementation leaves it undefined so the realm's URL will be used as the Issuer's ID } finally { Assertions.assertNotNull(clientScopeId); @@ -96,6 +103,57 @@ public class ClientScopeTestOid4Vci extends AbstractClientScopeTest { } } + @DisplayName("Verify CRUD of clientScope when OID4VCI is disabled for the realm") + @Test + public void testCreateOid4vciClientScopeDisabledForTheRealm() { + RealmRepresentation realm = managedRealm.admin().toRepresentation(); + try { + // Create clientScope1 successfully + String clientScopeId1; + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName("test-client-scope1"); + clientScope.setDescription("test-client-scope-description"); + clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); + clientScope.setAttributes(Map.of("test-attribute", "test-value")); + try (Response response = clientScopes().create(clientScope)) { + Assertions.assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); + clientScopeId1 = ApiUtil.getCreatedId(response); + } + + // Disable OID4VCI for the realm + realm.setVerifiableCredentialsEnabled(false); + managedRealm.admin().update(realm); + + // Test not possible to update existing oid4vci client-scope + ClientScopeResource clientScope1 = managedRealm.admin().clientScopes().get(clientScopeId1); + ClientScopeRepresentation clientScopeRep1 = clientScope1.toRepresentation(); + clientScopeRep1.setDescription("Foo"); + try { + clientScope1.update(clientScopeRep1); + Assert.fail("Not expected to update client scope"); + } catch (BadRequestException bre) { + // expected + } + + // Still possible to delete oid4vci clientScope + clientScope1.remove(); + + // Not possible to create new oid4vci clientScope + clientScope= new ClientScopeRepresentation(); + clientScope.setName("test-client-scope2"); + clientScope.setDescription("test-client-scope-description"); + clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL); + clientScope.setAttributes(Map.of("test-attribute", "test-value")); + try (Response response = clientScopes().create(clientScope)) { + Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + } + } finally { + // Revert + realm.setVerifiableCredentialsEnabled(true); + managedRealm.admin().update(realm); + } + } + public static class DefaultServerConfigWithOid4Vci implements KeycloakServerConfig { @Override diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientRegistrationTest.java index 74ad94974be..352c6e36d1d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientRegistrationTest.java @@ -206,6 +206,22 @@ public class ClientRegistrationTest extends AbstractClientRegistrationTest { registerClient(); } + /** + * OID4VC protocol is not valid for clients. It can only be used for ClientScopes. + * Attempting to create a client with protocol "oid4vc" should be rejected. + */ + @Test + public void registerOid4vcClientShouldBeRejected() { + authManageClients(); + + ClientRepresentation client = buildClient(); + client.setProtocol("oid4vc"); + + Response response = adminClient.realm(REALM_NAME).clients().create(client); + assertEquals("Creating a client with OID4VC protocol should be rejected as it is not a valid protocol for clients.", + 400, response.getStatus()); + } + @Test public void registerClientAsAdminWithNoAccess() throws ClientRegistrationException { authNoAccess(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java index 22e38c3de1c..9566ecfadd6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/PreAuthorizedGrantTest.java @@ -97,6 +97,33 @@ public class PreAuthorizedGrantTest extends AbstractTestRealmKeycloakTest { assertEquals("If no code is provided, no access token should be returned.", HttpStatus.SC_BAD_REQUEST, accessTokenResponse.getStatusCode()); } + /** + * When verifiable credentials are disabled for the realm, the pre-authorized code + * grant (used by OID4VCI flows) must be rejected with 403 Forbidden. + */ + @Test + public void testPreAuthorizedGrantRealmDisabled() throws Exception { + // Disable verifiable credentials for the test realm + RealmRepresentation realmRep = adminClient.realm(TEST_REALM_NAME).toRepresentation(); + realmRep.setVerifiableCredentialsEnabled(false); + adminClient.realm(TEST_REALM_NAME).update(realmRep); + + try { + String userSessionId = getUserSession(); + String preAuthorizedCode = getTestingClient().testing() + .getPreAuthorizedCode(TEST_REALM_NAME, userSessionId, "test-app", Time.currentTime() + 30); + + AccessTokenResponse accessTokenResponse = postCode(preAuthorizedCode); + assertEquals("Pre-authorized grant should be forbidden when verifiable credentials are disabled.", + HttpStatus.SC_FORBIDDEN, accessTokenResponse.getStatusCode()); + } finally { + // Re-enable verifiable credentials so other tests see the default behavior + RealmRepresentation realmRepReset = adminClient.realm(TEST_REALM_NAME).toRepresentation(); + realmRepReset.setVerifiableCredentialsEnabled(true); + adminClient.realm(TEST_REALM_NAME).update(realmRepReset); + } + } + private AccessTokenResponse postCode(String preAuthorizedCode) throws Exception { HttpPost post = new HttpPost(getTokenEndpoint()); List parameters = new LinkedList<>(); @@ -123,6 +150,8 @@ public class PreAuthorizedGrantTest extends AbstractTestRealmKeycloakTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); + UserRepresentation user = UserBuilder.create() .id("user-id") .username("john") diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCIWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCIWellKnownProviderTest.java index 8ffa93bec0d..90293f88d0a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCIWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCIWellKnownProviderTest.java @@ -61,6 +61,8 @@ public class OID4VCIWellKnownProviderTest extends OID4VCTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); + if (testRealm.getComponents() != null) { testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaEncKeyProvider(RSA_OAEP_256, "enc-key-oaep256", 100)); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/CredentialBuilderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/CredentialBuilderTest.java index 3824761ce7a..197729ce33e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/CredentialBuilderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/credentialbuilder/CredentialBuilderTest.java @@ -46,5 +46,6 @@ public abstract class CredentialBuilderTest extends OID4VCTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java index d64b11136f4..53ad4940c5b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/mappers/OID4VCTargetRoleMapperTest.java @@ -82,6 +82,7 @@ public class OID4VCTargetRoleMapperTest extends OID4VCTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); ClientRepresentation newClient = new ClientRepresentation(); newClient.setClientId("newClient"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JWTVCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JWTVCIssuerWellKnownProviderTest.java index b9473fca11b..dd8385d155b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JWTVCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JWTVCIssuerWellKnownProviderTest.java @@ -69,6 +69,8 @@ import static org.junit.Assert.assertTrue; @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); + if (testRealm.getComponents() != null) { testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); } else { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtCredentialSignerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtCredentialSignerTest.java index 20130048f82..59ad4d23bc8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtCredentialSignerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JwtCredentialSignerTest.java @@ -243,6 +243,8 @@ public class JwtCredentialSignerTest extends OID4VCTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); + if (testRealm.getComponents() != null) { testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(rsaKey)); } else { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDCredentialSignerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDCredentialSignerTest.java index 70b435f3257..dbebc0fd9bf 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDCredentialSignerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/LDCredentialSignerTest.java @@ -211,6 +211,8 @@ public class LDCredentialSignerTest extends OID4VCTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); + if (testRealm.getComponents() != null) { testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getEdDSAKeyProvider()); } else { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index a9d67708a89..74bcf412343 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -658,6 +658,8 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); + if (testRealm.getComponents() == null) { testRealm.setComponents(new MultivaluedHashMap<>()); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 2adb7c42ff9..35a6abc4802 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -771,6 +771,33 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest })); } + /** + * When verifiable credentials are disabled for the realm, the OID4VCI well-known + * endpoint must not be exposed. + */ + @Test + public void testWellKnownEndpointDisabledWhenVerifiableCredentialsOff() { + try (Client client = AdminClientUtil.createResteasyClient()) { + // Disable verifiable credentials for the test realm + RealmRepresentation realmRep = adminClient.realm(TEST_REALM_NAME).toRepresentation(); + realmRep.setVerifiableCredentialsEnabled(false); + adminClient.realm(TEST_REALM_NAME).update(realmRep); + + String metadataUrl = getRealmMetadataPath(TEST_REALM_NAME); + WebTarget target = client.target(metadataUrl); + + try (Response response = target.request().get()) { + assertEquals("OID4VCI well-known endpoint should be hidden when verifiable credentials are disabled", + Response.Status.NOT_FOUND.getStatusCode(), response.getStatus()); + } + } finally { + // Re-enable verifiable credentials to avoid side effects on other tests + RealmRepresentation realmRep = adminClient.realm(TEST_REALM_NAME).toRepresentation(); + realmRep.setVerifiableCredentialsEnabled(true); + adminClient.realm(TEST_REALM_NAME).update(realmRep); + } + } + @Test public void testBatchCredentialIssuanceValidation() { KeycloakTestingClient testingClient = this.testingClient; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointDisabledTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointDisabledTest.java index d3e32af72c4..2dcf9163c2b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointDisabledTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointDisabledTest.java @@ -1,19 +1,27 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; +import java.util.List; import java.util.function.Consumer; import jakarta.ws.rs.core.Response; +import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.testsuite.Assert; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.Before; import org.junit.Test; +import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId; +import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId; + import static org.junit.Assert.assertEquals; /** @@ -26,6 +34,50 @@ public class OID4VCJWTIssuerEndpointDisabledTest extends OID4VCIssuerEndpointTes return false; } + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + super.configureTestRealm(testRealm); + testRealm.setVerifiableCredentialsEnabled(false); + } + + /** + * Override setup to skip creating oid4vc client scopes when verifiable credentials is disabled. + * The parent setup() tries to create client scopes with oid4vc protocol, which will fail + * with the new validation that prevents creating oid4vc scopes when VC is disabled. + */ + @Override + @Before + public void setup() { + CryptoIntegration.init(this.getClass().getClassLoader()); + httpClient = HttpClientBuilder.create().build(); + client = testRealm().clients().findByClientId(clientId).get(0); + namedClient = testRealm().clients().findByClientId(namedClientId).get(0); + + List.of(client, namedClient).forEach(client -> { + String clientId = client.getClientId(); + // Enable OID4VCI for the client by default, but allow tests to override + setClientOid4vciEnabled(clientId, shouldEnableOid4vci()); + }); + } + + /** + * When verifiable credentials are disabled at the realm level, OID4VCI endpoints + * must reject calls regardless of the client configuration. + */ + @Test + public void testRealmDisabledEndpoints() { + testWithBearerToken(token -> testingClient.server(TEST_REALM_NAME).run((session) -> { + AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); + authenticator.setTokenString(token); + OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); + + // Nonce endpoint should be forbidden when OID4VCI is disabled for the realm + CorsErrorResponseException nonceException = Assert.assertThrows(CorsErrorResponseException.class, issuerEndpoint::getCNonce); + assertEquals("Realm-disabled OID4VCI should return 403 for nonce endpoint", + Response.Status.FORBIDDEN.getStatusCode(), nonceException.getResponse().getStatus()); + })); + } + @Test public void testClientNotEnabled() { testWithBearerToken(token -> testingClient.server(TEST_REALM_NAME).run((session) -> { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointDisabledTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointDisabledTest.java index ec002434c87..65a4c909306 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointDisabledTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCSdJwtIssuingEndpointDisabledTest.java @@ -1,19 +1,27 @@ package org.keycloak.testsuite.oid4vc.issuance.signing; +import java.util.List; import java.util.function.Consumer; import jakarta.ws.rs.core.Response; +import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.model.CredentialRequest; +import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.testsuite.Assert; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.Before; import org.junit.Test; +import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId; +import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId; + import static org.junit.Assert.assertEquals; /** @@ -26,6 +34,35 @@ public class OID4VCSdJwtIssuingEndpointDisabledTest extends OID4VCIssuerEndpoint return false; } + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + super.configureTestRealm(testRealm); + testRealm.setVerifiableCredentialsEnabled(false); + } + + /** + * Override setup to skip creating oid4vc client scopes when verifiable credentials is disabled. + * The parent setup() tries to create client scopes with oid4vc protocol, which will fail + * with the new validation that prevents creating oid4vc scopes when VC is disabled. + */ + @Override + @Before + public void setup() { + CryptoIntegration.init(this.getClass().getClassLoader()); + httpClient = HttpClientBuilder.create().build(); + client = testRealm().clients().findByClientId(clientId).get(0); + namedClient = testRealm().clients().findByClientId(namedClientId).get(0); + + // Skip creating oid4vc client scopes when VC is disabled - they cannot be created + // and are not needed for these tests which verify that endpoints reject calls + + List.of(client, namedClient).forEach(client -> { + String clientId = client.getClientId(); + // Enable OID4VCI for the client by default, but allow tests to override + setClientOid4vciEnabled(clientId, shouldEnableOid4vci()); + }); + } + @Test public void testClientNotEnabled() { testWithBearerToken(token -> testingClient.server(TEST_REALM_NAME).run((session) -> { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java index 5c7b2f533ad..a106c09dbbb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/SdJwtCredentialSignerTest.java @@ -369,6 +369,8 @@ public class SdJwtCredentialSignerTest extends OID4VCTest { @Override public void configureTestRealm(RealmRepresentation testRealm) { + testRealm.setVerifiableCredentialsEnabled(true); + if (testRealm.getComponents() != null) { testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(rsaKey)); } else {