Support linking oauth identity to existing OpenID accounts
diff --git a/BUCK b/BUCK
index 3216e67..e90d89b 100644
--- a/BUCK
+++ b/BUCK
@@ -9,7 +9,11 @@
     'Gerrit-PluginName: gerrit-oauth-provider',
     'Gerrit-HttpModule: com.googlesource.gerrit.plugins.oauth.HttpModule',
   ],
-  provided_deps = ['//lib:gson'],
+  provided_deps = [
+    '//lib:guava',
+    '//lib:gson',
+    '//lib/commons:codec',
+  ],
   deps = [':scribe-oauth'],
 )
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/GitHubOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/GitHubOAuthService.java
index c5b3c50..d0e8f95 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/GitHubOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/GitHubOAuthService.java
@@ -14,17 +14,20 @@
 
 package com.googlesource.gerrit.plugins.oauth;
 
+import com.google.common.base.CharMatcher;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthVerifier;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import org.scribe.builder.ServiceBuilder;
@@ -50,14 +53,17 @@
 
   @Inject
   GitHubOAuthService(PluginConfigFactory cfgFactory,
-      @PluginName String pluginName) {
+      @PluginName String pluginName,
+      @CanonicalWebUrl Provider<String> urlProvider) {
     PluginConfig cfg = cfgFactory.getFromGerritConfig(
         pluginName + CONFIG_SUFFIX);
+    String canonicalWebUrl = CharMatcher.is('/').trimTrailingFrom(
+        urlProvider.get()) + "/";
     service = new ServiceBuilder()
         .provider(GitHub2Api.class)
         .apiKey(cfg.getString("client-id"))
         .apiSecret(cfg.getString("client-secret"))
-        .callback(cfg.getString("callback"))
+        .callback(canonicalWebUrl)
         .scope(SCOPE)
         .build();
   }
@@ -90,7 +96,8 @@
       return new OAuthUserInfo(id.getAsString(),
           login.isJsonNull() ? null : login.getAsString(),
           email.isJsonNull() ? null : email.getAsString(),
-          name.isJsonNull() ? null : name.getAsString());
+          name.isJsonNull() ? null : name.getAsString(),
+          null);
     } else {
         throw new IOException(String.format(
             "Invalid JSON '%s': not a JSON Object", userJson));
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 ed05b19..a73b3e0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/GoogleOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/GoogleOAuthService.java
@@ -14,19 +14,25 @@
 
 package com.googlesource.gerrit.plugins.oauth;
 
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthVerifier;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
+import org.apache.commons.codec.binary.Base64;
 import org.scribe.builder.ServiceBuilder;
 import org.scribe.model.OAuthRequest;
 import org.scribe.model.Response;
@@ -36,6 +42,9 @@
 import org.scribe.oauth.OAuthService;
 
 import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 
 import javax.servlet.http.HttpServletResponse;
 
@@ -43,29 +52,37 @@
 class GoogleOAuthService implements OAuthServiceProvider {
   static final String CONFIG_SUFFIX = "-google-oauth";
   private static final String PROTECTED_RESOURCE_URL =
-      "https://www.googleapis.com/userinfo/v2/me";
-  //"https://www.googleapis.com/plus/v1/people/me/openIdConnect";
-  // profile causes username to disappear
-  private static final String SCOPE = "email";
+      //"https://www.googleapis.com/userinfo/v2/me";
+      "https://www.googleapis.com/plus/v1/people/me/openIdConnect";
+  private static final String SCOPE = "email profile";
   private final OAuthService service;
+  private final String canonicalWebUrl;
+  private final boolean linkToExistingOpenIDAccounts;
 
   @Inject
   GoogleOAuthService(PluginConfigFactory cfgFactory,
-      @PluginName String pluginName) {
+      @PluginName String pluginName,
+      @CanonicalWebUrl Provider<String> urlProvider) {
     PluginConfig cfg = cfgFactory.getFromGerritConfig(
         pluginName + CONFIG_SUFFIX);
-    service = new ServiceBuilder()
+    this.canonicalWebUrl = CharMatcher.is('/').trimTrailingFrom(
+        urlProvider.get()) + "/";
+    this.linkToExistingOpenIDAccounts = cfg.getBoolean(
+        "link-to-existing-openid-accounts", false);
+    this.service = new ServiceBuilder()
         .provider(Google2Api.class)
         .apiKey(cfg.getString("client-id"))
         .apiSecret(cfg.getString("client-secret"))
-        .callback(cfg.getString("callback"))
-        .scope(SCOPE)
+        .callback(canonicalWebUrl + "oauth")
+        .scope(linkToExistingOpenIDAccounts
+            ? "openid " + SCOPE
+            : SCOPE)
         .build();
   }
 
   @Override
   public OAuthToken getRequestToken() {
-    throw new IllegalStateException();
+    throw new IllegalStateException("Not supported workflow in OAuth 2.0");
   }
 
   @Override
@@ -84,19 +101,73 @@
             JsonElement.class);
     if (userJson.isJsonObject()) {
       JsonObject jsonObject = userJson.getAsJsonObject();
+      JsonElement id = jsonObject.get("sub");
+      if (id.isJsonNull()) {
+        throw new IOException(String.format(
+            "Response doesn't contain id field"));
+      }
       JsonElement email = jsonObject.get("email");
       JsonElement name = jsonObject.get("name");
-      JsonElement id = jsonObject.get("id");
+      String claimedIdentifier = null;
+
+      if (linkToExistingOpenIDAccounts) {
+        claimedIdentifier = lookupClaimedIdentity(token);
+      }
       return new OAuthUserInfo(id.getAsString() /*externalId*/,
-          name.isJsonNull() ? null : name.getAsString() /*username*/,
+          null /*username*/,
           email.isJsonNull() ? null : email.getAsString() /*email*/,
-	      null /*displayName*/);
+          name.isJsonNull() ? null : name.getAsString() /*displayName*/,
+	      claimedIdentifier /*claimedIdentity*/);
     } else {
         throw new IOException(String.format(
             "Invalid JSON '%s': not a JSON Object", userJson));
     }
   }
 
