[Cognito OAuth]Support for link-to-existing-gerrit-accounts

This adds support for link-to-existing-gerrit-accounts to Cognito
service in OAuth plugin.

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.

Change-Id: I9724cf13efe7026f6959b5e585b9c465063cdf82
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/CognitoOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/CognitoOAuthService.java
index eee5a94..bcd2126 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/CognitoOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/CognitoOAuthService.java
@@ -53,6 +53,7 @@
   private final String rootUrl;
   private final OAuth20Service service;
   private final String serviceName;
+  private final boolean linkExistingGerrit;
 
   @Inject
   CognitoOAuthService(
@@ -68,6 +69,7 @@
     }
 
     serviceName = cfg.getString(InitOAuth.SERVICE_NAME, "Cognito");
+    linkExistingGerrit = cfg.getBoolean(InitOAuth.LINK_TO_EXISTING_GERRIT_ACCOUNT, false);
 
     service =
         new ServiceBuilder(cfg.getString(InitOAuth.CLIENT_ID))
@@ -110,7 +112,7 @@
           asString(username),
           asString(email),
           asString(name),
-          null);
+          linkExistingGerrit ? "gerrit:" + username.getAsString() : null /*claimedIdentity*/);
     } catch (ExecutionException | InterruptedException e) {
       throw new RuntimeException("Cannot retrieve user info resource", e);
     }
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 183f46e..a292b74 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/InitOAuth.java
@@ -248,6 +248,8 @@
             "Use Cognito OAuth provider for Gerrit login ?");
     if (configureCognitoOAuthProvider && configureOAuth(cognitoOAuthProviderSection)) {
       checkRootUrl(cognitoOAuthProviderSection.string("Cognito Root URL", ROOT_URL, null));
+      cognitoOAuthProviderSection.string(
+          "Link to existing Gerrit LDAP accounts?", LINK_TO_EXISTING_GERRIT_ACCOUNT, "false");
     }
   }
 
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 2ebd021..2373dec 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -336,11 +336,14 @@
 The client-id and secret-id can be obtained in the AWS Cognito web interface once you create a new App Integration for Gerrit.
 See [Creating an app integration](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-configuring-app-integration.html).
 
-#### Migrating from LDAP
+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 to Authentik/Cognito
 
 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.
