avatar Artículo

Cómo añadir Google Sign-In a Amazon Cognito con Account Linking (CDK + PreSignUp)

Cómo añadir Google Sign-In a Amazon Cognito con Account Linking (CDK + PreSignUp)

Añadir Continuar con Google a una aplicación con Amazon Cognito es relativamente sencillo. La parte interesante aparece cuando un usuario ya tiene una cuenta con email y contraseña y, más adelante, entra con Google usando ese mismo email.

Por defecto, Cognito puede crear un segundo usuario federado. Acabas con dos identidades, dos valores de sub y, potencialmente, datos repartidos entre dos cuentas.

En este tutorial vamos a resolver las dos partes:

  1. Añadir Google como Identity Provider en Cognito con AWS CDK.
  2. Enlazar la identidad de Google con un usuario nativo de Cognito que ya exista con el mismo email verificado, usando un trigger PreSignUp.

El ejemplo real es AWS Announcements Hub, una aplicación serverless construida con React, Amazon Cognito, Amazon API Gateway, AWS Lambda, Amazon DynamoDB y AWS CDK.

El tutorial parte de que ya tienes un User Pool de Cognito y el login nativo con email y contraseña funcionando.

Cómo funciona el flujo

Tu aplicación no autentica directamente contra Google. Cognito actúa como intermediario:

1
Usuario → App → endpoint OAuth de Cognito → Google → Cognito → App

Google autentica al usuario y redirige de vuelta a Cognito. Cognito devuelve después tokens de Cognito a tu aplicación, así que tu backend sigue validando los mismos JWT independientemente de si el usuario ha entrado con contraseña o con Google.

Paso 1: Configurar Google

En Google Cloud Console:

  1. Crea o selecciona un proyecto.
  2. Configura Branding y la pantalla de consentimiento.
  3. Añade los scopes openid, email y profile.
  4. Crea un cliente OAuth de tipo Web application.

Google Cloud Console — Branding Configuración de Branding para el flujo OAuth

Google Cloud Console — Clients El cliente OAuth 2.0 utilizado por Cognito

El valor importante es la Authorized redirect URI. Google redirige a Cognito, no directamente a tu aplicación.

Por ejemplo, si tu dominio de Cognito es:

1
news-playingaws-prod.auth.eu-south-2.amazoncognito.com

registra:

1
https://news-playingaws-prod.auth.eu-south-2.amazoncognito.com/oauth2/idpresponse

Detalle del cliente OAuth El cliente OAuth con el callback /oauth2/idpresponse de Cognito

Guarda el Client ID y el Client Secret.

Paso 2: Guardar las credenciales de Google

No guardes el Google Client Secret en el repositorio.

En este ejemplo dejamos el Client ID en Parameter Store y el secret en Secrets Manager:

1
2
3
4
5
6
7
8
9
10
11
12
REGION=eu-south-2

aws ssm put-parameter \
  --name "/news/prod/google-client-id" \
  --value "TU_CLIENT_ID.apps.googleusercontent.com" \
  --type String \
  --region $REGION

aws secretsmanager create-secret \
  --name "/news/prod/google-client-secret" \
  --secret-string 'TU_CLIENT_SECRET' \
  --region $REGION

ssm-secure parece la opción obvia para el secret, pero AWS::Cognito::UserPoolIdentityProvider no soporta de forma fiable esa referencia dinámica en estas propiedades. Secrets Manager evita ese problema.

Paso 3: Configurar Cognito con CDK

Necesitamos tres piezas: un dominio de Cognito, el Identity Provider de Google y la configuración OAuth del User Pool Client.

Dominio de Cognito

1
2
3
4
5
userPool.addDomain("ManagedLoginDomain", {
  cognitoDomain: {
    domainPrefix: `news-playingaws-${environment}`,
  },
});

Identity Provider de Google

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const googleClientId = ssm.StringParameter.valueForStringParameter(
  this,
  `/news/${environment}/google-client-id`,
);

