Ce tutoriel explique comment implémenter les passkeys dans votre application web. Nous utilisons Node.js (TypeScript), SimpleWebAuthn, HTML / JavaScript Vanilla et MySQL.
Vincent
Created: June 20, 2025
Updated: June 24, 2025
We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.
Dans ce tutoriel, nous vous aidons dans vos efforts d'implémentation des passkeys, en offrant un guide étape par étape sur la façon d'ajouter les passkeys à votre site web.
Disposer d'une authentification moderne, robuste et conviviale est essentiel lorsque vous souhaitez créer un excellent site web ou une excellente application. Les passkeys sont apparus comme la réponse à ce défi. En tant que nouvelle norme pour les connexions, ils promettent un avenir sans les inconvénients des mots de passe traditionnels, offrant une expérience de connexion véritablement sans mot de passe (qui est non seulement sécurisée mais aussi très pratique).
Recent Articles
📖
Passkeys dans les applications natives : Implémentation native ou via WebView
👤
Dépannage des passkeys : solutions aux problèmes et erreurs
👤
Comment activer les passkeys sur Windows
⚙️
Tutoriel Passkeys : Comment implémenter les passkeys dans les applications web
⚙️
Tests E2E des passkeys avec Playwright via l'authentificateur virtuel WebAuthn
Ce qui exprime vraiment le potentiel des passkeys, c'est le soutien qu'ils ont recueilli. Tous les navigateurs importants, que ce soit Chrome, Firefox, Safari ou Edge, et tous les grands fabricants d'appareils (Apple, Microsoft, Google) ont intégré leur prise en charge. Cette adhésion unanime montre que les passkeys sont la nouvelle norme pour les connexions.
Oui, il existe déjà des tutoriels sur l'intégration des passkeys dans les applications web. Que ce soit pour des frameworks frontend comme React, Vue.js ou Next.js, il existe une pléthore de guides conçus pour atténuer les défis et accélérer vos implémentations de passkeys. Cependant, il manque un tutoriel de bout en bout qui reste minimaliste et proche du matériel. De nombreux développeurs nous ont contactés pour nous demander un tutoriel qui clarifie l'implémentation des passkeys pour les applications web.
C'est précisément pourquoi nous avons conçu ce guide. Notre objectif ? Créer une configuration minimale viable pour les passkeys, englobant les couches frontend, backend et base de données (cette dernière étant souvent négligée bien qu'elle puisse causer de sérieux maux de tête).
À la fin de ce parcours, vous aurez construit une application web minimale viable, où vous pourrez :
Pour ceux qui sont pressés ou qui souhaitent une référence, l'ensemble du code est disponible sur GitHub.
Curieux de voir à quoi ressemble le résultat final ? Voici un aperçu du projet final (nous admettons qu'il a l'air très basique, mais les choses intéressantes se trouvent sous la surface) :
Nous sommes pleinement conscients que certaines parties du code et du projet peuvent être réalisées différemment ou de manière plus sophistiquée, mais nous voulions nous concentrer sur l'essentiel. C'est pourquoi nous avons intentionnellement gardé les choses simples et centrées sur les passkeys.
Comment ajouter des passkeys à mon site web de production ?
Ceci est un exemple très minimal d'authentification par passkey. Les éléments suivants ne sont PAS pris en compte / implémentés dans ce tutoriel ou ne le sont que de manière très basique :
Obtenir une prise en charge complète de toutes ces fonctionnalités nécessite un effort de développement considérablement plus important. Pour ceux qui sont intéressés, nous recommandons de jeter un œil à cet article sur les idées fausses des développeurs de passkeys.
Avant de plonger dans l'implémentation des passkeys, examinons les compétences et les outils nécessaires. Voici ce dont vous avez besoin pour commencer :
Une solide maîtrise des éléments de base du web, à savoir HTML, CSS et JavaScript, est essentielle. Nous avons intentionnellement gardé les choses simples, en nous abstenant de tout framework JavaScript moderne et en nous appuyant sur du JavaScript / HTML Vanilla. La seule chose plus sophistiquée que nous utilisons est la bibliothèque wrapper WebAuthn @simplewebauthn/browser.
Pour notre backend, nous utilisons un serveur Node.js (Express)
écrit en TypeScript. Nous avons également décidé de travailler avec l'implémentation
serveur WebAuthn de SimpleWebAuthn
(@simplewebauthn/server
avec @simplewebauthn/typescript-types
). Il existe de
nombreuses implémentations de serveur WebAuthn disponibles, vous pouvez donc bien sûr
utiliser n'importe laquelle d'entre elles. Comme nous avons opté pour le serveur WebAuthn
en TypeScript, des connaissances de base en Node.js et npm sont
requises.
Toutes les données utilisateur et les clés publiques des passkeys sont stockées dans une base de données. Nous avons choisi MySQL comme technologie de base de données. Une compréhension fondamentale de MySQL et des bases de données relationnelles est utile, bien que nous vous guiderons à travers chaque étape.
Dans ce qui suit, nous utilisons souvent les termes WebAuthn et passkeys de manière interchangeable, même s'ils ne signifient pas officiellement la même chose. Pour une meilleure compréhension, en particulier dans la partie code, nous faisons cependant cette supposition.
Avec ces prérequis en place, vous êtes prêt à plonger dans le monde des passkeys.
Ben Gould
Head of Engineering
I’ve built hundreds of integrations in my time, including quite a few with identity providers and I’ve never been so impressed with a developer experience as I have been with Corbado.
10,000+ devs trust Corbado & make the Internet safer with passkeys. Got questions? We’ve written 150+ blog posts on passkeys.
Join Passkeys CommunityAvant d'entrer dans le code et les configurations, jetons un œil à l'architecture du système que nous voulons construire. Voici une décomposition de l'architecture que nous allons mettre en place :
Avec cette vue d'ensemble de l'architecture, vous devriez avoir une carte conceptuelle de la façon dont les composants de notre application fonctionnent. Au fur et à mesure, nous approfondirons chacun de ces composants, en détaillant leur configuration, leur paramétrage et leur interaction.
Le diagramme suivant décrit le flux du processus lors de l'inscription (sign-up) :
Le diagramme suivant décrit le flux du processus lors de l'authentification (connexion) :
De plus, vous trouverez ici la structure du projet (uniquement les fichiers les plus importants) :
passkeys-tutorial ├── src # Contient tout le code source TypeScript du backend │ ├── controllers # Logique métier pour gérer des types de requêtes spécifiques │ │ ├── authentication.ts # Logique d'authentification Passkey │ │ └── registration.ts # Logique d'inscription Passkey │ ├── middleware │ │ ├── customError.ts # Ajoute des messages d'erreur personnalisés de manière standardisée │ │ └── errorHandler.ts # Gestionnaire d'erreurs général │ ├── public │ │ ├── index.html # Fichier HTML principal du frontend │ │ ├── css │ │ │ └── style.css # Style de base │ │ └── js │ │ └── script.js # Logique JavaScript (incl. API WebAuthn) │ ├── routes # Définitions des routes API et de leurs gestionnaires │ │ └── routes.ts # Routes spécifiques aux passkeys │ ├── services │ │ ├── credentialService.ts# Interagit avec la table des identifiants │ │ └── userService.ts # Interagit avec la table des utilisateurs │ ├── utils # Fonctions d'aide et utilitaires │ | ├── constants.ts # Quelques constantes (ex. rpID) │ | └── utils.ts # Fonction d'aide │ ├── database.ts # Crée la connexion de Node.js à la base de données MySQL │ ├── index.ts # Point d'entrée du serveur Node.js │ └── server.ts # Gère tous les paramètres du serveur ├── config.json # Quelques configurations pour le projet Node.js ├── docker-compose.yml # Définit les services, réseaux et volumes pour les conteneurs Docker ├── Dockerfile # Crée une image Docker du projet ├── init-db.sql # Définit notre schéma de base de données MySQL ├── package.json # Gère les dépendances et les scripts du projet Node.js └── tsconfig.json # Configure la manière dont TypeScript compile votre code
Lors de l'implémentation des passkeys, la configuration de la base de données est un composant clé. Notre approche utilise un conteneur Docker exécutant MySQL, offrant un environnement simple et isolé, essentiel pour des tests et un déploiement fiables.
Notre schéma de base de données est intentionnellement minimaliste, ne comportant que deux tables. Cette simplicité facilite une compréhension plus claire et une maintenance plus aisée.
Structure détaillée des tables
1. Table des identifiants (Credentials) : Centrale pour l'authentification par passkey, cette table stocke les identifiants passkey. Colonnes critiques :
credential_id
, un type de données et un formatage appropriés sont cruciaux.2. Table des utilisateurs (Users) : Lie les comptes utilisateurs à leurs identifiants correspondants.
Notez que nous avons nommé la première table credentials
car cela correspond à notre
expérience et à ce que d'autres bibliothèques recommandent comme étant plus approprié
(contrairement à la suggestion de SimpleWebAuthn de la nommer authenticator
ou
authenticator_device
).
Les types de données pour credential_id
et public_key
sont cruciaux. Des erreurs
surviennent souvent à cause de types de données, d'encodage ou de formatage incorrects (en
particulier, la différence entre Base64 et Base64URL est une cause fréquente d'erreurs),
ce qui peut perturber tout le processus d'inscription ou de connexion.
Toutes les commandes SQL nécessaires à la configuration de ces tables sont contenues dans
le fichier init-db.sql
. Ce script garantit une initialisation rapide et sans erreur de
la base de données.
Pour des cas plus sophistiqués, vous pouvez ajouter credential_device_type
ou
credential_backed_up
pour stocker plus d'informations sur les identifiants et améliorer
l'expérience utilisateur. Nous nous en abstenons cependant dans ce tutoriel.
init-db.sqlCREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users (id) );
Après avoir créé ce fichier, nous créons un nouveau fichier docker-compose.yml
à la
racine du projet :
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
Ce fichier démarre la base de données MySQL sur le port 3306 et crée la structure de base de données définie. Il est important de noter que le nom et le mot de passe de la base de données utilisés ici sont simples à des fins de démonstration. Dans un environnement de production, vous devriez utiliser des identifiants plus complexes pour une sécurité renforcée.
Ensuite, nous passons à l'exécution de notre conteneur Docker. À ce stade, notre fichier
docker-compose.yml
ne comprend que ce seul conteneur, mais nous ajouterons d'autres
composants plus tard. Pour démarrer le conteneur, utilisez la commande suivante :
docker compose up -d
Une fois le conteneur en cours d'exécution, nous devons vérifier si la base de données fonctionne comme prévu. Ouvrez un terminal et exécutez la commande suivante pour interagir avec la base de données MySQL :
docker exec -it <container ID> mysql -uroot -p
Il vous sera demandé d'entrer le mot de passe root, qui est my-secret-pw
dans notre
exemple. Après vous être connecté, sélectionnez la base de données webauthn_db
et
affichez les tables à l'aide de ces commandes :
use webauthn_db; show tables;
À ce stade, vous devriez voir les deux tables définies dans notre script. Initialement, ces tables seront vides, ce qui indique que notre configuration de base de données est terminée et prête pour les prochaines étapes de l'implémentation des passkeys.
Le backend est le cœur de toute application passkey, agissant comme le hub central pour le traitement des requêtes d'authentification utilisateur provenant du frontend. Il communique avec la bibliothèque serveur WebAuthn pour gérer les requêtes d'inscription et de connexion, et il interagit avec votre base de données MySQL pour stocker et récupérer les identifiants utilisateur. Ci-dessous, nous vous guiderons dans la configuration de votre backend en utilisant Node.js (Express) avec TypeScript, qui exposera une API publique pour gérer toutes les requêtes.
Tout d'abord, créez un nouveau répertoire pour votre projet et naviguez-y à l'aide de votre terminal ou de votre invite de commande.
Exécutez la commande
npx create-express-typescript-application passkeys-tutorial
Cela crée un squelette de code de base d'une application Node.js (Express) écrite en TypeScript que nous pouvons utiliser pour des adaptations ultérieures.
Votre projet nécessite plusieurs paquets clés que nous devons installer en plus :
Passez dans le nouveau répertoire et installez-les avec les commandes suivantes (nous installons également les types TypeScript requis) :
cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid
Pour confirmer que tout est correctement installé, exécutez
npm run dev:nodemon
Cela devrait démarrer votre serveur Node.js en mode développement avec Nodemon, qui redémarre automatiquement le serveur à chaque modification de fichier.
Conseil de dépannage : Si vous rencontrez des erreurs, essayez de mettre à jour
ts-node
vers la version 10.8.1 dans le fichier package.json
, puis exécutez npm i
pour installer les mises à jour.
Votre fichier server.ts
a la configuration de base et le middleware pour une application
Express. Pour intégrer la fonctionnalité passkey, vous devrez
ajouter :
Ces améliorations sont essentielles pour permettre l'authentification par passkey dans le backend de votre application. Nous les configurerons plus tard.
Après avoir créé et démarré la base de données dans la
section 4, nous devons maintenant nous assurer que notre
backend peut se connecter à la base de données MySQL. Pour ce faire, nous créons un
nouveau fichier database.ts
dans le dossier /src
et ajoutons le contenu suivant :
database.tsimport mysql from "mysql2"; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, }); // Promisify for Node.js async/await. export const promisePool = pool.promise();
Ce fichier sera utilisé plus tard par notre serveur pour accéder à la base de données.
Jetons un bref coup d'œil à notre config.json
, où deux variables sont déjà définies : le
port sur lequel nous exécutons l'application et l'environnement :
config.json{ "PORT": 8080, "NODE_ENV": "development" }
package.json
peut rester tel quel et devrait ressembler à :
package.json{ "name": "passkeys-tutorial", "version": "0.0.1", "description": "passkeys-tutorial initialised with create-express-typescript-application.", "main": "src/index.ts", "scripts": { "build": "tsc", "start": "node ./build/src/index.js", "dev": "ts-node ./src/index.ts", "dev:nodemon": "nodemon -w src -e ts,json -x ts-node ./src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["express", "typescript"], "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/node": "^14.18.63", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "nodemon": "^2.0.22", "ts-node": "^10.8.1", "typescript": "^4.9.5" }, "dependencies": { "@simplewebauthn/server": "^8.3.5", "@types/express-session": "^1.17.10", "@types/uuid": "^9.0.7", "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.18.2", "express-session": "^1.17.3", "fs": "^0.0.1-security", "helmet": "^4.6.0", "morgan": "^1.10.0", "mysql2": "^3.6.5", "uuid": "^9.0.1" } }
index.ts
ressemble à :
index.tsimport app from "./server"; import config from "../config.json"; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info("Express application started on port: " + port); });
Dans server.ts
, nous devons adapter quelques autres choses. De plus, un cache temporaire
d'une sorte (par exemple, redis, memcache ou express-session) est
nécessaire pour stocker les challenges temporaires contre lesquels les utilisateurs
peuvent s'authentifier. Nous avons décidé d'utiliser express-session
et de déclarer le
module express-session
en haut pour que les choses fonctionnent avec express-session
.
De plus, nous rationalisons le routage et supprimons la gestion des erreurs pour l'instant
(cela sera ajouté au middleware plus tard) :
server.tsimport express, { Express } from "express"; import morgan from "morgan"; import helmet from "helmet"; import cors from "cors"; import config from "../config.json"; import { router as passkeyRoutes } from "./routes/routes"; import session from "express-session"; const app: Express = express(); declare module "express-session" { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set("json spaces", 4); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === "development" || config.NODE_ENV === "development") { app.use(morgan("dev")); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === "production" || config.NODE_ENV === "production") { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use("/api/passkey", passkeyRoutes); app.use(express.static("src/public")); export default app;
Pour gérer efficacement les données dans nos deux tables créées, nous allons développer
deux services distincts dans un nouveau répertoire src/services
:
authenticatorService.ts
et userService.ts
.
Chaque service encapsulera les méthodes CRUD (Create, Read, Update, Delete), nous
permettant d'interagir avec la base de données de manière modulaire et organisée. Ces
services faciliteront le stockage, la récupération et la mise à jour des données dans les
tables authenticator
et user
. Voici comment la structure de ces fichiers requis
devrait être organisée :
userService.ts
ressemble à ceci :
userService.tsimport { promisePool } from "../database"; // Adjust the import path as necessary import { v4 as uuidv4 } from "uuid"; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [ userId, ]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query( "SELECT * FROM users WHERE username = ?", [username], ); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query("INSERT INTO users (id, username) VALUES (?, ?)", [ id, username, ]); return { id, username }; }, };
credentialService.ts
ressemble à ce qui suit :
credentialService.tsimport { promisePool } from "../database"; import type { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; export const credentialService = { async saveNewCredential( userId: string, credentialId: string, publicKey: string, counter: number, transports: string, ) { try { await promisePool.query( "INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)", [userId, credentialId, publicKey, counter, transports], ); } catch (error) { console.error("Error saving new credential:", error); throw error; } }, async getCredentialByCredentialId( credentialId: string, ): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query( "SELECT * FROM credentials WHERE credential_id = ? LIMIT 1", [credentialId], ); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(",") : [], } as AuthenticatorDevice; } catch (error) { console.error("Error retrieving credential:", error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( "UPDATE credentials SET counter = ? WHERE credential_id = ?", [newCounter, credentialId], ); } catch (error) { console.error("Error updating credential counter:", error); throw error; } }, };
Pour gérer les erreurs de manière centralisée et faciliter le débogage, nous ajoutons un
fichier errorHandler.ts
:
errorHandler.tsimport { Request, Response, NextFunction } from "express"; import { CustomError } from "./customError"; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = ( err: CustomError, req: Request, res: Response, next: NextFunction, ) => { const statusCode = err.statusCode || 500; const message = err.message || "Internal Server Error"; console.log(message); res.status(statusCode).send({ error: message }); };
De plus, nous ajoutons un nouveau fichier customError.ts
car nous voudrons plus tard
pouvoir créer des erreurs personnalisées pour nous aider à trouver les bogues plus
rapidement :
customError.tsexport class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }
Dans le dossier utils
, nous créons deux fichiers constants.ts
et utils.ts
.
constant.ts
contient quelques informations de base sur le serveur WebAuthn, comme le nom
de la relying party, l'ID de la
relying party et l'origine :
constant.tsexport const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;
utils.ts
contient deux fonctions dont nous aurons besoin plus tard pour l'encodage et le
décodage des données :
utils.tsexport const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
Nous arrivons maintenant au cœur de notre backend : les contrôleurs. Nous créons deux
contrôleurs, un pour la création d'un nouveau passkey (registration.ts
) et un pour la
connexion avec un passkey (authentication.ts
).
registration.ts
ressemble à ceci :
registration.tsimport { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64 } from "../utils/utils"; import { rpName, rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from "express"; import { CustomError } from "../middleware/customError"; export const handleRegisterStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; if (!username) { return next(new CustomError("Username empty", 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError("User already exists", 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: "direct", excludeCredentials: [], authenticatorSelection: { residentKey: "preferred", }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleRegisterFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };
Passons en revue les fonctionnalités de nos contrôleurs, qui gèrent les deux points de terminaison clés du processus d'inscription WebAuthn. C'est aussi là que réside l'une des plus grandes différences avec l'authentification par mot de passe : pour chaque tentative d'inscription ou de connexion, deux appels API backend sont nécessaires, ce qui requiert un contenu frontend spécifique entre les deux. Les mots de passe n'ont généralement besoin que d'un seul point de terminaison.
1. Point de terminaison handleRegisterStart :
Ce point de terminaison est déclenché par le frontend, recevant un nom d'utilisateur pour créer un nouveau passkey et un nouveau compte. Dans cet exemple, nous n'autorisons la création d'un nouveau compte / passkey que s'il n'existe pas encore de compte. Dans les applications réelles, vous devriez gérer cela de manière à ce que les utilisateurs soient informés qu'un passkey existe déjà et que l'ajout depuis le même appareil n'est pas possible (mais l'utilisateur pourrait ajouter des passkeys depuis un autre appareil après une forme de confirmation). Pour des raisons de simplicité, nous ignorons cela dans ce tutoriel.
Les PublicKeyCredentialCreationOptions
sont préparées. residentKey
est défini sur
preferred
, et attestationType
sur direct
, recueillant plus de données de
l'authentificateur pour un stockage potentiel en base de données.
En général, les PublicKeyCredentialCreationOptions
se composent des données suivantes :
dictionary PublicKeyCredentialCreationOptions { required PublicKeyCredentialRpEntity rp; required PublicKeyCredentialUserEntity user; required BufferSource challenge; required sequence<PublicKeyCredentialParameters> pubKeyCredParams; unsigned long timeout; sequence<PublicKeyCredentialDescriptor> excludeCredentials = []; AuthenticatorSelectionCriteria authenticatorSelection; DOMString attestation = "none"; AuthenticationExtensionsClientInputs extensions; };
rp.name
) et le domaine
(rp.id
).user.name
, user.id
et
user.displayName
.L'ID utilisateur et le challenge sont stockés dans un objet de session, simplifiant le processus à des fins de tutoriel. De plus, la session est effacée après chaque tentative d'inscription ou de connexion.
2. Point de terminaison handleRegisterFinish :
Ce point de terminaison récupère l'ID utilisateur et le challenge définis précédemment. Il
vérifie la RegistrationResponse
avec le challenge. Si elle est valide, il stocke un
nouvel identifiant pour l'utilisateur. Une fois stocké dans la base de données, l'ID
utilisateur et le challenge sont supprimés de la session.
Conseil : Lors du débogage de votre application, nous vous recommandons vivement d'utiliser Chrome comme navigateur et ses fonctionnalités intégrées pour améliorer l'expérience de développement des applications basées sur les passkeys, par exemple l'authentificateur WebAuthn virtuel et le journal de l'appareil (voir nos conseils supplémentaires pour les développeurs de passkeys ci-dessous pour plus d'informations).
Ensuite, nous passons à authentication.ts
, qui a une structure et une fonctionnalité
similaires.
authentication.ts
ressemble à ceci :
authentication.tsimport { Request, Response, NextFunction } from "express"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64, base64ToUint8Array } from "../utils/utils"; import { rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts, } from "@simplewebauthn/server/esm"; import { CustomError } from "../middleware/customError"; export const handleLoginStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError("User not found", 404)); } req.session.loggedInUserId = user.id; // allowCredentials is purposely for this demo left empty. This causes all existing local credentials // to be displayed for the service instead only the ones the username has registered. const options = await generateAuthenticationOptions({ timeout: 60000, allowCredentials: [], userVerification: "required", rpID, }); req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleLoginFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const credentialID = isoBase64URL.toBase64(body.rawId); const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); const dbCredential: AuthenticatorDevice | null = await credentialService.getCredentialByCredentialId(credentialID); if (!dbCredential) { return next(new CustomError("Credential not registered with this site", 404)); } // @ts-ignore const user = await userService.getUserById(dbCredential.userID); if (!user) { return next(new CustomError("User not found", 404)); } // @ts-ignore dbCredential.credentialID = base64ToUint8Array(dbCredential.credentialID); // @ts-ignore dbCredential.credentialPublicKey = base64ToUint8Array( dbCredential.credentialPublicKey, ); let verification: VerifiedAuthenticationResponse; const opts: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: dbCredential, }; verification = await verifyAuthenticationResponse(opts); const { verified, authenticationInfo } = verification; if (verified) { await credentialService.updateCredentialCounter( uint8ArrayToBase64(bodyCredIDBuffer), authenticationInfo.newCounter, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.currentChallenge = undefined; req.session.loggedInUserId = undefined; } };
Notre processus d'authentification (connexion) implique deux points de terminaison :
1. Point de terminaison handleLoginStart :
Ce point de terminaison est activé lorsqu'un utilisateur tente de se connecter. Il vérifie d'abord si le nom d'utilisateur existe dans la base de données, renvoyant une erreur s'il n'est pas trouvé. Dans un scénario réel, vous pourriez proposer de créer un nouveau compte à la place.
Pour les utilisateurs existants, il récupère l'ID utilisateur de la base de données, le
stocke dans la session et génère des options PublicKeyCredentialRequestOptions
.
allowCredentials
est laissé vide pour ne pas restreindre l'utilisation des identifiants.
C'est pourquoi tous les passkeys disponibles pour cette relying party peuvent être
sélectionnés dans la modale des passkeys.
Le challenge généré est également stocké dans la session et les
PublicKeyCredentialRequestOptions
sont renvoyées au frontend.
Les PublicKeyCredentialRequestOptions
se composent des données suivantes :
dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
2. Point de terminaison handleLoginFinish :
Ce point de terminaison récupère le currentChallenge
et le loggedInUserId
de la
session.
Il interroge la base de données pour le bon identifiant en utilisant l'ID de l'identifiant
du corps de la requête. Si l'identifiant est trouvé, cela signifie que l'utilisateur
associé à cet ID d'identifiant peut maintenant être authentifié (connecté). Ensuite, nous
pouvons interroger l'utilisateur dans la table des utilisateurs via l'ID utilisateur que
nous obtenons de l'identifiant et vérifier l'authenticationResponse
en utilisant le
challenge et le corps de la requête. Si tout est réussi, nous affichons le message de
succès de la connexion. Si aucun identifiant correspondant n'est trouvé, une erreur est
envoyée.
De plus, si la vérification réussit, le compteur de l'identifiant est mis à jour, le challenge utilisé et le loggedInUserId sont supprimés de la session.
En plus de cela, nous pouvons supprimer le dossier src/app
et src/constant
ainsi que
tous les fichiers qu'ils contiennent.
Note : La gestion de session appropriée et la protection des routes, cruciales dans les applications réelles, sont omises ici pour des raisons de simplicité dans ce tutoriel.
Enfin, nous devons nous assurer que nos contrôleurs sont accessibles en ajoutant les
routes appropriées à routes.ts
qui se trouve dans un nouveau répertoire src/routes
:
routes.tsimport express from "express"; import { handleError } from "../middleware/errorHandler"; import { handleRegisterStart, handleRegisterFinish } from "../controllers/registration"; import { handleLoginStart, handleLoginFinish } from "../controllers/authentication"; const router = express.Router(); router.post("/registerStart", handleRegisterStart); router.post("/registerFinish", handleRegisterFinish); router.post("/loginStart", handleLoginStart); router.post("/loginFinish", handleLoginFinish); router.use(handleError); export { router };
Cette partie du tutoriel sur les passkeys se concentre sur la manière de prendre en charge
les passkeys dans le frontend de votre application. Nous avons un frontend très basique
composé de trois fichiers : index.html
, styles.css
et script.js
. Ces trois fichiers
se trouvent dans un nouveau dossier src/public
.
Le fichier index.html
contient un champ de saisie pour le nom d'utilisateur et deux
boutons pour s'inscrire et se connecter. De plus, nous importons le script
@simplewebauthn/browser
qui simplifie l'interaction avec l'API Web Authentication du
navigateur dans le fichier js/script.js
.
index.html
ressemble à ceci :
index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Passkey Tutorial</title> <link rel="stylesheet" href="css/style.css" /> </head> <body> <div class="container"> <h1>Passkey Tutorial</h1> <div id="message"></div> <div class="input-group"> <input type="text" id="username" placeholder="Enter username" /> <button id="registerButton">Register</button> <button id="loginButton">Login</button> </div> </div> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script> <script src="js/script.js"></script> </body> </html>
script.js
ressemble à ce qui suit :
script.jsdocument.getElementById("registerButton").addEventListener("click", register); document.getElementById("loginButton").addEventListener("click", login); function showMessage(message, isError = false) { const messageElement = document.getElementById("message"); messageElement.textContent = message; messageElement.style.color = isError ? "red" : "green"; } async function register() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get registration options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/registerStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); console.log(response); // Check if the registration options are ok. if (!response.ok) { throw new Error( "User already exists or failed to get registration options from server", ); } // Convert the registration options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new attestation is created. This also means a new public-private-key pair is created. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Send attestationResponse back to server for verification and storage. const verificationResponse = await fetch("/api/passkey/registerFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attestationResponse), }); if (verificationResponse.ok) { showMessage("Registration successful"); } else { showMessage("Registration failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } } async function login() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get login options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/loginStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); // Check if the login options are ok. if (!response.ok) { throw new Error("Failed to get login options from server"); } // Convert the login options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new assertionResponse is created. This also means that the challenge has been signed. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Send assertionResponse back to server for verification. const verificationResponse = await fetch("/api/passkey/loginFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionResponse), }); if (verificationResponse.ok) { showMessage("Login successful"); } else { showMessage("Login failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } }
Dans script.js
, il y a trois fonctions principales :
1. Fonction showMessage :
C'est une fonction utilitaire utilisée principalement pour afficher des messages d'erreur, aidant au débogage.
2. Fonction Register :
Déclenchée lorsque l'utilisateur clique sur « Register ». Elle extrait le nom
d'utilisateur du champ de saisie et l'envoie au point de terminaison
passkeyRegisterStart
. La réponse inclut les PublicKeyCredentialCreationOptions
,
qui sont converties en JSON et passées à SimpleWebAuthnBrowser.startRegistration
. Cet
appel active l'authentificateur de l'appareil (comme Face ID ou Touch ID). Après une
authentification locale réussie, le challenge signé est renvoyé au point de terminaison
passkeyRegisterFinish
, complétant le processus de création de passkey.
Pendant le processus d'inscription, l'objet d'attestation joue un rôle crucial, alors examinons-le de plus près.
L'objet d'attestation se compose principalement de trois éléments
: fmt
, attStmt
et authData
. L'élément fmt
signifie le format de la déclaration
d'attestation, tandis que attStmt
représente la déclaration d'attestation elle-même.
Dans les scénarios où l'attestation est jugée inutile, le fmt
sera désigné comme « none
», ce qui conduit à un attStmt
vide.
L'accent est mis sur le segment authData
au sein de cette structure. Ce segment est
essentiel pour récupérer des éléments clés tels que l'ID de la relying party, les flags,
le compteur et les données d'identifiant attestées sur notre serveur. Concernant les
flags, BS (Backup State) et BE (Backup Eligibility) sont particulièrement intéressants car
ils fournissent plus d'informations si un passkey est synchronisé (par exemple via le
Trousseau iCloud ou 1Password). De
plus, UV (User Verification) et UP (User Presence) fournissent d'autres informations
utiles.
Il est important de noter que diverses parties de l'objet d'attestation, y compris les données de l'authentificateur, l'ID de la relying party et la déclaration d'attestation, sont soit hachées, soit signées numériquement par l'authentificateur à l'aide de sa clé privée. Ce processus est essentiel pour maintenir l'intégrité globale de l'objet d'attestation.
3. Fonction Login :
Activée lorsque l'utilisateur clique sur « Login ». Similaire à la fonction d'inscription,
elle extrait le nom d'utilisateur et l'envoie au point de terminaison passkeyLoginStart
.
La réponse, contenant les PublicKeyCredentialRequestOptions
, est convertie en JSON
et utilisée avec SimpleWebAuthnBrowser.startAuthentication
. Cela déclenche
l'authentification locale sur l'appareil. Le challenge signé est ensuite renvoyé au point
de terminaison passkeyLoginFinish
. Une réponse réussie de ce point de terminaison
indique que l'utilisateur s'est connecté à l'application avec succès.
De plus, le fichier CSS d'accompagnement fournit un style simple pour l'application :
body { font-family: "Helvetica Neue", Arial, sans-serif; text-align: center; padding: 40px; background-color: #f3f4f6; color: #333; } .container { max-width: 400px; margin: auto; background: white; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); border-radius: 8px; } h1 { color: #007bff; font-size: 24px; margin-bottom: 20px; } .input-group { margin-bottom: 20px; } input[type="text"] { padding: 10px; margin-bottom: 10px; border: 1px solid #ced4da; border-radius: 4px; width: calc(100% - 22px); } button { width: calc(50% - 20px); padding: 10px 0; margin: 5px; font-size: 16px; cursor: pointer; border: none; border-radius: 4px; background-color: #007bff; color: white; } button:hover { background-color: #0056b3; } #message { color: #dc3545; margin: 20px; }
Pour voir votre application en action, compilez et exécutez votre code TypeScript avec :
npm run dev
Votre serveur devrait maintenant être opérationnel à l'adresse http://localhost:8080.
Considérations pour la production :
N'oubliez pas que ce que nous avons couvert n'est qu'un aperçu de base. Lors du déploiement d'une application passkey dans un environnement de production, vous devez approfondir les points suivants :
Nous avons déjà configuré un conteneur Docker pour notre base de données. Ensuite, nous
allons étendre notre configuration Docker Compose pour inclure le serveur avec le backend
et le frontend. Votre fichier docker-compose.yml
doit être mis à jour en conséquence.
Pour conteneuriser notre application, nous créons un nouveau Dockerfile qui installe les paquets requis et démarre le serveur de développement :
Docker# Use an official Node runtime as a parent image FROM node:20-alpine # Set the working directory in the container WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ # Install any needed packages RUN npm install # Bundle your app's source code inside the Docker image COPY . . # Make port 8080 available to the world outside this container EXPOSE 8080 # Define the command to run your app CMD ["npm", "run", "dev"]
Ensuite, nous étendons également le fichier docker-compose.yml
pour démarrer ce
conteneur :
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db
Si vous exécutez maintenant docker compose up
dans votre terminal et accédez à
http://localhost:8080, vous devriez voir la version fonctionnelle
de votre application web passkey (ici, fonctionnant sur
Windows 11 23H2 + Chrome 119) :
Comme nous travaillons depuis un certain temps sur des implémentations de passkeys, nous avons rencontré quelques défis si vous travaillez sur des applications passkey réelles :
De plus, nous avons les conseils suivants pour les développeurs en ce qui concerne la partie implémentation :
Utiliser le débogueur de Passkeys
Le débogueur de Passkeys aide à tester différentes configurations de serveur WebAuthn et réponses client. De plus, il fournit un excellent analyseur pour les réponses de l'authentificateur.
Déboguer avec la fonctionnalité de journal de l'appareil de Chrome
Utilisez le journal de l'appareil de Chrome (accessible via chrome://device-log/) pour surveiller les appels FIDO/WebAuthn. Cette fonctionnalité fournit des journaux en temps réel du processus d'authentification (connexion), vous permettant de voir les données échangées et de résoudre les problèmes qui surviennent.
Un autre raccourci très utile pour obtenir tous vos passkeys dans Chrome est d'utiliser chrome://settings/passkeys.
Utiliser l'authentificateur WebAuthn virtuel de Chrome
Pour éviter d'utiliser l'invite Touch ID, Face ID ou Windows Hello pendant le développement, Chrome est livré avec un authentificateur WebAuthn virtuel très pratique qui émule un véritable authentificateur. Nous vous recommandons vivement de l'utiliser pour accélérer les choses. Trouvez plus de détails ici.
Tester sur différentes plateformes et navigateurs
Assurez la compatibilité et la fonctionnalité sur divers navigateurs et plateformes. WebAuthn se comporte différemment selon les navigateurs, un test approfondi est donc essentiel.
Tester sur différents appareils
Ici, il est particulièrement utile de travailler avec des outils comme ngrok, où vous pouvez rendre votre application locale accessible sur d'autres appareils (mobiles).
Définir la vérification de l'utilisateur sur « Preferred »
Lors de la définition des propriétés pour userVerification
dans les
PublicKeyCredentialRequestOptions
, choisissez de les définir sur preferred
car
c'est un bon compromis entre convivialité et sécurité. Cela signifie que des contrôles de
sécurité sont en place sur les appareils appropriés tout en maintenant la convivialité sur
les appareils sans capacités biométriques.
Nous espérons que ce tutoriel sur les passkeys vous a permis de bien comprendre comment implémenter efficacement les passkeys. Tout au long du tutoriel, nous avons parcouru les étapes essentielles pour créer une application passkey, en nous concentrant sur les concepts fondamentaux et l'implémentation pratique. Bien que ce guide serve de point de départ, il y a beaucoup plus à explorer et à affiner dans le monde de WebAuthn.
Nous encourageons les développeurs à approfondir les nuances des passkeys (par exemple, l'ajout de plusieurs passkeys, la vérification de la compatibilité des appareils avec les passkeys ou l'offre de solutions de récupération). C'est un voyage qui vaut la peine d'être entrepris, offrant à la fois des défis et d'immenses récompenses dans l'amélioration de l'authentification des utilisateurs. Avec les passkeys, vous ne construisez pas seulement une fonctionnalité ; vous contribuez à un monde numérique plus sûr et plus convivial.
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles
Table of Contents