DEV Community

Cover image for Google Sign In with Cognito and Nest.js
bright inventions
bright inventions

Posted on • Originally published at brightinventions.pl

Google Sign In with Cognito and Nest.js

If you want to implement Google sign-in, also called Google federation, and combine it with using AWS Cognito this blog
post if for you.

We'll use aws-cdk combined with Nest.js to achieve that.


Setup


At Bright Inventions, we often keep infrastructure code next to application code.
Thus let's start with creating:


  • Nest.js backend project 
nest new backend
Enter fullscreen mode Exit fullscreen mode



  • aws-cdk infrastructure project 
mkdir infrastrucutre
(cd infrastrucutre && npx cdk@2 init --language=typescript)
Enter fullscreen mode Exit fullscreen mode



Cognito UserPool


Cognito UserPool represents our users' directory. You can think of it as the repository of of user accounts.


export class CognitoGoogleAuthNestJs extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const userPool = new UserPool(this, 'users', {
      selfSignUpEnabled: true,
      signInAliases: { email: true }
    });

    const userPoolDomain = userPool.addDomain('backend', {
      cognitoDomain: {
        domainPrefix: "tutorial-bright"
      }
    });

    new CfnOutput(this, 'user-pool-domain-uri', {
      value: userPoolDomain.baseUrl()
    })
  }
}
Enter fullscreen mode Exit fullscreen mode



Setup Google API credentials


We need to enable Cognito to talk with Google APIs.
In our Google Cloud Platform project, let's enable OAuth consent screen:

OAuth COnsent screen

Next configure application name and a domain of the oauth handling endpoints. AWS Cognito provides us with
those endpoints:

Authorized daomain

Remember that until your application is published, you can only use it with test users.
You'll be able to add test users while configuring OAuth consent screen.

Finally, create an oauth client id using the web application type:

Image description

Finally, you need to configure Authorized redirect URIs to point to
AWS Cognito IDP response endpoint
that will be in the form of: https://${domainPrefix}.auth.${region}.amazoncognito.com/oauth2/idpresponse

Image description

Download the oauth client credentials JSON file. We should not store the client credentials in our source code.
Let's use AWS Secret Manager for that:


 aws secretsmanager create-secret \
  --name cognito-google-oauth-credentials \
  --secret-string "$(jq '.web' < ~/Downloads/client_secret.apps.googleusercontent.com.json)"
Enter fullscreen mode Exit fullscreen mode


The jq '.web' ... extract the nested web attribute content. This is required as you can't reference nested values in
AWS Secret Manager.


Configure OAuth clients


We need to instruct Cognito to be able to communicate with Google as the Identity Provider:


const clientCredentials = Secret.fromSecretNameV2(this, 'google-client-credentials', 'cognito-google-oauth-credentials')

userPool.registerIdentityProvider(new UserPoolIdentityProviderGoogle(this, "Google", {
  userPool,
  clientId: clientCredentials.secretValueFromJson("client_id").unsafeUnwrap(),
  clientSecret: clientCredentials.secretValueFromJson("client_secret").unsafeUnwrap(),

  // Email scope is required, otherwise we'll not get it
  scopes: ["email"],
  attributeMapping: {
    email: ProviderAttribute.GOOGLE_EMAIL,
  },
}));
Enter fullscreen mode Exit fullscreen mode


Finally, we need to tell Cognito where to take the end user for after authentication. The callback url will be the url
of our Nest.js backend.


const hostedZone = HostedZone.fromLookup(this, 'tutorial.bright.dev', {
  domainName: 'tutorial.bright.dev'
});

const baseNestJsUrl = new URL(`https://nestj-google-cognito.${hostedZone.zoneName}`);

const callbackUrl = new URL("/auth/callback", baseNestJsUrl)

const userPoolClient = userPool.addClient('nest.js', {
  generateSecret: true,
  supportedIdentityProviders: [UserPoolClientIdentityProvider.GOOGLE],
  oAuth: {
    callbackUrls: [callbackUrl],
  },
});
// workaround for https://github.com/aws/aws-cdk/issues/15692
userPoolClient.node.addDependency(identityProviderGoogle)
Enter fullscreen mode Exit fullscreen mode



Handle Cognito sign-in callback in Nest.js


After Cognito federates with Google OpenId Connect Provider, it passes the control to our application.
In essence, it is the Authorization code grant with PKCE.
Our application will receive a code that it has to exchange for Access Token, Id Token and Refresh Token using Cognito
APIs.

If your backend should automatically redirect unauthenticated API clients to OAuth authorize endpoint, then
use passport-oauth2.
To make our example more transparent, we invoke the token endpoint manually:


@Controller()
export class AuthController {
  constructor(private readonly configService: ConfigService<OAuthClientEnvConfiguration>) {
  }

