@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.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
  }
@@ -1072,8 +1073,20 @@ var ENDPOINTS = {
1072
1073
  resetPassword: "/bff/reset-password",
1073
1074
  otpRequest: "/bff/otp/request",
1074
1075
  otpVerify: "/bff/otp/verify",
1075
- pinLogin: "/bff/pin/login"
1076
+ pinLogin: "/bff/pin/login",
1077
+ config: "/bff/config",
1078
+ pinEnroll: "/bff/pin/enroll",
1079
+ pinUnlock: "/bff/pin/unlock",
1080
+ pinDisable: "/bff/pin/disable"
1076
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];
1077
1090
  function isRecord(value) {
1078
1091
  return typeof value === "object" && value !== null;
1079
1092
  }
@@ -1094,6 +1107,81 @@ function toOtpRequestResult(data) {
1094
1107
  code: typeof data.code === "string" ? data.code : null
1095
1108
  };
1096
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
+ }
1097
1185
  var BffAuthClient = class {
1098
1186
  constructor(options) {
1099
1187
  this.http = options.http;
@@ -1209,22 +1297,126 @@ var BffAuthClient = class {
1209
1297
  return user;
1210
1298
  }
1211
1299
  /**
1212
- * Shared POST for every state-changing `/bff/*` call: same-origin, cookie
1213
- * included, `X-BFF-Csrf` header attached. Throws a labelled error on non-2xx.
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.
1214
1309
  */
1215
- async postState(path, body, label) {
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) {
1216
1401
  const headers = {
1217
1402
  "Content-Type": JSON_CONTENT_TYPE,
1218
1403
  Accept: JSON_CONTENT_TYPE,
1219
1404
  [CSRF_HEADER]: CSRF_HEADER_VALUE
1220
1405
  };
1221
- const response = await this.http({
1406
+ return this.http({
1222
1407
  url: `${this.baseUrl}${path}`,
1223
1408
  method: "POST",
1224
1409
  headers,
1225
1410
  body: body === void 0 ? void 0 : JSON.stringify(body),
1226
1411
  credentials: "include"
1227
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);
1228
1420
  if (!response.ok) {
1229
1421
  throw new Error(`${label} failed with status ${String(response.status)}`);
1230
1422
  }