@dloizides/auth-client 3.0.0 → 3.3.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/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { T as TokenStorage, c as AuthTokens } from './AuthClient-D95OMajD.js';
2
- export { A as AuthApiClient, d as AuthApiClientOptions, b as AuthClient, e as AuthClientCollaborators, f as AuthClientConfig, g as AuthClientFromIssuerInput, h as AuthEventEmitter, i as AuthEventListener, j as AuthEventName, k as AuthEventUnsubscribe, a as AuthSessionInfo, D as DirectKcOptions, F as ForgotPasswordRequest, I as InactivityStore, l as InactivityTracker, m as InactivityTrackerOptions, L as LoginOptions, n as LogoutOptions, O as OtpLoginRequest, P as PasswordLoginRequest, o as RawAuthLoginResponse, p as RefreshFn, q as RefreshInterceptor, r as RefreshInterceptorOptions, R as ResetPasswordRequest } from './AuthClient-D95OMajD.js';
1
+ import { T as TokenStorage, k as AuthTokens } from './AuthClient-Bb7N2shJ.js';
2
+ export { A as AuthApiClient, a as AuthApiClientOptions, b as AuthClient, c as AuthClientCollaborators, d as AuthClientConfig, e as AuthClientFromIssuerInput, f as AuthEventEmitter, g as AuthEventListener, h as AuthEventName, i as AuthEventUnsubscribe, j as AuthSessionInfo, D as DirectKcOptions, F as ForgotPasswordRequest, I as InactivityStore, l as InactivityTracker, m as InactivityTrackerOptions, L as LoginOptions, n as LogoutOptions, O as OtpLoginRequest, P as PasswordLoginRequest, R as RawAuthLoginResponse, o as RefreshFn, p as RefreshInterceptor, q as RefreshInterceptorOptions, r as ResetPasswordRequest } from './AuthClient-Bb7N2shJ.js';
3
3
  export { ExchangeAuthorizationCodeInput, FetchDiscoveryDocumentInput, OidcDiscoveryDocument, PkcePair, RefreshAccessTokenInput, clearDiscoveryCache, deriveCodeChallenge, exchangeAuthorizationCode, fetchDiscoveryDocument, generateCodeVerifier, generatePkcePair, refreshAccessToken } from './oidc/index.js';
4
- import { H as HttpClient, R as RawTokenResponse, T as TokenResponse } from './TokenResponse-CY1CaU2l.js';
5
- export { a as HttpRequest, b as HttpResponse, c as createFetchHttpClient } from './TokenResponse-CY1CaU2l.js';
4
+ import { H as HttpClient, R as RawTokenResponse, T as TokenResponse } from './TokenResponse-BkIDjenX.js';
5
+ export { a as HttpRequest, b as HttpResponse, c as createFetchHttpClient } from './TokenResponse-BkIDjenX.js';
6
6
 
