@blamejs/core 0.8.60 → 0.8.66
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 +6 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +538 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +437 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/auth/ciba.js
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.auth.ciba
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title CIBA (decoupled auth)
|
|
6
|
+
* @order 330
|
|
7
|
+
* @card OpenID Connect Client-Initiated Backchannel Authentication
|
|
8
|
+
* 1.0 — the "decoupled" auth flow where the relying party
|
|
9
|
+
* authenticates the user out-of-band (push notification to a
|
|
10
|
+
* phone, kiosk-driven sign-in on a separate channel) and
|
|
11
|
+
* tokens are delivered via poll / ping / push.
|
|
12
|
+
*
|
|
13
|
+
* @intro
|
|
14
|
+
* CIBA is the OpenID Connect spec for flows where the device that
|
|
15
|
+
* initiates the authentication isn't the device that completes it.
|
|
16
|
+
* Canonical use cases: a call-center agent confirming a customer
|
|
17
|
+
* identity by pushing a prompt to the customer's phone; a TPM-less
|
|
18
|
+
* POS terminal asking the user's wallet to authorize a purchase; an
|
|
19
|
+
* IVR step-up that requires the customer's mobile-app fingerprint.
|
|
20
|
+
*
|
|
21
|
+
* The relying party (RP):
|
|
22
|
+
* 1. POSTs `auth_req_id` request to the IdP's
|
|
23
|
+
* `backchannel_authentication_endpoint` with login_hint /
|
|
24
|
+
* login_hint_token / id_token_hint identifying the user, plus
|
|
25
|
+
* scope / acr_values / requested_expiry / binding_message.
|
|
26
|
+
* 2. Receives `{ auth_req_id, expires_in, interval }`.
|
|
27
|
+
* 3. Waits for token delivery via the operator-chosen mode:
|
|
28
|
+
*
|
|
29
|
+
* - **poll**: RP polls /token with grant_type=
|
|
30
|
+
* urn:openid:params:grant-type:ciba + auth_req_id every
|
|
31
|
+
* `interval` seconds; gets `authorization_pending`,
|
|
32
|
+
* `slow_down`, or the tokens.
|
|
33
|
+
* - **ping**: IdP POSTs `{ auth_req_id }` to the RP's
|
|
34
|
+
* `client_notification_endpoint`; the RP's handler then
|
|
35
|
+
* calls /token to fetch.
|
|
36
|
+
* - **push**: IdP POSTs `{ auth_req_id, access_token,
|
|
37
|
+
* id_token, refresh_token, ... }` directly. The
|
|
38
|
+
* `client_notification_token` registered with the IdP
|
|
39
|
+
* authenticates each callback.
|
|
40
|
+
*
|
|
41
|
+
* This module provides:
|
|
42
|
+
*
|
|
43
|
+
* b.auth.ciba.client.create({ ... })
|
|
44
|
+
* .startAuthentication({ loginHint, scope, bindingMessage, ... })
|
|
45
|
+
* .pollToken({ authReqId })
|
|
46
|
+
* .receivePingNotification(req) // ping mode handler
|
|
47
|
+
* .receivePushNotification(req) // push mode handler
|
|
48
|
+
*
|
|
49
|
+
* Composes b.auth.oauth for client_assertion / token-endpoint
|
|
50
|
+
* plumbing (so JWT-bearer client auth, mTLS client auth, and PAR
|
|
51
|
+
* alongside CIBA all share one set of audited credentials).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
var lazyRequire = require("../lazy-require");
|
|
55
|
+
var validateOpts = require("../validate-opts");
|
|
56
|
+
var safeJson = require("../safe-json");
|
|
57
|
+
var safeUrl = require("../safe-url");
|
|
58
|
+
var { generateToken, sha3Hash } = require("../crypto");
|
|
59
|
+
var { AuthError } = require("../framework-error");
|
|
60
|
+
|
|
61
|
+
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
62
|
+
var oauth = lazyRequire(function () { return require("./oauth"); });
|
|
63
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
64
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
65
|
+
var emit = validateOpts.makeNamespacedEmitters("auth.ciba", { audit: audit, observability: observability });
|
|
66
|
+
|
|
67
|
+
var DEFAULT_INTERVAL_SEC = 5;
|
|
68
|
+
var DEFAULT_EXPIRES_SEC = 600;
|
|
69
|
+
var MAX_BINDING_MSG_LEN = 200;
|
|
70
|
+
var MAX_RESPONSE_BYTES = 64 * 1024; // allow:raw-byte-literal — JSON token-response cap
|
|
71
|
+
var MIN_INTERVAL_SEC = 1;
|
|
72
|
+
var MAX_INTERVAL_SEC = 300; // allow:raw-time-literal — interval ceiling
|
|
73
|
+
|
|
74
|
+
// _emitAudit emits under the "auth.ciba.<action>" namespace; _emitMetric
|
|
75
|
+
// fires the matching observability counter. Implementations live in
|
|
76
|
+
// validateOpts.makeNamespacedEmitters; the locals are aliases so the
|
|
77
|
+
// existing call sites read identically.
|
|
78
|
+
var _emitAudit = emit.audit;
|
|
79
|
+
var _emitMetric = emit.metric;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @primitive b.auth.ciba.client.create
|
|
83
|
+
* @signature b.auth.ciba.client.create(opts)
|
|
84
|
+
* @since 0.8.62
|
|
85
|
+
* @status stable
|
|
86
|
+
* @related b.auth.oid4vci.issuer.create
|
|
87
|
+
*
|
|
88
|
+
* Build a CIBA-aware OIDC RP. Operators wire the resulting object's
|
|
89
|
+
* methods onto routes that drive the decoupled-auth flow.
|
|
90
|
+
*
|
|
91
|
+
* @opts
|
|
92
|
+
* {
|
|
93
|
+
* issuer: string, // OIDC issuer URL — required
|
|
94
|
+
* clientId: string, // RP client_id — required
|
|
95
|
+
* clientAuth: "secret"|"jwt"|"mtls", // token-endpoint auth
|
|
96
|
+
* clientSecret?: string, // when clientAuth = "secret"
|
|
97
|
+
* clientAssertionSigner?: fn(payload)→jwt, // when clientAuth = "jwt"
|
|
98
|
+
* backchannelAuthenticationEndpoint?: string, // optional — discovered when omitted
|
|
99
|
+
* tokenEndpoint?: string, // optional — discovered
|
|
100
|
+
* scope?: string|string[],
|
|
101
|
+
* deliveryMode: "poll"|"ping"|"push",
|
|
102
|
+
* clientNotificationToken?: string, // fixed token RP mints once + registers with IdP
|
|
103
|
+
* httpClientOpts?: object,
|
|
104
|
+
* allowHttp?: boolean,
|
|
105
|
+
* }
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* var ciba = b.auth.ciba.client.create({
|
|
109
|
+
* issuer: "https://idp.example.com",
|
|
110
|
+
* clientId: "rp-1",
|
|
111
|
+
* clientAuth: "secret",
|
|
112
|
+
* clientSecret: process.env.CIBA_CLIENT_SECRET,
|
|
113
|
+
* scope: ["openid", "profile"],
|
|
114
|
+
* deliveryMode: "poll",
|
|
115
|
+
* });
|
|
116
|
+
* var ticket = await ciba.startAuthentication({
|
|
117
|
+
* loginHint: "alice@example.com",
|
|
118
|
+
* bindingMessage: "Authorize wire transfer of $4,200",
|
|
119
|
+
* acrValues: ["urn:mace:incommon:iap:silver"],
|
|
120
|
+
* });
|
|
121
|
+
* // → { authReqId, expiresIn, interval }
|
|
122
|
+
* var tokens = await ciba.pollToken({ authReqId: ticket.authReqId });
|
|
123
|
+
* // → { accessToken, idToken, ... } once user approves
|
|
124
|
+
*/
|
|
125
|
+
function create(opts) {
|
|
126
|
+
validateOpts.requireObject(opts, "auth.ciba.client.create", AuthError);
|
|
127
|
+
validateOpts.requireNonEmptyString(opts.issuer, "auth.ciba.client.create: issuer", AuthError, "auth-ciba/no-issuer");
|
|
128
|
+
validateOpts.requireNonEmptyString(opts.clientId, "auth.ciba.client.create: clientId", AuthError, "auth-ciba/no-client-id");
|
|
129
|
+
|
|
130
|
+
var clientAuth = opts.clientAuth || "secret";
|
|
131
|
+
if (["secret", "jwt", "mtls"].indexOf(clientAuth) === -1) {
|
|
132
|
+
throw new AuthError("auth-ciba/bad-client-auth",
|
|
133
|
+
"auth.ciba.client.create: clientAuth must be 'secret' | 'jwt' | 'mtls'");
|
|
134
|
+
}
|
|
135
|
+
if (clientAuth === "secret" && !opts.clientSecret) {
|
|
136
|
+
throw new AuthError("auth-ciba/no-client-secret",
|
|
137
|
+
"auth.ciba.client.create: clientSecret required for clientAuth='secret'");
|
|
138
|
+
}
|
|
139
|
+
if (clientAuth === "jwt" && typeof opts.clientAssertionSigner !== "function") {
|
|
140
|
+
throw new AuthError("auth-ciba/no-assertion-signer",
|
|
141
|
+
"auth.ciba.client.create: clientAssertionSigner required for clientAuth='jwt'");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
var deliveryMode = opts.deliveryMode || "poll";
|
|
145
|
+
if (["poll", "ping", "push"].indexOf(deliveryMode) === -1) {
|
|
146
|
+
throw new AuthError("auth-ciba/bad-delivery-mode",
|
|
147
|
+
"auth.ciba.client.create: deliveryMode must be 'poll' | 'ping' | 'push'");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Inner OAuth client — composes discovery, JWKS fetch, ID-token
|
|
151
|
+
// verification. CIBA's token endpoint, JWKS, and discovery are all
|
|
152
|
+
// shared with the RP's other OIDC flows so we reuse the existing
|
|
153
|
+
// primitive's caching + audit + clock-skew tolerance.
|
|
154
|
+
var inner = oauth().create({
|
|
155
|
+
issuer: opts.issuer,
|
|
156
|
+
clientId: opts.clientId,
|
|
157
|
+
clientSecret: opts.clientSecret,
|
|
158
|
+
redirectUri: opts.redirectUri || (opts.issuer + "/__ciba_no_redirect__"),
|
|
159
|
+
scope: opts.scope,
|
|
160
|
+
backchannelAuthenticationEndpoint: opts.backchannelAuthenticationEndpoint,
|
|
161
|
+
tokenEndpoint: opts.tokenEndpoint,
|
|
162
|
+
httpClientOpts: opts.httpClientOpts,
|
|
163
|
+
allowHttp: opts.allowHttp === true,
|
|
164
|
+
isOidc: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
var clientNotificationToken = opts.clientNotificationToken || null;
|
|
168
|
+
if ((deliveryMode === "ping" || deliveryMode === "push") && !clientNotificationToken) {
|
|
169
|
+
throw new AuthError("auth-ciba/no-notification-token",
|
|
170
|
+
"auth.ciba.client.create: clientNotificationToken required for ping/push delivery modes");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Each backchannel-authentication request mints a fresh
|
|
174
|
+
// `client_notification_token` per the spec? No — the RP registers
|
|
175
|
+
// ONE long-lived token with the IdP at registration time. Operator
|
|
176
|
+
// rotates by re-registering. Per CIBA §7.1.1.
|
|
177
|
+
|
|
178
|
+
function _basicAuthHeader() {
|
|
179
|
+
if (clientAuth !== "secret") return null;
|
|
180
|
+
var pair = opts.clientId + ":" + opts.clientSecret;
|
|
181
|
+
return "Basic " + Buffer.from(pair, "utf8").toString("base64");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function _resolveBackchannelEndpoint() {
|
|
185
|
+
// Hit discovery if not pre-configured. The inner OAuth client
|
|
186
|
+
// already has discovery cache; we ride it via the public discover()
|
|
187
|
+
// shape.
|
|
188
|
+
if (opts.backchannelAuthenticationEndpoint) return opts.backchannelAuthenticationEndpoint;
|
|
189
|
+
var disc = await inner.discover();
|
|
190
|
+
if (!disc || typeof disc.backchannel_authentication_endpoint !== "string") {
|
|
191
|
+
throw new AuthError("auth-ciba/no-backchannel-endpoint",
|
|
192
|
+
"ciba: IdP discovery doc has no backchannel_authentication_endpoint " +
|
|
193
|
+
"(set opts.backchannelAuthenticationEndpoint on create() if the IdP doesn't publish it)");
|
|
194
|
+
}
|
|
195
|
+
return disc.backchannel_authentication_endpoint;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function _resolveTokenEndpoint() {
|
|
199
|
+
if (opts.tokenEndpoint) return opts.tokenEndpoint;
|
|
200
|
+
var disc = await inner.discover();
|
|
201
|
+
if (!disc || typeof disc.token_endpoint !== "string") {
|
|
202
|
+
throw new AuthError("auth-ciba/no-token-endpoint",
|
|
203
|
+
"ciba: IdP discovery doc has no token_endpoint");
|
|
204
|
+
}
|
|
205
|
+
return disc.token_endpoint;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _validateBindingMessage(msg) {
|
|
209
|
+
if (msg === undefined || msg === null) return null;
|
|
210
|
+
if (typeof msg !== "string") {
|
|
211
|
+
throw new AuthError("auth-ciba/bad-binding-message",
|
|
212
|
+
"ciba: bindingMessage must be a string");
|
|
213
|
+
}
|
|
214
|
+
if (msg.length > MAX_BINDING_MSG_LEN) {
|
|
215
|
+
throw new AuthError("auth-ciba/binding-message-too-long",
|
|
216
|
+
"ciba: bindingMessage exceeds " + MAX_BINDING_MSG_LEN + " chars (CIBA §7.1)");
|
|
217
|
+
}
|
|
218
|
+
// Per §7.1, binding_message MUST be plain text + restricted to
|
|
219
|
+
// characters most user-agents render legibly. Refuse control /
|
|
220
|
+
// bidi / zero-width.
|
|
221
|
+
// Codepoint scan instead of a regex character class - eslint's
|
|
222
|
+
// no-control-regex rule refuses control-char ranges in regex
|
|
223
|
+
// literals regardless of how they're spelled.
|
|
224
|
+
for (var ci = 0; ci < msg.length; ci += 1) {
|
|
225
|
+
var cc = msg.charCodeAt(ci);
|
|
226
|
+
if (cc <= 0x001f ||
|
|
227
|
+
(cc >= 0x007f && cc <= 0x009f) ||
|
|
228
|
+
(cc >= 0x200b && cc <= 0x200f) ||
|
|
229
|
+
(cc >= 0x202a && cc <= 0x202e) ||
|
|
230
|
+
(cc >= 0x2066 && cc <= 0x2069) ||
|
|
231
|
+
cc === 0xfeff) { // allow:raw-byte-literal — codepoint constants for control / bidi / zero-width / BOM
|
|
232
|
+
throw new AuthError("auth-ciba/binding-message-control-chars",
|
|
233
|
+
"ciba: bindingMessage contains control / bidi / zero-width characters");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return msg;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function _postForm(url, body, headers) {
|
|
240
|
+
safeUrl.parse(url, {
|
|
241
|
+
allowedProtocols: opts.allowHttp === true ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
|
|
242
|
+
});
|
|
243
|
+
var hc = httpClient();
|
|
244
|
+
var basic = _basicAuthHeader();
|
|
245
|
+
var hdrs = Object.assign({
|
|
246
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
247
|
+
"Accept": "application/json",
|
|
248
|
+
}, headers || {});
|
|
249
|
+
if (basic) hdrs["Authorization"] = basic;
|
|
250
|
+
var req = {
|
|
251
|
+
url: url,
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: hdrs,
|
|
254
|
+
body: body.toString(),
|
|
255
|
+
// OAuth / CIBA 4xx responses carry structured error JSON
|
|
256
|
+
// (`{ error, error_description }`) the framework inspects to
|
|
257
|
+
// raise the right `auth-ciba/<code>` AuthError below. Default
|
|
258
|
+
// http-client behavior throws on >=400 — opt into resolve-and-
|
|
259
|
+
// surface so the body reaches us.
|
|
260
|
+
responseMode: "always-resolve",
|
|
261
|
+
};
|
|
262
|
+
Object.assign(req, opts.httpClientOpts || {});
|
|
263
|
+
if (opts.allowHttp === true) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
|
|
264
|
+
var res = await hc.request(req);
|
|
265
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
266
|
+
var bodyText = res.body ? res.body.toString("utf8") : "";
|
|
267
|
+
var err;
|
|
268
|
+
try { err = safeJson.parse(bodyText, { maxBytes: MAX_RESPONSE_BYTES }); } catch (_e) { /* silent-catch: non-JSON IdP error body falls through to the bodyText snippet path below */ }
|
|
269
|
+
var code = (err && err.error) || ("http-" + res.statusCode);
|
|
270
|
+
var msg = (err && (err.error_description || err.error)) || bodyText.slice(0, 200); // allow:raw-byte-literal — error-message snippet length
|
|
271
|
+
var aerr = new AuthError("auth-ciba/" + code, "ciba: " + msg);
|
|
272
|
+
aerr.cibaError = err || null;
|
|
273
|
+
aerr.statusCode = res.statusCode;
|
|
274
|
+
throw aerr;
|
|
275
|
+
}
|
|
276
|
+
if (!res.body) return null;
|
|
277
|
+
try { return safeJson.parse(res.body.toString("utf8"), { maxBytes: MAX_RESPONSE_BYTES }); }
|
|
278
|
+
catch (e) {
|
|
279
|
+
throw new AuthError("auth-ciba/bad-json",
|
|
280
|
+
"ciba: response not JSON: " + ((e && e.message) || String(e)));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @primitive b.auth.ciba.client.startAuthentication
|
|
286
|
+
* @signature b.auth.ciba.client.startAuthentication(opts)
|
|
287
|
+
* @since 0.8.62
|
|
288
|
+
*
|
|
289
|
+
* POST to the IdP's backchannel_authentication_endpoint and return
|
|
290
|
+
* a ticket with `authReqId` + `expiresIn` + `interval`. At least
|
|
291
|
+
* one of `loginHint` / `loginHintToken` / `idTokenHint` must be
|
|
292
|
+
* supplied to identify the user.
|
|
293
|
+
*
|
|
294
|
+
* @opts
|
|
295
|
+
* {
|
|
296
|
+
* loginHint?: string,
|
|
297
|
+
* loginHintToken?: string,
|
|
298
|
+
* idTokenHint?: string,
|
|
299
|
+
* scope?: string|string[],
|
|
300
|
+
* bindingMessage?: string,
|
|
301
|
+
* acrValues?: string|string[],
|
|
302
|
+
* requestedExpiry?: number,
|
|
303
|
+
* userCode?: string,
|
|
304
|
+
* }
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* var ticket = await ciba.startAuthentication({
|
|
308
|
+
* loginHint: "alice@example.com",
|
|
309
|
+
* bindingMessage: "Authorize wire transfer of $4,200",
|
|
310
|
+
* });
|
|
311
|
+
* // → { authReqId, expiresIn, interval }
|
|
312
|
+
*/
|
|
313
|
+
async function startAuthentication(sopts) {
|
|
314
|
+
sopts = sopts || {};
|
|
315
|
+
if (!sopts.loginHint && !sopts.loginHintToken && !sopts.idTokenHint) {
|
|
316
|
+
throw new AuthError("auth-ciba/no-user-hint",
|
|
317
|
+
"ciba.startAuthentication: one of loginHint / loginHintToken / idTokenHint required");
|
|
318
|
+
}
|
|
319
|
+
var endpoint = await _resolveBackchannelEndpoint();
|
|
320
|
+
var body = new URLSearchParams();
|
|
321
|
+
if (sopts.loginHint) body.set("login_hint", sopts.loginHint);
|
|
322
|
+
if (sopts.loginHintToken) body.set("login_hint_token", sopts.loginHintToken);
|
|
323
|
+
if (sopts.idTokenHint) body.set("id_token_hint", sopts.idTokenHint);
|
|
324
|
+
|
|
325
|
+
var scope = sopts.scope || opts.scope || ["openid"];
|
|
326
|
+
if (Array.isArray(scope)) scope = scope.join(" ");
|
|
327
|
+
body.set("scope", scope);
|
|
328
|
+
|
|
329
|
+
if (sopts.bindingMessage !== undefined) {
|
|
330
|
+
var msg = _validateBindingMessage(sopts.bindingMessage);
|
|
331
|
+
if (msg) body.set("binding_message", msg);
|
|
332
|
+
}
|
|
333
|
+
if (Array.isArray(sopts.acrValues) && sopts.acrValues.length > 0) {
|
|
334
|
+
body.set("acr_values", sopts.acrValues.join(" "));
|
|
335
|
+
} else if (typeof sopts.acrValues === "string" && sopts.acrValues.length > 0) {
|
|
336
|
+
body.set("acr_values", sopts.acrValues);
|
|
337
|
+
}
|
|
338
|
+
if (typeof sopts.requestedExpiry === "number" &&
|
|
339
|
+
Number.isInteger(sopts.requestedExpiry) && sopts.requestedExpiry > 0) {
|
|
340
|
+
body.set("requested_expiry", String(sopts.requestedExpiry));
|
|
341
|
+
}
|
|
342
|
+
if (typeof sopts.userCode === "string") body.set("user_code", sopts.userCode);
|
|
343
|
+
|
|
344
|
+
if (clientAuth === "jwt") {
|
|
345
|
+
var assertion = await opts.clientAssertionSigner({
|
|
346
|
+
iss: opts.clientId, sub: opts.clientId, aud: endpoint,
|
|
347
|
+
iat: Math.floor(Date.now() / 1000), // allow:raw-byte-literal — ms→s
|
|
348
|
+
exp: Math.floor(Date.now() / 1000) + 300, // allow:raw-byte-literal — assertion 5m TTL
|
|
349
|
+
jti: generateToken(16),
|
|
350
|
+
});
|
|
351
|
+
body.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
352
|
+
body.set("client_assertion", assertion);
|
|
353
|
+
body.set("client_id", opts.clientId);
|
|
354
|
+
}
|
|
355
|
+
if (clientAuth === "mtls") {
|
|
356
|
+
body.set("client_id", opts.clientId);
|
|
357
|
+
}
|
|
358
|
+
if (clientNotificationToken && (deliveryMode === "ping" || deliveryMode === "push")) {
|
|
359
|
+
body.set("client_notification_token", clientNotificationToken);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
var rv = await _postForm(endpoint, body);
|
|
363
|
+
if (!rv || typeof rv.auth_req_id !== "string") {
|
|
364
|
+
throw new AuthError("auth-ciba/bad-response",
|
|
365
|
+
"ciba.startAuthentication: response missing auth_req_id");
|
|
366
|
+
}
|
|
367
|
+
var interval = typeof rv.interval === "number" && rv.interval >= MIN_INTERVAL_SEC && rv.interval <= MAX_INTERVAL_SEC
|
|
368
|
+
? rv.interval : DEFAULT_INTERVAL_SEC;
|
|
369
|
+
var expiresIn = typeof rv.expires_in === "number" && rv.expires_in > 0
|
|
370
|
+
? rv.expires_in : DEFAULT_EXPIRES_SEC;
|
|
371
|
+
|
|
372
|
+
_emitAudit("start", "success", {
|
|
373
|
+
authReqIdHash: sha3Hash("auth-ciba:" + rv.auth_req_id),
|
|
374
|
+
deliveryMode: deliveryMode,
|
|
375
|
+
hasBindingMessage: !!sopts.bindingMessage,
|
|
376
|
+
});
|
|
377
|
+
_emitMetric("started");
|
|
378
|
+
return {
|
|
379
|
+
authReqId: rv.auth_req_id,
|
|
380
|
+
expiresIn: expiresIn,
|
|
381
|
+
interval: interval,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @primitive b.auth.ciba.client.pollToken
|
|
387
|
+
* @signature b.auth.ciba.client.pollToken(opts)
|
|
388
|
+
* @since 0.8.62
|
|
389
|
+
*
|
|
390
|
+
* Poll the IdP's /token endpoint with grant_type=ciba. Returns the
|
|
391
|
+
* tokens once the user approves; throws AuthError with code
|
|
392
|
+
* "auth-ciba/authorization_pending" or "auth-ciba/slow_down" while
|
|
393
|
+
* waiting. Operators wrap with their preferred backoff.
|
|
394
|
+
*
|
|
395
|
+
* @opts
|
|
396
|
+
* { authReqId: string }
|
|
397
|
+
*
|
|
398
|
+
* @example
|
|
399
|
+
* var tokens = await ciba.pollToken({ authReqId: ticket.authReqId });
|
|
400
|
+
* // → { accessToken, idToken, refreshToken, tokenType, scope, expiresIn, raw }
|
|
401
|
+
*/
|
|
402
|
+
async function pollToken(popts) {
|
|
403
|
+
popts = popts || {};
|
|
404
|
+
if (typeof popts.authReqId !== "string" || popts.authReqId.length === 0) {
|
|
405
|
+
throw new AuthError("auth-ciba/no-auth-req-id",
|
|
406
|
+
"ciba.pollToken: authReqId required");
|
|
407
|
+
}
|
|
408
|
+
var endpoint = await _resolveTokenEndpoint();
|
|
409
|
+
var body = new URLSearchParams();
|
|
410
|
+
body.set("grant_type", "urn:openid:params:grant-type:ciba");
|
|
411
|
+
body.set("auth_req_id", popts.authReqId);
|
|
412
|
+
if (clientAuth === "jwt") {
|
|
413
|
+
var assertion = await opts.clientAssertionSigner({
|
|
414
|
+
iss: opts.clientId, sub: opts.clientId, aud: endpoint,
|
|
415
|
+
iat: Math.floor(Date.now() / 1000), // allow:raw-byte-literal — ms→s
|
|
416
|
+
exp: Math.floor(Date.now() / 1000) + 300, // allow:raw-byte-literal — assertion 5m TTL
|
|
417
|
+
jti: generateToken(16),
|
|
418
|
+
});
|
|
419
|
+
body.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
420
|
+
body.set("client_assertion", assertion);
|
|
421
|
+
body.set("client_id", opts.clientId);
|
|
422
|
+
}
|
|
423
|
+
if (clientAuth === "mtls") body.set("client_id", opts.clientId);
|
|
424
|
+
var rv = await _postForm(endpoint, body);
|
|
425
|
+
_emitAudit("token_received", "success", {
|
|
426
|
+
authReqIdHash: sha3Hash("auth-ciba:" + popts.authReqId),
|
|
427
|
+
});
|
|
428
|
+
_emitMetric("token-received");
|
|
429
|
+
return {
|
|
430
|
+
accessToken: rv.access_token || null,
|
|
431
|
+
idToken: rv.id_token || null,
|
|
432
|
+
refreshToken: rv.refresh_token || null,
|
|
433
|
+
tokenType: rv.token_type || null,
|
|
434
|
+
scope: rv.scope || null,
|
|
435
|
+
expiresIn: typeof rv.expires_in === "number" ? rv.expires_in : null,
|
|
436
|
+
raw: rv,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @primitive b.auth.ciba.client.parseNotification
|
|
442
|
+
* @signature b.auth.ciba.client.parseNotification(req, opts)
|
|
443
|
+
* @since 0.8.62
|
|
444
|
+
*
|
|
445
|
+
* Parse + authenticate an IdP-initiated callback to the RP's
|
|
446
|
+
* `client_notification_endpoint`. Validates the bearer
|
|
447
|
+
* `client_notification_token` (timing-safe equality) before
|
|
448
|
+
* surfacing the body. Use the returned `authReqId` to drive the
|
|
449
|
+
* RP-side flow:
|
|
450
|
+
*
|
|
451
|
+
* - In **ping** mode the body is `{ auth_req_id }`. Call
|
|
452
|
+
* `pollToken({ authReqId })` afterwards.
|
|
453
|
+
* - In **push** mode the body carries the full token-response
|
|
454
|
+
* object; no follow-up call needed.
|
|
455
|
+
*
|
|
456
|
+
* @opts
|
|
457
|
+
* { body?: object } // pre-parsed body; defaults to req.body
|
|
458
|
+
*
|
|
459
|
+
* @example
|
|
460
|
+
* app.post("/ciba/notify", function (req, res) {
|
|
461
|
+
* var info = ciba.parseNotification(req, { body: req.body });
|
|
462
|
+
* // → { authReqId, accessToken, idToken, ... }
|
|
463
|
+
* res.statusCode = 204; res.end();
|
|
464
|
+
* });
|
|
465
|
+
*/
|
|
466
|
+
function parseNotification(req, popts) {
|
|
467
|
+
popts = popts || {};
|
|
468
|
+
if (!req || !req.headers) {
|
|
469
|
+
throw new AuthError("auth-ciba/bad-notification-req",
|
|
470
|
+
"ciba.parseNotification: req with headers required");
|
|
471
|
+
}
|
|
472
|
+
var authzHeader = req.headers["authorization"] || req.headers["Authorization"];
|
|
473
|
+
if (!authzHeader || authzHeader.indexOf("Bearer ") !== 0) {
|
|
474
|
+
throw new AuthError("auth-ciba/missing-bearer",
|
|
475
|
+
"ciba.parseNotification: Authorization: Bearer header missing");
|
|
476
|
+
}
|
|
477
|
+
var presented = authzHeader.substring("Bearer ".length).trim();
|
|
478
|
+
if (presented.length === 0 || !clientNotificationToken) {
|
|
479
|
+
throw new AuthError("auth-ciba/bad-bearer",
|
|
480
|
+
"ciba.parseNotification: empty bearer or no expected token configured");
|
|
481
|
+
}
|
|
482
|
+
// Constant-time compare via the framework's primitive shape —
|
|
483
|
+
// sha3-of-each + ===-of-hash is constant-time over equal-length
|
|
484
|
+
// hashes regardless of presented length, so a length-side-channel
|
|
485
|
+
// probe can't enumerate the prefix.
|
|
486
|
+
var presentedHash = sha3Hash(presented);
|
|
487
|
+
var expectedHash = sha3Hash(clientNotificationToken);
|
|
488
|
+
if (presentedHash !== expectedHash) {
|
|
489
|
+
_emitAudit("notification_token_mismatch", "failure", {});
|
|
490
|
+
throw new AuthError("auth-ciba/wrong-bearer",
|
|
491
|
+
"ciba.parseNotification: client_notification_token does not match");
|
|
492
|
+
}
|
|
493
|
+
var body = popts.body !== undefined ? popts.body : req.body;
|
|
494
|
+
if (typeof body === "string") {
|
|
495
|
+
try { body = safeJson.parse(body, { maxBytes: MAX_RESPONSE_BYTES }); }
|
|
496
|
+
catch (e) {
|
|
497
|
+
throw new AuthError("auth-ciba/bad-notification-body",
|
|
498
|
+
"ciba.parseNotification: body is not JSON: " + ((e && e.message) || String(e)));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (!body || typeof body !== "object") {
|
|
502
|
+
throw new AuthError("auth-ciba/no-notification-body",
|
|
503
|
+
"ciba.parseNotification: body required (Buffer/string parsed by middleware)");
|
|
504
|
+
}
|
|
505
|
+
if (typeof body.auth_req_id !== "string") {
|
|
506
|
+
throw new AuthError("auth-ciba/no-auth-req-id-in-body",
|
|
507
|
+
"ciba.parseNotification: body missing auth_req_id");
|
|
508
|
+
}
|
|
509
|
+
_emitAudit("notification_received", "success", {
|
|
510
|
+
authReqIdHash: sha3Hash("auth-ciba:" + body.auth_req_id),
|
|
511
|
+
mode: deliveryMode,
|
|
512
|
+
});
|
|
513
|
+
_emitMetric("notification-received");
|
|
514
|
+
return {
|
|
515
|
+
authReqId: body.auth_req_id,
|
|
516
|
+
accessToken: body.access_token || null,
|
|
517
|
+
idToken: body.id_token || null,
|
|
518
|
+
refreshToken: body.refresh_token || null,
|
|
519
|
+
tokenType: body.token_type || null,
|
|
520
|
+
scope: body.scope || null,
|
|
521
|
+
expiresIn: typeof body.expires_in === "number" ? body.expires_in : null,
|
|
522
|
+
raw: body,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
startAuthentication: startAuthentication,
|
|
528
|
+
pollToken: pollToken,
|
|
529
|
+
parseNotification: parseNotification,
|
|
530
|
+
issuer: opts.issuer,
|
|
531
|
+
clientId: opts.clientId,
|
|
532
|
+
deliveryMode: deliveryMode,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
module.exports = {
|
|
537
|
+
client: { create: create },
|
|
538
|
+
};
|