Jetons d'accès

Comment le widget obtient un identifiant de courte durée au début de chaque mesure, sans jamais détenir votre clé d’API

Le widget ne détient aucun secret. Avant chaque mesure il demande un jeton d’accès de courte durée, et options.proxy.retrieveAccessToken est votre façon de répondre.

C’est la seule option obligatoire. Sans elle le widget parcourt tout de même ses écrans — l’accueil, le consentement, les consignes sont tous accessibles — mais au moment où une mesure devrait démarrer, il échoue sur UNDEFINED_ACCESS_TOKEN_PROXY.

Pourquoi un jeton, et pourquoi à chaque fois

Votre clé d’API est permanente, sans portée, et ouvre toutes les routes de l’API. Elle n’a rien à faire dans une page.

Un jeton d’accès est l’inverse : limité à une permission, valide quelques minutes, et consommé par la poignée de main websocket qui ouvre la mesure. C’est cette propriété d’usage unique qui rend sa présence dans un navigateur acceptable — et c’est aussi pourquoi le widget en redemande un au début de chaque mesure plutôt que d’en garder un en cache.

Un utilisateur qui abandonne une mesure puis recommence provoquera deux demandes de jeton. C’est attendu : le premier jeton a été dépensé par la tentative abandonnée.

Les trois stratégies

StratégieQui appelle l’APICe que vous fournissezÀ utiliser quand
delegateLe widget, contre votre point d’accèsurl, avec headers et body facultatifsVotre serveur peut exposer un point d’accès. Le choix habituel.
handleVotre propre codefetch, une fonction qui retourne le jetonL’obtention du jeton demande un état applicatif — une session, une file d’attente, un jeton déjà en mémoire.
unsafe-api-keyLe widget, contre l’API SaphereapiKeyDémonstrations et essais, sur le Test uniquement. Jamais en Production.

delegate — le widget appelle votre point d’accès

const instance = SaphereScan.create("#scan", {
    proxy: {
        retrieveAccessToken: {
            strategy: "delegate",
            url: "/api/saphere-token"
        }
    }
})

url est soit une URL http(s) absolue, soit un chemin enraciné sur /, servi par la page elle-même.

Le widget envoie un POST sans corps par défaut — il n’y a rien de la mesure à transmettre, puisqu’elle n’existe pas encore. Votre point d’accès n’a rien à lire ; il a seulement à décider si cet utilisateur peut lancer une mesure, et à répondre par un jeton.

Les cookies ne sont pas envoyés

La requête part sans identifiants. Un point d’accès sur une autre origine doit s’authentifier par headers et rien d’autre — un cookie de session sur votre domaine ne voyagera pas avec elle. Un chemin de même origine est le moyen le plus simple de conserver votre session existante.

Deux formes de réponse sont acceptées :

{ "value": "ivst-8Kx2mQ…", "id": "3f1a…", "expiresAt": "2026-08-24T16:00:00.000Z" }
"ivst-8Kx2mQ…"

La première est la réponse de l’API elle-même à POST /access-tokens, ce qui vous permet de la relayer telle quelle sans la démonter. La seconde est une chaîne nue, pour un point d’accès qui ne rend que le strict nécessaire.

C'est `value` qui ouvre, jamais `id`

La base ne stocke qu’un condensé du jeton, de sorte que l’id de la ligne n’ouvre absolument rien. Un point d’accès qui relaie l’id seul verra chaque poignée de main refusée — avec un jeton d’apparence parfaitement bien formée.

headers authentifie l’appel, et body existe pour le cas où le point d’accès que vous visez est une API exigeant des paramètres plutôt qu’un point d’accès à vous :

retrieveAccessToken: {
    strategy: "delegate",
    url: "/api/saphere-token",
    headers: { "X-Session": sessionId }
}

handle — vous le récupérez vous-même

Prenez cette stratégie dès que l’obtention d’un jeton demande plus d’une requête : une session applicative à consulter, un jeton déjà détenu en mémoire, une file d’attente, un doublure de test à substituer.

const instance = SaphereScan.create("#scan", {
    proxy: {
        retrieveAccessToken: {
            strategy: "handle",
            fetch: async signal => {
                const response = await fetch("/api/saphere-token", {
                    method: "POST",
                    signal,
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ userId: currentUser.id })
                })
                const { value } = await response.json()
                return value
            }
        }
    }
})

La fonction reçoit l’AbortSignal de la tentative. Transmettez-le à fetch et une requête en vol est annulée quand l’utilisateur quitte l’écran, au lieu d’aboutir à une mesure que plus personne n’attend.

Elle peut retourner une chaîne ou une promesse de chaîne, et elle peut être déclarée sans aucun paramètre — le widget ne suppose rien au-delà de « c’est une fonction ».

Une fonction sans `return`

L’erreur d’intégration la plus courante est un fetch qui exécute la requête et oublie d’en retourner le résultat. Le widget la rapporte en token:error avec le code UNUSABLE_RESPONSE, qui est exactement le cas à chercher en premier.

unsafe-api-key — essais uniquement