7
7
  /**
8
8
  * Roles emitted by Keycloak realms in the dloizides.com portfolio.
@@ -342,6 +342,143 @@ interface BffResetPasswordRequest {
342
342
  token: string;
343
343
  newPassword: string;
344
344
  }
345
+ /**
346
+ * Payload for `POST /bff/otp/request` — the BFF proxies it to TenantService,
347
+ * which generates a short-TTL code and emails it.
348
+ */
349
+ interface BffOtpRequestRequest {
350
+ /** The email address (or username) the one-time code is sent to. */
351
+ identifier: string;
352
+ }
353
+ /** Payload for `POST /bff/otp/verify` — the BFF exchanges it for a session. */
354
+ interface BffOtpVerifyRequest {
355
+ /** The email / username the code was requested for. */
356
+ username: string;
357
+ /** The one-time code the user entered. */
358
+ otp: string;
359
+ }
360
+ /**
361
+ * Payload for `POST /bff/pin/login` — the BFF exchanges an event-scoped PIN
362
+ * for a session.
363
+ *
364
+ * The `(event, pin)` pair alone identifies the staff member: no `username` /
365
+ * `password` ever leaves the browser. A PIN entered in an event's context
366
+ * grants that staff member their event-scoped role for that event only
367
+ * (the unified-auth plan §4.4 — event-scoped, per-individual PINs).
368
+ */
369
+ interface BffPinLoginRequest {
370
+ /** The numeric PIN the staff member entered. */
371
+ pin: string;
372
+ /** External id of the event the PIN is scoped to (supplied by the page/route). */
373
+ eventExternalId: string;
374
+ }
375
+ /** Payload for `POST /bff/pin/enroll` — bind a device PIN to the current session. */
376
+ interface BffDevicePinEnrollRequest {
377
+ /** The numeric PIN the user chose. */
378
+ pin: string;
379
+ /** The chosen PIN length (must be one of 4 / 6 / 8). */
380
+ digits: number;
381
+ }
382
+ /** Payload for `POST /bff/pin/unlock` — re-establish a session from a remembered device. */
383
+ interface BffDevicePinUnlockRequest {
384
+ /** The numeric device PIN the returning user entered. */
385
+ pin: string;
386
+ }
387
+ /**
388
+ * The optional per-device state half of `GET /bff/config`, all fields safe-defaulted.
389
+ *
390
+ * Read off the BFF's per-device record (keyed by the device cookie). Older BFFs /
391
+ * tests omit the fields entirely, in which case the PIN-unlock gate simply never
392
+ * triggers (`hasPin` defaults to `false`, `rememberedUsername` to `null`).
393
+ */
394
+ interface BffDeviceState {
395
+ /** Non-secret username this device remembers, or `null` when none. */
396
+ rememberedUsername: string | null;
397
+ /** `true` when this device has an enrolled device PIN. */
398
+ hasPin: boolean;
399
+ /** The enrolled PIN length (4 / 6 / 8), or `null` when unknown / no PIN. */
400
+ pinDigits: number | null;
401
+ /** The device-local preferred-method hint (e.g. `"pin"`), or `null`. */
402
+ preferredMethod: string | null;
403
+ }
404
+ /**
405
+ * The parsed `GET /bff/config` response: which login methods this BFF advertises,
406
+ * whether self-serve registration is enabled, and the optional per-device state.
407
+ *
408
+ * `methods` are the lowercase strings the server-side `BffLoginMethod` enum
409
+ * serialises to (`"password"` | `"otp"` | `"pin"` | `"passkey"`), de-duplicated
410
+ * and order-preserving. On a network failure or malformed body the client returns
411
+ * a safe fallback (`["password"]`, registration off, empty device-state).
412
+ */
413
+ interface BffLoginConfig {
414
+ /** The enabled login methods, lowercase, in the order the BFF advertised them. */
415
+ methods: string[];
416
+ /** `true` when this BFF exposes self-serve registration. */
417
+ registrationEnabled: boolean;
418
+ /** The optional per-device state (remembered username + device PIN), safe-defaulted. */
419
+ deviceState: BffDeviceState;
420
+ }
421
+ /**
422
+ * Discriminated result of `unlockWithDevicePin`. Never rejects — the unlock UI
423
+ * routes on `status` (the published `login`/`pinLogin` throw an opaque error on
424
+ * any non-2xx, collapsing 401 vs 429; this is why the device-PIN flow needs its
425
+ * own client surface).
426
+ *
427
+ * The two `429` outcomes are distinct and MUST stay distinct: the BFF's per-IP
428
+ * `BffAuth` sliding-window limiter answers `429` with an EMPTY body
429
+ * (`rateLimited` — the UI may poll through it), whereas the device-PIN lockout
430
+ * answers `429` with a JSON `{ error }` body + a `Retry-After` header (`locked` —
431
+ * the UI shows a "try again in N s" message).
432
+ */
433
+ type DevicePinUnlockResult = {
434
+ status: 'success';
435
+ user: BffUser;
436
+ } | {
437
+ status: 'invalid';
438
+ } | {
439
+ status: 'locked';
440
+ retryAfterSeconds: number | null;
441
+ } | {
442
+ status: 'rateLimited';
443
+ retryAfterSeconds: number | null;
444
+ } | {
445
+ status: 'error';
446
+ };
447
+ /**
448
+ * Discriminated result of `enrollDevicePin`. Never rejects — the enrol form routes
449
+ * on `status`:
450
+ * - `success` — HTTP 200, the device PIN is bound to the current session;
451
+ * - `unauthorized` — HTTP 401, no session to bind to;
452
+ * - `forbidden` — HTTP 403, a PIN-established session can't enrol a new PIN;
453
+ * - `invalidPin` — HTTP 400, the PIN format was rejected;
454
+ * - `error` — anything else (501 disabled / 502 grant failed / network).
455
+ */
456
+ type DevicePinEnrollResult = {
457
+ status: 'success';
458
+ } | {
459
+ status: 'unauthorized';
460
+ } | {
461
+ status: 'forbidden';
462
+ } | {
463
+ status: 'invalidPin';
464
+ } | {
465
+ status: 'error';
466
+ };
467
+ /**
468
+ * The body `POST /bff/otp/request` relays from TenantService.
469
+ *
470
+ * Anti-enumeration: the shape is identical whether or not the identifier is
471
+ * registered. `code` is non-null only outside production (a dev convenience);
472
+ * the UI must never depend on it being present.
473
+ */
474
+ interface BffOtpRequestResult {
475
+ /** Always `true` on a relayed 200 — the request was accepted. */
476
+ success: boolean;
477
+ /** Seconds until the emitted code expires — drives a countdown in the UI. */
478
+ expiresIn: number;
479
+ /** The code itself, non-production only; `null` (or absent) in production. */
480
+ code: string | null;
481
+ }
345
482
  /**
346
483
  * The user object returned by `GET /bff/me` and `POST /bff/login`. The BFF
347
484
  * returns the sanitised KC claims under a `user` envelope and **never** a
@@ -411,6 +548,86 @@ declare class BffAuthClient {
411
548
  * response (e.g. `400` for an invalid / expired token).
412
549
  */
