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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# The server applies the SSO profile on server-driven logins

- **Status:** Accepted
- **Revisions:** v1 2026-10-17 (initial)
- **Deciders:** Mohit Yadav
- **Guard:** `UserProfileClaimsTest`, `AuthenticationCodeFlowHandlerTest`, `EmailFirstIdentityIT.testSignInStoresAndRefreshesTheProviderPicture`, `AuthProvider.test.tsx` ("leaves the profile to the server after a confidential-client login")
- **Related:** #35674, #25623, #25391

## Context

A user's display name and picture come from the identity provider's ID token claims (`name`, or
`given_name` + `family_name`, and `picture`). Under a public OIDC client the browser holds those
claims and writes them to the user with a PATCH. Under a confidential client, and under SAML, the
server completes sign-in. The browser is only handed the OpenMetadata username in a redirect
parameter.

So nobody applied the provider's profile on those logins. The legacy principal-claims path
resolved the display name and dropped it (from 1.12.4), and no server path read `picture`. The
browser then saved the username it was handed as the display name.

## Decision

- **Who writes it.** When the server completes sign-in (a confidential OIDC client, or SAML), the
server applies the provider's display name and picture. The UI does not write the profile after
such a login (`isServerDrivenLogin`). Under a public OIDC client the browser keeps doing it.
- **Picture.** The server reads the standard OIDC `picture` claim and stores it only if it is an
absolute `http`/`https` URI, because it becomes an image source in the browser. A new picture
replaces the whole stored image list, so sizes derived from an older picture stop showing. A
token without a picture leaves the stored one alone.
- **Display name on the legacy principal-claims path.** The provider's name fills the display name
only when the account has none, or has one equal to its login name, which is what the browser
wrote while the provider's name was dropped. Any other display name was chosen in OpenMetadata
and is kept. The email-first path keeps its existing rule: the provider's name replaces the
stored one whenever the provider sends one.

## Consequences

- Accounts that the browser stamped with their login name are repaired on their next sign-in,
without a migration.
- Profile pictures depend on the provider sending `picture` for the requested scopes. Azure AD
sends none, so its users still have no picture.
- Moving the display-name rule of the two paths into line, either way, means revisiting this
record: the legacy path would overwrite chosen names, or the email-first path would stop
following provider renames.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.UserRepository;
import org.openmetadata.service.security.EmailFirstUserProvisioner;
import org.openmetadata.service.security.UserProfileClaims;
import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.UserUtil;

Expand Down Expand Up @@ -227,6 +228,45 @@ void testLoginDoesNotEraseStoredUserFields(TestNamespace ns) {
assertEquals(user.getId(), after.getId());
}

/**
* The principal-claims OIDC login finds the account by name and saves it back when the login
* changes it, here by filling the display name. The copy it loads must carry the stored profile.
*/
@Test
void testLoginByNameDoesNotEraseStoredUserFields(TestNamespace ns) {
String localPart = ("byname" + ns.uniqueShortId()).toLowerCase(Locale.ROOT);
URI picture = URI.create("https://idp.test.om.org/" + localPart + ".png");
User user = provisionWithPicture(localPart + "@x.test.om.org", picture);
UserRepository userRepository = Entity.getUserRepository();

User forLogin = userRepository.getUserByNameForAuthUpdate(user.getName());
assertEquals(picture, forLogin.getProfile().getImages().getImage());

forLogin.setDisplayName("Name From The Provider");
UserUtil.addOrUpdateUser(forLogin);

assertEquals(
picture, storedPicture(user.getEmail()), "Saving the login's copy erased the profile");
}

