@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.
@@ -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
+ };