Aller au contenu

Identifier le client

Un jeton signé identifie le client auprès du widget. Il est émis par votre backend et vérifié par Service : ses affirmations ne peuvent donc pas être falsifiées.

Les valeurs de la page transmettre le contexte sont des indications et ne sont pas de confiance. Un jeton transporte deux choses qu’une indication ne peut pas : l’identité et les verrous.

jeton = base64url(payload_json) "." base64url(HMAC_SHA256(secret, base64url(payload_json)))

Le MAC couvre le segment base64url tel que transmis, et non les octets JSON. La vérification ne re-sérialise donc jamais votre empreinte : nous ne pouvons pas être en désaccord avec vous sur l’ordre des clés ou l’échappement unicode.

{
"iss": "ivk_…",
"sub": "member-8891",
"iat": 1754297400,
"exp": 1754298300,
"guest": { "first_name": "Marie", "last_name": "Lefèvre", "email": "", "phone": "", "locale": "fr" },
"consent": { "marketing_email": true },
"reservation": {
"date": "2026-09-03",
"shift_id": 1,
"metadata": { "voucher": "GOLF-3P-8891" }
},
"locked": ["date", "shift_id"],
"return_url": "https://groupe.example/sejour/8891"
}

iss est l’identifiant public de votre clé de vérification — il sélectionne le secret, il n’y a donc aucun identifiant de clé distinct à transmettre. La rotation consiste à « créer une deuxième clé, basculer votre site, puis révoquer la première » : une opération sur les données, pas une modification d’identifiants.

Transmettez-le au widget et à rien d’autre :

widget.setGuestToken(await monBackend.creerJetonService(utilisateurActuel));

Fabriquez-le sur votre serveur. Le secret signe des affirmations sur l’identité de quelqu’un ; une copie dans une page est une copie que n’importe qui peut lire et utiliser pour affirmer n’importe quoi.

Le restaurant la crée depuis le back-office, dans Paramètres › Développeurs › Identification du client. C’est une fonctionnalité de l’offre Premium, et seul un utilisateur ayant le rôle Propriétaire peut en créer une.

Le secret reste lisible. Il n’y a pas d’affichage unique à recopier : il peut être affiché de nouveau à tout moment depuis le même écran, et chaque affichage est journalisé — qui l’a consulté, et quand. C’est l’inverse de ce que le produit fait de ses autres secrets, et c’est délibéré : perdre sa copie n’est pas un incident, et le consulter n’est pas gratuit.

Une clé couvre un restaurant — sauf si ce restaurant appartient à un groupe. La plupart n’en font pas partie ; si c’est votre cas, il n’y a rien de plus à savoir ici : la clé que vous créez couvre votre restaurant, et les deux paragraphes suivants ne vous concernent pas.

Pour les restaurants appartenant à un groupe, une seule clé les couvre tous. Il n’y a rien à créer par restaurant ni à restreindre : une clé créée depuis n’importe quel restaurant du groupe se vérifie pour tous les restaurants de ce groupe, et pour eux seuls.

Toujours pour les groupes uniquement : si les restaurants d’un groupe n’ont pas le même propriétaire, personne ne peut y créer de clé seul — le back-office refuse, et le groupe doit demander à Service de l’émettre. La révocation n’est pas concernée : chaque propriétaire peut révoquer.

exp - iat ne peut pas dépasser 15 minutes, et un jeton qui revendique davantage est rejeté purement et simplement plutôt que discrètement raccourci — un jeton trop long est une violation de contrat qu’il vaut mieux faire échouer bruyamment en intégration. Nous tolérons 60 secondes de décalage d’horloge sur iat uniquement ; être indulgent sur exp prolongerait la durée de vie réelle au-delà du plafond.

Fabriquez-en un par ouverture du widget. À l’expiration, le client voit un message explicite « session expirée » puis le parcours anonyme ordinaire — jamais une nouvelle signature silencieuse, qui rendrait la durée de vie décorative.

Trois limites s’appliquent. La troisième exige un calcul.

PlafondLimiteS’applique à
Toute chaîne revendiquée255 caractèresnom, e-mail, téléphone, langue, clé de salle
return_url2 048 caractèresles URL ont légitimement besoin de place
reservation.metadata4 096 octetsl’objet JSON décodé
Le jeton entier32 768 octetsen base64url, tel que transmis

