@dloizides/auth-client 2.0.0 → 3.0.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
@@ -157,11 +157,63 @@ var AuthClient = class _AuthClient {
157
157
  ...config,
158
158
  scope: config.scope ?? DEFAULT_SCOPE
159
159
  };
160
+ this.directKcAuth = config.useDirectKcAuth === true;
160
161
  this.tokenStorage = storage;
161
162
  this.api = collaborators.api;
162
163
  this.interceptor = collaborators.interceptor;
163
164
  this.inactivityTracker = collaborators.inactivityTracker;
164
165
  this.events = collaborators.events ?? new AuthEventEmitter();
166
+ this.onTokenAcquired = collaborators.onTokenAcquired;
167
+ this.onTokenRefreshed = collaborators.onTokenRefreshed;
168
+ }
169
+ /**
170
+ * Whether this client is configured to route auth flows directly to
171
+ * Keycloak (v2.1.0 direct-KC path) instead of through the proxied
172
+ * identity-api `/auth/*` endpoints.
173
+ *
174
+ * Apps can render conditionally on this — e.g. to swap a login form for
175
+ * a "Sign in with Keycloak" redirect button.
176
+ */
177
+ isDirectMode() {
178
+ return this.directKcAuth;
179
+ }
180
+ /**
181
+ * Persist a token bundle produced by an external flow (e.g. the
182
+ * app-side `useKeycloakExchange` hook that consumes the shared
183
+ * `exchangeAuthorizationCode` primitive). Fires `onTokenAcquired` after
184
+ * persistence and marks the inactivity tracker active.
185
+ *
186
+ * Designed for the v2.1.0 direct-KC path where the PKCE code exchange
187
+ * happens in the app's React-Query hook (which needs `useDispatch`/etc.)
188
+ * but the token persistence + observability should still flow through
189
+ * the shared client.
190
+ */
191
+ async acceptDirectKcTokens(response) {
192
+ const tokens = tokenResponseToAuthTokens(response);
193
+ await this.tokenStorage.write(tokens);
194
+ if (this.inactivityTracker !== void 0) {
195
+ await this.inactivityTracker.markActive();
196
+ }
197
+ if (this.onTokenAcquired !== void 0) {
198
+ this.onTokenAcquired(tokens);
199
+ }
200
+ return tokens;
201
+ }
202
+ /**
203
+ * Same as {@link acceptDirectKcTokens} but fires `onTokenRefreshed`.
204
+ * Use after a `refreshAccessToken()` swap to keep observability counts
205
+ * separated between "fresh login" and "silent refresh".
206
+ */
207
+ async acceptDirectKcRefresh(response) {
208
+ const tokens = tokenResponseToAuthTokens(response);
209
+ await this.tokenStorage.write(tokens);
210
+ if (this.inactivityTracker !== void 0) {
211
+ await this.inactivityTracker.markActive();
212
+ }
213
+ if (this.onTokenRefreshed !== void 0) {
214
+ this.onTokenRefreshed(tokens);
215
+ }
216
+ return tokens;
165
217
  }
