aws-amplify / amplify-backend

Home to all tools related to Amplify's code-first DX (Gen 2) for building fullstack apps on AWS
Apache License 2.0
143 stars 45 forks source link

Auto-verify email when signing up with provider doesn't work #1619

Closed ethancdaniel closed 1 week ago

ethancdaniel commented 1 month ago

Environment information

System:
  OS: Linux 5.15 Ubuntu 22.04.4 LTS 22.04.4 LTS (Jammy Jellyfish)
  CPU: (12) x64 AMD Ryzen 5 7600X 6-Core Processor
  Memory: 9.13 GB / 15.20 GB
  Shell: /usr/bin/zsh
Binaries:
  Node: 20.14.0 - ~/.local/share/mise/installs/node/20.14/bin/node
  Yarn: undefined - undefined
  npm: 10.7.0 - ~/.local/share/mise/installs/node/20.14/bin/npm
  pnpm: 9.1.4 - /mnt/wslg/runtime-dir/fnm_multishells/23862_1717624346631/bin/pnpm
NPM Packages:
  @aws-amplify/backend: Not Found
  @aws-amplify/backend-cli: Not Found
  aws-amplify: Not Found
  aws-cdk: Not Found
  aws-cdk-lib: Not Found
  typescript: Not Found
AWS environment variables:
  AWS_STS_REGIONAL_ENDPOINTS = regional
  AWS_NODEJS_CONNECTION_REUSE_ENABLED = 1
  AWS_SDK_LOAD_CONFIG = 1
No CDK environment variables

Description

I'm trying to auto-verify / confirm users that sign up with Google OAuth. After searching online, pre-signup lambdas seem to be the easiest option available. These are the lambda function files I added and then added preSignUp as a trigger in auth/resource.ts. The lambda function logs true for both autoConfirmUser and autoVerifyEmail but when I check the cognito console, the user is unverified. Any advice on fixing this strategy or using a completely different strategy that I am unaware of would be greatly appreciated.

This is the doc I used as a guideline for the pre-signup trigger:

pre-signup/handler.ts

import type { PreSignUpTriggerHandler } from "aws-lambda";

export const handler: PreSignUpTriggerHandler = async (event) => {
  // Confirm the user
  event.response.autoConfirmUser = true;

  // Set the email as verified if it is in the request
  if ("email" in event.request.userAttributes) {
    event.response.autoVerifyEmail = true;
  }

  // Set the phone number as verified if it is in the request
  if ("phone_number" in event.request.userAttributes) {
    event.response.autoVerifyPhone = true;
  }
  console.log(event.response.autoConfirmUser);
  console.log(event.response.autoVerifyEmail);

  // Return to Amazon Cognito
  return event;
};

pre-signup/resource.ts

import { defineFunction } from "@aws-amplify/backend";

export const preSignUp = defineFunction();

auth/resource.ts

import { defineAuth, secret } from "@aws-amplify/backend";
import { preSignUp } from "../functions/pre-signup/resource";
/**
 * Define and configure your auth resource
 * @see https://docs.amplify.aws/gen2/build-a-backend/auth
 */
export const auth = defineAuth({
  loginWith: {
    email: true,
    externalProviders: {
      google: {
        clientId: secret("GOOGLE_ID"),
        clientSecret: secret("GOOGLE_SECRET"),
        scopes: ["email"],
      },
      callbackUrls: [
        "http://localhost:3000/home",
        "https://<my-amplify-url>/home",
      ],
      logoutUrls: [
        "http://localhost:3000",
        "https://<my-amplify-url>",
      ],
    },
  },
  userAttributes: {
    preferredUsername: {
      mutable: true,
      required: false,
    },
  },
  triggers: {
    preSignUp,
  },
});
josefaidt commented 1 month ago

Hey @ethancdaniel :wave: thanks for raising this! While we work to reproduce the issue on our end, you can modify the user pool to automatically verify email and phone_number attributes https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cognito.CfnUserPool.html#autoverifiedattributes

ykethan commented 1 month ago

Hey @ethancdaniel, I was able to reproduce the issue. On a bit of digging, this appears to be an expected behavior on AWS Cognito. Refer to this post providing information on this behavior: https://repost.aws/questions/QUMvXcvbNqSNymZgbN2GLoqQ/cognito-external-provider-user-email-cannot-be-automatically-verified Additionally, refer to this similar issue providing a workaround using post confirmation trigger to set the attributes: https://github.com/aws-amplify/amplify-js/issues/5117#issuecomment-647725294

ethancdaniel commented 1 month ago

