diff --git a/.travis.yml b/.travis.yml index db305c1..f9b6f1c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,7 +29,7 @@ install: true before_install: - OS=linux - ARCH=x86_64 - - V=0.8.1 + - V=0.9.0 - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then OS=darwin; fi - GH_BASE="https://github.com/bazelbuild/bazel/releases/download/$V" - GH_ARTIFACT="bazel-$V-installer-$OS-$ARCH.sh" diff --git a/README.md b/README.md index df3506e..aa8daa5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Supported OAuth providers: * [GitLab](https://about.gitlab.com/) * [Google](https://developers.google.com/identity/protocols/OAuth2) * [Keycloak](http://www.keycloak.org/) +* [Office365](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols) See the [Wiki](https://github.com/davido/gerrit-oauth-provider/wiki) what it can do for you. diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java index 06d2f90..d51d918 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java @@ -91,5 +91,12 @@ protected void configureServlets() { .annotatedWith(Exports.named(KeycloakOAuthService.CONFIG_SUFFIX)) .to(KeycloakOAuthService.class); } + + cfg = cfgFactory.getFromGerritConfig(pluginName + Office365OAuthService.CONFIG_SUFFIX); + if (cfg.getString(InitOAuth.CLIENT_ID) != null) { + bind(OAuthServiceProvider.class) + .annotatedWith(Exports.named(Office365OAuthService.CONFIG_SUFFIX)) + .to(Office365OAuthService.class); + } } } 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 3d10f72..b8e54e4 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java @@ -41,6 +41,7 @@ class InitOAuth implements InitStep { private final Section gitlabOAuthProviderSection; private final Section dexOAuthProviderSection; private final Section keycloakOAuthProviderSection; + private final Section office365OAuthProviderSection; @Inject InitOAuth(ConsoleUI ui, Section.Factory sections, @PluginName String pluginName) { @@ -61,6 +62,8 @@ class InitOAuth implements InitStep { sections.get(PLUGIN_SECTION, pluginName + DexOAuthService.CONFIG_SUFFIX); this.keycloakOAuthProviderSection = sections.get(PLUGIN_SECTION, pluginName + KeycloakOAuthService.CONFIG_SUFFIX); + this.office365OAuthProviderSection = + sections.get(PLUGIN_SECTION, pluginName + Office365OAuthService.CONFIG_SUFFIX); } @Override @@ -122,6 +125,12 @@ public void run() throws Exception { keycloakOAuthProviderSection.string("Keycloak Realm", REALM, null); configureOAuth(keycloakOAuthProviderSection); } + + boolean configureOffice365OAuthProvider = + ui.yesno(true, "Use Office365 OAuth provider for Gerrit login ?"); + if (configureOffice365OAuthProvider) { + configureOAuth(office365OAuthProviderSection); + } } private void configureOAuth(Section s) { diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/Office365Api.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/Office365Api.java new file mode 100644 index 0000000..12dd12f --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/Office365Api.java @@ -0,0 +1,82 @@ +// Copyright (C) 2018 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.googlesource.gerrit.plugins.oauth; + +import static org.scribe.utils.OAuthEncoder.encode; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.scribe.builder.api.DefaultApi20; +import org.scribe.exceptions.OAuthException; +import org.scribe.extractors.AccessTokenExtractor; +import org.scribe.model.OAuthConfig; +import org.scribe.model.Token; +import org.scribe.model.Verb; +import org.scribe.oauth.OAuthService; +import org.scribe.utils.Preconditions; + +public class Office365Api extends DefaultApi20 { + private static final String AUTHORIZE_URL = + "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=%s"; + + @Override + public String getAccessTokenEndpoint() { + return "https://login.microsoftonline.com/organizations/oauth2/v2.0/token"; + } + + @Override + public String getAuthorizationUrl(OAuthConfig config) { + Preconditions.checkValidUrl( + config.getCallback(), + "Must provide a valid url as callback. Office365 does not support OOB"); + Preconditions.checkEmptyString( + config.getScope(), + "Must provide a valid value as scope. Office365 does not support no scope"); + + return String.format( + AUTHORIZE_URL, config.getApiKey(), encode(config.getCallback()), encode(config.getScope())); + } + + @Override + public Verb getAccessTokenVerb() { + return Verb.POST; + } + + @Override + public OAuthService createService(OAuthConfig config) { + return new OAuth20ServiceImpl(this, config); + } + + @Override + public AccessTokenExtractor getAccessTokenExtractor() { + return new Office365JsonTokenExtractor(); + } + + private static final class Office365JsonTokenExtractor implements AccessTokenExtractor { + private Pattern accessTokenPattern = Pattern.compile("\"access_token\"\\s*:\\s*\"(\\S*?)\""); + + @Override + public Token extract(String response) { + Preconditions.checkEmptyString( + response, "Cannot extract a token from a null or empty String"); + Matcher matcher = accessTokenPattern.matcher(response); + if (matcher.find()) { + return new Token(matcher.group(1), "", response); + } + + throw new OAuthException("Cannot extract an acces token. Response was: " + response); + } + } +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/Office365OAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/Office365OAuthService.java new file mode 100644 index 0000000..360b650 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/Office365OAuthService.java @@ -0,0 +1,143 @@ +// Copyright (C) 2018 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.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 java.io.IOException; +import javax.servlet.http.HttpServletResponse; +import org.scribe.builder.ServiceBuilder; +import org.scribe.model.OAuthRequest; +import org.scribe.model.Response; +import org.scribe.model.Token; +import org.scribe.model.Verb; +import org.scribe.model.Verifier; +import org.scribe.oauth.OAuthService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +class Office365OAuthService implements OAuthServiceProvider { + private static final Logger log = LoggerFactory.getLogger(Office365OAuthService.class); + static final String CONFIG_SUFFIX = "-office365-oauth"; + private static final String OFFICE365_PROVIDER_PREFIX = "office365-oauth:"; + private static final String PROTECTED_RESOURCE_URL = "https://graph.microsoft.com/v1.0/me"; + private static final String SCOPE = + "openid offline_access https://graph.microsoft.com/user.readbasic.all"; + private final OAuthService service; + private final String canonicalWebUrl; + private final boolean useEmailAsUsername; + + @Inject + Office365OAuthService( + PluginConfigFactory cfgFactory, + @PluginName String pluginName, + @CanonicalWebUrl Provider urlProvider) { + PluginConfig cfg = cfgFactory.getFromGerritConfig(pluginName + CONFIG_SUFFIX); + this.canonicalWebUrl = CharMatcher.is('/').trimTrailingFrom(urlProvider.get()) + "/"; + this.useEmailAsUsername = cfg.getBoolean(InitOAuth.USE_EMAIL_AS_USERNAME, false); + this.service = + new ServiceBuilder() + .provider(Office365Api.class) + .apiKey(cfg.getString(InitOAuth.CLIENT_ID)) + .apiSecret(cfg.getString(InitOAuth.CLIENT_SECRET)) + .callback(canonicalWebUrl + "oauth") + .scope(SCOPE) + .build(); + if (log.isDebugEnabled()) { + log.debug("OAuth2: canonicalWebUrl={}", canonicalWebUrl); + log.debug("OAuth2: scope={}", SCOPE); + log.debug("OAuth2: useEmailAsUsername={}", useEmailAsUsername); + } + } + + @Override + public OAuthUserInfo getUserInfo(OAuthToken token) throws IOException { + OAuthRequest request = new OAuthRequest(Verb.GET, PROTECTED_RESOURCE_URL); + request.addHeader("Accept", "*/*"); + request.addHeader("Authorization", "Bearer " + token.getToken()); + Response response = request.send(); + if (response.getCode() != HttpServletResponse.SC_OK) { + throw new IOException( + String.format( + "Status %s (%s) for request %s", + response.getCode(), response.getBody(), request.getUrl())); + } + JsonElement userJson = + OutputFormat.JSON.newGson().fromJson(response.getBody(), JsonElement.class); + if (log.isDebugEnabled()) { + log.debug("User info response: {}", response.getBody()); + } + if (userJson.isJsonObject()) { + JsonObject jsonObject = userJson.getAsJsonObject(); + JsonElement id = jsonObject.get("id"); + if (id == null || id.isJsonNull()) { + throw new IOException(String.format("Response doesn't contain id field")); + } + JsonElement email = jsonObject.get("mail"); + JsonElement name = jsonObject.get("displayName"); + String login = null; + + if (useEmailAsUsername && !email.isJsonNull()) { + login = email.getAsString().split("@")[0]; + } + return new OAuthUserInfo( + OFFICE365_PROVIDER_PREFIX + id.getAsString() /*externalId*/, + login /*username*/, + email == null || email.isJsonNull() ? null : email.getAsString() /*email*/, + name == null || name.isJsonNull() ? null : name.getAsString() /*displayName*/, + null); + } + + throw new IOException(String.format("Invalid JSON '%s': not a JSON Object", userJson)); + } + + @Override + public OAuthToken getAccessToken(OAuthVerifier rv) { + Verifier vi = new Verifier(rv.getValue()); + Token to = service.getAccessToken(null, vi); + OAuthToken result = new OAuthToken(to.getToken(), to.getSecret(), to.getRawResponse()); + return result; + } + + @Override + public String getAuthorizationUrl() { + String url = service.getAuthorizationUrl(null); + return url; + } + + @Override + public String getVersion() { + return service.getVersion(); + } + + @Override + public String getName() { + return "Office365 OAuth2"; + } +}