Daily Actions: Enregistrer des données dans DynamoDB via une API Gateway et une Lambda sécurisées par Cognito

Daily Actions: Enregistrer des données dans DynamoDB via une API Gateway et une Lambda sécurisées par Cognito

Dans l’article précédent, notre application Next.js statique a appris à authentifier ses utilisateurs avec Amazon Cognito. Mais une page de connexion ne protège rien à elle seule : tant que notre backend accepte des requêtes anonymes, n’importe qui peut écrire dans notre base.

Dans cet article, nous relions les morceaux. L’utilisateur connecté enverra une nouvelle action vers un endpoint API Gateway, une fonction Lambda l’insérera dans DynamoDB, et seules les requêtes portant un jeton Cognito valide passeront.

Prérequis

Comment la requête est sécurisée

  sequenceDiagram
    actor U as Navigateur (utilisateur connecté)
    participant G as API Gateway
    participant C as Authorizer Cognito
    participant L as Lambda create-action
    participant D as DynamoDB

    U->>G: POST /actions + Authorization: ID token
    G->>C: Vérifier le jeton
    alt jeton absent ou invalide
        C-->>G: Rejeté
        G-->>U: 401 Unauthorized (Lambda jamais invoquée)
    else jeton valide
        C-->>G: Claims (sub, email...)
        G->>L: Invoquer avec les claims
        L->>D: PutItem
        D-->>L: OK
        L-->>U: 201 Created
    end

L’authorizer vérifie la signature du jeton, son expiration et son audience par rapport à notre User Pool. Si la vérification échoue, API Gateway répond 401 lui-même : notre code ne s’exécute pas et aucune invocation Lambda n’est facturée.

1. La table DynamoDB

Chaque action appartient à un utilisateur : la clé de partition est donc l’utilisateur, et la clé de tri identifie l’action. Toutes les actions d’un utilisateur vivent dans la même partition et pourront être lues avec un Query peu coûteux (ce sera l’objet du prochain article).

Dans infrastructure/lib/infrastructure-stack.ts, ajoutez les imports :

TS
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs";
Cliquez pour développer et voir plus

puis la table, après les ressources Cognito :

TS
const actionsTable = new dynamodb.Table(this, "ActionsTable", {
  tableName: "DailyActions",
  partitionKey: { name: "userId", type: dynamodb.AttributeType.STRING },
  sortKey: { name: "actionId", type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  removalPolicy: cdk.RemovalPolicy.DESTROY, // tutoriel uniquement, pas pour la production
});
Cliquez pour développer et voir plus

2. La fonction Lambda

Installez les outils dans le dossier infrastructure :

SH
cd infrastructure
npm install -D esbuild @types/aws-lambda
npm install @aws-sdk/client-dynamodb
Cliquez pour développer et voir plus

Créez infrastructure/lambda/create-action.ts :

TS
import { randomUUID } from "node:crypto";
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

const client = new DynamoDBClient({});
const TABLE_NAME = process.env.TABLE_NAME!;
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "").split(",");

const reply = (
  event: APIGatewayProxyEvent,
  statusCode: number,
  body: unknown,
): APIGatewayProxyResult => {
  const origin = event.headers?.origin ?? event.headers?.Origin ?? "";
  return {
    statusCode,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin)
        ? origin
        : ALLOWED_ORIGINS[0],
    },
    body: JSON.stringify(body),
  };
};

export const handler = async (
  event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
  // 1. L'identité vient du jeton vérifié, jamais du corps de la requête
  const userId = event.requestContext.authorizer?.claims?.sub;
  if (!userId) {
    return reply(event, 401, { message: "Unauthorized" });
  }

  // 2. Valider l'entrée
  let body: { title?: unknown; dueDate?: unknown };
  try {
    body = JSON.parse(event.body ?? "{}");
  } catch {
    return reply(event, 400, { message: "Invalid JSON body" });
  }

  const title = typeof body.title === "string" ? body.title.trim() : "";
  if (title.length === 0 || title.length > 120) {
    return reply(event, 400, { message: "title must be 1 to 120 characters" });
  }

  // dueDate est optionnel, au format YYYY-MM-DD
  const dueDate = body.dueDate;
  if (
    dueDate !== undefined &&
    (typeof dueDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(dueDate))
  ) {
    return reply(event, 400, { message: "dueDate must be YYYY-MM-DD" });
  }

  // 3. Insérer
  const action = {
    userId,
    actionId: randomUUID(),
    title,
    done: false,
    createdAt: new Date().toISOString(),
    ...(dueDate && { dueDate }),
  };

  try {
    await client.send(
      new PutItemCommand({
        TableName: TABLE_NAME,
        // Each value carries its DynamoDB type: S = string, BOOL = boolean
        Item: {
          userId: { S: action.userId },
          actionId: { S: action.actionId },
          title: { S: action.title },
          done: { BOOL: action.done },
          createdAt: { S: action.createdAt },
          ...(action.dueDate && { dueDate: { S: action.dueDate } }),
        },
        ConditionExpression: "attribute_not_exists(actionId)",
      }),
    );
    return reply(event, 201, { action });
  } catch (error) {
    console.error("Error creating action:", error);
    return reply(event, 500, { message: "Failed to create action" });
  }
};
Cliquez pour développer et voir plus

