Le test d'authentification API couvre trois états critiques au-delà du chemin nominal. Les requêtes non authentifiées doivent renvoyer 401. Les requêtes avec des permissions insuffisantes doivent renvoyer 403. Et les requêtes avec des tokens expirés doivent échouer proprement plutôt que silencieusement.
Les trois questions auxquelles tout test d'authentification doit répondre
Avant d'écrire une seule assertion, tout test API impliquant l'authentification doit répondre à trois questions.
Qui suis-je ? L'identité est établie par un identifiant : une clé API, un couple nom d'utilisateur/mot de passe, un client ID et un secret. Le test a besoin d'un moyen de fournir cet identifiant sans le coder en dur. Ai-je la permission ? L'authentification (prouver qui vous êtes) et l'autorisation (ce que vous avez le droit de faire) sont deux systèmes différents. Un token valide pour un utilisateur ordinaire ne doit pas donner accès à un endpoint réservé aux administrateurs. Les tests qui vérifient uniquement que les utilisateurs authentifiés peuvent accéder aux ressources passent à côté de la moitié des bugs qui arrivent en production. Mon identifiant est-il toujours valide ? Les tokens expirent. Les clés API sont renouvelées. Les tests qui mettent en cache un token au démarrage et tournent pendant des heures avec un token de courte durée échoueront de façon trompeuse. L'échec n'apparaît pas comme une erreur d'authentification, mais comme une "requête échouée" ou un 401 déroutant au milieu d'une suite qui avait bien démarré.Chaque section de cet article répond à une ou plusieurs de ces questions.
Authentification par clé API
Les clés API sont la forme la plus simple d'authentification : une chaîne secrète statique que le client envoie avec chaque requête. Elles s'envoient de deux façons.
La clé dans un header est plus courante et plus sécurisée. Le nom du header varie selon les APIs :X-API-Key, Authorization, Api-Key s'utilisent tous dans la pratique.
curl -H "X-API-Key: sk_live_abc123" https://api.example.com/v1/dataimport { test, expect } from '@playwright/test';
test('GET /data avec clé API dans le header', async ({ request }) => {
const response = await request.get('https://api.example.com/v1/data', {
headers: {
'X-API-Key': process.env.API_KEY ?? ''
}
});
expect(response.status()).toBe(200);
});curl "https://api.example.com/v1/data?api_key=sk_live_abc123"test('GET /data avec clé API dans le paramètre de requête', async ({ request }) => {
const response = await request.get('https://api.example.com/v1/data', {
params: {
api_key: process.env.API_KEY ?? ''
}
});
expect(response.status()).toBe(200);
});Quand la même clé API s'applique à tous les tests d'une suite, configurez-la une seule fois dans playwright.config.ts plutôt que de la répéter par test :
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'X-API-Key': process.env.API_KEY ?? ''
}
}
});Chaque requête faite par la fixture request inclura automatiquement ce header. Vous éliminez le bruit par test sans perdre la possibilité de surcharger le header pour des tests spécifiques qui ont besoin d'identifiants différents.
.env et des secrets de dépôt en CI. Une clé commitée dans le contrôle de version doit être considérée comme compromise immédiatement.Tokens Bearer et JWTs
L'authentification par token Bearer est un processus en deux étapes : obtenir un token, puis l'utiliser. Les JSON Web Tokens (JWTs) sont le format de token le plus courant. Ce sont des structures JSON encodées en base64 qui contiennent des informations sur l'identité et les permissions de l'utilisateur, ainsi qu'un timestamp d'expiration.
Obtenir un token ressemble à un POST de connexion standard :
import { test, expect } from '@playwright/test';
test('obtenir un token Bearer et appeler un endpoint protégé', async ({ request }) => {
// Étape 1 : s'authentifier et recevoir un token
const loginRes = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD
}
});
expect(loginRes.status()).toBe(200);
const { access_token } = await loginRes.json();
// Étape 2 : l'utiliser sur chaque requête protégée
const profileRes = await request.get('/api/user/profile', {
headers: {
Authorization: `Bearer ${access_token}`
}
});
expect(profileRes.status()).toBe(200);
const profile = await profileRes.json();
expect(profile).toHaveProperty('id');
expect(profile).toHaveProperty('email');
});Pour une suite qui exécute de nombreux tests sous le même utilisateur, déplacez la connexion dans beforeAll. Le token est récupéré une seule fois et partagé :
import { test, expect } from '@playwright/test';
let accessToken: string;
test.beforeAll(async ({ request }) => {
const res = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD
}
});
const body = await res.json();
accessToken = body.access_token;
});
test('lister les commandes en tant qu\'utilisateur authentifié', async ({ request }) => {
const response = await request.get('/api/orders', {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(response.status()).toBe(200);
});
test('obtenir les détails d\'une commande', async ({ request }) => {
const response = await request.get('/api/orders/123', {
headers: { Authorization: `Bearer ${accessToken}` }
});
expect(response.status()).toBe(200);
});JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()). C'est utile quand vous voulez vérifier que le token contient les rôles ou le temps d'expiration attendus sans appeler un autre endpoint API.Basic auth
L'authentification HTTP Basic encode un nom d'utilisateur et un mot de passe sous la forme base64(username:password) et les envoie dans le header Authorization. C'est une méthode ancienne, qu'on trouve surtout dans les outils internes, les APIs legacy et les environnements de test.
Playwright gère l'encodage pour vous via l'option httpCredentials :
test('Basic auth via httpCredentials', async ({ playwright }) => {
const context = await playwright.request.newContext({
baseURL: 'https://api.example.com',
httpCredentials: {
username: process.env.BASIC_USER ?? '',
password: process.env.BASIC_PASS ?? ''
}
});
const response = await context.get('/protected/resource');
expect(response.status()).toBe(200);
await context.dispose();
});Vous pouvez aussi le faire manuellement en définissant le header directement :
test('Basic auth via header Authorization', async ({ request }) => {
const credentials = Buffer.from(
`${process.env.BASIC_USER}:${process.env.BASIC_PASS}`
).toString('base64');
const response = await request.get('/protected/resource', {
headers: {
Authorization: `Basic ${credentials}`
}
});
expect(response.status()).toBe(200);
});L'approche httpCredentials est plus propre pour un contexte entier. L'approche par header manuel est utile quand vous devez tester des cas limites comme des identifiants mal formés, ou quand différentes requêtes du même test ont besoin d'identifiants différents.
OAuth2 client credentials pour les tests service à service
OAuth2 client credentials est la variante machine à machine d'OAuth2. Aucune connexion utilisateur n'intervient. Un service s'authentifie avec son client ID et son secret, reçoit un token d'accès, et utilise ce token pour appeler l'API d'un autre service. C'est courant dans les architectures microservices et les intégrations tierces.
Le flux :
curl -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=myapp&client_secret=secret123&scope=read:orders"Dans Playwright :
import { test, expect } from '@playwright/test';
async function getClientCredentialsToken(request: any): Promise<string> {
const tokenRes = await request.post('https://auth.example.com/oauth/token', {
form: {
grant_type: 'client_credentials',
client_id: process.env.OAUTH_CLIENT_ID ?? '',
client_secret: process.env.OAUTH_CLIENT_SECRET ?? '',
scope: 'read:orders write:orders'
}
});
expect(tokenRes.status()).toBe(200);
const { access_token } = await tokenRes.json();
return access_token;
}
test('appeler l\'API orders avec un token OAuth2 client credentials', async ({ request }) => {
const token = await getClientCredentialsToken(request);
const response = await request.get('https://api.example.com/v1/orders', {
headers: {
Authorization: `Bearer ${token}`
}
});
expect(response.status()).toBe(200);
});Les tokens client credentials expirent aussi. Pour une suite qui exécute de nombreux tests, la même stratégie de rafraîchissement que pour les tokens Bearer s'applique. Obtenez le token une fois dans beforeAll, suivez son expiration, et rafraîchissez-le avant qu'il soit utilisé.
Le champ scope compte pour les tests d'autorisation. Demander read:orders doit autoriser un GET mais pas un POST. Demander un scope qui ne vous a pas été accordé doit renvoyer 403, pas 401. Ces cas méritent d'être testés explicitement.
Tester les états d'échec d'authentification : 401 vs 403
La différence entre 401 et 403 n'est pas cosmétique. Un 401 signifie que la requête n'avait pas d'identifiant valide : le serveur ne sait pas qui vous êtes. Un 403 signifie que le serveur sait qui vous êtes mais refuse ce que vous demandez. Renvoyer le mauvais code est un bug, et un bug qui vaut la peine d'être testé.
import { test, expect } from '@playwright/test';
test('401 quand aucun identifiant n\'est envoyé', async ({ request }) => {
// Pas de header Authorization : le serveur doit rejeter comme non authentifié
const response = await request.get('/api/admin/users');
expect(response.status()).toBe(401);
});
test('403 quand authentifié mais sans permission suffisante', async ({ request }) => {
// Se connecter en tant qu'utilisateur ordinaire (pas admin)
const loginRes = await request.post('/api/auth/login', {
data: {
email: process.env.REGULAR_USER_EMAIL,
password: process.env.REGULAR_USER_PASSWORD
}
});
const { access_token } = await loginRes.json();
// Tenter d'accéder à un endpoint réservé aux administrateurs
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(response.status()).toBe(403);
});
test('401 quand le token est expiré', async ({ request }) => {
// Un token expiré connu (codé en dur pour ce test spécifique)
const expiredToken = process.env.EXPIRED_JWT_TOKEN ?? '';
const response = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${expiredToken}` }
});
expect(response.status()).toBe(401);
});
test('401 quand le token est mal formé', async ({ request }) => {
const response = await request.get('/api/user/profile', {
headers: { Authorization: 'Bearer not.a.valid.jwt' }
});
expect(response.status()).toBe(401);
});Tester les tokens expirés est difficile parce qu'on ne contrôle pas toujours le temps. Trois approches pratiques existent. Conserver un token expiré connu dans une variable d'environnement de test, qui n'a jamais besoin de fonctionner, juste d'être expiré. Lancer les tests contre un serveur avec un décalage d'horloge configurable. Ou appeler un endpoint qui permet d'expirer explicitement un token.
Autre point à tester : est-ce qu'un token d'un environnement fonctionne dans un autre ? Est-ce qu'un token émis pour l'utilisateur A permet d'accéder aux ressources de l'utilisateur B ? Cette deuxième question correspond à la catégorie BOLA (Broken Object Level Authorization) couverte dans la section suivante.
Fixture d'authentification réutilisable avec rafraîchissement automatique des tokens
Répéter le flux de connexion dans chaque test crée du bruit. Pire, un token en cache dans beforeAll pour toute la durée d'une longue suite expirera silencieusement en cours d'exécution. La solution est une fixture qui gère les deux : partager le token entre les tests, mais suivre quand il doit être rafraîchi.
// fixtures/auth.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
interface AuthContext {
getToken: () => Promise<string>;
asUser: (role: 'admin' | 'user' | 'readonly') => Promise<string>;
}
interface TokenCache {
token: string;
expiresAt: number; // Timestamp Unix en ms
}
const tokenCache = new Map<string, TokenCache>();
async function fetchToken(
request: APIRequestContext,
email: string,
password: string
): Promise<string> {
const cacheKey = email;
const cached = tokenCache.get(cacheKey);
// Renvoyer le token en cache s'il lui reste plus de 30 secondes
if (cached && cached.expiresAt - Date.now() > 30_000) {
return cached.token;
}
const res = await request.post('/api/auth/login', {
data: { email, password }
});
const body = await res.json();
const { access_token, expires_in } = body;
tokenCache.set(cacheKey, {
token: access_token,
expiresAt: Date.now() + expires_in * 1000
});
return access_token;
}
type AuthFixtures = { auth: AuthContext };
export const test = base.extend<AuthFixtures>({
auth: async ({ request }, use) => {
const authContext: AuthContext = {
getToken: () =>
fetchToken(
request,
process.env.TEST_USER_EMAIL ?? '',
process.env.TEST_USER_PASSWORD ?? ''
),
asUser: (role) => {
const credentials: Record<string, { email: string; password: string }> = {
admin: {
email: process.env.ADMIN_EMAIL ?? '',
password: process.env.ADMIN_PASSWORD ?? ''
},
user: {
email: process.env.TEST_USER_EMAIL ?? '',
password: process.env.TEST_USER_PASSWORD ?? ''
},
readonly: {
email: process.env.READONLY_EMAIL ?? '',
password: process.env.READONLY_PASSWORD ?? ''
}
};
const creds = credentials[role];
return fetchToken(request, creds.email, creds.password);
}
};
await use(authContext);
}
});
export { expect } from '@playwright/test';Les tests qui utilisent cette fixture sont propres et explicites sur le rôle sous lequel ils opèrent :
import { test, expect } from '../fixtures/auth.fixture';
test('l\'admin peut lister tous les utilisateurs', async ({ request, auth }) => {
const token = await auth.asUser('admin');
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${token}` }
});
expect(response.status()).toBe(200);
});
test('l\'utilisateur readonly ne peut pas supprimer un enregistrement', async ({ request, auth }) => {
const token = await auth.asUser('readonly');
const response = await request.delete('/api/records/123', {
headers: { Authorization: `Bearer ${token}` }
});
expect(response.status()).toBe(403);
});Le cache Map est au niveau du module, donc il persiste entre les tests du même processus worker. La marge de 30 secondes avant l'expiration signifie qu'un token est rafraîchi avant qu'il n'expire vraiment, pas après qu'il a causé un échec.
Bases du test de sécurité : quels bugs d'authentification chercher
Les tests d'authentification vérifient que les mécanismes fonctionnent. Les tests de sécurité vérifient que ces mécanismes ne peuvent pas être contournés. Ce sont deux choses différentes, et c'est dans la seconde que vivent les bugs intéressants.
Broken Object Level Authorization (BOLA) est la vulnérabilité de sécurité API la plus courante. L'utilisateur A se connecte et récupère son profil à/api/users/42. La question à tester : est-ce que l'utilisateur A peut aussi récupérer /api/users/43 ? L'ID est devinable, et beaucoup d'APIs oublient de vérifier que l'utilisateur demandeur est bien propriétaire de la ressource demandée.
test('BOLA : l\'utilisateur A ne peut pas lire le profil de l\'utilisateur B', async ({ request }) => {
// S'authentifier en tant qu'utilisateur A
const loginA = await request.post('/api/auth/login', {
data: { email: process.env.USER_A_EMAIL, password: process.env.USER_A_PASSWORD }
});
const { access_token: tokenA } = await loginA.json();
// Obtenir le profil de l'utilisateur A pour trouver son ID
const profileA = await request.get('/api/user/me', {
headers: { Authorization: `Bearer ${tokenA}` }
});
const { id: idA } = await profileA.json();
// Obtenir l'ID de l'utilisateur B (depuis un autre compte de test que vous contrôlez)
const idB = process.env.USER_B_ID ?? '';
// Tenter de lire le profil de l'utilisateur B avec le token de l'utilisateur A
const attemptedAccess = await request.get(`/api/users/${idB}`, {
headers: { Authorization: `Bearer ${tokenA}` }
});
// Doit renvoyer 403, pas 200
expect(attemptedAccess.status()).toBe(403);
});Autres patterns à vérifier :
Authentification manquante sur des endpoints non documentés. Les parties plus anciennes d'une API, les endpoints internes, ou les routes récemment ajoutées manquent parfois de middleware d'authentification. Demander systématiquement des endpoints sans identifiants et vérifier qu'aucun ne renvoie 200 est un test de balayage utile. Escalade de privilèges. Si votre API a un champ rôle dans le corps de la requête (par exemple{ "role": "admin" }), un utilisateur ordinaire qui envoie ce champ doit être ignoré ou rejeté. Pas promu.
Réutilisation du token après déconnexion. Après avoir appelé /api/auth/logout, le token d'accès doit être invalidé côté serveur. L'utiliser à nouveau doit renvoyer 401.
test('le token est invalidé après la déconnexion', async ({ request }) => {
const loginRes = await request.post('/api/auth/login', {
data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD }
});
const { access_token } = await loginRes.json();
// Confirmer que le token fonctionne
const beforeLogout = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(beforeLogout.status()).toBe(200);
// Se déconnecter
await request.post('/api/auth/logout', {
headers: { Authorization: `Bearer ${access_token}` }
});
// Le token ne doit plus être accepté
const afterLogout = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${access_token}` }
});
expect(afterLogout.status()).toBe(401);
});Ces tests ne nécessitent pas de scanner de sécurité. Ce sont juste des appels API avec des entrées délibérément incorrectes ou des identifiants mal assortis. L'investissement est faible ; la valeur est de détecter les vulnérabilités avant un test d'intrusion ou, pire, un vrai incident.
FAQ
Dois-je utiliser un seul ensemble d'identifiants de test pour toute la suite, ou des identifiants uniques par test ?Un ensemble par rôle suffit généralement. Le risque que des identifiants partagés causent des interférences entre tests est faible tant que les tests font le ménage après eux. Le problème principal est la concurrence. Si des workers parallèles se connectent avec le même utilisateur et que le serveur suit les sessions strictement, vous pouvez avoir des échecs d'authentification inattendus. Des identifiants séparés par worker parallèle évite ça.
Quelle est la bonne façon de stocker plusieurs identifiants de test pour différents rôles ?Utilisez des variables d'environnement préfixées : ADMIN_EMAIL, ADMIN_PASSWORD, READONLY_EMAIL, etc. Associez-les aux noms de rôles dans une fixture comme montré plus haut. Ne stockez jamais les mots de passe dans les fichiers de fixture ou les helpers de test, seulement dans des variables d'environnement ou un gestionnaire de secrets.
Si le token d'accès expire pendant un lancement de tests, vous devez échanger le refresh token contre un nouveau. L'approche de cache de token dans la section fixture gère ça : quand le token en cache est à moins de 30 secondes de l'expiration, il se ré-authentifie. Pour les flux OAuth2 avec des refresh tokens explicites, stockez le token d'accès et le refresh token dans le cache. Tentez un échange grant_type=refresh_token avant de retomber sur une reconnexion complète.
Pas au niveau API. Le flux authorization code est un flux navigateur côté utilisateur. Testez-le avec la fixture page de Playwright et un navigateur. Pour les tests API, limitez-vous aux flux qui ne nécessitent pas d'interaction utilisateur. Client credentials sert pour le service à service. Password grant (si le serveur le supporte) sert pour l'impersonation d'utilisateur dans les tests.
C'est un problème de conception côté serveur, mais vous devez quand même le gérer. Vérifiez le code de statut et le corps dans votre assertion :
const response = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${token}` }
});
// Certaines APIs renvoient 200 avec un corps d'erreur au lieu de 401
const body = await response.json();
expect(body.error).toBeUndefined();
expect(response.status()).toBe(200);Oui, mais attention à l'état partagé. Si des tests écrivent dans les données du même utilisateur, l'exécution parallèle crée des conditions de course. Exécutez les opérations d'écriture sous des utilisateurs différents ou sérialisez-les. Les tests d'authentification en lecture seule se parallélisent sans problème parce qu'ils ne modifient pas d'état.
→ See also: Tests d'API avec l'APIRequestContext de Playwright (Sans Postman) | Gérer l'Authentification dans Playwright avec storageState (Sans Se Connecter à Chaque Test) | Tests d'API Avancés avec Playwright: Patterns pour des Projets Réels