@aircloudhq/web 1.0.2 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aircloudhq/web",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "The application package of an Air-Cloud product: the Rails Action View helpers, i18n over the product's locale bundle, resource pages over its models, and React bindings, for its site and its JavaScript functions.",
5
5
  "keywords": [
6
6
  "aircloud",
@@ -29,6 +29,8 @@
29
29
  },
30
30
  "exports": {
31
31
  ".": "./src/index.js",
32
+ "./principal": "./src/principal.js",
33
+ "./assertion": "./src/assertion.js",
32
34
  "./helpers/number": "./src/helpers/number.js",
33
35
  "./helpers/date": "./src/helpers/date.js",
34
36
  "./helpers/text": "./src/helpers/text.js",
@@ -0,0 +1,114 @@
1
+ // @aircloudhq/web/assertion — a CONTAINER's verification of the platform principal assertion (#632
2
+ // ADR-241 decision 4). The web-origin holds the end-user session; an authenticated request reaches the
3
+ // Builder's container with `x-air-principal`, an ES256 JWS the platform signed under its dedicated
4
+ // `web-origin-principal` key, and never the session cookie or a token. The container verifies it here
5
+ // with the same semantics as the functions-host route trigger (functions/src/caps_principal.rs):
6
+ //
7
+ // import { verifyPrincipal } from '@aircloudhq/web/assertion';
8
+ // const me = await verifyPrincipal(request); // throws on anything but a valid assertion
9
+ //
10
+ // Checked: alg ES256 under a key of the principal key set (`jwksFile` — default AIR_PRINCIPAL_JWKS_FILE,
11
+ // the file the platform projects into every container from the key set its control plane reconciles into
12
+ // the tenant namespace; a container never fetches it, so it needs no path to a platform API), the issuer,
13
+ // `aud` = the product host the request names, `tenant_id` = this product's tenant (default
14
+ // AIR_TENANT_ID), a lifetime of at most 60 s that has not ended, and a well-formed principal. A rotation
15
+ // rewrites the file: an assertion naming a kid the cached set lacks re-reads it once. Fail-closed: a
16
+ // missing setting, an absent or malformed key set or any mismatch throws.
17
+
18
+ import { readFile } from 'node:fs/promises';
19
+
20
+ export const PRINCIPAL_HEADER = 'x-air-principal';
21
+ export const PRINCIPAL_ISSUER = 'aircloud-web-origin-principal';
22
+ /** == functions/src/caps_principal.rs MAX_ASSERTION_LIFETIME_SECS / ASSERTION_CLOCK_SKEW_SECS. */
23
+ const MAX_LIFETIME_SECS = 60;
24
+ const CLOCK_SKEW_SECS = 5;
25
+ const JWKS_MAX_AGE_MS = 300_000;
26
+
27
+ const cache = new Map(); // jwksFile → { keys, at }
28
+
29
+ const env = (k) => (typeof process !== 'undefined' && process.env ? process.env[k] : undefined);
30
+
31
+ function b64uDecode(s) {
32
+ return Uint8Array.from(Buffer.from(s, 'base64url'));
33
+ }
34
+
35
+ function parse(token) {
36
+ const parts = typeof token === 'string' ? token.split('.') : [];
37
+ if (parts.length !== 3) throw new Error('the principal assertion is not a compact JWS');
38
+ const [h, p, s] = parts;
39
+ const json = (seg) => JSON.parse(new TextDecoder().decode(b64uDecode(seg)));
40
+ return { header: json(h), claims: json(p), input: new TextEncoder().encode(`${h}.${p}`), sig: b64uDecode(s) };
41
+ }
42
+
43
+ async function readKeySet(file, now) {
44
+ const doc = JSON.parse(await readFile(file, 'utf8'));
45
+ if (!doc || !Array.isArray(doc.keys)) throw new Error(`the principal key set ${file} is not a JWK set`);
46
+ cache.set(file, { keys: doc.keys, at: now });
47
+ return doc.keys;
48
+ }
49
+
50
+ /** The key `kid` of the set at `file`: cached, re-read when stale or when the cached set lacks the kid. */
51
+ async function keyFor(file, kid, now) {
52
+ const hit = cache.get(file);
53
+ const fresh = hit && now - hit.at < JWKS_MAX_AGE_MS;
54
+ let found = fresh ? hit.keys.find((k) => k.kid === kid) : undefined;
55
+ if (!found) found = (await readKeySet(file, now)).find((k) => k.kid === kid);
56
+ if (!found) throw new Error(`no principal key '${kid}' in ${file}`);
57
+ return found;
58
+ }
59
+
60
+ const text = (v, what) => {
61
+ if (v === undefined || v === null) return null;
62
+ if (typeof v !== 'string') throw new Error(`the assertion's ${what} is not text`);
63
+ return v;
64
+ };
65
+
66
+ /**
67
+ * Verify the platform principal assertion of `request` (a Fetch API Request, or headers) and return the
68
+ * end user it asserts.
69
+ * @param {Request|{headers: Headers}} request
70
+ * @param {{jwksFile?: string, tenantId?: string, issuer?: string, host?: string, now?: () => number}} [options]
71
+ * @returns {Promise<{sub: string, tenantId: string, orgPath: string, roles: string[], act: object|null, flash: string|null, csrf: string|null}>}
72
+ */
73
+ export async function verifyPrincipal(request, options = {}) {
74
+ const jwksFile = options.jwksFile ?? env('AIR_PRINCIPAL_JWKS_FILE');
75
+ const tenantId = options.tenantId ?? env('AIR_TENANT_ID');
76
+ const issuer = options.issuer ?? PRINCIPAL_ISSUER;
77
+ const now = (options.now ?? (() => Math.floor(Date.now() / 1000)))();
78
+ if (!jwksFile) throw new Error('verifyPrincipal: no key-set file (AIR_PRINCIPAL_JWKS_FILE) — refusing (fail-closed)');
79
+ if (!tenantId) throw new Error('verifyPrincipal: no tenant (AIR_TENANT_ID) — refusing (fail-closed)');
80
+ const headers = request.headers;
81
+ const host = (options.host ?? headers.get('x-forwarded-host') ?? headers.get('host') ?? '').toLowerCase();
82
+ const token = headers.get(PRINCIPAL_HEADER);
83
+ if (!token) throw new Error('the request carries no principal assertion');
84
+
85
+ const { header, claims: c, input, sig } = parse(token);
86
+ if (header.alg !== 'ES256' || typeof header.kid !== 'string') throw new Error('the assertion is not ES256 with a kid');
87
+ const jwk = await keyFor(jwksFile, header.kid, now * 1000);
88
+ const { kid, alg, use, key_ops: _ops, ...material } = jwk;
89
+ const pub = await crypto.subtle.importKey('jwk', material, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
90
+ const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, pub, sig, input);
91
+ if (!ok) throw new Error('the assertion does not verify');
92
+
93
+ if (c.iss !== issuer) throw new Error('the assertion names another issuer');
94
+ if (!host || c.aud !== host) throw new Error('the assertion is for another product host');
95
+ if (!Number.isInteger(c.iat) || !Number.isInteger(c.exp) || c.exp <= c.iat || c.exp - c.iat > MAX_LIFETIME_SECS) {
96
+ throw new Error('the assertion lifetime is out of bounds');
97
+ }
98
+ if (now >= c.exp) throw new Error('the assertion has expired');
99
+ if (Math.max(c.iat, c.nbf ?? 0) > now + CLOCK_SKEW_SECS) throw new Error('the assertion is not valid yet');
100
+ if (c.tenant_id !== tenantId) throw new Error('the assertion is for another tenant');
101
+ if (typeof c.org_path !== 'string' || !c.org_path.startsWith('/')) throw new Error('the assertion carries no org_path');
102
+ if (typeof c.sub !== 'string' || c.sub === '') throw new Error('the assertion carries no sub');
103
+ const roles = c.roles ?? [];
104
+ if (!Array.isArray(roles) || roles.some((r) => typeof r !== 'string')) throw new Error("the assertion's roles are not strings");
105
+ return {
106
+ sub: c.sub,
107
+ tenantId: c.tenant_id,
108
+ orgPath: c.org_path,
109
+ roles,
110
+ act: c.act ?? null,
111
+ flash: text(c.flash, 'flash'),
112
+ csrf: text(c.csrf, 'csrf'),
113
+ };
114
+ }
@@ -0,0 +1,58 @@
1
+ // @aircloudhq/web/principal — the end user of a `session` route, its flash and its CSRF proof, for a
2
+ // JavaScript function (#632 ADR-241 decision 4, plan W15.4/W15.7; the Rails `current_user`-shaped
3
+ // principal, `flash` and `csrf_meta_tags`, ADR-221 D4).
4
+ //
5
+ // The web-origin holds the end-user session; the function never sees the session cookie or a token.
6
+ // The functions host hands the guest what the platform asserted, through the
7
+ // `aircloud:functions/principal` interface, and this module reads it:
8
+ //
9
+ // import { principal, flash, setFlash, csrfMeta } from '@aircloudhq/web/principal';
10
+ // const me = principal(); // null on a public route
11
+ // const { notice } = flash(); // the message the previous request set (read once)
12
+ // setFlash({ notice: 'Saved' }); // the next request's message
13
+ // `<head>${csrfMeta()}</head>` // the token a form or fetch sends as X-CSRF-Token
14
+ //
15
+ // The CSRF check is the session edge's (the web-origin): an unsafe request is accepted when its
16
+ // `Origin` is the product's, and a client that sends no `Origin` proves it with the session's token.
17
+ import { csrfToken as wireCsrf, current, flash as wireFlash, setFlash as wireSetFlash } from 'aircloud:functions/principal@1.0.0';
18
+
19
+ const escapeAttr = (s) =>
20
+ String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
21
+
22
+ /**
23
+ * The end user this invocation serves, or null outside a `session` route.
24
+ * @returns {{sub: string, tenantId: string, orgPath: string, roles: string[], act: object|null}|null}
25
+ */
26
+ export function principal() {
27
+ const p = current();
28
+ if (!p) return null;
29
+ return { sub: p.sub, tenantId: p.tenantId, orgPath: p.orgPath, roles: [...p.roles], act: p.act ? JSON.parse(p.act) : null };
30
+ }
31
+
32
+ /** The flash the previous request of this session set (`{}` when none) — the platform clears it. */
33
+ export function flash() {
34
+ const f = wireFlash();
35
+ if (!f) return {};
36
+ try {
37
+ return JSON.parse(f);
38
+ } catch {
39
+ return { notice: f }; // a flash a container set as plain text
40
+ }
41
+ }
42
+
43
+ /** Set the flash the next request of this session reads (an object, stored as JSON). */
44
+ export function setFlash(message) {
45
+ wireSetFlash(JSON.stringify(message));
46
+ }
47
+
48
+ /** The session's CSRF token, or null outside a `session` route. */
49
+ export function csrfToken() {
50
+ return wireCsrf() ?? null;
51
+ }
52
+
53
+ /** The Rails `csrf_meta_tags` pair carrying the session's token; empty outside a session. */
54
+ export function csrfMeta() {
55
+ const t = csrfToken();
56
+ if (!t) return '';
57
+ return `<meta name="csrf-param" content="authenticity_token" />\n<meta name="csrf-token" content="${escapeAttr(t)}" />`;
58
+ }