166
218
  /**
167
219
  * Build an {@link AuthClient} from a standalone issuer URL by parsing the
@@ -306,7 +358,11 @@ var AuthClient = class _AuthClient {
306
358
  if (this.interceptor === void 0) {
307
359
  throw new Error("AuthClient.refresh: no RefreshInterceptor configured");
308
360
  }
309
- return this.interceptor.refreshTokens();
361
+ const tokens = await this.interceptor.refreshTokens();
362
+ if (tokens !== null && this.onTokenRefreshed !== void 0) {
363
+ this.onTokenRefreshed(tokens);
364
+ }
365
+ return tokens;
310
366
  }
311
367
  async loginWithOtp(input) {
312
368
  return this.runLogin(this.requireApi().loginWithOtp({
@@ -353,6 +409,9 @@ var AuthClient = class _AuthClient {
353
409
  if (this.inactivityTracker !== void 0) {
354
410
  await this.inactivityTracker.markActive();
355
411
  }
412
+ if (this.onTokenAcquired !== void 0) {
413
+ this.onTokenAcquired(tokens);
414
+ }
356
415
  return tokens;
357
416
  }
358
417
  requireApi() {
@@ -372,6 +431,152 @@ var AuthClient = class _AuthClient {
372
431
  }
373
432
  };
374
433
 
434
+ // src/oidc/discovery.ts
435
+ var cache = /* @__PURE__ */ new Map();
436
+ function normalizeIssuer(issuerUrl) {
437
+ return issuerUrl.replace(/\/$/, "");
438
+ }
439
+ function isOidcDiscoveryDocument(data) {
440
+ if (data === null || typeof data !== "object") {
441
+ return false;
442
+ }
443
+ const d = data;
444
+ return typeof d.issuer === "string" && d.issuer !== "" && typeof d.authorization_endpoint === "string" && d.authorization_endpoint !== "" && typeof d.token_endpoint === "string" && d.token_endpoint !== "";
445
+ }
446
+ async function fetchDiscoveryDocument(input) {
447
+ const key = normalizeIssuer(input.issuerUrl);
448
+ const cached = cache.get(key);
449
+ if (cached !== void 0) {
450
+ return cached;
451
+ }
452
+ const response = await input.http({
453
+ url: `${key}/.well-known/openid-configuration`,
454
+ method: "GET"
455
+ });
456
+ if (!response.ok) {
457
+ throw new Error(
458
+ `OIDC discovery failed: ${String(response.status)} for ${key}`
459
+ );
460
+ }
461
+ if (!isOidcDiscoveryDocument(response.data)) {
462
+ throw new Error(`OIDC discovery returned invalid metadata for ${key}`);
463
+ }
464
+ cache.set(key, response.data);
465
+ return response.data;
466
+ }
467
+ function clearDiscoveryCache() {
468
+ cache.clear();
469
+ }
470
+
471
+ // src/oidc/pkce.ts
472
+ var VERIFIER_MIN_LENGTH = 43;
473
+ var VERIFIER_MAX_LENGTH = 128;
474
+ var DEFAULT_VERIFIER_LENGTH = 64;
475
+ var RANDOM_BYTES_PER_CHAR = 1;
476
+ var UNRESERVED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
477
+ function getCrypto() {
478
+ const c = globalThis.crypto;
479
+ if (c === void 0 || c.subtle === void 0) {
480
+ throw new Error("pkce: globalThis.crypto.subtle is required (Node 16+ / modern browser)");
481
+ }
482
+ return c;
483
+ }
484
+ function assertVerifierLength(length) {
485
+ if (length < VERIFIER_MIN_LENGTH || length > VERIFIER_MAX_LENGTH) {
486
+ throw new Error(`pkce: code_verifier length must be ${String(VERIFIER_MIN_LENGTH)}-${String(VERIFIER_MAX_LENGTH)} chars (RFC 7636)`);
487
+ }
488
+ }
489
+ function base64UrlEncode(buffer) {
490
+ const bytes = new Uint8Array(buffer);
491
+ let binary = "";
492
+ for (let i = 0; i < bytes.length; i++) {
493
+ binary += String.fromCharCode(bytes[i]);
494
+ }
495
+ const b64 = globalThis.btoa?.(binary) ?? Buffer.from(binary, "binary").toString("base64");
496
+ let end = b64.length;
497
+ while (end > 0 && b64.charCodeAt(end - 1) === "=".charCodeAt(0)) {
498
+ end -= 1;
499
+ }
500
+ return b64.slice(0, end).replace(/\+/g, "-").replace(/\//g, "_");
501
+ }
502
+ function generateCodeVerifier(length = DEFAULT_VERIFIER_LENGTH) {
503
+ assertVerifierLength(length);
504
+ const crypto = getCrypto();
505
+ const bytes = new Uint8Array(length * RANDOM_BYTES_PER_CHAR);
506
+ crypto.getRandomValues(bytes);
507
+ let out = "";
508
+ for (let i = 0; i < length; i++) {
509
+ const byte = bytes[i];
510
+ out += UNRESERVED_CHARS[byte % UNRESERVED_CHARS.length];
511
+ }
512
+ return out;
513
+ }
514
+ async function deriveCodeChallenge(verifier) {
515
+ assertVerifierLength(verifier.length);
516
+ const crypto = getCrypto();
517
+ const data = new TextEncoder().encode(verifier);
518
+ const digest = await crypto.subtle.digest("SHA-256", data);
519
+ return base64UrlEncode(digest);
520
+ }
521
+ async function generatePkcePair(length) {
522
+ const codeVerifier = generateCodeVerifier(length);
523
+ const codeChallenge = await deriveCodeChallenge(codeVerifier);
524
+ return { codeVerifier, codeChallenge, codeChallengeMethod: "S256" };
525
+ }
526
+
527
+ // src/utils/buildTokenRequestBody.ts
528
+ function buildAuthorizationCodeBody(input) {
529
+ return new URLSearchParams({
530
+ client_id: input.clientId,
531
+ grant_type: "authorization_code",
532
+ code: input.code,
533
+ redirect_uri: input.redirectUri,
534
+ code_verifier: input.codeVerifier
535
+ }).toString();
536
+ }
537
+ function buildRefreshTokenBody(input) {
538
+ return new URLSearchParams({
539
+ client_id: input.clientId,
540
+ grant_type: "refresh_token",
541
+ refresh_token: input.refreshToken
542
+ }).toString();
543
+ }
544
+
545
+ // src/oidc/tokenExchange.ts
546
+ var FORM_HEADERS = {
547
+ "Content-Type": "application/x-www-form-urlencoded"
548
+ };
549
+ async function postTokenEndpoint(http, url, body) {
550
+ const response = await http({
551
+ url,
552
+ method: "POST",
553
+ headers: FORM_HEADERS,
554
+ body
555
+ });
556
+ if (!response.ok) {
557
+ throw new Error(`token endpoint POST failed: ${String(response.status)}`);
558
+ }
559
+ return normalizeTokenResponse(response.data);
560
+ }
561
+ async function exchangeAuthorizationCode(input) {
562
+ const url = buildTokenEndpoint(input.baseUrl, input.realm);
563
+ const body = buildAuthorizationCodeBody({
564
+ clientId: input.clientId,
565
+ code: input.code,
566
+ redirectUri: input.redirectUri,
567
+ codeVerifier: input.codeVerifier
568
+ });
569
+ return postTokenEndpoint(input.http, url, body);
570
+ }
571
+ async function refreshAccessToken(input) {
572
+ const url = buildTokenEndpoint(input.baseUrl, input.realm);
573
+ const body = buildRefreshTokenBody({
574
+ clientId: input.clientId,
575
+ refreshToken: input.refreshToken
576
+ });
577
+ return postTokenEndpoint(input.http, url, body);
578
+ }
579
+
375
580
  // src/types/KeycloakRoles.ts
376
581
  var KeycloakRoles = /* @__PURE__ */ ((KeycloakRoles2) => {
377
582
  KeycloakRoles2["SuperUser"] = "superUser";
@@ -852,6 +1057,121 @@ var AuthApiClient = class {
852
1057
  }
853
1058
  };
854
1059
 
1060
+ // src/bff/BffAuthClient.ts
1061
+ var CSRF_HEADER = "X-BFF-Csrf";
1062
+ var CSRF_HEADER_VALUE = "1";
1063
+ var JSON_CONTENT_TYPE = "application/json";
1064
+ var ENDPOINTS = {
1065
+ login: "/bff/login",
1066
+ logout: "/bff/logout",
1067
+ me: "/bff/me",
1068
+ register: "/bff/register",
1069
+ forgotPassword: "/bff/forgot-password",
1070
+ resetPassword: "/bff/reset-password"
1071
+ };
1072
+ function isRecord(value) {
1073
+ return typeof value === "object" && value !== null;
1074
+ }
1075
+ function extractUser(data) {
1076
+ if (!isRecord(data)) {
1077
+ return null;
1078
+ }
1079
+ const envelope = data;
1080
+ return isRecord(envelope.user) ? envelope.user : null;
1081
+ }
1082
+ var BffAuthClient = class {
1083
+ constructor(options) {
1084
+ this.http = options.http;
1085
+ this.baseUrl = (options.baseUrl ?? "").replace(/\/$/, "");
1086
+ }
1087
+ /**
1088
+ * `POST /bff/login` — the BFF does ROPC against Keycloak server-side, stores
1089
+ * the tokens in its Redis vault, and sets the httpOnly session cookie.
1090
+ * Returns the sanitised user. Throws on a non-2xx response.
1091
+ */
1092
+ async login(request) {
1093
+ const data = await this.postState(ENDPOINTS.login, request, "login");
1094
+ const user = extractUser(data);
1095
+ if (user === null) {
1096
+ throw new Error("login: BFF response missing user");
1097
+ }
1098
+ return user;
1099
+ }
1100
+ /**
1101
+ * `POST /bff/logout` — the BFF calls KC end-session, deletes the Redis
1102
+ * session, and clears the cookie. Non-fatal: a failed logout still leaves
1103
+ * the SPA logged out client-side. Throws only on a non-2xx response.
1104
+ */
1105
+ async logout() {
1106
+ await this.postState(ENDPOINTS.logout, void 0, "logout");
1107
+ }
1108
+ /**
1109
+ * `GET /bff/me` — the live session's sanitised user, or `null` when there is
1110
+ * no session (the BFF answers `401`). Used at app load to bootstrap auth
1111
+ * state in place of the old token-in-storage check.
1112
+ */
1113
+ async getCurrentUser() {
1114
+ const response = await this.http({
1115
+ url: `${this.baseUrl}${ENDPOINTS.me}`,
1116
+ method: "GET",
1117
+ headers: { Accept: JSON_CONTENT_TYPE },
1118
+ credentials: "include"
1119
+ });
1120
+ if (!response.ok) {
1121
+ return null;
1122
+ }
1123
+ return extractUser(response.data);
1124
+ }
1125
+ /**
1126
+ * `POST /bff/register` — the BFF proxies registration to TenantService and,
1127
+ * on success, establishes a session exactly like `login`. Returns the user.
1128
+ */
1129
+ async register(request) {
1130
+ const data = await this.postState(ENDPOINTS.register, request, "register");
1131
+ const user = extractUser(data);
1132
+ if (user === null) {
1133
+ throw new Error("register: BFF response missing user");
1134
+ }
1135
+ return user;
1136
+ }
1137
+ /**
1138
+ * `POST /bff/forgot-password` — proxied to TenantService. The backend
1139
+ * returns 200 unconditionally (no email enumeration); anything else throws.
1140
+ */
1141
+ async forgotPassword(request) {
1142
+ await this.postState(ENDPOINTS.forgotPassword, request, "forgot-password");
1143
+ }
1144
+ /**
1145
+ * `POST /bff/reset-password` — proxied to TenantService. Throws on a non-2xx
1146
+ * response (e.g. `400` for an invalid / expired token).
1147
+ */
1148
+ async resetPassword(request) {
1149
+ await this.postState(ENDPOINTS.resetPassword, request, "reset-password");
1150
+ }
1151
+ /**
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.
1154
+ */
1155
+ async postState(path, body, label) {
1156
+ const headers = {
1157
+ "Content-Type": JSON_CONTENT_TYPE,
1158
+ Accept: JSON_CONTENT_TYPE,
1159
+ [CSRF_HEADER]: CSRF_HEADER_VALUE
1160
+ };
1161
+ const response = await this.http({
1162
+ url: `${this.baseUrl}${path}`,
1163
+ method: "POST",
1164
+ headers,
1165
+ body: body === void 0 ? void 0 : JSON.stringify(body),
1166
+ credentials: "include"
1167
+ });
1168
+ if (!response.ok) {
1169
+ throw new Error(`${label} failed with status ${String(response.status)}`);
1170
+ }
1171
+ return response.data;
1172
+ }
1173
+ };
1174
+
855
1175
  // src/utils/normalizeKeycloakUser.ts
856
1176
  function isNonEmptyString(value) {
857
1177
  return typeof value === "string" && value !== "";
@@ -902,24 +1222,6 @@ function normalizeKeycloakUser(u) {
902
1222
  };
903
1223
  }
904
1224
 
905
- // src/utils/buildTokenRequestBody.ts
906
- function buildAuthorizationCodeBody(input) {
907
- return new URLSearchParams({
908
- client_id: input.clientId,
909
- grant_type: "authorization_code",
910
- code: input.code,
911
- redirect_uri: input.redirectUri,
912
- code_verifier: input.codeVerifier
913
- }).toString();
914
- }
915
- function buildRefreshTokenBody(input) {
916
- return new URLSearchParams({
917
- client_id: input.clientId,
918
- grant_type: "refresh_token",
919
- refresh_token: input.refreshToken
920
- }).toString();
921
- }
922
-
923
1225
  // src/utils/extractAuthCode.ts
924
1226
  function extractAuthCode(response) {
925
1227
  if (!response) {
@@ -980,6 +1282,6 @@ function decodeUtf8(binary) {
980
1282
  return new TextDecoder("utf-8").decode(bytes);
981
1283
  }
982
1284
 
983
- export { AuthApiClient, AuthClient, AuthEventEmitter, BiometricGate, BrowserStorageTokenStorage, CookieTokenStorage, InMemoryTokenStorage, InactivityTracker, KeycloakRoles, RefreshInterceptor, SecureStoreTokenStorage, buildAuthorizationCodeBody, buildAuthorizationEndpoint, buildAuthorizationUrl, buildIssuerUrl, buildLogoutEndpoint, buildRefreshTokenBody, buildTokenEndpoint, buildUserInfoEndpoint, computeExpiresAt, createFetchHttpClient, decodeJwt, extractAuthCode, isKeycloakRole, isTokenExpired, normalizeKeycloakUser, normalizeTokenResponse, parseBaseUrlFromIssuer, parseRealmFromIssuer, tokenResponseToAuthTokens };
1285
+ export { AuthApiClient, AuthClient, AuthEventEmitter, BffAuthClient, BiometricGate, BrowserStorageTokenStorage, CookieTokenStorage, InMemoryTokenStorage, InactivityTracker, KeycloakRoles, RefreshInterceptor, SecureStoreTokenStorage, buildAuthorizationCodeBody, buildAuthorizationEndpoint, buildAuthorizationUrl, buildIssuerUrl, buildLogoutEndpoint, buildRefreshTokenBody, buildTokenEndpoint, buildUserInfoEndpoint, clearDiscoveryCache, computeExpiresAt, createFetchHttpClient, decodeJwt, deriveCodeChallenge, exchangeAuthorizationCode, extractAuthCode, fetchDiscoveryDocument, generateCodeVerifier, generatePkcePair, isKeycloakRole, isTokenExpired, normalizeKeycloakUser, normalizeTokenResponse, parseBaseUrlFromIssuer, parseRealmFromIssuer, refreshAccessToken, tokenResponseToAuthTokens };
984
1286
  //# sourceMappingURL=index.mjs.map
985
1287
  //# sourceMappingURL=index.mjs.map