@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.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
|
-
*
|
|
1153
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
}
|