From f91363d12dd39db2f087501765fc334ded9f9c4d Mon Sep 17 00:00:00 2001 From: Ricardo Martin Date: Wed, 3 Dec 2025 11:45:34 +0100 Subject: [PATCH] Improve Public Key Management for JWTAuthorizationGrant identity provider Closes #44243 Signed-off-by: rmartinc --- .github/workflows/js-ci.yml | 2 +- .../idm/CertificateRepresentation.java | 9 ++ .../admin/messages/messages_en.properties | 3 +- .../src/clients/keys/ImportKeyDialog.tsx | 8 +- .../add/DiscoverySettings.tsx | 44 +----- .../add/JWTAuthorizationGrantSettings.tsx | 12 +- .../identity-providers/add/JwksSettings.tsx | 129 ++++++++++++++++ .../jwt-authorization-grant.spec.ts | 138 ++++++++++++++++++ .../admin-ui/test/identity-providers/main.ts | 32 ++++ .../test/identity-providers/oidc.spec.ts | 7 +- js/apps/admin-ui/test/utils/file-chooser.ts | 13 +- js/apps/admin-ui/test/utils/files/key.jwks | 9 ++ js/apps/admin-ui/test/utils/files/key.pem | 3 + .../src/defs/certificateRepresentation.ts | 1 + .../src/resources/identityProviders.ts | 10 ++ .../JWTAuthorizationGrantConfig.java | 47 ++++++ ...JWTAuthorizationGrantIdentityProvider.java | 13 +- ...TAuthorizationGrantJWKSEndpointLoader.java | 28 ---- .../oidc/OIDCIdentityProviderConfig.java | 49 +------ .../keys/loader/HardcodedPublicKeyLoader.java | 5 +- .../OIDCIdentityProviderPublicKeyLoader.java | 51 ++----- .../keys/loader/PublicKeyStorageManager.java | 23 ++- .../ClientAttributeCertificateResource.java | 117 ++------------- .../admin/IdentityProvidersResource.java | 33 +++++ .../services/util/CertificateInfoHelper.java | 106 ++++++++++++++ .../AbstractJWTAuthorizationGrantTest.java | 44 ++++++ .../oauth/JWTAuthorizationGrantTest.java | 1 + .../broker/KcOIDCBrokerWithSignatureTest.java | 124 +++++++++++++++- .../broker/KcOidcBrokerConfiguration.java | 6 +- .../broker/KcOidcBrokerLogoutTest.java | 8 +- .../AbstractClientAuthSignedJWTTest.java | 10 +- .../oauth/ClientAuthSignedJWTTest.java | 3 +- 32 files changed, 775 insertions(+), 313 deletions(-) create mode 100644 js/apps/admin-ui/src/identity-providers/add/JwksSettings.tsx create mode 100644 js/apps/admin-ui/test/identity-providers/jwt-authorization-grant.spec.ts create mode 100644 js/apps/admin-ui/test/utils/files/key.jwks create mode 100644 js/apps/admin-ui/test/utils/files/key.pem delete mode 100644 services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantJWKSEndpointLoader.java diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml index b5b76870e1c..7aadf938645 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,oid4vc-vci,kubernetes-service-accounts &> ~/server.log & + keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users,spiffe,oid4vc-vci,kubernetes-service-accounts,jwt-authorization-grant &> ~/server.log & env: KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin diff --git a/core/src/main/java/org/keycloak/representations/idm/CertificateRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/CertificateRepresentation.java index d10616caaf1..0b328a0240b 100755 --- a/core/src/main/java/org/keycloak/representations/idm/CertificateRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/CertificateRepresentation.java @@ -28,6 +28,7 @@ public class CertificateRepresentation { protected String publicKey; protected String certificate; protected String kid; + protected String jwks; public String getPrivateKey() { return privateKey; @@ -60,4 +61,12 @@ public class CertificateRepresentation { public void setKid(String kid) { this.kid = kid; } + + public String getJwks() { + return jwks; + } + + public void setJwks(String jwks) { + this.jwks = jwks; + } } 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 a5b0a83e682..42901e989ec 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 @@ -805,6 +805,7 @@ deleteMappingTitle=Delete mapping? profile=Profile active=Active generateKeysDescription=If you generate new keys, you can download the keystore with the private key automatically and save it on your client's side. Keycloak server will save just the certificate and public key, but not the private key. +importKeysDescription=Import a public key using different file formats. Please select the type of archive you want to import. addSubFlowTitle=Add a sub-flow useTruststoreSpiHelp=Specifies whether LDAP connection will use the Truststore SPI with the truststore configured in command-line options. 'Always' means that it will always use it. 'Never' means that it will not use it. Note that even if Keycloak truststore is not configured, the default java cacerts or certificate specified by 'javax.net.ssl.trustStore' property will be used. forcePostBindingHelp=Always use POST binding for responses. @@ -1974,7 +1975,7 @@ scopeParameter=Scope parameter unsigned=Unsigned userGroupsRetrieveStrategy=User groups retrieve strategy addSubFlow=Add sub-flow -validatingPublicKeyHelp=The public key in PEM format that must be used to verify external IDP signatures. +validatingPublicKeyHelp=The public key in PEM or JWKS format that must be used to verify external IDP signatures. The button below can be used to import an external file with different key and certificate formats. The provider needs to be saved after the import to store the changes. client-uris-must-match.label=Client URIs Must Match webAuthnPolicyAcceptableAaguids=Acceptable AAGUIDs noRoles-roles=No roles in this realm diff --git a/js/apps/admin-ui/src/clients/keys/ImportKeyDialog.tsx b/js/apps/admin-ui/src/clients/keys/ImportKeyDialog.tsx index 75869c5d8da..12b82d9ec84 100644 --- a/js/apps/admin-ui/src/clients/keys/ImportKeyDialog.tsx +++ b/js/apps/admin-ui/src/clients/keys/ImportKeyDialog.tsx @@ -16,6 +16,8 @@ import { StoreSettings } from "./StoreSettings"; type ImportKeyDialogProps = { toggleDialog: () => void; save: (importFile: ImportFile) => void; + title?: string; + description?: string; }; export type ImportFile = { @@ -28,6 +30,8 @@ export type ImportFile = { export const ImportKeyDialog = ({ save, toggleDialog, + title = "generateKeys", + description = "generateKeysDescription", }: ImportKeyDialogProps) => { const { t } = useTranslation(); const form = useForm(); @@ -50,7 +54,7 @@ export const ImportKeyDialog = ({ return ( - {t("generateKeysDescription")} + {t(description)}
diff --git a/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx b/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx index 8f8f4e3bd47..ea2a57f2982 100644 --- a/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx @@ -3,12 +3,9 @@ import { ExpandableSection } from "@patternfly/react-core"; import { useState } from "react"; import { useFormContext, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { - SelectControl, - TextAreaControl, - TextControl, -} from "@keycloak/keycloak-ui-shared"; +import { SelectControl, TextControl } from "@keycloak/keycloak-ui-shared"; import { DefaultSwitchControl } from "../../components/SwitchControl"; +import { JwksSettings } from "./JwksSettings"; import "./discovery-settings.css"; @@ -27,10 +24,6 @@ const Fields = ({ readOnly, isOIDC }: DiscoverySettingsProps) => { control, name: "config.validateSignature", }); - const useJwks = useWatch({ - control, - name: "config.useJwksUrl", - }); const isPkceEnabled = useWatch({ control, name: "config.pkceEnabled", @@ -104,38 +97,7 @@ const Fields = ({ readOnly, isOIDC }: DiscoverySettingsProps) => { {(validateSignature === "true" || jwtAuthorizationGrantEnabled === "true" || supportsClientAssertions == "true") && ( - <> - - {useJwks === "true" ? ( - - ) : ( - <> - - - - )} - + )} )} diff --git a/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantSettings.tsx index c553dfd7806..ea461b7185d 100644 --- a/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/JWTAuthorizationGrantSettings.tsx @@ -1,8 +1,9 @@ import { useTranslation } from "react-i18next"; - import { TextControl, NumberControl } from "@keycloak/keycloak-ui-shared"; import { JWTAuthorizationGrantAssertionSettings } from "./JWTAuthorizationGrantAssertionSettings"; import { Divider } from "@patternfly/react-core"; +import { JwksSettings } from "./JwksSettings"; + export default function JWTAuthorizationGrantSettings() { const { t } = useTranslation(); return ( @@ -22,14 +23,7 @@ export default function JWTAuthorizationGrantSettings() { required: t("required"), }} /> - + { + const { t } = useTranslation(); + const { control, setValue } = + useFormContext(); + const { adminClient } = useAdminClient(); + const { addAlert, addError } = useAlerts(); + const [openImportKeys, toggleOpenImportKeys, setOpenImportKeys] = useToggle(); + const useJwks = useWatch({ + control, + name: "config.useJwksUrl", + defaultValue: "true", + }); + const publicKeySignatureVerifier = useWatch({ + control, + name: "config.publicKeySignatureVerifier", + }); + + const importKey = async (importFile: ImportFile) => { + try { + const formData = new FormData(); + const { file, ...rest } = importFile; + + for (const [key, value] of Object.entries(rest)) { + formData.append(key, value); + } + + formData.append("file", file); + const info = await adminClient.identityProviders.uploadCertificate( + {}, + formData, + ); + if (info.jwks) { + setValue("config.publicKeySignatureVerifier", info.jwks); + setValue("config.publicKeySignatureVerifierKeyId", ""); + addAlert(t("importSuccess"), AlertVariant.success); + } else if (info.publicKey) { + setValue("config.publicKeySignatureVerifier", info.publicKey); + addAlert(t("importSuccess"), AlertVariant.success); + } else { + addError("importError", t("emptyResources")); + } + } catch (error) { + addError("importError", error); + } + }; + + return ( + <> + + {useJwks === "true" ? ( + + ) : ( + <> + {openImportKeys && ( + + )} + {!publicKeySignatureVerifier?.trim().startsWith("{") && ( + + )} + + {!readOnly && ( + + + + )} + + )} + + ); +}; diff --git a/js/apps/admin-ui/test/identity-providers/jwt-authorization-grant.spec.ts b/js/apps/admin-ui/test/identity-providers/jwt-authorization-grant.spec.ts new file mode 100644 index 00000000000..cccdab833fa --- /dev/null +++ b/js/apps/admin-ui/test/identity-providers/jwt-authorization-grant.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; +import { + createJwtAuthorizationGrantProvider, + createJwtAuthorizationGrantProviderKey, + clickSaveButton, +} from "./main.ts"; +import { assertNotificationMessage } from "../utils/masthead.ts"; +import { goToIdentityProviders } from "../utils/sidebar.ts"; +import { clickTableRowItem } from "../utils/table.ts"; +import { login } from "../utils/login.ts"; +import adminClient from "../utils/AdminClient.ts"; +import { assertModalTitle, confirmModal } from "../utils/modal.ts"; +import { selectItem } from "../utils/form.ts"; +import { chooseFileByLocator } from "../utils/file-chooser.ts"; + +test.describe.serial("JWT Authorization Grant identity provider test", () => { + test.beforeEach(async ({ page }) => { + await login(page); + await goToIdentityProviders(page); + }); + + test.afterEach(() => + adminClient.deleteIdentityProvider("jwt-authorization-grant"), + ); + + test("should create a JWT Authorization Grant provider with JWKS url", async ({ + page, + }) => { + await createJwtAuthorizationGrantProvider( + page, + "jwt-authorization-grant", + "https://localhost/realms/test", + "https://localhost/realms/test/protocol/openid-connect/certs", + ); + + await assertNotificationMessage( + page, + "Identity provider successfully created", + ); + + await goToIdentityProviders(page); + await clickTableRowItem(page, "jwt-authorization"); + + await expect(page.getByTestId("config.issuer")).toHaveValue( + "https://localhost/realms/test", + ); + await expect(page.getByTestId("config.useJwksUrl")).toBeChecked(); + await expect(page.getByTestId("config.jwksUrl")).toHaveValue( + "https://localhost/realms/test/protocol/openid-connect/certs", + ); + + await page + .getByTestId("config.issuer") + .fill("https://localhost/realms/test2"); + await page + .getByTestId("config.jwksUrl") + .fill("https://localhost/realms/test2/protocol/openid-connect/certs"); + + await clickSaveButton(page); + + await assertNotificationMessage(page, "Provider successfully updated"); + + await expect(page.getByTestId("config.issuer")).toHaveValue( + "https://localhost/realms/test2", + ); + await expect(page.getByTestId("config.jwksUrl")).toHaveValue( + "https://localhost/realms/test2/protocol/openid-connect/certs", + ); + }); + + test("should create a JWT Authorization Grant provider with public key pem", async ({ + page, + }) => { + await createJwtAuthorizationGrantProviderKey( + page, + "jwt-authorization-grant", + "https://localhost/realms/test", + "keyId", + "MEMwBQYDK2VxAzoAWOVoLNsZlgw5dvat/Xi83Rh7zQMOerq3XrTT1xVbqDX2naZPlza0gwyNnMV6H6vnUGbaCK/+mgCA", + ); + + await assertNotificationMessage( + page, + "Identity provider successfully created", + ); + + await goToIdentityProviders(page); + await clickTableRowItem(page, "jwt-authorization-grant"); + + await expect(page.getByTestId("config.issuer")).toHaveValue( + "https://localhost/realms/test", + ); + await expect(page.getByTestId("config.useJwksUrl")).not.toBeChecked(); + await expect(page.getByTestId("config.jwksUrl")).toBeHidden(); + await expect( + page.getByTestId("config.publicKeySignatureVerifierKeyId"), + ).toHaveValue("keyId"); + await expect( + page.getByTestId("config.publicKeySignatureVerifier"), + ).toHaveValue( + "MEMwBQYDK2VxAzoAWOVoLNsZlgw5dvat/Xi83Rh7zQMOerq3XrTT1xVbqDX2naZPlza0gwyNnMV6H6vnUGbaCK/+mgCA", + ); + + await page.getByTestId("import-certificate-button").click(); + await assertModalTitle(page, "Import key"); + await selectItem(page, page.locator("#keystoreFormat"), "Public Key PEM"); + await chooseFileByLocator( + page, + "../utils/files/key.pem", + page.locator("#importFile-browse-button"), + ); + await confirmModal(page); + + await expect( + page.getByTestId("config.publicKeySignatureVerifier"), + ).toHaveValue(/MIIBI/); + + await clickSaveButton(page); + await assertNotificationMessage(page, "Provider successfully updated"); + + await page.getByTestId("import-certificate-button").click(); + await assertModalTitle(page, "Import key"); + await selectItem(page, page.locator("#keystoreFormat"), "JSON Web Key Set"); + await chooseFileByLocator( + page, + "../utils/files/key.jwks", + page.locator("#importFile-browse-button"), + ); + await confirmModal(page); + + await expect( + page.getByTestId("config.publicKeySignatureVerifier"), + ).toHaveValue(/{ "keys" : /); + + await clickSaveButton(page); + await assertNotificationMessage(page, "Provider successfully updated"); + }); +}); diff --git a/js/apps/admin-ui/test/identity-providers/main.ts b/js/apps/admin-ui/test/identity-providers/main.ts index 89e29d8a588..9ad99d5b36e 100644 --- a/js/apps/admin-ui/test/identity-providers/main.ts +++ b/js/apps/admin-ui/test/identity-providers/main.ts @@ -55,6 +55,38 @@ export async function createSPIFFEProvider( await clickAddButton(page); } +export async function createJwtAuthorizationGrantProvider( + page: Page, + providerName: string, + issuer: string, + jwksUrl: string, +) { + await clickProviderCard(page, providerName); + await expect(page.getByTestId("config.useJwksUrl")).toBeChecked(); + await page.getByTestId("config.issuer").fill(issuer); + await page.getByTestId("config.jwksUrl").fill(jwksUrl); + await clickAddButton(page); +} + +export async function createJwtAuthorizationGrantProviderKey( + page: Page, + providerName: string, + issuer: string, + keyId: string, + key: string, +) { + await clickProviderCard(page, providerName); + await page.getByTestId("config.issuer").fill(issuer); + await expect(page.getByTestId("config.useJwksUrl")).toBeChecked(); + await page.getByTestId("config.useJwksUrl").click({ force: true }); + await expect( + page.getByTestId("config.publicKeySignatureVerifierKeyId"), + ).toBeVisible(); + await page.getByTestId("config.publicKeySignatureVerifierKeyId").fill(keyId); + await page.getByTestId("config.publicKeySignatureVerifier").fill(key); + await clickAddButton(page); +} + export async function createKubernetesProvider( page: Page, providerName: string, diff --git a/js/apps/admin-ui/test/identity-providers/oidc.spec.ts b/js/apps/admin-ui/test/identity-providers/oidc.spec.ts index 86c4b20bd1c..111742f35d1 100644 --- a/js/apps/admin-ui/test/identity-providers/oidc.spec.ts +++ b/js/apps/admin-ui/test/identity-providers/oidc.spec.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test"; +import { test, expect } from "@playwright/test"; import { v4 as uuid } from "uuid"; import adminClient from "../utils/AdminClient.ts"; import { switchOn } from "../utils/form.ts"; @@ -67,10 +67,7 @@ test.describe.serial("OIDC identity provider test", () => { await assertPkceMethodExists(page); await clickSaveButton(page); - await assertNotificationMessage( - page, - "Could not update the provider. The 'Validating public key' is required when 'Validate signatures' enabled and 'Use JWKS URL' disabled", - ); + await expect(page.getByText("Required field")).toBeVisible(); await switchOn(page, "#config\\.useJwksUrl"); await assertJwksUrlExists(page, true); diff --git a/js/apps/admin-ui/test/utils/file-chooser.ts b/js/apps/admin-ui/test/utils/file-chooser.ts index c0753936f31..1d9a91359ad 100644 --- a/js/apps/admin-ui/test/utils/file-chooser.ts +++ b/js/apps/admin-ui/test/utils/file-chooser.ts @@ -1,10 +1,19 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import path from "node:path"; import { fileURLToPath } from "node:url"; export async function chooseFile(page: Page, file: string) { + const locator = page.getByText("Browse..."); + await chooseFileByLocator(page, file, locator); +} + +export async function chooseFileByLocator( + page: Page, + file: string, + locator: Locator, +) { const fileChooserPromise = page.waitForEvent("filechooser"); - await page.getByText("Browse...").click(); + await locator.click(); const fileChooser = await fileChooserPromise; const fileName = fileURLToPath(import.meta.url); diff --git a/js/apps/admin-ui/test/utils/files/key.jwks b/js/apps/admin-ui/test/utils/files/key.jwks new file mode 100644 index 00000000000..b79c1cbb489 --- /dev/null +++ b/js/apps/admin-ui/test/utils/files/key.jwks @@ -0,0 +1,9 @@ +{ "keys" : [ { + "kid" : "VUsJUVMP3-DjQWi0JqASvcaZp-dmUDxljJ-OzlWGcsg", + "kty" : "EC", + "alg" : "ES256", + "use" : "sig", + "crv" : "P-256", + "x" : "nPx0cVHYyLqSsYUMQNZKRFChusBTBpRRfjQtYljhFaw", + "y" : "2Wp_Hljj-A3RKoq7dv_0f9Ur_KCm1efpX93cpPASCj4" +} ] } diff --git a/js/apps/admin-ui/test/utils/files/key.pem b/js/apps/admin-ui/test/utils/files/key.pem new file mode 100644 index 00000000000..15a5598c461 --- /dev/null +++ b/js/apps/admin-ui/test/utils/files/key.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgqyu/u7C9+Xr1wbRD2StuCdYinhwNhqC1WpDIsx8Zzw2gaTxCiwv+JlFMi+386erX7S99BDebfN+Zc0ybplz78LCI0qHOCGf6TSpuJYj1i1S51+PtcDg4lo8YwQQt4JRH7xz6szwp8uEGmgnv4abbKMPMhNMdfJS/fEmodsQId7b/aN/v7heRO23T0ry9frPwmWf3cfZurEdRSyc/AKv8qQvpwNr0lsQAcOMQz2hwiLdz1hoT2Qhp8v7abctym4TBswHXuQx9wEywvvIpyz+JMllfcIRIi2tkyKk8E4D2i2xXtksBdEWAQN398EnxtP3OnhZmr2k8uec6COj5XZjdwIDAQAB +-----END PUBLIC KEY----- diff --git a/js/libs/keycloak-admin-client/src/defs/certificateRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/certificateRepresentation.ts index 9abf28ea324..761e02f9f5e 100644 --- a/js/libs/keycloak-admin-client/src/defs/certificateRepresentation.ts +++ b/js/libs/keycloak-admin-client/src/defs/certificateRepresentation.ts @@ -6,4 +6,5 @@ export default interface CertificateRepresentation { publicKey?: string; certificate?: string; kid?: string; + jwks?: string; } diff --git a/js/libs/keycloak-admin-client/src/resources/identityProviders.ts b/js/libs/keycloak-admin-client/src/resources/identityProviders.ts index 2b99bf59364..45fc1a5e712 100644 --- a/js/libs/keycloak-admin-client/src/resources/identityProviders.ts +++ b/js/libs/keycloak-admin-client/src/resources/identityProviders.ts @@ -3,6 +3,7 @@ import type IdentityProviderMapperRepresentation from "../defs/identityProviderM import type { IdentityProviderMapperTypeRepresentation } from "../defs/identityProviderMapperTypeRepresentation.js"; import type IdentityProviderRepresentation from "../defs/identityProviderRepresentation.js"; import type { ManagementPermissionReference } from "../defs/managementPermissionReference.js"; +import type CertificateRepresentation from "../defs/certificateRepresentation.js"; import Resource from "./resource.js"; export interface PaginatedQuery { @@ -50,6 +51,15 @@ export class IdentityProviders extends Resource<{ realm?: string }> { catchNotFound: true, }); + public uploadCertificate = this.makeUpdateRequest< + {}, + FormData, + CertificateRepresentation + >({ + method: "POST", + path: "/upload-certificate", + }); + public update = this.makeUpdateRequest< { alias: string }, IdentityProviderRepresentation, diff --git a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantConfig.java b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantConfig.java index b38f4585a64..a57d689f26b 100644 --- a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantConfig.java +++ b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantConfig.java @@ -4,6 +4,7 @@ package org.keycloak.broker.jwtauthorizationgrant; import java.util.Map; import static org.keycloak.broker.oidc.OIDCIdentityProviderConfig.JWKS_URL; +import static org.keycloak.broker.oidc.OIDCIdentityProviderConfig.USE_JWKS_URL; import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ISSUER; public interface JWTAuthorizationGrantConfig { @@ -18,6 +19,10 @@ public interface JWTAuthorizationGrantConfig { String JWT_AUTHORIZATION_GRANT_ALLOWED_CLOCK_SKEW = "jwtAuthorizationGrantAllowedClockSkew"; + String PUBLIC_KEY_SIGNATURE_VERIFIER = "publicKeySignatureVerifier"; + + String PUBLIC_KEY_SIGNATURE_VERIFIER_KEY_ID = "publicKeySignatureVerifierKeyId"; + Map getConfig(); default boolean isJWTAuthorizationGrantEnabled() { @@ -53,13 +58,55 @@ public interface JWTAuthorizationGrantConfig { } } + default String getPublicKeySignatureVerifier() { + return getConfig().get(PUBLIC_KEY_SIGNATURE_VERIFIER); + } + + default void setPublicKeySignatureVerifier(String signingCertificate) { + if (signingCertificate == null) { + getConfig().remove(PUBLIC_KEY_SIGNATURE_VERIFIER); + } else { + getConfig().put(PUBLIC_KEY_SIGNATURE_VERIFIER, signingCertificate); + } + } + + default String getPublicKeySignatureVerifierKeyId() { + return getConfig().get(PUBLIC_KEY_SIGNATURE_VERIFIER_KEY_ID); + } + + default void setPublicKeySignatureVerifierKeyId(String publicKeySignatureVerifierKeyId) { + if (publicKeySignatureVerifierKeyId == null) { + getConfig().remove(PUBLIC_KEY_SIGNATURE_VERIFIER_KEY_ID); + } else { + getConfig().put(PUBLIC_KEY_SIGNATURE_VERIFIER_KEY_ID, publicKeySignatureVerifierKeyId); + } + } + + default boolean isUseJwksUrl() { + return Boolean.parseBoolean(getConfig().get(USE_JWKS_URL)); + } + + default void setUseJwksUrl(boolean useJwksUrl) { + getConfig().put(USE_JWKS_URL, String.valueOf(useJwksUrl)); + } + default String getIssuer() { return getConfig().get(ISSUER); } + default void setIssuer(String issuer) { + getConfig().put(ISSUER, issuer); + } + default String getJwksUrl() { return getConfig().get(JWKS_URL); } + default void setJwksUrl(String jwksUrl) { + getConfig().put(JWKS_URL, jwksUrl); + } + String getInternalId(); + + String getAlias(); } diff --git a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProvider.java b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProvider.java index 85edc0824fb..900788fc921 100644 --- a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantIdentityProvider.java @@ -11,8 +11,7 @@ import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.SignatureProvider; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; -import org.keycloak.keys.PublicKeyStorageProvider; -import org.keycloak.keys.PublicKeyStorageUtils; +import org.keycloak.keys.loader.PublicKeyStorageManager; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; @@ -82,14 +81,14 @@ public class JWTAuthorizationGrantIdentityProvider implements JWTAuthorizationGr private boolean verifySignature(JWSInput jws) { try { - String jwkurl = config.getJwksUrl(); JWSHeader header = jws.getHeader(); - String kid = header.getKeyId(); String alg = header.getRawAlgorithm(); - String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId()); - PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); - KeyWrapper publicKey = keyStorage.getPublicKey(modelKey, kid, alg, new JWTAuthorizationGrantJWKSEndpointLoader(session, jwkurl)); + KeyWrapper publicKey = PublicKeyStorageManager.getIdentityProviderKeyWrapper(session, session.getContext().getRealm(), getConfig(), jws); + if (publicKey == null) { + LOGGER.debugf("Failed to verify token, key not found for algorithm %s", alg); + return false; + } SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, alg); if (signatureProvider == null) { diff --git a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantJWKSEndpointLoader.java b/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantJWKSEndpointLoader.java deleted file mode 100644 index 021440d0ae1..00000000000 --- a/services/src/main/java/org/keycloak/broker/jwtauthorizationgrant/JWTAuthorizationGrantJWKSEndpointLoader.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.keycloak.broker.jwtauthorizationgrant; - -import org.keycloak.crypto.PublicKeysWrapper; -import org.keycloak.http.simple.SimpleHttp; -import org.keycloak.jose.jwk.JSONWebKeySet; -import org.keycloak.jose.jwk.JWK; -import org.keycloak.keys.PublicKeyLoader; -import org.keycloak.models.KeycloakSession; -import org.keycloak.util.JWKSUtils; - - -public class JWTAuthorizationGrantJWKSEndpointLoader implements PublicKeyLoader { - - private final KeycloakSession session; - private final String jwksUrl; - - public JWTAuthorizationGrantJWKSEndpointLoader(KeycloakSession session, String jwksUrl) { - this.session = session; - this.jwksUrl = jwksUrl; - } - - @Override - public PublicKeysWrapper loadKeys() throws Exception { - JSONWebKeySet jwks = SimpleHttp.create(session).doGet(jwksUrl).asJson(JSONWebKeySet.class); - return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true); - } - -} diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java index 00baba20902..40c5ff84f01 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java @@ -44,19 +44,10 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig imp super(); } - public String getPrompt() { - return getConfig().get("prompt"); - } public void setPrompt(String prompt) { getConfig().put("prompt", prompt); } - public String getIssuer() { - return getConfig().get(ISSUER); - } - public void setIssuer(String issuer) { - getConfig().put(ISSUER, issuer); - } public String getLogoutUrl() { return getConfig().get("logoutUrl"); } @@ -69,7 +60,7 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig imp } public void setSendClientOnLogout(boolean value) { - getConfig().put("sendClientIdOnLogout", Boolean.valueOf(value).toString()); + getConfig().put("sendClientIdOnLogout", String.valueOf(value)); } public boolean isSendIdTokenOnLogout() { @@ -77,27 +68,11 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig imp } public void setSendIdTokenOnLogout(boolean value) { - getConfig().put("sendIdTokenOnLogout", Boolean.valueOf(value).toString()); - } - - public String getPublicKeySignatureVerifier() { - return getConfig().get("publicKeySignatureVerifier"); - } - - public void setPublicKeySignatureVerifier(String signingCertificate) { - getConfig().put("publicKeySignatureVerifier", signingCertificate); - } - - public String getPublicKeySignatureVerifierKeyId() { - return getConfig().get("publicKeySignatureVerifierKeyId"); - } - - public void setPublicKeySignatureVerifierKeyId(String publicKeySignatureVerifierKeyId) { - getConfig().put("publicKeySignatureVerifierKeyId", publicKeySignatureVerifierKeyId); + getConfig().put("sendIdTokenOnLogout", String.valueOf(value)); } public boolean isValidateSignature() { - return Boolean.valueOf(getConfig().get("validateSignature")); + return Boolean.parseBoolean(getConfig().get("validateSignature")); } public void setValidateSignature(boolean validateSignature) { @@ -112,24 +87,8 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig imp return Boolean.parseBoolean(getConfig().get(IS_ACCESS_TOKEN_JWT)); } - public boolean isUseJwksUrl() { - return Boolean.valueOf(getConfig().get(USE_JWKS_URL)); - } - - public void setUseJwksUrl(boolean useJwksUrl) { - getConfig().put(USE_JWKS_URL, String.valueOf(useJwksUrl)); - } - - public String getJwksUrl() { - return getConfig().get(JWKS_URL); - } - - public void setJwksUrl(String jwksUrl) { - getConfig().put(JWKS_URL, jwksUrl); - } - public boolean isBackchannelSupported() { - return Boolean.valueOf(getConfig().get("backchannelSupported")); + return Boolean.parseBoolean(getConfig().get("backchannelSupported")); } public void setBackchannelSupported(boolean backchannel) { diff --git a/services/src/main/java/org/keycloak/keys/loader/HardcodedPublicKeyLoader.java b/services/src/main/java/org/keycloak/keys/loader/HardcodedPublicKeyLoader.java index 31657ec2880..913dcca03cf 100644 --- a/services/src/main/java/org/keycloak/keys/loader/HardcodedPublicKeyLoader.java +++ b/services/src/main/java/org/keycloak/keys/loader/HardcodedPublicKeyLoader.java @@ -21,6 +21,7 @@ import java.util.Collections; import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.KeyUtils; import org.keycloak.common.util.PemUtils; +import org.keycloak.crypto.Algorithm; import org.keycloak.crypto.JavaAlgorithm; import org.keycloak.crypto.KeyType; import org.keycloak.crypto.KeyUse; @@ -41,6 +42,7 @@ public class HardcodedPublicKeyLoader implements PublicKeyLoader { keyWrapper = new KeyWrapper(); keyWrapper.setKid(kid); keyWrapper.setUse(KeyUse.SIG); + keyWrapper.setAlgorithm(algorithm); // depending the algorithm load the correct key from the encoded string if (JavaAlgorithm.isRSAJavaAlgorithm(algorithm)) { keyWrapper.setType(KeyType.RSA); @@ -50,7 +52,8 @@ public class HardcodedPublicKeyLoader implements PublicKeyLoader { keyWrapper.setPublicKey(PemUtils.decodePublicKey(encodedKey, KeyType.EC)); } else if (JavaAlgorithm.isEddsaJavaAlgorithm(algorithm)) { keyWrapper.setType(KeyType.OKP); - keyWrapper.setPublicKey(PemUtils.decodePublicKey(encodedKey, KeyType.OKP)); + keyWrapper.setPublicKey(PemUtils.decodePublicKey(encodedKey, Algorithm.EdDSA)); + keyWrapper.setCurve(keyWrapper.getPublicKey().getAlgorithm()); } else if (JavaAlgorithm.isHMACJavaAlgorithm(algorithm)) { keyWrapper.setType(KeyType.OCT); keyWrapper.setSecretKey(KeyUtils.loadSecretKey(Base64Url.decode(encodedKey), algorithm)); diff --git a/services/src/main/java/org/keycloak/keys/loader/OIDCIdentityProviderPublicKeyLoader.java b/services/src/main/java/org/keycloak/keys/loader/OIDCIdentityProviderPublicKeyLoader.java index fe3bdd03277..08b0fb1b8d2 100644 --- a/services/src/main/java/org/keycloak/keys/loader/OIDCIdentityProviderPublicKeyLoader.java +++ b/services/src/main/java/org/keycloak/keys/loader/OIDCIdentityProviderPublicKeyLoader.java @@ -17,16 +17,7 @@ package org.keycloak.keys.loader; -import java.security.PublicKey; -import java.util.Collections; - -import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; -import org.keycloak.common.util.KeyUtils; -import org.keycloak.common.util.PemUtils; -import org.keycloak.crypto.Algorithm; -import org.keycloak.crypto.KeyType; -import org.keycloak.crypto.KeyUse; -import org.keycloak.crypto.KeyWrapper; +import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantConfig; import org.keycloak.crypto.PublicKeysWrapper; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; @@ -34,6 +25,8 @@ import org.keycloak.keys.PublicKeyLoader; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oidc.utils.JWKSHttpUtils; import org.keycloak.util.JWKSUtils; +import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.StringUtil; import org.jboss.logging.Logger; @@ -45,9 +38,9 @@ public class OIDCIdentityProviderPublicKeyLoader implements PublicKeyLoader { private static final Logger logger = Logger.getLogger(OIDCIdentityProviderPublicKeyLoader.class); private final KeycloakSession session; - private final OIDCIdentityProviderConfig config; + private final JWTAuthorizationGrantConfig config; - public OIDCIdentityProviderPublicKeyLoader(KeycloakSession session, OIDCIdentityProviderConfig config) { + public OIDCIdentityProviderPublicKeyLoader(KeycloakSession session, JWTAuthorizationGrantConfig config) { this.session = session; this.config = config; } @@ -59,36 +52,18 @@ public class OIDCIdentityProviderPublicKeyLoader implements PublicKeyLoader { JSONWebKeySet jwks = JWKSHttpUtils.sendJwksRequest(session, jwksUrl); return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true); } else { + String publicKeySignatureVerifier = config.getPublicKeySignatureVerifier(); + if (StringUtil.isBlank(publicKeySignatureVerifier)) { + return PublicKeysWrapper.EMPTY; + } try { - KeyWrapper publicKey = getSavedPublicKey(); - if (publicKey == null) { - return PublicKeysWrapper.EMPTY; - } - return new PublicKeysWrapper(Collections.singletonList(publicKey)); + // only load jwks, direct pem public key needs to load a hardcoded key locator + JSONWebKeySet jwks = JsonSerialization.readValue(publicKeySignatureVerifier, JSONWebKeySet.class); + return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG); } catch (Exception e) { - logger.warnf(e, "Unable to retrieve publicKey for verify signature of identityProvider '%s' . Error details: %s", config.getAlias(), e.getMessage()); + logger.warnf(e, "Unable to retrieve publicKey for verify signature of identityProvider '%s'.", config.getAlias()); return PublicKeysWrapper.EMPTY; } } } - - protected KeyWrapper getSavedPublicKey() throws Exception { - KeyWrapper keyWrapper = null; - if (config.getPublicKeySignatureVerifier() != null && !config.getPublicKeySignatureVerifier().trim().equals("")) { - PublicKey publicKey = PemUtils.decodePublicKey(config.getPublicKeySignatureVerifier()); - keyWrapper = new KeyWrapper(); - String presetKeyId = config.getPublicKeySignatureVerifierKeyId(); - String kid = (presetKeyId == null || presetKeyId.trim().isEmpty()) - ? KeyUtils.createKeyId(publicKey) - : presetKeyId; - keyWrapper.setKid(kid); - keyWrapper.setType(KeyType.RSA); - keyWrapper.setAlgorithm(Algorithm.RS256); - keyWrapper.setUse(KeyUse.SIG); - keyWrapper.setPublicKey(publicKey); - } else { - logger.warnf("No public key saved on identityProvider %s", config.getAlias()); - } - return keyWrapper; - } } diff --git a/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java b/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java index 349ea74f0d5..dcf9e02e878 100644 --- a/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java +++ b/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java @@ -19,7 +19,7 @@ package org.keycloak.keys.loader; import java.security.PublicKey; -import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantConfig; import org.keycloak.crypto.KeyWrapper; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jws.JWSInput; @@ -29,6 +29,7 @@ import org.keycloak.keys.PublicKeyStorageUtils; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.utils.StringUtil; import org.jboss.logging.Logger; @@ -64,10 +65,7 @@ public class PublicKeyStorageManager { return keyStorage.getFirstPublicKey(modelKey, algAlgorithm, loader); } - public static KeyWrapper getIdentityProviderKeyWrapper(KeycloakSession session, RealmModel realm, OIDCIdentityProviderConfig idpConfig, JWSInput input) { - boolean keyIdSetInConfiguration = idpConfig.getPublicKeySignatureVerifierKeyId() != null - && ! idpConfig.getPublicKeySignatureVerifierKeyId().trim().isEmpty(); - + public static KeyWrapper getIdentityProviderKeyWrapper(KeycloakSession session, RealmModel realm, JWTAuthorizationGrantConfig idpConfig, JWSInput input) { String kid = input.getHeader().getKeyId(); String alg = input.getHeader().getRawAlgorithm(); @@ -79,16 +77,17 @@ public class PublicKeyStorageManager { loader = new OIDCIdentityProviderPublicKeyLoader(session, idpConfig); } else { String pem = idpConfig.getPublicKeySignatureVerifier(); - - if (pem == null || pem.trim().isEmpty()) { + if (StringUtil.isNotBlank(pem) && pem.trim().startsWith("{")) { + loader = new OIDCIdentityProviderPublicKeyLoader(session, idpConfig); + } else if (StringUtil.isNotBlank(pem)) { + loader = new HardcodedPublicKeyLoader( + StringUtil.isNotBlank(idpConfig.getPublicKeySignatureVerifierKeyId()) + ? idpConfig.getPublicKeySignatureVerifierKeyId().trim() + : kid, pem, alg); + } else { logger.warnf("No public key saved on identityProvider %s", idpConfig.getAlias()); return null; } - - loader = new HardcodedPublicKeyLoader( - keyIdSetInConfiguration - ? idpConfig.getPublicKeySignatureVerifierKeyId().trim() - : kid, pem, alg); } return keyStorage.getPublicKey(modelKey, kid, alg, loader); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientAttributeCertificateResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientAttributeCertificateResource.java index cb308ddc77c..e2b5ba9b111 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientAttributeCertificateResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientAttributeCertificateResource.java @@ -19,17 +19,14 @@ package org.keycloak.services.resources.admin; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.security.KeyStore; import java.security.PrivateKey; -import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.Calendar; import java.util.Set; import java.util.stream.Collectors; -import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotAcceptableException; @@ -38,16 +35,13 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.util.KeystoreUtil.KeystoreFormat; import org.keycloak.common.util.PemUtils; -import org.keycloak.common.util.StreamUtil; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; -import org.keycloak.http.FormPartValue; import org.keycloak.models.ClientModel; import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; @@ -61,12 +55,10 @@ import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator; import org.keycloak.services.util.CertificateInfoHelper; -import com.google.common.base.Strings; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.extensions.Extension; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.tags.Tag; -import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.NoCache; /** @@ -77,12 +69,6 @@ import org.jboss.resteasy.reactive.NoCache; @Extension(name = KeycloakOpenAPI.Profiles.ADMIN, value = "") public class ClientAttributeCertificateResource { - public static final String CERTIFICATE_PEM = "Certificate PEM"; - public static final String PUBLIC_KEY_PEM = "Public Key PEM"; - public static final String JSON_WEB_KEY_SET = "JSON Web Key Set"; - - private static final Logger logger = Logger.getLogger(ClientAttributeCertificateResource.class); - protected final RealmModel realm; private final AdminPermissionEvaluator auth; protected final ClientModel client; @@ -152,9 +138,10 @@ public class ClientAttributeCertificateResource { @Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENT_ATTRIBUTE_CERTIFICATE) @Operation( summary = "Upload certificate and eventually private key") public CertificateRepresentation uploadJks() throws IOException { + auth.clients().requireConfigure(client); try { - CertificateRepresentation info = updateCertFromRequest(); - adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(info).success(); + CertificateRepresentation info = CertificateInfoHelper.getCertificateFromRequest(session); + updateCertFromRequest(info); return info; } catch (IllegalStateException ise) { throw new ErrorResponseException("certificate-not-found", "Certificate or key with given alias not found in the keystore", Response.Status.BAD_REQUEST); @@ -174,105 +161,23 @@ public class ClientAttributeCertificateResource { @Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENT_ATTRIBUTE_CERTIFICATE) @Operation( summary = "Upload only certificate, not private key") public CertificateRepresentation uploadJksCertificate() throws IOException { + auth.clients().requireManage(client); try { - CertificateRepresentation info = updateCertFromRequest(); - adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(info).success(); + CertificateRepresentation info = CertificateInfoHelper.getCertificateFromRequest(session); + updateCertFromRequest(info); return info; } catch (IllegalStateException ise) { throw new ErrorResponseException("certificate-not-found", "Certificate or key with given alias not found in the keystore", Response.Status.BAD_REQUEST); } } - private CertificateRepresentation updateCertFromRequest() throws IOException { - auth.clients().requireManage(client); - CertificateRepresentation info = new CertificateRepresentation(); - MultivaluedMap uploadForm = session.getContext().getHttpRequest().getMultiPartFormParameters(); - FormPartValue keystoreFormatPart = uploadForm.getFirst("keystoreFormat"); - if (keystoreFormatPart == null) { - throw new BadRequestException("keystoreFormat cannot be null"); - } - String keystoreFormat = keystoreFormatPart.asString(); - FormPartValue inputParts = uploadForm.getFirst("file"); - - boolean fileEmpty = false; - try { - fileEmpty = inputParts == null || Strings.isNullOrEmpty(inputParts.asString()); - } catch (Exception e) { - // ignore - } - - if (fileEmpty) { - throw new BadRequestException("file cannot be empty"); - } - - if (keystoreFormat.equals(CERTIFICATE_PEM)) { - String pem = StreamUtil.readString(inputParts.asInputStream(), StandardCharsets.UTF_8); - pem = PemUtils.removeBeginEnd(pem); - - // Validate format - KeycloakModelUtils.getCertificate(pem); - info.setCertificate(pem); + private void updateCertFromRequest(CertificateRepresentation info) { + if (OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol()) && info.getJwks() != null) { + CertificateInfoHelper.updateClientModelJwksString(client, attributePrefix, info.getJwks()); + } else { CertificateInfoHelper.updateClientModelCertificateInfo(client, info, attributePrefix); - return info; - } else if (keystoreFormat.equals(PUBLIC_KEY_PEM)) { - String pem = StreamUtil.readString(inputParts.asInputStream(), StandardCharsets.UTF_8); - - // Validate format - KeycloakModelUtils.getPublicKey(pem); - info.setPublicKey(pem); - CertificateInfoHelper.updateClientModelCertificateInfo(client, info, attributePrefix); - return info; - } else if (keystoreFormat.equals(JSON_WEB_KEY_SET)) { - String jwks = StreamUtil.readString(inputParts.asInputStream(), StandardCharsets.UTF_8); - - info = CertificateInfoHelper.jwksStringToSigCertificateRepresentation(jwks); - // jwks is only valid for OIDC clients - if (OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) { - CertificateInfoHelper.updateClientModelJwksString(client, attributePrefix, jwks); - } else { - CertificateInfoHelper.updateClientModelCertificateInfo(client, info, attributePrefix); - } - return info; } - - String keyAlias = uploadForm.getFirst("keyAlias").asString(); - FormPartValue keyPasswordPart = uploadForm.getFirst("keyPassword"); - char[] keyPassword = keyPasswordPart != null ? keyPasswordPart.asString().toCharArray() : null; - - FormPartValue storePasswordPart = uploadForm.getFirst("storePassword"); - char[] storePassword = storePasswordPart != null ? storePasswordPart.asString().toCharArray() : null; - PrivateKey privateKey = null; - X509Certificate certificate = null; - try { - KeyStore keyStore = CryptoIntegration.getProvider().getKeyStore(KeystoreFormat.valueOf(keystoreFormat)); - keyStore.load(inputParts.asInputStream(), storePassword); - try { - privateKey = (PrivateKey) keyStore.getKey(keyAlias, keyPassword); - } catch (Exception e) { - // ignore - } - certificate = (X509Certificate) keyStore.getCertificate(keyAlias); - } catch (Exception e) { - logger.error("Error loading keystore", e); - if (e.getCause() instanceof UnrecoverableKeyException keyException) { - throw new BadRequestException(keyException.getMessage()); - } else { - throw new BadRequestException("error loading keystore"); - } - } - - if (privateKey != null) { - String privateKeyPem = KeycloakModelUtils.getPemFromKey(privateKey); - info.setPrivateKey(privateKeyPem); - } - - if (certificate != null) { - String certPem = KeycloakModelUtils.getPemFromCertificate(certificate); - info.setCertificate(certPem); - } - - CertificateInfoHelper.updateClientModelCertificateInfo(client, info, attributePrefix); - return info; + adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).representation(info).success(); } /** diff --git a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java index 14457ec42b5..8cd82b211a9 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/IdentityProvidersResource.java @@ -18,6 +18,7 @@ package org.keycloak.services.resources.admin; import java.io.IOException; +import java.security.cert.X509Certificate; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -39,6 +40,7 @@ import jakarta.ws.rs.core.Response; import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.broker.provider.IdentityProviderFactory; import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.common.util.PemUtils; import org.keycloak.common.util.StreamUtil; import org.keycloak.connections.httpclient.HttpClientProvider; import org.keycloak.events.admin.OperationType; @@ -51,14 +53,18 @@ import org.keycloak.models.IdentityProviderType; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.models.utils.StripSecretsUtils; import org.keycloak.provider.ProviderFactory; +import org.keycloak.representations.idm.CertificateRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ErrorResponseException; import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator; +import org.keycloak.services.util.CertificateInfoHelper; import org.keycloak.utils.ReservedCharValidator; import org.keycloak.utils.StringUtil; @@ -133,6 +139,33 @@ public class IdentityProvidersResource { return providerFactory.parseConfig(session, config); } + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + @Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENT_ATTRIBUTE_CERTIFICATE) + @Operation( summary = "Uploads a certificate, prepares the jwks or public key associated, and returns the certificate representation.") + @Path("upload-certificate") + public CertificateRepresentation uploadCertificate() throws IOException { + auth.realm().requireManageIdentityProviders(); + try { + CertificateRepresentation info = CertificateInfoHelper.getCertificateFromRequest(session); + if (info.getJwks() != null || info.getPublicKey() != null) { + // uploaded a jwks or a publick key + return info; + } else if (info.getCertificate() != null) { + // get the key from the certificate file + X509Certificate certificate = KeycloakModelUtils.getCertificate(info.getCertificate()); + String pubKeyPem = PemUtils.encodeKey(certificate.getPublicKey()); + info.setPublicKey(pubKeyPem); + return info; + } else { + throw new ErrorResponseException("certificate-not-found", "Invalid certificate/key in file", Response.Status.BAD_REQUEST); + } + } catch (IllegalStateException ise) { + throw new ErrorResponseException("certificate-not-found", "Certificate or key error loding from uploaded file", Response.Status.BAD_REQUEST); + } + } + /** * Import identity provider from JSON body * diff --git a/services/src/main/java/org/keycloak/services/util/CertificateInfoHelper.java b/services/src/main/java/org/keycloak/services/util/CertificateInfoHelper.java index 38cd76d4d56..70007ead486 100644 --- a/services/src/main/java/org/keycloak/services/util/CertificateInfoHelper.java +++ b/services/src/main/java/org/keycloak/services/util/CertificateInfoHelper.java @@ -18,13 +18,27 @@ package org.keycloak.services.util; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.PrivateKey; import java.security.PublicKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; import java.util.HashMap; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.MultivaluedMap; + +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.util.KeystoreUtil; +import org.keycloak.common.util.PemUtils; +import org.keycloak.common.util.StreamUtil; +import org.keycloak.http.FormPartValue; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKParser; import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -32,12 +46,20 @@ import org.keycloak.representations.idm.CertificateRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.util.JWKSUtils; import org.keycloak.util.JsonSerialization; +import org.keycloak.util.Strings; + +import org.jboss.logging.Logger; /** * @author Marek Posolda */ public class CertificateInfoHelper { + public static final String CERTIFICATE_PEM = "Certificate PEM"; + public static final String PUBLIC_KEY_PEM = "Public Key PEM"; + public static final String JSON_WEB_KEY_SET = "JSON Web Key Set"; + + private static final Logger logger = Logger.getLogger(CertificateInfoHelper.class); public static final String PRIVATE_KEY = "private.key"; public static final String X509CERTIFICATE = "certificate"; @@ -83,9 +105,11 @@ public class CertificateInfoHelper { throw new IllegalStateException("Certificate not found for use sig"); } + // set the public key as before and also the full jwks PublicKey publicKey = JWKParser.create(publicKeyJwk).toPublicKey(); String publicKeyPem = KeycloakModelUtils.getPemFromKey(publicKey); CertificateRepresentation info = new CertificateRepresentation(); + info.setJwks(jwks); info.setPublicKey(publicKeyPem); info.setKid(publicKeyJwk.getKeyId()); return info; @@ -172,6 +196,88 @@ public class CertificateInfoHelper { setOrRemoveAttr(client, kidAttribute, rep.getKid()); } + public static CertificateRepresentation getCertificateFromRequest(KeycloakSession session) throws IOException { + CertificateRepresentation info = new CertificateRepresentation(); + MultivaluedMap uploadForm = session.getContext().getHttpRequest().getMultiPartFormParameters(); + FormPartValue keystoreFormatPart = uploadForm.getFirst("keystoreFormat"); + if (keystoreFormatPart == null) { + throw new BadRequestException("keystoreFormat cannot be null"); + } + String keystoreFormat = keystoreFormatPart.asString(); + FormPartValue inputParts = uploadForm.getFirst("file"); + + boolean fileEmpty = false; + try { + fileEmpty = inputParts == null || Strings.isEmpty(inputParts.asString()); + } catch (Exception e) { + // ignore + } + + if (fileEmpty) { + throw new BadRequestException("file cannot be empty"); + } + + if (keystoreFormat.equals(CERTIFICATE_PEM)) { + String pem = StreamUtil.readString(inputParts.asInputStream(), StandardCharsets.UTF_8); + pem = PemUtils.removeBeginEnd(pem); + + // Validate format + KeycloakModelUtils.getCertificate(pem); + info.setCertificate(pem); + return info; + } else if (keystoreFormat.equals(PUBLIC_KEY_PEM)) { + String pem = StreamUtil.readString(inputParts.asInputStream(), StandardCharsets.UTF_8); + + // Validate format + KeycloakModelUtils.getPublicKey(pem); + info.setPublicKey(pem); + return info; + } else if (keystoreFormat.equals(JSON_WEB_KEY_SET)) { + String jwks = StreamUtil.readString(inputParts.asInputStream(), StandardCharsets.UTF_8); + + info = CertificateInfoHelper.jwksStringToSigCertificateRepresentation(jwks); + return info; + } + + String keyAlias = uploadForm.getFirst("keyAlias").asString(); + FormPartValue keyPasswordPart = uploadForm.getFirst("keyPassword"); + char[] keyPassword = keyPasswordPart != null ? keyPasswordPart.asString().toCharArray() : null; + + FormPartValue storePasswordPart = uploadForm.getFirst("storePassword"); + char[] storePassword = storePasswordPart != null ? storePasswordPart.asString().toCharArray() : null; + PrivateKey privateKey = null; + X509Certificate certificate = null; + try { + KeyStore keyStore = CryptoIntegration.getProvider().getKeyStore(KeystoreUtil.KeystoreFormat.valueOf(keystoreFormat)); + keyStore.load(inputParts.asInputStream(), storePassword); + try { + privateKey = (PrivateKey) keyStore.getKey(keyAlias, keyPassword); + } catch (Exception e) { + // ignore + } + certificate = (X509Certificate) keyStore.getCertificate(keyAlias); + } catch (Exception e) { + logger.error("Error loading keystore", e); + if (e.getCause() instanceof UnrecoverableKeyException keyException) { + throw new BadRequestException(keyException.getMessage()); + } else { + throw new BadRequestException("error loading keystore"); + } + } + + if (privateKey != null) { + String privateKeyPem = KeycloakModelUtils.getPemFromKey(privateKey); + info.setPrivateKey(privateKeyPem); + } + + if (certificate != null) { + String certPem = KeycloakModelUtils.getPemFromCertificate(certificate); + info.setCertificate(certPem); + } + + return info; + } + private static void setOrRemoveAttr(ClientRepresentation client, String attrName, String attrValue) { if (attrValue != null) { if (client.getAttributes() == null) { diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java index 49696f1c739..71408feaa98 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java @@ -4,6 +4,7 @@ import java.util.List; import org.keycloak.OAuth2Constants; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.common.util.PemUtils; import org.keycloak.common.util.Time; import org.keycloak.crypto.Algorithm; import org.keycloak.events.Details; @@ -213,6 +214,49 @@ public abstract class AbstractJWTAuthorizationGrantTest extends BaseAbstractJWTA assertFailure("Invalid signature", response, events.poll()); } + @Test + public void testValidateSignatureFixedKey() { + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.FALSE.toString()); + rep.getConfig().put(OIDCIdentityProviderConfig.JWKS_URL, ""); + rep.getConfig().put(OIDCIdentityProviderConfig.PUBLIC_KEY_SIGNATURE_VERIFIER, + PemUtils.encodeKey(identityProvider.getKeys().getKeyWrapper().getPublicKey())); + }); + + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", response); + } + + @Test + public void testValidateSignatureFixedKeyAndKeyId() { + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.FALSE.toString()); + rep.getConfig().put(OIDCIdentityProviderConfig.JWKS_URL, ""); + rep.getConfig().put(OIDCIdentityProviderConfig.PUBLIC_KEY_SIGNATURE_VERIFIER, + PemUtils.encodeKey(identityProvider.getKeys().getKeyWrapper().getPublicKey())); + rep.getConfig().put(OIDCIdentityProviderConfig.PUBLIC_KEY_SIGNATURE_VERIFIER_KEY_ID, + identityProvider.getKeys().getKeyWrapper().getKid()); + }); + + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", response); + } + + @Test + public void testValidateSignatureFixedKeyUsingJwks() { + realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> { + rep.getConfig().put(OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.FALSE.toString()); + rep.getConfig().put(OIDCIdentityProviderConfig.JWKS_URL, ""); + rep.getConfig().put(OIDCIdentityProviderConfig.PUBLIC_KEY_SIGNATURE_VERIFIER, identityProvider.getKeys().getJwksString()); + }); + + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", response); + } + @Test public void testScope() { oAuthClient.openid(false).scope("address phone"); diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java index 442dedea9f7..5bd6011a4e4 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java @@ -28,6 +28,7 @@ public class JWTAuthorizationGrantTest extends AbstractJWTAuthorizationGrantTest .providerId(JWTAuthorizationGrantIdentityProviderFactory.PROVIDER_ID) .alias(IDP_ALIAS) .setAttribute(IdentityProviderModel.ISSUER, IDP_ISSUER) + .setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.TRUE.toString()) .setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks") .build()); return realm; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOIDCBrokerWithSignatureTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOIDCBrokerWithSignatureTest.java index 8f87a1b7452..1b703d76abd 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOIDCBrokerWithSignatureTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOIDCBrokerWithSignatureTest.java @@ -18,7 +18,10 @@ package org.keycloak.testsuite.broker; import java.io.Closeable; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriBuilder; @@ -42,6 +45,7 @@ import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.KeysMetadataRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.broker.util.SimpleHttpDefault; import org.keycloak.testsuite.client.resources.TestingCacheResource; import org.keycloak.testsuite.updaters.ClientAttributeUpdater; import org.keycloak.testsuite.util.AccountHelper; @@ -141,6 +145,19 @@ public class KcOIDCBrokerWithSignatureTest extends AbstractBaseBrokerTest { updateIdentityProvider(idpRep); } + private void updateIdentityProviderWithJwks() throws IOException { + IdentityProviderRepresentation idpRep = getIdentityProvider(); + OIDCIdentityProviderConfigRep cfg = new OIDCIdentityProviderConfigRep(idpRep); + cfg.setValidateSignature(true); + cfg.setUseJwksUrl(false); + + UriBuilder b = OIDCLoginProtocolService.certsUrl(UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT)); + String jwks = SimpleHttpDefault.doGet(b.build(bc.providerRealmName()).toString(), oauth.httpClient().get()).asString(); + cfg.setPublicKeySignatureVerifier(jwks); + cfg.setPublicKeySignatureVerifierKeyId(""); + updateIdentityProvider(idpRep); + } + @Test public void testSignatureVerificationHardcodedPublicKey() throws Exception { @@ -259,6 +276,95 @@ public class KcOIDCBrokerWithSignatureTest extends AbstractBaseBrokerTest { } } + @Test + public void testSignatureVerificationHardcodedPublicKeyEd25519() throws Exception { + IdentityProviderRepresentation idpRep = getIdentityProvider(); + OIDCIdentityProviderConfigRep cfg = new OIDCIdentityProviderConfigRep(idpRep); + cfg.setValidateSignature(true); + cfg.setUseJwksUrl(false); + + rotateKeys(Algorithm.EdDSA, "eddsa-generated", new MultivaluedHashMap<>( + Map.of("eddsaEllipticCurveKey", List.of(Algorithm.Ed25519)))); + + KeysMetadataRepresentation.KeyMetadataRepresentation key = org.keycloak.testsuite.util.KeyUtils.findActiveSigningKey(providerRealm(), Algorithm.EdDSA); + cfg.setPublicKeySignatureVerifier(key.getPublicKey()); + updateIdentityProvider(idpRep); + + try (Closeable clientUpdater = ClientAttributeUpdater.forClient(adminClient, bc.providerRealmName(), bc.getIDPClientIdInProviderRealm()) + .setAttribute(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG, Algorithm.EdDSA) + .setAttribute(OIDCConfigAttributes.AUTHORIZATION_SIGNED_RESPONSE_ALG, Algorithm.EdDSA) + .setAttribute(OIDCConfigAttributes.ID_TOKEN_SIGNED_RESPONSE_ALG, Algorithm.EdDSA) + .update()) { + + logInAsUserInIDPForFirstTime(); + appPage.assertCurrent(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + + logInAsUserInIDP(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + } + } + + @Test + public void testSignatureVerificationHardcodedPublicKeyEd448() throws Exception { + IdentityProviderRepresentation idpRep = getIdentityProvider(); + OIDCIdentityProviderConfigRep cfg = new OIDCIdentityProviderConfigRep(idpRep); + cfg.setValidateSignature(true); + cfg.setUseJwksUrl(false); + + rotateKeys(Algorithm.EdDSA, "eddsa-generated", new MultivaluedHashMap<>( + Map.of("eddsaEllipticCurveKey", List.of(Algorithm.Ed448)))); + + KeysMetadataRepresentation.KeyMetadataRepresentation key = org.keycloak.testsuite.util.KeyUtils.findActiveSigningKey(providerRealm(), Algorithm.EdDSA); + cfg.setPublicKeySignatureVerifier(key.getPublicKey()); + updateIdentityProvider(idpRep); + + try (Closeable clientUpdater = ClientAttributeUpdater.forClient(adminClient, bc.providerRealmName(), bc.getIDPClientIdInProviderRealm()) + .setAttribute(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG, Algorithm.EdDSA) + .setAttribute(OIDCConfigAttributes.AUTHORIZATION_SIGNED_RESPONSE_ALG, Algorithm.EdDSA) + .setAttribute(OIDCConfigAttributes.ID_TOKEN_SIGNED_RESPONSE_ALG, Algorithm.EdDSA) + .update()) { + + logInAsUserInIDPForFirstTime(); + appPage.assertCurrent(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + + logInAsUserInIDP(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + } + } + + @Test + public void testSignatureVerificationJwksAttributeRS256() throws Exception { + updateIdentityProviderWithJwks(); + + logInAsUserInIDPForFirstTime(); + appPage.assertCurrent(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + + logInAsUserInIDP(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + } + + @Test + public void testSignatureVerificationJwksAttributeES256() throws Exception { + rotateKeys(Algorithm.ES256, "ecdsa-generated"); + updateIdentityProviderWithJwks(); + try (Closeable clientUpdater = ClientAttributeUpdater.forClient(adminClient, bc.providerRealmName(), bc.getIDPClientIdInProviderRealm()) + .setAttribute(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG, Algorithm.ES256) + .setAttribute(OIDCConfigAttributes.AUTHORIZATION_SIGNED_RESPONSE_ALG, Algorithm.ES256) + .setAttribute(OIDCConfigAttributes.ID_TOKEN_SIGNED_RESPONSE_ALG, Algorithm.ES256) + .update()) { + + logInAsUserInIDPForFirstTime(); + appPage.assertCurrent(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + + logInAsUserInIDP(); + AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), bc.getUserLogin()); + } + } + @Test public void testSignatureVerificationHardcodedPublicKeyWithKeyIdSetExplicitly() throws Exception { // Configure OIDC identity provider with JWKS URL @@ -412,7 +518,8 @@ public class KcOIDCBrokerWithSignatureTest extends AbstractBaseBrokerTest { // Set the same "kid" of the default key and newly created key. // Assumption is that used algorithm RS512 is NOT the realm default one. When the realm default is updated to RS512, this one will need to change - ComponentRepresentation newKeyRep = createComponentRep(Algorithm.RS512, "rsa-generated", providerRealm().toRepresentation().getId()); + ComponentRepresentation newKeyRep = createComponentRep(Algorithm.RS512, "rsa-generated", + providerRealm().toRepresentation().getId(), new MultivaluedHashMap<>()); newKeyRep.getConfig().putSingle(Attributes.KID_KEY, activeKid); try (Response response = providerRealm().components().add(newKeyRep)) { assertEquals(201, response.getStatus()); @@ -435,11 +542,15 @@ public class KcOIDCBrokerWithSignatureTest extends AbstractBaseBrokerTest { } private void rotateKeys(String algorithm, String providerId) { + rotateKeys(algorithm, providerId, new MultivaluedHashMap<>()); + } + + private void rotateKeys(String algorithm, String providerId, MultivaluedHashMap extra) { String activeKid = providerRealm().keys().getKeyMetadata().getActive().get(algorithm); // Rotate public keys on the parent broker String realmId = providerRealm().toRepresentation().getId(); - ComponentRepresentation keys = createComponentRep(algorithm, providerId, realmId); + ComponentRepresentation keys = createComponentRep(algorithm, providerId, realmId, extra); try (Response response = providerRealm().components().add(keys)) { assertEquals(201, response.getStatus()); } @@ -448,15 +559,16 @@ public class KcOIDCBrokerWithSignatureTest extends AbstractBaseBrokerTest { assertNotEquals(activeKid, updatedActiveKid); } - private ComponentRepresentation createComponentRep(String algorithm, String providerId, String realmId) { + private ComponentRepresentation createComponentRep(String algorithm, String providerId, String realmId, MultivaluedHashMap extra) { ComponentRepresentation keys = new ComponentRepresentation(); keys.setName("generated"); keys.setProviderType(KeyProvider.class.getName()); keys.setProviderId(providerId); keys.setParentId(realmId); - keys.setConfig(new MultivaluedHashMap<>()); - keys.getConfig().putSingle("priority", Long.toString(System.currentTimeMillis())); - keys.getConfig().putSingle("algorithm", algorithm); + MultivaluedHashMap config = new MultivaluedHashMap<>(extra); + keys.setConfig(config); + config.putSingle("priority", Long.toString(System.currentTimeMillis())); + config.putSingle("algorithm", algorithm); return keys; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java index edd0cd869bd..6dc12ad4200 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java @@ -91,7 +91,11 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration { client.setAdminUrl(getConsumerRoot() + "/auth/realms/" + consumerRealmName() + "/broker/" + getIDPAlias() + "/endpoint"); - OIDCAdvancedConfigWrapper.fromClientRepresentation(client).setPostLogoutRedirectUris(Collections.singletonList("+")); + OIDCAdvancedConfigWrapper oidcClient = OIDCAdvancedConfigWrapper.fromClientRepresentation(client); + oidcClient.setPostLogoutRedirectUris(Collections.singletonList("+")); + + oidcClient.setBackchannelLogoutUrl(getConsumerRoot() + + "/auth/realms/" + consumerRealmName() + "/protocol/openid-connect/logout/backchannel-logout"); ProtocolMapperRepresentation emailMapper = new ProtocolMapperRepresentation(); emailMapper.setName("email"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerLogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerLogoutTest.java index 24838de6b13..756b7f7a09a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerLogoutTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerLogoutTest.java @@ -241,10 +241,14 @@ public class KcOidcBrokerLogoutTest extends AbstractKcOidcBrokerLogoutTest { Map config = representation.getConfig(); Map originalConfig = new HashMap<>(config); - try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, bc.consumerRealmName(), "broker-app") + try (ClientAttributeUpdater clientUpdaterConsumer = ClientAttributeUpdater.forClient(adminClient, bc.consumerRealmName(), "broker-app") .setFrontchannelLogout(true) .setAttribute(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI, getConsumerRoot() + "/auth/realms/" + bc.consumerRealmName() + "/app/logout") - .update()){ + .update(); + ClientAttributeUpdater clientUpdaterProvider = ClientAttributeUpdater.forClient(adminClient, bc.providerRealmName(), bc.getIDPClientIdInProviderRealm()) + .setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "") // use frontchannel in client logout + .update();) { + config.put("backchannelSupported", Boolean.FALSE.toString()); config.put("sendIdTokenOnLogout", Boolean.FALSE.toString()); config.put("sendClientIdOnLogout", Boolean.TRUE.toString()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java index b1cdc08068e..dd2846e2697 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java @@ -291,7 +291,7 @@ public abstract class AbstractClientAuthSignedJWTTest extends AbstractKeycloakTe try (BufferedWriter writer = Files.newBufferedWriter(tempFile)) { writer.write(ksInfo.getCertificateInfo().getCertificate()); } - testUploadKeystore(org.keycloak.services.resources.admin.ClientAttributeCertificateResource.CERTIFICATE_PEM, + testUploadKeystore(CertificateInfoHelper.CERTIFICATE_PEM, tempFile.toFile().getAbsolutePath(), "undefined", "undefined"); Files.delete(tempFile); @@ -309,7 +309,7 @@ public abstract class AbstractClientAuthSignedJWTTest extends AbstractKeycloakTe try (BufferedWriter writer = Files.newBufferedWriter(tempFile)) { writer.write(ksInfo.getCertificateInfo().getPublicKey()); } - testUploadKeystore(org.keycloak.services.resources.admin.ClientAttributeCertificateResource.PUBLIC_KEY_PEM, + testUploadKeystore(CertificateInfoHelper.PUBLIC_KEY_PEM, tempFile.toFile().getAbsolutePath(), "undefined", "undefined"); Files.delete(tempFile); @@ -540,11 +540,11 @@ public abstract class AbstractClientAuthSignedJWTTest extends AbstractKeycloakTe client = getClient(testRealm.getRealm(), client.getId()).toRepresentation(); // Assert the uploaded certificate - if (keystoreFormat.equals(org.keycloak.services.resources.admin.ClientAttributeCertificateResource.PUBLIC_KEY_PEM)) { + if (keystoreFormat.equals(CertificateInfoHelper.PUBLIC_KEY_PEM)) { String pem = new String(Files.readAllBytes(keystoreFile.toPath())); final String publicKeyNew = client.getAttributes().get(JWTClientAuthenticator.ATTR_PREFIX + "." + CertificateInfoHelper.PUBLIC_KEY); assertEquals("Certificates don't match", pem, publicKeyNew); - } else if (keystoreFormat.equals(org.keycloak.services.resources.admin.ClientAttributeCertificateResource.JSON_WEB_KEY_SET)) { + } else if (keystoreFormat.equals(CertificateInfoHelper.JSON_WEB_KEY_SET)) { Assert.assertEquals("true", client.getAttributes().get(OIDCConfigAttributes.USE_JWKS_STRING)); String jwks = new String(Files.readAllBytes(keystoreFile.toPath())); Assert.assertEquals(jwks, client.getAttributes().get(OIDCConfigAttributes.JWKS_STRING)); @@ -554,7 +554,7 @@ public abstract class AbstractClientAuthSignedJWTTest extends AbstractKeycloakTe // Just assert it's valid public key PublicKey pk = KeycloakModelUtils.getPublicKey(info.getPublicKey()); Assert.assertNotNull(pk); - } else if (keystoreFormat.equals(org.keycloak.services.resources.admin.ClientAttributeCertificateResource.CERTIFICATE_PEM)) { + } else if (keystoreFormat.equals(CertificateInfoHelper.CERTIFICATE_PEM)) { String pem = new String(Files.readAllBytes(keystoreFile.toPath())); assertCertificate(client, certOld, pem); } else { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java index cf4a01d52f5..e07585d857b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java @@ -43,6 +43,7 @@ import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.RefreshToken; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.services.util.CertificateInfoHelper; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.util.ClientManager; @@ -414,7 +415,7 @@ public class ClientAuthSignedJWTTest extends AbstractClientAuthSignedJWTTest { @Test public void testUploadJWKS() throws Exception { - testUploadKeystore(org.keycloak.services.resources.admin.ClientAttributeCertificateResource.JSON_WEB_KEY_SET, "clientreg-test/jwks.json", "undefined", "undefined"); + testUploadKeystore(CertificateInfoHelper.JSON_WEB_KEY_SET, "clientreg-test/jwks.json", "undefined", "undefined"); } // TEST ERRORS