mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
[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:
committed by
GitHub
parent
1abddbf64c
commit
f3bd3dcd2e
2
.github/workflows/js-ci.yml
vendored
2
.github/workflows/js-ci.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/stability-js-ci.yml
vendored
2
.github/workflows/stability-js-ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
199
js/apps/admin-ui/test/realm-settings/oid4vci-attributes.spec.ts
Normal file
199
js/apps/admin-ui/test/realm-settings/oid4vci-attributes.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -539,6 +539,11 @@ class AdminClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getServerInfo() {
|
||||
await this.#login();
|
||||
return await this.#client.serverInfo.find();
|
||||
}
|
||||
|
||||
async copyFlow(
|
||||
name: string,
|
||||
newName: string,
|
||||
|
||||
Reference in New Issue
Block a user