413
550
  resetPassword(request: BffResetPasswordRequest): Promise<void>;
551
+ /**
552
+ * `POST /bff/otp/request` — the BFF proxies to TenantService, which generates
553
+ * a short-TTL code and emails it.
554
+ *
555
+ * The endpoint is anti-enumeration: a `200` is the normal path whether or not
556
+ * the identifier is registered. This method therefore **returns** the relayed
557
+ * `{ success, expiresIn, code }` body (so the UI can show the expiry) rather
558
+ * than treating a 200 as opaque. It still throws on a non-2xx — a `501`
559
+ * (OTP not enabled) or `502` (upstream down) is a real failure to surface.
560
+ */
561
+ requestOtp(request: BffOtpRequestRequest): Promise<BffOtpRequestResult>;
562
+ /**
563
+ * `POST /bff/otp/verify` — the BFF runs the OTP direct-grant against Keycloak
564
+ * server-side, stores the tokens in its Redis vault, and sets the httpOnly
565
+ * session cookie. Returns the sanitised user, exactly like `login`. Throws on
566
+ * a non-2xx (e.g. `401` for a bad / expired code).
567
+ */
568
+ verifyOtp(request: BffOtpVerifyRequest): Promise<BffUser>;
569
+ /**
570
+ * `POST /bff/pin/login` — the BFF runs the event-scoped PIN direct-grant
571
+ * against Keycloak server-side (the `(event, pin)` pair resolves to the
572
+ * staff member's KC account + event-scoped role), stores the tokens in its
573
+ * Redis vault, and sets the httpOnly session cookie. Returns the sanitised
574
+ * user, exactly like `login` / `verifyOtp`. Throws on a non-2xx — `401` for
575
+ * a bad / expired / locked-out PIN or an unknown event, `501` when PIN login
576
+ * is not an enabled method for this BFF.
577
+ */
578
+ pinLogin(request: BffPinLoginRequest): Promise<BffUser>;
579
+ /**
580
+ * `GET /bff/config` — which login methods this BFF advertises, whether
581
+ * registration is enabled, and the optional per-device state (remembered
582
+ * username + device PIN). Unauthenticated, no CSRF (GET).
583
+ *
584
+ * NEVER throws: on a non-2xx, a network error, or a malformed body it returns
585
+ * a safe fallback (`{ methods: ['password'], registrationEnabled: false,
586
+ * deviceState: { rememberedUsername: null, hasPin: false, pinDigits: null,
587
+ * preferredMethod: null } }`) so the login surface stays usable even when the
588
+ * config endpoint is unreachable.
589
+ */
590
+ getLoginConfig(): Promise<BffLoginConfig>;
591
+ /**
592
+ * `POST /bff/pin/enroll` — bind a device PIN to the CURRENT strong session.
593
+ * Requires an authenticated session (the cookie travels via
594
+ * `credentials: 'include'`); the BFF requests an offline token, hashes the
595
+ * PIN, and sets the device cookie.
596
+ *
597
+ * NEVER throws — resolves a discriminated {@link DevicePinEnrollResult}:
598
+ * - 200 → `success`;
599
+ * - 401 → `unauthorized` (no session);
600
+ * - 403 → `forbidden` (a PIN-established session can't enrol);
601
+ * - 400 → `invalidPin` (bad format);
602
+ * - anything else (501 disabled / 502 grant failed) / network → `error`.
603
+ */
604
+ enrollDevicePin(request: BffDevicePinEnrollRequest): Promise<DevicePinEnrollResult>;
605
+ /**
606
+ * `POST /bff/pin/unlock` — re-establish a session from a remembered device.
607
+ * No prior session; the device cookie travels via `credentials: 'include'`.
608
+ *
609
+ * NEVER throws — resolves a discriminated {@link DevicePinUnlockResult}:
610
+ * - 200 + `{ user }` → `success` (a session cookie was set);
611
+ * - 401 → `invalid` (wrong PIN / unknown-or-revoked device);
612
+ * - 429 + JSON body → `locked` (device lockout; `Retry-After` parsed);
613
+ * - 429 + empty body → `rateLimited` (per-IP limiter; `Retry-After` parsed);
614
+ * - anything else / malformed 200 body / network → `error`.
615
+ */
616
+ unlockWithDevicePin(request: BffDevicePinUnlockRequest): Promise<DevicePinUnlockResult>;
617
+ /**
618
+ * `POST /bff/pin/disable` — drop the device PIN for the CURRENT session. The
619
+ * BFF revokes the offline token at Keycloak, deletes the device record, and
620
+ * clears the device cookie. Requires an authenticated session.
621
+ *
622
+ * NEVER throws — resolves `true` on a 2xx, `false` on anything else.
623
+ */
624
+ disableDevicePin(): Promise<boolean>;
625
+ /**
626
+ * Shared POST for the never-throw device-PIN calls: same-origin, cookie
627
+ * included, `X-BFF-Csrf` header attached. Returns the raw {@link HttpResponse}
628
+ * (status + body + headers) so the caller can route on it instead of throwing.
629
+ */
630
+ private postRaw;
414
631
  /**
415
632
  * Shared POST for every state-changing `/bff/*` call: same-origin, cookie
416
633
  * included, `X-BFF-Csrf` header attached. Throws a labelled error on non-2xx.
@@ -591,4 +808,4 @@ declare function normalizeTokenResponse(raw: RawTokenResponse): TokenResponse;
591
808
  */
