@blamejs/core 0.10.14 → 0.11.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 +4 -0
- package/README.md +1 -0
- package/index.js +18 -0
- package/lib/auth/oauth.js +187 -0
- package/lib/auth/saml.js +1366 -13
- package/lib/cms-codec.js +141 -0
- package/lib/compliance.js +73 -0
- package/lib/csp.js +271 -0
- package/lib/dbsc.js +299 -0
- package/lib/fedcm.js +264 -0
- package/lib/hal.js +125 -0
- package/lib/importmap-integrity.js +90 -0
- package/lib/jsonapi.js +230 -0
- package/lib/lro.js +200 -0
- package/lib/mail-crypto-pgp.js +312 -2
- package/lib/mail-crypto-smime.js +530 -69
- package/lib/mail-deploy.js +632 -5
- package/lib/metrics.js +62 -12
- package/lib/middleware/security-headers.js +2 -1
- package/lib/standard-webhooks.js +183 -0
- package/lib/web-push-vapid.js +322 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
|
+
};
|