+  /**
+   * @param token
+   * @return OpenID id token, when contained in id_token, null otherwise
+   */
+  private static String lookupClaimedIdentity(OAuthToken token) {
+    JsonElement idToken =
+      OutputFormat.JSON.newGson().fromJson(token.getRaw(), JsonElement.class);
+    if (idToken.isJsonObject()) {
+      JsonObject idTokenObj = idToken.getAsJsonObject();
+      JsonElement idTokenElement = idTokenObj.get("id_token");
+      if (!idTokenElement.isJsonNull()) {
+        String payload = decodePayload(idTokenElement.getAsString());
+        if (!Strings.isNullOrEmpty(payload)) {
+          JsonElement openidIdToken =
+            OutputFormat.JSON.newGson().fromJson(payload, JsonElement.class);
+          if (openidIdToken.isJsonObject()) {
+            JsonObject openidIdObj = openidIdToken.getAsJsonObject();
+            JsonElement openidIdElement = openidIdObj.get("openid_id");
+            if (!openidIdElement.isJsonNull()) {
+              return openidIdElement.getAsString();
+            }
+          }
+        }
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Decode payload from JWT according to spec:
+   * "header.payload.signature"
+   *
+   * @param idToken Base64 encoded tripple, separated with dot
+   * @return openid_id part of payload, when contained, null otherwise
+   */
+  private static String decodePayload(String idToken) {
+    Preconditions.checkNotNull(idToken);
+    String[] jwtParts = idToken.split("\\.");
+    Preconditions.checkState(jwtParts.length == 3);
+    String payloadStr = jwtParts[1];
+    Preconditions.checkNotNull(payloadStr);
+    return new String(Base64.decodeBase64(payloadStr));
+  }
+
   @Override
   public OAuthToken getAccessToken(OAuthToken rt,
       OAuthVerifier rv) {
@@ -108,7 +179,7 @@
     Token to = service.getAccessToken(ti, vi);
     OAuthToken result = new OAuthToken(to.getToken(),
         to.getSecret(), to.getRawResponse());
-    return result;
+     return result;
   }
 
   @Override
@@ -117,7 +188,17 @@
     if (rt != null) {
       ti = new Token(rt.getToken(), rt.getSecret(), rt.getRaw());
     }
-    return service.getAuthorizationUrl(ti);
+
+    String url = service.getAuthorizationUrl(ti);
+    try {
+      if (linkToExistingOpenIDAccounts) {
+        url += "&openid.realm=" + URLEncoder.encode(canonicalWebUrl,
+            StandardCharsets.UTF_8.name());
+      }
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalArgumentException(e);
+    }
+    return url;
   }
 
   @Override
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index ee18793..6605afa 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -16,13 +16,24 @@
   [plugin "@PLUGIN@-google-oauth"]
     client-id = "<client-id>"
     client-secret = "<client-secret>"
-    callback = "http://localhost:8080/oauth"
+    link-to-existing-openid-accounts = true
 
   [plugin "@PLUGIN@-github-oauth"]
     client-id = "<client-id>"
     client-secret = "<client-secret>"
-    callback = "http://localhost:8080/oauth"
 ```
 
-When one from the sections above is omitted, OAuth SSO is used. The login form with provider selection isn’t shown. When both sections are omitted, Gerrit will not start.
+When one from the sections above is omitted, OAuth SSO is used.
+The login form with provider selection isn’t shown. When both
+sections are omitted, Gerrit will not start.
+
+Google OAuth provider seamlessly supports linking of OAuth identity
+to existing OpenID accounts. This feature is deactivated by default.
+To activate it, add
+
+```
+plugin.gerrit-oauth-provider-google-oauth.link-to-existing-openid-accounts = true
+```
+
+to Google OAuth configuration section.