Requêtes signées
Comment une app vérifie qu’un appel, ou un navigateur qui arrive sur sa page d’installation, vient vraiment de Spunto.
Une app partage un seul secret avec Spunto. Il sert de deux façons, avec une séparation de domaine pour que l’une ne puisse jamais passer pour l’autre :
- Les signatures de requête — sur chaque appel de serveur à serveur que fait Spunto :
/status,/oauth/complete,/capabilities/*. - Les jetons de lancement — sur les routes navigateur qui démarrent un flux :
installPath,grantPath.
Les deux sont des HMAC-SHA256, encodés en hexadécimal. Rejetez tout ce qui ne se vérifie pas, et jamais avec un message qui dise comment la vérification a échoué au-delà de « unauthorized ».
Chaque appel porte deux en-têtes :
| En-tête | Valeur |
|---|---|
x-spunto-timestamp | Millisecondes depuis l’epoch, au moment où Spunto l’a envoyé |
x-spunto-signature | hex(HMAC_SHA256(secret, "{timestamp}.{METHOD}.{path}.{body}")) |
METHODest en majuscules — toujoursPOSTaujourd’hui.pathest le chemin relatif à votre URL de base, tel que Spunto l’a demandé :/capabilities/git/cloneCredential, sans query string. La méthode et le chemin sont dans la signature pour qu’un corps signé ne puisse pas être rejoué sur un autre endpoint qui accepterait par hasard la même forme.bodyest le corps brut de la requête, octet pour octet. Vérifiez avant de parser : resérialiser le JSON et signer le résultat, c’est la façon classique d’écrire un contrôle qui passe en local et échoue en conditions réelles.
Refusez un timestamp qui s’écarte de plus de 5 minutes de votre horloge, pour qu’un appel intercepté ne puisse pas être rejoué plus tard.
import { createHmac, timingSafeEqual } from "node:crypto"
const MAX_SKEW_MS = 5 * 60 * 1000
export function verifySpuntoRequest(req: {
secret: string
method: string
path: string
rawBody: string
signature?: string
timestamp?: string
}): boolean {
// An empty secret would accept any signature computed with "".
if (!req.secret || !req.signature || !req.timestamp) return false
const sentAt = Number(req.timestamp)
if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > MAX_SKEW_MS) return false
const expected = createHmac("sha256", req.secret)
.update(`${req.timestamp}.${req.method.toUpperCase()}.${req.path}.${req.rawBody}`)
.digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(req.signature)
// Constant-time, and length-checked first: timingSafeEqual throws on a mismatch.
return a.length === b.length && timingSafeEqual(a, b)
}Avec Hono, par exemple :
app.post("/capabilities/:capability/:operation", async (c) => {
const raw = await c.req.text()
const ok = verifySpuntoRequest({
secret: process.env.SPUNTO_SIGNING_SECRET!,
method: c.req.method,
path: c.req.path,
rawBody: raw,
signature: c.req.header("x-spunto-signature"),
timestamp: c.req.header("x-spunto-timestamp"),
})
if (!ok) return c.json({ error: "Unauthorized" }, 401)
const { orgId, userId, input } = JSON.parse(raw)
// …
})Quand un membre clique sur Connect (ou Authorize), son navigateur arrive sur votre installPath (ou grantPath) avec ?launch=<token>. Le jeton est la déclaration signée de Spunto : cet utilisateur de cette organisation peut démarrer ce flux, et doit revenir ici.
<payload>.<mac>
payload = base64url(JSON.stringify({ org, user, return, purpose, exp }))
mac = hex(HMAC_SHA256(secret, "spunto-launch.v1." + payload))| Champ | |
|---|---|
org | L’id de l’organisation — opaque, stockez-le tel quel |
user | L’id du membre qui a démarré le flux |
return | Où renvoyer le navigateur quand vous avez terminé, suivi de ?completion=… |
purpose | install ou grant — vérifiez qu’il correspond à la route |
exp | Expiration, en millisecondes depuis l’epoch — dix minutes après la signature |
export function verifyLaunch(secret: string, token: string | undefined, purpose: "install" | "grant") {
if (!secret || !token) return null
const dot = token.lastIndexOf(".")
if (dot <= 0) return null
const payload = token.slice(0, dot)
const expected = Buffer.from(
createHmac("sha256", secret).update(`spunto-launch.v1.${payload}`).digest("hex"),
)
const given = Buffer.from(token.slice(dot + 1))
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null
const launch = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
if (launch.purpose !== purpose || typeof launch.exp !== "number" || launch.exp < Date.now()) return null
if (!launch.org || !launch.user || !launch.return) return null
return launch as { org: string; user: string; return: string; purpose: string; exp: number }
}Gardez le lancement vérifié tout au long de votre propre flux (dans un cookie signé, ou dans le state de votre OAuth), et vérifiez return une fois de plus avant d’y rediriger : il doit être sur l’origine Spunto avec laquelle vous avez été enregistré. La signature s’en porte déjà garante — cette seconde barrière empêche un secret qui aurait fuité de transformer votre app en open redirect.
Un jeton de lancement dit seulement qui a démarré le flux. Qui le termine, c’est Spunto qui le confirme dans POST /oauth/complete → Installer.
Settings → Developer apps → New secret en génère un nouveau et l’affiche une seule fois. Spunto signe avec lui immédiatement : déployez donc d’abord votre app en acceptant à la fois l’ancien et le nouveau secret, régénérez, puis retirez l’ancien.