Skip to content

Commit

Permalink
Add client certificate support (#596)
Browse files Browse the repository at this point in the history
Co-authored-by: Tim Jacomb <[email protected]>
  • Loading branch information
itzikbekelmicrosoft and timja authored Jul 17, 2024
1 parent 6977816 commit c478593
Show file tree
Hide file tree
Showing 21 changed files with 483 additions and 118 deletions.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ A Jenkins Plugin that supports authentication & authorization via Microsoft Entr

1. Add a new Reply URL `https://{your_jenkins_host}/securityRealm/finishLogin`. Make sure "Jenkins URL" (Manage Jenkins => Configure System) is set to the same value as `https://{your_jenkins_host}`.

1. Click `Certificates & secrets`, under Client secrets click `New client secret` to generate a new key, copy the `value`, it will be used as `Client Secret` in Jenkins.
1. Click `Certificates & secrets`

- To use a client secret: Under Client secrets, click `New client secret` to generate a new key. Copy the `value`, it will be used as `Client Secret` in Jenkins.

- To use a certificate: Under Certificates, click `Upload certificate` to upload your certificate. This certificate will be used for client certificate authentication in Jenkins. You will need to use the corresponding private key associated with this certificate in PEM format.

1. Click `Authentication`, under 'Implicit grant and hybrid flows', enable `ID tokens`.

Expand Down Expand Up @@ -98,4 +102,4 @@ A: You can disable the security from the config file (see [https://www.jenkins.i

#### Q: Why am I getting an error "insufficient privileges to complete the operation" even after having granted the permission?

A: It can take a long time for the privileges to take effect, which could be 10-20 minutes. Just wait for a while and try again.
A: It can take a long time for the privileges to take effect, which could be 10-20 minutes. Just wait for a while and try again.
113 changes: 95 additions & 18 deletions src/main/java/com/microsoft/jenkins/azuread/AzureSecurityRealm.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
import com.azure.core.credential.TokenRequestContext;
import com.azure.identity.ClientSecretCredential;
import com.azure.identity.ClientSecretCredentialBuilder;
import com.azure.identity.ClientCertificateCredential;
import com.azure.identity.ClientCertificateCredentialBuilder;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.oauth.OAuth20Service;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.microsoft.graph.authentication.TokenCredentialAuthProvider;
import com.microsoft.graph.http.GraphServiceException;
import com.microsoft.graph.httpcore.HttpClients;
import com.microsoft.graph.models.Group;
import com.microsoft.graph.options.Option;
import com.microsoft.graph.options.QueryOption;
Expand All @@ -33,7 +33,6 @@
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Extension;
import hudson.ProxyConfiguration;
import hudson.Util;
import hudson.model.Descriptor;
import hudson.model.User;
Expand All @@ -52,9 +51,6 @@

import jenkins.model.Jenkins;
import jenkins.security.SecurityListener;
import jenkins.util.JenkinsJVM;
import okhttp3.Credentials;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
Expand All @@ -79,9 +75,10 @@
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.Proxy;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
Expand Down Expand Up @@ -113,6 +110,8 @@ public class AzureSecurityRealm extends SecurityRealm {
public static final String CALLBACK_URL = "/securityRealm/finishLogin";
private static final String CONVERTER_NODE_CLIENT_ID = "clientid";
private static final String CONVERTER_NODE_CLIENT_SECRET = "clientsecret";
private static final String CONVERTER_NODE_CLIENT_CERTIFICATE = "clientCertificate";
private static final String CONVERTER_NODE_CREDENTIAL_TYPE = "credentialType";
private static final String CONVERTER_NODE_TENANT = "tenant";
private static final String CONVERTER_NODE_CACHE_DURATION = "cacheduration";
private static final String CONVERTER_NODE_FROM_REQUEST = "fromrequest";
Expand All @@ -122,28 +121,31 @@ public class AzureSecurityRealm extends SecurityRealm {
public static final String CONVERTER_DISABLE_GRAPH_INTEGRATION = "disableGraphIntegration";
public static final String CONVERTER_SINGLE_LOGOUT = "singleLogout";
public static final String CONVERTER_PROMPT_ACCOUNT = "promptAccount";

public static final String CONVERTER_ENVIRONMENT_NAME = "environmentName";

private Cache<String, AzureAdUser> caches;

private Secret clientId;
private Secret clientSecret;
private Secret clientCertificate;
private Secret tenant;
private int cacheDuration;
private boolean fromRequest = false;
private boolean promptAccount;
private boolean singleLogout;
private boolean disableGraphIntegration;
private String azureEnvironmentName = "Azure";
private String credentialType = "Secret";

public AccessToken getAccessToken() {
ClientSecretCredential clientSecretCredential = getClientSecretCredential();

TokenRequestContext tokenRequestContext = new TokenRequestContext();
String graphResource = AzureEnvironment.getGraphResource(getAzureEnvironmentName());
tokenRequestContext.setScopes(singletonList(graphResource + ".default"));

AccessToken accessToken = clientSecretCredential.getToken(tokenRequestContext).block();
AccessToken accessToken = ("Certificate".equals(credentialType) ? getClientCertificateCredential() : getClientSecretCredential())
.getToken(tokenRequestContext)
.block();

if (accessToken == null) {
throw new IllegalStateException("Access token null when it is required");
Expand All @@ -152,6 +154,13 @@ public AccessToken getAccessToken() {
return accessToken;
}

InputStream getCertificate() {

String secretString = clientCertificate.getPlainText();

return new ByteArrayInputStream(secretString.getBytes(StandardCharsets.UTF_8));
}

ClientSecretCredential getClientSecretCredential() {
String azureEnv = getAzureEnvironmentName();
return new ClientSecretCredentialBuilder()
Expand All @@ -163,6 +172,17 @@ ClientSecretCredential getClientSecretCredential() {
.build();
}

ClientCertificateCredential getClientCertificateCredential() {
String azureEnv = getAzureEnvironmentName();
return new ClientCertificateCredentialBuilder()
.clientId(clientId.getPlainText())
.pemCertificate(getCertificate())
.tenantId(tenant.getPlainText())
.sendCertificateChain(true)
.authorityHost(getAuthorityHost(azureEnv))
.httpClient(HttpClientRetriever.get())
.build();

Check warning on line 184 in src/main/java/com/microsoft/jenkins/azuread/AzureSecurityRealm.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 146-184 are not covered by tests
}
public boolean isPromptAccount() {
return promptAccount;
}
Expand Down Expand Up @@ -192,16 +212,24 @@ public String getClientSecretSecret() {
return clientSecret.getEncryptedValue();
}

public String getClientCertificateSecret() {
return clientCertificate.getEncryptedValue();
}

public String getCredentialType() {
return credentialType;
}
public String getTenantSecret() {
return tenant.getEncryptedValue();
}

String getCredentialCacheKey() {
return Util.getDigestOf(clientId.getPlainText()
+ clientSecret.getPlainText()
String credentialComponent = clientId.getPlainText()
+ ("Certificate".equals(credentialType) ? clientCertificate.getPlainText() : clientSecret.getPlainText())
+ tenant.getPlainText()
+ azureEnvironmentName
);
+ azureEnvironmentName;

return Util.getDigestOf(credentialComponent);

Check warning on line 232 in src/main/java/com/microsoft/jenkins/azuread/AzureSecurityRealm.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 227-232 are not covered by tests
}

public String getClientId() {
Expand Down Expand Up @@ -230,6 +258,11 @@ public void setDisableGraphIntegration(boolean disableGraphIntegration) {
this.disableGraphIntegration = disableGraphIntegration;
}

@DataBoundSetter
public void setCredentialType(String credentialType) {
this.credentialType = credentialType;
}

public void setClientId(String clientId) {
this.clientId = Secret.fromString(clientId);
}
Expand All @@ -238,10 +271,19 @@ public Secret getClientSecret() {
return clientSecret;
}

public Secret getClientCertificate() {
return clientCertificate;
}

public void setClientSecret(String clientSecret) {
this.clientSecret = Secret.fromString(clientSecret);
}

@DataBoundSetter
public void setClientCertificate(String clientCertificate) {
this.clientCertificate = Secret.fromString(clientCertificate);
}

public String getTenant() {
return tenant.getPlainText();
}
Expand Down Expand Up @@ -277,7 +319,7 @@ public JwtConsumer getJwtConsumer() {

OAuth20Service getOAuthService() {
return new ServiceBuilder(clientId.getPlainText())
.apiSecret(clientSecret.getPlainText())
.apiSecret("Certificate".equals(credentialType) ? clientCertificate.getPlainText() : clientSecret.getPlainText())

Check warning on line 322 in src/main/java/com/microsoft/jenkins/azuread/AzureSecurityRealm.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 322 is not covered by tests
.responseType("id_token")
.defaultScope("openid profile email")
.callback(getRootUrl() + CALLBACK_URL)
Expand Down Expand Up @@ -619,10 +661,20 @@ public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingC
writer.setValue(realm.getClientIdSecret());
writer.endNode();

writer.startNode(CONVERTER_NODE_CLIENT_SECRET);
writer.setValue(realm.getClientSecretSecret());
writer.startNode(CONVERTER_NODE_CREDENTIAL_TYPE);
writer.setValue(realm.getCredentialType());
writer.endNode();

if ("Secret".equals(realm.getCredentialType())) {
writer.startNode(CONVERTER_NODE_CLIENT_SECRET);
writer.setValue(realm.getClientSecretSecret());
writer.endNode();
} else {
writer.startNode(CONVERTER_NODE_CLIENT_CERTIFICATE);
writer.setValue(realm.getClientCertificateSecret());
writer.endNode();
}

writer.startNode(CONVERTER_NODE_TENANT);
writer.setValue(realm.getTenantSecret());
writer.endNode();
Expand Down Expand Up @@ -666,6 +718,12 @@ public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext co
case CONVERTER_NODE_CLIENT_SECRET:
realm.setClientSecret(value);
break;
case CONVERTER_NODE_CLIENT_CERTIFICATE:
realm.setClientCertificate(value);
break;
case CONVERTER_NODE_CREDENTIAL_TYPE:
realm.setCredentialType(value);
break;
case CONVERTER_NODE_TENANT:
realm.setTenant(value);
break;
Expand Down Expand Up @@ -746,17 +804,36 @@ public ListBoxModel doFillAzureEnvironmentNameItems() {

public FormValidation doVerifyConfiguration(@QueryParameter final String clientId,
@QueryParameter final Secret clientSecret,
@QueryParameter final Secret clientCertificate,
@QueryParameter final String credentialType,
@QueryParameter final String tenant,
@QueryParameter final String testObject,
@QueryParameter final String azureEnvironmentName) {
if (testObject.equals("")) {
switch (credentialType) {
case "Secret":
if (Secret.toString(clientSecret).isEmpty()) {
return FormValidation.error("Please set a secret");
}
break;
case "Certificate":
if (Secret.toString(clientCertificate).isEmpty()) {
return FormValidation.error("Please set a certificate");
}
break;
default:
return FormValidation.error("Invalid credential type");
}

if (testObject.isEmpty()) {
return FormValidation.error("Please set a test user principal name or object ID");
}

GraphServiceClient<Request> graphServiceClient = GraphClientCache.getClient(
new GraphClientCacheKey(
clientId,
Secret.toString(clientSecret),
Secret.toString(clientCertificate),

Check warning on line 835 in src/main/java/com/microsoft/jenkins/azuread/AzureSecurityRealm.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 812-835 are not covered by tests
credentialType,
tenant,
azureEnvironmentName
)
Expand Down
52 changes: 43 additions & 9 deletions src/main/java/com/microsoft/jenkins/azuread/GraphClientCache.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.microsoft.jenkins.azuread;

import com.azure.core.credential.TokenCredential;
import com.azure.identity.ClientSecretCredential;
import com.azure.identity.ClientSecretCredentialBuilder;
import com.azure.identity.ClientCertificateCredential;
import com.azure.identity.ClientCertificateCredentialBuilder;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.microsoft.graph.authentication.TokenCredentialAuthProvider;
Expand All @@ -20,6 +23,9 @@
import org.apache.commons.lang3.StringUtils;

import java.net.Proxy;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

import static com.microsoft.jenkins.azuread.AzureEnvironment.AZURE_PUBLIC_CLOUD;
import static com.microsoft.jenkins.azuread.AzureEnvironment.getAuthorityHost;
Expand All @@ -35,14 +41,7 @@ public class GraphClientCache {
.build(GraphClientCache::createGraphClient);

private static GraphServiceClient<Request> createGraphClient(GraphClientCacheKey key) {
final ClientSecretCredential clientSecretCredential = getClientSecretCredential(key);

String graphResource = AzureEnvironment.getGraphResource(key.getAzureEnvironmentName());

final TokenCredentialAuthProvider authProvider = new TokenCredentialAuthProvider(
singletonList(graphResource + ".default"),
clientSecretCredential
);
TokenCredentialAuthProvider authProvider = getAuthProvider(key);

OkHttpClient.Builder builder = HttpClients.createDefault(authProvider)
.newBuilder();
Expand All @@ -63,6 +62,33 @@ private static GraphServiceClient<Request> createGraphClient(GraphClientCacheKey
return graphServiceClient;
}

private static TokenCredentialAuthProvider getAuthProvider(GraphClientCacheKey key) {
String graphResource = AzureEnvironment.getGraphResource(key.getAzureEnvironmentName());

TokenCredential tokenCredential;
if ("Secret".equals(key.getCredentialType())) {
tokenCredential = getClientSecretCredential(key);
} else if ("Certificate".equals(key.getCredentialType())) {
tokenCredential = getClientCertificateCredential(key);
} else {
throw new IllegalArgumentException("Invalid credential type");
}
return new TokenCredentialAuthProvider(
singletonList(graphResource + ".default"),
tokenCredential);
}

static ClientCertificateCredential getClientCertificateCredential(GraphClientCacheKey key) {
return new ClientCertificateCredentialBuilder()
.clientId(key.getClientId())
.pemCertificate(getCertificate(key))
.tenantId(key.getTenantId())
.sendCertificateChain(true)
.authorityHost(getAuthorityHost(key.getAzureEnvironmentName()))
.httpClient(HttpClientRetriever.get())
.build();
}

static ClientSecretCredential getClientSecretCredential(GraphClientCacheKey key) {
return new ClientSecretCredentialBuilder()
.clientId(key.getClientId())
Expand All @@ -73,6 +99,12 @@ static ClientSecretCredential getClientSecretCredential(GraphClientCacheKey key)
.build();
}

static InputStream getCertificate(GraphClientCacheKey key) {

String secretString = key.getClientCertificate();
return new ByteArrayInputStream(secretString.getBytes(StandardCharsets.UTF_8));
}

static GraphServiceClient<Request> getClient(GraphClientCacheKey key) {
return TOKEN_CACHE.get(key);
}
Expand All @@ -81,6 +113,8 @@ public static GraphServiceClient<Request> getClient(AzureSecurityRealm azureSecu
GraphClientCacheKey key = new GraphClientCacheKey(
azureSecurityRealm.getClientId(),
Secret.toString(azureSecurityRealm.getClientSecret()),
Secret.toString(azureSecurityRealm.getClientCertificate()),
azureSecurityRealm.getCredentialType(),

Check warning on line 117 in src/main/java/com/microsoft/jenkins/azuread/GraphClientCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 44-117 are not covered by tests
azureSecurityRealm.getTenant(),
azureSecurityRealm.getAzureEnvironmentName()
);
Expand Down Expand Up @@ -111,4 +145,4 @@ public static OkHttpClient.Builder addProxyToHttpClientIfRequired(OkHttpClient.B

return builder;
}
}
}
Loading

0 comments on commit c478593

Please sign in to comment.