592
809
  declare function tokenResponseToAuthTokens(response: TokenResponse, now?: number): AuthTokens;
593
810
 
594
- export { AuthTokens, type AuthorizationCodeBodyInput, type AuthorizationResponseLike, type AuthorizationUrlInput, BffAuthClient, type BffAuthClientOptions, type BffForgotPasswordRequest, type BffLoginRequest, type BffRegisterRequest, type BffResetPasswordRequest, type BffUser, type BiometricFlagStore, BiometricGate, type BiometricGateLike, type BiometricGateOptions, BrowserStorageTokenStorage, type BrowserStorageTokenStorageOptions, CookieTokenStorage, HttpClient, InMemoryTokenStorage, KeycloakRoles, type KeycloakUserInfo, type LocalAuthLike, type NormalizedUser, RawTokenResponse, type RefreshTokenBodyInput, type SecureStoreLike, SecureStoreTokenStorage, type SecureStoreTokenStorageOptions, type StorageLike, TokenResponse, TokenStorage, buildAuthorizationCodeBody, buildAuthorizationEndpoint, buildAuthorizationUrl, buildIssuerUrl, buildLogoutEndpoint, buildRefreshTokenBody, buildTokenEndpoint, buildUserInfoEndpoint, computeExpiresAt, decodeJwt, extractAuthCode, isKeycloakRole, isTokenExpired, normalizeKeycloakUser, normalizeTokenResponse, parseBaseUrlFromIssuer, parseRealmFromIssuer, tokenResponseToAuthTokens };
811
+ export { AuthTokens, type AuthorizationCodeBodyInput, type AuthorizationResponseLike, type AuthorizationUrlInput, BffAuthClient, type BffAuthClientOptions, type BffDevicePinEnrollRequest, type BffDevicePinUnlockRequest, type BffDeviceState, type BffForgotPasswordRequest, type BffLoginConfig, type BffLoginRequest, type BffOtpRequestRequest, type BffOtpRequestResult, type BffOtpVerifyRequest, type BffPinLoginRequest, type BffRegisterRequest, type BffResetPasswordRequest, type BffUser, type BiometricFlagStore, BiometricGate, type BiometricGateLike, type BiometricGateOptions, BrowserStorageTokenStorage, type BrowserStorageTokenStorageOptions, CookieTokenStorage, type DevicePinEnrollResult, type DevicePinUnlockResult, HttpClient, InMemoryTokenStorage, KeycloakRoles, type KeycloakUserInfo, type LocalAuthLike, type NormalizedUser, RawTokenResponse, type RefreshTokenBodyInput, type SecureStoreLike, SecureStoreTokenStorage, type SecureStoreTokenStorageOptions, type StorageLike, TokenResponse, TokenStorage, buildAuthorizationCodeBody, buildAuthorizationEndpoint, buildAuthorizationUrl, buildIssuerUrl, buildLogoutEndpoint, buildRefreshTokenBody, buildTokenEndpoint, buildUserInfoEndpoint, computeExpiresAt, decodeJwt, extractAuthCode, isKeycloakRole, isTokenExpired, normalizeKeycloakUser, normalizeTokenResponse, parseBaseUrlFromIssuer, parseRealmFromIssuer, tokenResponseToAuthTokens };
package/dist/index.js CHANGED
@@ -944,7 +944,8 @@ function createFetchHttpClient(fetchImpl) {
944
944
  return {
945
945
  status: response.status,
946
946
  ok: response.ok,
947
- data
947
+ data,
948
+ header: (name) => response.headers.get(name)
948
949
  };
949
950
  };