+If you have used LDAP before and have accounts with an externalIDs like `gerrit:firstname.lastname` and a user in Authentik/Cognito
+with username `firstname.lastname` logs in it will link the Authentik/Cognito 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.
+When all users has logged in once in Gerrit with their Authentik/Cognito account it's recommended that the configuration option is removed.
\ No newline at end of file
diff --git a/src/test/java/com/googlesource/gerrit/plugins/oauth/CognitoApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/oauth/CognitoApiTest.java
new file mode 100644
index 0000000..bf97a70
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/oauth/CognitoApiTest.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2025 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.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Test;
+
+public class CognitoApiTest {
+
+    private CognitoApi api;
+    private static final String TEST_ROOT_URL = "https://test.example.com";
+
+    @Before
+    public void setUp() {
+        api = new CognitoApi(TEST_ROOT_URL);
+    }
+
+    @Test
+    public void testGetAccessTokenEndpoint() {
+        assertThat(api.getAccessTokenEndpoint()).isEqualTo(TEST_ROOT_URL + "/oauth2/token/");
+    }
+
+    @Test
+    public void testGetAuthorizationBaseUrl() {
+        assertThat(api.getAuthorizationBaseUrl()).isEqualTo(TEST_ROOT_URL + "/oauth2/authorize/");
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/com/googlesource/gerrit/plugins/oauth/CognitoOAuthServiceTest.java b/src/test/java/com/googlesource/gerrit/plugins/oauth/CognitoOAuthServiceTest.java
new file mode 100644
index 0000000..6b10e78
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/oauth/CognitoOAuthServiceTest.java
@@ -0,0 +1,195 @@
+// Copyright (C) 2025 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.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.github.scribejava.core.model.OAuthRequest;
+import com.github.scribejava.core.model.Response;
+import com.github.scribejava.core.oauth.OAuth20Service;
+import com.google.gerrit.extensions.auth.oauth.OAuthToken;
+import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.inject.Provider;
+
+import java.lang.reflect.Field;
+import javax.servlet.http.HttpServletResponse;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class CognitoOAuthServiceTest {
+
+  // Mocks for constructor dependencies of CognitoOAuthService
+  @Mock private PluginConfigFactory mockConfigFactory;
+  @Mock private PluginConfig mockPluginConfig;
+  @Mock private Provider<String> mockUrlProvider;
+
+  // This is the ScribeJava service we want to mock
+  @Mock private OAuth20Service mockScribeOAuthService;
+
+  // Constants for configuration values
+  private static final String TEST_PLUGIN_NAME = "gerrit-oauth-provider-cognito";
+  private static final String TEST_CANONICAL_WEB_URL = "http://gerrit.example.com";
+  private static final String TEST_COGNITO_ROOT_URL =
+      "https://cognito-idp.us-east-1.amazonaws.com/USER_POOL_ID";
+  private static final String TEST_CLIENT_ID = "test-client-id-123";
+  private static final String TEST_CLIENT_SECRET = "test-client-secret-abc";
+  private static final String DEFAULT_SERVICE_NAME = "Cognito";
+
+  // User details from Cognito
+  private static final String COGNITO_USER_ID = "abcdef-12345-uuid";
+  private static final String COGNITO_USERNAME = "jane.doe"; // This is the expected username
+  private static final String COGNITO_EMAIL = "jane.doe@example.com";
+  private static final String COGNITO_NAME = "Jane Doe"; // This is the expected display name
+
+  // Define the prefix locally in the test, mirroring CognitoOAuthService
+  private static final String COGNITO_PROVIDER_PREFIX_FOR_TEST = "cognito-oauth:";
+
+  @Before
+  public void setUp() throws Exception {
+    // Mock the PluginConfigFactory to return our mockPluginConfig
+    when(mockConfigFactory.getFromGerritConfig(
+            TEST_PLUGIN_NAME + CognitoOAuthService.CONFIG_SUFFIX))
+        .thenReturn(mockPluginConfig);
+
+    // Mock the CanonicalWebUrl provider
+    when(mockUrlProvider.get()).thenReturn(TEST_CANONICAL_WEB_URL);
+
+    // Configure the mockPluginConfig with necessary values for CognitoOAuthService
+    // constructor
+    when(mockPluginConfig.getString(InitOAuth.ROOT_URL)).thenReturn(TEST_COGNITO_ROOT_URL);
+    when(mockPluginConfig.getString(InitOAuth.CLIENT_ID)).thenReturn(TEST_CLIENT_ID);
+    when(mockPluginConfig.getString(InitOAuth.CLIENT_SECRET)).thenReturn(TEST_CLIENT_SECRET);
+    when(mockPluginConfig.getString(InitOAuth.SERVICE_NAME, DEFAULT_SERVICE_NAME))
+        .thenReturn(DEFAULT_SERVICE_NAME);
+  }
+
+  /**
+   * Helper method to create an instance of CognitoOAuthService with a mocked internal
+   * OAuth20Service. This allows testing the logic of CognitoOAuthService in isolation.
+   *
+   * @param linkExistingGerritAccounts The value for the 'link-to-existing-gerrit-account' config.
+   * @return An instance of CognitoOAuthService with the ScribeJava service mocked.
+   * @throws Exception If reflection fails.
+   */
+  private CognitoOAuthService createServiceAndInjectMock(boolean linkExistingGerritAccounts)
+      throws Exception {
+    // Configure the specific 'link-to-existing-gerrit-account' for this instance
+    when(mockPluginConfig.getBoolean(InitOAuth.LINK_TO_EXISTING_GERRIT_ACCOUNT, false))
+        .thenReturn(linkExistingGerritAccounts);
+
+    CognitoOAuthService serviceInstance =
+        new CognitoOAuthService(mockConfigFactory, TEST_PLUGIN_NAME, mockUrlProvider);
+
+    // Replace the internal OAuth20Service with our mock using reflection.
+    Field serviceField = CognitoOAuthService.class.getDeclaredField("service");
+    serviceField.setAccessible(true); // Allow modification of the private final field
+    serviceField.set(serviceInstance, mockScribeOAuthService); // Inject our mock
+    return serviceInstance;
+  }
+
+  /**
+   * Helper method to mock the HTTP response from Cognito's user info endpoint.
+   *
+   * @param userId The 'sub' (subject) ID from Cognito.
+   * @param username The 'preferred_username' from Cognito. Can be null.
+   * @param email The 'email' from Cognito. Can be null.
+   * @param name The 'name' from Cognito. Can be null.
+   * @throws Exception If mocking fails.
+   */
+  private void mockCognitoUserInfoResponse(
+      String userId, String username, String email, String name) throws Exception {
+    // Construct the JSON response string. Handles nulls correctly for JSON.
+    String cognitoJsonResponse =
+        String.format(
+            "{\"sub\":\"%s\",\"preferred_username\":%s,\"email\":%s,\"name\":%s}",
+            userId, // 'sub' should always be a non-null string
+            username == null ? "null" : "\"" + username + "\"",
+            email == null ? "null" : "\"" + email + "\"",
+            name == null ? "null" : "\"" + name + "\"");
+
+    Response mockHttpResponse = mock(Response.class);
+    when(mockHttpResponse.getCode()).thenReturn(HttpServletResponse.SC_OK);
+    // Simulate successful HTTP 200 OK
+    when(mockHttpResponse.getBody()).thenReturn(cognitoJsonResponse);
+
+    // Configure the mockScribeOAuthService to return this mockHttpResponse
+    when(mockScribeOAuthService.execute(any(OAuthRequest.class))).thenReturn(mockHttpResponse);
+  }
+
+  /**
+   * Test Case 1: linkExistingGerrit=true, username is VALID. Expects claimedIdentity to be
+   * "gerrit:{username}".
+   */
+  @Test
+  public void getUserInfo_linkTrue_validUsername_shouldSetClaimedIdentity() throws Exception {
+    // --- ARRANGE ---
+    CognitoOAuthService service = createServiceAndInjectMock(true);
+    // linkExistingGerrit = true
+    mockCognitoUserInfoResponse(COGNITO_USER_ID, COGNITO_USERNAME, COGNITO_EMAIL, COGNITO_NAME);
+    OAuthToken inputToken =
+        new OAuthToken("dummyAccessToken", "dummySecretForTest", "dummyRawResponse");
+
+    // --- ACT ---
+    OAuthUserInfo userInfo = service.getUserInfo(inputToken);
+
+    // --- ASSERT ---
+    // Primary assertion for this test case
+    assertThat(userInfo.getClaimedIdentity()).isEqualTo("gerrit:" + COGNITO_USERNAME);
+
+    // Secondary assertions for completeness of OAuthUserInfo object
+    assertThat(userInfo).isNotNull();
+    assertThat(userInfo.getExternalId())
+        .isEqualTo(COGNITO_PROVIDER_PREFIX_FOR_TEST + COGNITO_USER_ID);
+    assertThat(userInfo.getUserName()).isEqualTo(COGNITO_USERNAME);
+    assertThat(userInfo.getDisplayName()).isEqualTo(COGNITO_NAME);
+    assertThat(userInfo.getEmailAddress()).isEqualTo(COGNITO_EMAIL);
+  }
+
+  /**
+   * Test Case 2: linkExistingGerrit=false, username is VALID. Expects claimedIdentity to be null.
+   */
+  @Test
+  public void getUserInfo_linkFalse_validUsername_shouldSetClaimedIdentityNull() throws Exception {
+    // --- ARRANGE ---
+    CognitoOAuthService service = createServiceAndInjectMock(false);
+    // linkExistingGerrit = false
+    mockCognitoUserInfoResponse(COGNITO_USER_ID, COGNITO_USERNAME, COGNITO_EMAIL, COGNITO_NAME);
+    OAuthToken inputToken =
+        new OAuthToken("dummyAccessToken", "dummySecretForTest", "dummyRawResponse");
+
+    // --- ACT ---
+    OAuthUserInfo userInfo = service.getUserInfo(inputToken);
+
+    // --- ASSERT ---
+    // Primary assertion for this test case
+    assertThat(userInfo.getClaimedIdentity()).isNull();
+
+    // Secondary assertions
+    assertThat(userInfo).isNotNull();
+    assertThat(userInfo.getExternalId())
+        .isEqualTo(COGNITO_PROVIDER_PREFIX_FOR_TEST + COGNITO_USER_ID);
+    assertThat(userInfo.getUserName()).isEqualTo(COGNITO_USERNAME);
+  }
+}