Skip to content

Commit

Permalink
Implement authentication flow mapping for authentication context clas…
Browse files Browse the repository at this point in the history
…s reference (ACR) values.

This implements an alternative mapping for the existing ACR feature where administrators can map requested ACR values to authentication flows, in addition to the existing level of authentication (LoA) mapping. It also implements a protocol mapper for populating the ACR claim in the resulting OIDC tokens.

This implementation adds an ACR to auth flow mapping configuration at the client and realm level. When a client authenticates a user, and they request a particular ACR value, Keycloak will check for a mapped authentication flow in the client or realm configuration. If one is found, Keycloak will executed the corresponding authentication flow. If none match, then the existing LoA behavior will take place. Upon successful completion of an authentication flow, Keycloak tracks the flow ID in a user session note.

The protocol mapper takes this completed authentication flow ID from the user session note and finds the associated ACR value from the realm or client configuration and sets it in the tokens.

Closes keycloak#24297

Signed-off-by: Ben Cresitello-Dittmar <[email protected]>
  • Loading branch information
Ben Cresitello-Dittmar committed Dec 20, 2023
1 parent 751cadc commit 36f89d9
Show file tree
Hide file tree
Showing 20 changed files with 1,184 additions and 26 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/documentation/server_admin/topics/admin-console.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ include::realms/proc-using-admin-console.adoc[leveloffset=1]
include::realms/master.adoc[leveloffset=2]
include::realms/proc-creating-a-realm.adoc[leveloffset=2]
include::realms/ssl.adoc[leveloffset=2][]
include::realms/acr-auth-flow-mapping.adoc[leveloffset=2][]
include::realms/email.adoc[leveloffset=2]
include::realms/themes.adoc[leveloffset=2]
include::realms/proc-configuring-internationalization.adoc[leveloffset=2]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,23 @@ really requires level 2, the client is encouraged to check the presence of the `
image:images/client-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].

[[_mapping-acr-to-auth-flow-client]]
*ACR to Authentication Flow Mapping*

In the advanced settings of a client, you can define mappings from `Authentication Context Class Reference (ACR)` values in OIDC authorization requests to authentication
flows. This enables clients to request specific authentication flows using the OIDC ACR claim. For more details on using this claim see the
https://openid.net/specs/openid-connect-core-1_0.html#acrSemantics[official OIDC specification].

ACR authentication flow mappings and level of authentication (LoA) mappings provide two different ways for administrators to enable clients to request a particular strength of
authentication when authenticating users. While LoA provides the ability to step-up authentication, sometimes it is necessary for applications to authenticate users with an
entirely different policy in different scenarios. For these cases, clients can use the ACR to authentication flow mapping functionality.

[NOTE]
====
When both level of authentication (LoA) and auth flow mappings are defined for a given client or realm, Keycloak will first check if there is a valid auth flow
mapping. If there is, then Keycloak will ignore any matching LoA mappings and route the authentication session to the mapped authentication flow. If there isn't,
then the LoA process will continue as described in the chapter <<_mapping-acr-to-loa-client, ACR to Level of Authentication (LoA) Mapping>>
====

image:images/client-oidc-map-acr-to-auth-flow.png[alt="ACR to auth flow mapping"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[[_acr_auth_flow_mapping]]

= ACR to Authentication Flow Mapping

In the general settings of the realm, you can define mappings from `Authentication Context Class Reference (ACR)` values in OIDC authorization requests to authentication
flows. This enables clients to request specific authentication flows using the OIDC ACR claim. For more details on using this claim see the
https://openid.net/specs/openid-connect-core-1_0.html#acrSemantics[official OIDC specification].

ACR to authentication flow mappings and level of authentication (LoA) mappings provide two different ways for administrators to enable clients to request a particular strength of
authentication when authenticating users. While LoA provides the ability to step-up authentication, sometimes it is necessary for applications to authenticate users with an
entirely different policy in different scenarios. For these cases, clients can use the ACR to authentication flow mapping functionality.

This mapping can be configured at both the realm level and the client level. If no mapping is configured at the client level, it will default to the realm level configuration.

For more information about the client level configuration, see <<_mapping-acr-to-auth-flow-client,Client ACR to Authentication Flow Mapping>>.

[NOTE]
====
When both level of authentication (LoA) and auth flow mappings are defined for a given client or realm, Keycloak will first check if there is a valid auth flow
mapping. If there is, then Keycloak will ignore any matching LoA mappings and route the authentication session to the mapped authentication flow. If there isn't,
then the LoA process will continue as described in the chapter <<_mapping-acr-to-loa-client, ACR to Level of Authentication (LoA) Mapping>>
====

image:images/client-oidc-map-acr-to-auth-flow.png[alt="ACR to auth flow mapping"]
Original file line number Diff line number Diff line change
Expand Up @@ -2941,3 +2941,13 @@ missingEmailMessage='{{0}}': Please specify email.
missingPasswordMessage='{{0}}': Please specify password.
referral=Referral
referralHelp=Specifies if LDAP referrals should be followed or ignored. Please note that enabling referrals can slow down authentication as it allows the LDAP server to decide which other LDAP servers to use. This could potentially include untrusted servers.
acrFlowMapping.title=ACR to Authentication Flow Mapping
acrFlowMapping.help=Define which authentication flow to map Authentication Context Class Reference (ACR) values to. When a client submits an authorization request with an ACR value specified, the user will be directed to the first authentication flow matching this value.
acrFlowMapping.add=Add mapping
acrFlowMapping.empty=No mappings have been defined yet. Click the button below to add an ACR value to authentication flow mapping.
acrFlowMapping.key.header=ACR Value
acrFlowMapping.key.placeholder=Type an ACR value
acrFlowMapping.key.error=An ACR value must be specified
acrFlowMapping.value.header=Authentication Flow
acrFlowMapping.value.placeholder=Select a flow
acrFlowMapping.value.error=A flow must be selected
20 changes: 20 additions & 0 deletions js/apps/admin-ui/src/clients/ClientDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,16 @@ export default function ClientDetails() {
),
);
}

if (client.attributes?.["acr.authflow.map"]) {
form.setValue(
convertAttributeNameToForm("attributes.acr.authflow.map"),
// @ts-ignore
Object.entries(
JSON.parse(client.attributes["acr.authflow.map"]),
).flatMap(([key, value]) => ({ key, value })),
);
}
};

useFetch(
Expand Down Expand Up @@ -350,6 +360,16 @@ export default function ClientDetails() {
);
}

if (submittedClient.attributes?.["acr.authflow.map"]) {
submittedClient.attributes["acr.authflow.map"] = JSON.stringify(
Object.fromEntries(
(submittedClient.attributes["acr.authflow.map"] as KeyValueType[])
.filter(({ key }) => key !== "")
.map(({ key, value }) => [key, value]),
),
);
}

try {
const newClient: ClientRepresentation = {
...client,
Expand Down
2 changes: 2 additions & 0 deletions js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { convertAttributeNameToForm } from "../../util";
import { useFetch } from "../../utils/useFetch";
import { FormFields } from "../ClientDetails";
import { TokenLifespan } from "./TokenLifespan";
import { AcrFlowMapping } from "../../components/acr-flow-mapping/AcrFlowMapping";

import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";

Expand Down Expand Up @@ -280,6 +281,7 @@ export const AdvancedSettings = ({
name={convertAttributeNameToForm("attributes.acr.loa.map")}
/>
</FormGroup>
<AcrFlowMapping />
<FormGroup
label={t("defaultACRValues")}
fieldId="defaultACRValues"
Expand Down
215 changes: 215 additions & 0 deletions js/apps/admin-ui/src/components/acr-flow-mapping/AcrFlowMapping.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import {
Button,
FormGroup,
Select,
SelectOption,
SelectVariant,
Grid,
GridItem,
ActionList,
ActionListItem,
EmptyState,
EmptyStateBody,
HelperText,
HelperTextItem,
} from "@patternfly/react-core";
import { sortBy } from "lodash-es";
import { useState, Fragment } from "react";
import { Controller, useFormContext, useFieldArray } from "react-hook-form";
import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
import { useTranslation } from "react-i18next";

import { HelpItem } from "ui-shared";

import { adminClient } from "../../admin-client";
import { useFetch } from "../../utils/useFetch";
import { KeycloakTextInput } from "../keycloak-text-input/KeycloakTextInput";
import { convertAttributeNameToForm } from "../../util";

export const AcrFlowMapping = () => {
const { t } = useTranslation();
const [flows, setFlows] = useState<JSX.Element[]>([]);
const [selectOpen] = useState({} as Record<string, boolean>);
const [key, setKey] = useState(0);

const name: string = convertAttributeNameToForm(
"attributes.acr.authflow.map",
);
const {
control,
register,
formState: { errors },
} = useFormContext();

const { fields, append, remove } = useFieldArray({
shouldUnregister: true,
control,
name,
});

const appendNew = () => append({ key: "", value: "" });

const refresh = () => setKey(key + 1);

const toggle = (k: string) => {
const state = selectOpen[k] ? true : false;
selectOpen[k] = !state;
refresh();
};

useFetch(
() => adminClient.authenticationManagement.getFlows(),
(flows) => {
let filteredFlows = [
...flows.filter((flow) => flow.providerId !== "client-flow"),
];
filteredFlows = sortBy(filteredFlows, [(f) => f.alias]);
setFlows([
<SelectOption key="empty" value="">
{t("choose")}
</SelectOption>,
...filteredFlows.map((flow) => (
<SelectOption key={flow.id} value={flow.id}>
{flow.alias}
</SelectOption>
)),
]);
},
[],
);

return (
<FormGroup
label={t("acrFlowMapping.title")}
fieldId="acrFlowMapping"
labelIcon={
<HelpItem
helpText={t("acrFlowMapping.help")}
fieldLabelId="acrFlowMapping"
/>
}
>
{fields.length > 0 ? (
<>
<Grid hasGutter>
<GridItem className="pf-c-form__label" span={5}>
<span className="pf-c-form__label-text">
{t("acrFlowMapping.key.header")}
</span>
</GridItem>
<GridItem className="pf-c-form__label" span={7}>
<span className="pf-c-form__label-text">
{t("acrFlowMapping.value.header")}
</span>
</GridItem>
{fields.map((attribute, index) => {
const keyError = !!(errors as any).attributes?.[
name.replace("attributes.", "")
]?.[index]?.key;
const valueError = !!(errors as any).attributes?.[
name.replace("attributes.", "")
]?.[index]?.value;

return (
<Fragment key={attribute.id}>
<GridItem span={5}>
<KeycloakTextInput
placeholder={t("acrFlowMapping.key.placeholder")}
data-testid={`${name}-key`}
{...register(`${name}.${index}.key`, { required: true })}
validated={keyError ? "error" : "default"}
isRequired
/>
{keyError && (
<HelperText>
<HelperTextItem variant="error">
{t("acrFlowMapping.key.error")}
</HelperTextItem>
</HelperText>
)}
</GridItem>
<GridItem span={5}>
<Controller
name={`${name}.${index}.value`}
control={control}
render={({ field }) => (
<Select
placeholderText={t(
"acrFlowMapping.value.placeholder",
)}
toggleId={`${name}.${index}.value`}
variant={SelectVariant.single}
onToggle={() => toggle(`${name}.${index}.value`)}
isOpen={selectOpen[`${name}.${index}.value`]}
onSelect={(_, value) => {
field.onChange(value);
toggle(`${name}.${index}.value`);
}}
selections={[field.value]}
{...register(`${name}.${index}.value`, {
required: true,
})}
validated={valueError ? "error" : "default"}
>
{flows}
</Select>
)}
/>

{valueError && (
<HelperText>
<HelperTextItem variant="error">
{t("acrFlowMapping.value.error")}
</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-u-px-0 pf-u-mt-sm"
variant="link"
icon={<PlusCircleIcon />}
onClick={appendNew}
>
{t("acrFlowMapping.add")}
</Button>
</ActionListItem>
</ActionList>
</>
) : (
<EmptyState
data-testid={`${name}-empty-state`}
className="pf-u-p-0"
variant="xs"
>
<EmptyStateBody>{t("acrFlowMapping.empty")}</EmptyStateBody>
<Button
data-testid={`${name}-add-row`}
variant="link"
icon={<PlusCircleIcon />}
isSmall
onClick={appendNew}
>
{t("acrFlowMapping.add")}
</Button>
</EmptyState>
)}
</FormGroup>
);
};
15 changes: 15 additions & 0 deletions js/apps/admin-ui/src/realm-settings/GeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { FormattedLink } from "../components/external-link/FormattedLink";
import { FormAccess } from "../components/form/FormAccess";
import { KeyValueInput } from "../components/key-value-form/KeyValueInput";
import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
import { AcrFlowMapping } from "../components/acr-flow-mapping/AcrFlowMapping";
import { useRealm } from "../context/realm-context/RealmContext";
import {
addTrailingSlash,
Expand Down Expand Up @@ -86,6 +87,17 @@ export const RealmSettingsGeneralTab = ({
result,
);
}

if (realm.attributes?.["acr.authflow.map"]) {
setValue(
convertAttributeNameToForm("attributes.acr.authflow.map"),
// @ts-ignore
Object.entries(
JSON.parse(realm.attributes["acr.authflow.map"]),
).flatMap(([key, value]) => ({ key, value })),
);
}

Check failure on line 100 in js/apps/admin-ui/src/realm-settings/GeneralTab.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Delete `····`
setUserProfileEnabled(realm.attributes?.["userProfileEnabled"] === "true");
};

Expand Down Expand Up @@ -222,6 +234,9 @@ export const RealmSettingsGeneralTab = ({
/>
</FormProvider>
</FormGroup>
<FormProvider {...form}>
<AcrFlowMapping />
</FormProvider>
<FormGroup
hasNoPaddingTop
label={t("userManagedAccess")}
Expand Down
10 changes: 10 additions & 0 deletions js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,16 @@ export const RealmSettingsTabs = ({
);
}

if (r.attributes?.["acr.authflow.map"]) {
r.attributes["acr.authflow.map"] = JSON.stringify(
Object.fromEntries(
(r.attributes["acr.authflow.map"] as KeyValueType[])
.filter(({ key }) => key !== "")
.map(({ key, value }) => [key, value]),
),
);
}

try {
const savedRealm: UIRealmRepresentation = {
...realm,
Expand Down
Loading

0 comments on commit 36f89d9

Please sign in to comment.