Ce qui compte ici :

3. API Gateway avec un authorizer Cognito

Toujours dans la stack, définissez la fonction, l’API et l’authorizer :

TS
const allowedOrigins = [
  `https://${distribution.domainName}`,
  "http://localhost:3001", // dev local
];

const createActionFunction = new lambdaNodejs.NodejsFunction(
  this,
  "CreateActionFunction",
  {
    entry: "lambda/create-action.ts",
    handler: "handler",
    runtime: lambda.Runtime.NODEJS_22_X,
    environment: {
      TABLE_NAME: actionsTable.tableName,
      ALLOWED_ORIGINS: allowedOrigins.join(","),
    },
  },
);

// Moindre privilège : cette fonction ne peut appeler que PutItem sur cette table
actionsTable.grant(createActionFunction, "dynamodb:PutItem");

const api = new apigateway.RestApi(this, "DailyActionsApi", {
  restApiName: "Daily Actions API",
  defaultCorsPreflightOptions: {
    allowOrigins: allowedOrigins,
    allowMethods: ["POST"],
    allowHeaders: apigateway.Cors.DEFAULT_HEADERS, // inclut Authorization
  },
});

const authorizer = new apigateway.CognitoUserPoolsAuthorizer(
  this,
  "ActionsAuthorizer",
  { cognitoUserPools: [userPool] },
);

const actions = api.root.addResource("actions");
actions.addMethod(
  "POST",
  new apigateway.LambdaIntegration(createActionFunction),
  {
    authorizer,
    authorizationType: apigateway.AuthorizationType.COGNITO,
  },
);

// Les erreurs générées par API Gateway elle-même (401, 403...) ont aussi besoin des en-têtes CORS
const corsHeader = {
  "Access-Control-Allow-Origin": `'https://${distribution.domainName}'`,
};
api.addGatewayResponse("Unauthorized", {
  type: apigateway.ResponseType.UNAUTHORIZED,
  responseHeaders: corsHeader,
});
api.addGatewayResponse("Default4xx", {
  type: apigateway.ResponseType.DEFAULT_4XX,
  responseHeaders: corsHeader,
});

new cdk.CfnOutput(this, "ApiUrl", { value: api.url });
Cliquez pour développer et voir plus

Donnez ensuite l’URL de l’API au front-end en ajoutant une ligne à l’objet config.json de l’article précédent :

TS
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",
  apiUrl: api.url, // nouveau
};
Cliquez pour développer et voir plus

Les points à retenir :

4. Appeler l’API depuis Next.js

Le layout charge déjà config.json. Il nous faut maintenant apiUrl dans d’autres composants : nous exposons donc la config via un petit contexte React.

Créez lib/config.ts :

TS
import { createContext, useContext } from "react";

export interface AppConfig {
  apiUrl: string;
}

export const ConfigContext = createContext<AppConfig | null>(null);

export function useAppConfig() {
  const config = useContext(ConfigContext);
  if (!config)
    throw new Error("useAppConfig must be used inside ConfigContext");
  return config;
}
Cliquez pour développer et voir plus

Dans app/layout.tsx, séparez la config : la partie OIDC va à AuthProvider, apiUrl va dans notre contexte.

TSX
import { ConfigContext } from "@/lib/config"

interface Config {
  authority: string
  client_id: string
  redirect_uri: string
  response_type: string
  scope: string
  apiUrl: string // nouveau
}

// ...dans RootLayout, avant le return
const { apiUrl, ...oidcConfig } = config ?? ({} as Config)

// ...puis, à la place de <AuthProvider {...config}>
<AuthProvider {...oidcConfig}>
  <ConfigContext.Provider value={{ apiUrl }}>
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>{children}</ThemeProvider>
    </QueryClientProvider>
  </ConfigContext.Provider>
