[SAP IAS] Optionally allow the Resource Owner Password Flow Change-Id: If8a57ac4d487fece946b76a49fddeba3d63bc192
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java index 5d31e8c..a029cec 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java
@@ -14,30 +14,87 @@ package com.googlesource.gerrit.plugins.oauth.sap; +import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME; +import static java.nio.charset.StandardCharsets.UTF_8; + import com.github.scribejava.core.model.OAuth2AccessToken; +import com.google.common.base.Splitter; +import com.google.gerrit.entities.Account; import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider; import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo; +import com.google.gerrit.server.account.externalids.ExternalId; +import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory; +import com.google.gerrit.server.account.externalids.ExternalIds; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; import com.google.inject.Inject; import com.google.inject.Singleton; +import com.googlesource.gerrit.plugins.oauth.OAuthPluginConfigFactory; import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig; +import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme; import java.io.IOException; +import java.util.Base64; +import java.util.List; +import java.util.Optional; @Singleton @OAuthServiceProviderConfig(name = SAPIasOAuthService.PROVIDER_NAME) public class SAPIasOAuthLoginProvider implements OAuthLoginProvider { + private static final String USER_NAME_ATTRIBUTE = "sub"; + private final SAPIasOAuthService service; + private final boolean enableResourceOwnerPasswordFlow; + private final ExternalIds externalIds; + private final ExternalIdKeyFactory externalIdKeyFactory; + private final String extIdScheme; @Inject - SAPIasOAuthLoginProvider(SAPIasOAuthService service) { + SAPIasOAuthLoginProvider( + SAPIasOAuthService service, + OAuthPluginConfigFactory cfgFactory, + ExternalIds externalIds, + ExternalIdKeyFactory externalIdKeyFactory) { this.service = service; + this.enableResourceOwnerPasswordFlow = + cfgFactory + .create(SAPIasOAuthService.PROVIDER_NAME) + .getBoolean("enable-resource-owner-password-flow", false); + this.externalIds = externalIds; + this.externalIdKeyFactory = externalIdKeyFactory; + this.extIdScheme = + OAuthServiceProviderExternalIdScheme.create(SAPIasOAuthService.PROVIDER_NAME); } @Override - public OAuthUserInfo login(String username, String token) throws IOException { - if (token == null) { + public OAuthUserInfo login(String username, String secret) throws IOException { + if (secret == null) { throw new IOException("Authentication error"); } - OAuth2AccessToken accessToken = new OAuth2AccessToken(token); + OAuth2AccessToken accessToken; + if (isAccessToken(secret)) { + accessToken = new OAuth2AccessToken(secret); + } else if (enableResourceOwnerPasswordFlow) { + if (username == null) { + throw new IOException("Authentication error"); + } + Optional<Account.Id> accountId = + externalIds + .get(externalIdKeyFactory.create(SCHEME_USERNAME, username)) + .map(ExternalId::accountId); + if (accountId.isEmpty()) { + throw new IOException("Authentication error"); + } + ExternalId extId = + externalIds.byAccount(accountId.get()).stream() + .filter(e -> e.key().isScheme(this.extIdScheme)) + .findAny() + .orElseThrow(() -> new IOException("Authentication error")); + accessToken = service.getAccessToken(extId.email(), secret); + } else { + throw new IOException("Authentication error"); + } OAuthUserInfo userInfo = service.getUserInfo(accessToken); // A username does not have to be provided, but if it is, it should match // the username provided by the IDP to prevent confusion. The username is @@ -48,4 +105,51 @@ } return userInfo; } + + private boolean isAccessToken(String accessToken) { + try { + JsonObject jsonWebToken = toJsonWebToken(accessToken); + return getAttribute(jsonWebToken, USER_NAME_ATTRIBUTE) != null; + } catch (IOException e) { + return false; + } + } + + private static String getAttribute(JsonObject json, String name) { + JsonPrimitive prim = getAsJsonPrimitive(json, name); + return prim != null && prim.isString() ? prim.getAsString() : null; + } + + private static JsonPrimitive getAsJsonPrimitive(JsonObject json, String name) { + JsonElement attr = json.get(name); + if (attr == null || !attr.isJsonPrimitive()) { + return null; + } + return attr.getAsJsonPrimitive(); + } + + public static JsonObject getAsJsonObject(String s) { + JsonElement json = JsonParser.parseString(s); + if (!json.isJsonObject()) { + return new JsonObject(); + } + return json.getAsJsonObject(); + } + + private JsonObject toJsonWebToken(String accessToken) throws IOException { + List<String> segments = getSegments(accessToken); + return getAsJsonObject(decodeBase64(segments.get(1))); + } + + private String decodeBase64(String s) { + return new String(Base64.getDecoder().decode(s), UTF_8); + } + + private List<String> getSegments(String accessToken) throws IOException { + List<String> segments = Splitter.on('.').splitToList(accessToken); + if (segments.size() != 3) { + throw new IOException("Invalid token: must be of the form 'header.token.signature'"); + } + return segments; + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java index 78a3739..a41c500 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java +++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthService.java
@@ -127,6 +127,16 @@ return authorizationUrlBuilder.build(); } + public OAuth2AccessToken getAccessToken(String externalUsername, String password) { + try { + return service.getAccessTokenPasswordGrant(externalUsername, password); + } catch (IOException | InterruptedException | ExecutionException e) { + String msg = "Cannot retrieve access token"; + log.error(msg, e); + throw new RuntimeException(msg, e); + } + } + @Override public String getVersion() { return service.getVersion();
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index addd12e..3ce8f25 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -99,6 +99,7 @@ client-secret = "<client-secret>" link-to-existing-gerrit-accounts = false enable-pkce = false + enable-resource-owner-password-flow = false ``` When one from the sections above is omitted, OAuth SSO is used. @@ -368,3 +369,8 @@ You can optionally set `enable-pkce = true` if you want to use PKCE as part of the authorization workflow during login. + +If login via password for git over HTTp or REST API should still be possible, the Resource +Owner Password Flow can optionally be enabled. This OAuth flow lets the user send the password +and Gerrit will use that password to authenticate the user against the OAuth server. Note, that +this flow is not considered to be secure. To enable this flow set `enable-resource-owner-password-flow = true`.