diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml index cc8e67aac42..6208d7ccdae 100644 --- a/.github/workflows/js-ci.yml +++ b/.github/workflows/js-ci.yml @@ -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 diff --git a/.github/workflows/stability-js-ci.yml b/.github/workflows/stability-js-ci.yml index d2f7203e795..ac91a8f502e 100644 --- a/.github/workflows/stability-js-ci.yml +++ b/.github/workflows/stability-js-ci.yml @@ -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 diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 555c6702732..8ca92bce3d1 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -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. diff --git a/js/apps/admin-ui/src/realm-settings/TokensTab.tsx b/js/apps/admin-ui/src/realm-settings/TokensTab.tsx index 0dda8cce013..45d833643f4 100644 --- a/js/apps/admin-ui/src/realm-settings/TokensTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/TokensTab.tsx @@ -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(); + // 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 ( - - + const sections = [ + { + title: t("general"), + panel: ( )} - - + ), + }, + { + title: t("refreshTokens"), + panel: ( )} - - + ), + }, + { + title: t("accessTokens"), + panel: ( )} - - + ), + }, + { + title: t("actionTokens"), + panel: ( - - - - + reset={() => reset(realm)} + /> + )} - - + ), + }, + { + title: t("oid4vciAttributes"), + isHidden: !isFeatureEnabled(Feature.OpenId4VCI), + panel: ( + + + + reset(realm)} + /> + + ), + }, + ]; + + return ( + ); }; 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 new file mode 100644 index 00000000000..d179ab24c79 --- /dev/null +++ b/js/apps/admin-ui/test/realm-settings/oid4vci-attributes.spec.ts @@ -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(); +}); diff --git a/js/apps/admin-ui/test/realm-settings/tokens.ts b/js/apps/admin-ui/test/realm-settings/tokens.ts index 8ee89b9a530..62639734368 100644 --- a/js/apps/admin-ui/test/realm-settings/tokens.ts +++ b/js/apps/admin-ui/test/realm-settings/tokens.ts @@ -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) { diff --git a/js/apps/admin-ui/test/utils/AdminClient.ts b/js/apps/admin-ui/test/utils/AdminClient.ts index fb052994f19..92a187a056b 100644 --- a/js/apps/admin-ui/test/utils/AdminClient.ts +++ b/js/apps/admin-ui/test/utils/AdminClient.ts @@ -539,6 +539,11 @@ class AdminClient { } } + async getServerInfo() { + await this.#login(); + return await this.#client.serverInfo.find(); + } + async copyFlow( name: string, newName: string,