Daily Actions: Sécuriser son application Next.js avec AWS Cognito et CDK

Daily Actions: Sécuriser son application Next.js avec AWS Cognito et CDK

Maintenant que notre application Next.js est en ligne sur S3 et CloudFront, il est temps de lui ajouter une brique indispensable : l’authentification. Avec une architecture en export statique, les solutions traditionnelles basées sur des serveurs (comme NextAuth.js avec des routes API Node.js) ne fonctionnent pas directement.

Nous allons utiliser AWS Cognito pour gérer nos utilisateurs (User Pool) et intégrer le SDK AWS (ou Amplify) côté front-end pour connecter notre application statique en toute sécurité. Le CDK se charge de toute la configuration infrastructure.

Prérequis

Avant de commencer, il vous faut :

1. Mise à jour de l’infrastructure CDK (Cognito)

Ouvrez votre fichier infrastructure/lib/infrastructure-stack.ts et ajoutez la ressource Cognito User Pool. Nous allons également exporter les identifiants nécessaires pour que le front-end sache à quel User Pool s’adresser.

TS
// create userpool
const userPool = new cognito.UserPool(this, "UserPool", {
  userPoolName: "DailyAppUserPool",
  selfSignUpEnabled: true,
  signInAliases: { email: true },
  autoVerify: { email: true }, // voir remarque 1
  featurePlan: cognito.FeaturePlan.ESSENTIALS, // requis pour 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/", // dev local
    ],
    logoutUrls: [
      `https://${distribution.domainName}/`,
      "http://localhost:3001/",
    ],
    scopes: [
      cognito.OAuthScope.OPENID,
      cognito.OAuthScope.EMAIL,
      cognito.OAuthScope.PHONE,
    ],
  },
});

// Domaine préfixe Cognito + moteur Managed Login
const domain = userPool.addDomain("Domain", {
  cognitoDomain: { domainPrefix: "daily-app-auth-xyz" }, // unique par région
  managedLoginVersion: cognito.ManagedLoginVersion.NEWER_MANAGED_LOGIN,
});

// Style par défaut, celui que la console crée pour vous
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,
});
creation des configs cognito
//
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. Déploiement S3 --- & upload des configs cognito
new s3deploy.BucketDeployment(this, "DeployNextApp", {
  sources: [
    s3deploy.Source.asset("../frontend/out"),
    s3deploy.Source.jsonData("config.json", config),
  ],
  destinationBucket: bucket,
  distribution,
  distributionPaths: ["/*"],
});
Cliquez pour développer et voir plus

Configuration du Front-end Next.js

Puisque notre application est compilée en export statique (output: 'export'), elle ne possède pas de serveur Node.js pour injecter des variables d’environnement au moment des requêtes.

Pour contourner cette limite et permettre à notre application de récupérer dynamiquement l’URL de notre User Pool Cognito, nous utilisons un fichier config.json placé dans le dossier racine de Next.js, chargé au démarrage via le layout principal (RootLayout).

TS
new s3deploy.BucketDeployment(this, "DeployNextApp", {
  sources: [
    s3deploy.Source.asset("../frontend/out"),
    s3deploy.Source.jsonData("config.json", config),
  ],
  destinationBucket: bucket,
  distribution,
  distributionPaths: ["/*"],
});
Cliquez pour développer et voir plus

Installation de la bibliothèque OIDC

Dans votre dossier frontend, installez le package react-oidc-context (et oidc-client-ts requis en dépendance) pour gérer la connexion OpenID Connect avec Cognito :

SH
cd frontend
pnpm install oidc-client-ts react-oidc-context
Cliquez pour développer et voir plus

Le RootLayout dynamique

Votre fichier app/layout.tsx récupère automatiquement le fichier config.json au chargement de l’application, initialise le fournisseur d’authentification (AuthProvider), et sécurise le rendu de votre 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 introuvable (${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>Erreur de configuration : {error}</p>
        ) : !config ? (
          <p>Chargement…</p>
        ) : (
          <AuthProvider {...config}>
            <QueryClientProvider client={queryClient}>
              <ThemeProvider>{children}</ThemeProvider>
            </QueryClientProvider>
          </AuthProvider>
        )}
      </body>
    </html>
  )
}
Cliquez pour développer et voir plus

Utilisation de l’authentification dans un composant (Page de connexion / Navbar)

TS
const auth = useAuth();

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

const handleSignOut = () => {
  auth.signoutSilent();
};
Cliquez pour développer et voir plus

2. Le code de l’infrastructure (CDK)

avec la mise à jours de l’infra infrastructure/lib/infrastructure-stack.ts. devient

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 }, // voir remarque 1
      featurePlan: cognito.FeaturePlan.ESSENTIALS, // requis pour 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/", // dev local
        ],
        logoutUrls: [
          `https://${distribution.domainName}/`,
          "http://localhost:3001/",
        ],
        scopes: [
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.PHONE,
        ],
      },
    });

    // Domaine préfixe Cognito + moteur Managed Login
    const domain = userPool.addDomain("Domain", {
      cognitoDomain: { domainPrefix: "daily-app-auth-xyz" }, // unique par région
      managedLoginVersion: cognito.ManagedLoginVersion.NEWER_MANAGED_LOGIN,
    });

    // Style par défaut, celui que la console crée pour vous
    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: ["/*"],
    });
  }
}
Cliquez pour développer et voir plus

3. Déploiement en une étape

BASH
cd infrastructure
npm run deploy:all
Cliquez pour développer et voir plus

En sortie, récupérez l’URL CloudFront (domaine du type https://xxxxxxxxxxxx.cloudfront.net/) pour ouvrir l’application.

application view

application view

5. Nettoyer

Pour éviter des coûts après les tests :

SH
cd infrastructure
cdk destroy
Cliquez pour développer et voir plus

Commentaires

Commencer la recherche

Saisissez des mots-clés pour rechercher des articles

↑↓
↵
ESC
⌘K Raccourci