@dloizides/auth-client 3.2.1 → 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.mjs CHANGED
@@ -942,7 +942,8 @@ function createFetchHttpClient(fetchImpl) {
942
942
  return {
943
943
  status: response.status,
944
944
  ok: response.ok,
945
- data
945
+ data,
946
+ header: (name) => response.headers.get(name)
946
947
  };
947
948
  };
948
949
  }
@@ -1070,8 +1071,20 @@ var ENDPOINTS = {
1070
1071
  resetPassword: "/bff/reset-password",
1071
1072
  otpRequest: "/bff/otp/request",
1072
1073
  otpVerify: "/bff/otp/verify",
1073
- pinLogin: "/bff/pin/login"
1074
+ pinLogin: "/bff/pin/login",
1075
+ config: "/bff/config",
1076
+ pinEnroll: "/bff/pin/enroll",
1077
+ pinUnlock: "/bff/pin/unlock",
1078
+ pinDisable: "/bff/pin/disable"
1074
1079
  };
1080
+ var HTTP_OK = 200;
1081
+ var HTTP_MULTIPLE_CHOICES = 300;
1082
+ var HTTP_BAD_REQUEST = 400;
1083
+ var HTTP_UNAUTHORIZED = 401;
1084
+ var HTTP_FORBIDDEN = 403;
1085
+ var HTTP_TOO_MANY_REQUESTS = 429;
1086
+ var FALLBACK_METHODS = ["password"];
1087
+ var ALLOWED_PIN_DIGITS = [4, 6, 8];
1075
1088
  function isRecord(value) {
1076
1089
  return typeof value === "object" && value !== null;
1077
1090
  }
@@ -1092,6 +1105,81 @@ function toOtpRequestResult(data) {
1092
1105
  code: typeof data.code === "string" ? data.code : null
1093
1106
  };
1094
1107
  }
