8000 Support for transient users for brokered accounts by hmlnarik · Pull Request #23977 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ public enum Feature {
LINKEDIN_OAUTH("LinkedIn Social Identity Provider based on OAuth", Type.DEPRECATED),

DEVICE_FLOW("OAuth 2.0 Device Authorization Grant", Type.DEFAULT),

TRANSIENT_USERS("Transient users for brokering", Type.PREVIEW),
;

private final Type type;
Expand Down
3 changes: 2 additions & 1 deletion common/src/test/java/org/keycloak/common/ProfileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public void checkDefaults() {

Assert.assertEquals(Profile.ProfileName.DEFAULT, profile.getName());
Set<Profile.Feature> disabledFeatures = new HashSet<>(Arrays.asList(
Profile.Feature.TRANSIENT_USERS,
Profile.Feature.DPOP,
Profile.Feature.FIPS,
Profile.Feature.ACCOUNT3,
Expand All @@ -92,7 +93,7 @@ public void checkDefaults() {
disabledFeatures.add(Profile.Feature.KERBEROS);
}
assertEquals(profile.getDisabledFeatures(), disabledFeatures);
assertEquals(profile.getPreviewFeatures(), Profile.Feature.ACCOUNT3, Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.RECOVERY_CODES, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Profile.Feature.CLIENT_SECRET_ROTATION, Profile.Feature.UPDATE_EMAIL, Profile.Feature.DPOP);
assertEquals(profile.getPreviewFeatures(), Profile.Feature.ACCOUNT3, Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.RECOVERY_CODES, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Profile.Feature.CLIENT_SECRET_ROTATION, Profile.Feature.UPDATE_EMAIL, Profile.Feature.DPOP, Profile.Feature.TRANSIENT_USERS);
}

@Test
Expand Down
2 changes: 2 additions & 0 deletions js/apps/admin-ui/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2871,6 +2871,7 @@
"disableUserInfo": "Disable user info",
"isAccessTokenJWT": "Access Token is JWT",
"userInfoUrl": "User Info URL",
"doNotStoreUsers": "Do not store users",
"issuer": "Issuer",
"prompt": "Prompt",
"prompts": {
Expand Down Expand Up @@ -2954,6 +2955,7 @@
"disableUserInfoHelp": "Disable usage of User Info service to obtain additional user information? Default is to use this OIDC service.",
"isAccessTokenJWTHelp": "The Access Token received from the Identity Provider is a JWT and its claims will be accessible for mappers.",
"userInfoUrlHelp": "The User Info Url. This is optional.",
"doNotStoreUsersHelp": "When enabled, users from this broker are not persisted in internal database.",
"issuerHelp": "The issuer identifier for the issuer of the response. If not provided, no validation will be performed.",
"promptHelp": "Specifies whether the Authorization Server prompts the End-User for re-authentication and consent.",
"acceptsPromptNoneHelp": "This is just used together with Identity Provider Authenticator or when kc_idp_hint points to this identity provider. In case that client sends a request with prompt=none and user is not yet authenticated, the error will not be directly returned to client, but the request with prompt=none will be forwarded to this identity provider.",
Expand Down
116 changes: 76 additions & 40 deletions js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { HelpItem } from "ui-shared";

import { adminClient } from "../../admin-client";
import { useFetch } from "../../utils/useFetch";
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";
import type { FieldProps } from "../component/FormGroupField";
import { FormGroupField } from "../component/FormGroupField";
import { SwitchField } from "../component/SwitchField";
Expand Down Expand Up @@ -96,6 +97,7 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
const {
control,
register,
setValue,
formState: { errors },
} = useFormContext<IdentityProviderRepresentation>();
const [syncModeOpen, setSyncModeOpen] = useState(false);
Expand All @@ -105,6 +107,14 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
defaultValue: "false",
});
const claimFilterRequired = filteredByClaim === "true";
const isFeatureEnabled = useIsFeatureEnabled();
const isTransientUsersEnabled = isFeatureEnabled(Feature.TransientUsers);
const transientUsers = useWatch({
control,
name: "config.doNotStoreUsers",
defaultValue: "false",
});
const syncModeAvailable = transientUsers === "false";
return (
<>
{!isOIDC && !isSAML && (
Expand Down Expand Up @@ -231,46 +241,72 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
defaultValue=""
/>

<FormGroup
className="pf-u-pb-3xl"
label={t("syncMode")}
labelIcon={
<HelpItem helpText={t("syncModeHelp")} fieldLabelId="syncMode" />
}
fieldId="syncMode"
>
<Controller
name="config.syncMode"
defaultValue={syncModes[0].toUpperCase()}
control={control}
render={({ field }) => (
<Select
toggleId="syncMode"
required
direction="up"
=> setSyncModeOpen(!syncModeOpen)}
value) => {
field.onChange(value as string);
setSyncModeOpen(false);
}}
selections={t(`syncModes.${field.value.toLowerCase()}`)}
variant={SelectVariant.single}
aria-label={t("syncMode")}
isOpen={syncModeOpen}
>
{syncModes.map((option) => (
<SelectOption
selected={option === field.value}
key={option}
value={option.toUpperCase()}
>
{t(`syncModes.${option}`)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
{isTransientUsersEnabled && (
<FormGroupField label="doNotStoreUsers">
<Controller
name="config.doNotStoreUsers"
defaultValue="false"
control={control}
render={({ field }) => (
<Switch
id="doNotStoreUsers"
label={t("on")}
labelOff={t("off")}
isChecked={field.value === "true"}
=> {
field.onChange(value.toString());
// if field is checked, set sync mode to import
if (value) {
setValue("config.syncMode", "IMPORT");
}
}}
/>
)}
/>
</FormGroupField>
)}
{syncModeAvailable && (
<FormGroup
className="pf-u-pb-3xl"
label={t("syncMode")}
labelIcon={
<HelpItem helpText={t("syncModeHelp")} fieldLabelId="syncMode" />
}
fieldId="syncMode"
>
<Controller
name="config.syncMode"
defaultValue={syncModes[0].toUpperCase()}
control={control}
render={({ field }) => (
<Select
toggleId="syncMode"
required
direction="up"
=> setSyncModeOpen(!syncModeOpen)}
value) => {
field.onChange(value as string);
setSyncModeOpen(false);
}}
selections={t(`syncModes.${field.value.toLowerCase()}`)}
variant={SelectVariant.single}
aria-label={t("syncMode")}
isOpen={syncModeOpen}
>
{syncModes.map((option) => (
<SelectOption
selected={option === field.value}
key={option}
value={option.toUpperCase()}
>
{t(`syncModes.${option}`)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
)}
</>
);
};
1 change: 1 addition & 0 deletions js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export enum Feature {
DynamicScopes = "DYNAMIC_SCOPES",
DPoP = "DPOP",
DeviceFlow = "DEVICE_FLOW",
TransientUsers = "TRANSIENT_USERS",
}

export default function useIsFeatureEnabled() {
Expand Down
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

package org.keycloak.models.sessions.infinispan;

import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import java.util.Collections;
import java.util.concurrent.ConcurrentHashMap;
import java.util.HashSet;
Expand All @@ -28,8 +30,11 @@
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.sessions.infinispan.entities.AuthenticationSessionEntity;
import org.keycloak.models.light.LightweightUserAdapter;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import static org.keycloak.models.Constants.SESSION_NOTE_LIGHTWEIGHT_USER;
import static org.keycloak.models.light.LightweightUserAdapter.isLightweightUser;

/**
* NOTE: Calling setter doesn't automatically enlist for update
Expand Down Expand Up @@ -282,12 +287,49 @@ public void clearExecutionStatus() {

@Override
public UserModel getAuthenticatedUser() {
return entity.getAuthUserId() == null ? null : session.users().getUserById(getRealm(), entity.getAuthUserId()); }
if (entity.getAuthUserId() == null) {
return null;
}

if (Profile.isFeatureEnabled(Feature.TRANSIENT_USERS) && getUserSessionNotes().containsKey(SESSION_NOTE_LIGHTWEIGHT_USER)) {
LightweightUserAdapter cachedUser = session.getAttribute("authSession.user." + parent.getId(), LightweightUserAdapter.class);

if (cachedUser != null) {
return cachedUser;
}

LightweightUserAdapter lua = LightweightUserAdapter.fromString(session, parent.getRealm(), getUserSessionNotes().get(SESSION_NOTE_LIGHTWEIGHT_USER));
session.setAttribute("authSession.user." + parent.getId(), lua);
lua.setUpdateHandler(lua1 -> {
if (lua == lua1) { // Ensure there is no conflicting user model, only the latest lightweight user can be used
setUserSessionNote(SESSION_NOTE_LIGHTWEIGHT_USER, lua1.serialize());
}
});

return lua;
} else {
return session.users().getUserById(getRealm(), entity.getAuthUserId());
}
}

@Override
public void setAuthenticatedUser(UserModel user) {
if (user == null) entity.setAuthUserId(null);
else entity.setAuthUserId(user.getId());
if (user == null) {
entity.setAuthUserId(null);
setUserSessionNote(SESSION_NOTE_LIGHTWEIGHT_USER, null);
} else {
entity.setAuthUserId(user.getId());

if (isLightweightUser(user)) {
LightweightUserAdapter lua = (LightweightUserAdapter) user;
setUserSessionNote(SESSION_NOTE_LIGHTWEIGHT_USER, lua.serialize());
lua.setUpdateHandler(lua1 -> {
if (lua == lua1) { // Ensure there is no conflicting user model, only the latest lightweight user can be used
setUserSessionNote(SESSION_NOTE_LIGHTWEIGHT_USER, lua1.serialize());
}
});
}
}
update();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import org.infinispan.stream.CacheCollectors;
import org.jboss.logging.Logger;
import org.keycloak.cluster.ClusterProvider;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.util.Retry;
import org.keycloak.common.util.Time;
import org.keycloak.models.AuthenticatedClientSessionModel;
Expand Down Expand Up @@ -53,13 +55,13 @@
import org.keycloak.models.sessions.infinispan.events.RealmRemovedSessionEvent;
import org.keycloak.models.sessions.infinispan.events.RemoveUserSessionsEvent;
import org.keycloak.models.sessions.infinispan.events.SessionEventsSenderTransaction;
import org.keycloak.models.sessions.infinispan.stream.Comparators;
import org.keycloak.models.sessions.infinispan.stream.Mappers;
import org.keycloak.models.sessions.infinispan.stream.SessionPredicate;
import org.keycloak.models.sessions.infinispan.stream.UserSessionPredicate;
import org.keycloak.models.sessions.infinispan.util.FuturesHelper;
import org.keycloak.models.sessions.infinispan.util.InfinispanKeyGenerator;
import org.keycloak.connections.infinispan.InfinispanUtil;
import org.keycloak.models.light.LightweightUserAdapter;
import org.keycloak.models.sessions.infinispan.util.SessionTimeouts;

import java.io.Serializable;
Expand All @@ -83,6 +85,7 @@
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import static org.keycloak.models.Constants.SESSION_NOTE_LIGHTWEIGHT_USER;
import static org.keycloak.utils.StreamsUtil.paginatedStream;

/**
Expand Down Expand Up @@ -231,7 +234,9 @@ public UserSessionModel createUserSession(String id, RealmModel realm, UserModel
SessionUpdateTask<UserSessionEntity> createSessionTask = Tasks.addIfAbsentSync();
sessionTx.addTask(id, createSessionTask, entity, persistenceState);

UserSessionAdapter adapter = wrap(realm, entity, false);
UserSessionAdapter adapter = user instanceof LightweightUserAdapter
? wrap(realm, entity, false, user)
: wrap(realm, entity, false);
adapter.setPersistenceState(persistenceState);
return adapter;
}
Expand Down Expand Up @@ -736,20 +741,37 @@ protected void removeUserSession(UserSessionEntity sessionEntity, boolean offlin
userSessionUpdateTx.addTask(sessionEntity.getId(), removeTask);
}

UserSessionAdapter wrap(RealmModel realm, UserSessionEntity entity, boolean offline) {
UserSessionAdapter wrap(RealmModel realm, UserSessionEntity entity, boolean offline, UserModel user) {
InfinispanChangelogBasedTransaction<String, UserSessionEntity> userSessionUpdateTx = getTransaction(offline);
InfinispanChangelogBasedTransaction<UUID, AuthenticatedClientSessionEntity> clientSessionUpdateTx = getClientSessionTransaction(offline);

if (entity == null) {
return null;
}

UserModel user = session.users().getUserById(realm, entity.getUser());
return new UserSessionAdapter(session, user, this, userSessionUpdateTx, clientSessionUpdateTx, realm, entity, offline);
}

UserSessionAdapter wrap(RealmModel realm, UserSessionEntity entity, boolean offline) {
UserModel user = null;
if (Profile.isFeatureEnabled(Feature.TRANSIENT_USERS) && entity.getNotes().containsKey(SESSION_NOTE_LIGHTWEIGHT_USER)) {
LightweightUserAdapter lua = LightweightUserAdapter.fromString(session, realm, entity.getNotes().get(SESSION_NOTE_LIGHTWEIGHT_USER));
final UserSessionAdapter us = wrap(realm, entity, offline, lua);
lua.setUpdateHandler(lua1 -> {
if (lua == lua1) { // Ensure there is no conflicting user model, only the latest lightweight user can be used
us.setNote(SESSION_NOTE_LIGHTWEIGHT_USER, lua1.serialize());
}
});
return us;
}

user = session.users().getUserById(realm, entity.getUser());

if (user == null) {
return null;
}

return new UserSessionAdapter(session, user, this, userSessionUpdateTx, clientSessionUpdateTx, realm, entity, offline);
return wrap(realm, entity, offline, user);
}

AuthenticatedClientSessionAdapter wrap(UserSessionModel userSession, ClientModel client, AuthenticatedClientSessionEntity entity, boolean offline) {
Expand Down
Loading
0