Add Authentik OAuth provider This adds a OAuth provider for Authentik [1]. This adds a configuration option named link-to-existing-gerrit-accounts that can be set to true to migrate to this provider from existing LDAP accounts by linking them with externalIDs instead of trying to create new accounts. [1] https://goauthentik.io Change-Id: Ic5c46320267fc6cc5416370382c7387666ce8466
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/AuthentikApi.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/AuthentikApi.java new file mode 100644 index 0000000..249f85f --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/AuthentikApi.java
@@ -0,0 +1,38 @@ +// Copyright (C) 2023 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.github.scribejava.core.builder.api.DefaultApi20; + +public class AuthentikApi extends DefaultApi20 { + private static final String AUTHORIZE_URL = "%s/application/o/authorize/"; + private static final String ACCESS_TOKEN_URL = "%s/application/o/token/"; + + private final String rootUrl; + + public AuthentikApi(String rootUrl) { + this.rootUrl = rootUrl; + } + + @Override + public String getAccessTokenEndpoint() { + return String.format(ACCESS_TOKEN_URL, rootUrl); + } + + @Override + protected String getAuthorizationBaseUrl() { + return String.format(AUTHORIZE_URL, rootUrl); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/AuthentikOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/AuthentikOAuthService.java new file mode 100644 index 0000000..df810a1 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/AuthentikOAuthService.java
@@ -0,0 +1,143 @@ +// Copyright (C) 2023 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 com.google.gerrit.json.OutputFormat.JSON; + +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.model.OAuthRequest; +import com.github.scribejava.core.model.Response; +import com.github.scribejava.core.model.Verb; +import com.github.scribejava.core.oauth.OAuth20Service; +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.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.ProvisionException; +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.ExecutionException; +import javax.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AuthentikOAuthService implements OAuthServiceProvider { + private static final Logger log = LoggerFactory.getLogger(AuthentikOAuthService.class); + private static final String AUTHENTIK_PROVIDER_PREFIX = "authentik-oauth:"; + static final String CONFIG_SUFFIX = "-authentik-oauth"; + private static final String PROTECTED_RESOURCE_URL = "%s/application/o/userinfo/"; + private final OAuth20Service service; + private final String serviceName; + private final String rootUrl; + private final boolean linkExistingGerrit; + + @Inject + AuthentikOAuthService( + PluginConfigFactory cfgFactory, + @PluginName String pluginName, + @CanonicalWebUrl Provider<String> urlProvider) { + PluginConfig cfg = cfgFactory.getFromGerritConfig(pluginName + CONFIG_SUFFIX); + String canonicalWebUrl = CharMatcher.is('/').trimTrailingFrom(urlProvider.get()) + "/"; + + rootUrl = cfg.getString(InitOAuth.ROOT_URL); + if (!URI.create(rootUrl).isAbsolute()) { + throw new ProvisionException("Root URL must be absolute URL"); + } + serviceName = cfg.getString(InitOAuth.SERVICE_NAME, "Authentik"); + linkExistingGerrit = cfg.getBoolean(InitOAuth.LINK_TO_EXISTING_GERRIT_ACCOUNT, false); + + service = + new ServiceBuilder(cfg.getString(InitOAuth.CLIENT_ID)) + .apiSecret(cfg.getString(InitOAuth.CLIENT_SECRET)) + .callback(canonicalWebUrl + "oauth") + .defaultScope("openid profile email") + .build(new AuthentikApi(rootUrl)); + } + + @Override + public OAuthUserInfo getUserInfo(OAuthToken token) throws IOException { + OAuthRequest request = + new OAuthRequest(Verb.GET, String.format(PROTECTED_RESOURCE_URL, rootUrl)); + OAuth2AccessToken t = new OAuth2AccessToken(token.getToken(), token.getRaw()); + service.signRequest(t, request); + + try (Response response = service.execute(request)) { + 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 = JSON.newGson().fromJson(response.getBody(), JsonElement.class); + if (log.isDebugEnabled()) { + log.debug("User info response: {}", response.getBody()); + } + JsonObject jsonObject = userJson.getAsJsonObject(); + if (jsonObject == null || jsonObject.isJsonNull()) { + throw new IOException("Response doesn't contain 'user' field" + jsonObject); + } + JsonElement id = jsonObject.get("sub"); + JsonElement username = jsonObject.get("preferred_username"); + JsonElement email = jsonObject.get("email"); + JsonElement name = jsonObject.get("name"); + return new OAuthUserInfo( + AUTHENTIK_PROVIDER_PREFIX + id.getAsString() /*externalId*/, + username == null || username.isJsonNull() ? null : username.getAsString() /*username*/, + email == null || email.isJsonNull() ? null : email.getAsString() /*email*/, + name == null || name.isJsonNull() ? null : name.getAsString() /*displayName*/, + linkExistingGerrit ? "gerrit:" + username.getAsString() : null /*claimedIdentity*/); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException("Cannot retrieve user info resource", e); + } + } + + @Override + public OAuthToken getAccessToken(OAuthVerifier verifier) { + try { + OAuth2AccessToken accessToken = service.getAccessToken(verifier.getValue()); + return new OAuthToken( + accessToken.getAccessToken(), accessToken.getTokenType(), accessToken.getRawResponse()); + } catch (InterruptedException | ExecutionException | IOException e) { + String msg = "Cannot retrieve access token"; + log.error(msg, e); + throw new RuntimeException(msg, e); + } + } + + @Override + public String getAuthorizationUrl() { + return service.getAuthorizationUrl(); + } + + @Override + public String getVersion() { + return service.getVersion(); + } + + @Override + public String getName() { + return serviceName; + } +}
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 5b6a68a..adfde3a 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/HttpModule.java
@@ -151,5 +151,12 @@ .annotatedWith(Exports.named(Auth0OAuthService.CONFIG_SUFFIX)) .to(Auth0OAuthService.class); } + + cfg = cfgFactory.getFromGerritConfig(pluginName + AuthentikOAuthService.CONFIG_SUFFIX); + if (cfg.getString(InitOAuth.CLIENT_ID) != null) { + bind(OAuthServiceProvider.class) + .annotatedWith(Exports.named(AuthentikOAuthService.CONFIG_SUFFIX)) + .to(AuthentikOAuthService.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 101c1f1..2eac861 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
@@ -37,6 +37,7 @@ static final String REALM = "realm"; static final String TENANT = "tenant"; static final String LINK_TO_EXISTING_OFFICE365_ACCOUNT = "link-to-existing-office365-accounts"; + static final String LINK_TO_EXISTING_GERRIT_ACCOUNT = "link-to-existing-gerrit-accounts"; static final String SERVICE_NAME = "service-name"; static String FIX_LEGACY_USER_ID_QUESTION = "Fix legacy user id, without oauth provider prefix?"; @@ -56,6 +57,7 @@ private final Section phabricatorOAuthProviderSection; private final Section tuleapOAuthProviderSection; private final Section auth0OAuthProviderSection; + private final Section authentikOAuthProviderSection; @Inject InitOAuth(ConsoleUI ui, Section.Factory sections, @PluginName String pluginName) { @@ -90,6 +92,8 @@ sections.get(PLUGIN_SECTION, pluginName + TuleapOAuthService.CONFIG_SUFFIX); this.auth0OAuthProviderSection = sections.get(PLUGIN_SECTION, pluginName + Auth0OAuthService.CONFIG_SUFFIX); + this.authentikOAuthProviderSection = + sections.get(PLUGIN_SECTION, pluginName + AuthentikOAuthService.CONFIG_SUFFIX); } @Override @@ -224,6 +228,16 @@ if (configureAuth0OAuthProvider && configureOAuth(auth0OAuthProviderSection)) { checkRootUrl(auth0OAuthProviderSection.string("Auth0 Root URL", ROOT_URL, null)); } + + boolean configureAuthentikOAuthProvider = + ui.yesno( + isConfigured(authentikOAuthProviderSection), + "Use Authentik OAuth provider for Gerrit login ?"); + if (configureAuthentikOAuthProvider && configureOAuth(authentikOAuthProviderSection)) { + checkRootUrl(authentikOAuthProviderSection.string("Authentik Root URL", ROOT_URL, null)); + authentikOAuthProviderSection.string( + "Link to existing gerrit accounts?", LINK_TO_EXISTING_GERRIT_ACCOUNT, "false"); + } } /**
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index 2059c18..ee8f852 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -81,6 +81,12 @@ root-url = "<root url>" # for example, https://dev-abc.us.auth0.com client-id = "<client-id>" client-secret = "<client-secret>" + + [plugin "@PLUGIN@-authentik-oauth"] + root-url = "<root url>" # for example, https://authentik.example.com + client-id = "<client-id>" + client-secret = "<client-secret>" + link-to-existing-gerrit-accounts = false ``` When one from the sections above is omitted, OAuth SSO is used. @@ -312,3 +318,19 @@ You can optionally set `use-preferred-username = false` if you would prefer to not have the `preferred_username` token be automatically set as the users username, and instead let users choose their own usernames. + +### Authentik + +When setting up a Application in Authentik for Gerrit use the `OAuth2/OpenID Provider` type. + +You can optionally set `link-to-existing-gerrit-accounts = true` if you want the provider to link a account based +on the username instead of trying to create a new account, see below migration from LDAP. + +#### Migrating from LDAP + +Set the `link-to-existing-gerrit-accounts = true` option. + +If you have used LDAP before and have accounts with an externalIDs like `gerrit:firstname.lastname` and a user in Authentik +with username `firstname.lastname` logs in it will link the Authentik account to that Gerrit account. + +When all users has logged in once in Gerrit with their Authentik account it's recommended that the configuration option is removed.