@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.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
  }
@@ -1067,8 +1068,23 @@ var ENDPOINTS = {
1067
1068
  me: "/bff/me",
1068
1069
  register: "/bff/register",
1069
1070
  forgotPassword: "/bff/forgot-password",
1070
- resetPassword: "/bff/reset-password"
1071
+ resetPassword: "/bff/reset-password",
1072
+ otpRequest: "/bff/otp/request",
1073
+ otpVerify: "/bff/otp/verify",
1074
+ pinLogin: "/bff/pin/login",
1075
+ config: "/bff/config",
1076
+ pinEnroll: "/bff/pin/enroll",
1077
+ pinUnlock: "/bff/pin/unlock",
1078
+ pinDisable: "/bff/pin/disable"
1071
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];
1072
1088
  function isRecord(value) {
1073
1089
  return typeof value === "object" && value !== null;
1074
1090
  }
@@ -1079,6 +1095,91 @@ function extractUser(data) {
1079
1095
  const envelope = data;
1080
1096
  return isRecord(envelope.user) ? envelope.user : null;
1081
1097
  }
1098
+ function toOtpRequestResult(data) {
1099
+ if (!isRecord(data)) {
1100
+ return { success: true, expiresIn: 0, code: null };
1101
+ }
1102
+ return {
1103
+ success: typeof data.success === "boolean" ? data.success : true,
1104
+ expiresIn: typeof data.expiresIn === "number" ? data.expiresIn : 0,
1105
+ code: typeof data.code === "string" ? data.code : null
1106
+ };
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
+ }
1082
1183
  var BffAuthClient = class {
1083
1184
  constructor(options) {
1084
1185
  this.http = options.http;
@@ -1149,22 +1250,171 @@ var BffAuthClient = class {
1149
1250
  await this.postState(ENDPOINTS.resetPassword, request, "reset-password");
1150
1251
  }
1151
1252
  /**
1152
- * Shared POST for every state-changing `/bff/*` call: same-origin, cookie
1153
- * included, `X-BFF-Csrf` header attached. Throws a labelled error on non-2xx.
1253
+ * `POST /bff/otp/request` the BFF proxies to TenantService, which generates
1254
+ * a short-TTL code and emails it.
1255
+ *
1256
+ * The endpoint is anti-enumeration: a `200` is the normal path whether or not
1257
+ * the identifier is registered. This method therefore **returns** the relayed
1258
+ * `{ success, expiresIn, code }` body (so the UI can show the expiry) rather
1259
+ * than treating a 200 as opaque. It still throws on a non-2xx — a `501`
1260
+ * (OTP not enabled) or `502` (upstream down) is a real failure to surface.
1154
1261
  */
1155
- async postState(path, body, label) {
1262
+ async requestOtp(request) {
1263
+ const data = await this.postState(ENDPOINTS.otpRequest, request, "otp-request");
1264
+ return toOtpRequestResult(data);
1265
+ }
1266
+ /**
1267
+ * `POST /bff/otp/verify` — the BFF runs the OTP direct-grant against Keycloak
1268
+ * server-side, stores the tokens in its Redis vault, and sets the httpOnly
1269
+ * session cookie. Returns the sanitised user, exactly like `login`. Throws on
1270
+ * a non-2xx (e.g. `401` for a bad / expired code).
1271
+ */
1272
+ async verifyOtp(request) {
1273
+ const data = await this.postState(ENDPOINTS.otpVerify, request, "otp-verify");
1274
+ const user = extractUser(data);
1275
+ if (user === null) {
1276
+ throw new Error("otp-verify: BFF response missing user");
1277
+ }
1278
+ return user;
1279
+ }
1280
+ /**
1281
+ * `POST /bff/pin/login` — the BFF runs the event-scoped PIN direct-grant
1282
+ * against Keycloak server-side (the `(event, pin)` pair resolves to the
1283
+ * staff member's KC account + event-scoped role), stores the tokens in its
1284
+ * Redis vault, and sets the httpOnly session cookie. Returns the sanitised
1285
+ * user, exactly like `login` / `verifyOtp`. Throws on a non-2xx — `401` for
1286
+ * a bad / expired / locked-out PIN or an unknown event, `501` when PIN login
1287
+ * is not an enabled method for this BFF.
1288
+ */
1289
+ async pinLogin(request) {
1290
+ const data = await this.postState(ENDPOINTS.pinLogin, request, "pin-login");
1291
+ const user = extractUser(data);
1292
+ if (user === null) {
1293
+ throw new Error("pin-login: BFF response missing user");
1294
+ }
1295
+ return user;
1296
+ }
1297
+ /**
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.
1307
+ */
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) {
1156
1399
  const headers = {
1157
1400
  "Content-Type": JSON_CONTENT_TYPE,
1158
1401
  Accept: JSON_CONTENT_TYPE,
1159
1402
  [CSRF_HEADER]: CSRF_HEADER_VALUE
1160
1403
  };
1161
- const response = await this.http({
1404
+ return this.http({
1162
1405
  url: `${this.baseUrl}${path}`,
1163
1406
  method: "POST",
1164
1407
  headers,
1165
1408
  body: body === void 0 ? void 0 : JSON.stringify(body),
1166
1409
  credentials: "include"
1167
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);
1168
1418
  if (!response.ok) {
1169
1419
  throw new Error(`${label} failed with status ${String(response.status)}`);
1170
1420
  }