const googleProvider = new cognito.UserPoolIdentityProviderGoogle(this, "GoogleIdP", {
  userPool,
  clientId: googleClientId,
  clientSecretValue: cdk.SecretValue.secretsManager(
    `/news/${environment}/google-client-secret`,
  ),
  scopes: ["openid", "email", "profile"],
  attributeMapping: {
    email: cognito.ProviderAttribute.GOOGLE_EMAIL,
    emailVerified: cognito.ProviderAttribute.other("email_verified"),
    givenName: cognito.ProviderAttribute.GOOGLE_GIVEN_NAME,
    familyName: cognito.ProviderAttribute.GOOGLE_FAMILY_NAME,
  },
});

Mapear email_verified es importante porque la lógica de linking solo confiará en identidades verificadas.

OAuth en el User Pool Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const userPoolClient = userPool.addClient("WebClient", {
  authFlows: {
    userPassword: true,
    userSrp: true,
  },
  preventUserExistenceErrors: true,
  oAuth: {
    flows: {
      authorizationCodeGrant: true,
    },
    scopes: [
      cognito.OAuthScope.OPENID,
      cognito.OAuthScope.EMAIL,
      cognito.OAuthScope.PROFILE,
    ],
    callbackUrls: [
      "https://news.playingaws.com",
      "http://localhost:3000",
    ],
    logoutUrls: [
      "https://news.playingaws.com",
      "http://localhost:3000",
    ],
  },
  supportedIdentityProviders: [
    cognito.UserPoolClientIdentityProvider.COGNITO,
    cognito.UserPoolClientIdentityProvider.GOOGLE,
  ],
});

userPoolClient.node.addDependency(googleProvider);

Usamos Authorization Code Grant. La librería del frontend se encargará de PKCE.

Paso 4: Añadir Google Sign-In con Amplify

Con Amplify v6, configura el dominio OAuth de Cognito y utiliza signInWithRedirect():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Amplify.configure({
  Auth: {
    Cognito: {
      userPoolId,
      userPoolClientId,
      loginWith: {
        oauth: {
          domain: "news-playingaws-prod.auth.eu-south-2.amazoncognito.com",
          scopes: ["openid", "email", "profile"],
          redirectSignIn: [window.location.origin],
          redirectSignOut: [window.location.origin],
          responseType: "code",
        },
      },
    },
  },
});

export const signInWithGoogle = () =>
  signInWithRedirect({ provider: "Google" });

El botón solo necesita llamar a signInWithGoogle().

Botón Continue with Google en el login El login nativo y Google Sign-In pueden convivir en la misma interfaz

Selector de cuentas de Google Google realiza la autenticación y devuelve al usuario a Cognito

Paso 5: Enlazar Google con un usuario existente de Cognito

Esta es la parte que evita las cuentas duplicadas.

Imagina que un usuario ya está registrado con:

1
usuario@example.com + contraseña

Más adelante utiliza Continuar con Google con ese mismo email verificado.

Sin linking, Cognito puede crear un usuario federado separado, por ejemplo Google_....

Usuario Google_ en la consola de Cognito Sin linking, la identidad nativa y la de Google pueden existir como usuarios separados

Lo solucionamos con un trigger PreSignUp. Cognito invoca la Lambda antes de crear el usuario externo, lo que nos permite asociar esa identidad de Google al usuario nativo que ya existe.

Lambda PreSignUp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import {
  AdminLinkProviderForUserCommand,
  CognitoIdentityProviderClient,
  ListUsersCommand,
} from "@aws-sdk/client-cognito-identity-provider";

const client = new CognitoIdentityProviderClient({});

