[OID4VCI] Extend realm UI configuration by OID4VCI attributes (#41757)

* Extend realm UI configuration by OID4VCI attributes

Closes #39533

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: adjust tests in oid4vci-attributes.spec.ts based on feature availability

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: directly check OID4VCI feature from server info in tests before running

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: address comment(s) by @IngridPuppet

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: skip tests when oid4vci feature is not enabled

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: move oid4vc realm attributes setting to token tab

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: refactor tokens tab and restructure oid4vci attributes tests

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: apply review comments by @jonkoops

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: refactored tests to use createTestBed()

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: apply changes by @jonkoops

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: address review comments

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: change test format

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: enable oid4vc feature in worfklows

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* Update .github/workflows/stability-js-ci.yml

Co-authored-by: Jon Koops <jonkoops@gmail.com>
Signed-off-by: forkimenjeckayang <104195313+forkimenjeckayang@users.noreply.github.com>

* fix: included required fields in tokens.ts as fix for CI tests

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

* update: address more review comments

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>

---------

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
Signed-off-by: forkimenjeckayang <104195313+forkimenjeckayang@users.noreply.github.com>
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
forkimenjeckayang
2025-09-18 18:30:34 +01:00
committed by GitHub
parent 1abddbf64c
commit f3bd3dcd2e
7 changed files with 306 additions and 39 deletions

View File

@@ -230,7 +230,7 @@ jobs:
- name: Start Keycloak server
run: |
tar xfvz keycloak-999.0.0-SNAPSHOT.tar.gz
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users,spiffe &> ~/server.log &
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users,spiffe,oid4vc-vci &> ~/server.log &
env:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin

View File

@@ -119,7 +119,7 @@ jobs:
- name: Start Keycloak server
run: |
tar xfvz keycloak-999.0.0-SNAPSHOT.tar.gz
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users &> ~/server.log &
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users,spiffe,oid4vc-vci &> ~/server.log &
env:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin

View File

@@ -3556,3 +3556,9 @@ invalid_request_object=Invalid request object
request_not_supported=Request not supported
request_uri_not_supported=Request uri not supported
registration_not_supported=Registration not supported
oid4vciAttributes=OID4VCI attributes
oid4vciNonceLifetime=OID4VCI Nonce Lifetime
oid4vciNonceLifetimeHelp=The lifetime of the OID4VCI nonce in seconds.
preAuthorizedCodeLifespan=Pre-Authorized Code Lifespan
preAuthorizedCodeLifespanHelp=The lifespan of the pre-authorized code in seconds.
oid4vciFormValidationError=Please ensure the OID4VCI attribute fields are filled with values 30 seconds or greater.

View File

@@ -1,19 +1,18 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import {
FormPanel,
HelpItem,
KeycloakSelect,
SelectVariant,
ScrollForm,
useAlerts,
} from "@keycloak/keycloak-ui-shared";
import {
ActionGroup,
Button,
AlertVariant,
FormGroup,
FormHelperText,
HelperText,
HelperTextItem,
NumberInput,
PageSection,
SelectOption,
Switch,
Text,
@@ -24,10 +23,13 @@ import { useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormAccess } from "../components/form/FormAccess";
import { FixedButtonsGroup } from "../components/form/FixedButtonGroup";
import { convertAttributeNameToForm } from "../util";
import {
TimeSelector,
toHumanFormat,
} from "../components/time-selector/TimeSelector";
import { TimeSelectorControl } from "../components/time-selector/TimeSelectorControl";
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { useWhoAmI } from "../context/whoami/WhoAmI";
import { beerify, sortProviders } from "../util";
@@ -45,6 +47,7 @@ export const RealmSettingsTokensTab = ({
save,
}: RealmSettingsSessionsTabProps) => {
const { t } = useTranslation();
const { addAlert } = useAlerts();
const serverInfo = useServerInfo();
const isFeatureEnabled = useIsFeatureEnabled();
const { whoAmI } = useWhoAmI();
@@ -59,6 +62,11 @@ export const RealmSettingsTokensTab = ({
const { control, register, reset, formState, handleSubmit } =
useFormContext<RealmRepresentation>();
// Show a global error notification if validation fails
const onError = () => {
addAlert(t("oid4vciFormValidationError"), AlertVariant.danger);
};
const offlineSessionMaxEnabled = useWatch({
control,
name: "offlineSessionMaxLifespanEnabled",
@@ -77,9 +85,10 @@ export const RealmSettingsTokensTab = ({
defaultValue: false,
});
return (
<PageSection variant="light">
<FormPanel title={t("general")} className="kc-sso-session-template">
const sections = [
{
title: t("general"),
panel: (
<FormAccess
isHorizontal
role="manage-realm"
@@ -236,11 +245,11 @@ export const RealmSettingsTokensTab = ({
</>
)}
</FormAccess>
</FormPanel>
<FormPanel
title={t("refreshTokens")}
className="kc-client-session-template"
>
),
},
{
title: t("refreshTokens"),
panel: (
<FormAccess
isHorizontal
role="manage-realm"
@@ -308,11 +317,11 @@ export const RealmSettingsTokensTab = ({
</FormGroup>
)}
</FormAccess>
</FormPanel>
<FormPanel
title={t("accessTokens")}
className="kc-offline-session-template"
>
),
},
{
title: t("accessTokens"),
panel: (
<FormAccess
isHorizontal
role="manage-realm"
@@ -437,11 +446,11 @@ export const RealmSettingsTokensTab = ({
</FormGroup>
)}
</FormAccess>
</FormPanel>
<FormPanel
className="kc-login-settings-template"
title={t("actionTokens")}
>
),
},
{
title: t("actionTokens"),
panel: (
<FormAccess
isHorizontal
role="manage-realm"
@@ -618,21 +627,69 @@ export const RealmSettingsTokensTab = ({
)}
/>
</FormGroup>
<ActionGroup>
<Button
variant="primary"
type="submit"
data-testid="tokens-tab-save"
{!isFeatureEnabled(Feature.OpenId4VCI) && (
<FixedButtonsGroup
name="tokens-tab"
isSubmit
isDisabled={!formState.isDirty}
>
{t("save")}
</Button>
<Button variant="link" onClick={() => reset(realm)}>
{t("revert")}
</Button>
</ActionGroup>
reset={() => reset(realm)}
/>
)}
</FormAccess>
</FormPanel>
</PageSection>
),
},
{
title: t("oid4vciAttributes"),
isHidden: !isFeatureEnabled(Feature.OpenId4VCI),
panel: (
<FormAccess
isHorizontal
role="manage-realm"
className="pf-v5-u-mt-lg"
onSubmit={handleSubmit(save, onError)}
>
<TimeSelectorControl
name={convertAttributeNameToForm(
"attributes.vc.c-nonce-lifetime-seconds",
)}
label={t("oid4vciNonceLifetime")}
labelIcon={t("oid4vciNonceLifetimeHelp")}
controller={{
defaultValue: 60,
rules: { min: 30 },
}}
min={30}
units={["second", "minute", "hour"]}
/>
<TimeSelectorControl
name={convertAttributeNameToForm(
"attributes.preAuthorizedCodeLifespanS",
)}
label={t("preAuthorizedCodeLifespan")}
labelIcon={t("preAuthorizedCodeLifespanHelp")}
controller={{
defaultValue: 30,
rules: { min: 30 },
}}
min={30}
units={["second", "minute", "hour"]}
/>
<FixedButtonsGroup
name="tokens-tab"
isSubmit
isDisabled={!formState.isDirty}
reset={() => reset(realm)}
/>
</FormAccess>
),
},
];
return (
<ScrollForm
label={t("jumpToSection")}
className="pf-v5-u-px-lg pf-v5-u-pb-lg"
sections={sections}
/>
);
};

View File

@@ -0,0 +1,199 @@
import { expect, test } from "@playwright/test";
import { generatePath } from "react-router-dom";
import { toRealmSettings } from "../../src/realm-settings/routes/RealmSettings.tsx";
import { createTestBed } from "../support/testbed.ts";
import adminClient from "../utils/AdminClient.js";
import { SERVER_URL, ROOT_PATH } from "../utils/constants.ts";
import { login } from "../utils/login.js";
test("OID4VCI section visibility and jump link in Tokens tab", async ({
page,
}) => {
const realm = await createTestBed();
await login(page, { to: toRealmSettings({ realm }) });
const tokensTab = page.getByTestId("rs-tokens-tab");
await tokensTab.click();
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
await expect(oid4vciJumpLink).toBeVisible();
await oid4vciJumpLink.click();
const oid4vciSection = page.getByRole("heading", {
name: "OID4VCI attributes",
});
await expect(oid4vciSection).toBeVisible();
});
test("should render fields and save values with correct attribute keys", async ({
page,
}) => {
const realm = await createTestBed();
await login(page, { to: toRealmSettings({ realm }) });
const tokensTab = page.getByTestId("rs-tokens-tab");
await tokensTab.click();
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
await oid4vciJumpLink.click();
const nonceField = page.getByTestId(
"attributes.vc🍺c-nonce-lifetime-seconds",
);
const preAuthField = page.getByTestId(
"attributes.preAuthorizedCodeLifespanS",
);
await expect(nonceField).toBeVisible();
await expect(preAuthField).toBeVisible();
await nonceField.fill("60");
await preAuthField.fill("120");
await page.getByTestId("tokens-tab-save").click();
await expect(
page.getByText("Realm successfully updated").first(),
).toBeVisible();
const realmData = await adminClient.getRealm(realm);
expect(realmData).toBeDefined();
// TimeSelector converts values based on selected unit (60 minutes = 3600 seconds, 120 seconds = 120 seconds)
expect(realmData?.attributes?.["vc.c-nonce-lifetime-seconds"]).toBe("3600");
expect(realmData?.attributes?.["preAuthorizedCodeLifespanS"]).toBe("120");
});
test("should persist values after page refresh", async ({ page }) => {
const realm = await createTestBed();
await login(page, { to: toRealmSettings({ realm }) });
const tokensTab = page.getByTestId("rs-tokens-tab");
await tokensTab.click();
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
await oid4vciJumpLink.click();
const nonceField = page.getByTestId(
"attributes.vc🍺c-nonce-lifetime-seconds",
);
const preAuthField = page.getByTestId(
"attributes.preAuthorizedCodeLifespanS",
);
await nonceField.fill("60");
await preAuthField.fill("120");
await page.getByTestId("tokens-tab-save").click();
await expect(
page.getByText("Realm successfully updated").first(),
).toBeVisible();
// Refresh the page
await page.reload();
// Navigate back to realm settings using the same pattern as login
const url = new URL(generatePath(ROOT_PATH, { realm }), SERVER_URL);
url.hash = toRealmSettings({ realm }).pathname!;
await page.goto(url.toString());
// The TimeSelector component converts values based on units, so we need to check the actual saved values
const realmData = await adminClient.getRealm(realm);
expect(realmData?.attributes?.["vc.c-nonce-lifetime-seconds"]).toBeDefined();
expect(realmData?.attributes?.["preAuthorizedCodeLifespanS"]).toBeDefined();
// The values should be numbers representing seconds
const nonceValue = parseInt(
realmData?.attributes?.["vc.c-nonce-lifetime-seconds"] || "0",
);
const preAuthValue = parseInt(
realmData?.attributes?.["preAuthorizedCodeLifespanS"] || "0",
);
expect(nonceValue).toBeGreaterThan(0);
expect(preAuthValue).toBeGreaterThan(0);
});
test("should validate form fields and save valid values", async ({ page }) => {
const realm = await createTestBed();
await login(page, { to: toRealmSettings({ realm }) });
const tokensTab = page.getByTestId("rs-tokens-tab");
await tokensTab.click();
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
await oid4vciJumpLink.click();
const nonceField = page.getByTestId(
"attributes.vc🍺c-nonce-lifetime-seconds",
);
const preAuthField = page.getByTestId(
"attributes.preAuthorizedCodeLifespanS",
);
const saveButton = page.getByTestId("tokens-tab-save");
// Test that fields are visible and can be filled
await expect(nonceField).toBeVisible();
await expect(preAuthField).toBeVisible();
await expect(saveButton).toBeVisible();
// Test with valid values - this should work
await nonceField.clear();
await preAuthField.clear();
// Fill with smaller, more reasonable values for testing
await nonceField.fill("60");
await preAuthField.fill("120");
// Save button should be enabled when form has values
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(
page.getByText("Realm successfully updated").first(),
).toBeVisible();
// Verify the values were saved correctly
const realmData = await adminClient.getRealm(realm);
expect(realmData?.attributes?.["vc.c-nonce-lifetime-seconds"]).toBeDefined();
expect(realmData?.attributes?.["preAuthorizedCodeLifespanS"]).toBeDefined();
// The values should be numbers representing seconds
const nonceValue = parseInt(
realmData?.attributes?.["vc.c-nonce-lifetime-seconds"] || "0",
);
const preAuthValue = parseInt(
realmData?.attributes?.["preAuthorizedCodeLifespanS"] || "0",
);
expect(nonceValue).toBeGreaterThan(0);
expect(preAuthValue).toBeGreaterThan(0);
});
test("should show validation error for values below minimum threshold", async ({
page,
}) => {
const realm = await createTestBed();
await login(page, { to: toRealmSettings({ realm }) });
const tokensTab = page.getByTestId("rs-tokens-tab");
await tokensTab.click();
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
await oid4vciJumpLink.click();
const nonceField = page.getByTestId(
"attributes.vc🍺c-nonce-lifetime-seconds",
);
const preAuthField = page.getByTestId(
"attributes.preAuthorizedCodeLifespanS",
);
const saveButton = page.getByTestId("tokens-tab-save");
// Fill with values below the minimum threshold (29 seconds)
await nonceField.fill("29");
await preAuthField.fill("29");
await saveButton.click();
// Check for validation error message
const validationErrorText =
"Please ensure the OID4VCI attribute fields are filled with values 30 seconds or greater.";
await expect(page.getByText(validationErrorText).first()).toBeVisible();
});

View File

@@ -1,4 +1,4 @@
import { Page, expect } from "@playwright/test";
import { type Page, expect } from "@playwright/test";
import { changeTimeUnit, switchOn } from "../utils/form.ts";
export async function goToTokensTab(page: Page) {

View File

@@ -539,6 +539,11 @@ class AdminClient {
}
}
async getServerInfo() {
await this.#login();
return await this.#client.serverInfo.find();
}
async copyFlow(
name: string,
newName: string,