# Verifying in other languages

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

Source: https://storeware.ai/docs/webhooks/other-languages

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](https://storeware.ai/docs/vectors/signing.json) 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

```python title="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

```php title="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

```go title="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

```ruby title="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](https://storeware.ai/docs/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:

```python title="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.

```bash
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:

| Field               | What it is                                                       |
| ------------------- | ---------------------------------------------------------------- |
| `access_token`      | Starts `swt_`. Send it as a Bearer token to the API.             |
| `refresh_token`     | Starts `swr_`. Trades for a new pair once the access token ends. |
| `token_type`        | Always `Bearer`.                                                 |
| `expires_in`        | Seconds until the access token stops working.                    |
| `scope`             | The scopes the merchant granted, separated by spaces.            |
| `store_id`          | The shop.                                                        |
| `installation_id`   | The installation, the same as `sid` in the session's claims.     |
| `issued_token_type` | Always `urn:ietf:params:oauth:token-type:access_token`.          |
| `webhook_secret`    | Verifies signed requests. Store it now.                          |
| `session_secret`    | Verifies 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.