</AuthProvider>
Cliquez pour développer et voir plus

Le projet possède déjà une couche de services dans app/todo : TodoServices dialogue avec le backend, useTodoQuery l’enveloppe avec TanStack Query, et la page n’appelle que addTodo. Nous gardons cette structure et ne changeons que le POST.

Dans app/todo/services/TodoServices.ts, remplacez addTodo :

TS
public async addTodo(
  apiUrl: string,
  idToken: string,
  todo: Omit<Todo, "id" | "createdAt">
): Promise<Todo> {
  // L'authorizer Cognito attend le jeton ID, sans préfixe "Bearer"
  const response = await fetch(`${apiUrl}actions`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: idToken,
    },
    body: JSON.stringify({ title: todo.text, dueDate: todo.dueDate }),
  })
  if (!response.ok) {
    const { message } = await response
      .json()
      .catch(() => ({ message: response.statusText }))
    throw new Error(message)
  }

  // L'API renvoie une "action" : on la convertit en Todo
  const { action } = await response.json()
  return {
    id: action.actionId,
    userId: action.userId,
    text: action.title,
    completed: action.done,
    dueDate: action.dueDate,
    createdAt: Date.parse(action.createdAt),
  }
}
Cliquez pour développer et voir plus

L’API parle en title / done alors que le modèle du front utilise text / completed : addTodo fait donc la traduction dans les deux sens.

Ensuite, dans app/todo/services/todoQuery.ts, donnez à addTodo l’URL de l’API et le jeton ID. Les autres appels (fetchTodos, updateTodo, deleteTodo) visent encore l’ancien backend : nous les remplacerons dans les prochains articles.

TS
import { useAppConfig } from "@/lib/config";

export function useTodoQuery() {
  const auth = useAuth();
  const token = auth.user?.access_token;
  const idToken = auth.user?.id_token; // nouveau
  const { apiUrl } = useAppConfig(); // nouveau
  // ...

  const addTodoMutation = useMutation({
    mutationFn: (newTodo: Omit<Todo, "id" | "createdAt">) =>
      todoServices.addTodo(apiUrl, idToken!, newTodo), // modifié
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
  // ...
}
Cliquez pour développer et voir plus

Enfin, dans app/todo/page.tsx, supprimez la ligne userId: \USER#${auth?.user?.profile?.sub}`denewTodo(etuseAuth`, devenu inutile). Le backend déduit le propriétaire du jeton, le client n’a rien à envoyer.

La page elle-même ne change pas : son formulaire appelle déjà addTodo avec le texte et l’échéance optionnelle.

5. Déployer

Construisez le front-end, puis déployez le tout avec la commande de l’article précédent :

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

À la fin, CDK affiche la sortie ApiUrl (https://xxxx.execute-api.<region>.amazonaws.com/prod/).

6. Tester

Une requête anonyme doit échouer

BASH
curl -i -X POST "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions" \
  -H "Content-Type: application/json" \
  -d '{"title":"Je ne suis pas connecté"}'
Cliquez pour développer et voir plus

Vous devez obtenir HTTP/2 401 avec {"message":"Unauthorized"}. Dans CloudWatch, il n’y a aucun log pour CreateActionFunction : la requête ne l’a jamais atteinte.

Une requête authentifiée doit réussir

Connectez-vous sur l’URL CloudFront, ouvrez la page /todo et ajoutez une tâche : elle est enregistrée dans DynamoDB (vérifiez-la avec la commande scan plus bas). La liste ne l’affiche pas encore, car la lecture arrive dans le prochain article.

Pour rejouer l’appel avec curl, copiez l’ID token depuis le navigateur : DevTools → Application → Session Storage → la clé qui commence par oidc.user:, champ id_token.

BASH
curl -i -X POST "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions" \
  -H "Content-Type: application/json" \
  -H "Authorization: <id_token>" \
  -d '{"title":"Écrire mon article"}'
Cliquez pour développer et voir plus

Attendu : HTTP/2 201 et l’action créée dans le corps.

Vérifier les données

BASH
aws dynamodb scan --table-name DailyActions
Cliquez pour développer et voir plus

L’élément doit porter votre sub Cognito comme userId, et non une valeur envoyée par le client. Essayez d’envoyer {"title":"x","userId":"quelqu-un-d-autre"} : le champ en trop est ignoré.

7. Nettoyage

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