Menu
CoddyTech

Expiration des jetons

Fait partie de la section Authentification et sécurité du Journey Node.js de Coddy. Leçon 17 sur 24.

Un serveur ne peut pas révoquer un jeton : rien n’est stocké, donc un jeton volé fonctionne jusqu’à son expiration. Limite la durée de validité des jetons à quelques minutes plutôt qu’à plusieurs jours, et laisse le client en demander un nouveau.

jwtVerify refuse déjà un jeton dont la date exp est dépassée. exp est un instant exprimé en secondes depuis le 1er janvier 1970. Cela déclenche une erreur errors.JWTExpired. err instanceof errors.JWTExpired est vrai pour ce type d’erreur, ce qui te permet de distinguer un jeton expiré d’un jeton falsifié et de l’indiquer : un client qui lit Jeton expiré sait qu’il doit se reconnecter.

import { errors, jwtVerify } from 'jose';

try {
    await jwtVerify(inviteToken, inviteKey);
} catch (err) {
    if (err instanceof errors.JWTExpired) {
        return res.status(410).json({ error: 'This invite has run out' });
    }
    return res.status(400).json({ error: 'Not a real invite' });
}
challenge icon

Défi

Facile

Un serveur ne peut pas révoquer un jeton. Ainsi, un jeton volé continue de fonctionner jusqu’à son expiration. Donne une courte durée de vie aux jetons et indique clairement à une application quand son jeton a expiré, afin qu’elle sache qu’elle doit se reconnecter.

  1. Dans server.js, dans POST /token, fais expirer les jetons 15 minutes après leur création, au lieu d’une heure.
  2. Dans requireUser, examine l’erreur levée par jwtVerify. Pour un jeton expiré, jose lève une erreur errors.JWTExpired. Importe errors depuis jose. Si l’erreur est de ce type, réponds avec le statut 401 et {"error": "Token expired"}.
  3. Tous les autres problèmes renvoient toujours le statut 401 et {"error": "Log in first"} : absence d’en-tête, jeton modifié ou jeton signé avec une autre clé.

Essaie : appuie sur « Jouer ». Envoie POST /signup, puis POST /token, les deux avec {"username": "ada", "password": "correct horse"}. Envoie GET /inspect?token= suivi du nouveau jeton : exp est maintenant 900 secondes après iat. Les deux valeurs représentent le nombre de secondes écoulées depuis le 1er janvier 1970. Ensuite, voici un jeton signé avec le secret de ce serveur, qui a expiré le 1er janvier 2026 : eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.PL8VCHpGNwBJ-7EawZ60veEEE-ZyosNC7xYQQpgT2uE Dans le champ Headers, définis Authorization sur Bearer, suivi d’un espace et de ce jeton, puis envoie GET /me : le statut est 401 et la réponse est {"error": "Token expired"}. Modifie une lettre dans sa partie centrale et envoie-le de nouveau : le statut est 401 et la réponse est {"error": "Log in first"}. Un jeton modifié échoue à la vérification de signature avant que sa date soit lue.

Essayez vous-même

import express from 'express';
import { SignJWT, decodeJwt, jwtVerify } from 'jose';
import { users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

const app = express();
app.use(express.json());

app.post('/signup', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const password = String(req.body.password ?? '');
    if (!username || password.length < 8) {
        return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
    }
    if (await users.findOne({ username })) {
        return res.status(409).json({ error: 'That username is taken' });
    }
    await users.insertOne({ username, passwordHash: await hashPassword(password) });
    res.status(201).json({ username });
});

app.post('/login', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const user = await users.findOne({ username });
    if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
        return res.status(401).json({ error: 'Wrong username or password' });
    }
    res.json({ username: user.username });
});

app.post('/token', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const user = await users.findOne({ username });
    if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
        return res.status(401).json({ error: 'Wrong username or password' });
    }
    const token = await new SignJWT({ username: user.username })
        .setProtectedHeader({ alg: 'HS256' })
        .setSubject(user._id.toHexString())
        .setIssuedAt()
        .setExpirationTime('1h')
        .sign(secret);
    res.json({ token });
});

// Vérifie le jeton dans l’en-tête Authorization, ou renvoie 401.
async function requireUser(req, res, next) {
    const token = (req.get('authorization') ?? '').replace(/^Bearer /, '');
    let payload;
    try {
        ({ payload } = await jwtVerify(token, secret));
    } catch {
        return res.status(401).json({ error: 'Log in first' });
    }
    req.user = { id: payload.sub, username: payload.username };
    next();
}

app.get('/me', requireUser, (req, res) => {
    res.json(req.user);
});

// Affiche le contenu d’un jeton : GET /inspect?token=...
app.get('/inspect', (req, res) => {
    try {
        res.json(decodeJwt(String(req.query.token ?? '')));
    } catch {
        res.status(400).json({ error: 'Not a JWT' });
    }
});

// La page d’administration. Aucune déclaration n’est fiable tant que sa signature n’a pas été vérifiée.
app.get('/admin', (req, res) => {
    res.status(403).json({ error: 'Admins only' });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});

Toutes les leçons de Authentification et sécurité

Entraînez-vous par vous-même : Playground Express