Merge branch 'stable-2.10'

* stable-2.10:
  Bump plugin version to 0.4
  Add support for hosted domain to Google OAuth provider

Conflicts:
	VERSION
diff --git a/VERSION b/VERSION
index f5bb986..8026f1e 100644
--- a/VERSION
+++ b/VERSION
@@ -1,4 +1,4 @@
 # Used by BUCK to include "Implementation-Version" in plugin Manifest.
 # If this file doesn't exist the output of 'git describe' is used
 # instead.
-PLUGIN_VERSION = '2.11'
+PLUGIN_VERSION = '2.11.1'
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/GoogleOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/GoogleOAuthService.java
index 81cd416..438455a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/GoogleOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/GoogleOAuthService.java
@@ -62,6 +62,7 @@
   private final OAuthService service;
   private final String canonicalWebUrl;
   private final boolean linkToExistingOpenIDAccounts;
+  private final String domain;
 
   @Inject
   GoogleOAuthService(PluginConfigFactory cfgFactory,
@@ -73,6 +74,7 @@
         urlProvider.get()) + "/";
     this.linkToExistingOpenIDAccounts = cfg.getBoolean(
         InitOAuth.LINK_TO_EXISTING_OPENID_ACCOUNT, false);
+    this.domain = cfg.getString(InitOAuth.DOMAIN);
     String scope = linkToExistingOpenIDAccounts
         ? "openid " + SCOPE
         : SCOPE;
@@ -88,6 +90,7 @@
       log.debug("OAuth2: scope={}", scope);
       log.debug("OAuth2: linkToExistingOpenIDAccounts={}",
           linkToExistingOpenIDAccounts);
+      log.debug("OAuth2: domain={}", domain);
     }
   }
 
@@ -119,8 +122,21 @@
       JsonElement name = jsonObject.get("name");
       String claimedIdentifier = null;
 
-      if (linkToExistingOpenIDAccounts) {
-        claimedIdentifier = lookupClaimedIdentity(token);
+      if (linkToExistingOpenIDAccounts
+          || !Strings.isNullOrEmpty(domain)) {
+        JsonObject jwtToken = retrieveJWTToken(token);
+        if (linkToExistingOpenIDAccounts) {
+          claimedIdentifier = retrieveClaimedIdentity(jwtToken);
+        }
+        if (!Strings.isNullOrEmpty(domain)) {
+          String hdClaim = retrieveHostedDomain(jwtToken);
+          if (!domain.equalsIgnoreCase(hdClaim)) {
+            // TODO(davido): improve error reporting in OAuth extension point
+            log.error("Error: hosted domain validation failed: {}",
+                Strings.nullToEmpty(hdClaim));
+            return null;
+          }
+        }
       }
       return new OAuthUserInfo(id.getAsString() /*externalId*/,
           null /*username*/,
@@ -133,30 +149,19 @@
     }
   }
 
-  /**
-   * @param token
-   * @return OpenID id token, when contained in id_token, null otherwise
-   */
-  private static String lookupClaimedIdentity(OAuthToken token) {
+  private JsonObject retrieveJWTToken(OAuthToken token) {
     JsonElement idToken =
-      OutputFormat.JSON.newGson().fromJson(token.getRaw(), JsonElement.class);
-    if (idToken.isJsonObject()) {
+        OutputFormat.JSON.newGson().fromJson(token.getRaw(), JsonElement.class);
+    if (idToken != null && idToken.isJsonObject()) {
       JsonObject idTokenObj = idToken.getAsJsonObject();
       JsonElement idTokenElement = idTokenObj.get("id_token");
-      if (!idTokenElement.isJsonNull()) {
+      if (idTokenElement != null && !idTokenElement.isJsonNull()) {
         String payload = decodePayload(idTokenElement.getAsString());
         if (!Strings.isNullOrEmpty(payload)) {
-          JsonElement openidIdToken =
+          JsonElement tokenJsonElement =
             OutputFormat.JSON.newGson().fromJson(payload, JsonElement.class);
-          if (openidIdToken.isJsonObject()) {
-            JsonObject openidIdObj = openidIdToken.getAsJsonObject();
-            JsonElement openidIdElement = openidIdObj.get("openid_id");
-            if (!openidIdElement.isJsonNull()) {
-              String openIdId = openidIdElement.getAsString();
-              log.debug("OAuth2: openid_id={}", openIdId);
-              return openIdId;
-            }
-            log.debug("OAuth2: JWT doesn't contain openid_id element");
+          if (tokenJsonElement.isJsonObject()) {
+            return tokenJsonElement.getAsJsonObject();
           }
         }
       }
@@ -164,6 +169,28 @@
     return null;
   }
 
+  private static String retrieveClaimedIdentity(JsonObject jwtToken) {
+    JsonElement openidIdElement = jwtToken.get("openid_id");
+    if (openidIdElement != null && !openidIdElement.isJsonNull()) {
+      String openIdId = openidIdElement.getAsString();
+      log.debug("OAuth2: openid_id={}", openIdId);
+      return openIdId;
+    }
+    log.debug("OAuth2: JWT doesn't contain openid_id element");
+    return null;
+  }
+
+  private static String retrieveHostedDomain(JsonObject jwtToken) {
+    JsonElement hdClaim = jwtToken.get("hd");
+    if (hdClaim != null && !hdClaim.isJsonNull()) {
+      String hd = hdClaim.getAsString();
+      log.debug("OAuth2: hd={}", hd);
+      return hd;
+    }
+    log.debug("OAuth2: JWT doesn't contain hd element");
+    return null;
+  }
+
   /**
    * Decode payload from JWT according to spec:
    * "header.payload.signature"
@@ -197,6 +224,10 @@
         url += "&openid.realm=" + URLEncoder.encode(canonicalWebUrl,
             StandardCharsets.UTF_8.name());
       }
+      if (!Strings.isNullOrEmpty(domain)) {
+        url += "&hd=" + URLEncoder.encode(domain,
+            StandardCharsets.UTF_8.name());
+      }
     } catch (UnsupportedEncodingException e) {
       throw new IllegalArgumentException(e);
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
index 9dcf725..14c60a9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
@@ -25,6 +25,7 @@
   static final String CLIENT_SECRET = "client-secret";
   static final String LINK_TO_EXISTING_OPENID_ACCOUNT =
       "link-to-existing-openid-accounts";
+  static final String DOMAIN = "domain";
 
   private final ConsoleUI ui;
   private final Section googleOAuthProviderSection;
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index d80a584..03a45bf 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -37,6 +37,21 @@
 
 to Google OAuth configuration section.
 
+It is possile to restrict sign-in to accounts of one (hosted) domain for
+Google OAuth. The `domain` option can be added:
+
+```
+plugin.gerrit-oauth-provider-google-oauth.domain = "mycollege.edu"
+```
+
+(See the spec)[https://developers.google.com/identity/protocols/OpenIDConnect#hd-param]
+for more information. To protect against client-side request modification, the returned
+ID token is checked to contain a matching hd claim (which is proof the account does belong
+to the hosted domain). If the hd claim wasn't included in ID token or didn't match the
+provided `domain` configuration option the authentication is rejected. Note: Because of
+current limitation of the OAuth extension point in gerrit (blame /me for that) the user
+would only see "Unauthorized" message.
+
 ## Obtaining provider authorizations
 
 ### Google