Thanks for the response @josefaidt and @ykethan! I followed your suggestion to change it to a post confirmation trigger, but it didn't work. Is there any way to modify the user pool to only require email verification when signing up with email but not for OAuth sign up? It's logging all three console.logs I added in cloudwatch by the way.

import type { PostConfirmationTriggerHandler } from "aws-lambda";
import { CognitoIdentityProvider } from "@aws-sdk/client-cognito-identity-provider";

export const handler: PostConfirmationTriggerHandler = async (
  event,
  context,
  callback
) => {
  console.log("hi");
  if (event.request.userAttributes.email) {
    console.log("has email");
    if (
      event.request.userAttributes["cognito:user_status"] ===
      "EXTERNAL_PROVIDER"
    ) {
      console.log("is external provider");
      const cognitoIdServiceProvider = new CognitoIdentityProvider({
        region: "us-east-2",
      });
      var params = {
        UserAttributes: [
          {
            Name: "email_verified",
            Value: "true",
          },
        ],
        UserPoolId: event.userPoolId,
        Username: event.userName,
      };

      cognitoIdServiceProvider.adminUpdateUserAttributes(
        params,
        function (err: any) {
          if (err) {
            callback(null, event);
          } else {
            callback(null, event);
          }
        }
      );
    } else {
      callback(null, event);
    }
  } else {
    callback(null, event);
  }
};
josefaidt commented 1 month ago

Hey @ethancdaniel it looks like you can potentially map the email_verified attribute with google's verified_email attribute from the profile scope https://developers.google.com/identity/sign-in/web/backend-auth#node.js

Can you try mapping this in your backend?

import { defineAuth, secret } from "@aws-amplify/backend";
import { preSignUp } from "../functions/pre-signup/resource";
/**
 * Define and configure your auth resource
 * @see https://docs.amplify.aws/gen2/build-a-backend/auth
 */
export const auth = defineAuth({
  loginWith: {
    email: true,
    externalProviders: {
      google: {
        clientId: secret("GOOGLE_ID"),
        clientSecret: secret("GOOGLE_SECRET"),
        scopes: ["email", "profile"],
        attributeMapping: {
          email: "email",
          email_verified: "email_verified"
        },
      },
      callbackUrls: [
        "http://localhost:3000/home",
        "https://<my-amplify-url>/home",
      ],
      logoutUrls: [
        "http://localhost:3000",
        "https://<my-amplify-url>",
      ],
    },
  },
  userAttributes: {
    preferredUsername: {
      mutable: true,
      required: false,
    },
  },
});
trooms commented 1 month ago

Hey @ethancdaniel it looks like you can potentially map the email_verified attribute with google's verified_email attribute from the profile scope https://developers.google.com/identity/sign-in/web/backend-auth#node.js

Can you try mapping this in your backend?

I've recreated this exactly and email_verified is not a part of the attribute mapping.

From aws-cdk-lib/aws-cognito/lib/user-pool-idps/base.d.ts:

export interface AttributeMapping {
    /**
     * The user's postal address is a required attribute.
     * @default - not mapped
     */
    readonly address?: ProviderAttribute;
    /**
     * The user's birthday.
     * @default - not mapped
     */
    readonly birthdate?: ProviderAttribute;
    /**
     * The user's e-mail address.
     * @default - not mapped
     */
    readonly email?: ProviderAttribute;
    /**
     * The surname or last name of user.
     * @default - not mapped
     */
    readonly familyName?: ProviderAttribute;
    /**
     * The user's gender.
     * @default - not mapped
     */
    readonly gender?: ProviderAttribute;
    /**
     * The user's first name or give name.
     * @default - not mapped
     */
    readonly givenName?: ProviderAttribute;
    /**
     * The user's locale.
     * @default - not mapped
     */
    readonly locale?: ProviderAttribute;
    /**
     * The user's middle name.
     * @default - not mapped
     */
    readonly middleName?: ProviderAttribute;
    /**
     * The user's full name in displayable form.
     * @default - not mapped
     */
    readonly fullname?: ProviderAttribute;
    /**
     * The user's nickname or casual name.
     * @default - not mapped
     */
    readonly nickname?: ProviderAttribute;
    /**
     * The user's telephone number.
     * @default - not mapped
     */
    readonly phoneNumber?: ProviderAttribute;
    /**
     * The URL to the user's profile picture.
     * @default - not mapped
     */
    readonly profilePicture?: ProviderAttribute;
    /**
     * The user's preferred username.
     * @default - not mapped
     */
    readonly preferredUsername?: ProviderAttribute;
    /**
     * The URL to the user's profile page.
     * @default - not mapped
     */
    readonly profilePage?: ProviderAttribute;
    /**
     * The user's time zone.
     * @default - not mapped
     */
    readonly timezone?: ProviderAttribute;
    /**
     * Time, the user's information was last updated.
     * @default - not mapped
     */
    readonly lastUpdateTime?: ProviderAttribute;
    /**
     * The URL to the user's web page or blog.
     * @default - not mapped
     */
    readonly website?: ProviderAttribute;
    /**
     * Specify custom attribute mapping here and mapping for any standard attributes not supported yet.
     * @default - no custom attribute mapping
     */
    readonly custom?: {
        [key: string]: ProviderAttribute;
    };
}

