Daily Actions: Secure your Next.js application with AWS Cognito and CDK

Daily Actions: Secure your Next.js application with AWS Cognito and CDK

Now that our Next.js application is live on S3 and CloudFront, it’s time to add an essential component: authentication. With a static export architecture, traditional server-based solutions (like NextAuth.js with Node.js API routes) do not work directly.

We will use AWS Cognito to manage our users (User Pool) and integrate the AWS SDK (or Amplify) on the front-end to connect our static application securely. CDK handles all the infrastructure configuration.

Prerequisites

Before starting, you need to:

1. Updating the CDK Infrastructure (Cognito)

Open your infrastructure/lib/infrastructure-stack.ts file and add the Cognito User Pool resource. We will also export the necessary credentials so the front-end knows which User Pool to target.

TS
// create userpool
const userPool = new cognito.UserPool(this, "UserPool", {
  userPoolName: "DailyAppUserPool",
  selfSignUpEnabled: true,
  signInAliases: { email: true },
  autoVerify: { email: true }, // see note 1
  featurePlan: cognito.FeaturePlan.ESSENTIALS, // required for Managed Login
  standardAttributes: {
    email: { required: true, mutable: false },
  },
});

// create user pool client
const userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", {
  userPoolClientName: "DailyAppUserPoolClient",
  userPool,
  generateSecret: false,
  authFlows: { userSrp: true },
  oAuth: {
    flows: { authorizationCodeGrant: true },
    callbackUrls: [
      `https://${distribution.domainName}/`,
      "http://localhost:3001/", // local dev
    ],
    logoutUrls: [
      `https://${distribution.domainName}/`,
      "http://localhost:3001/",
    ],
    scopes: [
      cognito.OAuthScope.OPENID,
      cognito.OAuthScope.EMAIL,
      cognito.OAuthScope.PHONE,
    ],
  },
});

// Cognito prefix domain + Managed Login engine
const domain = userPool.addDomain("Domain", {
  cognitoDomain: { domainPrefix: "daily-app-auth-xyz" }, // unique per region
  managedLoginVersion: cognito.ManagedLoginVersion.NEWER_MANAGED_LOGIN,
});

// Default branding, the one the console creates for you
const branding = new cognito.CfnManagedLoginBranding(this, "Branding", {
  userPoolId: userPool.userPoolId,
  clientId: userPoolClient.userPoolClientId,
  useCognitoProvidedValues: true,
});
branding.node.addDependency(domain);

new cdk.CfnOutput(this, "CognitoDomainUrl", { value: domain.baseUrl() });

new cdk.CfnOutput(this, "UserPoolId", {
  value: userPool.userPoolId,
});

new cdk.CfnOutput(this, "UserPoolClientId", {
  value: userPoolClient.userPoolClientId,
});

new cdk.CfnOutput(this, "DistributionDomainName", {
  value: distribution.domainName,
});

const config = {
  authority: `https://cognito-idp.${this.region}.amazonaws.com/${userPool.userPoolId}`,
  client_id: userPoolClient.userPoolClientId,
  redirect_uri: "https://" + distribution.domainName + "/",
  response_type: "code",
  scope: "phone openid email",
};

// --- 4. S3 Deployment --- & upload cognito configs
new s3deploy.BucketDeployment(this, "DeployNextApp", {
  sources: [
    s3deploy.Source.asset("../frontend/out"),
    s3deploy.Source.jsonData("config.json", config),
  ],
  destinationBucket: bucket,
  distribution,
  distributionPaths: ["/*"],
});
Click to expand and view more

Next.js Front-end Configuration

Since our application is compiled as a static export (output: 'export'), it does not have a Node.js server to inject environment variables at request time.

To bypass this limitation and allow our application to dynamically retrieve our Cognito User Pool URL, we use a config.json file placed in the Next.js root folder, loaded at startup via the main layout (RootLayout).

TS
new s3deploy.BucketDeployment(this, "DeployNextApp", {
  sources: [
    s3deploy.Source.asset("../frontend/out"),
    s3deploy.Source.jsonData("config.json", config),
  ],
  destinationBucket: bucket,
  distribution,
  distributionPaths: ["/*"],
});
Click to expand and view more

Installing the OIDC Library

In your frontend folder, install the react-oidc-context package (and oidc-client-ts required as a dependency) to handle OpenID Connect authentication with Cognito:

SH
cd frontend
pnpm install oidc-client-ts react-oidc-context
Click to expand and view more

The Dynamic RootLayout

Your app/layout.tsx file automatically fetches the config.json file when the application loads, initializes the authentication provider (AuthProvider), and secures the rendering of your interface:

JS
"use client"
import { Geist, Geist_Mono } from "next/font/google"

import "./globals.css"
import { ThemeProvider } from "@/components/theme-provider"
import { cn } from "@/lib/utils"
import { AuthProvider } from "react-oidc-context"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useEffect, useState } from "react"

const geist = Geist({ subsets: ["latin"], variable: "--font-sans" })

const fontMono = Geist_Mono({
  subsets: ["latin"],
  variable: "--font-mono",
})

interface Config {
  authority: string
  client_id: string
  redirect_uri: string
  response_type: string
  scope: string
}

