Storeware Docs

Verifying in other languages

Check Storeware's signatures and session tokens in Python, PHP, Go or Ruby, and test your code against known answers.

The SDK is JavaScript. If your app is written in something else, the recipes below make the same checks it makes. Run yours against the test vectors before you trust it: they hold the answers the SDK gives, and a test keeps the two the same.

What to check

A signed request, which is every call Storeware makes to your app:

  1. Read the raw body, as bytes, before you parse anything. Parsing and encoding it again changes the bytes, and the signature no longer matches.
  2. Split storeware-signature on commas into t, v1 and v2. Refuse a header with no v2. v1 signs no kind, so never accept it alone.
  3. Refuse the request if t is more than 300 seconds from now, in either direction.
  4. Compute HMAC-SHA256 of t, a dot, the kind, a dot and the raw body, keyed with your secret, as hex. The kind is the one your endpoint handles: webhook, tool, knowledge, block, proxy, action or lifecycle. If one endpoint serves several kinds and reads storeware-kind to choose, check against the kind you chose.
  5. Compare it with v2 in constant time.

An admin session token, which your app's home and panels are opened with:

  1. Split the token on dots into three parts, keeping an empty last part. Anything else is malformed.
  2. Decode the first part, the header. Refuse it unless alg is HS256. Your code decides the algorithm, never the token.
  3. Compute HMAC-SHA256 of the first two parts and the dot between them, as they arrived, keyed with your session_secret. Encode it as base64url with no padding, and compare it with the third part in constant time.
  4. Decode the second part, the claims. aud must be your client id, exp must be later than now, and iat must be no more than 60 seconds ahead of now.

Both secrets are used as the UTF-8 bytes of their text. They look like hex, but do not decode them.

Python

storeware_verify.py
import base64
import hashlib
import hmac
import json
import time

TOLERANCE_SECONDS = 300


def verify_signed_request(secret: str, raw_body: bytes, header: str, kind: str, now: int | None = None) -> dict:
    """Checks storeware-signature for an endpoint that serves `kind`. Raises ValueError if it fails."""
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t, v2 = parts.get("t"), parts.get("v2")
    if not t or not v2:  # v1 alone is never enough: it signs no kind
        raise ValueError("malformed signature header")
    timestamp = int(t)
    now = int(time.time()) if now is None else now
    if abs(now - timestamp) > TOLERANCE_SECONDS:
        raise ValueError("timestamp outside the tolerance window")
    signed = f"{timestamp}.{kind}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), v2.encode()):
        raise ValueError("signature mismatch")
    return json.loads(raw_body)


def _b64url(part: str) -> bytes:
    return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))


def verify_session(token: str, secret: str, client_id: str, now: int | None = None) -> dict:
    """Checks an admin session token. Returns its claims, or raises ValueError."""
    parts = token.split(".")
    if len(parts) != 3:
        raise ValueError("malformed token")
    header, body, signature = parts
    decoded = json.loads(_b64url(header))
    if not isinstance(decoded, dict) or decoded.get("alg") != "HS256":
        raise ValueError("unsupported signature algorithm")
    digest = hmac.new(secret.encode(), f"{header}.{body}".encode(), hashlib.sha256).digest()
    expected = base64.urlsafe_b64encode(digest).rstrip(b"=")
    if not hmac.compare_digest(expected, signature.encode()):
        raise ValueError("signature mismatch")
    claims = json.loads(_b64url(body))
    now = int(time.time()) if now is None else now
    if claims.get("aud") != client_id:
        raise ValueError("token is for another app")
    if not isinstance(claims.get("exp"), int) or claims["exp"] <= now:
        raise ValueError("token has expired")
    if isinstance(claims.get("iat"), int) and claims["iat"] > now + 60:
        raise ValueError("token was issued in the future")
    return claims

Needs Python 3.10 or later. Pass the body as bytes: request.get_data() in Flask, await request.body() in FastAPI, request.body in Django.

PHP

storeware_verify.php
<?php

const STOREWARE_TOLERANCE_SECONDS = 300;

/** Checks storeware-signature for an endpoint that serves $kind. Throws if it fails. */
function storeware_verify_signed_request(string $secret, string $rawBody, string $header, string $kind, ?int $now = null): array
{
    $parts = [];
    foreach (explode(',', $header) as $part) {
        $pair = explode('=', trim($part), 2);
        if (count($pair) === 2) {
            $parts[$pair[0]] = $pair[1];
        }
    }
    // v1 alone is never enough: it signs no kind.
    if (($parts['t'] ?? '') === '' || ($parts['v2'] ?? '') === '') {
        throw new RuntimeException('malformed signature header');
    }
    $timestamp = (int) $parts['t'];
    $now ??= time();
    if (abs($now - $timestamp) > STOREWARE_TOLERANCE_SECONDS) {
        throw new RuntimeException('timestamp outside the tolerance window');
    }
    $expected = hash_hmac('sha256', "{$timestamp}.{$kind}.{$rawBody}", $secret);
    if (!hash_equals($expected, $parts['v2'])) {
        throw new RuntimeException('signature mismatch');
    }
    return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}