@Test
void testSignInStoresAndRefreshesTheProviderPicture(TestNamespace ns) {
String localPart = ("picture" + ns.uniqueShortId()).toLowerCase(Locale.ROOT);
String email = localPart + "@x.test.om.org";
URI firstPicture = URI.create("https://idp.test.om.org/" + localPart + "/first.png");
URI newPicture = URI.create("https://idp.test.om.org/" + localPart + "/new.png");

provisionWithPicture(email, firstPicture);
assertEquals(firstPicture, storedPicture(email), "Sign-up must store the provider's picture");

provisionWithPicture(email, newPicture);
assertEquals(newPicture, storedPicture(email), "A new provider picture must replace the old");

provisionWithPicture(email, null);
assertEquals(
newPicture, storedPicture(email), "A token without a picture must keep the stored one");
}

@Test
void testSynthesizedEmailDomainListingFindsAccountsToRepair(TestNamespace ns) {
String suffix = ns.uniqueShortId().toLowerCase(Locale.ROOT);
Expand Down Expand Up @@ -257,6 +297,26 @@ private User provision(String email, String displayName) {
return provision(email, displayName, null);
}

/** Applies the picture the way the OIDC sign-in does, both to a new account and an existing one. */
private User provisionWithPicture(String email, URI picture) {
return EmailFirstUserProvisioner.forProvider(
"TEST",
new AuthorizerConfiguration(),
Entity.getUserRepository(),
user -> UserProfileClaims.syncPicture(user, picture),
user -> UserProfileClaims.syncPicture(user, picture))
.getOrCreate(email, "Picture User", null, true);
}

private URI storedPicture(String email) {
Profile profile =
Entity.getUserRepository()
.getByEmail(null, email, new EntityUtil.Fields(Set.of("profile")))
.getProfile();
assertNotNull(profile, "Expected a stored profile for " + email);
return profile.getImages().getImage();
}

private User provision(String email, String displayName, String identityProviderSubject) {
List<String> noTeams = List.of();
return EmailFirstUserProvisioner.forProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,14 @@ public Fields getAuthUpdateFields() {
return new Fields(fields);
}

/**
* Name lookup for an authentication flow that updates the account it finds. Reads it fresh, with
* the {@link #getAuthUpdateFields} a PUT of the result writes back.
*/
public User getUserByNameForAuthUpdate(String name) {
return getByName(null, name, getAuthUpdateFields(), NON_DELETED, false);
}

/**
* Streams the accounts whose email sits in {@code domain}, ordered by name, in pages. The
* {@code change-email} ops flow uses this to find accounts still holding a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -646,8 +646,8 @@ public void handleCallback(HttpServletRequest req, HttpServletResponse resp) {
OidcIdentityResolver.ResolvedOidcIdentity identity = resolveOidcIdentity(claims);
User user =
identity.emailFirstFlow()
? getOrCreateEmailFirstOidcUser(identity.email(), identity.displayName(), claims)
: getOrCreateOidcUser(identity.userName(), identity.email(), claims);
? getOrCreateEmailFirstOidcUser(identity, claims)
: getOrCreateOidcUser(identity, claims);
syncRolesFromProvider(user, claims);

Entity.getUserRepository().updateUserLastLoginTime(user, System.currentTimeMillis());
Expand Down Expand Up @@ -1166,33 +1166,47 @@ private OidcIdentityResolver.ResolvedOidcIdentity resolveOidcIdentity(
}

private User getOrCreateEmailFirstOidcUser(
String email, String displayName, Map<String, Object> claims) {
OidcIdentityResolver.ResolvedOidcIdentity identity, Map<String, Object> claims) {
List<String> teamsFromClaim = findTeamsFromClaims(teamClaimMapping, claims);
return EmailFirstUserProvisioner.forProvider(
"OIDC",
authorizerConfiguration,
Entity.getUserRepository(),
user -> UserUtil.assignTeamsFromClaim(user, teamsFromClaim),
user -> UserUtil.assignTeamsFromClaim(user, teamsFromClaim))
user -> applyTeamsAndPicture(user, teamsFromClaim, identity.picture()),
user -> applyTeamsAndPicture(user, teamsFromClaim, identity.picture()))
.getOrCreate(
email,
displayName,
identity.email(),
identity.displayName(),
SecurityUtil.getClaimOrObject(claims.get(JWTTokenGenerator.SUBJECT_CLAIM)),
Boolean.TRUE.equals(authenticationConfiguration.getEnableSelfSignup()));
}

private static boolean applyTeamsAndPicture(User user, List<String> teams, URI picture) {
boolean teamsAssigned = UserUtil.assignTeamsFromClaim(user, teams);
boolean pictureChanged = UserProfileClaims.syncPicture(user, picture);
return teamsAssigned || pictureChanged;
}

private static boolean applyDisplayNameAndPicture(
User user, OidcIdentityResolver.ResolvedOidcIdentity identity) {
boolean displayNameFilled = UserProfileClaims.fillDisplayName(user, identity.displayName());
boolean pictureChanged = UserProfileClaims.syncPicture(user, identity.picture());
return displayNameFilled || pictureChanged;
}

private boolean isUserAdmin(String email, String username) {
return UserUtil.isConfiguredAdmin(authorizerConfiguration, email, username);
}

private User getOrCreateOidcUser(String userName, String email, Map<String, Object> claims) {
private User getOrCreateOidcUser(
OidcIdentityResolver.ResolvedOidcIdentity identity, Map<String, Object> claims) {
String userName = identity.userName();
String email = identity.email();
// Extract teams from claims if configured (supports array claims like groups)
List<String> teamsFromClaim = findTeamsFromClaims(teamClaimMapping, claims);

try {
// Fetch user with teams relationship loaded to preserve existing team memberships
User user =
Entity.getEntityByName(Entity.USER, userName, "id,roles,teams", Include.NON_DELETED);
User user = Entity.getUserRepository().getUserByNameForAuthUpdate(userName);

boolean shouldBeAdmin = isUserAdmin(email, userName);
boolean needsUpdate = false;
Expand All @@ -1212,7 +1226,8 @@ private User getOrCreateOidcUser(String userName, String email, Map<String, Obje

// Assign teams from claims if provided (this only adds, doesn't remove existing teams)
boolean teamsAssigned = UserUtil.assignTeamsFromClaim(user, teamsFromClaim);
needsUpdate = needsUpdate || teamsAssigned;
boolean profileChanged = applyDisplayNameAndPicture(user, identity);
needsUpdate = needsUpdate || teamsAssigned || profileChanged;

if (needsUpdate) {
UserUtil.addOrUpdateUser(user);
Expand Down Expand Up @@ -1245,6 +1260,7 @@ private User getOrCreateOidcUser(String userName, String email, Map<String, Obje
userName, new CreateUser().withName(userName).withEmail(email).withIsBot(false))
.withIsAdmin(isAdmin)
.withIsEmailVerified(true);
applyDisplayNameAndPicture(newUser, identity);

// Assign default role if configured
String defaultRoleName = authorizerConfiguration.getDefaultOAuthRole();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import static org.openmetadata.service.security.SecurityUtil.findUserNameFromClaims;
import static org.openmetadata.service.security.SecurityUtil.validateConfiguredEmailDomain;
import static org.openmetadata.service.security.SecurityUtil.validateEmailVerifiedClaim;
import static org.openmetadata.service.security.UserProfileClaims.pictureFrom;

import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
Expand All @@ -22,7 +24,7 @@
public class OidcIdentityResolver {

public record ResolvedOidcIdentity(
String userName, String email, String displayName, boolean emailFirstFlow) {}
String userName, String email, String displayName, URI picture, boolean emailFirstFlow) {}

private final AuthenticationConfiguration authenticationConfiguration;
private final AuthorizerConfiguration authorizerConfiguration;
Expand Down Expand Up @@ -72,7 +74,7 @@ public ResolvedOidcIdentity resolve(Map<String, Object> claims) {
String displayName =
extractDisplayNameFromClaim(claims, authenticationConfiguration.getDisplayNameClaim());
LOG.debug("OIDC email-first flow: email={}, displayName={}", email, displayName);
return new ResolvedOidcIdentity(null, email, displayName, true);
return new ResolvedOidcIdentity(null, email, displayName, pictureFrom(claims), true);
}
}

Expand All @@ -90,7 +92,7 @@ public ResolvedOidcIdentity resolve(Map<String, Object> claims) {
authorizerConfiguration.getAllowedDomains(),
authorizerConfiguration.getEnforcePrincipalDomain());
String displayName = extractDisplayNameFromClaims(claims);
return new ResolvedOidcIdentity(userName, email, displayName, false);
return new ResolvedOidcIdentity(userName, email, displayName, pictureFrom(claims), false);
}

public List<String> resolveTeams(Map<String, Object> claims, String teamClaimMapping) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package org.openmetadata.service.security;

import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.entity.teams.User;
import org.openmetadata.schema.type.ImageList;
import org.openmetadata.schema.type.Profile;

/**
* Keeps how an account is presented, its display name and picture, in step with what the identity
* provider asserts at sign-in. Only values the provider actually sent are applied: a claim the
* token leaves out never clears what the account already has.
*
* <p>ADR:2026-10-17-the-server-applies-the-sso-profile-on-server-driven-logins
*/
@Slf4j
public final class UserProfileClaims {
private static final String PICTURE_CLAIM = "picture";
private static final Set<String> WEB_SCHEMES = Set.of("http", "https");

private UserProfileClaims() {}

/**
* The standard OIDC {@code picture} claim, when it is an absolute http(s) address. The address
* becomes an image source in the browser, so anything else is dropped rather than stored.
*/
public static URI pictureFrom(Map<String, ?> claims) {
String claim = SecurityUtil.getClaimOrObject(claims.get(PICTURE_CLAIM));
return nullOrEmpty(claim) ? null : parseWebAddress(claim.trim());
}

/**
* Points the account's picture at the provider's. A new picture replaces every stored size, since
* sizes derived from the previous picture would otherwise keep showing it.
*
* @return whether the account changed
*/
public static boolean syncPicture(User user, URI picture) {
boolean changes = picture != null && !picture.equals(currentPicture(user));
if (changes) {
Profile profile = Optional.ofNullable(user.getProfile()).orElseGet(Profile::new);
user.setProfile(profile.withImages(new ImageList().withImage(picture)));
}
return changes;
}

/**
* Fills the display name from the provider when the account has none of its own. A name chosen in
* OpenMetadata is kept.
*
* @return whether the account changed
*/
public static boolean fillDisplayName(User user, String displayName) {
boolean fills =
!nullOrEmpty(displayName)
&& !displayName.isBlank()
&& lacksOwnDisplayName(user)
&& !displayName.equals(user.getDisplayName());
if (fills) {
user.setDisplayName(displayName);
}
return fills;
}

/**
* A display name that only repeats the login name says nothing the name does not. The browser
* wrote exactly that on sign-in while the provider's name was being dropped.
*/
private static boolean lacksOwnDisplayName(User user) {
String current = user.getDisplayName();
return nullOrEmpty(current) || current.equals(user.getName());
}

private static URI currentPicture(User user) {
return Optional.ofNullable(user.getProfile())
.map(Profile::getImages)
.map(ImageList::getImage)
.orElse(null);
}

private static URI parseWebAddress(String claim) {
URI address = toUri(claim);
return address != null && isWebAddress(address) ? address : null;
}

private static URI toUri(String claim) {
try {
return new URI(claim);
} catch (URISyntaxException e) {
LOG.debug("Ignoring a picture claim that is not a valid URI: {}", e.getReason());
return null;
}
}

private static boolean isWebAddress(URI address) {
return address.isAbsolute()
&& address.getHost() != null
&& WEB_SCHEMES.contains(address.getScheme().toLowerCase(Locale.ROOT));
}
}
Loading
Loading