@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/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.3.0 (2026-06-02)
4
+
5
+ Additive release for unified-login Increment 3 — device-bound PIN unlock +
6
+ `/bff/config`. Extracts kefi-web's proven device-PIN client (the "second use"),
7
+ giving `@dloizides/auth-web` 1.4.0 a shared same-origin client with
8
+ **discriminated** results. The published `login` / `pinLogin` throw an opaque
9
+ error on any non-2xx, collapsing 401 (wrong PIN) and 429 (locked / rate-limited);
10
+ the new device-PIN methods never throw and route on a discriminated `status`
11
+ instead. No breaking changes.
12
+
13
+ ### Added
14
+
15
+ - `BffAuthClient.getLoginConfig()` → `GET /bff/config`. Returns the advertised
16
+ login methods (lowercase, de-duplicated), `registrationEnabled`, and the
17
+ optional per-device state (`rememberedUsername`, `hasPin`, `pinDigits`,
18
+ `preferredMethod`). NEVER throws — on a non-2xx, a network error, or a
19
+ malformed body it returns a safe fallback (`['password']`, registration off,
20
+ empty device-state). Unauthenticated, no CSRF (GET).
21
+ - `BffAuthClient.enrollDevicePin({ pin, digits })` → `POST /bff/pin/enroll`.
22
+ Discriminated `DevicePinEnrollResult`: `success` (200), `unauthorized` (401),
23
+ `forbidden` (403, PIN-established session), `invalidPin` (400), `error`
24
+ (anything else / network). Never throws.
25
+ - `BffAuthClient.unlockWithDevicePin({ pin })` → `POST /bff/pin/unlock`.
26
+ Discriminated `DevicePinUnlockResult`: `success` + `user` (200), `invalid`
27
+ (401), `locked` (429 **with** a JSON body — device lockout), `rateLimited`
28
+ (429 with an **empty** body — per-IP limiter), `error` (anything else /
29
+ malformed 200 / network). Both 429 variants carry the parsed `Retry-After`
30
+ seconds (`null` when absent / unparseable). Never throws. Distinguishing
31
+ `locked` vs `rateLimited` lets UIs poll through rate limits but show lockout
32
+ copy.
33
+ - `BffAuthClient.disableDevicePin()` → `POST /bff/pin/disable`. `true` on a 2xx,
34
+ `false` otherwise. Never throws.
35
+ - Types: `BffLoginConfig`, `BffDeviceState`, `BffDevicePinEnrollRequest`,
36
+ `BffDevicePinUnlockRequest`, `DevicePinUnlockResult`, `DevicePinEnrollResult`.
37
+ - `HttpResponse.header?(name)` — an optional, case-insensitive response-header
38
+ accessor (the bundled `createFetchHttpClient` always provides it). Needed to
39
+ read `Retry-After` off the unlock `429`. Backward-compatible (optional).
40
+
41
+ ## 3.2.0 (2026-05-22)
42
+
43
+ Additive release for Phase 3d of the unified-auth plan — event-scoped PIN
44
+ login. Extends `BffAuthClient` with the browser-facing PIN call so the new
45
+ `<PinForm>` in `@dloizides/auth-web` has a same-origin client. No breaking
46
+ changes.
47
+
48
+ ### Added
49
+
50
+ - `BffAuthClient.pinLogin({ pin, eventExternalId })` → `POST /bff/pin/login`.
51
+ The BFF runs the event-scoped PIN direct-grant against Keycloak server-side
52
+ (the `(event, pin)` pair resolves to the staff member's KC account + their
53
+ event-scoped role) and sets the httpOnly session cookie. Returns the
54
+ sanitised `BffUser`, exactly like `login` / `verifyOtp`. Throws on a non-2xx
55
+ (`401` for a bad / expired / locked-out PIN or an unknown event, `501` when
56
+ PIN login is not an enabled method). Carries the `X-BFF-Csrf` header like
57
+ every other state-changing call. No `username` / `password` ever leaves the
58
+ browser.
59
+ - Type: `BffPinLoginRequest`.
60
+
61
+ ## 3.1.0 (2026-05-22)
62
+
63
+ Additive release for Phase 2d of the unified-auth plan — email-OTP. Extends
64
+ `BffAuthClient` with the two browser-facing OTP calls so the new `<OtpForm>` in
65
+ `@dloizides/auth-web` has a same-origin client. No breaking changes.
66
+
67
+ ### Added
68
+
69
+ - `BffAuthClient.requestOtp({ identifier })` → `POST /bff/otp/request`. The BFF
70
+ proxies to TenantService, which emails a short-TTL code. The endpoint is
71
+ anti-enumeration (a `200` is the normal path), so the method **returns** the
72
+ relayed `{ success, expiresIn, code }` body — the UI uses `expiresIn` for a
73
+ countdown. It still throws on a non-2xx (`501` OTP not enabled, `502` upstream
74
+ down). Carries the `X-BFF-Csrf` header like every other state-changing call.
75
+ - `BffAuthClient.verifyOtp({ username, otp })` → `POST /bff/otp/verify`. The BFF
76
+ runs the OTP direct-grant against Keycloak server-side and sets the httpOnly
77
+ session cookie. Returns the sanitised `BffUser`, exactly like `login`. Throws
78
+ on a non-2xx (e.g. `401` for a bad / expired code).
79
+ - Types: `BffOtpRequestRequest`, `BffOtpVerifyRequest`, `BffOtpRequestResult`.
80
+
3
81
  ## 3.0.0 (2026-05-19)