const queryClient = new QueryClient()

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  const [config, setConfig] = useState<Config | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    fetch("/config.json")
      .then((res) => {
        if (!res.ok) throw new Error(`config.json not found (${res.status})`)
        return res.json()
      })
      .then((data) => setConfig(data))
      .catch((err) => {
        console.error(err)
        setError(err.message)
      })
  }, [])

  return (
    <html
      lang="en"
      suppressHydrationWarning
      className={cn(
        "antialiased",
        fontMono.variable,
        "font-sans",
        geist.variable
      )}
    >
      <body>
        {error ? (
          <p>Configuration error : {error}</p>
        ) : !config ? (
          <p>Loading…</p>
        ) : (
          <AuthProvider {...config}>
            <QueryClientProvider client={queryClient}>
              <ThemeProvider>{children}</ThemeProvider>
            </QueryClientProvider>
          </AuthProvider>
        )}
      </body>
    </html>
  )
}
Click to expand and view more

Using Authentication in a Component (Login Page / Navbar)

TS
const auth = useAuth();

const handleSignIn = () => {
  auth.signinRedirect({ prompt: "login" });
};

const handleSignOut = () => {
  auth.signoutSilent();
};
Click to expand and view more

2. Infrastructure Code (CDK)

With the infrastructure update, infrastructure/lib/infrastructure-stack.ts becomes:

TS
import * as cdk from "aws-cdk-lib/core";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as cognito from "aws-cdk-lib/aws-cognito";

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

    const bucket = new s3.Bucket(this, "NextAppBucket", {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      bucketName: "next-app-daily-app",
    });

    const distribution = new cloudfront.Distribution(
      this,
      "NextAppDistribution",
      {
        defaultBehavior: {
          origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
          viewerProtocolPolicy:
            cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
        defaultRootObject: "index.html",
        errorResponses: [
          {
            httpStatus: 404,
            responseHttpStatus: 404,
            responsePagePath: "/404.html",
          },
        ],
      },
    );

    // create userpool
    const userPool = new cognito.UserPool(this, "UserPool", {
      userPoolName: "DailyAppUserPool",
      selfSignUpEnabled: true,
      signInAliases: { email: true },
      autoVerify: { email: true }, // see note 1
      featurePlan: cognito.FeaturePlan.ESSENTIALS, // required for Managed Login
      standardAttributes: {
        email: { required: true, mutable: false },
      },
    });

    // create user pool client
    const userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", {
      userPoolClientName: "DailyAppUserPoolClient",
      userPool,
      generateSecret: false,
      authFlows: { userSrp: true },
      oAuth: {
        flows: { authorizationCodeGrant: true },
        callbackUrls: [
          `https://${distribution.domainName}/`,
          "http://localhost:3001/", // local dev
        ],
        logoutUrls: [
          `https://${distribution.domainName}/`,
          "http://localhost:3001/",
        ],
        scopes: [
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.PHONE,
        ],
      },
    });

    // Cognito prefix domain + Managed Login engine
    const domain = userPool.addDomain("Domain", {
      cognitoDomain: { domainPrefix: "daily-app-auth-xyz" }, // unique per region
      managedLoginVersion: cognito.ManagedLoginVersion.NEWER_MANAGED_LOGIN,
    });

    // Default branding, the one the console creates for you
    const branding = new cognito.CfnManagedLoginBranding(this, "Branding", {
      userPoolId: userPool.userPoolId,
      clientId: userPoolClient.userPoolClientId,
      useCognitoProvidedValues: true,
    });
    branding.node.addDependency(domain);

    new cdk.CfnOutput(this, "CognitoDomainUrl", { value: domain.baseUrl() });

    new cdk.CfnOutput(this, "UserPoolId", {
      value: userPool.userPoolId,
    });

    new cdk.CfnOutput(this, "UserPoolClientId", {
      value: userPoolClient.userPoolClientId,
    });

    new cdk.CfnOutput(this, "DistributionDomainName", {
      value: distribution.domainName,
    });

    const config = {
      authority: `https://cognito-idp.${this.region}.amazonaws.com/${userPool.userPoolId}`,
      client_id: userPoolClient.userPoolClientId,
      redirect_uri: "https://" + distribution.domainName + "/",
      response_type: "code",
      scope: "phone openid email",
    };

    new s3deploy.BucketDeployment(this, "DeployNextApp", {
      sources: [
        s3deploy.Source.asset("../frontend/out"),
        s3deploy.Source.jsonData("config.json", config),
      ],
      destinationBucket: bucket,
      distribution,
      distributionPaths: ["/*"],
    });
  }
}
Click to expand and view more

3. One-Step Deployment

BASH
cd infrastructure
npm run deploy:all
Click to expand and view more

In the output, retrieve the CloudFront URL (domain type https://xxxxxxxxxxxx.cloudfront.net/) to launch the application.

application view

application view

5. Cleanup

To avoid ongoing costs after testing:

SH
cd infrastructure
cdk destroy
Click to expand and view more

Comments

Start searching

Enter keywords to search articles

↑↓
↵
ESC
⌘K Shortcut