950
951
  }
@@ -1069,8 +1070,23 @@ var ENDPOINTS = {
1069
1070
  me: "/bff/me",
1070
1071
  register: "/bff/register",
1071
1072
  forgotPassword: "/bff/forgot-password",
1072
- resetPassword: "/bff/reset-password"
1073
+ resetPassword: "/bff/reset-password",
1074
+ otpRequest: "/bff/otp/request",
1075
+ otpVerify: "/bff/otp/verify",
1076
+ pinLogin: "/bff/pin/login",
1077
+ config: "/bff/config",
1078
+ pinEnroll: "/bff/pin/enroll",
1079
+ pinUnlock: "/bff/pin/unlock",
1080
+ pinDisable: "/bff/pin/disable"
1073
1081
  };
1082
+ var HTTP_OK = 200;
1083
+ var HTTP_MULTIPLE_CHOICES = 300;
1084
+ var HTTP_BAD_REQUEST = 400;
1085
+ var HTTP_UNAUTHORIZED = 401;
1086
+ var HTTP_FORBIDDEN = 403;
1087
+ var HTTP_TOO_MANY_REQUESTS = 429;
1088
+ var FALLBACK_METHODS = ["password"];
1089
+ var ALLOWED_PIN_DIGITS = [4, 6, 8];
1074
1090
  function isRecord(value) {
1075
1091
  return typeof value === "object" && value !== null;
1076
1092
  }
@@ -1081,6 +1097,91 @@ function extractUser(data) {
1081
1097
  const envelope = data;
1082
1098
  return isRecord(envelope.user) ? envelope.user : null;
1083
1099
  }
