Skip to content

Commit 6fd38c5

Browse files
committed
Merge remote-tracking branch 'origin/develop' into fix-passwd-change
2 parents c259579 + 787a5b9 commit 6fd38c5

11 files changed

Lines changed: 392 additions & 109 deletions

File tree

‎model/src/main/java/org/cloudfoundry/identity/uaa/provider/SamlIdentityProviderDefinition.java‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import lombok.Data;
1919
import lombok.NoArgsConstructor;
2020
import org.cloudfoundry.identity.uaa.util.ObjectUtils;
21+
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
2122
import org.springframework.util.StringUtils;
2223
import org.xml.sax.InputSource;
2324
import org.xml.sax.SAXException;
@@ -91,7 +92,7 @@ public MetadataLocation getType() {
9192
}
9293

9394
public static MetadataLocation getType(String urlOrXmlData) {
94-
String trimmedValue = urlOrXmlData.trim();
95+
String trimmedValue = stripLeadingCharacters(urlOrXmlData);
9596

9697
if (trimmedValue.startsWith("<?xml") ||
9798
trimmedValue.startsWith("<md:EntityDescriptor") ||
@@ -111,6 +112,20 @@ public static MetadataLocation getType(String urlOrXmlData) {
111112
return MetadataLocation.UNKNOWN;
112113
}
113114

115+
/**
116+
* Trims whitespace and, if present, a leading byte order marker character. Some IdPs (e.g.
117+
* Microsoft Entra ID's federation metadata endpoint) prepend a byte order marker to their
118+
* XML response; whether it was originally a UTF-8, UTF-16LE, or UTF-16BE BOM, correctly
119+
* decoded bytes leave this same character (U+FEFF) at the start of the resulting String,
120+
* which would otherwise defeat the {@code startsWith} sniffing in {@link #getType(String)}.
121+
*/
122+
private static String stripLeadingCharacters(String value) {
123+
String trimmedValue = value.trim();
124+
return trimmedValue.startsWith(UaaStringUtils.BYTE_ORDER_MARKER)
125+
? trimmedValue.substring(UaaStringUtils.BYTE_ORDER_MARKER.length()).trim()
126+
: trimmedValue;
127+
}
128+
114129
@JsonIgnore
115130
public String getIdpEntityId() {
116131
return this.idpEntityId;

‎model/src/main/java/org/cloudfoundry/identity/uaa/util/UaaStringUtils.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ public final class UaaStringUtils {
5151

5252
public static final String EMPTY_STRING = "";
5353

54+
/**
55+
* The Unicode byte order marker (codepoint U+FEFF), as decoded into a Java String. This is
56+
* the same character regardless of which encoding's BOM bytes produced it (UTF-8's EF BB BF,
57+
* UTF-16LE's FF FE, UTF-16BE's FE FF, ...) once decoded with the correct charset for that
58+
* encoding, or when a UTF-8 BOM survives an incorrect {@code new String(bytes, UTF_8)} decode.
59+
*/
60+
public static final String BYTE_ORDER_MARKER = String.valueOf((char) 0xFEFF);
61+
5462
public static final String DEFAULT_UAA_URL = "http://localhost:8080/uaa";
5563

5664
private UaaStringUtils() {

‎model/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlIdentityProviderDefinitionTests.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import org.cloudfoundry.identity.uaa.provider.SamlIdentityProviderDefinition;
44
import org.cloudfoundry.identity.uaa.util.JsonUtils;
5+
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
56
import org.junit.jupiter.api.BeforeEach;
67
import org.junit.jupiter.api.Test;
78
import org.springframework.util.ReflectionUtils;
@@ -141,6 +142,18 @@ void get_data_type_when_valid() {
141142
assertThat(definition.getType()).isEqualTo(DATA);
142143
}
143144

145+
@Test
146+
void get_data_type_when_valid_with_leading_byte_order_marker() {
147+
// Microsoft Entra ID's federation metadata endpoint prepends a byte order marker to its
148+
// XML response. Whether the original bytes were a UTF-8, UTF-16LE, or UTF-16BE BOM,
149+
// correctly decoded text has this same character (U+FEFF) leading it; String.trim() does
150+
// not strip it, so metadata that is otherwise identical to get_data_type_when_valid()
151+
// must still classify as DATA, not UNKNOWN.
152+
String byteOrderMarkerPrefixedMetadata = UaaStringUtils.BYTE_ORDER_MARKER + IDP_METADATA;
153+
definition.setMetaDataLocation(byteOrderMarkerPrefixedMetadata);
154+
assertThat(definition.getType()).isEqualTo(DATA);
155+
}
156+
144157
public static final String ALIAS = "alias";
145158
public static final String IDP_METADATA = "<?xml version=\"1.0\"?>\n" +
146159
"<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\" xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\" entityID=\"http://" + ALIAS + ".cfapps.io/saml2/idp/metadata.php\" ID=\"pfx06ad4153-c17c-d286-194c-dec30bb92796\"><ds:Signature>\n" +

‎server/src/main/java/org/cloudfoundry/identity/uaa/audit/event/AbstractUaaEvent.java‎

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,35 @@
1313
*******************************************************************************/
1414
package org.cloudfoundry.identity.uaa.audit.event;
1515

16-
import tools.jackson.core.type.TypeReference;
1716
import org.cloudfoundry.identity.uaa.audit.AuditEvent;
1817
import org.cloudfoundry.identity.uaa.audit.AuditEventType;
1918
import org.cloudfoundry.identity.uaa.audit.UaaAuditService;
19+
import org.cloudfoundry.identity.uaa.authentication.UaaAuthenticationDetails;
2020
import org.cloudfoundry.identity.uaa.oauth.UaaOauth2Authentication;
2121
import org.cloudfoundry.identity.uaa.oauth.jwt.Jwt;
2222
import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper;
2323
import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication;
2424
import org.cloudfoundry.identity.uaa.oauth.provider.authentication.OAuth2AuthenticationDetails;
2525
import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants;
2626
import org.cloudfoundry.identity.uaa.util.JsonUtils;
27+
import org.slf4j.Logger;
28+
import org.slf4j.LoggerFactory;
2729
import org.springframework.context.ApplicationEvent;
2830
import org.springframework.security.core.Authentication;
2931
import org.springframework.security.core.GrantedAuthority;
3032
import org.springframework.security.core.context.SecurityContextHolder;
33+
import org.springframework.security.web.authentication.WebAuthenticationDetails;
34+
import org.springframework.util.StringUtils;
35+
import tools.jackson.core.type.TypeReference;
3136

3237
import java.io.Serial;
3338
import java.security.Principal;
3439
import java.util.ArrayList;
3540
import java.util.Collection;
3641
import java.util.Map;
42+
import java.util.Optional;
3743

3844
import static org.cloudfoundry.identity.uaa.util.UaaTokenUtils.isJwtToken;
39-
import static org.springframework.util.StringUtils.hasText;
4045

4146
/**
4247
* Base class for UAA events that want to publish audit records.
@@ -49,6 +54,9 @@ public abstract class AbstractUaaEvent extends ApplicationEvent {
4954

5055
@Serial
5156
private static final long serialVersionUID = -7639844193401892160L;
57+
58+
private static final Logger logger = LoggerFactory.getLogger(AbstractUaaEvent.class);
59+
5260
private final transient String zoneId;
5361

5462
private Authentication authentication;
@@ -115,35 +123,50 @@ private String getAuthenticationString(Authentication caller) {
115123
builder.append("caller=").append(caller.getName());
116124
}
117125

118-
if (caller.getDetails() != null) {
126+
Object details = caller.getDetails();
127+
if (details != null) {
119128
builder.append(", details=(");
120-
try {
121-
@SuppressWarnings("unchecked")
122-
Map<String, Object> map =
123-
JsonUtils.readValue((String) caller.getDetails(), new TypeReference<Map<String, Object>>(){
124-
});
125-
if (map.containsKey("remoteAddress")) {
126-
builder.append("remoteAddress=").append(map.get("remoteAddress")).append(", ");
127-
}
128-
builder.append("type=").append(caller.getDetails().getClass().getSimpleName());
129-
} catch (Exception _) {
130-
// ignore
131-
builder.append(caller.getDetails());
132-
}
129+
extractRemoteAddress(details).ifPresent(address -> builder.append("remoteAddress=").append(address).append(", "));
130+
builder.append("type=").append(details.getClass().getSimpleName());
133131
appendTokenDetails(caller, builder);
134132
builder.append(")");
135133
}
134+
136135
return builder.toString();
137136
}
138137

139-
protected void appendTokenDetails(Authentication caller, StringBuilder builder) {
138+
private Optional<String> extractRemoteAddress(Object details) {
139+
return switch (details) {
140+
case UaaAuthenticationDetails d -> Optional.ofNullable(d.getOrigin()).filter(StringUtils::hasText);
141+
case OAuth2AuthenticationDetails d -> Optional.ofNullable(d.getRemoteAddress()).filter(StringUtils::hasText);
142+
case WebAuthenticationDetails d -> Optional.ofNullable(d.getRemoteAddress()).filter(StringUtils::hasText);
143+
case Map<?, ?> map -> Optional.ofNullable(map.get("remoteAddress")).map(Object::toString).filter(StringUtils::hasText);
144+
case String jsonBlob -> extractRemoteAddressFromJson(jsonBlob);
145+
default -> {
146+
logger.warn("Unhandled Authentication.details type in audit origin: {}", details.getClass().getName());
147+
yield Optional.empty();
148+
}
149+
};
150+
}
151+
152+
private Optional<String> extractRemoteAddressFromJson(String jsonBlob) {
153+
try {
154+
Map<String, Object> map = JsonUtils.readValue(jsonBlob, new TypeReference<>() {
155+
});
156+
return map == null ? Optional.empty() : extractRemoteAddress(map);
157+
} catch (JsonUtils.JsonUtilException _) {
158+
return Optional.empty();
159+
}
160+
}
161+
162+
private void appendTokenDetails(Authentication caller, StringBuilder builder) {
140163
String tokenValue = null;
141164
if (caller instanceof UaaOauth2Authentication uaaOauth2Authentication) {
142165
tokenValue = uaaOauth2Authentication.getTokenValue();
143166
} else if (caller.getDetails() instanceof OAuth2AuthenticationDetails oAuth2AuthenticationDetails) {
144167
tokenValue = oAuth2AuthenticationDetails.getTokenValue();
145168
}
146-
if (hasText(tokenValue)) {
169+
if (StringUtils.hasText(tokenValue)) {
147170
if (isJwtToken(tokenValue)) {
148171
try {
149172
Jwt token = JwtHelper.decode(tokenValue);

‎server/src/main/java/org/cloudfoundry/identity/uaa/authentication/event/AbstractUaaAuthenticationEvent.java‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
import org.cloudfoundry.identity.uaa.authentication.UaaAuthenticationDetails;
1818
import org.springframework.security.core.Authentication;
1919

20+
import java.util.StringJoiner;
21+
22+
import static org.springframework.util.StringUtils.hasText;
23+
2024
/**
2125
* @author Luke Taylor
2226
*/
@@ -27,7 +31,21 @@ public abstract class AbstractUaaAuthenticationEvent extends AbstractUaaEvent {
2731
}
2832

2933
protected String getOrigin(UaaAuthenticationDetails details) {
30-
return details == null ? "unknown" : details.toString();
34+
if (details == null) {
35+
return "unknown";
36+
}
37+
38+
StringJoiner joiner = new StringJoiner(", ");
39+
40+
if (hasText(details.getOrigin())) {
41+
joiner.add("remoteAddress=" + details.getOrigin());
42+
}
43+
44+
if (hasText(details.getClientId())) {
45+
joiner.add("clientId=" + details.getClientId());
46+
}
47+
48+
return joiner.length() == 0 ? "unknown" : joiner.toString();
3149
}
3250

3351
UaaAuthenticationDetails getAuthenticationDetails() {

‎server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlIdentityProviderConfigurator.java‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.cloudfoundry.identity.uaa.provider.saml;
22

3+
import org.apache.commons.io.ByteOrderMark;
34
import org.apache.commons.io.IOUtils;
45
import org.apache.hc.core5.net.URIBuilder;
56
import org.cloudfoundry.identity.uaa.constants.OriginKeys;
@@ -17,7 +18,9 @@
1718

1819
import java.net.URI;
1920
import java.net.URISyntaxException;
21+
import java.nio.charset.Charset;
2022
import java.nio.charset.StandardCharsets;
23+
import java.util.Arrays;
2124
import java.util.List;
2225

2326
import static org.springframework.util.StringUtils.hasText;
@@ -166,9 +169,31 @@ public String resolveMetadataXml(SamlIdentityProviderDefinition def) {
166169
try {
167170
String adjustedMetadataURIForPort = adjustURIForPort(metadataLocation);
168171
byte[] metadata = fixedHttpMetaDataProvider.fetchMetadata(adjustedMetadataURIForPort, def.isSkipSslValidation());
169-
return new String(metadata, StandardCharsets.UTF_8);
172+
return new String(metadata, detectCharset(metadata));
170173
} catch (URISyntaxException e) {
171174
throw new IllegalStateException("Invalid socket factory(invalid URI):" + metadataLocation, e);
172175
}
173176
}
177+
178+
/**
179+
* Detects the charset of fetched metadata bytes from a leading byte order marker. Only
180+
* UTF-16 needs to be sniffed explicitly here: a UTF-8 byte order marker, if present, already
181+
* decodes correctly under the UTF-8 default and is stripped later by
182+
* {@link SamlIdentityProviderDefinition#getType(String)}, since it decodes to the same
183+
* character (U+FEFF) as a correctly-decoded UTF-16 BOM.
184+
*/
185+
static Charset detectCharset(byte[] metadata) {
186+
if (hasLeadingByteOrderMark(metadata, ByteOrderMark.UTF_16LE)) {
187+
return StandardCharsets.UTF_16LE;
188+
}
189+
if (hasLeadingByteOrderMark(metadata, ByteOrderMark.UTF_16BE)) {
190+
return StandardCharsets.UTF_16BE;
191+
}
192+
return StandardCharsets.UTF_8;
193+
}
194+
195+
private static boolean hasLeadingByteOrderMark(byte[] data, ByteOrderMark byteOrderMark) {
196+
byte[] bom = byteOrderMark.getBytes();
197+
return data.length >= bom.length && Arrays.equals(data, 0, bom.length, bom, 0, bom.length);
198+
}
174199
}

0 commit comments

Comments
 (0)