@blamejs/core 0.10.15 → 0.11.1

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/lib/dbsc.js ADDED
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.dbsc
4
+ * @nav Identity
5
+ * @title Device Bound Session Credentials
6
+ * @order 378
7
+ *
8
+ * @intro
9
+ * IETF draft-ietf-oauth-attestation-based-client-auth + Chrome's
10
+ * DBSC proposal — binds an HTTP session to a browser-generated key
11
+ * pair so a stolen session cookie alone can't impersonate the user
12
+ * from a different device. The browser holds the private key in
13
+ * secure hardware; every refresh proves possession via a signed
14
+ * challenge.
15
+ *
16
+ * Server flow:
17
+ * 1. `b.dbsc.challenge()` — mint a random challenge, sign it
18
+ * with the operator's HMAC key for replay defense, return
19
+ * the challenge string + the `Sec-Session-Challenge` header
20
+ * value. The browser auto-resolves the challenge via the
21
+ * DBSC refresh endpoint.
22
+ * 2. `b.dbsc.verifyBindingAssertion(jwt, { challenge, expectedAud })`
23
+ * — verify the browser-supplied JWT signed by the binding
24
+ * public key. Returns `{ valid, sub, jkt }` where `jkt` is
25
+ * the JWK thumbprint of the binding key.
26
+ *
27
+ * Composes existing b.crypto + b.auth.jwt; DBSC mandates ES256 /
28
+ * RS256 (browser TPM hardware). The framework refuses HS256 /
29
+ * none on parsed JWTs.
30
+ *
31
+ * @card
32
+ * IETF DBSC challenge minter + binding-assertion verifier. Stops device-portable session theft by binding cookies to hardware keys.
33
+ */
34
+
35
+ var nodeCrypto = require("node:crypto");
36
+ var validateOpts = require("./validate-opts");
37
+ var safeJson = require("./safe-json");
38
+ var bCrypto = require("./crypto");
39
+ var canonicalJson = require("./canonical-json");
40
+ var jwtExternal = require("./auth/jwt-external");
41
+ var C = require("./constants");
42
+ var { defineClass } = require("./framework-error");
43
+
44
+ var DbscError = defineClass("DbscError", { alwaysPermanent: true });
45
+
46
+ var DEFAULT_CHALLENGE_TTL_MS = C.TIME.minutes(5);
47
+
48
+ /**
49
+ * @primitive b.dbsc.challenge
50
+ * @signature b.dbsc.challenge(opts)
51
+ * @since 0.10.16
52
+ * @status stable
53
+ *
54
+ * Mint a fresh DBSC challenge. Returns `{ challenge, expiresAt,
55
+ * headerValue }` where `headerValue` is the `Sec-Session-Challenge`
56
+ * value to set on the response. The challenge is HMAC-SHA3-512
57
+ * signed so the server can verify the same challenge it issued
58
+ * on the assertion-verify path without persisting it.
59
+ *
60
+ * @opts
61
+ * secretKey: Buffer, // operator HMAC secret (>=32 bytes)
62
+ * ttlMs: number, // default 5 minutes
63
+ * nonce: string, // optional caller-supplied nonce (default: 32-byte random)
64
+ *
65
+ * @example
66
+ * var c = b.dbsc.challenge({ secretKey: opSecret });
67
+ * res.setHeader("Sec-Session-Challenge", c.headerValue);
68
+ */
69
+ function challenge(opts) {
70
+ opts = validateOpts.requireObject(opts, "dbsc.challenge", DbscError, "dbsc/bad-opts");
71
+ validateOpts(opts, ["secretKey", "ttlMs", "nonce"], "dbsc.challenge");
72
+ if (!Buffer.isBuffer(opts.secretKey) || opts.secretKey.length < 32) { // allow:raw-byte-literal — 32-byte HMAC secret floor
73
+ throw new DbscError("dbsc/bad-secret",
74
+ "challenge: opts.secretKey must be a Buffer (>= 32 bytes)");
75
+ }
76
+ validateOpts.optionalPositiveFinite(opts.ttlMs, "dbsc.challenge: ttlMs",
77
+ DbscError, "dbsc/bad-ttl");
78
+ var ttlMs = opts.ttlMs || DEFAULT_CHALLENGE_TTL_MS;
79
+ var nonceBuf = opts.nonce ? Buffer.from(String(opts.nonce), "utf8") : bCrypto.generateBytes(32); // allow:raw-byte-literal — 32-byte nonce
80
+ var expiresAt = Date.now() + ttlMs;
81
+ var msg = nonceBuf.toString("base64") + "." + expiresAt;
82
+ var mac = nodeCrypto.createHmac("sha3-512", opts.secretKey).update(msg).digest("base64");
83
+ var challengeStr = msg + "." + mac;
84
+ return {
85
+ challenge: challengeStr,
86
+ expiresAt: expiresAt,
87
+ headerValue: challengeStr,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * @primitive b.dbsc.verifyChallenge
93
+ * @signature b.dbsc.verifyChallenge(challengeStr, { secretKey })
94
+ * @since 0.10.16
95
+ * @status stable
96
+ *
97
+ * Verify a challenge string previously issued by `challenge()`.
98
+ * Returns truthy when the HMAC matches and the challenge hasn't
99
+ * expired. Refuses with typed errors on shape / expiry / MAC
100
+ * mismatch.
101
+ *
102
+ * @example
103
+ * var ok = b.dbsc.verifyChallenge(req.headers["sec-session-challenge"],
104
+ * { secretKey: process.env.DBSC_HMAC_KEY });
105
+ */
106
+ function verifyChallenge(challengeStr, opts) {
107
+ opts = validateOpts.requireObject(opts, "dbsc.verifyChallenge",
108
+ DbscError, "dbsc/bad-opts");
109
+ if (typeof challengeStr !== "string") {
110
+ throw new DbscError("dbsc/bad-challenge",
111
+ "verifyChallenge: challenge must be a string");
112
+ }
113
+ if (!Buffer.isBuffer(opts.secretKey) || opts.secretKey.length < 32) { // allow:raw-byte-literal — 32-byte HMAC secret floor
114
+ throw new DbscError("dbsc/bad-secret",
115
+ "verifyChallenge: opts.secretKey must be a Buffer (>= 32 bytes)");
116
+ }
117
+ var parts = challengeStr.split(".");
118
+ if (parts.length !== 3) {
119
+ throw new DbscError("dbsc/bad-challenge-shape",
120
+ "verifyChallenge: challenge must have 3 dot-separated parts");
121
+ }
122
+ var expiresAt = parseInt(parts[1], 10);
123
+ if (!isFinite(expiresAt) || expiresAt <= 0) {
124
+ throw new DbscError("dbsc/bad-expires",
125
+ "verifyChallenge: expiresAt is not a positive integer");
126
+ }
127
+ if (Date.now() > expiresAt) {
128
+ throw new DbscError("dbsc/expired",
129
+ "verifyChallenge: challenge expired");
130
+ }
131
+ var msg = parts[0] + "." + parts[1];
132
+ var expected = nodeCrypto.createHmac("sha3-512", opts.secretKey).update(msg).digest("base64");
133
+ if (!bCrypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(parts[2], "utf8"))) {
134
+ throw new DbscError("dbsc/bad-mac",
135
+ "verifyChallenge: HMAC mismatch (forged or wrong secret)");
136
+ }
137
+ return { valid: true, expiresAt: expiresAt };
138
+ }
139
+
140
+ /**
141
+ * @primitive b.dbsc.verifyBindingAssertion
142
+ * @signature b.dbsc.verifyBindingAssertion(assertion, opts)
143
+ * @since 0.10.16
144
+ * @status stable
145
+ *
146
+ * Verify a DBSC binding-assertion JWT. The browser signs a JWT with
147
+ * the device-bound private key whose header includes the JWK
148
+ * thumbprint of the binding key. Returns `{ valid, jkt, claims }`.
149
+ * Refuses HS256 / none (algorithm-confusion class) and any
150
+ * mismatched audience / challenge.
151
+ *
152
+ * @opts
153
+ * secretKey: Buffer, // HMAC secret used by challenge() (for re-verify)
154
+ * expectedAud: string, // expected RP origin
155
+ * maxAgeSec: number, // default 300s
156
+ *
157
+ * @example
158
+ * var v = b.dbsc.verifyBindingAssertion(req.body, {
159
+ * secretKey: opSecret,
160
+ * expectedAud: "https://rp.example",
161
+ * });
162
+ * if (!v.valid) throw 401;
163
+ * v.jkt; // → JWK thumbprint of the binding key (use as a session pin)
164
+ */
165
+ function verifyBindingAssertion(assertion, opts) {
166
+ opts = validateOpts.requireObject(opts, "dbsc.verifyBindingAssertion",
167
+ DbscError, "dbsc/bad-opts");
168
+ validateOpts(opts, ["secretKey", "expectedAud", "maxAgeSec"],
169
+ "dbsc.verifyBindingAssertion");
170
+ if (typeof assertion !== "string") {
171
+ throw new DbscError("dbsc/bad-assertion",
172
+ "verifyBindingAssertion: assertion must be a string JWT");
173
+ }
174
+ validateOpts.requireNonEmptyString(opts.expectedAud, "expectedAud",
175
+ DbscError, "dbsc/missing-aud");
176
+ var parts = assertion.split(".");
177
+ if (parts.length !== 3) {
178
+ throw new DbscError("dbsc/bad-jwt-shape",
179
+ "verifyBindingAssertion: JWT must have 3 parts");
180
+ }
181
+ var headerJson, payloadJson;
182
+ try { headerJson = safeJson.parse(Buffer.from(parts[0], "base64url").toString("utf8")); }
183
+ catch (_e) { throw new DbscError("dbsc/bad-jwt-header", "JWT header is not parseable JSON"); }
184
+ try { payloadJson = safeJson.parse(Buffer.from(parts[1], "base64url").toString("utf8")); }
185
+ catch (_e) { throw new DbscError("dbsc/bad-jwt-payload", "JWT payload is not parseable JSON"); }
186
+ // Algorithm-confusion defense — DBSC mandates ES256 / RS256 from
187
+ // hardware-backed keys; refuse symmetric or none algs.
188
+ if (headerJson.alg !== "ES256" && headerJson.alg !== "RS256") {
189
+ throw new DbscError("dbsc/bad-alg",
190
+ "verifyBindingAssertion: alg " + headerJson.alg + " refused (DBSC mandates ES256 / RS256)");
191
+ }
192
+ if (!headerJson.jwk || typeof headerJson.jwk !== "object") {
193
+ throw new DbscError("dbsc/no-jwk",
194
+ "verifyBindingAssertion: JWT header missing jwk (binding-key proof)");
195
+ }
196
+ // Verify the JWT signature against the embedded jwk (proof-of-
197
+ // possession of the binding-key). Refuse alg/kty mismatches at the
198
+ // import boundary (alg-confusion defense — JWT_KEY_CONFUSION-class
199
+ // attacks pass an HS256 jwk for an ES256-claimed token).
200
+ jwtExternal._assertAlgKtyMatch(headerJson.alg, headerJson.jwk);
201
+ var pubKey;
202
+ try { pubKey = nodeCrypto.createPublicKey({ key: headerJson.jwk, format: "jwk" }); }
203
+ catch (e) {
204
+ throw new DbscError("dbsc/bad-jwk",
205
+ "verifyBindingAssertion: jwk could not be imported: " + ((e && e.message) || String(e)));
206
+ }
207
+ var signingInput = parts[0] + "." + parts[1];
208
+ var sigBytes = Buffer.from(parts[2], "base64url");
209
+ var ok;
210
+ if (headerJson.alg === "ES256") {
211
+ // JWT raw r||s → DER for nodeCrypto.verify.
212
+ if (sigBytes.length !== 64) { // allow:raw-byte-literal — P-256 r||s shape
213
+ throw new DbscError("dbsc/bad-sig", "ES256 signature must be 64 bytes raw");
214
+ }
215
+ var derSig = _ecdsaRawToDer(sigBytes);
216
+ ok = nodeCrypto.verify("sha256", Buffer.from(signingInput, "utf8"), pubKey, derSig);
217
+ } else {
218
+ ok = nodeCrypto.verify("sha256", Buffer.from(signingInput, "utf8"), pubKey, sigBytes);
219
+ }
220
+ if (!ok) {
221
+ throw new DbscError("dbsc/bad-signature",
222
+ "verifyBindingAssertion: JWT signature does not verify against embedded jwk");
223
+ }
224
+ // Validate audience + freshness.
225
+ if (payloadJson.aud !== opts.expectedAud) {
226
+ throw new DbscError("dbsc/bad-aud",
227
+ "verifyBindingAssertion: aud '" + payloadJson.aud + "' != expected '" + opts.expectedAud + "'");
228
+ }
229
+ // Freshness — every assertion MUST carry either an `iat` (so the
230
+ // age check below can reject stale tokens) or a `challenge` (so the
231
+ // re-verify below pins the assertion to a server-issued nonce with
232
+ // its own expiry). An assertion lacking both replays indefinitely
233
+ // until the signing key rotates — refuse at the verifier boundary.
234
+ if (typeof payloadJson.iat !== "number" && !payloadJson.challenge) {
235
+ throw new DbscError("dbsc/no-freshness",
236
+ "verifyBindingAssertion: assertion must carry either 'iat' (age-checked) " +
237
+ "or 'challenge' (server-nonce-bound); without freshness material the " +
238
+ "assertion replays indefinitely");
239
+ }
240
+ var maxAge = (opts.maxAgeSec || 300) * 1000; // allow:raw-byte-literal allow:raw-time-literal — 5min default
241
+ if (typeof payloadJson.iat === "number" && Date.now() - payloadJson.iat * 1000 > maxAge) { // allow:raw-byte-literal allow:raw-time-literal — sec→ms
242
+ throw new DbscError("dbsc/stale",
243
+ "verifyBindingAssertion: iat is more than " + opts.maxAgeSec + "s old");
244
+ }
245
+ // Re-verify any embedded challenge if the assertion claims one.
246
+ if (payloadJson.challenge) {
247
+ verifyChallenge(payloadJson.challenge, { secretKey: opts.secretKey });
248
+ }
249
+ // Compute JWK thumbprint (RFC 7638) for operator-side session-pin.
250
+ var jkt = _jwkThumbprint(headerJson.jwk);
251
+ return { valid: true, jkt: jkt, claims: payloadJson };
252
+ }
253
+
254
+ function _ecdsaRawToDer(raw) {
255
+ if (raw.length !== 64) throw new DbscError("dbsc/bad-sig", "raw r||s must be 64 bytes"); // allow:raw-byte-literal — P-256 r||s shape
256
+ var r = _trimLeadingZeros(raw.slice(0, 32)); // allow:raw-byte-literal — 32-byte r
257
+ var s = _trimLeadingZeros(raw.slice(32)); // allow:raw-byte-literal — 32-byte s offset
258
+ function _intDer(buf) {
259
+ // Prepend 0x00 if high bit set (positive INTEGER per DER).
260
+ if (buf[0] & 0x80) buf = Buffer.concat([Buffer.from([0x00]), buf]); // allow:raw-byte-literal — DER sign-bit pad
261
+ return Buffer.concat([Buffer.from([0x02, buf.length]), buf]); // allow:raw-byte-literal — ASN.1 INTEGER tag
262
+ }
263
+ var rDer = _intDer(r);
264
+ var sDer = _intDer(s);
265
+ var seqBody = Buffer.concat([rDer, sDer]);
266
+ return Buffer.concat([Buffer.from([0x30, seqBody.length]), seqBody]); // allow:raw-byte-literal — ASN.1 SEQUENCE tag
267
+ }
268
+
269
+ function _trimLeadingZeros(buf) {
270
+ var i = 0;
271
+ while (i < buf.length - 1 && buf[i] === 0x00) i += 1; // allow:raw-byte-literal — leading zero byte
272
+ return buf.slice(i);
273
+ }
274
+
275
+ function _jwkThumbprint(jwk) {
276
+ // RFC 7638 canonical thumbprint: alphabetic key-name ordering + no
277
+ // whitespace + SHA-256 + base64url. For EC P-256 the required keys
278
+ // are { crv, kty, x, y }.
279
+ var members;
280
+ if (jwk.kty === "EC") {
281
+ members = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
282
+ } else if (jwk.kty === "RSA") {
283
+ members = { e: jwk.e, kty: jwk.kty, n: jwk.n };
284
+ } else {
285
+ throw new DbscError("dbsc/bad-jwk-kty",
286
+ "jwkThumbprint: unsupported kty " + jwk.kty);
287
+ }
288
+ var canonical = canonicalJson.stringify(members);
289
+ return bCrypto.toBase64Url(
290
+ nodeCrypto.createHash("sha256").update(Buffer.from(canonical, "utf8")).digest()
291
+ );
292
+ }
293
+
294
+ module.exports = {
295
+ challenge: challenge,
296
+ verifyChallenge: verifyChallenge,
297
+ verifyBindingAssertion: verifyBindingAssertion,
298
+ DbscError: DbscError,
299
+ };
package/lib/fedcm.js ADDED
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.fedcm
4
+ * @nav Identity
5
+ * @title FedCM Identity Provider
6
+ * @order 375
7
+ *
8
+ * @intro
9
+ * W3C FedCM (Federated Credential Management API, candidate
10
+ * recommendation 2024) identity-provider-side helpers. Operators
11
+ * running an IdP wire four endpoints per the spec; this module
12
+ * ships response-shape builders + the well-known config emitter.
13
+ *
14
+ * Endpoints (FedCM §5):
15
+ * - `/.well-known/web-identity` — discovery
16
+ * - `<config_url>` — IdP config doc (FedCM §6.3 IdentityProviderAPIConfig)
17
+ * - `accounts_endpoint` — returns the user's accounts at this IdP
18
+ * - `id_assertion_endpoint` — mints the id_token / verifiable
19
+ * credential bound to the relying-party origin
20
+ *
21
+ * The framework does NOT make the FedCM browser API call itself —
22
+ * that's user-agent surface. Operators wire response builders into
23
+ * their router and supply the per-account session state.
24
+ *
25
+ * @card
26
+ * FedCM IdP-side response builders (well-known + config + accounts + id_assertion) per W3C FedCM 2024 candidate recommendation.
27
+ */
28
+
29
+ var validateOpts = require("./validate-opts");
30
+ var safeUrl = require("./safe-url");
31
+ var { defineClass } = require("./framework-error");
32
+
33
+ var FedcmError = defineClass("FedcmError", { alwaysPermanent: true });
34
+
35
+ /**
36
+ * @primitive b.fedcm.wellKnown
37
+ * @signature b.fedcm.wellKnown({ provider_urls })
38
+ * @since 0.10.16
39
+ * @status stable
40
+ *
41
+ * Build the `/.well-known/web-identity` JSON body. `provider_urls`
42
+ * lists the operator's IdP config URLs (FedCM §5).
43
+ *
44
+ * @example
45
+ * res.setHeader("Content-Type", "application/json");
46
+ * res.end(JSON.stringify(b.fedcm.wellKnown({
47
+ * provider_urls: ["https://idp.example/fedcm/config.json"],
48
+ * })));
49
+ */
50
+ function wellKnown(opts) {
51
+ opts = validateOpts.requireObject(opts, "fedcm.wellKnown", FedcmError, "fedcm/bad-opts");
52
+ if (!Array.isArray(opts.provider_urls) || opts.provider_urls.length === 0) {
53
+ throw new FedcmError("fedcm/no-provider-urls",
54
+ "wellKnown: provider_urls must be a non-empty array");
55
+ }
56
+ for (var i = 0; i < opts.provider_urls.length; i += 1) {
57
+ var u = opts.provider_urls[i];
58
+ var parsed;
59
+ try { parsed = safeUrl.parse(u); }
60
+ catch (_e) {
61
+ throw new FedcmError("fedcm/bad-provider-url",
62
+ "wellKnown: provider_urls[" + i + "] is not a parseable URL");
63
+ }
64
+ if (parsed.protocol !== "https:") {
65
+ throw new FedcmError("fedcm/bad-provider-url",
66
+ "wellKnown: provider_urls[" + i + "] must be https");
67
+ }
68
+ }
69
+ return { provider_urls: opts.provider_urls.slice() };
70
+ }
71
+
72
+ /**
73
+ * @primitive b.fedcm.config
74
+ * @signature b.fedcm.config(opts)
75
+ * @since 0.10.16
76
+ * @status stable
77
+ *
78
+ * Build the IdentityProviderAPIConfig JSON body served at the
79
+ * operator's `config_url` per FedCM §6.3. Required fields:
80
+ * accounts_endpoint, client_metadata_endpoint, id_assertion_endpoint,
81
+ * login_url, branding (icon / name / colors).
82
+ *
83
+ * @example
84
+ * res.end(JSON.stringify(b.fedcm.config({
85
+ * accounts_endpoint: "/fedcm/accounts",
86
+ * client_metadata_endpoint: "/fedcm/client_metadata",
87
+ * id_assertion_endpoint: "/fedcm/id_assertion",
88
+ * login_url: "https://idp.example/login",
89
+ * branding: { background_color: "#000", color: "#fff", name: "Example IdP" },
90
+ * })));
91
+ */
92
+ function config(opts) {
93
+ opts = validateOpts.requireObject(opts, "fedcm.config", FedcmError, "fedcm/bad-opts");
94
+ validateOpts(opts, ["accounts_endpoint", "client_metadata_endpoint",
95
+ "id_assertion_endpoint", "login_url", "branding",
96
+ "disconnect_endpoint"], "fedcm.config");
97
+ var required = ["accounts_endpoint", "client_metadata_endpoint",
98
+ "id_assertion_endpoint", "login_url"];
99
+ for (var i = 0; i < required.length; i += 1) {
100
+ validateOpts.requireNonEmptyString(opts[required[i]], required[i],
101
+ FedcmError, "fedcm/missing-" + required[i]);
102
+ }
103
+ if (!opts.branding || typeof opts.branding !== "object") {
104
+ throw new FedcmError("fedcm/missing-branding", "config: opts.branding required");
105
+ }
106
+ var out = {
107
+ accounts_endpoint: opts.accounts_endpoint,
108
+ client_metadata_endpoint: opts.client_metadata_endpoint,
109
+ id_assertion_endpoint: opts.id_assertion_endpoint,
110
+ login_url: opts.login_url,
111
+ branding: {
112
+ name: opts.branding.name || "",
113
+ background_color: opts.branding.background_color || "#000000",
114
+ color: opts.branding.color || "#ffffff",
115
+ },
116
+ };
117
+ if (opts.branding.icons) out.branding.icons = opts.branding.icons.slice();
118
+ if (opts.disconnect_endpoint) out.disconnect_endpoint = opts.disconnect_endpoint;
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * @primitive b.fedcm.accountsResponse
124
+ * @signature b.fedcm.accountsResponse({ accounts })
125
+ * @since 0.10.16
126
+ * @status stable
127
+ *
128
+ * Build the JSON body for the accounts_endpoint response. Each
129
+ * account: { id, name, email, picture?, approved_clients? }
130
+ * Operator supplies the per-user account state.
131
+ *
132
+ * @example
133
+ * res.setHeader("Set-Cookie", "Sec-FedCM-CSRF=...");
134
+ * res.end(JSON.stringify(b.fedcm.accountsResponse({
135
+ * accounts: [{
136
+ * id: "1234", name: "Alice", email: "alice@example.com",
137
+ * approved_clients: ["rp.example"],
138
+ * }],
139
+ * })));
140
+ */
141
+ function accountsResponse(opts) {
142
+ opts = validateOpts.requireObject(opts, "fedcm.accountsResponse",
143
+ FedcmError, "fedcm/bad-opts");
144
+ if (!Array.isArray(opts.accounts)) {
145
+ throw new FedcmError("fedcm/no-accounts",
146
+ "accountsResponse: opts.accounts must be an array");
147
+ }
148
+ var sanitized = opts.accounts.map(function (a, i) {
149
+ if (!a || typeof a !== "object") {
150
+ throw new FedcmError("fedcm/bad-account",
151
+ "accountsResponse: accounts[" + i + "] must be an object");
152
+ }
153
+ if (typeof a.id !== "string" || a.id.length === 0) {
154
+ throw new FedcmError("fedcm/bad-account",
155
+ "accountsResponse: accounts[" + i + "].id required (string)");
156
+ }
157
+ var out = { id: a.id, name: a.name || "", email: a.email || "" };
158
+ if (a.given_name) out.given_name = a.given_name;
159
+ if (a.picture) out.picture = a.picture;
160
+ if (Array.isArray(a.approved_clients)) out.approved_clients = a.approved_clients.slice();
161
+ return out;
162
+ });
163
+ return { accounts: sanitized };
164
+ }
165
+
166
+ /**
167
+ * @primitive b.fedcm.idAssertionResponse
168
+ * @signature b.fedcm.idAssertionResponse({ token })
169
+ * @since 0.10.16
170
+ * @status stable
171
+ *
172
+ * Build the JSON body for the id_assertion_endpoint response. The
173
+ * operator mints the `token` (typically a signed JWT or verifiable
174
+ * credential) and the framework wraps it in the FedCM-spec shape.
175
+ *
176
+ * @example
177
+ * res.end(JSON.stringify(b.fedcm.idAssertionResponse({ token: jwt })));
178
+ */
179
+ function idAssertionResponse(opts) {
180
+ opts = validateOpts.requireObject(opts, "fedcm.idAssertionResponse",
181
+ FedcmError, "fedcm/bad-opts");
182
+ validateOpts.requireNonEmptyString(opts.token, "token",
183
+ FedcmError, "fedcm/missing-token");
184
+ return { token: opts.token };
185
+ }
186
+
187
+ /**
188
+ * @primitive b.fedcm.clientMetadataResponse
189
+ * @signature b.fedcm.clientMetadataResponse(opts)
190
+ * @since 0.10.16
191
+ * @status stable
192
+ *
193
+ * Build the JSON body for the FedCM client_metadata_endpoint
194
+ * response. Returns the relying-party policy URLs the browser
195
+ * surfaces during the IdP login prompt (privacy policy + terms of
196
+ * service). Both URLs are validated as https.
197
+ *
198
+ * @opts
199
+ * privacy_policy_url: string, // required https URL
200
+ * terms_of_service_url: string, // required https URL
201
+ *
202
+ * @example
203
+ * res.end(JSON.stringify(b.fedcm.clientMetadataResponse({
204
+ * privacy_policy_url: "https://rp.example/privacy",
205
+ * terms_of_service_url: "https://rp.example/tos",
206
+ * })));
207
+ */
208
+ function clientMetadataResponse(opts) {
209
+ opts = validateOpts.requireObject(opts, "fedcm.clientMetadataResponse",
210
+ FedcmError, "fedcm/bad-opts");
211
+ validateOpts(opts, ["privacy_policy_url", "terms_of_service_url"],
212
+ "fedcm.clientMetadataResponse");
213
+ validateOpts.requireNonEmptyString(opts.privacy_policy_url, "privacy_policy_url",
214
+ FedcmError, "fedcm/missing-privacy-url");
215
+ validateOpts.requireNonEmptyString(opts.terms_of_service_url, "terms_of_service_url",
216
+ FedcmError, "fedcm/missing-tos-url");
217
+ if (!/^https:/i.test(opts.privacy_policy_url)) {
218
+ throw new FedcmError("fedcm/bad-privacy-url",
219
+ "clientMetadataResponse: privacy_policy_url must be https");
220
+ }
221
+ if (!/^https:/i.test(opts.terms_of_service_url)) {
222
+ throw new FedcmError("fedcm/bad-tos-url",
223
+ "clientMetadataResponse: terms_of_service_url must be https");
224
+ }
225
+ return {
226
+ privacy_policy_url: opts.privacy_policy_url,
227
+ terms_of_service_url: opts.terms_of_service_url,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * @primitive b.fedcm.disconnectResponse
233
+ * @signature b.fedcm.disconnectResponse(opts)
234
+ * @since 0.10.16
235
+ * @status stable
236
+ *
237
+ * Build the JSON body for the FedCM disconnect_endpoint response.
238
+ * The browser calls this when the user revokes their FedCM grant;
239
+ * the IdP returns the disconnected account id so the browser can
240
+ * update its local state. `account_id` is REQUIRED per the spec.
241
+ *
242
+ * @opts
243
+ * account_id: string, // required — identifier of the account that was disconnected
244
+ *
245
+ * @example
246
+ * res.end(JSON.stringify(b.fedcm.disconnectResponse({ account_id: "1234" })));
247
+ */
248
+ function disconnectResponse(opts) {
249
+ opts = validateOpts.requireObject(opts, "fedcm.disconnectResponse",
250
+ FedcmError, "fedcm/bad-opts");
251
+ validateOpts.requireNonEmptyString(opts.account_id, "account_id",
252
+ FedcmError, "fedcm/missing-account-id");
253
+ return { account_id: opts.account_id };
254
+ }
255
+
256
+ module.exports = {
257
+ wellKnown: wellKnown,
258
+ config: config,
259
+ accountsResponse: accountsResponse,
260
+ clientMetadataResponse: clientMetadataResponse,
261
+ idAssertionResponse: idAssertionResponse,
262
+ disconnectResponse: disconnectResponse,
263
+ FedcmError: FedcmError,
264
+ };
package/lib/hal.js ADDED
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.hal
4
+ * @nav HTTP
5
+ * @title HAL hypermedia
6
+ * @order 173
7
+ *
8
+ * @intro
9
+ * HAL (Hypertext Application Language, IETF draft-kelly-json-hal-08).
10
+ * Wraps resources with `_links` + `_embedded` so clients discover
11
+ * navigation + nested resources without out-of-band routing. The
12
+ * spec is small; the framework's helper is correspondingly small.
13
+ *
14
+ * Content-Type: `application/hal+json`
15
+ *
16
+ * Reserved properties:
17
+ * - `_links` — { rel: linkObject | linkObject[] } per RFC 8288
18
+ * - `_embedded` — { rel: resource | resource[] }
19
+ * - `_templates` — HAL-FORMS extension (operator-supplied)
20
+ *
21
+ * @card
22
+ * HAL (draft-kelly-json-hal) + HAL-FORMS hypermedia response builder. Content-Type negotiation + _links/_embedded structure helpers per RFC 8288.
23
+ */
24
+
25
+ var validateOpts = require("./validate-opts");
26
+ var { defineClass } = require("./framework-error");
27
+
28
+ var HalError = defineClass("HalError", { alwaysPermanent: true });
29
+
30
+ var CONTENT_TYPE = "application/hal+json";
31
+
32
+ /**
33
+ * @primitive b.hal.resource
34
+ * @signature b.hal.resource(payload, opts?)
35
+ * @since 0.10.16
36
+ * @status stable
37
+ *
38
+ * Build a HAL resource. `payload` is the operator's domain object;
39
+ * `opts.links` is a map of `{ rel: href | linkObject | linkObject[] }`;
40
+ * `opts.embedded` is a map of `{ rel: resource | resource[] }`.
41
+ *
42
+ * @opts
43
+ * links: { [rel]: string | LinkObject | LinkObject[] },
44
+ * embedded: { [rel]: object | object[] },
45
+ * templates: { [name]: HalFormTemplate }, // HAL-FORMS extension
46
+ *
47
+ * @example
48
+ * var r = b.hal.resource(
49
+ * { title: "Hello", body: "World" },
50
+ * { links: { self: "/articles/1",
51
+ * next: { href: "/articles/2", title: "Next article" } } }
52
+ * );
53
+ */
54
+ function resource(payload, opts) {
55
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
56
+ throw new HalError("hal/bad-payload",
57
+ "resource: payload must be a non-array object");
58
+ }
59
+ opts = opts || {};
60
+ validateOpts(opts, ["links", "embedded", "templates"], "hal.resource");
61
+ // Build resource by shallow-clone (don't mutate operator input).
62
+ var out = {};
63
+ var keys = Object.keys(payload);
64
+ for (var i = 0; i < keys.length; i += 1) {
65
+ var k = keys[i];
66
+ if (k === "_links" || k === "_embedded" || k === "_templates") {
67
+ // Operator-supplied reserved keys in payload override opts —
68
+ // refuse to avoid ambiguity.
69
+ throw new HalError("hal/reserved-key",
70
+ "resource: payload must not contain reserved key '" + k + "' (use opts." +
71
+ (k === "_templates" ? "templates" : k.slice(1)) + ")");
72
+ }
73
+ out[k] = payload[k];
74
+ }
75
+ if (opts.links) {
76
+ if (typeof opts.links !== "object" || Array.isArray(opts.links)) {
77
+ throw new HalError("hal/bad-links", "resource: opts.links must be a non-array object");
78
+ }
79
+ out._links = _normaliseLinks(opts.links);
80
+ }
81
+ if (opts.embedded) {
82
+ if (typeof opts.embedded !== "object" || Array.isArray(opts.embedded)) {
83
+ throw new HalError("hal/bad-embedded", "resource: opts.embedded must be a non-array object");
84
+ }
85
+ out._embedded = opts.embedded;
86
+ }
87
+ if (opts.templates) {
88
+ if (typeof opts.templates !== "object" || Array.isArray(opts.templates)) {
89
+ throw new HalError("hal/bad-templates", "resource: opts.templates must be a non-array object");
90
+ }
91
+ out._templates = opts.templates;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function _normaliseLinks(links) {
97
+ var out = {};
98
+ var keys = Object.keys(links);
99
+ for (var i = 0; i < keys.length; i += 1) {
100
+ var rel = keys[i];
101
+ var val = links[rel];
102
+ if (typeof val === "string") {
103
+ out[rel] = { href: val };
104
+ } else if (Array.isArray(val)) {
105
+ out[rel] = val.map(function (v, idx) {
106
+ if (typeof v === "string") return { href: v };
107
+ if (v && typeof v === "object" && typeof v.href === "string") return v;
108
+ throw new HalError("hal/bad-link",
109
+ "_links." + rel + "[" + idx + "] must be a string or LinkObject");
110
+ });
111
+ } else if (val && typeof val === "object" && typeof val.href === "string") {
112
+ out[rel] = val;
113
+ } else {
114
+ throw new HalError("hal/bad-link",
115
+ "_links." + rel + " must be a string, LinkObject, or LinkObject[]");
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ module.exports = {
122
+ resource: resource,
123
+ CONTENT_TYPE: CONTENT_TYPE,
124
+ HalError: HalError,
125
+ };