diff --git a/.fernignore b/.fernignore
index cf7e3766..468503b4 100644
--- a/.fernignore
+++ b/.fernignore
@@ -22,6 +22,7 @@ src/schematic/datastream/
src/schematic/leases/
src/schematic/webhook_utils/
src/schematic/webhooks/verification.py
+src/schematic/context_signature/
tests/custom/
tests/datastream/
tests/leases/
@@ -29,6 +30,7 @@ tests/conformance/
tests/lease_support.py
conformance/
tests/webhook_utils/
+tests/context_signature/
CLAUDE.md
WASM_VERSION
.gitignore
diff --git a/README.md b/README.md
index 1596b8ad..c7c11811 100644
--- a/README.md
+++ b/README.md
@@ -405,6 +405,40 @@ def verify_webhook_manually(body: str, signature: str, timestamp: str, secret: s
return False
```
+## Context Signatures
+
+A browser SDK uses a publishable key, which anyone can read from your page, so on its own it can't prove which company or user it's acting for. When an environment requires context signatures, the API only accepts a publishable-key request whose company and user your backend has signed with the environment's secret API key. The browser SDK asks your backend for the signature through its `getContextSignature` option and sends it in the `X-Schematic-Context-Sig` header.
+
+### Signing a context
+
+Sign the logged-in user's own company and user, read from your session. Don't sign whatever company or user the browser asks for, or anyone could get a signature for another company.
+
+```python
+import os
+
+from flask import Flask, jsonify
+from schematic.context_signature import sign_context
+
+app = Flask(__name__)
+
+@app.route("/api/schematic/sign", methods=["POST"])
+def sign_schematic_context():
+ session = get_session() # however your app identifies the logged-in user
+ if session is None:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ signature = sign_context(
+ os.environ["SCHEMATIC_SECRET_KEY"],
+ {
+ "company": {"id": session.company_id},
+ "user": {"id": session.user_id},
+ },
+ )
+ return jsonify({"signature": signature})
+```
+
+The company and user keys must match the context you give the browser SDK exactly, or the API rejects the signature. A signature is valid for 10 minutes by default; set a different lifetime, up to an hour, with `lifetime_seconds`, and the start time with `issued_at`. The browser SDK reuses a signature until shortly before it expires, then asks for a new one. `sign_context` raises a `ContextSignatureError` when the secret is missing, the lifetime is out of range, or a key or value isn't a string.
+
## Advanced
### Flag Check Options
diff --git a/src/schematic/context_signature/__init__.py b/src/schematic/context_signature/__init__.py
new file mode 100644
index 00000000..972faf91
--- /dev/null
+++ b/src/schematic/context_signature/__init__.py
@@ -0,0 +1,25 @@
+"""Context signing for Schematic.
+
+This module signs the context a browser SDK sends with a publishable key, for
+environments that require context signatures.
+"""
+
+from .signing import (
+ CONTEXT_SIGNATURE_HEADER,
+ DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS,
+ MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS,
+ ContextSignatureError,
+ SignableContext,
+ canonicalize_context,
+ sign_context,
+)
+
+__all__ = [
+ "CONTEXT_SIGNATURE_HEADER",
+ "DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS",
+ "MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS",
+ "ContextSignatureError",
+ "SignableContext",
+ "canonicalize_context",
+ "sign_context",
+]
diff --git a/src/schematic/context_signature/signing.py b/src/schematic/context_signature/signing.py
new file mode 100644
index 00000000..f2bf11d2
--- /dev/null
+++ b/src/schematic/context_signature/signing.py
@@ -0,0 +1,141 @@
+"""Context signing for Schematic.
+
+Signs the context a browser SDK sends with a publishable key, for environments
+that require context signatures.
+
+A publishable key is public, so the API can't trust the company and user a
+browser names with it. When the environment requires signatures, your backend,
+which holds the secret API key, signs the logged-in user's own company and
+user, and the browser SDK sends that signature in the X-Schematic-Context-Sig
+header. Sign the context from your own authenticated session, never from what
+the browser asks for.
+
+The signature is ``iat.exp.sig``, where ``sig`` is the unpadded base64url
+HMAC-SHA256 of ``canonical(context).iat.exp`` keyed with the secret API key,
+and ``canonical`` is the context's JSON with keys sorted by code point and no
+whitespace. It matches the Go and Node.js SDKs.
+"""
+
+import base64
+import hashlib
+import hmac
+import json
+import math
+import time
+from datetime import datetime
+from typing import Mapping, Optional, TypedDict
+
+CONTEXT_SIGNATURE_HEADER = "X-Schematic-Context-Sig"
+"""Request header the browser SDK sends the signature in."""
+
+DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS = 600
+"""How long a signature is valid for unless ``lifetime_seconds`` says otherwise.
+It matches how long the browser SDK reuses one."""
+
+MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS = 3600
+"""The longest the API accepts a signature as valid for."""
+
+
+class SignableContext(TypedDict, total=False):
+ """The company and user a browser SDK checks flags and sends events for,
+ by their keys. Leave one out, or set it to None, when it's absent; an empty
+ dict is signed as empty. It must match the context the browser SDK is given
+ exactly."""
+
+ company: Optional[Mapping[str, str]]
+ user: Optional[Mapping[str, str]]
+
+
+class ContextSignatureError(Exception):
+ """Exception raised when a context can't be signed."""
+
+ pass
+
+
+def sign_context(
+ secret: str,
+ context: SignableContext,
+ *,
+ lifetime_seconds: int = DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS,
+ issued_at: Optional[datetime] = None,
+) -> str:
+ """Return the X-Schematic-Context-Sig value for a context.
+
+ Args:
+ secret: The secret API key for the environment the browser SDK's
+ publishable key belongs to
+ context: The logged-in user's company and user keys
+ lifetime_seconds: How long the signature is valid for, in whole
+ seconds, from 1 up to MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS
+ issued_at: When the signature is valid from; now by default. A naive
+ datetime is read as local time. Counted in whole seconds.
+
+ Returns:
+ The signature, ``iat.exp.sig``
+
+ Raises:
+ ContextSignatureError: If the secret is missing, the lifetime is out
+ of range, or a key's value isn't a string
+ """
+ if not secret:
+ raise ContextSignatureError("A secret API key is required to sign a context.")
+
+ if (
+ isinstance(lifetime_seconds, bool)
+ or not isinstance(lifetime_seconds, int)
+ or not 1 <= lifetime_seconds <= MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS
+ ):
+ raise ContextSignatureError(
+ f"lifetime_seconds must be a whole number from 1 to {MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS}."
+ )
+
+ timestamp = time.time() if issued_at is None else issued_at.timestamp()
+ iat = math.floor(timestamp)
+ return _sign_context_at(secret, context, iat, iat + lifetime_seconds)
+
+
+def _sign_context_at(secret: str, context: SignableContext, iat: int, exp: int) -> str:
+ payload = f"{canonicalize_context(context)}.{iat}.{exp}"
+ digest = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()
+ sig = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
+ return f"{iat}.{exp}.{sig}"
+
+
+def canonicalize_context(context: SignableContext) -> str:
+ """Encode a context the way the API does before verifying it: keys sorted
+ by code point, no whitespace, ``<``, ``>``, ``&`` and ``/`` left literal,
+ non-ASCII characters left raw, and U+2028 and U+2029 escaped."""
+ parts = []
+ # "company" sorts before "user".
+ company = context.get("company")
+ if company is not None:
+ parts.append(f'"company":{_canonicalize_keys(company)}')
+ user = context.get("user")
+ if user is not None:
+ parts.append(f'"user":{_canonicalize_keys(user)}')
+ return "{" + ",".join(parts) + "}"
+
+
+def _canonicalize_keys(keys: Mapping[str, str]) -> str:
+ normalized = {}
+ for name, value in keys.items():
+ if not isinstance(name, str) or not isinstance(value, str):
+ raise ContextSignatureError(f"The context key {name!r} and its value must be strings.")
+ normalized[_normalize(name)] = _normalize(value)
+ # Python compares strings by code point, the order the API sorts in.
+ entries = [f"{_encode_string(name)}:{_encode_string(normalized[name])}" for name in sorted(normalized)]
+ return "{" + ",".join(entries) + "}"
+
+
+def _normalize(value: str) -> str:
+ # The API reads a lone surrogate as U+FFFD and joins a surrogate pair into
+ # the character it encodes; Python strings can hold either.
+ return value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace")
+
+
+def _encode_string(value: str) -> str:
+ # json.dumps escapes quotes, backslashes and control characters the way the
+ # API does. ensure_ascii=False keeps other characters raw, as the API does,
+ # except U+2028 and U+2029, which the API escapes.
+ encoded = json.dumps(value, ensure_ascii=False)
+ return encoded.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
diff --git a/tests/context_signature/__init__.py b/tests/context_signature/__init__.py
new file mode 100644
index 00000000..ec659f47
--- /dev/null
+++ b/tests/context_signature/__init__.py
@@ -0,0 +1 @@
+"""Tests for context signing"""
diff --git a/tests/context_signature/context-signing.json b/tests/context_signature/context-signing.json
new file mode 100644
index 00000000..5ade5a85
--- /dev/null
+++ b/tests/context_signature/context-signing.json
@@ -0,0 +1,284 @@
+{
+ "category": "context_signing",
+ "description": "Vectors for signing a Schematic context, generated from the API's verifier (api/lib/contextsig) and checked by its tests. A signing helper must produce `canonical` from `context`, then `signature` from `canonical`, `secret`, `iat` and `exp`, byte for byte. A context is an object with optional `company` and `user`, each a flat map of string keys to string values; omit either when it's absent rather than sending null. A helper must refuse to sign when `exp - iat` exceeds `max_lifetime_seconds`. The wire format is described in docs/docs/ADRs/context-signatures.md. When an implementation diverges, add a vector here rather than special-casing one implementation.",
+ "max_lifetime_seconds": 3600,
+ "vectors": [
+ {
+ "name": "empty_context",
+ "description": "A context with neither company nor user signs as an empty object.",
+ "context": {},
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{}",
+ "signature": "1700000000.1700000600.dHOxXm5b92k2zvYhI0SQ-Fiob9S40pxnuuO0af5m-bc"
+ },
+ {
+ "name": "company_only",
+ "description": "Only the company is set; user is omitted entirely, not sent as null.",
+ "context": {
+ "company": {
+ "id": "comp_1"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"id\":\"comp_1\"}}",
+ "signature": "1700000000.1700000600.4Kp-nByFvw81ya-bGvfIfxCZ01CFJsLuvpv7PExMn-g"
+ },
+ {
+ "name": "user_only",
+ "description": "Only the user is set.",
+ "context": {
+ "user": {
+ "id": "user_1"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"user\":{\"id\":\"user_1\"}}",
+ "signature": "1700000000.1700000600.TtaAnqrSGcoZWyyNEpirmq9GpAgmSmlfVNqtXzSKmCI"
+ },
+ {
+ "name": "user_inserted_before_company",
+ "description": "Top-level keys sort, so insertion order doesn't matter.",
+ "context": {
+ "user": {
+ "id": "user_1"
+ },
+ "company": {
+ "id": "comp_1"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"id\":\"comp_1\"},\"user\":{\"id\":\"user_1\"}}",
+ "signature": "1700000000.1700000600.yeQ3kB3C0WzlSDXlN2KRI0b02erLakr-zT2ran9eSJo"
+ },
+ {
+ "name": "multiple_keys_unsorted",
+ "description": "Keys inside company and user sort too.",
+ "context": {
+ "company": {
+ "plan_id": "plan_1",
+ "id": "comp_1"
+ },
+ "user": {
+ "role": "admin",
+ "email": "a@example.com"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"id\":\"comp_1\",\"plan_id\":\"plan_1\"},\"user\":{\"email\":\"a@example.com\",\"role\":\"admin\"}}",
+ "signature": "1700000000.1700000600.9agSoQf9J1KVQ5aC-mAKWSstw0ukgHNKFbpdi_0Vm1k"
+ },
+ {
+ "name": "empty_company",
+ "description": "An empty keys map is kept as {}.",
+ "context": {
+ "company": {},
+ "user": {
+ "id": "user_1"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{},\"user\":{\"id\":\"user_1\"}}",
+ "signature": "1700000000.1700000600.lrpWxfGjqyZrlEIqHqEqW0weySvK14Qtlf6RwssLZQ0"
+ },
+ {
+ "name": "keys_sort_by_byte_uppercase_first",
+ "description": "Keys sort by UTF-8 bytes, not case-insensitively: 'B' (0x42) < '_' (0x5F) < 'a' (0x61).",
+ "context": {
+ "company": {
+ "a": "1",
+ "_": "2",
+ "B": "3"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"B\":\"3\",\"_\":\"2\",\"a\":\"1\"}}",
+ "signature": "1700000000.1700000600.0tso_81DIOtAlItlg2BLCQMTOYeEeTX_GijMjJ63LtU"
+ },
+ {
+ "name": "keys_sort_shorter_prefix_first",
+ "description": "A key that is a prefix of another sorts first; '2' (0x32) sorts before '_' (0x5F).",
+ "context": {
+ "company": {
+ "id_2": "c",
+ "id2": "b",
+ "id": "a"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"id\":\"a\",\"id2\":\"b\",\"id_2\":\"c\"}}",
+ "signature": "1700000000.1700000600.PRBTr8382ZV5XvWaEaFAHXBzN1ekP2a-Oai-sOFEvxY"
+ },
+ {
+ "name": "keys_sort_by_code_point_not_utf16",
+ "description": "Keys sort by code point (equivalently UTF-8 bytes). UTF-16 order, the default string sort in JavaScript, Java and C#, puts the emoji (surrogate 0xD83D) before U+FF5E; code-point order puts U+FF5E (0xFF5E) before the emoji (0x1F600).",
+ "context": {
+ "company": {
+ "😀": "emoji",
+ "~": "fullwidth tilde"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"~\":\"fullwidth tilde\",\"😀\":\"emoji\"}}",
+ "signature": "1700000000.1700000600.OHfc8vQolXTC35whD2P4r77OwgI1LrsszSYczJXOfzk"
+ },
+ {
+ "name": "empty_key_and_value",
+ "description": "Empty strings are valid keys and values; the empty key sorts first.",
+ "context": {
+ "company": {
+ "id": "",
+ "": "x"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"\":\"x\",\"id\":\"\"}}",
+ "signature": "1700000000.1700000600.I_EAwzsawe_Jrq01CTstVhYk9mrQx18-z3oG-WagVNE"
+ },
+ {
+ "name": "digit_string_stays_string",
+ "description": "A value made of digits is still a string and keeps its quotes.",
+ "context": {
+ "company": {
+ "id": "12345"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"id\":\"12345\"}}",
+ "signature": "1700000000.1700000600.oQyoPgb4BrXfS4XgpEUNg-go-Un6aG1d3MhUGopWbeQ"
+ },
+ {
+ "name": "html_characters_unescaped",
+ "description": "'<', '>' and '&' appear literally, not as \\u003c, \\u003e, \\u0026.",
+ "context": {
+ "company": {
+ "name": "Tom & Jerry"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"name\":\"Tom & Jerry\"}}",
+ "signature": "1700000000.1700000600.kFKnwy-dtdRT3HPD5X2r_Ft-ToXEU40kZNNIRHPGhOE"
+ },
+ {
+ "name": "solidus_unescaped",
+ "description": "'/' appears literally, not as '\\/'.",
+ "context": {
+ "company": {
+ "url": "https://example.com/a/b"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"url\":\"https://example.com/a/b\"}}",
+ "signature": "1700000000.1700000600.aIu-pdUIc2GrWRUYjB2Ly36SQGz_QhUlRFYRosDHJMY"
+ },
+ {
+ "name": "quote_and_backslash_escaped",
+ "description": "'\"' and '\\' are escaped with a backslash.",
+ "context": {
+ "company": {
+ "name": "say \"hi\" \\ bye"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"name\":\"say \\\"hi\\\" \\\\ bye\"}}",
+ "signature": "1700000000.1700000600.iCLZi3k6QgO7-WDoYBpuGeqiSrtzcBKpmYNQ1m-D3Lw"
+ },
+ {
+ "name": "control_characters_escaped",
+ "description": "Control characters below U+0020 are escaped: \\b, \\f, \\n, \\r and \\t by name, the rest as lowercase \\u00XX. DEL (U+007F) is not escaped.",
+ "context": {
+ "company": {
+ "note": "a\nb\rc\td\be\ff\u0000g\u001fh\u007fi"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"note\":\"a\\nb\\rc\\td\\be\\ff\\u0000g\\u001fh\u007fi\"}}",
+ "signature": "1700000000.1700000600.1RSPKFMOqM2H7Od15cDeBnerUNgl7RCsodWudl8KKjg"
+ },
+ {
+ "name": "line_separators_escaped",
+ "description": "U+2028 and U+2029 are escaped as \\u2028 and \\u2029. JSON.stringify, Python's json.dumps and most other encoders leave them raw.",
+ "context": {
+ "company": {
+ "note": "a\u2028b\u2029c"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"note\":\"a\\u2028b\\u2029c\"}}",
+ "signature": "1700000000.1700000600.tbU1MgYS9wXPnGr9amA62M1FvaMxt7-OXS_4axnP2fA"
+ },
+ {
+ "name": "non_ascii_raw",
+ "description": "Other non-ASCII characters appear as raw UTF-8, never as \\u escapes; that includes characters outside the BMP, which are not written as surrogate pairs.",
+ "context": {
+ "company": {
+ "name": "Café 日本 😀"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"name\":\"Café 日本 😀\"}}",
+ "signature": "1700000000.1700000600.H6liuTTYNtLZaPqBhyUUw5tMp8d4ot2yHjfpZnTqcpk"
+ },
+ {
+ "name": "other_secret",
+ "description": "The same context signed with a different secret gives a different signature.",
+ "context": {
+ "company": {
+ "id": "comp_1"
+ }
+ },
+ "secret": "test-secret-2",
+ "iat": 1700000000,
+ "exp": 1700000600,
+ "canonical": "{\"company\":{\"id\":\"comp_1\"}}",
+ "signature": "1700000000.1700000600.4eEK8qmJmv3l1kYXii_XXe2Cy4NuARgAG6XkThUBqQg"
+ },
+ {
+ "name": "max_lifetime",
+ "description": "exp may be at most max_lifetime_seconds after iat; this signature is valid for exactly that long.",
+ "context": {
+ "company": {
+ "id": "comp_1"
+ }
+ },
+ "secret": "test-secret-1",
+ "iat": 1700000000,
+ "exp": 1700003600,
+ "canonical": "{\"company\":{\"id\":\"comp_1\"}}",
+ "signature": "1700000000.1700003600.wZrgH4Y3MJWGbFIdN26tz9HRg4wbWmM89ao5kgbazUw"
+ }
+ ]
+}
diff --git a/tests/context_signature/test_signing.py b/tests/context_signature/test_signing.py
new file mode 100644
index 00000000..e4dd7b96
--- /dev/null
+++ b/tests/context_signature/test_signing.py
@@ -0,0 +1,91 @@
+"""Tests for context signing
+
+context-signing.json is copied verbatim from api/lib/contextsig/testdata in the
+Schematic API, which generates it from the verifier and checks it in its own
+tests. Don't edit it here.
+"""
+
+import json
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, Tuple
+
+import pytest
+
+from schematic.context_signature import (
+ DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS,
+ MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS,
+ ContextSignatureError,
+ canonicalize_context,
+ sign_context,
+)
+from schematic.context_signature.signing import _sign_context_at
+
+VECTORS: Dict[str, Any] = json.loads(
+ (Path(__file__).parent / "context-signing.json").read_text(encoding="utf-8")
+)
+
+
+def _window(signature: str) -> Tuple[int, int]:
+ iat, exp, _ = signature.split(".")
+ return int(iat), int(exp)
+
+
+def test_caps_the_lifetime_where_the_api_does():
+ assert MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS == VECTORS["max_lifetime_seconds"]
+
+
+@pytest.mark.parametrize("vector", VECTORS["vectors"], ids=[v["name"] for v in VECTORS["vectors"]])
+def test_signing_vectors(vector):
+ assert canonicalize_context(vector["context"]) == vector["canonical"]
+ assert _sign_context_at(vector["secret"], vector["context"], vector["iat"], vector["exp"]) == vector["signature"]
+
+
+CONTEXT = {"company": {"id": "comp_1"}}
+
+
+def test_signs_for_the_default_lifetime_from_now():
+ before = int(time.time())
+ iat, exp = _window(sign_context("secret", CONTEXT))
+
+ assert before <= iat <= int(time.time())
+ assert exp - iat == DEFAULT_CONTEXT_SIGNATURE_LIFETIME_SECONDS
+
+
+def test_signs_from_the_time_and_for_the_lifetime_given():
+ issued_at = datetime.fromtimestamp(1_700_000_000.5, tz=timezone.utc)
+ signature = sign_context("secret", CONTEXT, issued_at=issued_at, lifetime_seconds=300)
+
+ assert signature == _sign_context_at("secret", CONTEXT, 1_700_000_000, 1_700_000_300)
+
+
+@pytest.mark.parametrize("lifetime_seconds", [0, -60, 1.5, True, MAX_CONTEXT_SIGNATURE_LIFETIME_SECONDS + 1, 86_400])
+def test_refuses_a_lifetime_the_api_would_reject(lifetime_seconds):
+ with pytest.raises(ContextSignatureError):
+ sign_context("secret", CONTEXT, lifetime_seconds=lifetime_seconds)
+
+
+def test_refuses_a_missing_secret():
+ with pytest.raises(ContextSignatureError):
+ sign_context("", CONTEXT)
+
+
+@pytest.mark.parametrize("keys", [{"id": 123}, {"id": None}, {1: "a"}])
+def test_refuses_a_key_or_value_that_isnt_a_string(keys):
+ with pytest.raises(ContextSignatureError):
+ sign_context("secret", {"company": keys})
+
+
+def test_leaves_out_a_company_or_user_that_is_none():
+ assert canonicalize_context({"company": None, "user": None}) == "{}"
+
+
+def test_signs_a_lone_surrogate_as_the_replacement_character():
+ assert canonicalize_context({"company": {"name": "a\ud800b"}}) == '{"company":{"name":"a\ufffdb"}}'
+ assert canonicalize_context({"company": {"name": "a\udc00"}}) == '{"company":{"name":"a\ufffd"}}'
+
+
+def test_joins_a_surrogate_pair_into_its_character():
+ assert canonicalize_context({"company": {"name": "\ud83d\ude00"}}) == '{"company":{"name":"\U0001f600"}}'
+ assert canonicalize_context({"company": {"name": "\U0001f600"}}) == '{"company":{"name":"\U0001f600"}}'