I've also tried setting

attributeMapping: {
     email: "email",
     custom: {
          email_verified: "email_verified",
     }
}
ethancdaniel commented 1 month ago

Hey @ethancdaniel it looks like you can potentially map the email_verified attribute with google's verified_email attribute from the profile scope developers.google.com/identity/sign-in/web/backend-auth#node.js Can you try mapping this in your backend?

I've recreated this exactly and email_verified is not a part of the attribute mapping.

From aws-cdk-lib/aws-cognito/lib/user-pool-idps/base.d.ts:

export interface AttributeMapping {
    /**
     * The user's postal address is a required attribute.
     * @default - not mapped
     */
    readonly address?: ProviderAttribute;
    /**
     * The user's birthday.
     * @default - not mapped
     */
    readonly birthdate?: ProviderAttribute;
    /**
     * The user's e-mail address.
     * @default - not mapped
     */
    readonly email?: ProviderAttribute;
    /**
     * The surname or last name of user.
     * @default - not mapped
     */
    readonly familyName?: ProviderAttribute;
    /**
     * The user's gender.
     * @default - not mapped
     */
    readonly gender?: ProviderAttribute;
    /**
     * The user's first name or give name.
     * @default - not mapped
     */
    readonly givenName?: ProviderAttribute;
    /**
     * The user's locale.
     * @default - not mapped
     */
    readonly locale?: ProviderAttribute;
    /**
     * The user's middle name.
     * @default - not mapped
     */
    readonly middleName?: ProviderAttribute;
    /**
     * The user's full name in displayable form.
     * @default - not mapped
     */
    readonly fullname?: ProviderAttribute;
    /**
     * The user's nickname or casual name.
     * @default - not mapped
     */
    readonly nickname?: ProviderAttribute;
    /**
     * The user's telephone number.
     * @default - not mapped
     */
    readonly phoneNumber?: ProviderAttribute;
    /**
     * The URL to the user's profile picture.
     * @default - not mapped
     */
    readonly profilePicture?: ProviderAttribute;
    /**
     * The user's preferred username.
     * @default - not mapped
     */
    readonly preferredUsername?: ProviderAttribute;
    /**
     * The URL to the user's profile page.
     * @default - not mapped
     */
    readonly profilePage?: ProviderAttribute;
    /**
     * The user's time zone.
     * @default - not mapped
     */
    readonly timezone?: ProviderAttribute;
    /**
     * Time, the user's information was last updated.
     * @default - not mapped
     */
    readonly lastUpdateTime?: ProviderAttribute;
    /**
     * The URL to the user's web page or blog.
     * @default - not mapped
     */
    readonly website?: ProviderAttribute;
    /**
     * Specify custom attribute mapping here and mapping for any standard attributes not supported yet.
     * @default - no custom attribute mapping
     */
    readonly custom?: {
        [key: string]: ProviderAttribute;
    };
}

I've also tried setting

attributeMapping: {
     email: "email",
     custom: {
          email_verified: "email_verified",
     }
}

@josefaidt Can you take a look at this when you get the chance? Doesn't seem like the email_verified attribute exists.

josefaidt commented 1 month ago

Hey @ethancdaniel you're right, it does not seem to exist in the CDK type, despite how you can configure it manually in the console per provider CleanShot 2024-06-07 at 15 48 58

just to verify whether this is going to work as expected, can you try modifying this in the console and signing in? if this works we can look towards expanding this type to support the flow

ethancdaniel commented 1 month ago

@josefaidt What do you mean by this? I'm not sure what you want me to modify it to. And should I leave my code as is, or should I change something? image

josefaidt commented 1 month ago

@ethancdaniel sorry, the value would be email_verified. From the google docs it looks to be available on the same attribute name CleanShot 2024-06-07 at 16 49 33

ethancdaniel commented 1 month ago

@josefaidt email_verified is already set to email_verified by default

