@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 +78 -0
- package/README.md +38 -1
- package/dist/{AuthClient-BGr8L03W.d.mts → AuthClient-3lu6Y1bY.d.mts} +2 -2
- package/dist/{AuthClient-D95OMajD.d.ts → AuthClient-Bb7N2shJ.d.ts} +2 -2
- package/dist/{TokenResponse-CY1CaU2l.d.mts → TokenResponse-BkIDjenX.d.mts} +7 -0
- package/dist/{TokenResponse-CY1CaU2l.d.ts → TokenResponse-BkIDjenX.d.ts} +7 -0
- package/dist/index.d.mts +222 -5
- package/dist/index.d.ts +222 -5
- package/dist/index.js +256 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +256 -6
- package/dist/index.mjs.map +1 -1
- package/dist/oidc/index.d.mts +1 -1
- package/dist/oidc/index.d.ts +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/package.json +116 -116
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { T as TokenStorage,
|
|
2
|
-
export { A as AuthApiClient,
|
|
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-
|
|
5
|
-
export { a as HttpRequest, b as HttpResponse, c as createFetchHttpClient } from './TokenResponse-
|
|
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
|
-
*
|
|
1155
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
}
|