mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Step up authentication for saml - preview (#44185)
Closes #10155 Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
@@ -95,6 +95,7 @@ public class Profile {
|
||||
CLIENT_SECRET_ROTATION("Client Secret Rotation", Type.PREVIEW),
|
||||
|
||||
STEP_UP_AUTHENTICATION("Step-up Authentication", Type.DEFAULT),
|
||||
STEP_UP_AUTHENTICATION_SAML("Step-up Authentication Saml", Type.PREVIEW, Feature.STEP_UP_AUTHENTICATION),
|
||||
|
||||
CLIENT_AUTH_FEDERATED("Authenticates client based on assertions issued by identity provider", Type.PREVIEW),
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ When using the {project_name} Operator, set the update strategy to `Auto` to ben
|
||||
|
||||
For more details on the Operator configuration, see the https://www.keycloak.org/operator/rolling-updates[Avoiding downtime with rolling updates] guide.
|
||||
|
||||
= Step-up authentication for SAML (preview)
|
||||
|
||||
The feature `step-up-authentication-saml` extends the step-up authentication to include the SAML protocol and clients. This feature is in preview mode. Additional information is available in the link:{adminguide_link}#_step-up-authentication-saml[{adminguide_name}].
|
||||
|
||||
= Java 25 support
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -332,7 +332,7 @@ Finally, change the bindings.
|
||||
.Browser login with step-up mechanism
|
||||
image:images/authentication-step-up-flow.png[Authentication step up flow]
|
||||
|
||||
.Request a certain authentication level
|
||||
.Request a certain authentication level with OpenID Connect
|
||||
To use the step-up mechanism, you specify a requested level of authentication (LoA) in your authentication request. The `claims` parameter is used for this purpose:
|
||||
|
||||
[source,subs=+attributes]
|
||||
@@ -426,6 +426,88 @@ Note when the login request initiates a request with the `claims` parameter requ
|
||||
one of the specified levels. If it is not able to return one of the specified levels (For example if the requested level is unknown or bigger than configured conditions
|
||||
in the authentication flow), then {project_name} will throw an error.
|
||||
|
||||
[[_step-up-authentication-saml]]
|
||||
==== Step-up authentication for SAML
|
||||
|
||||
:tech_feature_name: Step-up Authentication for SAML
|
||||
:tech_feature_id: step-up-authentication-saml
|
||||
include::../../topics/templates/techpreview.adoc[]
|
||||
|
||||
For the SAML protocol, the step-up authentication uses the `<AuthnContextClassRef>` element (Authentication Context Class Reference or ACR) to map the Level of Authentication (LoA). This element is a URI reference that identifies an authentication context declaration. The LoA is requested by the client in the SAML request via the `<RequestedAuthnContext>` element.
|
||||
|
||||
[source,xml,subs=+attributes]
|
||||
----
|
||||
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" AssertionConsumerServiceURL="https://sp.example.com/" Destination="https://localhost:8543/realms/demo/protocol/saml" ForceAuthn="false" ID="1cc6ba19-b34d-48d5-ac44-3c4b2598b6c3" IssueInstant="2025-12-10T16:29:41.177Z" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0">
|
||||
<saml:Issuer>https://sp.example.com/</saml:Issuer>
|
||||
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient"/>
|
||||
<samlp:RequestedAuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken</saml:AuthnContextClassRef>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
|
||||
</samlp:RequestedAuthnContext>
|
||||
</samlp:AuthnRequest>
|
||||
----
|
||||
|
||||
The request asks for a set of authentication contexts that will be evaluated in order by {project_name} until one of them can be satisfied. This means that {project_name} knows that URI declaration and there is a level defined in the mapping that is sufficient for the request. If none of the specified contexts can be satisfied, then {project_name} returns a `<Response>` message with a second-level `<StatusCode>` of `urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext`.
|
||||
|
||||
[source,xml,subs=+attributes]
|
||||
----
|
||||
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Destination="https://sp.example.com/" ID="ID_70625c64-004a-44cc-b338-74802f0cc37d" InResponseTo="b935f66a-8185-40b6-990c-bd5f4971773e" IssueInstant="2025-12-10T17:00:46.894Z" Version="2.0">
|
||||
<saml:Issuer>https://localhost:8543/auth/realms/demo</saml:Issuer>
|
||||
<samlp:Status>
|
||||
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Responder">
|
||||
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext"/>
|
||||
</samlp:StatusCode>
|
||||
</samlp:Status>
|
||||
</samlp:Response>
|
||||
----
|
||||
|
||||
If {project_name} can satisfy the request, the step-up authentication flow is called to authenticate the user with the mapped level. The SAML response, using the `<AuthnStatement>` element inside the successful response, will contain again the `<AuthnContextClassRef>` with the context finally applied.
|
||||
|
||||
[source,xml,subs=+attributes]
|
||||
----
|
||||
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Destination="https://sp.example.com/" ID="ID_079be770-ace4-4a0b-bdad-2042267a9bb9" InResponseTo="1cc6ba19-b34d-48d5-ac44-3c4b2598b6c3" IssueInstant="2025-12-10T16:29:41.228Z" Version="2.0">
|
||||
<saml:Issuer>https://localhost:8543/auth/realms/demo</saml:Issuer>
|
||||
<samlp:Status>
|
||||
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
||||
</samlp:Status>
|
||||
<saml:Assertion xmlns="urn:oasis:names:tc:SAML:2.0:assertion" ID="ID_0278d50c-03c2-4ae0-accd-16161577faa5" IssueInstant="2025-12-10T16:29:41.228Z" Version="2.0">
|
||||
<saml:Issuer>https://localhost:8543/auth/realms/demo</saml:Issuer>
|
||||
<saml:Subject>
|
||||
<saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">G-b1e11e8a-c002-47bc-8060-64f6f11fe267</saml:NameID>
|
||||
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||
<saml:SubjectConfirmationData InResponseTo="1cc6ba19-b34d-48d5-ac44-3c4b2598b6c3" NotOnOrAfter="2025-12-10T16:34:39.228Z" Recipient="https://sp.example.com/"/>
|
||||
</saml:SubjectConfirmation>
|
||||
</saml:Subject>
|
||||
<saml:Conditions NotBefore="2025-12-10T16:29:39.228Z" NotOnOrAfter="2025-12-10T16:30:39.228Z">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>https://sp.example.com/</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="2025-12-10T16:29:41.229Z" SessionIndex="pZJ1Scsa1MyQ2KkesFEbedPq::56687d60-82d9-49bd-8609-dbc38153e55f" SessionNotOnOrAfter="2025-12-11T02:29:41.229Z">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
</saml:Assertion>
|
||||
</samlp:Response>
|
||||
----
|
||||
|
||||
For more information about the processing rules defined in the SAML standard for the `<RequestedAuthnContext>` element, see Section 3.3.2.2.1 Element `<RequestedAuthnContext>` of the *Assertions and Protocols for the OASIS Security Assertion Markup Language (SAML) V2.0* (`saml-core-2.0-os.pdf` document). The complete SAML v2.0 OASIS Standard set (PDF format) and schema files are available in the link:https://www.oasis-open.org/standard/saml/[Security Assertion Markup Language (SAML) v2.0] page. Note that the spec also defines a comparison (exact, minimum, better and maximum), that complicates what Level of Authentication will be finally selected in {project_name}.
|
||||
|
||||
When the `step-up-authentication-saml` feature is enabled, the <<_mapping-acr-to-loa-realm,ACR to Level of Authentication (LoA) Mapping>> is a table with three values: the OpenID Connect ACR, the SAML URI context and the Level of Authentication (LoA). The mapping can be defined for both client types. It can also be overridden at client level, but, in this case, only the URI and the LoA are present. The minimum ACR value which is allowed for the client can also be defined in the configuration. The step-up authentication options for SAML clients are placed in the **Advanced** tab, section **Advanced Settings**. See chapter <<_client-saml-configuration,Creating a SAML client>> for details.
|
||||
|
||||
In summary, when the step-up authentication is configured for SAML, {project_name} will process the specified context level in the SAML request and the minimum ACR allowed for the client (if they are present) to obtain the LoA (integer level) that should be reached in the authentication. If there is no available level that satisfies the request, the error is returned per specification. If the URI/LoA mapping returns a level that satisfies the request, the authentication flow is started, enforcing that Level of Authentication to be reached.
|
||||
|
||||
In order to maintain backwards compatibility, {project_name} does not return an error and continues adding the previous ACR `urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified` to the response in the following situations:
|
||||
|
||||
* The new feature `step-up-authentication-saml` is not enabled in {project_name}.
|
||||
* The SAML client does not define any mapping between context URI and LoA. Use the client mapping instead of the general realm mapping when you just need to apply the step-up for some specific SAML clients.
|
||||
* The `AuthnContextClassRef mapper` is not executed. This mapper is provided by a new default client scope `AuthnContextClassRef` which is now added to SAML clients by default. It is in charge of adding the resulting `<AuthnContextClassRef>` to the response.
|
||||
+
|
||||
NOTE: For new realms created with the `step-up-authentication-saml` feature enabled, the mapper and the client scope `AuthnContextClassRef` are automatically created and assigned to SAML clients. But, for exiting realms, if you want to use this preview feature, the client scope and the mapper should be created and assigned to the client manually. When the feature is promoted to supported, the migration will also create the client scope for existing realms if the feature is not disabled at startup.
|
||||
+
|
||||
* The LoA calculated at request time is not achieved by the authentication flow. For example, if the authentication flow used for authentication is not a step-up flow, or there is a misconfiguration between the URI/LoA mapping and the final level reached in the step-up authentication flow.
|
||||
|
||||
[[_registration-rc-client-flows]]
|
||||
==== Registration or Reset credentials requested by client
|
||||
|
||||
|
||||
@@ -180,3 +180,7 @@ This tab has many fields for specific situations. Some fields are covered in ot
|
||||
=== Advanced settings
|
||||
|
||||
*Assertion Lifespan*:: Specific client lifespan set in the SAML assertion conditions. After that time the assertion will be invalid. If not specified the realm *Access Token Lifespan* is used. The `SessionNotOnOrAfter` attribute is not modified and continue using the *SSO Session Max* time defined at realm level.
|
||||
|
||||
*ACR to LoA Mapping*:: Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR for SAML is an URI, whereas the LoA must be numeric. This mapping overrides the <<_mapping-acr-to-loa-realm,ACR to Level of Authentication (LoA) Mapping>> defined at realm level. Only present if <<_step-up-authentication-saml,Step-up authentication for SAML>> feature is enabled.
|
||||
|
||||
*Minimum ACR Value*:: Minimum ACR to be enforced by Keycloak. If the resulting authentication context for the request is as strong as this ACR the request is valid, otherwise Keycloak returns the `NoAuthnContext` status error. Only present if <<_step-up-authentication-saml,Step-up authentication for SAML>> feature is enabled.
|
||||
|
||||
@@ -6,6 +6,12 @@ The acr claim can be requested in the `claims` or `acr_values` parameter sent in
|
||||
|
||||
Mapping can be also specified at the client level in case that particular client needs to use different values than realm. However, a best practice is to stick to realm mappings.
|
||||
|
||||
.ACR to LoA mapping
|
||||
image:images/realm-oidc-map-acr-to-loa.png[alt="ACR to LoA mapping"]
|
||||
|
||||
For further details see <<_step-up-flow,Step-up Authentication>> and https://openid.net/specs/openid-connect-core-1_0.html#acrSemantics[the official OIDC specification].
|
||||
|
||||
If the feature for <<_step-up-authentication-saml,Step-up authentication for SAML>> is enabled, the ACR to LoA mapping is a table with three values. The new URI column is the URI that will map the SAML authentication context class reference to the numeric LoA. This new column is necessary if you want to use step-up authentication with the SAML protocol.
|
||||
|
||||
.ACR/URI to LoA mapping
|
||||
image:images/realm-oidc-map-acr-uri-to-loa.png[alt="ACR/URI to LoA mapping"]
|
||||
|
||||
+12
@@ -1445,6 +1445,7 @@ deleteConfirmGroup_other=Are you sure you want to delete these groups?
|
||||
scopePermissions.users.manage-description=Policies that decide if an administrator can manage all users in the realm
|
||||
defaultACRValuesHelp=Default values to be used as voluntary ACR in case that there is no explicit ACR requested by 'claims' or 'acr_values' parameter in the OIDC request.
|
||||
minimumACRValueHelp=Minimum ACR to be enforced by Keycloak. Overrides lower ACRs explicitly requested by 'acr_values' or 'claims', unless they are marked as essential.
|
||||
minimumACRValueSamlHelp=Minimum ACR to be enforced by Keycloak. If the resulting authentication context for the request is as strong as this ACR the request is valid, otherwise Keycloak returns the NoAuthnContext status error.
|
||||
membershipAttributeType=Membership attribute type
|
||||
eventTypes.PUSHED_AUTHORIZATION_REQUEST.name=Pushed authorization request
|
||||
included.client.audience.tooltip=The Client ID of the specified audience client will be included in the audience (aud) field of the token. If the token includes audiences, the specified value is added to them. It will not override existing audiences.
|
||||
@@ -1463,6 +1464,15 @@ prompts.select_account=Select account
|
||||
defaultACRValues=Default ACR Values
|
||||
minimumACRValue=Minimum ACR Value
|
||||
valueError=A value must be provided.
|
||||
loa=LoA
|
||||
uri=URI
|
||||
acr=ACR
|
||||
loaError=Invalid LoA
|
||||
uriError=Invalid URI
|
||||
acrError=Invalid ACR
|
||||
loaPlaceholder=Type a LoA
|
||||
uriPlaceholder=Type an URI
|
||||
acrPlaceholder=Type an ACR
|
||||
noConsents=No consents
|
||||
orderChangeSuccessUserFed=Successfully changed the priority order of user federation providers
|
||||
noUsersEmptyStateDescriptionContinued=to find them. Users that already have this role as an effective role cannot be added here.
|
||||
@@ -2792,6 +2802,8 @@ key=Key
|
||||
email=Email
|
||||
groupDeleted_other=Groups deleted
|
||||
acrToLoAMappingHelp=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR can be any value, whereas the LoA must be numeric.
|
||||
acrToLoAMappingSamlHelp=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR for SAML is an URI, whereas the LoA must be numeric.
|
||||
acrToLoAMappingRealmSamlHelp=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR can be any value and is used in OpenID Connect, the URI is the authentication context for SAML and it is an URI, finally the LoA must be numeric.
|
||||
uploadFile=Upload JSON file
|
||||
loginActionTimeoutHelp=Max time a user has to complete login related actions like update password or configure totp. This is recommended to be relatively long, such as 5 minutes or more.
|
||||
identityProviders=Identity providers
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared";
|
||||
import { ActionGroup, Button, FormGroup } from "@patternfly/react-core";
|
||||
import {
|
||||
HelpItem,
|
||||
TextControl,
|
||||
SelectControl,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
ActionGroup,
|
||||
Button,
|
||||
FormGroup,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DefaultSwitchControl } from "../../components/SwitchControl";
|
||||
@@ -11,6 +20,7 @@ import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { convertAttributeNameToForm } from "../../util";
|
||||
import { FormFields } from "../ClientDetails";
|
||||
import { TokenLifespan } from "./TokenLifespan";
|
||||
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";
|
||||
|
||||
type AdvancedSettingsProps = {
|
||||
save: () => void;
|
||||
@@ -29,39 +39,110 @@ export const AdvancedSettings = ({
|
||||
|
||||
const { realmRepresentation: realm } = useRealm();
|
||||
|
||||
const { control } = useFormContext();
|
||||
const { control, watch, register } = useFormContext();
|
||||
|
||||
const acrUriMapRealm = realm?.attributes?.["acr.uri.map"]
|
||||
? Object.values(JSON.parse(realm.attributes["acr.uri.map"]))
|
||||
: [];
|
||||
|
||||
const acrLoAMapClient = watch(
|
||||
convertAttributeNameToForm<FormFields>("attributes.acr.loa.map"),
|
||||
[],
|
||||
);
|
||||
|
||||
const validAcrLoAOptions = () =>
|
||||
acrLoAMapClient.length > 0
|
||||
? acrLoAMapClient.map((i: any) => i?.key).filter((i: any) => i !== "")
|
||||
: acrUriMapRealm;
|
||||
|
||||
const acrLoAMapNamesOptions = () => [
|
||||
{ key: "", value: t("choose") },
|
||||
...validAcrLoAOptions().map((i: any) => ({ key: i, value: i })),
|
||||
];
|
||||
|
||||
const isFeatureEnabled = useIsFeatureEnabled();
|
||||
|
||||
return (
|
||||
<FormAccess
|
||||
role="manage-realm"
|
||||
fineGrainedAccess={hasConfigureAccess}
|
||||
isHorizontal
|
||||
>
|
||||
{protocol !== "openid-connect" && (
|
||||
<FormGroup
|
||||
label={t("assertionLifespan")}
|
||||
fieldId="assertionLifespan"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("assertionLifespanHelp")}
|
||||
fieldLabelId="assertionLifespan"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.saml.assertion.lifespan",
|
||||
)}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TimeSelector
|
||||
units={["minute", "day", "hour"]}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
{protocol === "saml" && (
|
||||
<>
|
||||
<FormGroup
|
||||
label={t("assertionLifespan")}
|
||||
fieldId="assertionLifespan"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("assertionLifespanHelp")}
|
||||
fieldLabelId="assertionLifespan"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.saml.assertion.lifespan",
|
||||
)}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TimeSelector
|
||||
units={["minute", "day", "hour"]}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
{isFeatureEnabled(Feature.StepUpAuthenticationSaml) && (
|
||||
<>
|
||||
<FormGroup
|
||||
label={t("acrToLoAMapping")}
|
||||
fieldId="acrToLoAMapping"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("acrToLoAMappingSamlHelp")}
|
||||
fieldLabelId="acrToLoAMapping"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<KeyValueInput
|
||||
label={t("acrToLoAMapping")}
|
||||
name={convertAttributeNameToForm("attributes.acr.loa.map")}
|
||||
keyLabel="uri"
|
||||
valueLabel="loa"
|
||||
ValueComponent={(props) => (
|
||||
<TextInput
|
||||
placeholder={t("loaPlaceholder")}
|
||||
aria-label={t("loa")}
|
||||
validated={props.error ? "error" : "default"}
|
||||
{...register(props.name, {
|
||||
required: true,
|
||||
validate: (v: string) => Number.isInteger(parseInt(v)),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<SelectControl
|
||||
name={convertAttributeNameToForm(
|
||||
"attributes.minimum.acr.value",
|
||||
)}
|
||||
label={t("minimumACRValue")}
|
||||
labelIcon={t("minimumACRValueSamlHelp")}
|
||||
controller={{
|
||||
defaultValue: "",
|
||||
rules: {
|
||||
validate: (v: string) =>
|
||||
v === "" || validAcrLoAOptions().includes(v),
|
||||
},
|
||||
}}
|
||||
options={acrLoAMapNamesOptions()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{protocol === "openid-connect" && (
|
||||
<>
|
||||
@@ -158,6 +239,19 @@ export const AdvancedSettings = ({
|
||||
<KeyValueInput
|
||||
label={t("acrToLoAMapping")}
|
||||
name={convertAttributeNameToForm("attributes.acr.loa.map")}
|
||||
keyLabel="acr"
|
||||
valueLabel="loa"
|
||||
ValueComponent={(props) => (
|
||||
<TextInput
|
||||
placeholder={t("loaPlaceholder")}
|
||||
aria-label={t("loa")}
|
||||
validated={props.error ? "error" : "default"}
|
||||
{...register(props.name, {
|
||||
required: true,
|
||||
validate: (v: string) => Number.isInteger(parseInt(v)),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
|
||||
@@ -29,6 +29,7 @@ export type DefaultValue = {
|
||||
|
||||
type Field = {
|
||||
name: string;
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
type ValueField = Field & {
|
||||
@@ -39,6 +40,8 @@ type KeyValueInputProps = PropsWithChildren & {
|
||||
name: string;
|
||||
label?: string;
|
||||
isDisabled?: boolean;
|
||||
keyLabel?: string;
|
||||
valueLabel?: string;
|
||||
KeyComponent?: FunctionComponent<Field>;
|
||||
ValueComponent?: FunctionComponent<ValueField>;
|
||||
};
|
||||
@@ -47,6 +50,8 @@ export const KeyValueInput = ({
|
||||
name,
|
||||
label = "attributes",
|
||||
isDisabled = false,
|
||||
keyLabel = "key",
|
||||
valueLabel = "value",
|
||||
KeyComponent,
|
||||
ValueComponent,
|
||||
}: KeyValueInputProps) => {
|
||||
@@ -70,29 +75,36 @@ export const KeyValueInput = ({
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
const getError = () => {
|
||||
return name.split(".").reduce((record: any, key) => record?.[key], errors);
|
||||
};
|
||||
|
||||
return fields.length > 0 ? (
|
||||
<>
|
||||
<Grid hasGutter>
|
||||
<GridItem className="pf-v5-c-form__label" span={5}>
|
||||
<span className="pf-v5-c-form__label-text">{t("key")}</span>
|
||||
<span className="pf-v5-c-form__label-text">{t(keyLabel)}</span>
|
||||
</GridItem>
|
||||
<GridItem className="pf-v5-c-form__label" span={7}>
|
||||
<span className="pf-v5-c-form__label-text">{t("value")}</span>
|
||||
<span className="pf-v5-c-form__label-text">{t(valueLabel)}</span>
|
||||
</GridItem>
|
||||
{fields.map((attribute, index) => {
|
||||
const error = (errors as any)[name]?.[index];
|
||||
const error = getError()?.[index];
|
||||
const keyError = !!error?.key;
|
||||
const valueErrorPresent = !!error?.value || !!error?.message;
|
||||
const valueError = error?.message || t("valueError");
|
||||
const valueError = error?.message || t(`${valueLabel}Error`);
|
||||
return (
|
||||
<Fragment key={attribute.id}>
|
||||
<GridItem span={5}>
|
||||
{KeyComponent ? (
|
||||
<KeyComponent name={`${name}.${index}.key`} />
|
||||
<KeyComponent
|
||||
name={`${name}.${index}.key`}
|
||||
error={keyError}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
placeholder={t("keyPlaceholder")}
|
||||
aria-label={t("key")}
|
||||
placeholder={t(`${keyLabel}Placeholder`)}
|
||||
aria-label={t(keyLabel)}
|
||||
data-testid={`${name}-key`}
|
||||
{...register(`${name}.${index}.key`, { required: true })}
|
||||
validated={keyError ? "error" : "default"}
|
||||
@@ -103,7 +115,7 @@ export const KeyValueInput = ({
|
||||
{keyError && (
|
||||
<HelperText>
|
||||
<HelperTextItem variant="error">
|
||||
{t("keyError")}
|
||||
{t(`${keyLabel}Error`)}
|
||||
</HelperTextItem>
|
||||
</HelperText>
|
||||
)}
|
||||
@@ -113,11 +125,12 @@ export const KeyValueInput = ({
|
||||
<ValueComponent
|
||||
name={`${name}.${index}.value`}
|
||||
keyValue={values[index]?.key}
|
||||
error={valueErrorPresent}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
placeholder={t("valuePlaceholder")}
|
||||
aria-label={t("value")}
|
||||
placeholder={t(`${valueLabel}Placeholder`)}
|
||||
aria-label={t(valueLabel)}
|
||||
data-testid={`${name}-value`}
|
||||
{...register(`${name}.${index}.value`, { required: true })}
|
||||
validated={valueErrorPresent ? "error" : "default"}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
ActionList,
|
||||
ActionListItem,
|
||||
Button,
|
||||
EmptyState,
|
||||
EmptyStateBody,
|
||||
EmptyStateFooter,
|
||||
Grid,
|
||||
GridItem,
|
||||
HelperText,
|
||||
HelperTextItem,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
|
||||
import { Fragment, PropsWithChildren } from "react";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type RealmLoAMappingProps = PropsWithChildren & {
|
||||
name: string;
|
||||
label?: string;
|
||||
uri: boolean;
|
||||
};
|
||||
|
||||
export type RealmLoAMappingType = { acr: string; uri?: string; loa: string };
|
||||
|
||||
export const RealmLoAMapping = ({
|
||||
name,
|
||||
label = "attributes",
|
||||
uri = false,
|
||||
}: RealmLoAMappingProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
|
||||
const spanAcr = uri ? 4 : 5;
|
||||
const spanLoA = uri ? 2 : 5;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name,
|
||||
});
|
||||
|
||||
const appendNew = () => append({ acr: "", uri: "", loa: "" });
|
||||
|
||||
const getError = () => {
|
||||
return name.split(".").reduce((record: any, key) => record?.[key], errors);
|
||||
};
|
||||
|
||||
return fields.length > 0 ? (
|
||||
<>
|
||||
<Grid hasGutter>
|
||||
<GridItem className="pf-v5-c-form__label" span={spanAcr}>
|
||||
<span className="pf-v5-c-form__label-text">{t("acr")}</span>
|
||||
</GridItem>
|
||||
{uri && (
|
||||
<GridItem className="pf-v5-c-form__label" span={spanAcr}>
|
||||
<span className="pf-v5-c-form__label-text">{t("uri")}</span>
|
||||
</GridItem>
|
||||
)}
|
||||
<GridItem className="pf-v5-c-form__label" span={spanLoA}>
|
||||
<span className="pf-v5-c-form__label-text">{t("loa")}</span>
|
||||
</GridItem>
|
||||
{fields.map((attribute, index) => {
|
||||
const error = getError()?.[index];
|
||||
return (
|
||||
<Fragment key={attribute.id}>
|
||||
<GridItem span={spanAcr}>
|
||||
<TextInput
|
||||
placeholder={t("acrPlaceholder")}
|
||||
aria-label={t("acr")}
|
||||
data-testid={`${name}-acr`}
|
||||
{...register(`${name}.${index}.acr`, { required: true })}
|
||||
validated={error?.acr ? "error" : "default"}
|
||||
isRequired
|
||||
/>
|
||||
{error?.acr && (
|
||||
<HelperText>
|
||||
<HelperTextItem variant="error">
|
||||
{t("acrError")}
|
||||
</HelperTextItem>
|
||||
</HelperText>
|
||||
)}
|
||||
</GridItem>
|
||||
{uri && (
|
||||
<GridItem span={spanAcr}>
|
||||
<TextInput
|
||||
placeholder={t("uriPlaceholder")}
|
||||
aria-label={t("uri")}
|
||||
data-testid={`${name}-uri`}
|
||||
{...register(`${name}.${index}.uri`, {
|
||||
// some validation for URI in JS????
|
||||
})}
|
||||
validated={error?.uri ? "error" : "default"}
|
||||
isRequired={false}
|
||||
/>
|
||||
{error?.uri && (
|
||||
<HelperText>
|
||||
<HelperTextItem variant="error">
|
||||
{t("uriError")}
|
||||
</HelperTextItem>
|
||||
</HelperText>
|
||||
)}
|
||||
</GridItem>
|
||||
)}
|
||||
<GridItem span={spanLoA}>
|
||||
<TextInput
|
||||
placeholder={t("loaPlaceholder")}
|
||||
aria-label={t("loa")}
|
||||
data-testid={`${name}-loa`}
|
||||
{...register(`${name}.${index}.loa`, {
|
||||
required: true,
|
||||
validate: (v: string) => Number.isInteger(parseInt(v)),
|
||||
})}
|
||||
validated={error?.loa ? "error" : "default"}
|
||||
isRequired
|
||||
/>
|
||||
{error?.loa && (
|
||||
<HelperText>
|
||||
<HelperTextItem variant="error">
|
||||
{t("loaError")}
|
||||
</HelperTextItem>
|
||||
</HelperText>
|
||||
)}
|
||||
</GridItem>
|
||||
<GridItem span={2}>
|
||||
<Button
|
||||
variant="link"
|
||||
title={t("removeAttribute")}
|
||||
onClick={() => remove(index)}
|
||||
data-testid={`${name}-remove`}
|
||||
>
|
||||
<MinusCircleIcon />
|
||||
</Button>
|
||||
</GridItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<ActionList>
|
||||
<ActionListItem>
|
||||
<Button
|
||||
data-testid={`${name}-add-row`}
|
||||
className="pf-v5-u-px-0 pf-v5-u-mt-sm"
|
||||
variant="link"
|
||||
icon={<PlusCircleIcon />}
|
||||
onClick={appendNew}
|
||||
>
|
||||
{t("addAttribute", { label })}
|
||||
</Button>
|
||||
</ActionListItem>
|
||||
</ActionList>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
data-testid={`${name}-empty-state`}
|
||||
className="pf-v5-u-p-0"
|
||||
variant="xs"
|
||||
>
|
||||
<EmptyStateBody>{t("missingAttributes", { label })}</EmptyStateBody>
|
||||
<EmptyStateFooter>
|
||||
<Button
|
||||
data-testid={`${name}-add-row`}
|
||||
variant="link"
|
||||
icon={<PlusCircleIcon />}
|
||||
size="sm"
|
||||
onClick={appendNew}
|
||||
>
|
||||
{t("addAttribute", { label })}
|
||||
</Button>
|
||||
</EmptyStateFooter>
|
||||
</EmptyState>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,7 @@ import { DefaultSwitchControl } from "../components/SwitchControl";
|
||||
import { FormattedLink } from "../components/external-link/FormattedLink";
|
||||
import { FixedButtonsGroup } from "../components/form/FixedButtonGroup";
|
||||
import { FormAccess } from "../components/form/FormAccess";
|
||||
import { KeyValueInput } from "../components/key-value-form/KeyValueInput";
|
||||
import { RealmLoAMapping } from "../components/realm-loa-mapping/RealmLoAMapping";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
import {
|
||||
addTrailingSlash,
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
|
||||
import { UIRealmRepresentation } from "./RealmSettingsTabs";
|
||||
import { SIGNATURE_ALGORITHMS } from "../clients/add/SamlSignature";
|
||||
import type { RealmLoAMappingType } from "../components/realm-loa-mapping/RealmLoAMapping";
|
||||
|
||||
type RealmSettingsGeneralTabProps = {
|
||||
realm: UIRealmRepresentation;
|
||||
@@ -115,6 +116,9 @@ function RealmSettingsGeneralTabForm({
|
||||
Feature.AdminFineGrainedAuthzV2,
|
||||
);
|
||||
const isOpenid4vciEnabled = isFeatureEnabled(Feature.OpenId4VCI);
|
||||
const isStepUpAuthenticationSaml = isFeatureEnabled(
|
||||
Feature.StepUpAuthenticationSaml,
|
||||
);
|
||||
|
||||
const setupForm = () => {
|
||||
convertToFormValues(realm, setValue);
|
||||
@@ -124,13 +128,18 @@ function RealmSettingsGeneralTabForm({
|
||||
UNMANAGED_ATTRIBUTE_POLICIES[0],
|
||||
);
|
||||
if (realm.attributes?.["acr.loa.map"]) {
|
||||
const result = Object.entries(
|
||||
const acrLoaMap = Object.entries(
|
||||
JSON.parse(realm.attributes["acr.loa.map"]),
|
||||
).flatMap(([key, value]) => ({ key, value }));
|
||||
result.concat({ key: "", value: "" });
|
||||
).flatMap(([acr, loa]) => ({ acr, loa }) as RealmLoAMappingType);
|
||||
|
||||
if (isStepUpAuthenticationSaml && realm.attributes?.["acr.uri.map"]) {
|
||||
const acrUriMap = JSON.parse(realm.attributes["acr.uri.map"]);
|
||||
acrLoaMap.forEach((row) => (row.uri = acrUriMap?.[row?.acr]));
|
||||
}
|
||||
|
||||
setValue(
|
||||
convertAttributeNameToForm("attributes.acr.loa.map") as any,
|
||||
result,
|
||||
acrLoaMap,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -209,14 +218,19 @@ function RealmSettingsGeneralTabForm({
|
||||
fieldId="acrToLoAMapping"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("acrToLoAMappingHelp")}
|
||||
helpText={
|
||||
isStepUpAuthenticationSaml
|
||||
? t("acrToLoAMappingRealmSamlHelp")
|
||||
: t("acrToLoAMappingHelp")
|
||||
}
|
||||
fieldLabelId="acrToLoAMapping"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<KeyValueInput
|
||||
<RealmLoAMapping
|
||||
label={t("acrToLoAMapping")}
|
||||
name={convertAttributeNameToForm("attributes.acr.loa.map")}
|
||||
uri={isStepUpAuthenticationSaml}
|
||||
/>
|
||||
</FormGroup>
|
||||
<DefaultSwitchControl
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAdminClient } from "../admin-client";
|
||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||
import type { KeyValueType } from "../components/key-value-form/key-value-convert";
|
||||
import type { RealmLoAMappingType } from "../components/realm-loa-mapping/RealmLoAMapping";
|
||||
import {
|
||||
RoutableTabs,
|
||||
useRoutableTab,
|
||||
@@ -227,11 +227,20 @@ export const RealmSettingsTabs = () => {
|
||||
r.attributes?.["acr.loa.map"] &&
|
||||
typeof r.attributes["acr.loa.map"] !== "string"
|
||||
) {
|
||||
if (isFeatureEnabled(Feature.StepUpAuthenticationSaml)) {
|
||||
r.attributes["acr.uri.map"] = JSON.stringify(
|
||||
Object.fromEntries(
|
||||
(r.attributes["acr.loa.map"] as RealmLoAMappingType[])
|
||||
.filter(({ acr, uri }) => acr !== "" && uri && uri !== "")
|
||||
.map(({ acr, uri }) => [acr, uri]),
|
||||
),
|
||||
);
|
||||
}
|
||||
r.attributes["acr.loa.map"] = JSON.stringify(
|
||||
Object.fromEntries(
|
||||
(r.attributes["acr.loa.map"] as KeyValueType[])
|
||||
.filter(({ key }) => key !== "")
|
||||
.map(({ key, value }) => [key, value]),
|
||||
(r.attributes["acr.loa.map"] as RealmLoAMappingType[])
|
||||
.filter(({ acr }) => acr !== "")
|
||||
.map(({ acr, loa }) => [acr, loa]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export enum Feature {
|
||||
Passkeys = "PASSKEYS",
|
||||
ClientAuthFederated = "CLIENT_AUTH_FEDERATED",
|
||||
Workflows = "WORKFLOWS",
|
||||
StepUpAuthenticationSaml = "STEP_UP_AUTHENTICATION_SAML",
|
||||
}
|
||||
|
||||
export default function useIsFeatureEnabled() {
|
||||
|
||||
@@ -45,6 +45,7 @@ public class SAML2ErrorResponseBuilder implements SamlProtocolExtensionsAwareBui
|
||||
protected String statusMessage;
|
||||
protected String destination;
|
||||
protected NameIDType issuer;
|
||||
protected String inResponseTo;
|
||||
protected final List<NodeGenerator> extensions = new LinkedList<>();
|
||||
|
||||
public SAML2ErrorResponseBuilder status(String status) {
|
||||
@@ -71,6 +72,11 @@ public class SAML2ErrorResponseBuilder implements SamlProtocolExtensionsAwareBui
|
||||
return issuer(SAML2NameIDBuilder.value(issuer).build());
|
||||
}
|
||||
|
||||
public SAML2ErrorResponseBuilder inResponseTo(String inResponseTo) {
|
||||
this.inResponseTo = inResponseTo;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SAML2ErrorResponseBuilder addExtension(NodeGenerator extension) {
|
||||
this.extensions.add(extension);
|
||||
@@ -81,6 +87,7 @@ public class SAML2ErrorResponseBuilder implements SamlProtocolExtensionsAwareBui
|
||||
|
||||
try {
|
||||
StatusResponseType statusResponse = new ResponseType(IDGenerator.create("ID_"), XMLTimeUtil.getIssueInstant());
|
||||
statusResponse.setInResponseTo(inResponseTo);
|
||||
|
||||
StatusType statusType = JBossSAMLAuthnResponseFactory.createStatusTypeForResponder(status);
|
||||
statusType.setStatusMessage(statusMessage);
|
||||
|
||||
@@ -93,4 +93,11 @@ public interface MigrationProvider extends Provider {
|
||||
* @return created or already existing client scope 'service_account'
|
||||
*/
|
||||
ClientScopeModel addOIDCServiceAccountClientScope(RealmModel realm);
|
||||
|
||||
/**
|
||||
* Add the SAML mapper for the step-up <em>AuthnContextClassRef</em> authentication to the realm.
|
||||
* @param realm
|
||||
* @return created, already existing client scope or null if not step-up not enabled
|
||||
*/
|
||||
ClientScopeModel addSamlAuthnContextClassRefClientScope(RealmModel realm);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ public final class Constants {
|
||||
public static final String REQUESTED_LEVEL_OF_AUTHENTICATION = "requested-level-of-authentication";
|
||||
public static final String FORCE_LEVEL_OF_AUTHENTICATION = "force-level-of-authentication";
|
||||
public static final String ACR_LOA_MAP = "acr.loa.map";
|
||||
public static final String ACR_URI_MAP = "acr.uri.map";
|
||||
public static final String DEFAULT_ACR_VALUES = "default.acr.values";
|
||||
public static final String MINIMUM_ACR_VALUE = "minimum.acr.value";
|
||||
public static final int MINIMUM_LOA = 0;
|
||||
|
||||
@@ -69,7 +69,11 @@ public interface LoginProtocol extends Provider {
|
||||
* Passive authentication mode requested, user is logged in, but some other user interaction is necessary (eg. some required login actions exist or Consent approval is necessary for logged in
|
||||
* user)
|
||||
*/
|
||||
PASSIVE_INTERACTION_REQUIRED;
|
||||
PASSIVE_INTERACTION_REQUIRED,
|
||||
/**
|
||||
* Level of Authentication invalid or minimum not reached.
|
||||
*/
|
||||
LOA_INVALID;
|
||||
}
|
||||
|
||||
LoginProtocol setSession(KeycloakSession session);
|
||||
|
||||
@@ -450,6 +450,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
||||
return new OAuth2ErrorRepresentation(OAuthErrorException.ACCESS_DENIED, "User cancelled application-initiated action.");
|
||||
case CANCELLED_BY_USER:
|
||||
case CONSENT_DENIED:
|
||||
case LOA_INVALID:
|
||||
return new OAuth2ErrorRepresentation(OAuthErrorException.ACCESS_DENIED, errorMessage);
|
||||
case PASSIVE_INTERACTION_REQUIRED:
|
||||
return new OAuth2ErrorRepresentation(OAuthErrorException.INTERACTION_REQUIRED, null);
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.authentication.authenticators.util.LoAUtil;
|
||||
import org.keycloak.models.ClientModel;
|
||||
@@ -147,6 +148,20 @@ public class AcrUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Integer> getUriLoaMap(ClientModel client) {
|
||||
Map<String, Integer> result = getAcrLoaMapForClientOnly(client);
|
||||
if (!result.isEmpty()) {
|
||||
// client has always the correct maps uri or acr
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback to realm but using the two maps acr => uri => loa
|
||||
Map<String, Integer> acrLoaMap = getAcrLoaMap(client.getRealm());
|
||||
Map<String, String> acrUriMap = getAcrUriMap(client.getRealm());
|
||||
return acrLoaMap.entrySet().stream()
|
||||
.filter(e -> acrUriMap.containsKey(e.getKey()))
|
||||
.collect(Collectors.toMap(e -> acrUriMap.get(e.getKey()), Map.Entry::getValue));
|
||||
}
|
||||
|
||||
private static Map<String, Integer> getAcrLoaMapForClientOnly(ClientModel client) {
|
||||
String map = client.getAttribute(Constants.ACR_LOA_MAP);
|
||||
@@ -154,13 +169,21 @@ public class AcrUtils {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
|
||||
return parseAcrLoaMap(map);
|
||||
} catch (IOException e) {
|
||||
LOGGER.warnf("Invalid client configuration (ACR-LOA map) for client '%s'. Error details: %s", client.getClientId(), e.getMessage());
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Integer> parseAcrLoaMap(String map) throws IOException {
|
||||
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
|
||||
}
|
||||
|
||||
public static Map<String, String> parseAcrUriMap(String map) throws IOException {
|
||||
return JsonSerialization.readValue(map, new TypeReference<Map<String, String>>() {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param realm
|
||||
* @return map corresponding to "acr-to-loa" realm attribute.
|
||||
@@ -171,13 +194,31 @@ public class AcrUtils {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
|
||||
return parseAcrLoaMap(map);
|
||||
} catch (IOException e) {
|
||||
LOGGER.warnf("Invalid realm configuration (ACR-LOA map). Details: %s", e.getMessage());
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the acr to uri map in the realm.
|
||||
* @param realm
|
||||
* @return Map corresponding to acr to uri map
|
||||
*/
|
||||
public static Map<String, String> getAcrUriMap(RealmModel realm) {
|
||||
String map = realm.getAttribute(Constants.ACR_URI_MAP);
|
||||
if (map == null || map.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
return parseAcrUriMap(map);
|
||||
} catch (IOException e) {
|
||||
LOGGER.warnf("Invalid realm configuration (ACR-URI map). Details: %s", e.getMessage());
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
public static String mapLoaToAcr(int loa, Map<String, Integer> acrLoaMap, Collection<String> acrValues) {
|
||||
String acr = null;
|
||||
if (!acrLoaMap.isEmpty() && !acrValues.isEmpty()) {
|
||||
|
||||
@@ -160,6 +160,7 @@ public class SamlProtocol implements LoginProtocol {
|
||||
public static final String SAML_LOGOUT_INITIATOR_CLIENT_ID = "SAML_LOGOUT_INITIATOR_CLIENT_ID";
|
||||
public static final String USER_SESSION_ID = "userSessionId";
|
||||
public static final String CLIENT_SESSION_ID = "clientSessionId";
|
||||
public static final String SAML_AUTHN_CONTEXT_CLASS_REF = "saml_authn_context_class_ref";
|
||||
|
||||
|
||||
protected static final Logger logger = Logger.getLogger(SamlProtocol.class);
|
||||
@@ -241,7 +242,8 @@ public class SamlProtocol implements LoginProtocol {
|
||||
} else {
|
||||
return samlErrorMessage(
|
||||
authSession, new SamlClient(client), isPostBinding(authSession),
|
||||
authSession.getRedirectUri(), translateErrorToSAMLStatus(error), authSession.getClientNote(GeneralConstants.RELAY_STATE)
|
||||
authSession.getRedirectUri(), authSession.getClientNote(SAML_REQUEST_ID),
|
||||
translateErrorToSAMLStatus(error), authSession.getClientNote(GeneralConstants.RELAY_STATE)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -254,7 +256,7 @@ public class SamlProtocol implements LoginProtocol {
|
||||
public ClientData getClientData(AuthenticationSessionModel authSession) {
|
||||
String responseMode = isPostBinding(authSession) ? SamlProtocol.SAML_POST_BINDING : SamlProtocol.SAML_REDIRECT_BINDING;
|
||||
return new ClientData(authSession.getRedirectUri(),
|
||||
null,
|
||||
authSession.getClientNote(SAML_REQUEST_ID),
|
||||
responseMode,
|
||||
authSession.getClientNote(GeneralConstants.RELAY_STATE));
|
||||
}
|
||||
@@ -274,15 +276,16 @@ public class SamlProtocol implements LoginProtocol {
|
||||
|
||||
return samlErrorMessage(
|
||||
null, samlClient, postBinding,
|
||||
validRedirectUri, translateErrorToSAMLStatus(error), clientData.getState()
|
||||
validRedirectUri, clientData.getResponseType(), translateErrorToSAMLStatus(error), clientData.getState()
|
||||
);
|
||||
}
|
||||
|
||||
private Response samlErrorMessage(
|
||||
AuthenticationSessionModel authSession, SamlClient samlClient, boolean isPostBinding,
|
||||
String destination, SAMLError samlError, String relayState) {
|
||||
String destination, String inResponseTo, SAMLError samlError, String relayState) {
|
||||
JaxrsSAML2BindingBuilder binding = new JaxrsSAML2BindingBuilder(session).relayState(relayState);
|
||||
SAML2ErrorResponseBuilder builder = new SAML2ErrorResponseBuilder().destination(destination).issuer(getResponseIssuer(realm))
|
||||
.inResponseTo(inResponseTo)
|
||||
.status(samlError.error().get())
|
||||
.statusMessage(samlError.errorDescription());
|
||||
KeyManager keyManager = session.keys();
|
||||
@@ -326,6 +329,8 @@ public class SamlProtocol implements LoginProtocol {
|
||||
return new SAMLError(JBossSAMLURIConstants.STATUS_NO_PASSIVE, null);
|
||||
case ALREADY_LOGGED_IN:
|
||||
return new SAMLError(JBossSAMLURIConstants.STATUS_AUTHNFAILED, Constants.AUTHENTICATION_EXPIRED_MESSAGE);
|
||||
case LOA_INVALID:
|
||||
return new SAMLError(JBossSAMLURIConstants.STATUS_NOAUTHN_CTX, null);
|
||||
default:
|
||||
logger.warn("Untranslated protocol Error: " + error.name() + " so we return default SAML error");
|
||||
return new SAMLError(JBossSAMLURIConstants.STATUS_REQUEST_DENIED, null);
|
||||
@@ -542,7 +547,7 @@ public class SamlProtocol implements LoginProtocol {
|
||||
String nameId = getSAMLNameId(samlNameIdMappers, nameIdFormat, session, userSession, clientSession);
|
||||
|
||||
if (nameId == null) {
|
||||
return samlErrorMessage(null, samlClient, isPostBinding(authSession), redirectUri,
|
||||
return samlErrorMessage(authSession, samlClient, isPostBinding(authSession), redirectUri, authSession.getClientNote(SAML_REQUEST_ID),
|
||||
new SAMLError(JBossSAMLURIConstants.STATUS_INVALID_NAMEIDPOLICY, null), relayState);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.keycloak.protocol.AbstractLoginProtocolFactory;
|
||||
import org.keycloak.protocol.LoginProtocol;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.saml.mappers.AttributeStatementHelper;
|
||||
import org.keycloak.protocol.saml.mappers.AuthnContextClassRefMapper;
|
||||
import org.keycloak.protocol.saml.mappers.RoleListMapper;
|
||||
import org.keycloak.protocol.saml.mappers.UserPropertyAttributeStatementMapper;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
@@ -57,6 +58,7 @@ import org.keycloak.saml.validators.DestinationValidator;
|
||||
public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
|
||||
|
||||
public static final String SCOPE_ROLE_LIST = "role_list";
|
||||
public static final String SCOPE_AUTHN_CONTEXT_CLASS_REF = "AuthnContextClassRef";
|
||||
private static final String ROLE_LIST_CONSENT_TEXT = "${samlRoleListScopeConsentText}";
|
||||
|
||||
private DestinationValidator destinationValidator;
|
||||
@@ -101,6 +103,11 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
|
||||
model = RoleListMapper.create("role list", "Role", AttributeStatementHelper.BASIC, null, false);
|
||||
builtins.put("role list", model);
|
||||
defaultBuiltins.add(model);
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.STEP_UP_AUTHENTICATION_SAML)) {
|
||||
model = AuthnContextClassRefMapper.create(SCOPE_AUTHN_CONTEXT_CLASS_REF);
|
||||
builtins.put(SCOPE_AUTHN_CONTEXT_CLASS_REF, model);
|
||||
defaultBuiltins.add(model);
|
||||
}
|
||||
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
|
||||
model = OrganizationMembershipMapper.create();
|
||||
builtins.put("organization", model);
|
||||
@@ -132,6 +139,7 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
|
||||
roleListScope.setProtocol(getId());
|
||||
roleListScope.addProtocolMapper(builtins.get("role list"));
|
||||
newRealm.addDefaultClientScope(roleListScope, true);
|
||||
|
||||
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
|
||||
ClientScopeModel organizationScope = newRealm.addClientScope("saml_organization");
|
||||
organizationScope.setDescription("Organization Membership");
|
||||
@@ -140,6 +148,8 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
|
||||
organizationScope.addProtocolMapper(builtins.get("organization"));
|
||||
newRealm.addDefaultClientScope(organizationScope, true);
|
||||
}
|
||||
|
||||
addSamlAuthnContextClassRefClientScope(newRealm);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -227,4 +237,19 @@ public class SamlProtocolFactory extends AbstractLoginProtocolFactory {
|
||||
.add()
|
||||
.build();
|
||||
}
|
||||
|
||||
public ClientScopeModel addSamlAuthnContextClassRefClientScope(RealmModel newRealm) {
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.STEP_UP_AUTHENTICATION_SAML)) {
|
||||
ClientScopeModel authnContextClassRefScope = KeycloakModelUtils.getClientScopeByName(newRealm, SCOPE_AUTHN_CONTEXT_CLASS_REF);
|
||||
if (authnContextClassRefScope == null) {
|
||||
authnContextClassRefScope = newRealm.addClientScope(SCOPE_AUTHN_CONTEXT_CLASS_REF);
|
||||
authnContextClassRefScope.setDescription("AuthnContextClassRef Level of Authentiation");
|
||||
authnContextClassRefScope.setProtocol(getId());
|
||||
authnContextClassRefScope.addProtocolMapper(builtins.get(SCOPE_AUTHN_CONTEXT_CLASS_REF));
|
||||
newRealm.addDefaultClientScope(authnContextClassRefScope, true);
|
||||
}
|
||||
return authnContextClassRefScope;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import java.security.Key;
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
@@ -36,8 +38,10 @@ import org.keycloak.crypto.KeyUse;
|
||||
import org.keycloak.dom.saml.v2.SAML2Object;
|
||||
import org.keycloak.dom.saml.v2.assertion.NameIDType;
|
||||
import org.keycloak.dom.saml.v2.protocol.ArtifactResponseType;
|
||||
import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType;
|
||||
import org.keycloak.dom.saml.v2.protocol.ExtensionsType;
|
||||
import org.keycloak.dom.saml.v2.protocol.RequestAbstractType;
|
||||
import org.keycloak.dom.saml.v2.protocol.RequestedAuthnContextType;
|
||||
import org.keycloak.dom.saml.v2.protocol.StatusCodeType;
|
||||
import org.keycloak.dom.saml.v2.protocol.StatusResponseType;
|
||||
import org.keycloak.dom.saml.v2.protocol.StatusType;
|
||||
@@ -346,4 +350,73 @@ public class SamlProtocolUtils {
|
||||
writer.write(responseType);
|
||||
return DocumentUtil.getDocument(new ByteArrayInputStream(bos.toByteArray()));
|
||||
}
|
||||
|
||||
private static String checkLoAExact(String current, Map<String, Integer> acrLoaMap, int minLevel) {
|
||||
// authentication context in the authentication statement MUST be the exact match of at least one of the authentication contexts specified
|
||||
Integer level = acrLoaMap.get(current);
|
||||
if (level == null) {
|
||||
return null;
|
||||
}
|
||||
return level >= minLevel ? current : null;
|
||||
}
|
||||
|
||||
private static String checkLoAMinimum(String current, Map<String, Integer> acrLoaMap, String minLoa, int minLevel) {
|
||||
// authentication context in the authentication statement MUST be as strong as one of the authentication contexts specified
|
||||
Integer level = acrLoaMap.get(current);
|
||||
if (level == null) {
|
||||
return null;
|
||||
}
|
||||
// check if current value is OK, if not return minLoa which is valid because is greater than current
|
||||
return (level >= minLevel) ? current : minLoa;
|
||||
}
|
||||
|
||||
private static String checkLoAMaximum(String current, Map<String, Integer> acrLoaMap, int minLevel) {
|
||||
// authentication context in the authentication statement MUST be as strong as possible without exceeding the strength of at least one of the authentication contexts specified
|
||||
Integer level = acrLoaMap.get(current);
|
||||
if (level == null) {
|
||||
return null;
|
||||
}
|
||||
// only valid if it is better than minLoa
|
||||
return level >= minLevel ? current : null;
|
||||
}
|
||||
|
||||
private static String checkLoABetter(String current, Map<String, Integer> acrLoaMap, String minLoa, int minLevel) {
|
||||
// authentication context in the authentication statement MUST be stronger than any one of the authentication contexts specified
|
||||
Integer level = acrLoaMap.get(current);
|
||||
if (level == null) {
|
||||
return null;
|
||||
}
|
||||
// if minLoa is valid return minLoa
|
||||
if (minLevel > level) {
|
||||
return minLoa;
|
||||
}
|
||||
// find any level that is better than level, get the min of them
|
||||
return acrLoaMap.entrySet().stream()
|
||||
.filter(e -> e.getValue() > level)
|
||||
.min((Map.Entry<String, Integer> e1, Map.Entry<String, Integer> e2) -> e1.getValue().compareTo(e2.getValue()))
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static String checkLoa(AuthnContextComparisonType comparison, String current, Map<String, Integer> acrLoaMap, String minLoa, int minLevel) {
|
||||
if (comparison == null) {
|
||||
comparison = AuthnContextComparisonType.EXACT;
|
||||
}
|
||||
return switch (comparison) {
|
||||
case EXACT -> checkLoAExact(current, acrLoaMap, minLevel);
|
||||
case MINIMUM -> checkLoAMinimum(current, acrLoaMap, minLoa, minLevel);
|
||||
case MAXIMUM -> checkLoAMaximum(current, acrLoaMap, minLevel);
|
||||
case BETTER -> checkLoABetter(current, acrLoaMap, minLoa, minLevel);
|
||||
};
|
||||
}
|
||||
|
||||
public static String getSelectedLoA(RequestedAuthnContextType requestedAuthnContext, Map<String, Integer> acrLoaMap, String minLoa) {
|
||||
Integer minLevel = minLoa != null ? acrLoaMap.get(minLoa) : null;
|
||||
return requestedAuthnContext.getAuthnContextClassRef().stream()
|
||||
.map(current -> checkLoa(requestedAuthnContext.getComparison(), current, acrLoaMap,
|
||||
minLoa, minLevel != null ? minLevel : Integer.MIN_VALUE))
|
||||
.filter(Objects::nonNull)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import jakarta.ws.rs.core.UriInfo;
|
||||
|
||||
import org.keycloak.broker.saml.SAMLDataMarshaller;
|
||||
import org.keycloak.common.ClientConnection;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.common.util.PemUtils;
|
||||
import org.keycloak.connections.httpclient.HttpClientProvider;
|
||||
@@ -79,6 +80,7 @@ import org.keycloak.http.HttpRequest;
|
||||
import org.keycloak.http.HttpResponse;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeyManager;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakUriInfo;
|
||||
@@ -89,6 +91,7 @@ import org.keycloak.protocol.AuthorizationEndpointBase;
|
||||
import org.keycloak.protocol.LoginProtocol;
|
||||
import org.keycloak.protocol.LoginProtocolFactory;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.utils.AcrUtils;
|
||||
import org.keycloak.protocol.oidc.utils.RedirectUtils;
|
||||
import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor;
|
||||
import org.keycloak.protocol.saml.profile.ecp.SamlEcpProfileService;
|
||||
@@ -176,6 +179,15 @@ public class SamlService extends AuthorizationEndpointBase {
|
||||
|
||||
protected abstract Response error(KeycloakSession session, AuthenticationSessionModel authenticationSession, Response.Status status, String message, Object... parameters);
|
||||
|
||||
protected Response sendProtocolError(AuthenticationSessionModel authSession, LoginProtocol.Error error, String errorMessage) {
|
||||
LoginProtocol protocol = session.getProvider(LoginProtocol.class, SamlProtocol.LOGIN_PROTOCOL);
|
||||
protocol.setRealm(realm)
|
||||
.setHttpHeaders(session.getContext().getRequestHeaders())
|
||||
.setUriInfo(session.getContext().getUri())
|
||||
.setEventBuilder(event);
|
||||
return protocol.sendError(authSession, error, errorMessage);
|
||||
}
|
||||
|
||||
protected Response basicChecks(String samlRequest, String samlResponse, String artifact) {
|
||||
logger.tracef("basicChecks(%s, %s, %s)%s", samlRequest, samlResponse, artifact, getShortStackTrace());
|
||||
if (!checkSsl()) {
|
||||
@@ -524,6 +536,36 @@ public class SamlService extends AuthorizationEndpointBase {
|
||||
requestAbstractType = it.next().beforeProcessingLoginRequest(requestAbstractType, authSession);
|
||||
}
|
||||
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.STEP_UP_AUTHENTICATION_SAML)) {
|
||||
// step-up level of authentication
|
||||
Map<String, Integer> acrLoaMap = AcrUtils.getUriLoaMap(authSession.getClient());
|
||||
|
||||
if (!acrLoaMap.isEmpty()) {
|
||||
// only process the requested authn context if LoA defined
|
||||
String acrValue;
|
||||
if (requestAbstractType.getRequestedAuthnContext() != null
|
||||
&& !requestAbstractType.getRequestedAuthnContext().getAuthnContextClassRef().isEmpty()) {
|
||||
acrValue = SamlProtocolUtils.getSelectedLoA(requestAbstractType.getRequestedAuthnContext(),
|
||||
acrLoaMap, AcrUtils.getMinimumAcrValue(client));
|
||||
if (acrValue == null) {
|
||||
logger.debug("No AuthnContextClassRef is valid for the requested context.");
|
||||
event.detail(Details.REASON, "Invalid RequestedAuthnContext");
|
||||
event.error(Errors.INVALID_REQUEST);
|
||||
return sendProtocolError(authSession, LoginProtocol.Error.LOA_INVALID, null);
|
||||
}
|
||||
} else {
|
||||
acrValue = AcrUtils.getMinimumAcrValue(client);
|
||||
}
|
||||
|
||||
if (acrValue != null) {
|
||||
logger.tracef("SAML step-up authentication set to force using context '%s'", acrValue);
|
||||
authSession.setClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION, "true");
|
||||
authSession.setClientNote(SamlProtocol.SAML_AUTHN_CONTEXT_CLASS_REF, acrValue);
|
||||
authSession.setClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION, String.valueOf(acrLoaMap.get(acrValue)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If unset we fall back to default "false"
|
||||
final boolean isPassive = (null != requestAbstractType.isIsPassive() && requestAbstractType.isIsPassive().booleanValue());
|
||||
return newBrowserAuthentication(authSession, isPassive, redirectToAuthentication);
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.protocol.saml.mappers;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.authentication.authenticators.util.LoAUtil;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.dom.saml.v2.assertion.AssertionType;
|
||||
import org.keycloak.dom.saml.v2.assertion.AuthnContextClassRefType;
|
||||
import org.keycloak.dom.saml.v2.assertion.AuthnContextType;
|
||||
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
|
||||
import org.keycloak.dom.saml.v2.protocol.ResponseType;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.oidc.utils.AcrUtils;
|
||||
import org.keycloak.protocol.saml.SamlProtocol;
|
||||
import org.keycloak.provider.EnvironmentDependentProviderFactory;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* <p>Mapper to assign the used AuthnContextClassRef in the AunthContext response.</p>
|
||||
*
|
||||
* @author rmartinc
|
||||
*/
|
||||
public class AuthnContextClassRefMapper extends AbstractSAMLProtocolMapper implements SAMLLoginResponseMapper, EnvironmentDependentProviderFactory {
|
||||
|
||||
public static final String PROVIDER_ID = "saml-authn-context-class-ref-mapper";
|
||||
public static final String AUTHN_CONTEXT_CLASS_REF_CATEGORY = "AuthnContextClassRef mapper";
|
||||
protected static final Logger logger = Logger.getLogger(AuthnContextClassRefMapper.class);
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayType() {
|
||||
return AUTHN_CONTEXT_CLASS_REF_CATEGORY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayCategory() {
|
||||
return AUTHN_CONTEXT_CLASS_REF_CATEGORY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHelpText() {
|
||||
return "Add the AuthnContextClassRef to the AuthContext with the Level of Assurance if present.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseType transformLoginResponse(ResponseType response, ProtocolMapperModel mappingModel,
|
||||
KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
|
||||
int loa = LoAUtil.getCurrentLevelOfAuthentication(clientSessionCtx.getClientSession());
|
||||
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
|
||||
String acrValue = authSession != null? authSession.getClientNote(SamlProtocol.SAML_AUTHN_CONTEXT_CLASS_REF) : null;
|
||||
logger.tracef("Current level of authentication %d, requested level %s", loa, acrValue);
|
||||
if (loa < Constants.MINIMUM_LOA) {
|
||||
// if the authentication was not using a step-up flow, just return as before
|
||||
return response;
|
||||
}
|
||||
|
||||
Map<String, Integer> acrLoaMap = AcrUtils.getUriLoaMap(clientSessionCtx.getClientSession().getClient());
|
||||
if (acrValue == null) {
|
||||
// no acr explicitly request in SAML, check if we have a specific name for this loa level
|
||||
acrValue = acrLoaMap.entrySet().stream().filter(e -> loa == e.getValue()).map(Map.Entry::getKey).findAny().orElse(null);
|
||||
} else {
|
||||
// check the requested level was indeed achieved by the authentication flow, if not unspecified
|
||||
Integer requestedLevel = acrLoaMap.get(acrValue);
|
||||
if (requestedLevel == null || requestedLevel != loa) {
|
||||
logger.warnf("Requested level '%s' (%d) was not reached after authentication flow, current level %d",
|
||||
acrValue, requestedLevel, loa);
|
||||
acrValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
URI authnContextClassRef = createUri(acrValue);
|
||||
|
||||
if (authnContextClassRef == null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
Optional<AuthnStatementType> authStatementOptional = response.getAssertions().stream()
|
||||
.map(ResponseType.RTChoiceType::getAssertion)
|
||||
.map(AssertionType::getStatements)
|
||||
.flatMap(s -> s.stream())
|
||||
.filter(AuthnStatementType.class::isInstance)
|
||||
.map(AuthnStatementType.class::cast)
|
||||
.findAny();
|
||||
|
||||
if (authStatementOptional.isPresent()) {
|
||||
logger.tracef("Setting the authentication context to '%s'", acrValue);
|
||||
AuthnStatementType authStatement = authStatementOptional.get();
|
||||
AuthnContextType authContext = new AuthnContextType();
|
||||
AuthnContextType.AuthnContextTypeSequence sequence = new AuthnContextType.AuthnContextTypeSequence();
|
||||
sequence.setClassRef(new AuthnContextClassRefType(authnContextClassRef));
|
||||
authContext.setSequence(sequence);
|
||||
authStatement.setAuthnContext(authContext);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private URI createUri(String acrValue) {
|
||||
if (acrValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URI(acrValue);
|
||||
} catch (URISyntaxException e) {
|
||||
logger.warnf("Invalid URI syntax for AuthnContextClassRef in the Level of Authentication '%s'", acrValue);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSupported(Config.Scope config) {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.STEP_UP_AUTHENTICATION_SAML);
|
||||
}
|
||||
|
||||
public static ProtocolMapperModel create(String name) {
|
||||
ProtocolMapperModel mapper = new ProtocolMapperModel();
|
||||
mapper.setName(name);
|
||||
mapper.setProtocolMapper(PROVIDER_ID);
|
||||
mapper.setProtocol(SamlProtocol.LOGIN_PROTOCOL);
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ import org.keycloak.protocol.LoginProtocol;
|
||||
import org.keycloak.protocol.LoginProtocolFactory;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
|
||||
import org.keycloak.protocol.saml.SamlProtocol;
|
||||
import org.keycloak.protocol.saml.SamlProtocolFactory;
|
||||
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||
import org.keycloak.services.managers.RealmManager;
|
||||
|
||||
@@ -86,6 +88,10 @@ public class DefaultMigrationProvider implements MigrationProvider {
|
||||
return (OIDCLoginProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class, OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||
}
|
||||
|
||||
private SamlProtocolFactory getSamlProtocolFactory() {
|
||||
return (SamlProtocolFactory) session.getKeycloakSessionFactory().getProviderFactory(LoginProtocol.class, SamlProtocol.LOGIN_PROTOCOL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientScopeModel addOIDCRolesClientScope(RealmModel realm) {
|
||||
return getOIDCLoginProtocolFactory().addRolesClientScope(realm);
|
||||
@@ -117,6 +123,11 @@ public class DefaultMigrationProvider implements MigrationProvider {
|
||||
return getOIDCLoginProtocolFactory().addServiceAccountClientScope(realm);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientScopeModel addSamlAuthnContextClassRefClientScope(RealmModel realm) {
|
||||
return getSamlProtocolFactory().addSamlAuthnContextClassRefClientScope(realm);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
|
||||
+45
-11
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
package org.keycloak.validation;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
@@ -28,10 +29,12 @@ import java.util.Set;
|
||||
|
||||
import org.keycloak.authentication.authenticators.util.LoAUtil;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.protocol.ProtocolMapperConfigException;
|
||||
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.grants.ciba.CibaClientValidation;
|
||||
import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper;
|
||||
import org.keycloak.protocol.oidc.utils.AcrUtils;
|
||||
@@ -193,6 +196,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
|
||||
validatePairwiseInClientModel(context);
|
||||
new CibaClientValidation(context).validate();
|
||||
validateJwks(context);
|
||||
validateAcrLoaMap(context);
|
||||
validateDefaultAcrValues(context);
|
||||
validateMinimumAcrValue(context);
|
||||
validateClientSessionTimeout(context);
|
||||
@@ -206,6 +210,7 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
|
||||
validateUrls(context);
|
||||
validatePairwiseInOIDCClient(context);
|
||||
new CibaClientValidation(context).validate();
|
||||
validateAcrLoaMap(context);
|
||||
validateDefaultAcrValues(context);
|
||||
validateMinimumAcrValue(context);
|
||||
//context.getSession().getContext().getRealm().
|
||||
@@ -394,11 +399,11 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
|
||||
|
||||
private void validateDefaultAcrValues(ValidationContext<ClientModel> context) {
|
||||
ClientModel client = context.getObjectToValidate();
|
||||
if (!OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
|
||||
return;
|
||||
}
|
||||
List<String> defaultAcrValues = AcrUtils.getDefaultAcrValues(client);
|
||||
Map<String, Integer> acrToLoaMap = AcrUtils.getAcrLoaMap(client);
|
||||
if (acrToLoaMap.isEmpty()) {
|
||||
acrToLoaMap = AcrUtils.getAcrLoaMap(client.getRealm());
|
||||
}
|
||||
for (String configuredAcr : defaultAcrValues) {
|
||||
if (acrToLoaMap.containsKey(configuredAcr)) continue;
|
||||
if (LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm())
|
||||
@@ -408,19 +413,48 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAcrLoaMap(ValidationContext<ClientModel> context) {
|
||||
ClientModel client = context.getObjectToValidate();
|
||||
if (!SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol()) && !OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
|
||||
return;
|
||||
}
|
||||
String value = client.getAttribute(Constants.ACR_LOA_MAP);
|
||||
if (value != null && StringUtil.isNotBlank(value)) {
|
||||
try {
|
||||
Map<String, Integer> map = AcrUtils.parseAcrLoaMap(value);
|
||||
if (SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
|
||||
for (String uri : map.keySet()) {
|
||||
new URI(uri);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
context.addError(Constants.ACR_LOA_MAP, "Invalid client configuration (ACR-LOA map) for client");
|
||||
} catch (URISyntaxException e) {
|
||||
context.addError(Constants.ACR_LOA_MAP, "Invalid URI for ACR-LOA map: " + e.getInput());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMinimumAcrValue(ValidationContext<ClientModel> context) {
|
||||
ClientModel client = context.getObjectToValidate();
|
||||
if (!SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol()) && !OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
|
||||
return;
|
||||
}
|
||||
String minimumAcrValue = AcrUtils.getMinimumAcrValue(client);
|
||||
if (minimumAcrValue != null) {
|
||||
Map<String, Integer> acrToLoaMap = AcrUtils.getAcrLoaMap(client);
|
||||
if (acrToLoaMap.isEmpty()) {
|
||||
acrToLoaMap = AcrUtils.getAcrLoaMap(client.getRealm());
|
||||
}
|
||||
if (OIDCLoginProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
|
||||
Map<String, Integer> acrToLoaMap = AcrUtils.getAcrLoaMap(client);
|
||||
|
||||
if(!acrToLoaMap.containsKey(minimumAcrValue)) {
|
||||
if (LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm())
|
||||
.noneMatch(level -> minimumAcrValue.equals(String.valueOf(level)))) {
|
||||
context.addError("minimumAcrValue", "Minimum ACR value needs to be value specified in the ACR-To-Loa mapping or number level from set realm browser flow");
|
||||
if (!acrToLoaMap.containsKey(minimumAcrValue)) {
|
||||
if (LoAUtil.getLoAConfiguredInRealmBrowserFlow(client.getRealm())
|
||||
.noneMatch(level -> minimumAcrValue.equals(String.valueOf(level)))) {
|
||||
context.addError("minimumAcrValue", "Minimum ACR value needs to be value specified in the ACR-To-Loa mapping or number level from set realm browser flow");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Map<String, Integer> acrToLoaMap = AcrUtils.getUriLoaMap(client);
|
||||
if (!acrToLoaMap.containsKey(minimumAcrValue)) {
|
||||
context.addError("minimumAcrValue", "Minimum ACR value needs to be a URI specified in the ACR-To-Loa mapping");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,3 +61,4 @@ org.keycloak.protocol.oidc.mappers.SessionStateMapper
|
||||
org.keycloak.protocol.oidc.mappers.SubMapper
|
||||
org.keycloak.organization.protocol.mappers.saml.OrganizationMembershipMapper
|
||||
org.keycloak.organization.protocol.mappers.saml.OrganizationGroupMembershipMapper
|
||||
org.keycloak.protocol.saml.mappers.AuthnContextClassRefMapper
|
||||
|
||||
+5
@@ -38,6 +38,7 @@ import org.keycloak.testsuite.util.saml.HandleArtifactStepBuilder;
|
||||
import org.keycloak.testsuite.util.saml.IdPInitiatedLoginBuilder;
|
||||
import org.keycloak.testsuite.util.saml.LoginBuilder;
|
||||
import org.keycloak.testsuite.util.saml.ModifySamlResponseStepBuilder;
|
||||
import org.keycloak.testsuite.util.saml.OtpLoginBuilder;
|
||||
import org.keycloak.testsuite.util.saml.RequiredConsentBuilder;
|
||||
import org.keycloak.testsuite.util.saml.UpdateProfileBuilder;
|
||||
|
||||
@@ -206,6 +207,10 @@ public class SamlClientBuilder {
|
||||
return addStepBuilder(new LoginBuilder(this));
|
||||
}
|
||||
|
||||
public OtpLoginBuilder otpLogin() {
|
||||
return addStepBuilder(new OtpLoginBuilder(this));
|
||||
}
|
||||
|
||||
/** Handles update profile page after login */
|
||||
public UpdateProfileBuilder updateProfile() {
|
||||
return addStepBuilder(new UpdateProfileBuilder(this));
|
||||
|
||||
+24
@@ -18,12 +18,16 @@ package org.keycloak.testsuite.util.saml;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
|
||||
import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType;
|
||||
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
|
||||
import org.keycloak.dom.saml.v2.protocol.RequestedAuthnContextType;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.saml.common.exceptions.ConfigurationException;
|
||||
import org.keycloak.saml.common.exceptions.ParsingException;
|
||||
@@ -52,6 +56,8 @@ public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder<Authn
|
||||
private String signingCertificate;
|
||||
private URI protocolBinding;
|
||||
private String authorizationHeader;
|
||||
private List<String> authnContextClassRef = new LinkedList<>();
|
||||
private AuthnContextComparisonType comparison;
|
||||
|
||||
private final Document forceLoginRequestDocument;
|
||||
|
||||
@@ -93,6 +99,18 @@ public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder<Authn
|
||||
return this;
|
||||
}
|
||||
|
||||
public CreateAuthnRequestStepBuilder setComparison(AuthnContextComparisonType comparison) {
|
||||
this.comparison = comparison;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CreateAuthnRequestStepBuilder addAuthnContextClassRef(String... authnContextClassRef) {
|
||||
if (authnContextClassRef != null) {
|
||||
this.authnContextClassRef.addAll(List.of(authnContextClassRef));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public URI getProtocolBinding() {
|
||||
return protocolBinding;
|
||||
}
|
||||
@@ -152,6 +170,12 @@ public class CreateAuthnRequestStepBuilder extends SamlDocumentStepBuilder<Authn
|
||||
if (protocolBinding != null) {
|
||||
loginReq.setProtocolBinding(protocolBinding);
|
||||
}
|
||||
if (!authnContextClassRef.isEmpty()) {
|
||||
RequestedAuthnContextType requestAuthContext = new RequestedAuthnContextType();
|
||||
requestAuthContext.setComparison(comparison);
|
||||
authnContextClassRef.stream().forEach(ref -> requestAuthContext.addAuthnContextClassRef(ref));
|
||||
loginReq.setRequestedAuthnContext(requestAuthContext);
|
||||
}
|
||||
return SAML2Request.convert(loginReq);
|
||||
} catch (ConfigurationException | ParsingException | ProcessingException ex) {
|
||||
throw new RuntimeException(ex);
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.testsuite.util.saml;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import org.keycloak.testsuite.util.SamlClient.Step;
|
||||
import org.keycloak.testsuite.util.SamlClientBuilder;
|
||||
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.client.entity.UrlEncodedFormEntity;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.client.methods.HttpUriRequest;
|
||||
import org.apache.http.client.protocol.HttpClientContext;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Element;
|
||||
|
||||
import static org.keycloak.testsuite.util.Matchers.statusCodeIsHC;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author rmartinc
|
||||
*/
|
||||
public class OtpLoginBuilder implements Step {
|
||||
|
||||
private final SamlClientBuilder clientBuilder;
|
||||
private String otpPassword;
|
||||
|
||||
public OtpLoginBuilder(SamlClientBuilder clientBuilder) {
|
||||
this.clientBuilder = clientBuilder;
|
||||
}
|
||||
|
||||
public SamlClientBuilder build() {
|
||||
return this.clientBuilder;
|
||||
}
|
||||
|
||||
public OtpLoginBuilder otp(String otpPassword) {
|
||||
this.otpPassword = otpPassword;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpUriRequest perform(CloseableHttpClient client, URI currentURI, CloseableHttpResponse currentResponse, HttpClientContext context) throws Exception {
|
||||
assertThat(currentResponse, statusCodeIsHC(Response.Status.OK));
|
||||
String otpPageText = EntityUtils.toString(currentResponse.getEntity(), StandardCharsets.UTF_8);
|
||||
return handleOtpLoginPage(otpPageText, otpPassword);
|
||||
}
|
||||
|
||||
public static HttpUriRequest handleOtpLoginPage(String loginPage, String otpPassword) {
|
||||
org.jsoup.nodes.Document page = Jsoup.parse(loginPage);
|
||||
Element form = page.getElementById("kc-otp-login-form");
|
||||
if (form == null) {
|
||||
throw new IllegalArgumentException("Invalid OTP login form: " + loginPage);
|
||||
}
|
||||
|
||||
String action = form.attr("action");
|
||||
if (action == null) {
|
||||
throw new IllegalArgumentException("Invalid OTP login form: " + loginPage);
|
||||
}
|
||||
|
||||
HttpPost res = new HttpPost(action);
|
||||
List<NameValuePair> parameters = new LinkedList<>();
|
||||
parameters.add(new BasicNameValuePair("otp", otpPassword));
|
||||
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
|
||||
res.setEntity(formEntity);
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -198,12 +198,20 @@ public class LevelOfAssuranceFlowTest extends AbstractChangeImportedUserPassword
|
||||
}
|
||||
|
||||
public static void configureStepUpFlow(KeycloakTestingClient testingClient) {
|
||||
configureStepUpFlow(testingClient, ConditionalLoaAuthenticator.DEFAULT_MAX_AGE, 0, 0);
|
||||
configureStepUpFlow(TEST_REALM_NAME, testingClient, ConditionalLoaAuthenticator.DEFAULT_MAX_AGE, 0, 0);
|
||||
}
|
||||
|
||||
public static void configureStepUpFlow(String realmName, KeycloakTestingClient testingClient) {
|
||||
configureStepUpFlow(realmName, testingClient, ConditionalLoaAuthenticator.DEFAULT_MAX_AGE, 0, 0);
|
||||
}
|
||||
|
||||
private static void configureStepUpFlow(KeycloakTestingClient testingClient, int maxAge1, int maxAge2, int maxAge3) {
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(FLOW_ALIAS));
|
||||
testingClient.server(TEST_REALM_NAME)
|
||||
configureStepUpFlow(TEST_REALM_NAME, testingClient, maxAge1, maxAge2, maxAge3);
|
||||
}
|
||||
|
||||
private static void configureStepUpFlow(String realmName, KeycloakTestingClient testingClient, int maxAge1, int maxAge2, int maxAge3) {
|
||||
testingClient.server(realmName).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(FLOW_ALIAS));
|
||||
testingClient.server(realmName)
|
||||
.run(session -> FlowUtil.inCurrentRealm(session).selectFlow(FLOW_ALIAS).inForms(forms -> forms.clear()
|
||||
// level 1 authentication
|
||||
.addSubFlowExecution("level1-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
|
||||
|
||||
+827
@@ -0,0 +1,827 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.testsuite.saml;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.dom.saml.v2.assertion.AssertionType;
|
||||
import org.keycloak.dom.saml.v2.assertion.AuthnContextClassRefType;
|
||||
import org.keycloak.dom.saml.v2.assertion.AuthnContextType;
|
||||
import org.keycloak.dom.saml.v2.assertion.AuthnStatementType;
|
||||
import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType;
|
||||
import org.keycloak.dom.saml.v2.protocol.ResponseType;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.utils.DefaultAuthenticationFlows;
|
||||
import org.keycloak.models.utils.TimeBasedOTP;
|
||||
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
||||
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
|
||||
import org.keycloak.testsuite.Assert;
|
||||
import org.keycloak.testsuite.admin.Users;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||
import org.keycloak.testsuite.forms.LevelOfAssuranceFlowTest;
|
||||
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
|
||||
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
|
||||
import org.keycloak.testsuite.util.Matchers;
|
||||
import org.keycloak.testsuite.util.SamlClient;
|
||||
import org.keycloak.testsuite.util.SamlClientBuilder;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import org.apache.http.client.entity.UrlEncodedFormEntity;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.client.methods.HttpUriRequest;
|
||||
import org.apache.http.client.protocol.HttpClientContext;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.hamcrest.CoreMatchers;
|
||||
import org.hamcrest.MatcherAssert;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.select.Elements;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author rmartinc
|
||||
*/
|
||||
@EnableFeature(value = Profile.Feature.STEP_UP_AUTHENTICATION_SAML)
|
||||
public class LevelOfAssuranceFlowSamlTest extends AbstractSamlTest {
|
||||
|
||||
UserRepresentation otpUser;
|
||||
|
||||
public LevelOfAssuranceFlowSamlTest() {
|
||||
otpUser = createUserRepresentation("user-with-one-configured-otp", "otp1@redhat.com", null, null, true);
|
||||
Users.setPasswordFor(otpUser, CredentialRepresentation.PASSWORD);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addTestRealms(List<RealmRepresentation> testRealms) {
|
||||
super.addTestRealms(testRealms);
|
||||
RealmRepresentation testSaml = testRealms.iterator().next();
|
||||
testSaml.setOtpPolicyAlgorithm("HmacSHA1");
|
||||
testSaml.setOtpPolicyDigits(6);
|
||||
testSaml.setOtpPolicyInitialCounter(0);
|
||||
testSaml.setOtpPolicyLookAheadWindow(1);
|
||||
testSaml.setOtpPolicyPeriod(30);
|
||||
testSaml.setOtpPolicyType("totp");
|
||||
testSaml.setOtpPolicyCodeReusable(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void differentLevels() {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
// first request for level 1 password
|
||||
SamlClient samlClient = new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponsePassword);
|
||||
|
||||
// request for level 1 password again, should be automatically done
|
||||
samlClient.execute(new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.assertResponse(this::assertResponsePassword)
|
||||
.getSteps());
|
||||
|
||||
// request for level 2, should enforce OTP login
|
||||
samlClient.execute(new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.assertResponse(this::assertResponseTimeSyncToken)
|
||||
.getSteps());
|
||||
|
||||
// request for level 3, by default max-age is 0 for otp, otp again and push button
|
||||
samlClient.execute(new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:custom:authentication:pushbutton")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.addStep(new PushButtonStep())
|
||||
.assertResponse(this::assertResponsePushButton)
|
||||
.getSteps());
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void differentLevelsRedirect() {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
// first request for level 1 password
|
||||
SamlClient samlClient = new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.REDIRECT)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build().doNotFollowRedirects()
|
||||
.execute(response -> assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
|
||||
SamlClient.Binding.REDIRECT));
|
||||
|
||||
// request for level 2, should enforce OTP login
|
||||
samlClient.execute(new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.REDIRECT)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build().doNotFollowRedirects()
|
||||
.assertResponse(response -> assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken",
|
||||
SamlClient.Binding.REDIRECT))
|
||||
.getSteps());
|
||||
|
||||
// request for level 3, by default max-age is 0 for otp, otp again and push button
|
||||
samlClient.execute(new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.REDIRECT)
|
||||
.addAuthnContextClassRef("urn:custom:authentication:pushbutton")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.addStep(new PushButtonStep()).doNotFollowRedirects()
|
||||
.assertResponse(response -> assertResponse(response, "urn:custom:authentication:pushbutton",
|
||||
SamlClient.Binding.REDIRECT))
|
||||
.getSteps());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidAuthnContextClassRef() {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
// request for an undefined authn context class ref
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard")
|
||||
.relayState("0123456789")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.execute(this::assertErrorSamlResponsePost);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidAuthnContextClassRefRedirect() {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
// request for an undefined authn context class ref
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.REDIRECT)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build().doNotFollowRedirects()
|
||||
.execute(this::assertErrorSamlResponseRedirect);
|
||||
}
|
||||
|
||||
private void minimunAuthnContextClassRefTimeSyncTokenTest() {
|
||||
// login with password is not enough because minimum is TimeSyncToken
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.execute(this::assertErrorSamlResponsePost);
|
||||
|
||||
// login with TimeSyncToken should work as the minimum is OK
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void minimunAuthnContextClassRefTimeSyncToken() throws IOException {
|
||||
executeTest(this::minimunAuthnContextClassRefTimeSyncTokenTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noAuthnContextClassRef() {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponsePassword);
|
||||
}
|
||||
|
||||
private void authnContextClassRefNotReachedTest() {
|
||||
// ask for level 4 that will not be fullfilled
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:custom:authentication:level4")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.addStep(new PushButtonStep())
|
||||
.execute(this::assertErrorAuthenticationRequirementsNotFullfilled);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authnContextClassRefNotReached() throws IOException {
|
||||
Map<String, String> loaMap = Map.of(
|
||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "1",
|
||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken", "2",
|
||||
"urn:custom:authentication:pushbutton", "3",
|
||||
"urn:custom:authentication:level4", "4"
|
||||
);
|
||||
executeTest(this::authnContextClassRefNotReachedTest, loaMap, "");
|
||||
}
|
||||
|
||||
private void authnContextClassRefIncorrectMatchWithFlowTest() {
|
||||
// ask password wich in flow is 1 but requesting is 0, it means that final level does not match with the requested level
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponseUnspecified);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authnContextClassRefIncorrectMatchWithFlow() throws IOException {
|
||||
Map<String, String> loaMap = Map.of(
|
||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "0",
|
||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken", "1",
|
||||
"urn:custom:authentication:pushbutton", "2"
|
||||
);
|
||||
executeTest(this::authnContextClassRefIncorrectMatchWithFlowTest, loaMap, "");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authnContextClassRefOrder() {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
// first known authn context class ref is TimeSyncToken
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// first known authn context class ref is PasswordProtectedTransport
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.addAuthnContextClassRef("urn:custom:authentication:unknown")
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponsePassword);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidAuthnContextClassRefUri() throws IOException {
|
||||
// change the realm, because in realn there is no check
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
try (RealmAttributeUpdater realm = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
|
||||
.setAttribute(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(Map.of("invalid uri", "1")))
|
||||
.setAttribute(Constants.ACR_URI_MAP, JsonSerialization.writeValueAsString(Map.of("invalid uri", "invalid uri")))
|
||||
.update()) {
|
||||
|
||||
// the name of the acr loa map is not a valid URI, check unspecified is used
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponseUnspecified);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loaMapNotDefinedForSaml() throws IOException {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
// define no map for saml
|
||||
try (RealmAttributeUpdater realm = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
|
||||
.setAttribute(Constants.ACR_URI_MAP, "")
|
||||
.update()) {
|
||||
|
||||
// the name of the acr loa map is not a valid URI, check unspecified is used
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponseUnspecified);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noStepUpAuthentticationForSaml() throws IOException {
|
||||
// change the realm to use the default browser flow - no step-up authentication
|
||||
try (RealmAttributeUpdater realm = new RealmAttributeUpdater(adminClient.realm(REALM_NAME))
|
||||
.setBrowserFlow(DefaultAuthenticationFlows.BROWSER_FLOW)
|
||||
.update()) {
|
||||
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(bburkeUser).build()
|
||||
.execute(this::assertResponseUnspecified);
|
||||
}
|
||||
}
|
||||
|
||||
private void exactComparisonWithMinTimeSyncTest() {
|
||||
// request with a class ref less than min => error
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.EXACT)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.execute(this::assertErrorSamlResponsePost);
|
||||
|
||||
// request with class equals to min => requested level
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.EXACT)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// request with class greater than min => requested level
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.EXACT)
|
||||
.addAuthnContextClassRef("urn:custom:authentication:pushbutton")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.addStep(new PushButtonStep())
|
||||
.execute(this::assertResponsePushButton);
|
||||
}
|
||||
|
||||
private void exactComparisonNoMinTest() {
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.EXACT)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponsePassword);
|
||||
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.EXACT)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactComparison() throws IOException {
|
||||
executeTest(this::exactComparisonWithMinTimeSyncTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken");
|
||||
executeTest(this::exactComparisonNoMinTest);
|
||||
}
|
||||
|
||||
private void minimumComparisonWithMinTimeSyncTest() {
|
||||
// request with a class ref less than min => min level TimeSync
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MINIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// request with class equals to min => min level TimeSync
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MINIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// request with min greater than min => request level pushbutton
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MINIMUM)
|
||||
.addAuthnContextClassRef("urn:custom:authentication:pushbutton")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.addStep(new PushButtonStep())
|
||||
.execute(this::assertResponsePushButton);
|
||||
}
|
||||
|
||||
private void minimumComparisonNoMinTest() {
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MINIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponsePassword);
|
||||
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MINIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void minimumComparison() throws IOException {
|
||||
executeTest(this::minimumComparisonWithMinTimeSyncTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken");
|
||||
executeTest(this::minimumComparisonNoMinTest);
|
||||
}
|
||||
|
||||
private void maximumComparisonWithMinTimeSyncTest() {
|
||||
// request with a class ref less than min => error
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MAXIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.execute(this::assertErrorSamlResponsePost);
|
||||
|
||||
// request with a class ref equals or greater than min => that level
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MAXIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// request with a class ref equals or greater than min => that level
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MAXIMUM)
|
||||
.addAuthnContextClassRef("urn:custom:authentication:pushbutton")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.addStep(new PushButtonStep())
|
||||
.execute(this::assertResponsePushButton);
|
||||
}
|
||||
|
||||
private void maximumComparisonWithNoMinTest() {
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MAXIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.execute(this::assertResponsePassword);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void maximumComparison() throws IOException {
|
||||
executeTest(this::maximumComparisonWithMinTimeSyncTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken");
|
||||
executeTest(this::maximumComparisonWithNoMinTest);
|
||||
}
|
||||
|
||||
private void betterComparisonWithMinTimeSyncTest() {
|
||||
// request with a class ref less than min => min is returned
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.BETTER)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// request with a class ref equals to min => next level is returned
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.BETTER)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.addStep(new PushButtonStep())
|
||||
.execute(this::assertResponsePushButton);
|
||||
|
||||
// request with a class equals to max => error
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.BETTER)
|
||||
.addAuthnContextClassRef("urn:custom:authentication:pushbutton")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.execute(this::assertErrorSamlResponsePost);
|
||||
}
|
||||
|
||||
private void betterComparisonNoMinTest() {
|
||||
// always next level is returned
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.BETTER)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.addStep(new PushButtonStep())
|
||||
.execute(this::assertResponsePushButton);
|
||||
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.BETTER)
|
||||
.addAuthnContextClassRef("urn:custom:authentication:pushbutton")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.execute(this::assertErrorSamlResponsePost);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void betterComparison() throws IOException {
|
||||
executeTest(this::betterComparisonWithMinTimeSyncTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken");
|
||||
executeTest(this::betterComparisonNoMinTest);
|
||||
}
|
||||
|
||||
private void severalAuthnContextClassRefTest() {
|
||||
// exact should return the first one that accepts min
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// minimum should return the first one that accepts min
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MINIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// maximum should return the first one that is min or greater
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.MAXIMUM)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
|
||||
// better should return the next one to first which is valid
|
||||
new SamlClientBuilder()
|
||||
.authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_SALES_POST_SIG,
|
||||
SAML_ASSERTION_CONSUMER_URL_SALES_POST_SIG, SamlClient.Binding.POST)
|
||||
.setComparison(AuthnContextComparisonType.BETTER)
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport")
|
||||
.addAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken")
|
||||
.signWith(SAML_CLIENT_SALES_POST_SIG_PRIVATE_KEY, SAML_CLIENT_SALES_POST_SIG_PUBLIC_KEY)
|
||||
.build()
|
||||
.login().user(otpUser).build()
|
||||
.otpLogin().otp(new TimeBasedOTP().generateTOTP("DJmQfC73VGFhw7D4QJ8A")).build()
|
||||
.execute(this::assertResponseTimeSyncToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void severalAuthnContextClassRef() throws IOException {
|
||||
executeTest(this::severalAuthnContextClassRefTest, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken");
|
||||
}
|
||||
|
||||
private void executeTest(Runnable test) throws IOException {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
test.run();
|
||||
}
|
||||
|
||||
private void executeTest(Runnable test, String minAcr) throws IOException {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG)
|
||||
.setAttribute(Constants.MINIMUM_ACR_VALUE, minAcr)
|
||||
.update()) {
|
||||
test.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void executeTest(Runnable test, Map<String, String> acrLoaMap, String minAcr) throws IOException {
|
||||
LevelOfAssuranceFlowTest.configureStepUpFlow(REALM_NAME, testingClient);
|
||||
|
||||
try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_SALES_POST_SIG)
|
||||
.setAttribute(Constants.MINIMUM_ACR_VALUE, minAcr)
|
||||
.setAttribute(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(acrLoaMap))
|
||||
.update()) {
|
||||
test.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertErrorAuthenticationRequirementsNotFullfilled(CloseableHttpResponse response) {
|
||||
assertErrorPage(response, "Authentication requirements not fulfilled");
|
||||
}
|
||||
|
||||
private void assertErrorPage(CloseableHttpResponse response, String errorMessage) {
|
||||
try {
|
||||
MatcherAssert.assertThat(response, Matchers.statusCodeIsHC(Status.BAD_REQUEST));
|
||||
String page = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
|
||||
MatcherAssert.assertThat(page, CoreMatchers.containsString(errorMessage));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertErrorSamlResponsePost(CloseableHttpResponse response) {
|
||||
assertErrorSamlResponse(response, SamlClient.Binding.POST);
|
||||
}
|
||||
|
||||
private void assertErrorSamlResponseRedirect(CloseableHttpResponse response) {
|
||||
assertErrorSamlResponse(response, SamlClient.Binding.REDIRECT);
|
||||
}
|
||||
|
||||
private void assertErrorSamlResponse(CloseableHttpResponse response, SamlClient.Binding binding) {
|
||||
try {
|
||||
SAMLDocumentHolder holder = binding.extractResponse(response);
|
||||
MatcherAssert.assertThat(holder.getSamlObject(), Matchers.isSamlStatusResponse(
|
||||
JBossSAMLURIConstants.STATUS_RESPONDER, JBossSAMLURIConstants.STATUS_NOAUTHN_CTX));
|
||||
ResponseType responseType = (ResponseType) holder.getSamlObject();
|
||||
Assert.assertNotNull(responseType.getInResponseTo());
|
||||
Assert.assertNotNull(responseType.getID());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertResponseUnspecified(CloseableHttpResponse response) {
|
||||
assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified", SamlClient.Binding.POST);
|
||||
}
|
||||
|
||||
private void assertResponsePassword(CloseableHttpResponse response) {
|
||||
assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", SamlClient.Binding.POST);
|
||||
}
|
||||
|
||||
private void assertResponseTimeSyncToken(CloseableHttpResponse response) {
|
||||
assertResponse(response, "urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken", SamlClient.Binding.POST);
|
||||
}
|
||||
|
||||
private void assertResponsePushButton(CloseableHttpResponse response) {
|
||||
assertResponse(response, "urn:custom:authentication:pushbutton", SamlClient.Binding.POST);
|
||||
}
|
||||
|
||||
private void assertResponse(CloseableHttpResponse response, String classRef, SamlClient.Binding binding) {
|
||||
try {
|
||||
SAMLDocumentHolder holder = binding.extractResponse(response);
|
||||
MatcherAssert.assertThat(holder.getSamlObject(), Matchers.isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
|
||||
ResponseType responseType = (ResponseType) holder.getSamlObject();
|
||||
Assert.assertNotNull(responseType.getInResponseTo());
|
||||
Assert.assertNotNull(responseType.getID());
|
||||
Optional<URI> authContextClassRefOpt = responseType.getAssertions().stream()
|
||||
.map(ResponseType.RTChoiceType::getAssertion)
|
||||
.map(AssertionType::getStatements)
|
||||
.flatMap(s -> s.stream())
|
||||
.filter(AuthnStatementType.class::isInstance)
|
||||
.map(AuthnStatementType.class::cast)
|
||||
.findAny()
|
||||
.map(AuthnStatementType::getAuthnContext)
|
||||
.filter(Objects::nonNull)
|
||||
.map(AuthnContextType::getSequence)
|
||||
.filter(Objects::nonNull)
|
||||
.map(AuthnContextType.AuthnContextTypeSequence::getClassRef)
|
||||
.filter(Objects::nonNull)
|
||||
.map(AuthnContextClassRefType::getValue);
|
||||
Assert.assertTrue(authContextClassRefOpt.isPresent());
|
||||
Assert.assertEquals(classRef, authContextClassRefOpt.get().toString());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static class PushButtonStep implements SamlClient.Step {
|
||||
|
||||
@Override
|
||||
public HttpUriRequest perform(CloseableHttpClient client, URI currentURI, CloseableHttpResponse currentResponse, HttpClientContext context) throws Exception {
|
||||
MatcherAssert.assertThat(currentResponse, Matchers.statusCodeIsHC(Status.OK));
|
||||
String pageContent = EntityUtils.toString(currentResponse.getEntity(), StandardCharsets.UTF_8);
|
||||
org.jsoup.nodes.Document page = Jsoup.parse(pageContent);
|
||||
Elements forms = page.getElementsByTag("form");
|
||||
Assert.assertEquals(1, forms.size());
|
||||
Element form = forms.get(0);
|
||||
HttpPost res = new HttpPost(form.attr("action"));
|
||||
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(Collections.emptyList(), StandardCharsets.UTF_8);
|
||||
res.setEntity(formEntity);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -18,6 +18,10 @@
|
||||
"eventsEnabled" : true,
|
||||
"eventsListeners" : [ "jboss-logging" ],
|
||||
"enabledEventTypes" : [ ],
|
||||
"attributes": {
|
||||
"acr.loa.map": "{\"copper\":\"1\",\"silver\":\"2\",\"gold\":\"3\"}",
|
||||
"acr.uri.map": "{\"copper\":\"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport\",\"silver\":\"urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken\",\"gold\":\"urn:custom:authentication:pushbutton\"}"
|
||||
},
|
||||
"users" : [
|
||||
{
|
||||
"username" : "bburke",
|
||||
@@ -112,6 +116,23 @@
|
||||
"clientRoles" : {
|
||||
"realm-management" : [ "impersonation", "view-users" ]
|
||||
}
|
||||
},
|
||||
{
|
||||
"username" : "user-with-one-configured-otp",
|
||||
"enabled": true,
|
||||
"email" : "otp1@redhat.com",
|
||||
"credentials" : [
|
||||
{
|
||||
"type" : "password",
|
||||
"value" : "password"
|
||||
},
|
||||
{
|
||||
"id" : "unique",
|
||||
"type" : "otp",
|
||||
"secretData" : "{\"value\":\"DJmQfC73VGFhw7D4QJ8A\"}",
|
||||
"credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
|
||||
Reference in New Issue
Block a user