image

josefaidt commented 1 month ago

does it give you an error when you attempt to save the changes?

ethancdaniel commented 1 month ago

No, that's what it already was set to by default, my first screenshot was because I thought you wanted me to remove the mapping @josefaidt

josefaidt commented 1 month ago

Ah, thanks for clarifying @ethancdaniel let me do a bit more digging. There is an option of using AdminUpdateUserAttributesCommand in a post-authentication trigger or something similar, however that function will be invoked on each authentication event

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/cognito-identity-provider/command/AdminUpdateUserAttributesCommand/

josefaidt commented 1 month ago

Hey @ethancdaniel I was doing a bit of a dive on this and found you can use the PostConfirmation trigger and the SDK to update this on first sign-in!

Note: I've added a type to narrow the generic record type we see in the PostConfirmationTriggerHandler, but making a note of this to potentially introduce this as a type from the backend package to narrow based on the auth definition 🙂

// amplify/auth/post-confirmation/handler.ts
import type { PostConfirmationTriggerHandler } from "aws-lambda"
import {
  CognitoIdentityProviderClient,
  AdminUpdateUserAttributesCommand,
} from "@aws-sdk/client-cognito-identity-provider"

const client = new CognitoIdentityProviderClient()

// declared this to type the user attributes we see in the event
type UserAttributes = {
  sub: string
  email_verified: "false" | "true"
  identities: string
  "cognito:user_status": "EXTERNAL_PROVIDER" | string
  email: string
}

export const handler: PostConfirmationTriggerHandler = async (event) => {
  console.log("event:", JSON.stringify(event, null, 2))

  const userAttributes = event.request.userAttributes as UserAttributes
  if (userAttributes["cognito:user_status"] === "EXTERNAL_PROVIDER") {
    const command = new AdminUpdateUserAttributesCommand({
      UserPoolId: event.userPoolId,
      Username: event.userName,
      UserAttributes: [
        {
          Name: "email_verified",
          Value: "true",
        },
      ],
    })

    try {
      const result = await client.send(command)
      console.log("processed", result.$metadata.requestId)
    } catch (error) {
      console.error(
        "Unable to automatically verify external provider email",
        error,
      )
    }
  }

  return event
}
// amplify/auth/resource.ts
import { defineAuth } from "@aws-amplify/backend"
import { postConfirmation } from "./post-confirmation/resource"

/**
 * Define and configure your auth resource
 * @see https://docs.amplify.aws/gen2/build-a-backend/auth
 */
export const auth = defineAuth({
  loginWith: {
    email: true,
    externalProviders: {
      saml: {
        metadata: {
          metadataType: "URL",
          metadataContent:
            "https://some-url",
        },
        attributeMapping: {
          email: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
        },
        name: "MicrosoftEntraIDSAML",
      },
      logoutUrls: ["http://localhost:4321/", "hostedui://"],
      callbackUrls: ["http://localhost:4321/", "hostedui://callback/"],
    },
  },
  access: (allow) => [
    allow.resource(postConfirmation).to(["updateUserAttributes"]),
  ],
  triggers: {
    postConfirmation,
  },
})
ethancdaniel commented 1 month ago

@josefaidt This successfully changed email_verified to true, but there are two problems. 1.

access: (allow) => [
    allow.resource(postConfirmation).to(["updateUserAttributes"]),
  ],

access is an experimental feature that AWS says not to use in production image

  1. Even after verifying the email, fetchAuthSession does not work and instead returns session.tokens !== undefined.

Skipping email verification when signing in with an external provider is a very standard practice I've seen in websites, so if this is not possible without experimental features, is it that everyone is just using Auth0, Clerk, etc. instead of AWS Cognito?

josefaidt commented 1 month ago

Hey @ethancdaniel thanks for calling that out! I filed a PR to update the jsdoc for access. For 2, are you seeing undefined tokens in your frontend app? Do you see them set in localStorage?

Skipping email verification when signing in with an external provider...

This is the currently the default behavior for Amazon Cognito. In the example above we're asserting the email will be verified if coming from the external provider (in my case, SAML with Entra ID). This should not impact the tokens beyond setting the appropriate attribute value in the ID token -- in the event you'd like to have separate experiences for unverified users.

myendorphin commented 1 month ago

I got similar problem updating custom attribute in the preSignUp lambda event.request.userAttributes['custom:MyAttribute']

The event returned by the lambda with new values, but these are not saved on a created user record

ykethan commented 1 week ago

Closing the issue due to inactivity. Do reach out to us if you are still experiencing this issue