@forumulate/sdk 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +39 -0
- package/dist/cache.d.ts +56 -0
- package/dist/cache.js +34 -0
- package/dist/http.d.ts +1 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +82 -1
- package/dist/index.js +14 -0
- package/dist/operations.gen.d.ts +1315 -24
- package/dist/operations.gen.js +243 -40
- package/dist/policies.d.ts +264 -0
- package/dist/policies.js +93 -0
- package/dist/policy-crypto.d.ts +5 -0
- package/dist/policy-crypto.js +31 -0
- package/dist/policy-feed.d.ts +139 -0
- package/dist/policy-feed.js +249 -0
- package/dist/policy-tickets.d.ts +55 -0
- package/dist/policy-tickets.js +75 -0
- package/dist/policy-tokens.d.ts +188 -0
- package/dist/policy-tokens.js +251 -0
- package/dist/schema.d.ts +2 -2
- package/package.json +2 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POLICY_TICKET_MAX_SECONDS = exports.POLICY_TOKEN_AUDIENCE = void 0;
|
|
4
|
+
exports.hmacBytes = hmacBytes;
|
|
5
|
+
exports.parsePolicyGateKeys = parsePolicyGateKeys;
|
|
6
|
+
exports.policySha256Hex = policySha256Hex;
|
|
7
|
+
exports.policyEmailHash = policyEmailHash;
|
|
8
|
+
exports.decodePolicyToken = decodePolicyToken;
|
|
9
|
+
exports.verifyAcceptanceToken = verifyAcceptanceToken;
|
|
10
|
+
exports.verifyPolicyTicket = verifyPolicyTicket;
|
|
11
|
+
/**
|
|
12
|
+
* The policy gate's tokens, on YOUR server: sign a ticket, verify an
|
|
13
|
+
* acceptance token, offline.
|
|
14
|
+
*
|
|
15
|
+
* The gate hands three kinds of compact HS256 JWT around, all signed with a
|
|
16
|
+
* key of the Legal space's keyring (`pgk_…` id, `pgs_…` secret):
|
|
17
|
+
*
|
|
18
|
+
* ticket your server → the browser: "this browser acts for user S, for
|
|
19
|
+
* event E". You sign it; the widget and the hosted page present
|
|
20
|
+
* it, so nobody can accept in somebody else's name.
|
|
21
|
+
* acceptance Forumulate → your server: "S accepted these exact versions,
|
|
22
|
+
* shown this way, and is / is not clear to proceed".
|
|
23
|
+
* receipt an acceptance with no subject yet (a sign-up form accepted
|
|
24
|
+
* before the account existed); redeem it with
|
|
25
|
+
* `client.policies.claim`.
|
|
26
|
+
*
|
|
27
|
+
* The same key signs tickets (you) and acceptance tokens (us), so a verifier
|
|
28
|
+
* MUST check `use`: without that, a ticket — which you hand to the browser —
|
|
29
|
+
* would verify as proof of acceptance. `verifyAcceptanceToken` checks it, and
|
|
30
|
+
* the audience, the issuer, the expiry and (when you pass them) the subject
|
|
31
|
+
* and the space.
|
|
32
|
+
*
|
|
33
|
+
* The format is the one in `packages/shared/src/policy-gate/token.ts`, which
|
|
34
|
+
* signs the real tokens; `policy-tokens.test.ts` signs with that file and
|
|
35
|
+
* verifies with this one, so the two cannot drift apart quietly. It is a plain
|
|
36
|
+
* JWT on purpose: any JWT library in any language verifies it with the secret.
|
|
37
|
+
*
|
|
38
|
+
* WebCrypto only — Node ≥ 20, Bun, Deno, Workers, Auth0 Actions, a browser.
|
|
39
|
+
*/
|
|
40
|
+
const policy_crypto_1 = require("./policy-crypto");
|
|
41
|
+
exports.POLICY_TOKEN_AUDIENCE = 'forumulate:policy-gate';
|
|
42
|
+
/** A ticket may live this long at most; the API refuses a longer one. */
|
|
43
|
+
exports.POLICY_TICKET_MAX_SECONDS = 3600;
|
|
44
|
+
const enc = new TextEncoder();
|
|
45
|
+
async function hmacBytes(key, data) {
|
|
46
|
+
const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
47
|
+
return new Uint8Array(await crypto.subtle.sign('HMAC', k, enc.encode(data)));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse a keyring from one environment variable: `kid:secret` pairs separated
|
|
51
|
+
* by commas or whitespace (`FORUMULATE_POLICY_GATE_KEYS`). A bare secret is
|
|
52
|
+
* kept with an empty `kid` and tried against every token.
|
|
53
|
+
*/
|
|
54
|
+
function parsePolicyGateKeys(value) {
|
|
55
|
+
return (value ?? '')
|
|
56
|
+
.split(/[\s,]+/)
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((entry) => {
|
|
59
|
+
const i = entry.indexOf(':');
|
|
60
|
+
return i > 0 && entry.startsWith('pgk_')
|
|
61
|
+
? { kid: entry.slice(0, i), secret: entry.slice(i + 1) }
|
|
62
|
+
: { kid: '', secret: entry };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** SHA-256 hex of a string's UTF-8 bytes (the content hash). */
|
|
66
|
+
async function policySha256Hex(text) {
|
|
67
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(text)));
|
|
68
|
+
let out = '';
|
|
69
|
+
for (const b of digest)
|
|
70
|
+
out += b.toString(16).padStart(2, '0');
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** How an email becomes `emailHash`: trimmed, lower-cased, SHA-256 hex. */
|
|
74
|
+
function policyEmailHash(email) {
|
|
75
|
+
return policySha256Hex(email.trim().toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
/** Header and claims, UNVERIFIED — for logs and for picking a key. Never for decisions. */
|
|
78
|
+
function decodePolicyToken(token) {
|
|
79
|
+
const parts = String(token ?? '').split('.');
|
|
80
|
+
if (parts.length !== 3)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
const dec = new TextDecoder();
|
|
84
|
+
const header = JSON.parse(dec.decode((0, policy_crypto_1.base64UrlDecode)(parts[0])));
|
|
85
|
+
const claims = JSON.parse(dec.decode((0, policy_crypto_1.base64UrlDecode)(parts[1])));
|
|
86
|
+
if (!header || typeof header !== 'object' || !claims || typeof claims !== 'object')
|
|
87
|
+
return null;
|
|
88
|
+
return { header, claims };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const jwksCache = new Map();
|
|
95
|
+
function ringFromJwks(set) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const k of set?.keys ?? []) {
|
|
98
|
+
if (k.kty !== 'oct' || typeof k.k !== 'string')
|
|
99
|
+
continue;
|
|
100
|
+
if (k.alg && k.alg !== 'HS256')
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
out.push({ kid: k.kid ?? '', bytes: (0, policy_crypto_1.base64UrlDecode)(k.k) });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* a malformed entry is skipped, not fatal: the others may still verify */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
async function loadJwks(opts, force) {
|
|
112
|
+
const url = opts.jwksUrl;
|
|
113
|
+
const ttl = (opts.jwksCacheSeconds ?? 300) * 1000;
|
|
114
|
+
const hit = jwksCache.get(url);
|
|
115
|
+
if (hit && !force && Date.now() - hit.at < ttl)
|
|
116
|
+
return hit.keys;
|
|
117
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
118
|
+
const res = await f(url, { headers: { accept: 'application/json', ...(opts.jwksHeaders ?? {}) } });
|
|
119
|
+
if (!res.ok)
|
|
120
|
+
throw new Error(`Could not load the policy gate keys from ${url}: HTTP ${res.status}`);
|
|
121
|
+
const keys = ringFromJwks((await res.json()));
|
|
122
|
+
jwksCache.set(url, { at: Date.now(), keys });
|
|
123
|
+
return keys;
|
|
124
|
+
}
|
|
125
|
+
function staticRing(opts) {
|
|
126
|
+
const ring = [];
|
|
127
|
+
const list = typeof opts.keys === 'string' ? parsePolicyGateKeys(opts.keys) : (opts.keys ?? []);
|
|
128
|
+
for (const k of list)
|
|
129
|
+
if (k.secret)
|
|
130
|
+
ring.push({ kid: k.kid ?? '', bytes: enc.encode(k.secret) });
|
|
131
|
+
if (opts.secret)
|
|
132
|
+
ring.push({ kid: '', bytes: enc.encode(opts.secret) });
|
|
133
|
+
ring.push(...ringFromJwks(opts.jwks));
|
|
134
|
+
return ring;
|
|
135
|
+
}
|
|
136
|
+
/** The key(s) to try: the one named by `kid`, else every id-less key. */
|
|
137
|
+
function candidates(ring, kid) {
|
|
138
|
+
const named = ring.filter((k) => k.kid && k.kid === kid);
|
|
139
|
+
return named.length ? named : ring.filter((k) => !k.kid);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Verify an acceptance token (or receipt) OFFLINE, against your copy of the
|
|
143
|
+
* space's keyring. Returns a result rather than throwing, so the reason is in
|
|
144
|
+
* hand for a log line:
|
|
145
|
+
*
|
|
146
|
+
* ```ts
|
|
147
|
+
* const r = await verifyAcceptanceToken(token, {
|
|
148
|
+
* keys: process.env.FORUMULATE_POLICY_GATE_KEYS,
|
|
149
|
+
* subject: user.id,
|
|
150
|
+
* spaceId: process.env.FORUMULATE_LEGAL_SPACE,
|
|
151
|
+
* requireSatisfied: true,
|
|
152
|
+
* });
|
|
153
|
+
* if (!r.valid) return res.status(403).send(r.reason);
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* Online verification (no key on your server) is `client.policies.verify`.
|
|
157
|
+
*/
|
|
158
|
+
async function verifyAcceptanceToken(token, opts) {
|
|
159
|
+
const signed = await verifySignature(token, opts);
|
|
160
|
+
if (!signed.ok)
|
|
161
|
+
return { valid: false, reason: signed.reason };
|
|
162
|
+
const c = signed.claims;
|
|
163
|
+
const uses = opts.use === undefined ? ['acceptance'] : typeof opts.use === 'string' ? [opts.use] : opts.use;
|
|
164
|
+
// A ticket is signed with the same key; checking `use` is what keeps one
|
|
165
|
+
// from passing as proof of acceptance.
|
|
166
|
+
const failed = checkClaims(c, uses, opts);
|
|
167
|
+
if (failed)
|
|
168
|
+
return { valid: false, reason: failed };
|
|
169
|
+
if (c.iss !== 'forumulate')
|
|
170
|
+
return { valid: false, reason: 'wrong_issuer' };
|
|
171
|
+
const pending = Array.isArray(c.pend) ? c.pend : [];
|
|
172
|
+
const satisfied = c.ok === true;
|
|
173
|
+
if (opts.requireSatisfied && !satisfied)
|
|
174
|
+
return { valid: false, reason: 'not_satisfied' };
|
|
175
|
+
return { valid: true, claims: c, kid: signed.kid, satisfied, pending };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Verify a TICKET (yours, signed with the same keyring), for a server that
|
|
179
|
+
* receives one back: a bridge between an auth platform's redirect and the
|
|
180
|
+
* hosted page, say. A ticket meant to live longer than an hour is refused.
|
|
181
|
+
*/
|
|
182
|
+
async function verifyPolicyTicket(token, opts) {
|
|
183
|
+
const signed = await verifySignature(token, opts);
|
|
184
|
+
if (!signed.ok)
|
|
185
|
+
return { valid: false, reason: signed.reason };
|
|
186
|
+
const c = signed.claims;
|
|
187
|
+
const failed = checkClaims(c, ['ticket'], opts);
|
|
188
|
+
if (failed)
|
|
189
|
+
return { valid: false, reason: failed };
|
|
190
|
+
if (typeof c.iat !== 'number' || c.exp - c.iat > exports.POLICY_TICKET_MAX_SECONDS)
|
|
191
|
+
return { valid: false, reason: 'expired' };
|
|
192
|
+
return { valid: true, claims: c, kid: signed.kid };
|
|
193
|
+
}
|
|
194
|
+
/** The checks every kind shares: audience, use, time, and the ids you pinned. */
|
|
195
|
+
function checkClaims(c, uses, opts) {
|
|
196
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
197
|
+
const leeway = opts.leewaySeconds ?? 30;
|
|
198
|
+
if (c.aud !== exports.POLICY_TOKEN_AUDIENCE)
|
|
199
|
+
return 'wrong_audience';
|
|
200
|
+
if (typeof c.use !== 'string' || !uses.includes(c.use))
|
|
201
|
+
return 'wrong_use';
|
|
202
|
+
if (typeof c.exp !== 'number' || c.exp + leeway < now)
|
|
203
|
+
return 'expired';
|
|
204
|
+
if (typeof c.iat === 'number' && c.iat - leeway > now)
|
|
205
|
+
return 'not_yet_valid';
|
|
206
|
+
if (opts.subject !== undefined && c.sub !== opts.subject)
|
|
207
|
+
return 'wrong_subject';
|
|
208
|
+
if (opts.spaceId !== undefined && c.spc !== opts.spaceId)
|
|
209
|
+
return 'wrong_space';
|
|
210
|
+
if (opts.orgId !== undefined && c.org !== opts.orgId)
|
|
211
|
+
return 'wrong_org';
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/** The signature half: HS256 only, the key picked by `kid`, compared in constant time. */
|
|
215
|
+
async function verifySignature(token, opts) {
|
|
216
|
+
const parts = String(token ?? '').split('.');
|
|
217
|
+
const decoded = parts.length === 3 ? decodePolicyToken(token) : null;
|
|
218
|
+
if (!decoded)
|
|
219
|
+
return { ok: false, reason: 'malformed' };
|
|
220
|
+
// `alg` is read from the token only to REFUSE anything but HS256, never to
|
|
221
|
+
// choose an algorithm: that is the classic `alg: none` hole.
|
|
222
|
+
if (decoded.header.alg !== 'HS256')
|
|
223
|
+
return { ok: false, reason: 'unsupported_algorithm' };
|
|
224
|
+
let sig;
|
|
225
|
+
try {
|
|
226
|
+
sig = (0, policy_crypto_1.base64UrlDecode)(parts[2]);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return { ok: false, reason: 'malformed' };
|
|
230
|
+
}
|
|
231
|
+
let ring = staticRing(opts);
|
|
232
|
+
if (opts.jwksUrl)
|
|
233
|
+
ring = ring.concat(await loadJwks(opts, false));
|
|
234
|
+
if (!ring.length)
|
|
235
|
+
return { ok: false, reason: 'no_keys' };
|
|
236
|
+
let keys = candidates(ring, decoded.header.kid);
|
|
237
|
+
if (!keys.length && opts.jwksUrl) {
|
|
238
|
+
// A kid we have not seen: the keyring was rotated since the cache filled.
|
|
239
|
+
ring = staticRing(opts).concat(await loadJwks(opts, true));
|
|
240
|
+
keys = candidates(ring, decoded.header.kid);
|
|
241
|
+
}
|
|
242
|
+
if (!keys.length)
|
|
243
|
+
return { ok: false, reason: 'unknown_key' };
|
|
244
|
+
for (const key of keys) {
|
|
245
|
+
if ((0, policy_crypto_1.equalBytes)(await hmacBytes(key.bytes, `${parts[0]}.${parts[1]}`), sig)) {
|
|
246
|
+
return { ok: true, claims: decoded.claims, kid: key.kid };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { ok: false, reason: 'bad_signature' };
|
|
250
|
+
}
|
|
251
|
+
//# sourceMappingURL=policy-tokens.js.map
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/dist/schema.d.ts
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 36704471 bytes
|
|
4
|
+
sha256: 7f11d91a26719f96df4319c5f35eeef6cb0acdc29f4895f52f36c18d0b4903f7
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forumulate/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Typed TypeScript client for the Forumulate REST API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://forumulate.com/reference",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"dist",
|
|
26
26
|
"!dist/**/*.map",
|
|
27
27
|
"README.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
28
29
|
"LICENSE"
|
|
29
30
|
],
|
|
30
31
|
"publishConfig": {
|