From f0bb579eb76e810fc648f297439f8c809d0f9350 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 27 Oct 2026 10:47:41 -0600 Subject: [PATCH] add a context signing helper --- .fernignore | 3 + README.md | 40 +++ .../contextsig/ContextSignature.java | 265 ++++++++++++++++ .../contextsig/ContextSignatureException.java | 26 ++ .../contextsig/ContextSignatureTest.java | 155 ++++++++++ .../resources/contextsig/context-signing.json | 284 ++++++++++++++++++ 6 files changed, 773 insertions(+) create mode 100644 src/main/java/com/schematic/contextsig/ContextSignature.java create mode 100644 src/main/java/com/schematic/contextsig/ContextSignatureException.java create mode 100644 src/test/java/com/schematic/contextsig/ContextSignatureTest.java create mode 100644 src/test/resources/contextsig/context-signing.json diff --git a/.fernignore b/.fernignore index ef44d36b..06f66449 100644 --- a/.fernignore +++ b/.fernignore @@ -29,6 +29,7 @@ src/main/java/com/schematic/api/logger/LogLevel.java src/main/java/com/schematic/api/datastream/ src/main/java/com/schematic/api/logger/SchematicLogger.java src/main/java/com/schematic/webhook/ +src/main/java/com/schematic/contextsig/ src/test/java/com/schematic/api/HttpEventSenderTest.java src/test/java/com/schematic/api/TestCache.java src/test/java/com/schematic/api/TestEventBuffer.java @@ -41,6 +42,8 @@ src/test/java/com/schematic/api/credits/ src/test/java/com/schematic/api/datastream/ src/test/resources/quantity_cost.json src/test/java/com/schematic/webhook/ +src/test/java/com/schematic/contextsig/ +src/test/resources/contextsig/ .fern/replay.lock .fern/replay.yml .gitattributes diff --git a/README.md b/README.md index e176b9a9..aa2a43f0 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,46 @@ public void verifyWebhookManually(String body, String signature, String timestam } ``` +## 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. + +```java +import com.schematic.contextsig.ContextSignature; +import java.io.IOException; +import java.util.Collections; + +// In your signing endpoint handler: +public void signSchematicContext(HttpServletRequest request, HttpServletResponse response) throws IOException { + YourSession session = yourSession(request); // however your app identifies the logged-in user + if (session == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String signature = ContextSignature.sign( + System.getenv("SCHEMATIC_SECRET_KEY"), + Collections.singletonMap("id", session.getCompanyId()), + Collections.singletonMap("id", session.getUserId())); + + response.setContentType("application/json"); + response.getWriter().write("{\"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 `ContextSignature.options().lifetimeSeconds(...)`, and a start time other than now with `issuedAt(...)`: + +```java +String signature = ContextSignature.sign( + secret, company, user, ContextSignature.options().lifetimeSeconds(1800)); +``` + +The browser SDK reuses a signature until shortly before it expires, then asks for a new one. `sign` throws a `ContextSignatureException` when the secret is missing, the lifetime is out of range, or a key's value is null. + ## Configuration Options There are a number of configuration options that can be specified using the builder when instantiating the Schematic client. diff --git a/src/main/java/com/schematic/contextsig/ContextSignature.java b/src/main/java/com/schematic/contextsig/ContextSignature.java new file mode 100644 index 00000000..5fdc6efc --- /dev/null +++ b/src/main/java/com/schematic/contextsig/ContextSignature.java @@ -0,0 +1,265 @@ +package com.schematic.contextsig; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * 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 {@code iat.exp.sig}, where {@code sig} is the unpadded base64url HMAC-SHA256 of + * {@code canonical(context).iat.exp} keyed with the secret API key, and {@code canonical} is the + * context's JSON with keys sorted by code point and no whitespace. + */ +public final class ContextSignature { + + /** + * Request header the browser SDK sends the signature in. + */ + public static final String CONTEXT_SIGNATURE_HEADER = "X-Schematic-Context-Sig"; + + /** + * How long a signature is valid for unless {@link Options#lifetimeSeconds(long)} says otherwise. + * It matches how long the browser SDK reuses one. + */ + public static final long DEFAULT_LIFETIME_SECONDS = 600; + + /** + * The longest the API accepts a signature as valid for. + */ + public static final long MAX_LIFETIME_SECONDS = 3600; + + private static final String HMAC_SHA256 = "HmacSHA256"; + + // The API sorts keys by their UTF-8 bytes, which is code point order. + // String.compareTo compares UTF-16 code units, which puts a character + // outside the BMP (a surrogate pair) before U+E000 to U+FFFF. + private static final Comparator CODE_POINT_ORDER = ContextSignature::compareCodePoints; + + private ContextSignature() {} + + /** + * Options for {@link #sign(String, Map, Map, Options)}. + */ + public static final class Options { + private Instant issuedAt; + private long lifetimeSeconds = DEFAULT_LIFETIME_SECONDS; + + /** + * Sets when the signature is valid from, in place of now, for a caller that keeps its own + * clock. It's counted in whole seconds. + * + * @param issuedAt when the signature is valid from + * @return these options + */ + public Options issuedAt(Instant issuedAt) { + this.issuedAt = issuedAt; + return this; + } + + /** + * Sets how long the signature is valid for, from 1 up to {@link #MAX_LIFETIME_SECONDS}. + * + * @param lifetimeSeconds the lifetime in seconds + * @return these options + */ + public Options lifetimeSeconds(long lifetimeSeconds) { + this.lifetimeSeconds = lifetimeSeconds; + return this; + } + } + + /** + * Returns new options: valid from now, for {@link #DEFAULT_LIFETIME_SECONDS}. + * + * @return the default options + */ + public static Options options() { + return new Options(); + } + + /** + * Returns the X-Schematic-Context-Sig value for a context, valid from now for + * {@link #DEFAULT_LIFETIME_SECONDS}. + * + * @param secret the secret API key for the environment the browser SDK's publishable key + * belongs to + * @param company the logged-in user's company keys, or null to leave the company out + * @param user the logged-in user's keys, or null to leave the user out + * @return the signature + * @throws ContextSignatureException if the secret is missing or a key's value is null + */ + public static String sign(String secret, Map company, Map user) { + return sign(secret, company, user, options()); + } + + /** + * Returns the X-Schematic-Context-Sig value for a context. The company and user keys must + * match the context the browser SDK is given exactly; an empty map is signed as empty. + * + * @param secret the secret API key for the environment the browser SDK's publishable key + * belongs to + * @param company the logged-in user's company keys, or null to leave the company out + * @param user the logged-in user's keys, or null to leave the user out + * @param options when the signature is valid from, and for how long + * @return the signature + * @throws ContextSignatureException if the secret is missing, the lifetime is out of range, or + * a key's value is null + */ + public static String sign(String secret, Map company, Map user, Options options) { + Options opts = options == null ? options() : options; + if (opts.lifetimeSeconds < 1 || opts.lifetimeSeconds > MAX_LIFETIME_SECONDS) { + throw new ContextSignatureException("lifetimeSeconds must be from 1 to " + MAX_LIFETIME_SECONDS + "."); + } + + Instant issuedAt = opts.issuedAt == null ? Instant.now() : opts.issuedAt; + long iat = issuedAt.getEpochSecond(); + return signAt(secret, company, user, iat, iat + opts.lifetimeSeconds); + } + + static String signAt(String secret, Map company, Map user, long iat, long exp) { + if (secret == null || secret.isEmpty()) { + throw new ContextSignatureException("A secret API key is required to sign a context."); + } + + String payload = canonicalize(company, user) + "." + iat + "." + exp; + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256)); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + String sig = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + return iat + "." + exp + "." + sig; + } catch (GeneralSecurityException e) { + throw new ContextSignatureException("Could not compute the context signature.", e); + } + } + + /** + * Encodes a context the way the API does before verifying it: keys sorted by code point, no + * whitespace, {@code <}, {@code >}, {@code &} and {@code /} left literal, U+2028 and U+2029 + * escaped, and a lone surrogate read as U+FFFD. + */ + static String canonicalize(Map company, Map user) { + List parts = new ArrayList<>(2); + // "company" sorts before "user". + if (company != null) { + parts.add("\"company\":" + canonicalizeKeys(company)); + } + if (user != null) { + parts.add("\"user\":" + canonicalizeKeys(user)); + } + return "{" + String.join(",", parts) + "}"; + } + + private static String canonicalizeKeys(Map keys) { + List names = new ArrayList<>(keys.keySet()); + for (String name : names) { + if (name == null) { + throw new ContextSignatureException("Context keys can't be null."); + } + } + names.sort(CODE_POINT_ORDER); + + StringBuilder sb = new StringBuilder("{"); + for (int i = 0; i < names.size(); i++) { + String name = names.get(i); + String value = keys.get(name); + if (value == null) { + throw new ContextSignatureException("The value of context key \"" + name + "\" must be a string."); + } + if (i > 0) { + sb.append(','); + } + appendString(sb, name); + sb.append(':'); + appendString(sb, value); + } + return sb.append('}').toString(); + } + + private static int compareCodePoints(String a, String b) { + int i = 0; + int j = 0; + while (i < a.length() && j < b.length()) { + int ca = a.codePointAt(i); + int cb = b.codePointAt(j); + if (ca != cb) { + return Integer.compare(ca, cb); + } + i += Character.charCount(ca); + j += Character.charCount(cb); + } + return Integer.compare(a.length() - i, b.length() - j); + } + + // Matches Go's encoding/json with HTML escaping off: quotes and + // backslashes escaped, \b \f \n \r \t by name, other control characters + // as six-character escapes with lowercase hex, U+2028 and U+2029 escaped, + // and everything else raw. + private static void appendString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (Character.isHighSurrogate(c) + && i + 1 < value.length() + && Character.isLowSurrogate(value.charAt(i + 1))) { + sb.append(c).append(value.charAt(++i)); + continue; + } + if (Character.isSurrogate(c)) { + sb.append('\uFFFD'); + continue; + } + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '\u2028': + sb.append("\\u2028"); + break; + case '\u2029': + sb.append("\\u2029"); + break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } +} diff --git a/src/main/java/com/schematic/contextsig/ContextSignatureException.java b/src/main/java/com/schematic/contextsig/ContextSignatureException.java new file mode 100644 index 00000000..86b18c1c --- /dev/null +++ b/src/main/java/com/schematic/contextsig/ContextSignatureException.java @@ -0,0 +1,26 @@ +package com.schematic.contextsig; + +/** + * Exception thrown when a context can't be signed. + */ +public class ContextSignatureException extends RuntimeException { + + /** + * Constructs a new ContextSignatureException with the specified detail message. + * + * @param message the detail message + */ + public ContextSignatureException(String message) { + super(message); + } + + /** + * Constructs a new ContextSignatureException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause + */ + public ContextSignatureException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/test/java/com/schematic/contextsig/ContextSignatureTest.java b/src/test/java/com/schematic/contextsig/ContextSignatureTest.java new file mode 100644 index 00000000..c91577dd --- /dev/null +++ b/src/test/java/com/schematic/contextsig/ContextSignatureTest.java @@ -0,0 +1,155 @@ +package com.schematic.contextsig; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class ContextSignatureTest { + + // contextsig/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. + private static JsonNode loadVectors() throws IOException { + try (InputStream in = ContextSignatureTest.class.getResourceAsStream("/contextsig/context-signing.json")) { + assertNotNull(in, "context-signing.json is missing"); + return new ObjectMapper().readTree(in); + } + } + + private static Map keys(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + Map keys = new LinkedHashMap<>(); + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + keys.put(field.getKey(), field.getValue().asText()); + } + return keys; + } + + static Stream vectors() throws IOException { + List args = new ArrayList<>(); + for (JsonNode v : loadVectors().get("vectors")) { + args.add(Arguments.of(v.get("name").asText(), v)); + } + return args.stream(); + } + + private static long[] window(String signature) { + String[] parts = signature.split("\\."); + assertEquals(3, parts.length); + return new long[] {Long.parseLong(parts[0]), Long.parseLong(parts[1])}; + } + + private static Map company() { + return Collections.singletonMap("id", "comp_1"); + } + + @Test + void capsTheLifetimeWhereTheApiDoes() throws IOException { + assertEquals(loadVectors().get("max_lifetime_seconds").asLong(), ContextSignature.MAX_LIFETIME_SECONDS); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("vectors") + void matchesSigningVector(String name, JsonNode v) { + JsonNode context = v.get("context"); + Map company = keys(context.get("company")); + Map user = keys(context.get("user")); + + assertEquals(v.get("canonical").asText(), ContextSignature.canonicalize(company, user)); + assertEquals( + v.get("signature").asText(), + ContextSignature.signAt( + v.get("secret").asText(), + company, + user, + v.get("iat").asLong(), + v.get("exp").asLong())); + } + + @Test + void signsForTheDefaultLifetimeFromNow() { + long before = Instant.now().getEpochSecond(); + long[] window = window(ContextSignature.sign("secret", company(), null)); + + assertTrue(window[0] >= before); + assertTrue(window[0] <= Instant.now().getEpochSecond()); + assertEquals(ContextSignature.DEFAULT_LIFETIME_SECONDS, window[1] - window[0]); + } + + @Test + void signsFromTheTimeAndForTheLifetimeGiven() { + Instant issuedAt = Instant.ofEpochMilli(1_700_000_000_500L); + String signature = ContextSignature.sign( + "secret", + company(), + null, + ContextSignature.options().issuedAt(issuedAt).lifetimeSeconds(300)); + + assertEquals(ContextSignature.signAt("secret", company(), null, 1_700_000_000L, 1_700_000_300L), signature); + } + + @ParameterizedTest + @ValueSource(longs = {0, -60, ContextSignature.MAX_LIFETIME_SECONDS + 1, 86_400}) + void refusesALifetimeTheApiWouldReject(long lifetimeSeconds) { + assertThrows( + ContextSignatureException.class, + () -> ContextSignature.sign( + "secret", company(), null, ContextSignature.options().lifetimeSeconds(lifetimeSeconds))); + } + + @Test + void refusesAMissingSecret() { + assertThrows(ContextSignatureException.class, () -> ContextSignature.sign("", company(), null)); + assertThrows(ContextSignatureException.class, () -> ContextSignature.sign(null, company(), null)); + } + + @Test + void refusesANullValue() { + Map company = new HashMap<>(); + company.put("id", null); + assertThrows(ContextSignatureException.class, () -> ContextSignature.sign("secret", company, null)); + } + + @Test + void leavesOutANullCompanyAndUser() { + assertEquals("{}", ContextSignature.canonicalize(null, null)); + } + + @Test + void signsALoneSurrogateAsReplacementCharacterAsTheApiReadsIt() { + assertEquals( + "{\"company\":{\"name\":\"a�b\"}}", + ContextSignature.canonicalize(Collections.singletonMap("name", "a\uD800b"), null)); + assertEquals( + "{\"company\":{\"name\":\"a�\"}}", + ContextSignature.canonicalize(Collections.singletonMap("name", "a\uDC00"), null)); + } + + @Test + void keepsAValidSurrogatePair() { + assertEquals( + "{\"company\":{\"name\":\"😀\"}}", + ContextSignature.canonicalize(Collections.singletonMap("name", "😀"), null)); + } +} diff --git a/src/test/resources/contextsig/context-signing.json b/src/test/resources/contextsig/context-signing.json new file mode 100644 index 00000000..5ade5a85 --- /dev/null +++ b/src/test/resources/contextsig/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" + } + ] +}