Skip to content

Commit

Permalink
Add support for avatar
Browse files Browse the repository at this point in the history
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
  • Loading branch information
jonesbusy committed Feb 25, 2025
1 parent 1140bcf commit 91ca44a
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 7 deletions.
80 changes: 80 additions & 0 deletions src/main/java/org/jenkinsci/plugins/oic/OicAvatarProperty.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package org.jenkinsci.plugins.oic;

Check warning on line 1 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarProperty.java

View check run for this annotation

ci.jenkins.io / Java Compiler

checkstyle:check

ERROR: (misc) NewlineAtEndOfFile: Expected line ending for file is LF(\n), but CRLF(\r\n) is detected.

import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Extension;
import hudson.model.User;
import hudson.model.UserProperty;
import hudson.model.UserPropertyDescriptor;
import java.util.logging.Logger;

public class OicAvatarProperty extends UserProperty {
private static final Logger LOGGER = Logger.getLogger(OicAvatarProperty.class.getName());

private final AvatarImage avatarImage;

public OicAvatarProperty(AvatarImage avatarImage) {
this.avatarImage = avatarImage;
}

public String getAvatarUrl() {
if (isHasAvatar()) {
return getAvatarImageUrl();
}
return null;
}

private String getAvatarImageUrl() {
return avatarImage.url;
}

public boolean isHasAvatar() {
return avatarImage != null && avatarImage.isValid();

Check warning on line 31 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 31 is only partially covered, one branch is missing
}

public String getDisplayName() {
return "Openid Connect Avatar";
}

public String getIconFileName() {
return null;
}

public String getUrlName() {
return "oic-avatar";

Check warning on line 43 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 43 is not covered by tests

Check warning on line 43 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarProperty.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/jenkinsci/plugins/oic/OicAvatarProperty.java#L43

Added line #L43 was not covered by tests
}

@Extension
public static class DescriptorImpl extends UserPropertyDescriptor {

@Override
@NonNull
public String getDisplayName() {
return "Openid Connect Avatar";
}

@Override
public boolean isEnabled() {
return false;
}

@Override
public UserProperty newInstance(User user) {
return new OicAvatarProperty(null);
}
}

/**
* OIC avatar is standard picture field on the profile claim.
*/
public static class AvatarImage {
private final String url;

public AvatarImage(String url) {
this.url = url;
}

public boolean isValid() {
return url != null;

Check warning on line 77 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarProperty.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 77 is only partially covered, one branch is missing
}
}
}
19 changes: 19 additions & 0 deletions src/main/java/org/jenkinsci/plugins/oic/OicAvatarResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.jenkinsci.plugins.oic;

Check warning on line 1 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarResolver.java

View check run for this annotation

ci.jenkins.io / Java Compiler

checkstyle:check

ERROR: (misc) NewlineAtEndOfFile: Expected line ending for file is LF(\n), but CRLF(\r\n) is detected.

import hudson.Extension;
import hudson.model.User;
import hudson.tasks.UserAvatarResolver;

@Extension
public class OicAvatarResolver extends UserAvatarResolver {
@Override
public String findAvatarFor(User user, int width, int height) {
if (user != null) {

Check warning on line 11 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarResolver.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 11 is only partially covered, one branch is missing
OicAvatarProperty avatarProperty = user.getProperty(OicAvatarProperty.class);
if (avatarProperty != null) {

Check warning on line 13 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarResolver.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 13 is only partially covered, one branch is missing
return avatarProperty.getAvatarUrl();
}
}
return null;

Check warning on line 17 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarResolver.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 17 is not covered by tests

Check warning on line 17 in src/main/java/org/jenkinsci/plugins/oic/OicAvatarResolver.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/jenkinsci/plugins/oic/OicAvatarResolver.java#L17

Added line #L17 was not covered by tests
}
}
35 changes: 31 additions & 4 deletions src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ ClientAuthenticationMethod toClientAuthenticationMethod() {
private transient Expression<Object> emailFieldExpr = null;
private String groupsFieldName = null;
private transient Expression<Object> groupsFieldExpr = null;
private transient Expression<Object> avatarFieldExpr = null;
private transient String simpleGroupsFieldName = null;
private transient String nestedGroupFieldName = null;

Expand Down Expand Up @@ -336,6 +337,8 @@ public OicSecurityRealm(
this.serverConfiguration = serverConfiguration;
this.userIdStrategy = userIdStrategy;
this.groupIdStrategy = groupIdStrategy;
this.avatarFieldExpr =
compileJMESPath("picture", "avatar field"); // Default on OIDC spec, part of profile claim
}

@SuppressWarnings("deprecated")
Expand Down Expand Up @@ -365,6 +368,8 @@ protected Object readResolve() throws ObjectStreamException {
this.setGroupsFieldName(this.groupsFieldName);
}
// ensure Field JMESPath are computed
this.avatarFieldExpr =
this.compileJMESPath("picture", "avatar field"); // Default on OIDC spec, part of profile claim
this.setUserNameField(this.userNameField);
this.setEmailFieldName(this.emailFieldName);
this.setFullNameFieldName(this.fullNameFieldName);
Expand Down Expand Up @@ -1070,6 +1075,22 @@ private UsernamePasswordAuthenticationToken loginAndSetUserData(
user.setFullName(fullName);
}

// Set avatar if possible
try {
String avatarUrl = determineStringField(avatarFieldExpr, idToken, userInfo);
if (avatarUrl != null) {
LOGGER.finest("Avatar url is: " + avatarUrl);
OicAvatarProperty.AvatarImage avatarImage = new OicAvatarProperty.AvatarImage(avatarUrl);
OicAvatarProperty oicAvatarProperty = new OicAvatarProperty(avatarImage);
user.addProperty(oicAvatarProperty);
} else {
LOGGER.finest("No avatar URL found");
}

} catch (Exception e) {
LOGGER.log(Level.WARNING, "Failed to save profile photo for %s".formatted(user.getId()), e);

Check warning on line 1091 in src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 1090-1091 are not covered by tests

Check warning on line 1091 in src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java#L1090-L1091

Added lines #L1090 - L1091 were not covered by tests
}

user.addProperty(credentials);

OicUserDetails userDetails = new OicUserDetails(userName, grantedAuthorities);
Expand All @@ -1083,10 +1104,16 @@ private String determineStringField(Expression<Object> fieldExpr, JWT idToken, M
if (fieldExpr != null) {
if (userInfo != null) {
Object field = fieldExpr.search(userInfo);
if (field != null && field instanceof String) {
String fieldValue = Util.fixEmptyAndTrim((String) field);
if (fieldValue != null) {
return fieldValue;
if (field != null) {
if (field instanceof String) {
String fieldValue = Util.fixEmptyAndTrim((String) field);
if (fieldValue != null) {
return fieldValue;
}
}
// pac4j OIDC client returns URI for some fields like the "picture" field
if (field instanceof URI) {
return ((URI) field).toASCIIString();
}
}
}
Expand Down
45 changes: 42 additions & 3 deletions src/test/java/org/jenkinsci/plugins/oic/PluginTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ class PluginTest {
private static final String[] TEST_USER_GROUPS_REFRESHED = new String[] {"group1", "group2", "group3"};
private static final List<Map<String, String>> TEST_USER_GROUPS_MAP =
List.of(Map.of("id", "id1", "name", "group1"), Map.of("id", "id2", "name", "group2"));
private static final String TEST_ENCODED_AVATAR =
"iVBORw0KGgoAAAANSUhEUgAAABsAAAAaCAYAAABGiCfwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAH8SURBVEhL7ZbPK0RRFMe/z/w05Mc0Mo0mZeFXGM1IFDFEfjWKks1sSIkipDSrt1M2FpY2FpSllK2yV/4DWYlSLGzw7rvufe/Rm5n3a4rJwmdz77nz3vnee865Z56QSCQoikSJNhaFvy823Ulwsf6BWFTWVpxRsFionGJ7TEZtBSCmCEoE5ykvWGxtmMDvUefRIDDf/UtibXUUEx3ZzpcHCSpKNcOGgsQyk0QZbx/ecHDxhOdXCQEvsJpU1+1wLDYVk9FYq54qc/yAtcN7HF0+K/ZMnKChxj6cjsT8Hop1lqsvPojq+F1SR0EQsDMuKXMrHIkt9smoLtMME+L1wFCL9VWwFQtXUqR7nd2nzRECt0szDLAV2xq1dqAnXAmke8yLxVIsXk+RbLZPvJ7Ffh5y43dMxVjOHSU9F37h9cWkx1RsNi6zctaMHLxuthPdmMtUjLJrkp9nVyQSEbX5NwEvxf68BJ/H2Flr1I9wtRtLI0GU+oz32xQGzm6yfzN8ciUpsxZkLMTxs01UlbmUUJvBW9t4e/bp8sSiQYq5LutSF08flQ5ycvWirRjDc8cbwhd5YdydIUo3t6KpzgeJdZGNVMg0jJyAD5CZ1vWd+kzWNwg/+tFC4RVoxTtzN7DnYS0uR4z/VYgpCeVsRz/FPYu0eO5W5v9fVz9CEcWAT+xkgmHqzLIIAAAAAElFTkSuQmCC";

@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
Expand Down Expand Up @@ -334,6 +336,28 @@ void testLoginUsingUserInfoEndpointWithGroupsMap() throws Exception {
}
}

@Test
void testLoginUsingUserInfoEndpointWithAvatar() throws Exception {
mockAuthorizationRedirectsToFinishLogin();
mockTokenReturnsIdTokenWithoutValues();
mockUserInfoWithAvatar(wireMock);
configureWellKnown(null, null);

// Return avatar image when requested
wireMock.stubFor(get(urlPathEqualTo("/my-avatar.png"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "image/png")
.withBody(Base64.getDecoder().decode(TEST_ENCODED_AVATAR))));

jenkins.setSecurityRealm(new TestRealm(wireMock, null, EMAIL_FIELD, GROUPS_FIELD, true));
assertAnonymous();
browseLoginPage();
var user = assertTestUser();
assertTestUserEmail(user);
assertTestAvatar(user, wireMock);
}

@Test
void testLoginWithMinimalConfiguration() throws Exception {
mockAuthorizationRedirectsToFinishLogin();
Expand Down Expand Up @@ -822,6 +846,14 @@ private static void assertTestUserEmail(User user) {
"Email should be " + TEST_USER_EMAIL_ADDRESS);
}

private static void assertTestAvatar(User user, WireMockExtension wireMock) {
String expectedAvatarUrl = "http://localhost:%d/my-avatar.png".formatted(wireMock.getPort());
OicAvatarProperty avatarProperty = user.getProperty(OicAvatarProperty.class);
assertEquals(expectedAvatarUrl, avatarProperty.getAvatarUrl(), "Avatar url should be " + expectedAvatarUrl);
assertEquals("Openid Connect Avatar", avatarProperty.getDisplayName());
assertNull(avatarProperty.getIconFileName(), "Icon filename must be null");
}

private @NonNull User assertTestUser() {
Authentication authentication = getAuthentication();
assertEquals(TEST_USER_USERNAME, authentication.getPrincipal(), "Should be logged-in as " + TEST_USER_USERNAME);
Expand Down Expand Up @@ -1234,14 +1266,18 @@ private void mockUserInfoWithTestGroups() {
}

private void mockUserInfoWithGroups(@Nullable Object groups) {
mockUserInfo(getUserInfo(groups));
mockUserInfo(getUserInfo(groups, null));
}

private void mockUserInfoWithAvatar(WireMockExtension wireMock) {
mockUserInfo(getUserInfo(null, wireMock));
}

private void mockUserInfoJwtWithTestGroups(KeyPair keyPair, Object testUserGroups) throws Exception {
wireMock.stubFor(get(urlPathEqualTo("/userinfo"))
.willReturn(aResponse()
.withHeader("Content-Type", "application/jwt")
.withBody(createUserInfoJWT(keyPair.getPrivate(), toJson(getUserInfo(testUserGroups))))));
.withBody(createUserInfoJWT(keyPair.getPrivate(), toJson(getUserInfo(testUserGroups, null))))));
}

private void mockUserInfo(Map<String, Object> userInfo) {
Expand All @@ -1251,14 +1287,17 @@ private void mockUserInfo(Map<String, Object> userInfo) {
.withBody(toJson(userInfo))));
}

private static Map<String, Object> getUserInfo(@Nullable Object groups) {
private static Map<String, Object> getUserInfo(@Nullable Object groups, WireMockExtension wireMock) {
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("sub", TEST_USER_USERNAME);
userInfo.put(FULL_NAME_FIELD, TEST_USER_FULL_NAME);
userInfo.put(EMAIL_FIELD, TEST_USER_EMAIL_ADDRESS);
if (groups != null) {
userInfo.put(GROUPS_FIELD, groups);
}
if (wireMock != null) {
userInfo.put("picture", "http://localhost:" + wireMock.getPort() + "/my-avatar.png");
}
return userInfo;
}

Expand Down

0 comments on commit 91ca44a

Please sign in to comment.