4
82
 
5
83
  Major release for Phase 2 of the identity-hardening initiative. Adds the
package/README.md CHANGED
@@ -151,6 +151,43 @@ await bff.resetPassword({ token, newPassword });
151
151
  await bff.logout();
152
152
  ```
153
153
 
154
+ ### Device-bound PIN unlock (v3.3 — unified-login Increment 3)
155
+
156
+ A returning, remembered-device, logged-OUT user can re-establish a session with a
157
+ 4/6/8-digit device PIN. Unlike `login` / `pinLogin` (which throw an opaque error on
158
+ any non-2xx), the device-PIN methods **never throw** — they return discriminated
159
+ results so the UI can route on `status`.
160
+
161
+ ```ts
162
+ // Which methods does this BFF advertise + does this device remember a PIN?
163
+ // NEVER throws — safe fallback (['password'], registration off) on any failure.
164
+ const config = await bff.getLoginConfig();
165
+ if (config.deviceState.hasPin) {
166
+ /* render the device-PIN unlock screen */
167
+ }
168
+
169
+ // Bind a PIN to the current strong session.
170
+ const enroll = await bff.enrollDevicePin({ pin: '482913', digits: 6 });
171
+ // status: 'success' | 'unauthorized' | 'forbidden' | 'invalidPin' | 'error'
172
+
173
+ // Re-establish a session from a remembered device.
174
+ const unlock = await bff.unlockWithDevicePin({ pin: '482913' });
175
+ switch (unlock.status) {
176
+ case 'success': /* unlock.user — a session cookie was set */ break;
177
+ case 'invalid': /* wrong PIN / unknown-or-revoked device */ break;
178
+ case 'locked': /* device lockout — unlock.retryAfterSeconds */ break;
179
+ case 'rateLimited': /* per-IP limiter — may poll through it */ break;
180
+ case 'error': /* network / unexpected */ break;
181
+ }
182
+
183
+ await bff.disableDevicePin(); // true on success, never throws
184
+ ```
185
+
186
+ The two `429` outcomes are distinct on purpose: the per-IP `BffAuth` limiter
187
+ answers `429` with an **empty** body (`rateLimited` — a UI may poll through it),
188
+ whereas the device-PIN lockout answers `429` with a JSON `{ error }` body + a
189
+ `Retry-After` header (`locked` — show a "try again in N s" message).
190
+
154
191
  The direct-KC `AuthClient` / ROPC surface below is retained for consumers not
155
192
  yet on a BFF; it is deprecated and removed once every app has migrated.
156
193
 
@@ -191,7 +228,7 @@ auth.on('sessionExpired', () => {
191
228
 
192
229
  ### Core (`@dloizides/auth-client`)
193
230
 
194
- - `BffAuthClient` — same-origin client for a per-app Backend-For-Frontend. `login()`, `logout()`, `getCurrentUser()`, `register()`, `forgotPassword()`, `resetPassword()`. No token handling — the BFF owns tokens, the browser owns only an httpOnly cookie. **The recommended auth surface (v3).**
231
+ - `BffAuthClient` — same-origin client for a per-app Backend-For-Frontend. `login()`, `logout()`, `getCurrentUser()`, `register()`, `forgotPassword()`, `resetPassword()`, `requestOtp()`, `verifyOtp()`, `pinLogin()`, plus the v3.3 device-PIN surface: `getLoginConfig()`, `enrollDevicePin()`, `unlockWithDevicePin()`, `disableDevicePin()` (discriminated, never-throwing results). No token handling — the BFF owns tokens, the browser owns only an httpOnly cookie. **The recommended auth surface (v3).**
195
232
  - `AuthClient` — realm-aware orchestrator. `init()`, `refresh()`, `loginWithOtp()`, `loginWithPassword()`, `logout({ everywhere })`, `requestPasswordReset()`, `confirmPasswordReset()`, plus the v1 surface (`getAccessToken`, `getTokens`, `setTokens`, `clearTokens`, `buildAuthorizationUrl`, etc.). Direct-KC ROPC; deprecated in favour of `BffAuthClient`.
196
233
  - `AuthApiClient` — typed wrapper for IdentityService auth endpoints.
197
234
  - `AuthEventEmitter` — `sessionExpired` event.
@@ -1,4 +1,4 @@
1
- import { H as HttpClient, T as TokenResponse } from './TokenResponse-CY1CaU2l.mjs';
1
+ import { H as HttpClient, T as TokenResponse } from './TokenResponse-BkIDjenX.mjs';
2
2
 
3
3
  /**
4
4
  * Tiny dependency-free event emitter for auth lifecycle events.
@@ -457,4 +457,4 @@ declare class AuthClient {
457
457
  private resolveScope;
458
458
  }
459
459
 
460
- export { AuthApiClient as A, type DirectKcOptions as D, type ForgotPasswordRequest as F, type InactivityStore as I, type LoginOptions as L, type OtpLoginRequest as O, type PasswordLoginRequest as P, type ResetPasswordRequest as R, type TokenStorage as T, type AuthSessionInfo as a, AuthClient as b, type AuthTokens as c, type AuthApiClientOptions as d, type AuthClientCollaborators as e, type AuthClientConfig as f, type AuthClientFromIssuerInput as g, AuthEventEmitter as h, type AuthEventListener as i, type AuthEventName as j, type AuthEventUnsubscribe as k, InactivityTracker as l, type InactivityTrackerOptions as m, type LogoutOptions as n, type RawAuthLoginResponse as o, type RefreshFn as p, RefreshInterceptor as q, type RefreshInterceptorOptions as r };
460
+ export { AuthApiClient as A, type DirectKcOptions as D, type ForgotPasswordRequest as F, type InactivityStore as I, type LoginOptions as L, type OtpLoginRequest as O, type PasswordLoginRequest as P, type RawAuthLoginResponse as R, type TokenStorage as T, type AuthApiClientOptions as a, AuthClient as b, type AuthClientCollaborators as c, type AuthClientConfig as d, type AuthClientFromIssuerInput as e, AuthEventEmitter as f, type AuthEventListener as g, type AuthEventName as h, type AuthEventUnsubscribe as i, type AuthSessionInfo as j, type AuthTokens as k, InactivityTracker as l, type InactivityTrackerOptions as m, type LogoutOptions as n, type RefreshFn as o, RefreshInterceptor as p, type RefreshInterceptorOptions as q, type ResetPasswordRequest as r };
@@ -1,4 +1,4 @@
1
- import { H as HttpClient, T as TokenResponse } from './TokenResponse-CY1CaU2l.js';
1
+ import { H as HttpClient, T as TokenResponse } from './TokenResponse-BkIDjenX.js';
2
2
 
3
3
  /**
4
4
  * Tiny dependency-free event emitter for auth lifecycle events.
@@ -457,4 +457,4 @@ declare class AuthClient {
457
457
  private resolveScope;
458
458
  }
459
459
 
460
- export { AuthApiClient as A, type DirectKcOptions as D, type ForgotPasswordRequest as F, type InactivityStore as I, type LoginOptions as L, type OtpLoginRequest as O, type PasswordLoginRequest as P, type ResetPasswordRequest as R, type TokenStorage as T, type AuthSessionInfo as a, AuthClient as b, type AuthTokens as c, type AuthApiClientOptions as d, type AuthClientCollaborators as e, type AuthClientConfig as f, type AuthClientFromIssuerInput as g, AuthEventEmitter as h, type AuthEventListener as i, type AuthEventName as j, type AuthEventUnsubscribe as k, InactivityTracker as l, type InactivityTrackerOptions as m, type LogoutOptions as n, type RawAuthLoginResponse as o, type RefreshFn as p, RefreshInterceptor as q, type RefreshInterceptorOptions as r };
460
+ export { AuthApiClient as A, type DirectKcOptions as D, type ForgotPasswordRequest as F, type InactivityStore as I, type LoginOptions as L, type OtpLoginRequest as O, type PasswordLoginRequest as P, type RawAuthLoginResponse as R, type TokenStorage as T, type AuthApiClientOptions as a, AuthClient as b, type AuthClientCollaborators as c, type AuthClientConfig as d, type AuthClientFromIssuerInput as e, AuthEventEmitter as f, type AuthEventListener as g, type AuthEventName as h, type AuthEventUnsubscribe as i, type AuthSessionInfo as j, type AuthTokens as k, InactivityTracker as l, type InactivityTrackerOptions as m, type LogoutOptions as n, type RefreshFn as o, RefreshInterceptor as p, type RefreshInterceptorOptions as q, type ResetPasswordRequest as r };
@@ -20,6 +20,13 @@ interface HttpResponse {
20
20
  ok: boolean;
21
21
  /** Parsed body (already JSON-decoded). `undefined` for 204 / empty bodies. */
22
22
  data?: unknown;
23
+ /**
24
+ * Read a single response header by (case-insensitive) name, or `null` when
25
+ * absent. Optional so existing transports stay source-compatible; the bundled
26
+ * `createFetchHttpClient` always provides it. The device-PIN unlock flow needs
27
+ * it to read `Retry-After` off a `429`.
28
+ */
29
+ header?: (name: string) => string | null;
23
30
  }
24
31
  type HttpClient = (request: HttpRequest) => Promise<HttpResponse>;
25
32
  /**
@@ -20,6 +20,13 @@ interface HttpResponse {
20
20
  ok: boolean;
21
21
  /** Parsed body (already JSON-decoded). `undefined` for 204 / empty bodies. */
22
22
  data?: unknown;
23
+ /**
24
+ * Read a single response header by (case-insensitive) name, or `null` when
25
+ * absent. Optional so existing transports stay source-compatible; the bundled
26
+ * `createFetchHttpClient` always provides it. The device-PIN unlock flow needs
27
+ * it to read `Retry-After` off a `429`.
28
+ */
29
+ header?: (name: string) => string | null;
23
30
  }
24
31
  type HttpClient = (request: HttpRequest) => Promise<HttpResponse>;
25
32
  /**
package/dist/index.d.mts CHANGED
@@ -1,8 +1,8 @@
1
- import { T as TokenStorage, c as AuthTokens } from './AuthClient-BGr8L03W.mjs';
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-BGr8L03W.mjs';
1
+ import { T as TokenStorage, k as AuthTokens } from './AuthClient-3lu6Y1bY.mjs';
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-3lu6Y1bY.mjs';
3
3
  export { ExchangeAuthorizationCodeInput, FetchDiscoveryDocumentInput, OidcDiscoveryDocument, PkcePair, RefreshAccessTokenInput, clearDiscoveryCache, deriveCodeChallenge, exchangeAuthorizationCode, fetchDiscoveryDocument, generateCodeVerifier, generatePkcePair, refreshAccessToken } from './oidc/index.mjs';
4
- import { H as HttpClient, R as RawTokenResponse, T as TokenResponse } from './TokenResponse-CY1CaU2l.mjs';
5
- export { a as HttpRequest, b as HttpResponse, c as createFetchHttpClient } from './TokenResponse-CY1CaU2l.mjs';
4
+ import { H as HttpClient, R as RawTokenResponse, T as TokenResponse } from './TokenResponse-BkIDjenX.mjs';
5
+ export { a as HttpRequest, b as HttpResponse, c as createFetchHttpClient } from './TokenResponse-BkIDjenX.mjs';
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 };