function storeware_base64url_decode(string $part): string
{
    return (string) base64_decode(strtr($part, '-_', '+/'), true);
}

/** Checks an admin session token. Returns its claims, or throws. */
function storeware_verify_session(string $token, string $secret, string $clientId, ?int $now = null): array
{
    $parts = explode('.', $token);
    if (count($parts) !== 3) {
        throw new RuntimeException('malformed token');
    }
    [$header, $body, $signature] = $parts;
    $decoded = json_decode(storeware_base64url_decode($header), true);
    if (!is_array($decoded) || ($decoded['alg'] ?? null) !== 'HS256') {
        throw new RuntimeException('unsupported signature algorithm');
    }
    $digest = hash_hmac('sha256', "{$header}.{$body}", $secret, true);
    $expected = rtrim(strtr(base64_encode($digest), '+/', '-_'), '=');
    if (!hash_equals($expected, $signature)) {
        throw new RuntimeException('signature mismatch');
    }
    $claims = json_decode(storeware_base64url_decode($body), true, 512, JSON_THROW_ON_ERROR);
    $now ??= time();
    if (($claims['aud'] ?? null) !== $clientId) {
        throw new RuntimeException('token is for another app');
    }
    if (!is_int($claims['exp'] ?? null) || $claims['exp'] <= $now) {
        throw new RuntimeException('token has expired');
    }
    if (is_int($claims['iat'] ?? null) && $claims['iat'] > $now + 60) {
        throw new RuntimeException('token was issued in the future');
    }
    return $claims;
}

Read the body with file_get_contents('php://input'), or $request->getContent() in Laravel and Symfony. $_POST is already parsed, so it will not verify.

Go

storeware/verify.go
package storeware

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
)

const toleranceSeconds = 300

// VerifySignedRequest checks storeware-signature for an endpoint that serves kind.
// rawBody is the body as read, before any JSON decoding; now is Unix seconds.
func VerifySignedRequest(secret string, rawBody []byte, header, kind string, now int64) error {
	parts := map[string]string{}
	for _, part := range strings.Split(header, ",") {
		if key, value, ok := strings.Cut(strings.TrimSpace(part), "="); ok {
			parts[key] = value
		}
	}
	// v1 alone is never enough: it signs no kind.
	t, v2 := parts["t"], parts["v2"]
	if t == "" || v2 == "" {
		return errors.New("malformed signature header")
	}
	timestamp, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return errors.New("malformed timestamp")
	}
	if age := now - timestamp; age > toleranceSeconds || age < -toleranceSeconds {
		return errors.New("timestamp outside the tolerance window")
	}
	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.%s.", timestamp, kind)
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expected), []byte(v2)) {
		return errors.New("signature mismatch")
	}
	return nil
}

type SessionClaims struct {
	Iss     string         `json:"iss"`
	Aud     string         `json:"aud"`
	Sub     string         `json:"sub"`
	Exp     int64          `json:"exp"`
	Iat     int64          `json:"iat"`
	Jti     string         `json:"jti"`
	Sid     string         `json:"sid"`
	StoreID string         `json:"store_id"`
	OrgID   string         `json:"org_id"`
	Context map[string]any `json:"context"`
}

// VerifySession checks an admin session token and returns its claims.
func VerifySession(token, secret, clientID string, now int64) (*SessionClaims, error) {
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return nil, errors.New("malformed token")
	}
	var header struct {
		Alg string `json:"alg"`
	}
	headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
	if err != nil || json.Unmarshal(headerJSON, &header) != nil || header.Alg != "HS256" {
		return nil, errors.New("unsupported signature algorithm")
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(parts[0] + "." + parts[1]))
	expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expected), []byte(parts[2])) {
		return nil, errors.New("signature mismatch")
	}
	var claims SessionClaims
	body, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil || json.Unmarshal(body, &claims) != nil {
		return nil, errors.New("malformed claims")
	}
	if claims.Aud != clientID {
		return nil, errors.New("token is for another app")
	}
	if claims.Exp <= now {
		return nil, errors.New("token has expired")
	}
	if claims.Iat > now+60 {
		return nil, errors.New("token was issued in the future")
	}
	return &claims, nil
}

Read the body with io.ReadAll(r.Body) and verify it before you decode it. Pass time.Now().Unix() as now.

Ruby

storeware_verify.rb
require "base64"
require "json"
require "openssl"

TOLERANCE_SECONDS = 300