1108
+ var FALLBACK_LOGIN_CONFIG = {
1109
+ methods: [...FALLBACK_METHODS],
1110
+ registrationEnabled: false,
1111
+ deviceState: {
1112
+ rememberedUsername: null,
1113
+ hasPin: false,
1114
+ pinDigits: null,
1115
+ preferredMethod: null
1116
+ }
1117
+ };
1118
+ function readOptionalString(record, key) {
1119
+ const value = record[key];
1120
+ if (typeof value !== "string" || value === "") {
1121
+ return null;
1122
+ }
1123
+ return value;
1124
+ }
1125
+ function parseDeviceState(body) {
1126
+ const rawDigits = body.pinDigits;
1127
+ const hasValidDigits = typeof rawDigits === "number" && ALLOWED_PIN_DIGITS.includes(rawDigits);
1128
+ return {
1129
+ rememberedUsername: readOptionalString(body, "rememberedUsername"),
1130
+ hasPin: body.hasPin === true,
1131
+ pinDigits: hasValidDigits ? rawDigits : null,
1132
+ preferredMethod: readOptionalString(body, "preferredMethod")
1133
+ };
1134
+ }
1135
+ function parseMethods(body) {
1136
+ const raw = body.methods;
1137
+ if (!Array.isArray(raw)) {
1138
+ return [...FALLBACK_METHODS];
1139
+ }
1140
+ const parsed = raw.filter((value) => typeof value === "string" && value !== "").map((value) => value.toLowerCase());
1141
+ return parsed.length === 0 ? [...FALLBACK_METHODS] : Array.from(new Set(parsed));
1142
+ }
1143
+ function parseLoginConfig(data) {
1144
+ if (!isRecord(data)) {
1145
+ return FALLBACK_LOGIN_CONFIG;
1146
+ }
1147
+ return {
1148
+ methods: parseMethods(data),
1149
+ registrationEnabled: data.registrationEnabled === true,
1150
+ deviceState: parseDeviceState(data)
1151
+ };
1152
+ }
1153
+ function parseRetryAfter(response) {
1154
+ const raw = response.header?.("Retry-After");
1155
+ if (raw === null || raw === void 0) {
1156
+ return null;
1157
+ }
1158
+ const seconds = Number.parseInt(raw, 10);
1159
+ if (Number.isNaN(seconds) || seconds < 0) {
1160
+ return null;
1161
+ }
1162
+ return seconds;
1163
+ }
1164
+ function classifyTooManyRequests(response) {
1165
+ const retryAfterSeconds = parseRetryAfter(response);
1166
+ if (isRecord(response.data)) {
1167
+ return { status: "locked", retryAfterSeconds };
1168
+ }
1169
+ return { status: "rateLimited", retryAfterSeconds };
1170
+ }
1171
+ function classifyEnrollFailure(status) {
1172
+ if (status === HTTP_UNAUTHORIZED) {
1173
+ return { status: "unauthorized" };
1174
+ }
1175
+ if (status === HTTP_FORBIDDEN) {
1176
+ return { status: "forbidden" };
1177
+ }
1178
+ if (status === HTTP_BAD_REQUEST) {
1179
+ return { status: "invalidPin" };
1180
+ }
1181
+ return { status: "error" };
1182
+ }
1095
1183
  var BffAuthClient = class {
1096
1184
  constructor(options) {
1097
1185
  this.http = options.http;
@@ -1207,22 +1295,126 @@ var BffAuthClient = class {
1207
1295
  return user;
1208
1296
  }
1209
1297
  /**
1210
- * Shared POST for every state-changing `/bff/*` call: same-origin, cookie
1211
- * included, `X-BFF-Csrf` header attached. Throws a labelled error on non-2xx.
1298
+ * `GET /bff/config` which login methods this BFF advertises, whether
1299
+ * registration is enabled, and the optional per-device state (remembered
1300
+ * username + device PIN). Unauthenticated, no CSRF (GET).
1301
+ *
1302
+ * NEVER throws: on a non-2xx, a network error, or a malformed body it returns
1303
+ * a safe fallback (`{ methods: ['password'], registrationEnabled: false,
1304
+ * deviceState: { rememberedUsername: null, hasPin: false, pinDigits: null,
1305
+ * preferredMethod: null } }`) so the login surface stays usable even when the
1306
+ * config endpoint is unreachable.
1212
1307
  */
1213
- async postState(path, body, label) {
1308
+ async getLoginConfig() {
1309
+ try {
1310
+ const response = await this.http({
1311
+ url: `${this.baseUrl}${ENDPOINTS.config}`,
1312
+ method: "GET",
1313
+ headers: { Accept: JSON_CONTENT_TYPE },
1314
+ credentials: "include"
1315
+ });
1316
+ if (!response.ok) {
1317
+ return FALLBACK_LOGIN_CONFIG;
1318
+ }
1319
+ return parseLoginConfig(response.data);
1320
+ } catch {
1321
+ return FALLBACK_LOGIN_CONFIG;
1322
+ }
1323
+ }
1324
+ /**
1325
+ * `POST /bff/pin/enroll` — bind a device PIN to the CURRENT strong session.
1326
+ * Requires an authenticated session (the cookie travels via
1327
+ * `credentials: 'include'`); the BFF requests an offline token, hashes the
1328
+ * PIN, and sets the device cookie.
1329
+ *
1330
+ * NEVER throws — resolves a discriminated {@link DevicePinEnrollResult}:
1331
+ * - 200 → `success`;
1332
+ * - 401 → `unauthorized` (no session);
1333
+ * - 403 → `forbidden` (a PIN-established session can't enrol);
1334
+ * - 400 → `invalidPin` (bad format);
1335
+ * - anything else (501 disabled / 502 grant failed) / network → `error`.
1336
+ */
1337
+ async enrollDevicePin(request) {
1338
+ try {
1339
+ const response = await this.postRaw(ENDPOINTS.pinEnroll, request);
1340
+ if (response.ok) {
1341
+ return { status: "success" };
1342
+ }
1343
+ return classifyEnrollFailure(response.status);
1344
+ } catch {
1345
+ return { status: "error" };
1346
+ }
1347
+ }
1348
+ /**
1349
+ * `POST /bff/pin/unlock` — re-establish a session from a remembered device.
1350
+ * No prior session; the device cookie travels via `credentials: 'include'`.
1351
+ *
1352
+ * NEVER throws — resolves a discriminated {@link DevicePinUnlockResult}:
1353
+ * - 200 + `{ user }` → `success` (a session cookie was set);
1354
+ * - 401 → `invalid` (wrong PIN / unknown-or-revoked device);
1355
+ * - 429 + JSON body → `locked` (device lockout; `Retry-After` parsed);
1356
+ * - 429 + empty body → `rateLimited` (per-IP limiter; `Retry-After` parsed);
1357
+ * - anything else / malformed 200 body / network → `error`.
1358
+ */
1359
+ async unlockWithDevicePin(request) {
1360
+ try {
1361
+ const response = await this.postRaw(ENDPOINTS.pinUnlock, request);
1362
+ const isSuccess = response.status >= HTTP_OK && response.status < HTTP_MULTIPLE_CHOICES;
1363
+ if (isSuccess) {
1364
+ const user = extractUser(response.data);
1365
+ return user === null ? { status: "error" } : { status: "success", user };
1366
+ }
1367
+ if (response.status === HTTP_UNAUTHORIZED) {
1368
+ return { status: "invalid" };
1369
+ }
1370
+ if (response.status === HTTP_TOO_MANY_REQUESTS) {
1371
+ return classifyTooManyRequests(response);
1372
+ }
1373
+ return { status: "error" };
1374
+ } catch {
1375
+ return { status: "error" };
1376
+ }
1377
+ }
1378
+ /**
1379
+ * `POST /bff/pin/disable` — drop the device PIN for the CURRENT session. The
1380
+ * BFF revokes the offline token at Keycloak, deletes the device record, and
1381
+ * clears the device cookie. Requires an authenticated session.
1382
+ *
1383
+ * NEVER throws — resolves `true` on a 2xx, `false` on anything else.
1384
+ */
1385
+ async disableDevicePin() {
1386
+ try {
1387
+ const response = await this.postRaw(ENDPOINTS.pinDisable, void 0);
1388
+ return response.ok;
1389
+ } catch {
1390
+ return false;
1391
+ }
1392
+ }
1393
+ /**
1394
+ * Shared POST for the never-throw device-PIN calls: same-origin, cookie
1395
+ * included, `X-BFF-Csrf` header attached. Returns the raw {@link HttpResponse}
1396
+ * (status + body + headers) so the caller can route on it instead of throwing.
1397
+ */
1398
+ postRaw(path, body) {
1214
1399
  const headers = {
1215
1400
  "Content-Type": JSON_CONTENT_TYPE,
1216
1401
  Accept: JSON_CONTENT_TYPE,
1217
1402
  [CSRF_HEADER]: CSRF_HEADER_VALUE
1218
1403
  };
1219
- const response = await this.http({
1404
+ return this.http({
1220
1405
  url: `${this.baseUrl}${path}`,
1221
1406
  method: "POST",
1222
1407
  headers,
1223
1408
  body: body === void 0 ? void 0 : JSON.stringify(body),
1224
1409
  credentials: "include"
1225
1410
  });
1411
+ }
1412
+ /**
1413
+ * Shared POST for every state-changing `/bff/*` call: same-origin, cookie
1414
+ * included, `X-BFF-Csrf` header attached. Throws a labelled error on non-2xx.
1415
+ */
1416
+ async postState(path, body, label) {
1417
+ const response = await this.postRaw(path, body);
1226
1418
  if (!response.ok) {
1227
1419
  throw new Error(`${label} failed with status ${String(response.status)}`);
1228
1420
  }