Conditional login through identity provider (#20188)
Closes #20191 Co-authored-by: Jon Koops <jonkoops@gmail.com> Co-authored-by: andymunro <48995441+andymunro@users.noreply.github.com> Co-authored-by: Marek Posolda <mposolda@gmail.com>
This commit is contained in:
parent
e945a056bb
commit
e2ac9487f7
11 changed files with 357 additions and 7 deletions
|
@ -129,6 +129,14 @@ q=<name>:<value> <name>:<value> ...
|
||||||
|
|
||||||
Where `<name>` and `<value>` represent the attribute name and value, respectively.
|
Where `<name>` and `<value>` represent the attribute name and value, respectively.
|
||||||
|
|
||||||
|
= Essential claim configuration in OpenID Connect identity providers
|
||||||
|
|
||||||
|
OpenID Connect identity providers support a new configuration to specify that the ID tokens issued by the identity provider must have a specific claim,
|
||||||
|
otherwise the user can not authenticate through this broker.
|
||||||
|
|
||||||
|
The option is disabled by default; when it is enabled, you can specify the name of the JWT token claim to filter and the value to match
|
||||||
|
(supports regular expression format).
|
||||||
|
|
||||||
= LDAPS-only Truststore option removed
|
= LDAPS-only Truststore option removed
|
||||||
|
|
||||||
LDAP option to use truststore SPI `Only for ldaps` has been removed. This parameter is used to
|
LDAP option to use truststore SPI `Only for ldaps` has been removed. This parameter is used to
|
||||||
|
|
|
@ -58,6 +58,16 @@ Although each type of identity provider has its configuration options, all share
|
||||||
|GUI Order
|
|GUI Order
|
||||||
|The sort order of the available identity providers on the login page.
|
|The sort order of the available identity providers on the login page.
|
||||||
|
|
||||||
|
|
||||||
|
|Verify essential claim
|
||||||
|
|When *ON*, ID tokens issued by the identity provider must have a specific claim, otherwise, the user can not authenticate through this broker
|
||||||
|
|
||||||
|
|Essential claim
|
||||||
|
|When *Verify essential claim* is *ON*, the name of the JWT token claim to filter (match is case sensitive)
|
||||||
|
|
||||||
|
|Essential claim value
|
||||||
|
|When *Verify essential claim* is *ON*, the value of the JWT token claim to match (supports regular expression format)
|
||||||
|
|
||||||
|First Login Flow
|
|First Login Flow
|
||||||
|The authentication flow {project_name} triggers when users use this identity provider to log into {project_name} for the first time.
|
|The authentication flow {project_name} triggers when users use this identity provider to log into {project_name} for the first time.
|
||||||
|
|
||||||
|
|
|
@ -314,7 +314,7 @@ describe("Identity provider test", () => {
|
||||||
listingPage.goToItemDetails("github");
|
listingPage.goToItemDetails("github");
|
||||||
|
|
||||||
advancedSettings.typeScopesInput("openid");
|
advancedSettings.typeScopesInput("openid");
|
||||||
//advancedSettings.assertScopesInputEqual("openid"); //this line doesn't work
|
advancedSettings.assertScopesInputEqual("openid");
|
||||||
|
|
||||||
advancedSettings.assertStoreTokensSwitchTurnedOn(false);
|
advancedSettings.assertStoreTokensSwitchTurnedOn(false);
|
||||||
advancedSettings.assertAcceptsPromptNoneForwardFromClientSwitchTurnedOn(
|
advancedSettings.assertAcceptsPromptNoneForwardFromClientSwitchTurnedOn(
|
||||||
|
@ -331,7 +331,11 @@ describe("Identity provider test", () => {
|
||||||
advancedSettings.clickTrustEmailSwitch();
|
advancedSettings.clickTrustEmailSwitch();
|
||||||
advancedSettings.clickAccountLinkingOnlySwitch();
|
advancedSettings.clickAccountLinkingOnlySwitch();
|
||||||
advancedSettings.clickHideOnLoginPageSwitch();
|
advancedSettings.clickHideOnLoginPageSwitch();
|
||||||
|
advancedSettings.clickEssentialClaimSwitch();
|
||||||
|
advancedSettings.typeClaimNameInput("claim-name");
|
||||||
|
advancedSettings.typeClaimValueInput("claim-value");
|
||||||
|
|
||||||
|
advancedSettings.ensureAdvancedSettingsAreVisible();
|
||||||
advancedSettings.assertStoreTokensSwitchTurnedOn(true);
|
advancedSettings.assertStoreTokensSwitchTurnedOn(true);
|
||||||
advancedSettings.assertAcceptsPromptNoneForwardFromClientSwitchTurnedOn(
|
advancedSettings.assertAcceptsPromptNoneForwardFromClientSwitchTurnedOn(
|
||||||
true
|
true
|
||||||
|
@ -340,6 +344,9 @@ describe("Identity provider test", () => {
|
||||||
advancedSettings.assertTrustEmailSwitchTurnedOn(true);
|
advancedSettings.assertTrustEmailSwitchTurnedOn(true);
|
||||||
advancedSettings.assertAccountLinkingOnlySwitchTurnedOn(true);
|
advancedSettings.assertAccountLinkingOnlySwitchTurnedOn(true);
|
||||||
advancedSettings.assertHideOnLoginPageSwitchTurnedOn(true);
|
advancedSettings.assertHideOnLoginPageSwitchTurnedOn(true);
|
||||||
|
advancedSettings.assertEssentialClaimSwitchTurnedOn(true);
|
||||||
|
advancedSettings.assertClaimInputEqual("claim-name");
|
||||||
|
advancedSettings.assertClaimValueInputEqual("claim-value");
|
||||||
|
|
||||||
cy.findByTestId("idp-details-save").click();
|
cy.findByTestId("idp-details-save").click();
|
||||||
});
|
});
|
||||||
|
@ -355,6 +362,7 @@ describe("Identity provider test", () => {
|
||||||
);
|
);
|
||||||
advancedSettings.clickStoreTokensSwitch();
|
advancedSettings.clickStoreTokensSwitch();
|
||||||
advancedSettings.clickAcceptsPromptNoneForwardFromClientSwitch();
|
advancedSettings.clickAcceptsPromptNoneForwardFromClientSwitch();
|
||||||
|
advancedSettings.ensureAdvancedSettingsAreVisible();
|
||||||
advancedSettings.assertStoreTokensSwitchTurnedOn(false);
|
advancedSettings.assertStoreTokensSwitchTurnedOn(false);
|
||||||
advancedSettings.assertAcceptsPromptNoneForwardFromClientSwitchTurnedOn(
|
advancedSettings.assertAcceptsPromptNoneForwardFromClientSwitchTurnedOn(
|
||||||
false
|
false
|
||||||
|
|
|
@ -69,6 +69,9 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
|
||||||
private firstLoginFlowSelect = "#firstBrokerLoginFlowAlias";
|
private firstLoginFlowSelect = "#firstBrokerLoginFlowAlias";
|
||||||
private postLoginFlowSelect = "#postBrokerLoginFlowAlias";
|
private postLoginFlowSelect = "#postBrokerLoginFlowAlias";
|
||||||
private syncModeSelect = "#syncMode";
|
private syncModeSelect = "#syncMode";
|
||||||
|
private essentialClaimSwitch = "#filteredByClaim";
|
||||||
|
private claimNameInput = "#kc-claim-filter-name";
|
||||||
|
private claimValueInput = "#kc-claim-filter-value";
|
||||||
private addBtn = "createProvider";
|
private addBtn = "createProvider";
|
||||||
private saveBtn = "idp-details-save";
|
private saveBtn = "idp-details-save";
|
||||||
private revertBtn = "idp-details-revert";
|
private revertBtn = "idp-details-revert";
|
||||||
|
@ -94,6 +97,11 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ensureAdvancedSettingsAreVisible() {
|
||||||
|
cy.findByTestId("jump-link-general-settings").click();
|
||||||
|
cy.findByTestId("jump-link-advanced-settings").click();
|
||||||
|
}
|
||||||
|
|
||||||
public clickStoreTokensSwitch() {
|
public clickStoreTokensSwitch() {
|
||||||
cy.get(this.storeTokensSwitch).parent().click();
|
cy.get(this.storeTokensSwitch).parent().click();
|
||||||
return this;
|
return this;
|
||||||
|
@ -129,6 +137,21 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public clickEssentialClaimSwitch() {
|
||||||
|
cy.get(this.essentialClaimSwitch).parent().click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public typeClaimNameInput(text: string) {
|
||||||
|
cy.get(this.claimNameInput).type(text).blur();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public typeClaimValueInput(text: string) {
|
||||||
|
cy.get(this.claimValueInput).type(text).blur();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public selectFirstLoginFlowOption(loginFlowOption: LoginFlowOption) {
|
public selectFirstLoginFlowOption(loginFlowOption: LoginFlowOption) {
|
||||||
cy.get(this.firstLoginFlowSelect).click();
|
cy.get(this.firstLoginFlowSelect).click();
|
||||||
super.clickSelectMenuItem(
|
super.clickSelectMenuItem(
|
||||||
|
@ -182,7 +205,7 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public assertScopesInputEqual(text: string) {
|
public assertScopesInputEqual(text: string) {
|
||||||
cy.get(this.scopesInput).should("have.text", text).parent();
|
cy.get(this.scopesInput).should("have.value", text).parent();
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -230,6 +253,21 @@ export default class ProviderBaseGeneralSettingsPage extends PageObject {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public assertEssentialClaimSwitchTurnedOn(isOn: boolean) {
|
||||||
|
super.assertSwitchStateOn(cy.get(this.essentialClaimSwitch).parent(), isOn);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public assertClaimInputEqual(text: string) {
|
||||||
|
cy.get(this.claimNameInput).should("have.value", text).parent();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public assertClaimValueInputEqual(text: string) {
|
||||||
|
cy.get(this.claimValueInput).should("have.value", text).parent();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public assertFirstLoginFlowSelectOptionEqual(
|
public assertFirstLoginFlowSelectOptionEqual(
|
||||||
loginFlowOption: LoginFlowOption
|
loginFlowOption: LoginFlowOption
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -38,6 +38,9 @@
|
||||||
"trustEmail": "If enabled, email provided by this provider is not verified even if verification is enabled for the realm.",
|
"trustEmail": "If enabled, email provided by this provider is not verified even if verification is enabled for the realm.",
|
||||||
"accountLinkingOnly": "If true, users cannot log in through this provider. They can only link to this provider. This is useful if you don't want to allow login from the provider, but want to integrate with a provider",
|
"accountLinkingOnly": "If true, users cannot log in through this provider. They can only link to this provider. This is useful if you don't want to allow login from the provider, but want to integrate with a provider",
|
||||||
"hideOnLoginPage": "If hidden, login with this provider is possible only if requested explicitly, for example using the 'kc_idp_hint' parameter.",
|
"hideOnLoginPage": "If hidden, login with this provider is possible only if requested explicitly, for example using the 'kc_idp_hint' parameter.",
|
||||||
|
"filteredByClaim": "If true, ID tokens issued by the identity provider must have a specific claim. Otherwise, the user can not authenticate through this broker.",
|
||||||
|
"claimFilterName": "Name of the essential claim",
|
||||||
|
"claimFilterValue": "Value of the essential claim (with regex support)",
|
||||||
"firstBrokerLoginFlowAlias": "Alias of authentication flow, which is triggered after first login with this identity provider. Term 'First Login' means that no Keycloak account is currently linked to the authenticated identity provider account.",
|
"firstBrokerLoginFlowAlias": "Alias of authentication flow, which is triggered after first login with this identity provider. Term 'First Login' means that no Keycloak account is currently linked to the authenticated identity provider account.",
|
||||||
"postBrokerLoginFlowAlias": "Alias of authentication flow, which is triggered after each login with this identity provider. Useful if you want additional verification of each user authenticated with this identity provider (for example OTP). Leave this to \"None\" if you need no any additional authenticators to be triggered after login with this identity provider. Also note that authenticator implementations must assume that user is already set in ClientSession as identity provider already set it.",
|
"postBrokerLoginFlowAlias": "Alias of authentication flow, which is triggered after each login with this identity provider. Useful if you want additional verification of each user authenticated with this identity provider (for example OTP). Leave this to \"None\" if you need no any additional authenticators to be triggered after login with this identity provider. Also note that authenticator implementations must assume that user is already set in ClientSession as identity provider already set it.",
|
||||||
"syncMode": "Default sync mode for all mappers. The sync mode determines when user data will be synced using the mappers. Possible values are: 'legacy' to keep the behaviour before this option was introduced, 'import' to only import the user once during first login of the user with this identity provider, 'force' to always update the user during every login with this identity provider.",
|
"syncMode": "Default sync mode for all mappers. The sync mode determines when user data will be synced using the mappers. Possible values are: 'legacy' to keep the behaviour before this option was introduced, 'import' to only import the user once during first login of the user with this identity provider, 'force' to always update the user during every login with this identity provider.",
|
||||||
|
|
|
@ -149,6 +149,9 @@
|
||||||
"trustEmail": "Trust Email",
|
"trustEmail": "Trust Email",
|
||||||
"accountLinkingOnly": "Account linking only",
|
"accountLinkingOnly": "Account linking only",
|
||||||
"hideOnLoginPage": "Hide on login page",
|
"hideOnLoginPage": "Hide on login page",
|
||||||
|
"filteredByClaim": "Verify essential claim",
|
||||||
|
"claimFilterName": "Essential claim",
|
||||||
|
"claimFilterValue": "Essential claim value",
|
||||||
"firstBrokerLoginFlowAlias": "First login flow",
|
"firstBrokerLoginFlowAlias": "First login flow",
|
||||||
"postBrokerLoginFlowAlias": "Post login flow",
|
"postBrokerLoginFlowAlias": "Post login flow",
|
||||||
"syncMode": "Sync mode",
|
"syncMode": "Sync mode",
|
||||||
|
|
|
@ -1,20 +1,25 @@
|
||||||
import type AuthenticationFlowRepresentation from "@keycloak/keycloak-admin-client/lib/defs/authenticationFlowRepresentation";
|
import type AuthenticationFlowRepresentation from "@keycloak/keycloak-admin-client/lib/defs/authenticationFlowRepresentation";
|
||||||
|
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
|
||||||
import {
|
import {
|
||||||
FormGroup,
|
FormGroup,
|
||||||
Select,
|
Select,
|
||||||
SelectOption,
|
SelectOption,
|
||||||
SelectVariant,
|
SelectVariant,
|
||||||
|
Switch,
|
||||||
|
ValidatedOptions,
|
||||||
} from "@patternfly/react-core";
|
} from "@patternfly/react-core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { HelpItem } from "ui-shared";
|
import { HelpItem } from "ui-shared";
|
||||||
|
|
||||||
import { adminClient } from "../../admin-client";
|
import { adminClient } from "../../admin-client";
|
||||||
import { useFetch } from "../../utils/useFetch";
|
import { useFetch } from "../../utils/useFetch";
|
||||||
import type { FieldProps } from "../component/FormGroupField";
|
import type { FieldProps } from "../component/FormGroupField";
|
||||||
|
import { FormGroupField } from "../component/FormGroupField";
|
||||||
import { SwitchField } from "../component/SwitchField";
|
import { SwitchField } from "../component/SwitchField";
|
||||||
import { TextField } from "../component/TextField";
|
import { TextField } from "../component/TextField";
|
||||||
|
import { KeycloakTextInput } from "../../components/keycloak-text-input/KeycloakTextInput";
|
||||||
|
|
||||||
const LoginFlow = ({
|
const LoginFlow = ({
|
||||||
field,
|
field,
|
||||||
|
@ -93,8 +98,18 @@ type AdvancedSettingsProps = { isOIDC: boolean; isSAML: boolean };
|
||||||
|
|
||||||
export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
|
export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
|
||||||
const { t } = useTranslation("identity-providers");
|
const { t } = useTranslation("identity-providers");
|
||||||
const { control } = useFormContext();
|
const {
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<IdentityProviderRepresentation>();
|
||||||
const [syncModeOpen, setSyncModeOpen] = useState(false);
|
const [syncModeOpen, setSyncModeOpen] = useState(false);
|
||||||
|
const filteredByClaim = useWatch({
|
||||||
|
control,
|
||||||
|
name: "config.filteredByClaim",
|
||||||
|
defaultValue: "false",
|
||||||
|
});
|
||||||
|
const claimFilterRequired = filteredByClaim === "true";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isOIDC && !isSAML && (
|
{!isOIDC && !isSAML && (
|
||||||
|
@ -125,6 +140,88 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
|
||||||
/>
|
/>
|
||||||
<SwitchField field="config.hideOnLoginPage" label="hideOnLoginPage" />
|
<SwitchField field="config.hideOnLoginPage" label="hideOnLoginPage" />
|
||||||
|
|
||||||
|
{(!isSAML || isOIDC) && (
|
||||||
|
<FormGroupField label="filteredByClaim">
|
||||||
|
<Controller
|
||||||
|
name="config.filteredByClaim"
|
||||||
|
defaultValue="false"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
id="filteredByClaim"
|
||||||
|
label={t("common:on")}
|
||||||
|
labelOff={t("common:off")}
|
||||||
|
isChecked={field.value === "true"}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(value.toString());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormGroupField>
|
||||||
|
)}
|
||||||
|
{(!isSAML || isOIDC) && claimFilterRequired && (
|
||||||
|
<>
|
||||||
|
<FormGroup
|
||||||
|
label={t("identity-providers:claimFilterName")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText={t("identity-providers-help:claimFilterName")}
|
||||||
|
fieldLabelId="identity-providers:claimFilterName"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="kc-claim-filter-name"
|
||||||
|
isRequired
|
||||||
|
validated={
|
||||||
|
errors.config?.claimFilterName
|
||||||
|
? ValidatedOptions.error
|
||||||
|
: ValidatedOptions.default
|
||||||
|
}
|
||||||
|
helperTextInvalid={t("common:required")}
|
||||||
|
>
|
||||||
|
<KeycloakTextInput
|
||||||
|
isRequired
|
||||||
|
id="kc-claim-filter-name"
|
||||||
|
data-testid="claimFilterName"
|
||||||
|
validated={
|
||||||
|
errors.config?.claimFilterName
|
||||||
|
? ValidatedOptions.error
|
||||||
|
: ValidatedOptions.default
|
||||||
|
}
|
||||||
|
{...register("config.claimFilterName", { required: true })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
label={t("identity-providers:claimFilterValue")}
|
||||||
|
labelIcon={
|
||||||
|
<HelpItem
|
||||||
|
helpText={t("identity-providers-help:claimFilterValue")}
|
||||||
|
fieldLabelId="identity-providers:claimFilterName"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
fieldId="kc-claim-filter-value"
|
||||||
|
isRequired
|
||||||
|
validated={
|
||||||
|
errors.config?.claimFilterValue
|
||||||
|
? ValidatedOptions.error
|
||||||
|
: ValidatedOptions.default
|
||||||
|
}
|
||||||
|
helperTextInvalid={t("common:required")}
|
||||||
|
>
|
||||||
|
<KeycloakTextInput
|
||||||
|
isRequired
|
||||||
|
id="kc-claim-filter-value"
|
||||||
|
data-testid="claimFilterValue"
|
||||||
|
validated={
|
||||||
|
errors.config?.claimFilterValue
|
||||||
|
? ValidatedOptions.error
|
||||||
|
: ValidatedOptions.default
|
||||||
|
}
|
||||||
|
{...register("config.claimFilterValue", { required: true })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<LoginFlow
|
<LoginFlow
|
||||||
field="firstBrokerLoginFlowAlias"
|
field="firstBrokerLoginFlowAlias"
|
||||||
label="firstBrokerLoginFlowAlias"
|
label="firstBrokerLoginFlowAlias"
|
||||||
|
|
|
@ -36,6 +36,10 @@ public class IdentityProviderModel implements Serializable {
|
||||||
|
|
||||||
public static final String HIDE_ON_LOGIN = "hideOnLoginPage";
|
public static final String HIDE_ON_LOGIN = "hideOnLoginPage";
|
||||||
|
|
||||||
|
public static final String FILTERED_BY_CLAIMS = "filteredByClaim";
|
||||||
|
public static final String CLAIM_FILTER_NAME = "claimFilterName";
|
||||||
|
public static final String CLAIM_FILTER_VALUE = "claimFilterValue";
|
||||||
|
|
||||||
private String internalId;
|
private String internalId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -254,4 +258,28 @@ public class IdentityProviderModel implements Serializable {
|
||||||
public void setHideOnLogin(boolean hideOnLogin) {
|
public void setHideOnLogin(boolean hideOnLogin) {
|
||||||
getConfig().put(HIDE_ON_LOGIN, String.valueOf(hideOnLogin));
|
getConfig().put(HIDE_ON_LOGIN, String.valueOf(hideOnLogin));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isFilteredByClaims() {
|
||||||
|
return Boolean.valueOf(getConfig().getOrDefault(FILTERED_BY_CLAIMS, Boolean.toString(false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilteredByClaims(boolean filteredByClaims) {
|
||||||
|
getConfig().put(FILTERED_BY_CLAIMS, String.valueOf(filteredByClaims));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getClaimFilterName() {
|
||||||
|
return String.valueOf(getConfig().getOrDefault(CLAIM_FILTER_NAME, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClaimFilterName(String claimFilterName) {
|
||||||
|
getConfig().put(CLAIM_FILTER_NAME, claimFilterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getClaimFilterValue() {
|
||||||
|
return String.valueOf(getConfig().getOrDefault(CLAIM_FILTER_VALUE, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClaimFilterValue(String claimFilterValue) {
|
||||||
|
getConfig().put(CLAIM_FILTER_VALUE, claimFilterValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,9 @@ import jakarta.ws.rs.core.UriInfo;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;
|
import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;
|
||||||
|
|
||||||
|
@ -389,6 +392,30 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
|
||||||
throw new IdentityBrokerException("Mismatch between the subject in the id_token and the subject from the user_info endpoint");
|
throw new IdentityBrokerException("Mismatch between the subject in the id_token and the subject from the user_info endpoint");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (getConfig().isFilteredByClaims()) {
|
||||||
|
String filterName = getConfig().getClaimFilterName();
|
||||||
|
String filterValue = getConfig().getClaimFilterValue();
|
||||||
|
|
||||||
|
logger.tracef("Filtering user %s by %s=%s", idToken.getOtherClaims().get(getusernameClaimNameForIdToken()), filterName, filterValue);
|
||||||
|
if (idToken.getOtherClaims().containsKey(filterName)) {
|
||||||
|
Object claimObject = idToken.getOtherClaims().get(filterName);
|
||||||
|
List<String> claimValues = new ArrayList<>();
|
||||||
|
if (claimObject instanceof List) {
|
||||||
|
((List<?>)claimObject).forEach(v->claimValues.add(Objects.toString(v)));
|
||||||
|
} else {
|
||||||
|
claimValues.add(Objects.toString(claimObject));
|
||||||
|
}
|
||||||
|
logger.tracef("Found claim %s with values %s", filterName, claimValues);
|
||||||
|
if (!claimValues.stream().anyMatch(v->v.matches(filterValue))) {
|
||||||
|
logger.warnf("Claim %s has values \"%s\" that does not match the expected filter \"%s\"", filterName, claimValues, filterValue);
|
||||||
|
throw new IdentityBrokerException(String.format("Unmatched claim value for %s.", filterName));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debugf("Claim %s was not found", filterName);
|
||||||
|
throw new IdentityBrokerException(String.format("Claim %s not found", filterName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
identity.getContextData().put(BROKER_NONCE_PARAM, idToken.getOtherClaims().get(OIDCLoginProtocol.NONCE_PARAM));
|
identity.getContextData().put(BROKER_NONCE_PARAM, idToken.getOtherClaims().get(OIDCLoginProtocol.NONCE_PARAM));
|
||||||
|
|
||||||
if (getConfig().isStoreToken()) {
|
if (getConfig().isStoreToken()) {
|
||||||
|
|
|
@ -36,6 +36,8 @@ public abstract class AbstractInitializedBaseBrokerTest extends AbstractBaseBrok
|
||||||
|
|
||||||
protected IdentityProviderResource identityProviderResource;
|
protected IdentityProviderResource identityProviderResource;
|
||||||
|
|
||||||
|
protected void postInitializeUser(UserRepresentation user) {}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Before
|
@Before
|
||||||
public void beforeBrokerTest() {
|
public void beforeBrokerTest() {
|
||||||
|
@ -47,6 +49,7 @@ public abstract class AbstractInitializedBaseBrokerTest extends AbstractBaseBrok
|
||||||
user.setEmail(bc.getUserEmail());
|
user.setEmail(bc.getUserEmail());
|
||||||
user.setEmailVerified(true);
|
user.setEmailVerified(true);
|
||||||
user.setEnabled(true);
|
user.setEnabled(true);
|
||||||
|
postInitializeUser(user);
|
||||||
|
|
||||||
RealmResource realmResource = adminClient.realm(bc.providerRealmName());
|
RealmResource realmResource = adminClient.realm(bc.providerRealmName());
|
||||||
userId = createUserWithAdminClient(realmResource, user);
|
userId = createUserWithAdminClient(realmResource, user);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package org.keycloak.testsuite.broker;
|
package org.keycloak.testsuite.broker;
|
||||||
|
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
import com.google.common.collect.ImmutableMap;
|
import com.google.common.collect.ImmutableMap;
|
||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
import org.apache.http.impl.client.CloseableHttpClient;
|
import org.apache.http.impl.client.CloseableHttpClient;
|
||||||
|
@ -23,7 +24,10 @@ import org.keycloak.models.IdentityProviderMapperSyncMode;
|
||||||
import org.keycloak.models.IdentityProviderModel;
|
import org.keycloak.models.IdentityProviderModel;
|
||||||
import org.keycloak.models.IdentityProviderSyncMode;
|
import org.keycloak.models.IdentityProviderSyncMode;
|
||||||
import org.keycloak.models.utils.TimeBasedOTP;
|
import org.keycloak.models.utils.TimeBasedOTP;
|
||||||
|
import org.keycloak.protocol.ProtocolMapperUtils;
|
||||||
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
|
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
|
||||||
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
|
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
|
||||||
import org.keycloak.provider.ProviderConfigProperty;
|
import org.keycloak.provider.ProviderConfigProperty;
|
||||||
import org.keycloak.representations.idm.ClientRepresentation;
|
import org.keycloak.representations.idm.ClientRepresentation;
|
||||||
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
|
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
|
||||||
|
@ -42,8 +46,10 @@ import org.keycloak.testsuite.util.WaitUtils;
|
||||||
|
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@ -66,10 +72,13 @@ import static org.keycloak.testsuite.broker.BrokerTestTools.getProviderRoot;
|
||||||
* Final class as it's not intended to be overriden. Feel free to remove "final" if you really know what you are doing.
|
* Final class as it's not intended to be overriden. Feel free to remove "final" if you really know what you are doing.
|
||||||
*/
|
*/
|
||||||
public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
|
public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
|
||||||
|
private final static String USER_ATTRIBUTE_NAME = "user-attribute";
|
||||||
|
private final static String USER_ATTRIBUTE_VALUE = "attribute-value";
|
||||||
|
private final static String CLAIM_FILTER_REGEXP = ".*-value";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected BrokerConfiguration getBrokerConfiguration() {
|
protected BrokerConfiguration getBrokerConfiguration() {
|
||||||
return KcOidcBrokerConfiguration.INSTANCE;
|
return BROKER_CONFIG_INSTANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
|
@ -164,6 +173,10 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void loginFetchingUserFromUserEndpoint() {
|
public void loginFetchingUserFromUserEndpoint() {
|
||||||
|
loginFetchingUserFromUserEndpoint(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loginFetchingUserFromUserEndpoint(boolean loginIsDenied) {
|
||||||
RealmResource realm = realmsResouce().realm(bc.providerRealmName());
|
RealmResource realm = realmsResouce().realm(bc.providerRealmName());
|
||||||
ClientsResource clients = realm.clients();
|
ClientsResource clients = realm.clients();
|
||||||
ClientRepresentation brokerApp = clients.findByClientId("brokerapp").get(0);
|
ClientRepresentation brokerApp = clients.findByClientId("brokerapp").get(0);
|
||||||
|
@ -184,7 +197,11 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
|
||||||
|
|
||||||
logInWithBroker(bc);
|
logInWithBroker(bc);
|
||||||
|
|
||||||
waitForPage(driver, "update account information", false);
|
waitForPage(driver, loginIsDenied? "We are sorry..." : "update account information", false);
|
||||||
|
if (loginIsDenied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateAccountInformationPage.assertCurrent();
|
updateAccountInformationPage.assertCurrent();
|
||||||
Assert.assertTrue("We must be on correct realm right now",
|
Assert.assertTrue("We must be on correct realm right now",
|
||||||
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
|
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
|
||||||
|
@ -495,6 +512,84 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
|
||||||
checkUpdatedUserAttributesIdP(false);
|
checkUpdatedUserAttributesIdP(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void loginWithClaimFilter() {
|
||||||
|
IdentityProviderResource identityProviderResource = getIdentityProviderResource();
|
||||||
|
|
||||||
|
IdentityProviderRepresentation identityProvider = identityProviderResource.toRepresentation();
|
||||||
|
updateIdPClaimFilter(identityProvider, identityProviderResource, true, USER_ATTRIBUTE_NAME, USER_ATTRIBUTE_VALUE);
|
||||||
|
|
||||||
|
WaitUtils.waitForPageToLoad();
|
||||||
|
|
||||||
|
loginFetchingUserFromUserEndpoint();
|
||||||
|
|
||||||
|
UserRepresentation user = getFederatedIdentity();
|
||||||
|
|
||||||
|
Assert.assertNotNull(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void loginWithClaimRegexpFilter() {
|
||||||
|
IdentityProviderResource identityProviderResource = getIdentityProviderResource();
|
||||||
|
|
||||||
|
IdentityProviderRepresentation identityProvider = identityProviderResource.toRepresentation();
|
||||||
|
updateIdPClaimFilter(identityProvider, identityProviderResource, true, USER_ATTRIBUTE_NAME, CLAIM_FILTER_REGEXP);
|
||||||
|
|
||||||
|
WaitUtils.waitForPageToLoad();
|
||||||
|
|
||||||
|
loginFetchingUserFromUserEndpoint();
|
||||||
|
|
||||||
|
UserRepresentation user = getFederatedIdentity();
|
||||||
|
|
||||||
|
Assert.assertNotNull(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void denyLoginWithClaimFilter() {
|
||||||
|
IdentityProviderResource identityProviderResource = getIdentityProviderResource();
|
||||||
|
|
||||||
|
IdentityProviderRepresentation identityProvider = identityProviderResource.toRepresentation();
|
||||||
|
updateIdPClaimFilter(identityProvider, identityProviderResource, true, "hardcoded-missing-claim", "hardcoded-missing-claim-value");
|
||||||
|
WaitUtils.waitForPageToLoad();
|
||||||
|
|
||||||
|
loginFetchingUserFromUserEndpoint(true);
|
||||||
|
|
||||||
|
List<UserRepresentation> users = realmsResouce().realm(bc.consumerRealmName()).users().search(bc.getUserLogin());
|
||||||
|
assertThat(users, Matchers.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void postInitializeUser(UserRepresentation user) {
|
||||||
|
user.setAttributes(ImmutableMap.<String, List<String>> builder()
|
||||||
|
.put(USER_ATTRIBUTE_NAME, ImmutableList.<String> builder().add(USER_ATTRIBUTE_VALUE).build())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void updateIdPClaimFilter(IdentityProviderRepresentation idProvider, IdentityProviderResource idProviderResource, boolean filteredByClaim, String claimFilterName, String claimFilterValue) {
|
||||||
|
assertThat(idProvider, Matchers.notNullValue());
|
||||||
|
assertThat(idProviderResource, Matchers.notNullValue());
|
||||||
|
assertThat(claimFilterName, Matchers.notNullValue());
|
||||||
|
assertThat(claimFilterValue, Matchers.notNullValue());
|
||||||
|
|
||||||
|
if (idProvider.getConfig().getOrDefault(IdentityProviderModel.FILTERED_BY_CLAIMS, "false").equals(Boolean.toString(filteredByClaim)) &&
|
||||||
|
idProvider.getConfig().getOrDefault(IdentityProviderModel.CLAIM_FILTER_NAME, "").equals(claimFilterName) &&
|
||||||
|
idProvider.getConfig().getOrDefault(IdentityProviderModel.CLAIM_FILTER_VALUE, "").equals(claimFilterValue)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
idProvider.getConfig().put(IdentityProviderModel.FILTERED_BY_CLAIMS, Boolean.toString(filteredByClaim));
|
||||||
|
idProvider.getConfig().put(IdentityProviderModel.CLAIM_FILTER_NAME, claimFilterName);
|
||||||
|
idProvider.getConfig().put(IdentityProviderModel.CLAIM_FILTER_VALUE, claimFilterValue);
|
||||||
|
idProviderResource.update(idProvider);
|
||||||
|
|
||||||
|
idProvider = idProviderResource.toRepresentation();
|
||||||
|
assertThat("Cannot get Identity Provider", idProvider, Matchers.notNullValue());
|
||||||
|
assertThat("Filtered by claim didn't change", idProvider.getConfig().get(IdentityProviderModel.FILTERED_BY_CLAIMS), Matchers.equalTo(Boolean.toString(filteredByClaim)));
|
||||||
|
assertThat("Claim name didn't change", idProvider.getConfig().get(IdentityProviderModel.CLAIM_FILTER_NAME), Matchers.equalTo(claimFilterName));
|
||||||
|
assertThat("Claim value didn't change", idProvider.getConfig().get(IdentityProviderModel.CLAIM_FILTER_VALUE), Matchers.equalTo(claimFilterValue));
|
||||||
|
}
|
||||||
|
|
||||||
private void checkUpdatedUserAttributesIdP(boolean isForceSync) {
|
private void checkUpdatedUserAttributesIdP(boolean isForceSync) {
|
||||||
final String IDP_NAME = getBrokerConfiguration().getIDPAlias();
|
final String IDP_NAME = getBrokerConfiguration().getIDPAlias();
|
||||||
final String USERNAME = "demo-user";
|
final String USERNAME = "demo-user";
|
||||||
|
@ -625,4 +720,34 @@ public final class KcOidcBrokerTest extends AbstractAdvancedBrokerTest {
|
||||||
private IdentityProviderResource getIdentityProviderResource() {
|
private IdentityProviderResource getIdentityProviderResource() {
|
||||||
return realmsResouce().realm(bc.consumerRealmName()).identityProviders().get(bc.getIDPAlias());
|
return realmsResouce().realm(bc.consumerRealmName()).identityProviders().get(bc.getIDPAlias());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final CustomKcOidcBrokerConfiguration BROKER_CONFIG_INSTANCE = new CustomKcOidcBrokerConfiguration();
|
||||||
|
static class CustomKcOidcBrokerConfiguration extends KcOidcBrokerConfiguration {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ClientRepresentation> createProviderClients() {
|
||||||
|
List<ClientRepresentation> clients = super.createProviderClients();
|
||||||
|
|
||||||
|
ClientRepresentation client = clients.get(0);
|
||||||
|
ProtocolMapperRepresentation userAttrMapper = new ProtocolMapperRepresentation();
|
||||||
|
userAttrMapper.setName(USER_ATTRIBUTE_NAME);
|
||||||
|
userAttrMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||||
|
userAttrMapper.setProtocolMapper(UserAttributeMapper.PROVIDER_ID);
|
||||||
|
|
||||||
|
Map<String, String> userAttrMapperConfig = userAttrMapper.getConfig();
|
||||||
|
userAttrMapperConfig.put(ProtocolMapperUtils.USER_ATTRIBUTE, USER_ATTRIBUTE_NAME);
|
||||||
|
userAttrMapperConfig.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, USER_ATTRIBUTE_NAME);
|
||||||
|
userAttrMapperConfig.put(OIDCAttributeMapperHelper.JSON_TYPE, ProviderConfigProperty.STRING_TYPE);
|
||||||
|
userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
|
||||||
|
userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
|
||||||
|
userAttrMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true");
|
||||||
|
userAttrMapperConfig.put(ProtocolMapperUtils.MULTIVALUED, "false");
|
||||||
|
userAttrMapperConfig.put(ProtocolMapperUtils.AGGREGATE_ATTRS, "false");
|
||||||
|
List<ProtocolMapperRepresentation> mappers = new ArrayList<>(client.getProtocolMappers());
|
||||||
|
mappers.add(userAttrMapper);
|
||||||
|
client.setProtocolMappers(mappers);
|
||||||
|
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue