Skip to content

Commit

Permalink
Use JMESPath for extracting idtoken and userinfo fields (#281)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-doubez committed Mar 23, 2024
1 parent 62720cf commit 0332677
Show file tree
Hide file tree
Showing 12 changed files with 285 additions and 236 deletions.
3 changes: 3 additions & 0 deletions README.md
Expand Up @@ -75,6 +75,9 @@ for a minimal configuration example: 

![global-config](/docs/images/global-config.png)

All of the fields can be configured as a [JMES Path](https://jmespath.org/) specification.
Most of the time, the name of the field in the idtoken or userinfo is enough.

#### Using g-suite / google

Obtain the client id and secret from the developer console:
Expand Down
7 changes: 6 additions & 1 deletion pom.xml
Expand Up @@ -25,7 +25,7 @@
<connection>scm:git:ssh://github.com/${gitHubRepo}.git</connection>
<developerConnection>scm:git:git@github.com/${gitHubRepo}.git</developerConnection>
<url>https://github.com/${gitHubRepo}</url>
<tag>oic-auth-3.0</tag>
<tag>${revision}.${changelist}</tag>
</scm>

<name>OpenId Connect Authentication Plugin</name>
Expand Down Expand Up @@ -71,6 +71,11 @@
<artifactId>mailer</artifactId>
<version>1.34.2</version>
</dependency>
<dependency>
<groupId>io.burt</groupId>
<artifactId>jmespath-core</artifactId>
<version>0.6.0</version>
</dependency>

<dependency>
<groupId>org.apache.httpcomponents</groupId>
Expand Down
357 changes: 163 additions & 194 deletions src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java

Large diffs are not rendered by default.

Expand Up @@ -52,5 +52,4 @@ public boolean isCredentialsNonExpired() {
public boolean isEnabled() {
return true;
}

}
Expand Up @@ -16,4 +16,4 @@ OicSecurityRealm.UsingDefaultUsername = Using ''sub''.
OicSecurityRealm.UsingDefaultScopes = Using ''openid email''.
OicSecurityRealm.RUSureOpenIdNotInScope = Are you sure you don''t want to include ''openid'' as an scope?
OicSecurityRealm.EndSessionURLKeyRequired = End Session URL Key is required.
OicSecurityRealm.InvalidGroupsFieldName = Invalid groups field name - may only contain letters, numbers, and underscores and one instance of [].
OicSecurityRealm.InvalidFieldName = Invalid field name - must be a valid JMESPath expression.
Expand Up @@ -16,4 +16,3 @@ OicSecurityRealm.UsingDefaultUsername = \u4f7f\u7528 sub
OicSecurityRealm.UsingDefaultScopes = \u4f7f\u7528 openid \u96fb\u5b50\u4fe1\u7bb1
OicSecurityRealm.RUSureOpenIdNotInScope = \u662f\u5426\u78ba\u8a8d\u4e0d\u5c07 \u300copenid\u300d\u4f5c\u70ba\u4e00\u500b\u7bc4\u570d scope \uff1f
OicSecurityRealm.EndSessionURLKeyRequired = \u9700\u8981\u63d0\u4f9b\u7d42\u6b62 Session \u4e4b Url \u91d1\u9470
OicSecurityRealm.InvalidGroupsFieldName = \u7121\u6548\u7684\u7fa4\u7d44\u6b04\u4f4d\u540d\u7a31 - \u53ea\u80fd\u5305\u542b\u5b57\u6bcd\u3001\u6578\u5b57\u3001\u5e95\u7dda\u548c\u4e00\u500b [] \u5be6\u4f8b
@@ -1,8 +1,11 @@
<div>
Not required. If the field exists in the token and is an array of strings, then each string is added as a group.
Not required. The field specification must be a valid <a href="https://jmespath.org/" target="_blank">JMES Path</a>.

If the field exists in the token and is an array of strings, then each string is added as a group.
This allows groups based authorization in Jenkins. The SSO server will need to add the field with the list of groups in
the token. For example in Keycloak, this can be done with a 'Group Membership' mapper in the configuration of the client.

If the SSO server adds the groups as an array of maps instead, then specify the group field as "groups[].name" where "groups"
But if, by example, the SSO server adds the groups as an array of maps instead, then specify the group field as "groups[].name" where "groups"
is the field containing the array of maps, and "name" it the name of the key in the map that holds the group name.

</div>
@@ -1,4 +1,5 @@
<div>
Optional. The name of the field to check.
Optional. The name of the field to chek, which must be a valid <a href="https://jmespath.org/" target="_blank">JMES Path</a>.

If specified, users are required to have this field match the value to successfully login
</div>
</div>
Expand Up @@ -60,7 +60,7 @@ public void testConfig() {
assertEquals("http://localhost", oicSecurityRealm.getTokenServerUrl());
assertEquals(TokenAuthMethod.client_secret_post, oicSecurityRealm.getTokenAuthMethod());
assertEquals("userNameField", oicSecurityRealm.getUserNameField());
assertTrue(oicSecurityRealm.isRootURLFromRequest());
assertTrue(oicSecurityRealm.isRootURLFromRequest());
}

@Test
Expand Down
62 changes: 57 additions & 5 deletions src/test/java/org/jenkinsci/plugins/oic/DescriptorImplTest.java
Expand Up @@ -126,22 +126,74 @@ public void doCheckUserNameField() throws IOException {
TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD);

OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor();

assertNotNull(descriptor);

assertEquals(FormValidation.ok("Using 'sub'.").getMessage(),
descriptor.doCheckUserNameField(null).getMessage());
assertEquals(FormValidation.ok("Using 'sub'.").getMessage(), descriptor.doCheckUserNameField("").getMessage());
assertEquals(FormValidation.ok(), descriptor.doCheckUserNameField("http://localhost"));
assertEquals(FormValidation.ok(), descriptor.doCheckUserNameField("subfield"));
}

@Test
public void doCheckScopes() throws IOException {
public void doCheckFullNameFieldName() throws IOException {
configureWellKnown();
TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD);

OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor();
assertNotNull(descriptor);

assertEquals(FormValidation.ok(), descriptor.doCheckFullNameFieldName(""));
assertEquals(FormValidation.Kind.ERROR, descriptor.doCheckFullNameFieldName("]not valid").kind);
assertEquals(FormValidation.ok(), descriptor.doCheckFullNameFieldName("myname"));
}

@Test
public void doCheckEmailFieldName() throws IOException {
configureWellKnown();
TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD);

OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor();
assertNotNull(descriptor);

assertEquals(FormValidation.ok(), descriptor.doCheckEmailFieldName(""));
assertEquals(FormValidation.Kind.ERROR, descriptor.doCheckEmailFieldName("]not valid").kind);
assertEquals(FormValidation.ok(), descriptor.doCheckEmailFieldName("myemail"));
}

@Test
public void doCheckGroupsFieldName() throws IOException {
configureWellKnown();
TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD);

OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor();
assertNotNull(descriptor);

assertEquals(FormValidation.ok(), descriptor.doCheckGroupsFieldName(""));
assertEquals(FormValidation.Kind.ERROR, descriptor.doCheckGroupsFieldName("]not valid").kind);
assertEquals(FormValidation.ok(), descriptor.doCheckGroupsFieldName("mygroups"));
}

@Test
public void doCheckTokenFieldToCheckKey() throws IOException {
configureWellKnown();
TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD);

OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor();
assertNotNull(descriptor);

assertEquals(FormValidation.ok(), descriptor.doCheckTokenFieldToCheckKey(""));
assertEquals(FormValidation.Kind.ERROR, descriptor.doCheckTokenFieldToCheckKey("]not valid").kind);
assertEquals(FormValidation.ok(), descriptor.doCheckTokenFieldToCheckKey("akey"));
}

@Test
public void doCheckScopes() throws IOException {
configureWellKnown();
TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD);

OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor();
assertNotNull(descriptor);

assertEquals(FormValidation.ok("Using 'openid email'.").getMessage(),
descriptor.doCheckScopes(null).getMessage());
assertEquals(FormValidation.ok("Using 'openid email'.").getMessage(),
Expand All @@ -160,8 +212,8 @@ public void doCheckEndSessionEndpoint() throws IOException {
TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD);

OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor();

assertNotNull(descriptor);

assertEquals("End Session URL Key is required.",
descriptor.doCheckEndSessionEndpoint(null).getMessage());
assertEquals("End Session URL Key is required.",
Expand All @@ -176,8 +228,8 @@ public void doCheckPostLogoutRedirectUrl() throws IOException {
TestRealm realm = new TestRealm(wireMockRule, null, null, null, AUTO_CONFIG_FIELD);

OicSecurityRealm.DescriptorImpl descriptor = (DescriptorImpl) realm.getDescriptor();

assertNotNull(descriptor);

assertEquals(FormValidation.ok(), descriptor.doCheckPostLogoutRedirectUrl(null));
assertEquals(FormValidation.ok(), descriptor.doCheckPostLogoutRedirectUrl(""));
assertTrue(descriptor.doCheckPostLogoutRedirectUrl("not a url").getMessage().contains("Not a valid url."));
Expand Down
65 changes: 37 additions & 28 deletions src/test/java/org/jenkinsci/plugins/oic/FieldTest.java
Expand Up @@ -8,8 +8,6 @@
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

Expand All @@ -33,19 +31,12 @@ public void testNestedLookup() throws Exception {

TestRealm realm = new TestRealm(wireMockRule);

assertEquals("myemail@example.com", realm.getField(payload, "email"));
assertEquals("100", realm.getField(payload, "user.id"));
assertNull(realm.getField(payload, "unknown"));
assertNull(realm.getField(payload, "user"));
assertNull(realm.getField(payload, "user.invalid"));
assertNull(realm.getField(payload, "none"));

assertTrue(realm.containsField(payload, "email"));
assertTrue(realm.containsField(payload, "user.id"));
assertFalse(realm.containsField(payload, "unknown"));
assertFalse(realm.containsField(payload, "user"));
assertFalse(realm.containsField(payload, "user.invalid"));
assertTrue(realm.containsField(payload, "none"));
assertEquals("myemail@example.com", realm.getStringFieldFromJMESPath(payload, "email"));
assertEquals("100", realm.getStringFieldFromJMESPath(payload, "user.id"));
assertNull(realm.getStringFieldFromJMESPath(payload, "unknown"));
assertNull(realm.getStringFieldFromJMESPath(payload, "user"));
assertNull(realm.getStringFieldFromJMESPath(payload, "user.invalid"));
assertNull(realm.getStringFieldFromJMESPath(payload, "none"));
}

@Test
Expand All @@ -61,18 +52,36 @@ public void testNormalLookupDueToDot() throws Exception {

TestRealm realm = new TestRealm(wireMockRule);

assertEquals("myemail@example.com", realm.getField(payload, "email"));
assertNull(realm.getField(payload, "unknown"));
assertNull(realm.getField(payload, "user"));
assertNull(realm.getField(payload, "user.invalid"));
assertEquals("myusername", realm.getField(payload, "user.name"));
assertNull(realm.getField(payload, "none"));

assertTrue(realm.containsField(payload, "email"));
assertFalse(realm.containsField(payload, "unknown"));
assertFalse(realm.containsField(payload, "user"));
assertFalse(realm.containsField(payload, "user.invalid"));
assertTrue(realm.containsField(payload, "none"));
assertTrue(realm.containsField(payload, "user.name"));
assertEquals("myemail@example.com", realm.getStringFieldFromJMESPath(payload, "email"));
assertNull(realm.getStringFieldFromJMESPath(payload, "unknown"));
assertNull(realm.getStringFieldFromJMESPath(payload, "user"));
assertNull(realm.getStringFieldFromJMESPath(payload, "user.invalid"));
assertEquals("myusername", realm.getStringFieldFromJMESPath(payload, "\"user.name\""));
assertNull(realm.getStringFieldFromJMESPath(payload, "none"));
}

@Test
public void testFieldProcessing() throws Exception {
HashMap<String, Object> user = new HashMap<>();
user.put("id", "100");
user.put("name", "john");
user.put("surname", "dow");

GenericJson payload = new GenericJson();
payload.put("user", user);

TestRealm realm = new TestRealm(wireMockRule);

assertEquals("john dow", realm.getStringFieldFromJMESPath(payload, "[user.name, user.surname] | join(' ', @)"));
}

@Test
public void testInvalidFieldName() throws Exception {
GenericJson payload = new GenericJson();
payload.put("user", "john");

TestRealm realm = new TestRealm(wireMockRule);

assertNull(realm.getStringFieldFromJMESPath(payload, "[user)"));
}
}
9 changes: 9 additions & 0 deletions src/test/java/org/jenkinsci/plugins/oic/TestRealm.java
Expand Up @@ -3,6 +3,7 @@
import com.github.tomakehurst.wiremock.junit.WireMockRule;
import hudson.model.Descriptor;
import hudson.security.SecurityRealm;
import io.burt.jmespath.Expression;
import java.io.IOException;
import java.lang.reflect.Field;
import org.kohsuke.stapler.HttpResponse;
Expand Down Expand Up @@ -166,6 +167,14 @@ public HttpResponse doFinishLogin(StaplerRequest request) throws IOException {
return super.doFinishLogin(request);
}

public String getStringFieldFromJMESPath(Object object, String jmespathField) {
Expression<Object> expr = super.compileJMESPath(jmespathField, "test field");
if (expr == null) {
return null;
}
return super.getStringField(object, expr);
}

@Override
public Object readResolve() {
return super.readResolve();
Expand Down

0 comments on commit 0332677

Please sign in to comment.