retrieveAccessToken: { strategy: "unsafe-api-key", apiKey: "2428fbbc-…" }

Le widget appelle l’API lui-même, avec votre clé client. Le nom dit ce que c’est.

La clé d’API est permanente et sans portée. La placer ici la place dans la page — dans le paquet, dans le cache du navigateur, dans les outils de développement de chaque utilisateur. Une fuite ne se révoque pas en faisant tourner un jeton : elle se révoque en changeant la clé, ce qui coupe toutes les intégrations de ce client d’un coup.

Elle existe pour qu’une page de démonstration fonctionne en trente secondes. Elle n’a aucune place dans un produit livré — et la clé que vous y placez doit être une clé de Test. Une clé de Production dans une page est la seule erreur de cette page qui ne se rattrape pas discrètement.

Le point d’accès que vous devez écrire

Pour delegate et handle, il vous faut un point d’accès qui émet un jeton. Il doit appeler l’api du même environnement que celui depuis lequel le widget a été chargé — cette moitié-là vous revient, le widget ne peut pas la deviner. L’exemple ci-dessous vise la Production ; sur le Test, seul l’hôte change.

EnvironnementBase de l'API
Productionhttps://api.saphere.ai
Testhttps://api.test.saphere.ai

Le voici avec Express :

import express from "express"

const app = express()

const SAPHERE_API = "https://api.saphere.ai"
const API_KEY = process.env.SAPHERE_API_KEY   // jamais dans le paquet client

app.post("/api/saphere-token", async (request, response) => {
    // Votre propre autorisation, quelle qu'elle soit : une session, un quota, un droit.
    if (!request.session?.userId)
        return response.status(401).end()

    const created = await fetch(`${SAPHERE_API}/access-tokens`, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${API_KEY}`
        },
        body: JSON.stringify({
            permissions: [{ name: "websocket.measurement", maxAttempts: 1 }],
            duration: "15min"
        })
    })

    if (!created.ok)
        return response.status(502).end()

    const { value } = await created.json()
    // Ne relayez que ce qui ouvre. `id` et `expiresAt` peuvent être journalisés ; `value` non.
    response.json({ value })
})

Ce point d’accès est là où vivent vos règles métier. C’est le seul endroit capable de décider que cet utilisateur, en cet instant, peut lancer une mesure — le widget ne le peut pas, et l’API sait seulement que la clé est valide.

permissions

Un tableau, avec au moins une entrée. Aujourd’hui, une permission existe :

NomCe qu’elle accorde
websocket.measurementL’ouverture d’une session de mesure.

maxAttempts borne le nombre de fois où le jeton peut ouvrir une session.

Omettre `maxAttempts` produit un jeton rejouable

Le champ est nullable et sans valeur par défaut, et null signifie aucune limite — un tel jeton n’est borné que par sa durée. Rien ne le remplit à votre place, car une valeur par défaut écrite là bornerait silencieusement un jeton qu’un intégrateur a délibérément laissé sans borne. Écrivez maxAttempts: 1 sauf raison contraire.

duration

Un intervalle, non une date : 15min, 2h, 48h. Le serveur l’évalue au moment de l’insertion, de sorte qu’aucune horloge n’a à être accordée entre votre machine et la nôtre.

Le plafond est de 48 heures, et une demande au-delà est refusée. Un jeton vit le temps de la mesure qu’il ouvre ; il n’a pas besoin de survivre à la session qui l’a demandé.

Attention à une abréviation, héritée de la grammaire des intervalles : m signifie minutes. Les mois s’écrivent mon. Comme la semaine la plus courte dépasse déjà le plafond, toute durée écrite avec une unité supérieure au jour est refusée d’emblée.

Révoquer un jeton

curl -X DELETE https://api.saphere.ai/access-tokens/3f1a2b4c-… \
     -H "Authorization: Bearer $SAPHERE_API_KEY"

Ici c’est l’id qu’il vous faut, non la value — l’identifiant est ce qui désigne le jeton, la valeur est ce qui l’ouvre.

Un jeton inconnu, déjà révoqué ou appartenant à un autre client répondent tous le même 404, de sorte que personne ne peut sonder l’existence de jetons qui ne lui appartiennent pas. Un jeton expiré reste supprimable, ce qui vous permet de faire le ménage.

Diagnostiquer un échec

Chaque échec sur ce chemin est rapporté en token:error avec un code qui dit à qui appartient le problème :

codeOù regarder
NO_PROXYproxy.retrieveAccessToken n’a pas été déclaré du tout.
STRATEGY_FAILEDVotre fonction a levé, ou le réseau est tombé.
HTTP_ERRORVotre point d’accès a répondu hors de la plage 2xx.
UNUSABLE_RESPONSELa réponse ne portait aucun jeton exploitable — le plus souvent une fonction sans return.
TIMEOUTPlus de cinq secondes pour répondre.

Le jeton lui-même n’est jamais porté par un événement, sous aucun code.

Une cause mérite d’être écartée avant les autres : un jeton émis sur un environnement et présenté à l’autre. Il est refusé, et la session ne s’ouvre tout simplement pas — voir Environnements.