1100
+ function toOtpRequestResult(data) {
1101
+ if (!isRecord(data)) {
1102
+ return { success: true, expiresIn: 0, code: null };
1103
+ }
1104
+ return {
1105
+ success: typeof data.success === "boolean" ? data.success : true,
1106
+ expiresIn: typeof data.expiresIn === "number" ? data.expiresIn : 0,
1107
+ code: typeof data.code === "string" ? data.code : null
1108
+ };
1109
+ }
1110
+ var FALLBACK_LOGIN_CONFIG = {
1111
+ methods: [...FALLBACK_METHODS],
1112
+ registrationEnabled: false,
1113
+ deviceState: {
1114
+ rememberedUsername: null,
1115
+ hasPin: false,
1116
+ pinDigits: null,
1117
+ preferredMethod: null
1118
+ }
1119
+ };
1120
+ function readOptionalString(record, key) {
1121
+ const value = record[key];
1122
+ if (typeof value !== "string" || value === "") {
1123
+ return null;
1124
+ }
1125
+ return value;
1126
+ }
1127
+ function parseDeviceState(body) {
1128
+ const rawDigits = body.pinDigits;
1129
+ const hasValidDigits = typeof rawDigits === "number" && ALLOWED_PIN_DIGITS.includes(rawDigits);
1130
+ return {
1131
+ rememberedUsername: readOptionalString(body, "rememberedUsername"),
1132
+ hasPin: body.hasPin === true,
1133
+ pinDigits: hasValidDigits ? rawDigits : null,
1134
+ preferredMethod: readOptionalString(body, "preferredMethod")
1135
+ };
1136
+ }
1137
+ function parseMethods(body) {
1138
+ const raw = body.methods;
1139
+ if (!Array.isArray(raw)) {
1140
+ return [...FALLBACK_METHODS];
1141
+ }
1142
+ const parsed = raw.filter((value) => typeof value === "string" && value !== "").map((value) => value.toLowerCase());
1143
+ return parsed.length === 0 ? [...FALLBACK_METHODS] : Array.from(new Set(parsed));
1144
+ }
1145
+ function parseLoginConfig(data) {
1146
+ if (!isRecord(data)) {
1147
+ return FALLBACK_LOGIN_CONFIG;
1148
+ }
1149
+ return {
1150
+ methods: parseMethods(data),
1151
+ registrationEnabled: data.registrationEnabled === true,
1152
+ deviceState: parseDeviceState(data)
1153
+ };
1154
+ }
1155
+ function parseRetryAfter(response) {
1156
+ const raw = response.header?.("Retry-After");
1157
+ if (raw === null || raw === void 0) {
1158
+ return null;
1159
+ }
1160
+ const seconds = Number.parseInt(raw, 10);
1161
+ if (Number.isNaN(seconds) || seconds < 0) {
1162
+ return null;
1163
+ }
1164
+ return seconds;
1165
+ }
1166
+ function classifyTooManyRequests(response) {
1167
+ const retryAfterSeconds = parseRetryAfter(response);
1168
+ if (isRecord(response.data)) {
1169
+ return { status: "locked", retryAfterSeconds };
1170
+ }
1171
+ return { status: "rateLimited", retryAfterSeconds };
1172
+ }
1173
+ function classifyEnrollFailure(status) {
1174
+ if (status === HTTP_UNAUTHORIZED) {
1175
+ return { status: "unauthorized" };
1176
+ }
1177
+ if (status === HTTP_FORBIDDEN) {
1178
+ return { status: "forbidden" };
1179
+ }
1180
+ if (status === HTTP_BAD_REQUEST) {
1181
+ return { status: "invalidPin" };
1182
+ }
1183
+ return { status: "error" };
1184
+ }
1084
1185
  var BffAuthClient = class {
1085
1186
  constructor(options) {
1086
1187
  this.http = options.http;
@@ -1151,22 +1252,171 @@ var BffAuthClient = class {
1151
1252
  await this.postState(ENDPOINTS.resetPassword, request, "reset-password");
1152
1253
  }
1153
1254
  /**
1154
- * Shared POST for every state-changing `/bff/*` call: same-origin, cookie
1155
- * included, `X-BFF-Csrf` header attached. Throws a labelled error on non-2xx.
1255
+ * `POST /bff/otp/request` the BFF proxies to TenantService, which generates
1256
+ * a short-TTL code and emails it.
1257
+ *
1258
+ * The endpoint is anti-enumeration: a `200` is the normal path whether or not
1259
+ * the identifier is registered. This method therefore **returns** the relayed
1260
+ * `{ success, expiresIn, code }` body (so the UI can show the expiry) rather
1261
+ * than treating a 200 as opaque. It still throws on a non-2xx — a `501`
1262
+ * (OTP not enabled) or `502` (upstream down) is a real failure to surface.
1156
1263
  */
1157
- async postState(path, body, label) {
1264
+ async requestOtp(request) {
1265
+ const data = await this.postState(ENDPOINTS.otpRequest, request, "otp-request");
1266
+ return toOtpRequestResult(data);
1267
+ }
1268
+ /**
1269
+ * `POST /bff/otp/verify` — the BFF runs the OTP direct-grant against Keycloak
1270
+ * server-side, stores the tokens in its Redis vault, and sets the httpOnly
1271
+ * session cookie. Returns the sanitised user, exactly like `login`. Throws on
1272
+ * a non-2xx (e.g. `401` for a bad / expired code).
1273
+ */
1274
+ async verifyOtp(request) {
1275
+ const data = await this.postState(ENDPOINTS.otpVerify, request, "otp-verify");
1276
+ const user = extractUser(data);
1277
+ if (user === null) {
1278
+ throw new Error("otp-verify: BFF response missing user");
1279
+ }
1280
+ return user;
1281
+ }
1282
+ /**
1283
+ * `POST /bff/pin/login` — the BFF runs the event-scoped PIN direct-grant
1284
+ * against Keycloak server-side (the `(event, pin)` pair resolves to the
1285
+ * staff member's KC account + event-scoped role), stores the tokens in its
1286
+ * Redis vault, and sets the httpOnly session cookie. Returns the sanitised
1287
+ * user, exactly like `login` / `verifyOtp`. Throws on a non-2xx — `401` for
1288
+ * a bad / expired / locked-out PIN or an unknown event, `501` when PIN login
1289
+ * is not an enabled method for this BFF.
1290
+ */
1291
+ async pinLogin(request) {
1292
+ const data = await this.postState(ENDPOINTS.pinLogin, request, "pin-login");
1293
+ const user = extractUser(data);
1294
+ if (user === null) {
1295
+ throw new Error("pin-login: BFF response missing user");
1296
+ }
1297
+ return user;
1298
+ }
1299
+ /**
1300
+ * `GET /bff/config` — which login methods this BFF advertises, whether
1301
+ * registration is enabled, and the optional per-device state (remembered
1302
+ * username + device PIN). Unauthenticated, no CSRF (GET).
1303
+ *
1304
+ * NEVER throws: on a non-2xx, a network error, or a malformed body it returns
1305
+ * a safe fallback (`{ methods: ['password'], registrationEnabled: false,
1306
+ * deviceState: { rememberedUsername: null, hasPin: false, pinDigits: null,
1307
+ * preferredMethod: null } }`) so the login surface stays usable even when the
1308
+ * config endpoint is unreachable.
1309
+ */
1310
+ async getLoginConfig() {
1311
+ try {
1312
+ const response = await this.http({
1313
+ url: `${this.baseUrl}${ENDPOINTS.config}`,
1314
+ method: "GET",
1315
+ headers: { Accept: JSON_CONTENT_TYPE },
1316
+ credentials: "include"
1317
+ });
1318
+ if (!response.ok) {
1319
+ return FALLBACK_LOGIN_CONFIG;
1320
+ }
1321
+ return parseLoginConfig(response.data);
1322
+ } catch {
1323
+ return FALLBACK_LOGIN_CONFIG;
1324
+ }
1325
+ }
1326
+ /**
1327
+ * `POST /bff/pin/enroll` — bind a device PIN to the CURRENT strong session.
1328
+ * Requires an authenticated session (the cookie travels via
1329
+ * `credentials: 'include'`); the BFF requests an offline token, hashes the
1330
+ * PIN, and sets the device cookie.
1331
+ *
1332
+ * NEVER throws — resolves a discriminated {@link DevicePinEnrollResult}:
1333
+ * - 200 → `success`;
1334
+ * - 401 → `unauthorized` (no session);
1335
+ * - 403 → `forbidden` (a PIN-established session can't enrol);
1336
+ * - 400 → `invalidPin` (bad format);
1337
+ * - anything else (501 disabled / 502 grant failed) / network → `error`.
1338
+ */
1339
+ async enrollDevicePin(request) {
1340
+ try {
1341
+ const response = await this.postRaw(ENDPOINTS.pinEnroll, request);
1342
+ if (response.ok) {
1343
+ return { status: "success" };
1344
+ }
1345
+ return classifyEnrollFailure(response.status);
1346
+ } catch {
1347
+ return { status: "error" };
1348
+ }
1349
+ }
1350
+ /**
1351
+ * `POST /bff/pin/unlock` — re-establish a session from a remembered device.
1352
+ * No prior session; the device cookie travels via `credentials: 'include'`.
1353
+ *
1354
+ * NEVER throws — resolves a discriminated {@link DevicePinUnlockResult}:
1355
+ * - 200 + `{ user }` → `success` (a session cookie was set);
1356
+ * - 401 → `invalid` (wrong PIN / unknown-or-revoked device);
1357
+ * - 429 + JSON body → `locked` (device lockout; `Retry-After` parsed);
1358
+ * - 429 + empty body → `rateLimited` (per-IP limiter; `Retry-After` parsed);
1359
+ * - anything else / malformed 200 body / network → `error`.
1360
+ */
1361
+ async unlockWithDevicePin(request) {
1362
+ try {
1363
+ const response = await this.postRaw(ENDPOINTS.pinUnlock, request);
1364
+ const isSuccess = response.status >= HTTP_OK && response.status < HTTP_MULTIPLE_CHOICES;
1365
+ if (isSuccess) {
1366
+ const user = extractUser(response.data);
1367
+ return user === null ? { status: "error" } : { status: "success", user };
1368
+ }
1369
+ if (response.status === HTTP_UNAUTHORIZED) {
1370
+ return { status: "invalid" };
1371
+ }
1372
+ if (response.status === HTTP_TOO_MANY_REQUESTS) {
1373
+ return classifyTooManyRequests(response);
1374
+ }
1375
+ return { status: "error" };
1376
+ } catch {
1377
+ return { status: "error" };
1378
+ }
1379
+ }
1380
+ /**
1381
+ * `POST /bff/pin/disable` — drop the device PIN for the CURRENT session. The
1382
+ * BFF revokes the offline token at Keycloak, deletes the device record, and
1383
+ * clears the device cookie. Requires an authenticated session.
1384
+ *
1385
+ * NEVER throws — resolves `true` on a 2xx, `false` on anything else.
1386
+ */
1387
+ async disableDevicePin() {
1388
+ try {
1389
+ const response = await this.postRaw(ENDPOINTS.pinDisable, void 0);
1390
+ return response.ok;
1391
+ } catch {
1392
+ return false;
1393
+ }
1394
+ }
1395
+ /**
1396
+ * Shared POST for the never-throw device-PIN calls: same-origin, cookie
1397
+ * included, `X-BFF-Csrf` header attached. Returns the raw {@link HttpResponse}
1398
+ * (status + body + headers) so the caller can route on it instead of throwing.
1399
+ */
1400
+ postRaw(path, body) {
1158
1401
  const headers = {
1159
1402
  "Content-Type": JSON_CONTENT_TYPE,
1160
1403
  Accept: JSON_CONTENT_TYPE,
1161
1404
  [CSRF_HEADER]: CSRF_HEADER_VALUE
1162
1405
  };
1163
- const response = await this.http({
1406
+ return this.http({
1164
1407
  url: `${this.baseUrl}${path}`,
1165
1408
  method: "POST",
1166
1409
  headers,
1167
1410
  body: body === void 0 ? void 0 : JSON.stringify(body),
1168
1411
  credentials: "include"
1169
1412
  });
1413
+ }
1414
+ /**
1415
+ * Shared POST for every state-changing `/bff/*` call: same-origin, cookie
1416
+ * included, `X-BFF-Csrf` header attached. Throws a labelled error on non-2xx.
1417
+ */
1418
+ async postState(path, body, label) {
1419
+ const response = await this.postRaw(path, body);
1170
1420
  if (!response.ok) {
1171
1421
  throw new Error(`${label} failed with status ${String(response.status)}`);
1172
1422
  }