mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-06 06:49:53 -06:00
[OID4VCI] OpenID for Verifiable Credentials support in client settings (#39385)
Closes #32967 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com> Co-authored-by: Francis Pouatcha <francis.pouatcha@adorsys.com>
This commit is contained in:
committed by
GitHub
parent
cabd7cd474
commit
a3441689e9
@@ -3501,3 +3501,7 @@ givenNameClaim=Given name Claim
|
||||
givenNameClaimHelp=The name of the claim from the JSON document returned by the user profile endpoint representing the user's given name. If not provided, defaults to `given_name`.
|
||||
familyNameClaim=Family name Claim
|
||||
familyNameClaimHelp=The name of the claim from the JSON document returned by the user profile endpoint representing the user's family name. If not provided, defaults to `family_name`.
|
||||
openIdVerifiableCredentials=OpenID for Verifiable Credentials
|
||||
openIdVerifiableCredentialsHelp=This section is used to configure settings related to OpenID for Verifiable Credential Issuance (OID4VCI).
|
||||
oid4vciEnabled=Enable OID4VCI
|
||||
oid4vciEnabledHelp=Enable this option to allow the client to request verifiable credentials from Keycloak's OID4VCI credential endpoint.
|
||||
|
||||
@@ -14,6 +14,11 @@ import { ClusteringPanel } from "./advanced/ClusteringPanel";
|
||||
import { FineGrainOpenIdConnect } from "./advanced/FineGrainOpenIdConnect";
|
||||
import { FineGrainSamlEndpointConfig } from "./advanced/FineGrainSamlEndpointConfig";
|
||||
import { OpenIdConnectCompatibilityModes } from "./advanced/OpenIdConnectCompatibilityModes";
|
||||
import { OpenIdVerifiableCredentials } from "./advanced/OpenIdVerifiableCredentials";
|
||||
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
|
||||
|
||||
const PROTOCOL_OIDC = "openid-connect";
|
||||
const PROTOCOL_OID4VC = "oid4vc";
|
||||
|
||||
export const parseResult = (
|
||||
result: GlobalRequestResult,
|
||||
@@ -50,7 +55,7 @@ export type AdvancedProps = {
|
||||
|
||||
export const AdvancedTab = ({ save, client }: AdvancedProps) => {
|
||||
const { t } = useTranslation();
|
||||
const openIdConnect = "openid-connect";
|
||||
const isFeatureEnabled = useIsFeatureEnabled();
|
||||
|
||||
const { setValue } = useFormContext();
|
||||
const {
|
||||
@@ -81,7 +86,7 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
|
||||
},
|
||||
{
|
||||
title: t("fineGrainOpenIdConnectConfiguration"),
|
||||
isHidden: protocol !== openIdConnect,
|
||||
isHidden: protocol !== PROTOCOL_OIDC,
|
||||
panel: (
|
||||
<>
|
||||
<Text className="pf-v5-u-pb-lg">
|
||||
@@ -118,7 +123,7 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
|
||||
},
|
||||
{
|
||||
title: t("openIdConnectCompatibilityModes"),
|
||||
isHidden: protocol !== openIdConnect,
|
||||
isHidden: protocol !== PROTOCOL_OIDC,
|
||||
panel: (
|
||||
<>
|
||||
<Text className="pf-v5-u-pb-lg">
|
||||
@@ -140,7 +145,7 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
|
||||
},
|
||||
{
|
||||
title: t("fineGrainSamlEndpointConfig"),
|
||||
isHidden: protocol === openIdConnect,
|
||||
isHidden: protocol === PROTOCOL_OIDC,
|
||||
panel: (
|
||||
<>
|
||||
<Text className="pf-v5-u-pb-lg">
|
||||
@@ -199,6 +204,24 @@ export const AdvancedTab = ({ save, client }: AdvancedProps) => {
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("openIdVerifiableCredentials"),
|
||||
isHidden:
|
||||
(protocol !== PROTOCOL_OIDC && protocol !== PROTOCOL_OID4VC) ||
|
||||
!isFeatureEnabled(Feature.OpenId4VCI),
|
||||
panel: (
|
||||
<>
|
||||
<Text className="pf-v5-u-pb-lg">
|
||||
{t("openIdVerifiableCredentialsHelp")}
|
||||
</Text>
|
||||
<OpenIdVerifiableCredentials
|
||||
client={client}
|
||||
save={save}
|
||||
reset={() => resetFields(["oid4vci.enabled"])}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("authenticationOverrides"),
|
||||
panel: (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Button, ActionGroup } from "@patternfly/react-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormAccess } from "../../components/form/FormAccess";
|
||||
import { convertAttributeNameToForm } from "../../util";
|
||||
import { FormFields, SaveOptions } from "../ClientDetails";
|
||||
import ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
import { DefaultSwitchControl } from "../../components/SwitchControl";
|
||||
|
||||
type OpenIdVerifiableCredentialsProps = {
|
||||
client: ClientRepresentation;
|
||||
save: (options?: SaveOptions) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const OpenIdVerifiableCredentials = ({
|
||||
save,
|
||||
reset,
|
||||
}: OpenIdVerifiableCredentialsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<FormAccess role="manage-clients" isHorizontal>
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.oid4vci.enabled",
|
||||
)}
|
||||
label={t("oid4vciEnabled")}
|
||||
labelIcon={t("oid4vciEnabledHelp")}
|
||||
stringify
|
||||
/>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
variant="secondary"
|
||||
id="oid4vciSave"
|
||||
data-testid="oid4vciSave"
|
||||
onClick={() => save()}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
id="oid4vciRevert"
|
||||
data-testid="oid4vciRevert"
|
||||
variant="link"
|
||||
onClick={reset}
|
||||
>
|
||||
{t("revert")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormAccess>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import adminClient from "../utils/AdminClient";
|
||||
import { login } from "../utils/login";
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
switchOffExcludeSessionStateSwitch,
|
||||
saveAuthFlowOverride,
|
||||
revertAuthFlowOverride,
|
||||
saveOid4vci,
|
||||
revertOid4vci,
|
||||
assertOid4vciEnabled,
|
||||
switchOid4vciEnabled,
|
||||
} from "./advanced";
|
||||
|
||||
test.describe("Advanced tab test", () => {
|
||||
@@ -129,3 +133,55 @@ test.describe("Client Offline Session Max", () => {
|
||||
await assertTokenLifespanClientOfflineSessionMaxVisible(page, true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("OpenID for Verifiable Credentials", () => {
|
||||
const realmName = `oid4vci-test-${uuidv4()}`;
|
||||
const clientIdOpenIdConnect = `client-oidc-${uuidv4()}`;
|
||||
test.beforeAll(async () => {
|
||||
await adminClient.createRealm(realmName, {});
|
||||
await adminClient.createClient({
|
||||
clientId: clientIdOpenIdConnect,
|
||||
realm: realmName,
|
||||
protocol: "openid-connect",
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(() => adminClient.deleteRealm(realmName));
|
||||
|
||||
test.describe("with protocol openid-connect", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
await goToRealm(page, realmName);
|
||||
await goToClients(page);
|
||||
await clickTableRowItem(page, clientIdOpenIdConnect);
|
||||
|
||||
await page.waitForSelector('[data-testid="advancedTab"]', {
|
||||
state: "visible",
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByTestId("advancedTab").click();
|
||||
});
|
||||
|
||||
test("should handle OID4VC section visibility based on feature flag", async ({
|
||||
page,
|
||||
}) => {
|
||||
const toggleSwitch = page.locator("#attributes\\.oid4vci🍺enabled");
|
||||
|
||||
const isVisible = await toggleSwitch.isVisible();
|
||||
|
||||
if (isVisible) {
|
||||
await toggleSwitch.scrollIntoViewIfNeeded();
|
||||
await assertOid4vciEnabled(page, false);
|
||||
await switchOid4vciEnabled(page, true);
|
||||
await saveOid4vci(page);
|
||||
await assertOid4vciEnabled(page, true);
|
||||
await switchOid4vciEnabled(page, false);
|
||||
await assertOid4vciEnabled(page, false);
|
||||
await revertOid4vci(page);
|
||||
await assertOid4vciEnabled(page, true);
|
||||
} else {
|
||||
await expect(toggleSwitch).toBeHidden();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ const oAuthMutualSwitch =
|
||||
"#attributes\\.tls🍺client🍺certificate🍺bound🍺access🍺tokens";
|
||||
const pushedAuthorizationRequestRequiredSwitch =
|
||||
"#attributes\\.require🍺pushed🍺authorization🍺requests";
|
||||
const oid4vciEnabledSwitch = "#attributes\\.oid4vci🍺enabled";
|
||||
|
||||
export async function clickAdvancedSwitches(page: Page, toggle = true) {
|
||||
if (toggle) {
|
||||
@@ -164,3 +165,27 @@ export async function saveAuthFlowOverride(page: Page) {
|
||||
export async function revertAuthFlowOverride(page: Page) {
|
||||
await page.getByTestId("OIDCAuthFlowOverrideRevert").click();
|
||||
}
|
||||
|
||||
export async function switchOid4vciEnabled(page: Page, enable: boolean) {
|
||||
if (enable) {
|
||||
await switchOn(page, oid4vciEnabledSwitch);
|
||||
} else {
|
||||
await switchOff(page, oid4vciEnabledSwitch);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertOid4vciEnabled(page: Page, enabled: boolean) {
|
||||
if (enabled) {
|
||||
await expect(page.locator(oid4vciEnabledSwitch)).toBeChecked();
|
||||
} else {
|
||||
await expect(page.locator(oid4vciEnabledSwitch)).not.toBeChecked();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveOid4vci(page: Page) {
|
||||
await page.getByTestId("oid4vciSave").click();
|
||||
}
|
||||
|
||||
export async function revertOid4vci(page: Page) {
|
||||
await page.getByTestId("oid4vciRevert").click();
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.util.SecretGenerator;
|
||||
import org.keycloak.component.ComponentFactory;
|
||||
import org.keycloak.component.ComponentModel;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
@@ -123,6 +126,9 @@ public class OID4VCIssuerEndpoint {
|
||||
// lifespan of the preAuthorizedCodes in seconds
|
||||
private final int preAuthorizedCodeLifeSpan;
|
||||
|
||||
// constant for the OID4VCI enabled attribute key
|
||||
private static final String OID4VCI_ENABLED_ATTRIBUTE_KEY = "oid4vci.enabled";
|
||||
|
||||
/**
|
||||
* Credential builders are responsible for initiating the production of
|
||||
* credentials in a specific format. Their output is an appropriate credential
|
||||
@@ -174,6 +180,34 @@ public class OID4VCIssuerEndpoint {
|
||||
credentialBuilder -> credentialBuilder));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether the authenticated client is enabled for OID4VCI features.
|
||||
* <p>
|
||||
* If the client is not enabled, this method logs the status and throws a
|
||||
* {@link CorsErrorResponseException} with an appropriate error message.
|
||||
* </p>
|
||||
*
|
||||
* @throws CorsErrorResponseException if the client is not enabled for OID4VCI.
|
||||
*/
|
||||
private void checkClientEnabled() {
|
||||
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
|
||||
ClientModel client = clientSession.getClient();
|
||||
|
||||
boolean oid4vciEnabled = Boolean.parseBoolean(client.getAttributes().get(OID4VCI_ENABLED_ATTRIBUTE_KEY));
|
||||
|
||||
if (!oid4vciEnabled) {
|
||||
LOGGER.debugf("Client '%s' is not enabled for OID4VCI features.", client.getClientId());
|
||||
throw new CorsErrorResponseException(
|
||||
cors,
|
||||
Errors.INVALID_CLIENT,
|
||||
"Client not enabled for OID4VCI",
|
||||
Response.Status.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
LOGGER.debugf("Client '%s' is enabled for OID4VCI features.", client.getClientId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique notification ID for use in CredentialResponse.
|
||||
*
|
||||
@@ -182,7 +216,7 @@ public class OID4VCIssuerEndpoint {
|
||||
private String generateNotificationId() {
|
||||
return SecretGenerator.getInstance().randomString();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* the OpenId4VCI nonce-endpoint
|
||||
*
|
||||
@@ -213,6 +247,15 @@ public class OID4VCIssuerEndpoint {
|
||||
|
||||
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
|
||||
|
||||
// Initialize CORS configuration and validate if the client is enabled for OID4VCI
|
||||
cors = Cors.builder()
|
||||
.auth()
|
||||
.allowedMethods("GET")
|
||||
.auth()
|
||||
.exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
|
||||
|
||||
checkClientEnabled();
|
||||
|
||||
Map<String, SupportedCredentialConfiguration> credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
|
||||
LOGGER.debugf("Get an offer for %s", vcId);
|
||||
if (!credentialsMap.containsKey(vcId)) {
|
||||
@@ -335,6 +378,9 @@ public class OID4VCIssuerEndpoint {
|
||||
// do first to fail fast on auth
|
||||
AuthenticationManager.AuthResult authResult = getAuthResult();
|
||||
|
||||
// checkClientEnabled call after authentication
|
||||
checkClientEnabled();
|
||||
|
||||
// Both credential_configuration_id and credential_identifier are optional.
|
||||
// If the credential_configuration_id is present, credential_identifier can't be present.
|
||||
// But this implementation will tolerate the presence of both, waiting for clarity in specifications.
|
||||
@@ -345,7 +391,7 @@ public class OID4VCIssuerEndpoint {
|
||||
// Check if at least one of both is available.
|
||||
if (requestedCredentialConfigurationId == null && requestedCredentialIdentifier == null) {
|
||||
LOGGER.debugf("Missing both credential_configuration_id and credential_identifier. " +
|
||||
"At least one must be specified.");
|
||||
"At least one must be specified.");
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID));
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.utils.OAuth2Code;
|
||||
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
@@ -116,6 +117,10 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
protected CloseableHttpClient httpClient;
|
||||
protected ClientRepresentation client;
|
||||
|
||||
protected boolean shouldEnableOid4vci() {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected static String prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) {
|
||||
AuthenticationManager.AuthResult authResult = authenticator.authenticate();
|
||||
UserSessionModel userSessionModel = authResult.getSession();
|
||||
@@ -124,14 +129,14 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
String codeId = SecretGenerator.getInstance().randomString();
|
||||
String nonce = SecretGenerator.getInstance().randomString();
|
||||
OAuth2Code oAuth2Code = new OAuth2Code(codeId,
|
||||
Time.currentTime() + 6000,
|
||||
nonce,
|
||||
CREDENTIAL_OFFER_URI_CODE_SCOPE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
authenticatedClientSessionModel.getUserSession().getId());
|
||||
Time.currentTime() + 6000,
|
||||
nonce,
|
||||
CREDENTIAL_OFFER_URI_CODE_SCOPE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
authenticatedClientSessionModel.getUserSession().getId());
|
||||
|
||||
String oauthCode = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oAuth2Code);
|
||||
|
||||
@@ -149,7 +154,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
session,
|
||||
authenticator,
|
||||
Map.of(jwtCredentialBuilder.getSupportedFormat(), jwtCredentialBuilder,
|
||||
sdJwtCredentialBuilder.getSupportedFormat(), sdJwtCredentialBuilder)
|
||||
sdJwtCredentialBuilder.getSupportedFormat(), sdJwtCredentialBuilder)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,31 +179,34 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
|
||||
// Register the optional client scopes
|
||||
sdJwtTypeCredentialClientScope = registerOptionalClientScope(sdJwtTypeCredentialScopeName,
|
||||
null,
|
||||
sdJwtTypeCredentialConfigurationIdName,
|
||||
sdJwtTypeCredentialScopeName,
|
||||
sdJwtCredentialVct,
|
||||
Format.SD_JWT_VC,
|
||||
null);
|
||||
null,
|
||||
sdJwtTypeCredentialConfigurationIdName,
|
||||
sdJwtTypeCredentialScopeName,
|
||||
sdJwtCredentialVct,
|
||||
Format.SD_JWT_VC,
|
||||
null);
|
||||
jwtTypeCredentialClientScope = registerOptionalClientScope(jwtTypeCredentialScopeName,
|
||||
TEST_DID.toString(),
|
||||
jwtTypeCredentialConfigurationIdName,
|
||||
jwtTypeCredentialScopeName,
|
||||
null,
|
||||
Format.JWT_VC,
|
||||
TEST_CREDENTIAL_MAPPERS_FILE);
|
||||
TEST_DID.toString(),
|
||||
jwtTypeCredentialConfigurationIdName,
|
||||
jwtTypeCredentialScopeName,
|
||||
null,
|
||||
Format.JWT_VC,
|
||||
TEST_CREDENTIAL_MAPPERS_FILE);
|
||||
minimalJwtTypeCredentialClientScope = registerOptionalClientScope("vc-with-minimal-config",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
// Assign the registered optional client scopes to the client
|
||||
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), client.getClientId());
|
||||
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), client.getClientId());
|
||||
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), client.getClientId());
|
||||
|
||||
// Enable OID4VCI for the client by default, but allow tests to override
|
||||
setClientOid4vciEnabled(clientId, shouldEnableOid4vci());
|
||||
}
|
||||
|
||||
protected String getBearerToken(OAuthClient oAuthClient) {
|
||||
@@ -217,7 +225,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
oAuthClient.scope(credentialScopeName);
|
||||
}
|
||||
AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john",
|
||||
"password");
|
||||
"password");
|
||||
return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode()).getAccessToken();
|
||||
}
|
||||
|
||||
@@ -251,7 +259,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
|
||||
Map<String, String> attributes =
|
||||
new HashMap<>(Map.of(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "true",
|
||||
CredentialScopeModel.EXPIRY_IN_SECONDS, "15"));
|
||||
CredentialScopeModel.EXPIRY_IN_SECONDS, "15"));
|
||||
BiConsumer<String, String> addAttribute = (attributeName, value) -> {
|
||||
if (value != null) {
|
||||
attributes.put(attributeName, value);
|
||||
@@ -266,9 +274,9 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
String vcDisplay;
|
||||
try {
|
||||
vcDisplay = JsonSerialization.writeValueAsString(List.of(new DisplayObject().setName(credentialConfigurationId)
|
||||
.setLocale("en-EN"),
|
||||
new DisplayObject().setName(credentialConfigurationId)
|
||||
.setLocale("de-DE")));
|
||||
.setLocale("en-EN"),
|
||||
new DisplayObject().setName(credentialConfigurationId)
|
||||
.setLocale("de-DE")));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
@@ -288,8 +296,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
if (protocolMapperReferenceFile == null) {
|
||||
protocolMappers = getProtocolMappers(scopeName);
|
||||
addProtocolMappersToClientScope(clientScope, protocolMappers);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
protocolMappers = resolveProtocolMappers(protocolMapperReferenceFile);
|
||||
protocolMappers.add(getStaticClaimMapper(scopeName));
|
||||
addProtocolMappersToClientScope(clientScope, protocolMappers);
|
||||
@@ -304,7 +311,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
}
|
||||
try (InputStream inputStream = getClass().getResourceAsStream(protocolMapperReferenceFile)) {
|
||||
return JsonSerialization.mapper.readValue(inputStream,
|
||||
ClientScopeRepresentation.class).getProtocolMappers();
|
||||
ClientScopeRepresentation.class).getProtocolMappers();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
@@ -320,6 +327,17 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
user.logout();
|
||||
}
|
||||
|
||||
void setClientOid4vciEnabled(String clientId, boolean enabled) {
|
||||
ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0);
|
||||
ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId());
|
||||
|
||||
Map<String, String> attributes = new HashMap<>(clientRepresentation.getAttributes() != null ? clientRepresentation.getAttributes() : Map.of());
|
||||
attributes.put("oid4vci.enabled", String.valueOf(enabled));
|
||||
clientRepresentation.setAttributes(attributes);
|
||||
|
||||
clientResource.update(clientRepresentation);
|
||||
}
|
||||
|
||||
// Tests the AuthZCode complete flow without scope from
|
||||
// 1. Get authorization code without scope specified by wallet
|
||||
// 2. Using the code to get access token
|
||||
@@ -336,8 +354,8 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
try (Client client = AdminClientUtil.createResteasyClient()) {
|
||||
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
|
||||
URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder)
|
||||
.build(TEST_REALM_NAME,
|
||||
OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
|
||||
.build(TEST_REALM_NAME,
|
||||
OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID);
|
||||
WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri);
|
||||
|
||||
// 1. Get authoriZation code without scope specified by wallet
|
||||
@@ -347,7 +365,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
// 3. Get the credential configuration id from issuer metadata at .wellKnown
|
||||
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
|
||||
CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class),
|
||||
CredentialIssuer.class);
|
||||
CredentialIssuer.class);
|
||||
assertEquals(200, discoveryResponse.getStatus());
|
||||
assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer());
|
||||
assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint());
|
||||
@@ -360,17 +378,17 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
|
||||
CredentialRequest request = new CredentialRequest();
|
||||
request.setCredentialConfigurationId(oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getId());
|
||||
.get(testCredentialConfigurationId)
|
||||
.getId());
|
||||
|
||||
assertEquals(testFormat,
|
||||
oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getFormat());
|
||||
oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getFormat());
|
||||
assertEquals(testCredentialConfigurationId,
|
||||
oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getId());
|
||||
oid4vciIssuerConfig.getCredentialsSupported()
|
||||
.get(testCredentialConfigurationId)
|
||||
.getId());
|
||||
|
||||
c.accept(Map.of(
|
||||
"accessToken", token,
|
||||
@@ -401,7 +419,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
request.setCredentialConfigurationId(offeredCredential.getId());
|
||||
|
||||
StringEntity stringEntity = new StringEntity(JsonSerialization.writeValueAsString(request),
|
||||
ContentType.APPLICATION_JSON);
|
||||
ContentType.APPLICATION_JSON);
|
||||
|
||||
HttpPost postCredential = new HttpPost(credentialEndpoint);
|
||||
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
@@ -440,9 +458,9 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
|
||||
// Find existing client representation
|
||||
ClientRepresentation existingClient = testRealm.getClients().stream()
|
||||
.filter(client -> client.getClientId().equals(clientId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
|
||||
.filter(client -> client.getClientId().equals(clientId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
|
||||
|
||||
// Add role to existing client
|
||||
if (testRealm.getRoles() != null) {
|
||||
@@ -456,15 +474,14 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
return mergedRoles;
|
||||
}
|
||||
);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
testRealm.getRoles()
|
||||
.setClient(Map.of(existingClient.getClientId(),
|
||||
List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
|
||||
.setClient(Map.of(existingClient.getClientId(),
|
||||
List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
|
||||
}
|
||||
|
||||
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
|
||||
.orElse(new ArrayList<>());
|
||||
.orElse(new ArrayList<>());
|
||||
realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"))));
|
||||
testRealm.setUsers(realmUsers);
|
||||
}
|
||||
@@ -495,7 +512,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
assertNotNull("The first credential in the array should not be null.", credentialObj);
|
||||
|
||||
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(),
|
||||
JsonWebToken.class).getToken();
|
||||
JsonWebToken.class).getToken();
|
||||
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
|
||||
VerifiableCredential credential = JsonSerialization.mapper.convertValue(jsonWebToken.getOtherClaims().get(
|
||||
"vc"), VerifiableCredential.class);
|
||||
@@ -503,12 +520,12 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
|
||||
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
|
||||
assertTrue("The static claim should be set.",
|
||||
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
|
||||
credential.getCredentialSubject().getClaims().containsKey("scope-name"));
|
||||
assertEquals("The static claim should be set.",
|
||||
clientScope.getName(),
|
||||
credential.getCredentialSubject().getClaims().get("scope-name"));
|
||||
clientScope.getName(),
|
||||
credential.getCredentialSubject().getClaims().get("scope-name"));
|
||||
assertFalse("Only mappers supported for the requested type should have been evaluated.",
|
||||
credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
|
||||
credential.getCredentialSubject().getClaims().containsKey("AnotherCredentialType"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
import org.keycloak.testsuite.Assert;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* Tests for OID4VCIssuerEndpoint with OID4VCI disabled.
|
||||
*/
|
||||
public class OID4VCJWTIssuerEndpointDisabledTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Override
|
||||
protected boolean shouldEnableOid4vci() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClientNotEnabled() {
|
||||
testWithBearerToken(token -> testingClient.server(TEST_REALM_NAME).run((session) -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
// Test getCredentialOfferURI
|
||||
CorsErrorResponseException offerUriException = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
issuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
|
||||
);
|
||||
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
|
||||
Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus());
|
||||
|
||||
// Test requestCredential
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialIdentifier(jwtTypeCredentialScopeName);
|
||||
CorsErrorResponseException requestException = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
issuerEndpoint.requestCredential(credentialRequest)
|
||||
);
|
||||
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
|
||||
Response.Status.FORBIDDEN.getStatusCode(), requestException.getResponse().getStatus());
|
||||
}));
|
||||
}
|
||||
|
||||
private void testWithBearerToken(Consumer<String> testLogic) {
|
||||
String token = getBearerToken(oauth);
|
||||
testLogic.accept(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||
import org.keycloak.protocol.oid4vc.model.Format;
|
||||
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
import org.keycloak.testsuite.Assert;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* Tests for OID4VCIssuerEndpoint with OID4VCI disabled for SD-JWT format
|
||||
*/
|
||||
public class OID4VCSdJwtIssuingEndpointDisabledTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
@Override
|
||||
protected boolean shouldEnableOid4vci() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClientNotEnabled() {
|
||||
testWithBearerToken(token -> testingClient.server(TEST_REALM_NAME).run((session) -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
// Test getCredentialOfferURI
|
||||
CorsErrorResponseException offerUriException = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
issuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
|
||||
);
|
||||
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
|
||||
Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus());
|
||||
|
||||
// Test requestCredential
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialConfigurationId(sdJwtTypeCredentialConfigurationIdName);
|
||||
CorsErrorResponseException requestException = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
issuerEndpoint.requestCredential(credentialRequest)
|
||||
);
|
||||
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
|
||||
Response.Status.FORBIDDEN.getStatusCode(), requestException.getResponse().getStatus());
|
||||
}));
|
||||
}
|
||||
|
||||
private void testWithBearerToken(Consumer<String> testLogic) {
|
||||
String token = getBearerToken(oauth);
|
||||
testLogic.accept(token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user