export async function handler(event: any) {
  if (event.triggerSource !== "PreSignUp_ExternalProvider") {
    return event;
  }

  const email = event.request.userAttributes.email;
  const externalEmailVerified =
    event.request.userAttributes.email_verified === "true";

  if (!email || !externalEmailVerified) {
    return event;
  }

  const separator = event.userName.indexOf("_");
  if (separator < 1) {
    return event;
  }

  const providerName = event.userName.slice(0, separator);
  const providerUserId = event.userName.slice(separator + 1);

  const { Users = [] } = await client.send(
    new ListUsersCommand({
      UserPoolId: event.userPoolId,
      Filter: `email = "${email}"`,
      Limit: 10,
    }),
  );

  const nativeUser = Users.find((user) => {
    const emailVerified = user.Attributes?.find(
      (attribute) => attribute.Name === "email_verified",
    )?.Value;

    return (
      user.Username &&
      !user.Username.startsWith(`${providerName}_`) &&
      emailVerified === "true"
    );
  });

  if (!nativeUser?.Username) {
    return event;
  }

  await client.send(
    new AdminLinkProviderForUserCommand({
      UserPoolId: event.userPoolId,
      DestinationUser: {
        ProviderName: "Cognito",
        ProviderAttributeValue: nativeUser.Username,
      },
      SourceUser: {
        ProviderName: providerName,
        ProviderAttributeName: "Cognito_Subject",
        ProviderAttributeValue: providerUserId,
      },
    }),
  );

  return event;
}

El detalle importante es que el usuario nativo existente es el destino. Su identidad de Cognito se conserva y la identidad de Google se añade encima.

Eso significa que cualquier dato de la aplicación que ya esté asociado al sub del usuario nativo sigue apuntando a la misma cuenta.

Enlaza identidades solo cuando confíes en el email de ambos lados. En este ejemplo, Google debe devolver email_verified=true y el usuario existente de Cognito también debe tener el email verificado.

Conectar el trigger con CDK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const preSignUpFn = new lambda.Function(this, "PreSignUpTrigger", {
  runtime: lambda.Runtime.NODEJS_24_X,
  architecture: lambda.Architecture.ARM_64,
  handler: "services/auth/pre-signup.handler",
  code: lambda.Code.fromAsset("../../backend/dist"),
});

const userPool = new cognito.UserPool(this, "UserPool", {
  // ...resto de la configuración...
  lambdaTriggers: {
    preSignUp: preSignUpFn,
  },
});

preSignUpFn.role?.attachInlinePolicy(
  new iam.Policy(this, "PreSignUpLinkPolicy", {
    statements: [
      new iam.PolicyStatement({
        actions: [
          "cognito-idp:ListUsers",
          "cognito-idp:AdminLinkProviderForUser",
        ],
        resources: [userPool.userPoolArn],
      }),
    ],
  }),
);

Para comprobarlo, crea una cuenta nativa, cierra sesión y entra después con Google usando el mismo email verificado. Cognito debería conservar la cuenta nativa y asociarle la identidad de Google, en lugar de crear un segundo usuario independiente.

Troubleshooting

  • redirect_uri_mismatch de Google: comprueba que la URL exacta de Cognito .../oauth2/idpresponse está registrada en el cliente OAuth de Google.
  • redirect_mismatch de Cognito: la URL enviada por la app debe coincidir exactamente con una de las callbackUrls del User Pool Client.
  • Se siguen creando usuarios duplicados: comprueba que el trigger está configurado como PreSignUp, recibe PreSignUp_ExternalProvider y puede ejecutar AdminLinkProviderForUser.

Conclusión

Añadir Google Sign-In a Cognito es principalmente configuración. La parte que merece especial atención es la consolidación de identidades.

Sin account linking, un usuario que ya tenía una cuenta con contraseña puede acabar con un segundo usuario de Cognito respaldado por Google y con un sub distinto. Con una pequeña Lambda PreSignUp, puedes mantener el usuario nativo existente como identidad canónica y enlazar Google encima.

El resultado es sencillo para el usuario: una sola cuenta, dos formas de iniciar sesión.

Referencias

Este artículo está licenciado bajo CC BY 4.0 por el autor.