metadata est un membre de reservation, et non une revendication de premier niveau. Placé au premier niveau, il n’est pas lu, et rien ne le signale : la réservation n’en porte simplement aucun.

La limite du jeton mesure la forme encodée telle que transmise, et non l’objet JSON. Deux multiplicateurs s’appliquent :

  • base64, c’est 4/3. Le plafond de metadata à 4 096 mesure l’objet décodé ; celui-ci mesure ce qui part sur le fil.
  • ensure_ascii, c’est jusqu’à 3×. json.dumps de Python et json_encode de PHP échappent le non-ASCII par défaut : é voyage en é — deux octets en deviennent six — et la paire de substitution d’un emoji transforme quatre octets en douze.

Une charge utile respectant chaque limite par champ peut donc dépasser la limite du jeton. Un objet metadata de 2 611 octets a produit un jeton de 10 622 octets face à une limite de 8 Ko, et la réservation a perdu ses verrous sans qu’aucune erreur ne soit signalée. La limite est désormais de 32 Kio, dimensionnée sur le plus grand jeton qu’un site conforme peut produire, et son dépassement est signalé plutôt qu’ignoré.

Mesurez la longueur base64 du jeton terminé plutôt que la taille de l’objet qu’il encode.

"reservation": { "date": "2026-09-03", "shift_id": 1 },
"locked": ["date", "shift_id"]

Un champ verrouillé s’affiche sans commande d’édition et accompagné de votre motif, et le serveur refuse une réservation qui le contredit. Cette seconde moitié est la raison pour laquelle les verrous sont signés : la présentation seule ne serait qu’une suggestion, et une requête fabriquée passerait outre.

Verrouillables : date, time, party_size, section_key, shift_id. L’orthographe sur le fil est en snake_case.

Ne verrouillez que ce que votre bon contraint réellement. Un parcours où la date, le service et le nombre de couverts sont tous épinglés, un jour où plus rien n’est disponible, est une impasse : le client ne peut rien changer et le widget ne peut que le lui dire.

return_url ajoute, à la fin de la réservation, un bouton qui ramène le client vers votre site. Sur l’impasse décrite ci-dessus, c’est la seule sortie disponible.

L’URL est filtrée côté serveur par la liste blanche de votre clé de vérification. Enregistrer l’origine est une étape d’intégration, pas une modification de code. Une intégration qui livre le code sans enregistrer ses origines n’affiche aucun bouton, et aucune erreur n’est journalisée.

L’URL est fournie sur deux surfaces, avec deux niveaux de confiance :

  • Sur la confirmation de réservation, elle est signée et porte service_reservation, service_timestamp et service_signature — vous pouvez donc vérifier la réservation qu’on vous annonce au lieu de faire confiance à une chaîne de requête.
  • Pendant que le parcours est encore ouvert, elle n’est pas signée, faute de réservation à signer. Elle existe pour qu’un client qui ne peut rien réserver ait tout de même où aller.

Recalculez le HMAC sur l’horodatage et l’identifiant de réservation, joints par un point, avec le secret de votre clé de vérification :

service_signature == hex( HMAC_SHA256( secret, "{service_timestamp}.{service_reservation}" ) )
const expected = crypto
.createHmac("sha256", secret)
.update(`${params.get("service_timestamp")}.${params.get("service_reservation")}`)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(params.get("service_signature")),
);

C’est la même construction que l’en-tête Service-Signature de nos webhooks : qui a implémenté l’un a implémenté l’autre.

Rejetez un horodatage périmé. L’horodatage figure dans la matière signée et dans la chaîne de requête précisément pour cela : la signature couvre un identifiant de réservation stable et n’expire donc jamais d’elle-même, si bien qu’un lien capturé une fois fonctionnerait indéfiniment. Comparez à votre propre horloge et choisissez une fenêtre — quelques minutes, pas quelques heures.

Comparez en temps constant, comme ci-dessus. Une comparaison octet par octet avec sortie anticipée révèle la signature attendue à qui accepte de faire suffisamment de requêtes.

Un jeton aboutit à l’une de trois issues :

IssueQuandCe que voit le client
valideSignature correcte, dans sa fenêtreSes coordonnées, déjà remplies
expiréSignature correcte, exp dépasséUn message « session expirée »
invalideTout le resteRien — le parcours ordinaire

