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êteValeur
x-spunto-timestampMillisecondes depuis l’epoch, au moment où Spunto l’a envoyé
x-spunto-signaturehex(HMAC_SHA256(secret, "{timestamp}.{METHOD}.{path}.{body}"))
  • METHOD est en majuscules — toujours POST aujourd’hui.
  • path est 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.
  • body est 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)
  // …
})

Note

Si votre app est derrière un proxy qui la monte sous un préfixe (/acme), Spunto appelle quand même l’URL de base que vous avez enregistrée, et signe le chemin relatif à celle-ci. Assurez-vous que path est ce que votre app voit après le retrait du préfixe — ou enregistrez l’URL de base préfixe compris.

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
orgL’id de l’organisation — opaque, stockez-le tel quel
userL’id du membre qui a démarré le flux
returnOù renvoyer le navigateur quand vous avez terminé, suivi de ?completion=…
purposeinstall ou grant — vérifiez qu’il correspond à la route
expExpiration, 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.