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.