invalide est un fourre-tout. Une mauvaise signature, un émetteur inconnu, un émetteur appartenant à un autre groupe, un segment malformé et une durée de vie excessive y aboutissent tous, afin qu’une vérification échouée ne révèle pas quel contrôle a échoué. Seul un jeton dont la signature est vérifiée produit le message d’expiration.

Un jeton est toujours limité à un restaurant. Une clé de vérification couvre le restaurant depuis lequel elle a été créée — et, si ce restaurant appartient à un groupe, tous les restaurants de ce groupe. Présenter son jeton à un restaurant que la clé ne couvre pas donne invalide — la même réponse qu’un émetteur inconnu.

Un champ attesté que le client modifie n’est plus traité comme attesté. La comparaison se fait champ par champ, sur la valeur soumise : corriger un champ ne révoque donc pas les autres. Un client qui réserve pour quelqu’un d’autre change le nom, et la réservation est attribuée en conséquence.

Voici le fichier exact que notre propre suite de tests exécute ; le jeton qu’il produit est vérifié contre le vérificateur réel par un test côté serveur. Copiez-le plutôt que de repartir du texte ci-dessus.

src/snippets/mint-identity-assertion.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Reference implementation of the Service identity assertion.
*
* ⚠️ **This file is the published example.** It is embedded verbatim in
* `identifying-the-guest.md` and is covered by tests on both sides: a frontend
* test asserts it reproduces a fixed token byte for byte, and a backend spec
* asserts that same token verifies as `:valid`. If you change the algorithm
* here, both fail — which is the point. The docs used to describe this in prose
* and an adversarial reader who implemented from it got four independent
* decisions wrong, every one of which lands in the deliberately-undiagnosable
* `invalid` bucket.
*
* Those four, stated outright because prose kept losing them:
*
* 1. **The MAC covers the base64url SEGMENT as transmitted**, not the JSON
* that produced it. Sign the string you are about to send.
* 2. **The signature is base64url of the RAW digest**, not hex. (The
* return-URL signature IS hex — they differ, and that is not a mistake.
* See `verifyReturnUrl` below.)
* 3. **base64url, not base64**: `-` and `_`, and padding stripped. We accept
* padded input too, but unpadded is what to send.
* 4. **`iss` is the verification key's public id including its
* `ivk_` prefix** — it is not a bare id and not a key name.
*/
/** base64url with padding stripped (RFC 4648 §5). */
const b64url = (buf) =>
Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
/**
* Mint an identity assertion.
*
* @param {object} claims The payload. `iss` is required; `iat`/`exp` are added
* when absent, with the 15-minute maximum lifetime.
* @param {string} secret The verification key's secret, as issued — used
* verbatim, not decoded.
* @returns {string} `<payload>.<signature>`
*/
export function mintIdentityAssertion(claims, secret) {
const now = Math.floor(Date.now() / 1000);
const payload = { iat: now, exp: now + 15 * 60, ...claims };
// Sign the SEGMENT, not the JSON — see note 1 above. Whatever your JSON
// encoder does with key order or unicode escaping is therefore irrelevant to
// whether the signature verifies.
const segment = b64url(JSON.stringify(payload));
const signature = b64url(createHmac("sha256", secret).update(segment).digest());
return `${segment}.${signature}`;
}
/**
* Verify the signed return we append to your `return_url`.
*
* **Different construction from the token above, deliberately:** this one is
* HMAC over `"{timestamp}.{reservation}"` and is **hex**, matching the
* `Service-Signature` header on our webhooks — so an integrator who has
* implemented webhook verification has already implemented this.
*
* @param {URLSearchParams} params The query string we sent you.
* @param {string} secret The same verification-key secret.
* @param {number} maxAgeSeconds Reject anything older. The signature covers a
* stable reservation id and so never expires by
* itself; a link captured once would otherwise
* work forever.
*/
export function verifyReturnUrl(params, secret, maxAgeSeconds = 300) {
const timestamp = params.get("service_timestamp");
const reservation = params.get("service_reservation");
const signature = params.get("service_signature");
if (!timestamp || !reservation || !signature) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${reservation}`).digest("hex");
const given = Buffer.from(signature);
const want = Buffer.from(expected);
// Length-check first: timingSafeEqual THROWS on a length mismatch, which
// would turn a forged signature into a 500 rather than a rejection.
if (given.length !== want.length) return false;
if (!timingSafeEqual(given, want)) return false;
return Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) <= maxAgeSeconds;
}