  @Get("/auth/callback")
  async signIn(@Query('code') authorizationCode: string) {
    const clientId = this.configService.getOrThrow('OAUTH_CLIENT_ID')
    const clientSecret = this.configService.getOrThrow('OAUTH_CLIENT_SECRET')
    const authorizationEncoded = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");

    const authParams = new URLSearchParams(Object.entries({
      client_id: clientId,
      code: authorizationCode,
      grant_type: "authorization_code",
      redirect_uri: this.configService.getOrThrow('OAUTH_CALLBACK_URL'),
    }));

    const tokenUrl = `${this.configService.getOrThrow('OAUTH_AUTHORIZATION_SERVER_URL')}/oauth2/token?` + authParams;

    const tokenData = await (await fetch(tokenUrl, {
      method: 'POST',
      headers: {
        Authorization: `Basic ${authorizationEncoded}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
    })).json();
    // tokenData has id_token, access_token and refresh_token
  }
}
Enter fullscreen mode Exit fullscreen mode


At the end of the sign-in flow our application we have IdToken, AccessToken and RefreshToken.
What we do at this stage depends on our needs. For example, we can:


  • start a cookie-based session
  • return AccessToken to frontend
  • init user account configuration that do not fit into AWS Cognito  ## Combine Cognito with Passport Nest.js  Passport is often used in Node.js backends to deal with authentication. The passport-oauth2 extension provides an easy way to integrate with standard OAuth flows. Here's how to use it in Nest.js: 
@Controller()
export class AuthController {
  constructor(private readonly configService: ConfigService<OAuthClientEnvConfiguration>) {
  }

  @UseGuards(AuthGuard('oauth'))
  @Get("/auth/callback")
  async signInPassport(@Req() req: Express.AuthenticatedRequest) {
    // req.user has id_token, access_token and refresh_token 
  }

}

// register in AppModule
@Injectable()
export class NestPassportOAuthStrategy extends PassportStrategy(OAuth2Strategy) {
  constructor(configService: ConfigService<OAuthClientEnvConfiguration>) {
    super({
      clientID: configService.getOrThrow('OAUTH_CLIENT_ID'),
      clientSecret: configService.getOrThrow('OAUTH_CLIENT_SECRET'),
      authorizationURL: `${configService.getOrThrow('OAUTH_AUTHORIZATION_SERVER_URL')}/oauth2/authorize`,
      tokenURL: `${configService.getOrThrow('OAUTH_AUTHORIZATION_SERVER_URL')}/oauth2/token`,
      callbackURL: configService.getOrThrow('OAUTH_CALLBACK_URL')
    } as OAuth2Strategy.StrategyOptions, (accessToken, refreshToken, results, profile, verified) => {
      console.log('verified', { accessToken, refreshToken, results, profile, verified })
    });
  }
}
Enter fullscreen mode Exit fullscreen mode



Provide users with login URL


With AWS Cognito we can use hosted pages. However, we often need to have a full control over the UI of our
application.

In such a case, we can craft a special URL that will trigger the sign in flow. Here's how to create the URL that will
trigger login with Google flow:


const baseAuthUrl = this.configService.getOrThrow('OAUTH_AUTHORIZATION_SERVER_URL')
const clientId = this.configService.getOrThrow('OAUTH_CLIENT_ID')
const loginViaGoogleUrl = `${baseAuthUrl}/oauth2/authorize?${new URLSearchParams(Object.entries({
  client_id: clientId,
  identity_provider: 'Google',
  response_type: 'code',
  redirect_uri: this.configService.getOrThrow('OAUTH_CALLBACK_URL')
}))}`
Enter fullscreen mode Exit fullscreen mode


The URL will look as follows:


https://{cognitoDomainPrefix}.auth.{awsRegion}.amazoncognito.com/oauth2/authorize?client_id={cognitoClientId}&identity_provider=Google&response_type=code&redirect_uri={yourApplicationCallbackUrl}
Enter fullscreen mode Exit fullscreen mode


Please bear in mind that the client_id parameter is one retrieved from userPoolClient and not from Google project
API credentials.


ECS Task Definition


I'll spare you the details on how to run the Nest.js application in ECS. That's a topic for a separate blog post.
However, there are a couple of important configuration options that you need to provide for the above snippets to work:


 const backend = task.addContainer('backend', {
  image: ContainerImage.fromDockerImageAsset(new DockerImageAsset(this, 'backend-image', {
    directory: path.join(process.cwd(), '..', 'backend')
  })),
  environment: {
    PORT: '3000',
    OAUTH_CLIENT_ID: userPoolClient.userPoolClientId,
    OAUTH_CLIENT_SECRET: userPoolClient.userPoolClientSecret.unsafeUnwrap(),
    OAUTH_AUTHORIZATION_SERVER_URL: userPoolDomain.baseUrl(),
    OAUTH_CALLBACK_URL: callbackUrl.toString(),
  },
  portMappings: [{ containerPort: 3000 }],
  logging: LogDriver.awsLogs({
    streamPrefix: "backend",
    logGroup: logGroup
  })
});
Enter fullscreen mode Exit fullscreen mode



Summary


The full code of the above setup is available in GitHub.
In our example, AWS Cognito performs OpenID Connect exchange with Google. Our Nest.js application code only receives
information from Cognito. We can easily integrate new identity providers e.g. Facebook and our backend application code would still work.


By Piotr Mionskowski, Head of Technology & Partner @ Bright Inventions

Top comments (0)