Initial import.
diff --git a/.gitignore b/.gitignore
index 32858aa..900bd4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,6 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
+target
+project/target
+.idea
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5e9061d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,130 @@
+# Gerrit SAML Plugin
+
+This plugin allows you to authenticate to Gerrit using a SAML identity
+provider.
+
+## Installation
+
+Gerrit looks for 3 attributes (which are configurable) in the AttributeStatement:
+
+*DisplayName:* the full name of the user.
+*EmailAddress:* email address of the user.
+*UserName:* username (used for ssh).
+
+If any of these attributes is not found in the assertion, their value is
+taken from the NameId field of the SAML assertion.
+
+### Setting Gerrit in your IdP (Okta, Onelogin, ...)
+
+- Create a new SAML 2.0 application.
+- Set the following parameters:
+ - Single sign on URL: http://gerrit.site.com/plugins/gerrit-saml-plugin/saml
+ - Check "Use this for Recipient URL and Destination URL".
+ - Audience URI (SP Entity Id): http://gerrit.site.com/plugins/gerrit-saml-plugin/saml
+ - We need to set up the attributes in the assertion to send the right
+ information. Here is how to do it with Okta:
+ - Application username: "Okta username prefix"
+ - Add attribute statement: Name: "DisplayName" with Value
+ "user.displayName"
+ - Add attribute statement: Name: "EmailAddress" with Value
+ "user.email"
+ - *IMPORTANT*: If you are not using Okta, you need to set up an attribute
+ "UserName" with the value of the username (not email, without @). If you
+ do not do so, the name will be taken from the NameId provided by
+ the assertion. This is why in Okta we set the application username to
+ "Okta username prefix".
+- Obtain your IdP metadata (either URL or a local XML file)
+
+### Download the plugin
+
+Download [gerrit-saml-plugin](https://bintray.com/artifact/download/thesamet/maven/gerrit-saml-plugin-2.11.3-1.jar) and put it in $gerrit_site/lib/.
+
+### Configure Gerrit to use the SAML filter:
+In `$gerritSite/etc/gerrit.config` file, the `[httpd]` section should contain
+
+```
+[httpd]
+ filterClass = com.thesamet.gerrit.plugins.saml.SamlWebFilter
+```
+
+### Configure HTTP authentication for Gerrit:
+
+In `$gerritSite/etc/gerrit.config` file, the `[auth]` section should include
+the following lines:
+
+```
+[auth]
+ type = HTTP
+ logoutUrl = https://mysso.example.com/logout
+ httpHeader = X-SAML-UserName
+ httpDisplaynameHeader = X-SAML-DisplayName
+ httpEmailHeader = X-SAML-EmailHeader
+ httpExternalIdHeader = X-SAML-ExternalId
+```
+
+The header names are used internally between the SAML plugin and Gerrit to
+communicate the user's identity. You can use other names (as long as it will
+not conflict with any other HTTP header Gerrit might expect).
+
+### Create a local keystore
+
+In `$gerrit_site/etc` create a local keystore:
+
+```
+keytool -genkeypair -alias pac4j -keypass pac4j-demo-password \
+ -keystore samlKeystore.jks \
+ -storepass pac4j-demo-password -keyalg RSA -keysize 2048 -validity 3650
+```
+
+### Configure SAML
+
+Add a new `[saml]` section to `$site_path/etc/gerrit.config`:
+
+```
+[saml]
+ keystorePath = /path/to/samlKeystore.jks
+ keystorePassword = pac4j-demo-passwd
+ privateKeyPassword = pac4j-demo-passwd
+ metadataPath = https://mycompany.okta.com/app/hashash/sso/saml/metadata
+```
+
+**saml.metadataPath**: Location of IdP Metadata from your SAML identity provider.
+The value can be a URL, or a local file (prefix with `file://`)
+
+**saml.keystorePath**: Path to the keystore created above. If not absolute,
+the path is resolved relative to `$site_path`.
+
+**saml.privateKeyPassword**: Password protecting the private key of the generated
+key pair (needs to be the same as the password provided throguh the `keypass`
+flag above.)
+
+**saml.keystorePassword**: Password that is used to protect the integrity of the
+keystore (needs to be the same as the password provided throguh the `keystore`
+flag above.)
+
+**saml.displayNameAttr**: Gerrit will look for an attribute with this name in
+the assertion to find a display name for the user. If the attribute is not
+found, the NameId from the SAML assertion is used instead.
+
+Default is `DisplayName`
+
+**saml.emailAddressAttr**: Gerrit will look for an attribute with this name in
+the assertion to find a the email address of the user. If the attribute is not
+found, the NameId from the SAML assertion is used instead.
+
+Default is `EmailAddress`
+
+**saml.userNameAttr**: Gerrit will look for an attribute with this name in the
+assertion to find a the email address of the user. If the attribute is not
+found, the NameId from the SAML assertion is used instead.
+
+Default is `UserName`
+
+## Development
+
+- Clone this repository.
+- Install sbt.
+- Edit the code.
+- Run 'sbt assembly' to build the jar.
+- Copy target/out/gerrit-saml-plugin-$VERSION.jar into $site_path/lib/
+
diff --git a/build.sbt b/build.sbt
new file mode 100644
index 0000000..1d1f8b7
--- /dev/null
+++ b/build.sbt
@@ -0,0 +1,50 @@
+name := "gerrit-saml-plugin"
+
+val GerritVersion = "2.11.3"
+
+version := GerritVersion + "-1"
+
+javacOptions ++= Seq("-source", "1.7", "-target", "1.7")
+
+libraryDependencies += ("com.google.gerrit" % "gerrit-plugin-api" % GerritVersion % "provided")
+
+libraryDependencies += "org.pac4j" % "pac4j-saml" % "1.8.0-RC1"
+
+libraryDependencies ~= { _ map {
+ case m => m
+ .exclude("ch.qos.logback", "logback-classic")
+ .exclude("ch.qos.logback", "logback-core")
+ .exclude("com.google.guava", "guava")
+ .exclude("commons-codec", "commons-codec")
+ .exclude("commons-collections", "commons-collections")
+ .exclude("commons-httpclient", "commons-httpclient")
+ .exclude("commons-lang", "commons-lang")
+ .exclude("commons-logging", "commons-logging")
+ .exclude("commons-ssl", "commons-ssl")
+ .exclude("javax.servlet", "servlet-api")
+ .exclude("javax.xml", "*")
+ .exclude("junit", "*")
+ .exclude("org.apache.httpcomponents", "*")
+ .exclude("org.apache.velocity", "*")
+ .exclude("org.bouncycastle", "*")
+ .exclude("org.slf4j", "*")
+ .exclude("xalan", "xalan")
+}}
+
+assemblyMergeStrategy in assembly := {
+ case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard
+ case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard
+ case PathList("META-INF", "NOTICE") => MergeStrategy.discard
+ case PathList("META-INF", "LICENSE") => MergeStrategy.concat
+ // Trick is here: get all the initializers concatenated...
+ case PathList("META-INF", "services", "org.opensaml.core.config.Initializer") => MergeStrategy.concat
+ case PathList("schema", v) if v.endsWith(".xsd") => MergeStrategy.first
+ case PathList("credential-criteria-registry.properties") => MergeStrategy.first
+ case PathList(xml) if xml.endsWith(".xml") => MergeStrategy.first
+ case _ => MergeStrategy.deduplicate
+}
+
+assemblyOption in assembly := (assemblyOption in assembly).value.copy(includeScala = false)
+
+assemblyOutputPath in assembly := target.value / "out" / (s"${name.value}-${version.value}.jar")
+
diff --git a/project/assembly.sbt b/project/assembly.sbt
new file mode 100644
index 0000000..a815d58
--- /dev/null
+++ b/project/assembly.sbt
@@ -0,0 +1 @@
+addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.0")
diff --git a/src/main/java/com/thesamet/gerrit/plugins/saml/AuthenticatedUser.java b/src/main/java/com/thesamet/gerrit/plugins/saml/AuthenticatedUser.java
new file mode 100644
index 0000000..432e36f
--- /dev/null
+++ b/src/main/java/com/thesamet/gerrit/plugins/saml/AuthenticatedUser.java
@@ -0,0 +1,41 @@
+package com.thesamet.gerrit.plugins.saml;
+
+public class AuthenticatedUser implements java.io.Serializable {
+ private String username;
+ private String displayName;
+ private String email;
+ private String externalId;
+
+ public AuthenticatedUser(String username, String displayName, String email, String extenalId) {
+ this.username = username;
+ this.displayName = displayName;
+ this.email = email;
+ this.externalId = extenalId;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public String getExternalId() {
+ return externalId;
+ }
+
+ @Override
+ public String toString() {
+ return "AuthenticatedUser{" +
+ "username='" + username + '\'' +
+ ", displayName='" + displayName + '\'' +
+ ", email='" + email + '\'' +
+ ", externalId='" + externalId + '\'' +
+ '}';
+ }
+}
diff --git a/src/main/java/com/thesamet/gerrit/plugins/saml/SamlConfig.java b/src/main/java/com/thesamet/gerrit/plugins/saml/SamlConfig.java
new file mode 100644
index 0000000..ffca75f
--- /dev/null
+++ b/src/main/java/com/thesamet/gerrit/plugins/saml/SamlConfig.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.thesamet.gerrit.plugins.saml;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * SAML 2.0 related settings from {@code gerrit.config}.
+ */
+@Singleton
+public class SamlConfig {
+ private final String metadataPath;
+ private final String keystorePath;
+ private final String privateKeyPassword;
+ private final String keystorePassword;
+ private final String displayNameAttr;
+ private final String userNameAttr;
+ private final String emailAddressAttr;
+
+ @Inject
+ SamlConfig(@GerritServerConfig final Config cfg) {
+ metadataPath = getString(cfg, "metadataPath");
+ keystorePath = getString(cfg, "keystorePath");
+ privateKeyPassword = getString(cfg, "privateKeyPassword");
+ keystorePassword = getString(cfg, "keystorePassword");
+ displayNameAttr =
+ getGetStringWithDefault(cfg, "displayNameAttr", "DisplayName");
+ userNameAttr = getGetStringWithDefault(cfg, "userNameAttr", "UserName");
+ emailAddressAttr =
+ getGetStringWithDefault(cfg, "emailAddressAttr", "EmailAddress");
+ }
+
+ public String getMetadataPath() {
+ return metadataPath;
+ }
+
+ public String getKeystorePath() {
+ return keystorePath;
+ }
+
+ public String getPrivateKeyPassword() {
+ return privateKeyPassword;
+ }
+
+ public String getKeystorePassword() {
+ return keystorePassword;
+ }
+
+ public String getDisplayNameAttr() {
+ return displayNameAttr;
+ }
+
+ public String getUserNameAttr() {
+ return userNameAttr;
+ }
+
+ public String getEmailAddressAttr() {
+ return emailAddressAttr;
+ }
+
+ private static String getString(Config cfg, String name) {
+ return cfg.getString("saml", null, name);
+ }
+
+ private static String getGetStringWithDefault(Config cfg, String name,
+ String defaultValue) {
+ String result = getString(cfg, name);
+ if (result != null) {
+ return result;
+ }
+ return defaultValue;
+ }
+}
diff --git a/src/main/java/com/thesamet/gerrit/plugins/saml/SamlWebFilter.java b/src/main/java/com/thesamet/gerrit/plugins/saml/SamlWebFilter.java
new file mode 100644
index 0000000..b093e06
--- /dev/null
+++ b/src/main/java/com/thesamet/gerrit/plugins/saml/SamlWebFilter.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.thesamet.gerrit.plugins.saml;
+
+import com.google.common.collect.Iterators;
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+import org.pac4j.core.context.J2EContext;
+import org.pac4j.core.exception.RequiresHttpAction;
+import org.pac4j.core.exception.TechnicalException;
+import org.pac4j.saml.client.SAML2Client;
+import org.pac4j.saml.client.SAML2ClientConfiguration;
+import org.pac4j.saml.credentials.SAML2Credentials;
+import org.pac4j.saml.profile.SAML2Profile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.servlet.*;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+
+@Singleton
+class SamlWebFilter implements Filter {
+ static final String GERRIT_LOGOUT = "/logout";
+ static final String SAML_POSTBACK = "/plugins/gerrit-saml-plugin/saml";
+
+ private static final String SESSION_ATTR_USER = "Gerrit-Saml-User";
+
+ static final Logger log = LoggerFactory.getLogger(SamlWebFilter.class);
+ private final Injector injector;
+ private final SAML2Client saml2Client;
+ private final SamlConfig samlConfig;
+ private final String httpUserNameHeader;
+ private final String httpDisplaynameHeader;
+ private final String httpEmailHeader;
+ private final String httpExternalIdHeader;
+ private final HashSet<String> authHeaders;
+ private final String logoutUrl;
+
+ private String getHeaderFromConfig(Config gerritConfig, String name) {
+ String s = gerritConfig.getString("auth", null, name);
+ return s == null ? "" : s.toUpperCase();
+ }
+
+ @Inject
+ SamlWebFilter(Injector injector, @GerritServerConfig Config gerritConfig, SamlConfig samlConfig) {
+ this.injector = injector;
+ this.samlConfig = samlConfig;
+ saml2Client =
+ new SAML2Client(new SAML2ClientConfiguration(
+ samlConfig.getKeystorePath(), samlConfig.getKeystorePassword(),
+ samlConfig.getPrivateKeyPassword(), samlConfig.getMetadataPath()));
+ String callbackUrl = gerritConfig.getString("gerrit", null, "canonicalWebUrl") + "plugins/gerrit-saml-plugin/saml";
+ httpUserNameHeader = getHeaderFromConfig(gerritConfig, "httpHeader");
+ httpDisplaynameHeader = getHeaderFromConfig(gerritConfig, "httpDisplaynameHeader");
+ httpEmailHeader = getHeaderFromConfig(gerritConfig, "httpEmailHeader");
+ httpExternalIdHeader = getHeaderFromConfig(gerritConfig, "httpExternalIdHeader");
+ authHeaders = Sets.newHashSet(
+ httpUserNameHeader,
+ httpDisplaynameHeader,
+ httpEmailHeader,
+ httpExternalIdHeader);
+ if (authHeaders.contains("") || authHeaders.contains(null)) {
+ throw new RuntimeException("All authentication headers must be set.");
+ }
+ if (authHeaders.size() != 4) {
+ throw new RuntimeException("Unique values for httpUserNameHeader, " +
+ "httpDisplaynameHeader, httpEmailHeader and httpExternalIdHeader " +
+ "are required.");
+ }
+ logoutUrl = gerritConfig.getString("auth", null, "logoutUrl");
+
+ saml2Client.setCallbackUrl(callbackUrl);
+ }
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {
+ }
+
+ @Override
+ public void destroy() {
+ }
+
+ private AuthenticatedUser userFromRequest(HttpServletRequest request) {
+ HttpSession s = request.getSession();
+ AuthenticatedUser user = (AuthenticatedUser) s.getAttribute(SESSION_ATTR_USER);
+ if (user == null || user.getUsername() == null)
+ return null;
+ else return user;
+ }
+
+ private void signin(J2EContext context) throws RequiresHttpAction, IOException {
+ SAML2Credentials credentials = saml2Client.getCredentials(context);
+ SAML2Profile user = saml2Client.getUserProfile(credentials, context);
+ if (user != null) {
+ log.debug("Received SAML callback for userId={} with attributes: {}",
+ getUserName(user), user.getAttributes());
+ HttpSession s = context.getRequest().getSession();
+ s.setAttribute(SESSION_ATTR_USER, new AuthenticatedUser(
+ getUserName(user),
+ getDisplayName(user),
+ getEmailAddress(user),
+ "saml/" + user.getId()));
+
+ String redirectUri = context.getRequest().getParameter("RelayState");
+ log.debug("Got {}", redirectUri);
+ if (null == redirectUri) {
+ redirectUri = "/";
+ }
+ context.getResponse().sendRedirect(redirectUri);
+ } else {
+ signout(context.getRequest(), context.getResponse());
+ }
+ }
+
+ private void signout(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ HttpSession s = request.getSession();
+ s.removeAttribute(SESSION_ATTR_USER);
+ response.sendRedirect(logoutUrl);
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response,
+ FilterChain chain) throws IOException, ServletException {
+ HttpServletRequest httpRequest = (HttpServletRequest) request;
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
+ AuthenticatedUser user = userFromRequest(httpRequest);
+
+ try {
+ if (isSamlPostback(httpRequest)) {
+ J2EContext context = new J2EContext(httpRequest, httpResponse);
+ signin(context);
+ } else if (isGerritLogout(httpRequest)) {
+ signout(httpRequest, httpResponse);
+ } else if (isAllowedWithoutAuth(httpRequest)) {
+ // We allow URLs to continue without a user (and hence without the authentication
+ // headers. It is up for Gerrit to approve or deny these requests. We do it
+ // specifically for favicon.ico: it could be that during a normal authentication
+ // redirect, the browser will try to fetch /favicon.ico which will start a
+ // parallel authentication process, but it will override the redirectUri and the
+ // user will be redirected to /favicon.ico. This would have been eliminated
+ // if pac4j would allow obtaining RelayState...
+ chain.doFilter(httpRequest, response);
+ } else if (user == null) {
+ J2EContext context = new J2EContext(httpRequest, httpResponse);
+ redirectToIdentityProvider(context);
+ } else {
+ HttpServletRequest req = new AuthenticatedHttpRequest(httpRequest, user);
+ chain.doFilter(req, response);
+ }
+ } catch (final RequiresHttpAction requiresHttpAction) {
+ throw new TechnicalException("Unexpected HTTP action", requiresHttpAction);
+ }
+ }
+
+ private boolean isAllowedWithoutAuth(HttpServletRequest httpRequest) {
+ return (httpRequest.getRequestURI().equals("/favicon.ico"));
+ }
+
+ private void redirectToIdentityProvider(J2EContext context)
+ throws RequiresHttpAction {
+ String redirectUri = Url.decode(context
+ .getRequest()
+ .getRequestURI()
+ .substring(
+ context.getRequest().getContextPath().length()));
+ context.setSessionAttribute(SAML2Client.SAML_RELAY_STATE_ATTRIBUTE, redirectUri);
+ log.debug("Setting redirectUri: {}", redirectUri);
+ saml2Client.redirect(context, true);
+ }
+
+ private static boolean isGerritLogout(HttpServletRequest request) {
+ return request.getRequestURI().indexOf(GERRIT_LOGOUT) >= 0;
+ }
+
+ private static boolean isSamlPostback(HttpServletRequest request) {
+ return "POST".equals(request.getMethod())
+ && request.getRequestURI().indexOf(SAML_POSTBACK) >= 0;
+ }
+
+ private String getAttribute(SAML2Profile user, String attrName) {
+ List<?> names = (List<?>) user.getAttribute(attrName);
+ if (names != null && !names.isEmpty()) {
+ return (String) names.get(0);
+ }
+ return null;
+ }
+
+ private String getAttributeOrElseId(SAML2Profile user, String attrName) {
+ String value = getAttribute(user, attrName);
+ if (value != null) {
+ return value;
+ }
+ return user.getId();
+ }
+
+ private String getDisplayName(SAML2Profile user) {
+ return getAttributeOrElseId(user, samlConfig.getDisplayNameAttr());
+ }
+
+ private String getEmailAddress(SAML2Profile user) {
+ String emailAddress = getAttribute(user, samlConfig.getEmailAddressAttr());
+ if (emailAddress != null) {
+ return emailAddress;
+ }
+ String nameId = user.getId();
+ if (!nameId.contains("@")) {
+ log.debug(
+ "Email address attribute not found, NameId {} does not look like an email.",
+ nameId);
+ return null;
+ }
+ return emailAddress;
+ }
+
+ private String getUserName(SAML2Profile user) {
+ return getAttributeOrElseId(user, samlConfig.getUserNameAttr());
+ }
+
+ private class AuthenticatedHttpRequest extends HttpServletRequestWrapper {
+ private AuthenticatedUser user;
+
+ public AuthenticatedHttpRequest(HttpServletRequest request,
+ AuthenticatedUser user) {
+ super(request);
+ this.user = user;
+ }
+
+ @Override
+ public Enumeration<String> getHeaderNames() {
+ final Enumeration<String> wrappedHeaderNames = super.getHeaderNames();
+ HashSet<String> headerNames = new HashSet<>(authHeaders);
+ while (wrappedHeaderNames.hasMoreElements()) {
+ headerNames.add(wrappedHeaderNames.nextElement());
+ }
+ return Iterators.asEnumeration(headerNames.iterator());
+ }
+
+ @Override
+ public String getHeader(String name) {
+ String nameUpperCase = name.toUpperCase();
+ if (httpUserNameHeader.equals(nameUpperCase)) {
+ return user.getUsername();
+ } else if (httpDisplaynameHeader.equals(nameUpperCase)) {
+ return user.getDisplayName();
+ } else if (httpEmailHeader.equals(nameUpperCase)) {
+ return user.getEmail();
+ } else if (httpExternalIdHeader.equals(nameUpperCase)) {
+ return user.getExternalId();
+ } else {
+ return super.getHeader(name);
+ }
+ }
+ }
+}