# Checks storeware-signature for an endpoint that serves kind. Raises if it fails.
def verify_signed_request(secret, raw_body, header, kind, now: Time.now.to_i)
  parts = header.split(",").map { |part| part.strip.split("=", 2) }.select { |pair| pair.size == 2 }.to_h
  t, v2 = parts["t"], parts["v2"]
  # v1 alone is never enough: it signs no kind.
  raise ArgumentError, "malformed signature header" if t.to_s.empty? || v2.to_s.empty?
  timestamp = Integer(t, 10)
  raise ArgumentError, "timestamp outside the tolerance window" if (now - timestamp).abs > TOLERANCE_SECONDS
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{kind}.#{raw_body}")
  raise ArgumentError, "signature mismatch" unless OpenSSL.secure_compare(expected, v2)
  JSON.parse(raw_body)
end

# Checks an admin session token. Returns its claims, or raises.
def verify_session(token, secret, client_id, now: Time.now.to_i)
  parts = token.split(".", -1) # -1 keeps an empty last part
  raise ArgumentError, "malformed token" unless parts.size == 3
  header, body, signature = parts
  decoded = JSON.parse(Base64.urlsafe_decode64(header))
  raise ArgumentError, "unsupported signature algorithm" unless decoded.is_a?(Hash) && decoded["alg"] == "HS256"
  digest = OpenSSL::HMAC.digest("SHA256", secret, "#{header}.#{body}")
  expected = Base64.urlsafe_encode64(digest, padding: false)
  raise ArgumentError, "signature mismatch" unless OpenSSL.secure_compare(expected, signature)
  claims = JSON.parse(Base64.urlsafe_decode64(body))
  raise ArgumentError, "token is for another app" unless claims["aud"] == client_id
  raise ArgumentError, "token has expired" unless claims["exp"].is_a?(Integer) && claims["exp"] > now
  raise ArgumentError, "token was issued in the future" if claims["iat"].is_a?(Integer) && claims["iat"] > now + 60
  claims
end

OpenSSL.secure_compare needs Ruby 3.0 or later. Read the body with request.raw_post in Rails, or request.body.read in Rack and Sinatra.

Testing against the vectors

signing.json has three parts:

  • signed_requests.vectors: call your request check with secret, body, headers["storeware-signature"], endpoint_kind and now.
  • session_tokens.vectors: call your session check with token, secret, client_id and now.
  • token_exchange: the exact requests in the section below, and an example answer.

Each vector says whether to accept or refuse, and for a refusal the SDK's reason. Your own error messages may say it differently. Always pass the vector's now rather than the clock: the vectors were signed at fixed times. A runner for the Python recipe:

run_vectors.py
import json
import sys

from storeware_verify import verify_session, verify_signed_request

vectors = json.load(open(sys.argv[1], encoding="utf-8"))
failed = 0
for v in vectors["signed_requests"]["vectors"] + vectors["session_tokens"]["vectors"]:
    try:
        if "token" in v:
            verify_session(v["token"], v["secret"], v["client_id"], v["now"])
        else:
            verify_signed_request(v["secret"], v["body"].encode(), v["headers"]["storeware-signature"], v["endpoint_kind"], v["now"])
        got = "accept"
    except ValueError:
        got = "refuse"
    failed += got != v["expect"]
    print("ok  " if got == v["expect"] else "FAIL", v["name"])
sys.exit(1 if failed else 0)

Trading a session token for tokens

An app installed from Storeware has no authorization code. Its server sends the session token its home was opened with, and its client secret, and gets the installation's tokens back (RFC 8693 token exchange). Send it from your server only: it carries your client secret.

curl https://api.storeware.ai/api/oauth/token \
  --data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  --data-urlencode subject_token="$SESSION_TOKEN" \
  --data-urlencode subject_token_type=urn:storeware:params:oauth:token-type:session-token \
  --data-urlencode client_id="$CLIENT_ID" \
  --data-urlencode client_secret="$CLIENT_SECRET"

An app with no home trades the install grant from app.installed.v1 the same way, with grant_type=urn:storeware:grant-type:install and grant in place of the two subject fields. A grant works for an hour.

The body is form encoded. JSON is accepted too, and so are the client id and secret in an HTTP Basic header. The answer:

FieldWhat it is
access_tokenStarts swt_. Send it as a Bearer token to the API.
refresh_tokenStarts swr_. Trades for a new pair once the access token ends.
token_typeAlways Bearer.
expires_inSeconds until the access token stops working.
scopeThe scopes the merchant granted, separated by spaces.
store_idThe shop.
installation_idThe installation, the same as sid in the session's claims.
issued_token_typeAlways urn:ietf:params:oauth:token-type:access_token.
webhook_secretVerifies signed requests. Store it now.
session_secretVerifies session tokens. Store it now.

No other endpoint hands either secret back. A refusal answers status 400 with error and error_description, as RFC 6749 sets out. token_exchange in the vectors holds both requests byte for byte, and an example of each answer.

On this page