@authsome/ui-core 1.6.0 → 1.6.1

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.d.ts CHANGED
@@ -1448,6 +1448,23 @@ type AuthState = {
1448
1448
  } | {
1449
1449
  status: "unauthenticated";
1450
1450
  }
1451
+ /**
1452
+ * A session exists in storage but could not be validated because the auth
1453
+ * server was unreachable or errored — as distinct from the server rejecting
1454
+ * it, which yields "unauthenticated".
1455
+ *
1456
+ * This is deliberately NOT "authenticated": nothing has proven the token is
1457
+ * still good, so `isAuthenticated` is false and protected UI must stay
1458
+ * hidden. The session is retained and a refresh is scheduled, so a
1459
+ * transient outage resolves itself without signing the user out.
1460
+ *
1461
+ * Treat it as "signed out, but don't discard their session yet". An app that
1462
+ * wants to render optimistically can branch on it explicitly.
1463
+ */
1464
+ | {
1465
+ status: "unknown";
1466
+ session: Session;
1467
+ }
1451
1468
  /**
1452
1469
  * Sign-in returned the MFA gate (HTTP 403 with type:"mfa_required").
1453
1470
  * The user must complete the second factor against the same ticket
@@ -1498,7 +1515,14 @@ interface AuthConfig {
1498
1515
  initialClientConfig?: ClientConfig;
1499
1516
  /** Custom fetch implementation (defaults to global fetch). */
1500
1517
  fetch?: typeof fetch;
1501
- /** Storage implementation for persisting tokens (defaults to localStorage). */
1518
+ /**
1519
+ * Storage implementation for persisting tokens.
1520
+ *
1521
+ * Defaults to in-memory, so tokens do not survive a reload and no other
1522
+ * script on the page can read them. Pass createLocalStorage() to persist
1523
+ * across reloads, accepting that an XSS can then read the refresh token;
1524
+ * prefer backend-set httpOnly cookies where available.
1525
+ */
1502
1526
  storage?: TokenStorage;
1503
1527
  /** Callback invoked when the auth state changes. */
1504
1528
  onStateChange?: (state: AuthState) => void;
@@ -2704,6 +2728,25 @@ declare class AuthClient extends AuthClient$1 {
2704
2728
  * class and expose reactive state.
2705
2729
  */
2706
2730
 
2731
+ /**
2732
+ * Storage key under which the whole Session (tokens + expiry) is persisted.
2733
+ *
2734
+ * Exported because a cookie-backed TokenStorage writes it as a cookie name,
2735
+ * and server-side readers have to know what to look for. See
2736
+ * @authsome/ui-nextjs readSessionToken.
2737
+ */
2738
+ declare const SESSION_STORAGE_KEY = "authsome:session";
2739
+ /**
2740
+ * Token storage backed by window.localStorage, falling back to memory where
2741
+ * localStorage is unavailable (SSR, restricted contexts).
2742
+ *
2743
+ * Opt in only if you understand the exposure: everything written here,
2744
+ * including the refresh token, is readable by any script running on the page.
2745
+ * Prefer backend-set httpOnly cookies where you can.
2746
+ *
2747
+ * new AuthManager({ baseURL, storage: createLocalStorage() })
2748
+ */
2749
+ declare function createLocalStorage(): TokenStorage;
2707
2750
  /**
2708
2751
  * AuthManager is the core state machine that drives authentication.
2709
2752
  *
@@ -2739,6 +2782,22 @@ declare class AuthManager {
2739
2782
  * Call this once on app start.
2740
2783
  */
2741
2784
  initialize(): Promise<void>;
2785
+ /**
2786
+ * Resolves the state when hydrating a stored session failed.
2787
+ *
2788
+ * The two failure modes must not be conflated. A rejected credential means
2789
+ * the user is signed out. An unreachable server means we simply do not know,
2790
+ * and signing them out over a transient blip is its own bug — which is what
2791
+ * the previous behaviour tried to avoid, by reporting "authenticated" with a
2792
+ * null user. That kept people signed in through an outage but told every
2793
+ * consumer gating on `isAuthenticated` to render protected UI for a session
2794
+ * nothing had validated, and made `user.email` a crash.
2795
+ *
2796
+ * So: rejection signs out; unavailability yields "unknown", which is not
2797
+ * authenticated but retains the session and keeps a refresh scheduled so the
2798
+ * app recovers on its own.
2799
+ */
2800
+ private handleInitFailure;
2742
2801
  /** Sign in with email & password. */
2743
2802
  signIn(credentials: SignInRequest): Promise<void>;
2744
2803
  /** Sign up with email & password. */
@@ -2812,6 +2871,18 @@ declare class AuthManager {
2812
2871
  /** Tear down: clear timers and listeners. */
2813
2872
  destroy(): void;
2814
2873
  private handleAuthResponse;
2874
+ /**
2875
+ * Exchanges the refresh token for a new session.
2876
+ *
2877
+ * On failure the two cases are separated, as they are in initialize: a
2878
+ * rejected token means signed out, anything else means no verdict and is
2879
+ * worth retrying.
2880
+ *
2881
+ * `attempt` was previously accepted and never read, and the retry path
2882
+ * called back in without it — so a permanently-invalid token retried every
2883
+ * 30s for as long as the tab stayed open, and the user was never signed out.
2884
+ * It now bounds the retries and backs off between them.
2885
+ */
2815
2886
  private refreshSession;
2816
2887
  private scheduleRefresh;
2817
2888
  private clearRefreshTimer;
@@ -2822,6 +2893,59 @@ declare class AuthManager {
2822
2893
  private handleError;
2823
2894
  }
2824
2895
 
2896
+ /**
2897
+ * Redirect-target validation.
2898
+ *
2899
+ * Sign-in and sign-up flows accept a `?redirect=` parameter so a user who was
2900
+ * bounced to the auth page lands back where they started. That parameter is
2901
+ * attacker-controlled: without validation, `?redirect=https://evil.com` sends
2902
+ * the user to a hostile page *immediately after they authenticate*, which is
2903
+ * the most convincing possible moment to present a fake "confirm your
2904
+ * password" screen. It can also carry `javascript:` payloads.
2905
+ *
2906
+ * The check here resolves the candidate against the current origin and
2907
+ * compares origins, rather than inspecting the string. String checks are the
2908
+ * usual approach and they leak: `startsWith("/")` accepts `//evil.com` and
2909
+ * `/\evil.com`, both of which browsers resolve to a different host.
2910
+ */
2911
+ /** Options controlling which redirect targets are acceptable. */
2912
+ interface SafeRedirectOptions {
2913
+ /**
2914
+ * Origin the app is currently served from — `window.location.origin` in a
2915
+ * browser. Redirects to this origin are always allowed.
2916
+ */
2917
+ currentOrigin: string;
2918
+ /**
2919
+ * Extra origins accepted as redirect targets, for deployments where the auth
2920
+ * UI and the app live on different hosts. Empty by default: same-origin only.
2921
+ *
2922
+ * Entries are matched **exactly**, by origin. Wildcards and subdomain
2923
+ * patterns are deliberately unsupported — every tenant host must be listed
2924
+ * in full. Pattern matching here is a well-known source of account-takeover
2925
+ * bugs, and an explicit list is worth the verbosity.
2926
+ *
2927
+ * Each entry must be an absolute URL ("https://portal.example.com"); it is
2928
+ * reduced to its origin, so a trailing slash, a path, or a capitalized host
2929
+ * are all tolerated. Entries that cannot be parsed are ignored and will
2930
+ * never match.
2931
+ */
2932
+ allowedOrigins?: string[];
2933
+ }
2934
+ /**
2935
+ * Reports whether `candidate` is safe to navigate to after authentication.
2936
+ *
2937
+ * Accepts relative paths ("/dashboard", "/a/b?x=1#f") and absolute URLs whose
2938
+ * origin is the current origin or an allowed one. Refuses everything else,
2939
+ * including a malformed candidate.
2940
+ */
2941
+ declare function isSafeRedirect(candidate: string | null | undefined, options: SafeRedirectOptions): boolean;
2942
+ /**
2943
+ * Returns `candidate` when it passes {@link isSafeRedirect}, otherwise
2944
+ * `fallback`. Use this at navigation sites so an unsafe value degrades to a
2945
+ * known-good destination instead of blocking the user.
2946
+ */
2947
+ declare function safeRedirectTarget(candidate: string | null | undefined, fallback: string, options: SafeRedirectOptions): string;
2948
+
2825
2949
  /**
2826
2950
  * WebAuthn encoding helpers.
2827
2951
  *
@@ -2862,4 +2986,4 @@ declare function prepareRequestOptions(options: Record<string, unknown>): Public
2862
2986
  */
2863
2987
  declare function serializeCredential(credential: Credential): Record<string, unknown>;
2864
2988
 
2865
- export { type APIError, type KeyListItem as APIKey, type AddMemberRequest, type AdminBanUserRequest, AuthClient, type AuthClientConfig, AuthClientError, type AuthConfig, AuthManager, type AuthResponse, type AuthState, type ChallengeMFARequest, type ChangePasswordRequest, type ClientConfig, type CreateAPIKeyRequest, type CreateInvitationRequest, type CreateOrganizationRequest as CreateOrgRequest, type CreateOrganizationRequest, type Device, type EnrollMFARequest, type EnrollResponse, type ForgotPasswordRequest, type Invitation, type KeyListItem, type ListOptions, type ListResponse, type EnrollResponse as MFAEnrollment, type SendMagicLinkRequest as MagicLinkSendRequest, type VerifyMagicLinkRequest as MagicLinkVerifyRequest, type Member, type ChallengeMFARequest as MfaChallengeRequest, type EnrollMFARequest as MfaEnrollRequest, type SendSMSCodeRequest as MfaSMSSendRequest, type VerifySMSCodeRequest as MfaSMSVerifyRequest, type VerifyMFARequest as MfaVerifyRequest, type Organization, type RefreshRequest, type ResetPasswordRequest, type SSOConnectionConfig, type SendMagicLinkRequest, type SendSMSCodeRequest, type Session, type SignInRequest, type SignUpRequest, type SignupFieldConfig, type SignupFieldOption, type SignupFieldValidation, type SocialProviderConfig, type SsoACSRequest, type SsoCallbackRequest, type TokenResponse, type TokenStorage, type UpdateMeRequest, type UpdateOrganizationRequest as UpdateOrgRequest, type UpdateOrganizationRequest, type User, type VerifyEmailRequest, type VerifyMFARequest, type VerifyMagicLinkRequest, type VerifyRecoveryCodeRequest, type VerifySMSCodeRequest, base64urlToBuffer, bufferToBase64url, prepareCreationOptions, prepareRequestOptions, serializeCredential };
2989
+ export { type APIError, type KeyListItem as APIKey, type AddMemberRequest, type AdminBanUserRequest, AuthClient, type AuthClientConfig, AuthClientError, type AuthConfig, AuthManager, type AuthResponse, type AuthState, type ChallengeMFARequest, type ChangePasswordRequest, type ClientConfig, type CreateAPIKeyRequest, type CreateInvitationRequest, type CreateOrganizationRequest as CreateOrgRequest, type CreateOrganizationRequest, type Device, type EnrollMFARequest, type EnrollResponse, type ForgotPasswordRequest, type Invitation, type KeyListItem, type ListOptions, type ListResponse, type EnrollResponse as MFAEnrollment, type SendMagicLinkRequest as MagicLinkSendRequest, type VerifyMagicLinkRequest as MagicLinkVerifyRequest, type Member, type ChallengeMFARequest as MfaChallengeRequest, type EnrollMFARequest as MfaEnrollRequest, type SendSMSCodeRequest as MfaSMSSendRequest, type VerifySMSCodeRequest as MfaSMSVerifyRequest, type VerifyMFARequest as MfaVerifyRequest, type Organization, type RefreshRequest, type ResetPasswordRequest, SESSION_STORAGE_KEY, type SSOConnectionConfig, type SafeRedirectOptions, type SendMagicLinkRequest, type SendSMSCodeRequest, type Session, type SignInRequest, type SignUpRequest, type SignupFieldConfig, type SignupFieldOption, type SignupFieldValidation, type SocialProviderConfig, type SsoACSRequest, type SsoCallbackRequest, type TokenResponse, type TokenStorage, type UpdateMeRequest, type UpdateOrganizationRequest as UpdateOrgRequest, type UpdateOrganizationRequest, type User, type VerifyEmailRequest, type VerifyMFARequest, type VerifyMagicLinkRequest, type VerifyRecoveryCodeRequest, type VerifySMSCodeRequest, base64urlToBuffer, bufferToBase64url, createLocalStorage, isSafeRedirect, prepareCreationOptions, prepareRequestOptions, safeRedirectTarget, serializeCredential };
package/dist/index.js CHANGED
@@ -2661,11 +2661,15 @@ var AuthClient2 = class extends AuthClient {
2661
2661
  };
2662
2662
 
2663
2663
  // src/auth.ts
2664
- var SESSION_KEY = "authsome:session";
2664
+ var SESSION_STORAGE_KEY = "authsome:session";
2665
+ var SESSION_KEY = SESSION_STORAGE_KEY;
2665
2666
  var CONFIG_KEY = "authsome:client_config";
2666
2667
  var REFRESH_BEFORE_MS = 6e4;
2667
2668
  var CONFIG_TTL_MS = 5 * 6e4;
2668
- var memoryStorage = /* @__PURE__ */ (() => {
2669
+ var DEFAULT_SESSION_TTL_MS = 36e5;
2670
+ var MAX_REFRESH_ATTEMPTS = 5;
2671
+ var REFRESH_RETRY_BASE_MS = 3e4;
2672
+ function createMemoryStorage() {
2669
2673
  const store = /* @__PURE__ */ new Map();
2670
2674
  return {
2671
2675
  getItem: (key) => store.get(key) ?? null,
@@ -2676,15 +2680,28 @@ var memoryStorage = /* @__PURE__ */ (() => {
2676
2680
  store.delete(key);
2677
2681
  }
2678
2682
  };
2679
- })();
2680
- function defaultStorage() {
2683
+ }
2684
+ function createLocalStorage() {
2681
2685
  try {
2682
2686
  if (typeof window !== "undefined" && window.localStorage) {
2683
2687
  return window.localStorage;
2684
2688
  }
2685
2689
  } catch {
2686
2690
  }
2687
- return memoryStorage;
2691
+ return createMemoryStorage();
2692
+ }
2693
+ function toSession(res) {
2694
+ return {
2695
+ session_token: res.session_token,
2696
+ refresh_token: res.refresh_token,
2697
+ expires_at: res.expires_at ?? new Date(Date.now() + DEFAULT_SESSION_TTL_MS).toISOString()
2698
+ };
2699
+ }
2700
+ function isAuthRejection(err) {
2701
+ if (err instanceof AuthClientError) {
2702
+ return err.code === 401 || err.code === 403;
2703
+ }
2704
+ return false;
2688
2705
  }
2689
2706
  var AuthManager = class {
2690
2707
  client;
@@ -2699,7 +2716,7 @@ var AuthManager = class {
2699
2716
  configFetchPromise = null;
2700
2717
  constructor(config) {
2701
2718
  this.client = new AuthClient2(config);
2702
- this.storage = config.storage ?? defaultStorage();
2719
+ this.storage = config.storage ?? createMemoryStorage();
2703
2720
  this.onError = config.onError;
2704
2721
  this.publishableKey = config.publishableKey;
2705
2722
  if (config.initialClientConfig) {
@@ -2750,27 +2767,50 @@ var AuthManager = class {
2750
2767
  const user = await this.client.getMe(session.session_token);
2751
2768
  this.setState({ status: "authenticated", user, session });
2752
2769
  this.scheduleRefresh(session);
2770
+ } catch (err) {
2771
+ await this.handleInitFailure(err);
2772
+ }
2773
+ }
2774
+ /**
2775
+ * Resolves the state when hydrating a stored session failed.
2776
+ *
2777
+ * The two failure modes must not be conflated. A rejected credential means
2778
+ * the user is signed out. An unreachable server means we simply do not know,
2779
+ * and signing them out over a transient blip is its own bug — which is what
2780
+ * the previous behaviour tried to avoid, by reporting "authenticated" with a
2781
+ * null user. That kept people signed in through an outage but told every
2782
+ * consumer gating on `isAuthenticated` to render protected UI for a session
2783
+ * nothing had validated, and made `user.email` a crash.
2784
+ *
2785
+ * So: rejection signs out; unavailability yields "unknown", which is not
2786
+ * authenticated but retains the session and keeps a refresh scheduled so the
2787
+ * app recovers on its own.
2788
+ */
2789
+ async handleInitFailure(err) {
2790
+ const stored = await this.storage.getItem(SESSION_KEY);
2791
+ if (!stored) {
2792
+ this.setState({ status: "unauthenticated" });
2793
+ return;
2794
+ }
2795
+ let session;
2796
+ try {
2797
+ session = JSON.parse(stored);
2753
2798
  } catch {
2754
- const stored = await this.storage.getItem(SESSION_KEY);
2755
- if (stored) {
2756
- const session = JSON.parse(stored);
2757
- this.setState({ status: "authenticated", user: null, session });
2758
- this.scheduleRefresh(session);
2759
- } else {
2760
- this.setState({ status: "unauthenticated" });
2761
- }
2799
+ this.setState({ status: "unauthenticated" });
2800
+ return;
2801
+ }
2802
+ if (isAuthRejection(err)) {
2803
+ this.setState({ status: "unauthenticated" });
2804
+ return;
2762
2805
  }
2806
+ this.setState({ status: "unknown", session });
2807
+ this.scheduleRefresh(session);
2763
2808
  }
2764
2809
  /** Sign in with email & password. */
2765
2810
  async signIn(credentials) {
2766
2811
  try {
2767
2812
  const res = await this.client.signIn(credentials);
2768
- const session = {
2769
- session_token: res.session_token,
2770
- refresh_token: res.refresh_token,
2771
- expires_at: new Date(Date.now() + 36e5).toISOString()
2772
- // fallback
2773
- };
2813
+ const session = toSession(res);
2774
2814
  await this.handleAuthResponse(res.user, session);
2775
2815
  } catch (err) {
2776
2816
  if (err instanceof AuthClientError && err.type === "mfa_required" && err.details) {
@@ -2862,11 +2902,7 @@ var AuthManager = class {
2862
2902
  mfa_ticket: state.mfaTicket,
2863
2903
  code
2864
2904
  });
2865
- const session = {
2866
- session_token: res.session_token,
2867
- refresh_token: res.refresh_token,
2868
- expires_at: res.expires_at ?? new Date(Date.now() + 36e5).toISOString()
2869
- };
2905
+ const session = toSession(res);
2870
2906
  await this.handleAuthResponse(res.user, session);
2871
2907
  } catch (err) {
2872
2908
  this.onError?.({
@@ -2889,11 +2925,7 @@ var AuthManager = class {
2889
2925
  this.setState({ status: "loading" });
2890
2926
  try {
2891
2927
  const res = await this.client.verifyRecoveryCode(code);
2892
- const session = {
2893
- session_token: res.session_token,
2894
- refresh_token: res.refresh_token,
2895
- expires_at: new Date(Date.now() + 36e5).toISOString()
2896
- };
2928
+ const session = toSession(res);
2897
2929
  await this.handleAuthResponse(res.user, session);
2898
2930
  } catch (err) {
2899
2931
  this.handleError(err);
@@ -2912,11 +2944,7 @@ var AuthManager = class {
2912
2944
  const token = this.getSessionToken();
2913
2945
  if (!token) throw new Error("No session token available");
2914
2946
  const res = await this.client.verifySMSCodeForMFA(code, token);
2915
- const session = {
2916
- session_token: res.session_token,
2917
- refresh_token: res.refresh_token,
2918
- expires_at: new Date(Date.now() + 36e5).toISOString()
2919
- };
2947
+ const session = toSession(res);
2920
2948
  await this.handleAuthResponse(res.user, session);
2921
2949
  } catch (err) {
2922
2950
  this.handleError(err);
@@ -3013,6 +3041,18 @@ var AuthManager = class {
3013
3041
  this.setState({ status: "authenticated", user, session });
3014
3042
  this.scheduleRefresh(session);
3015
3043
  }
3044
+ /**
3045
+ * Exchanges the refresh token for a new session.
3046
+ *
3047
+ * On failure the two cases are separated, as they are in initialize: a
3048
+ * rejected token means signed out, anything else means no verdict and is
3049
+ * worth retrying.
3050
+ *
3051
+ * `attempt` was previously accepted and never read, and the retry path
3052
+ * called back in without it — so a permanently-invalid token retried every
3053
+ * 30s for as long as the tab stayed open, and the user was never signed out.
3054
+ * It now bounds the retries and backs off between them.
3055
+ */
3016
3056
  async refreshSession(refreshToken, attempt = 0) {
3017
3057
  try {
3018
3058
  const newSession = await this.client.refresh(refreshToken);
@@ -3020,11 +3060,30 @@ var AuthManager = class {
3020
3060
  await this.persistSession(newSession);
3021
3061
  this.setState({ status: "authenticated", user, session: newSession });
3022
3062
  this.scheduleRefresh(newSession);
3023
- } catch {
3063
+ } catch (err) {
3064
+ if (isAuthRejection(err)) {
3065
+ this.setState({ status: "unauthenticated" });
3066
+ return;
3067
+ }
3068
+ if (attempt >= MAX_REFRESH_ATTEMPTS) {
3069
+ const stored = await this.storage.getItem(SESSION_KEY);
3070
+ if (stored) {
3071
+ try {
3072
+ this.setState({ status: "unknown", session: JSON.parse(stored) });
3073
+ return;
3074
+ } catch {
3075
+ }
3076
+ }
3077
+ this.setState({ status: "unauthenticated" });
3078
+ return;
3079
+ }
3024
3080
  this.clearRefreshTimer();
3025
- this.refreshTimer = setTimeout(() => {
3026
- void this.refreshSession(refreshToken);
3027
- }, 3e4);
3081
+ this.refreshTimer = setTimeout(
3082
+ () => {
3083
+ void this.refreshSession(refreshToken, attempt + 1);
3084
+ },
3085
+ REFRESH_RETRY_BASE_MS * 2 ** attempt
3086
+ );
3028
3087
  }
3029
3088
  }
3030
3089
  scheduleRefresh(session) {
@@ -3078,6 +3137,40 @@ var AuthManager = class {
3078
3137
  }
3079
3138
  };
3080
3139
 
3140
+ // src/redirect.ts
3141
+ var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
3142
+ function isSafeRedirect(candidate, options) {
3143
+ if (typeof candidate !== "string" || candidate.trim() === "") {
3144
+ return false;
3145
+ }
3146
+ let resolved;
3147
+ try {
3148
+ resolved = new URL(candidate, options.currentOrigin);
3149
+ } catch {
3150
+ return false;
3151
+ }
3152
+ if (!ALLOWED_PROTOCOLS.has(resolved.protocol)) {
3153
+ return false;
3154
+ }
3155
+ if (resolved.origin === options.currentOrigin) {
3156
+ return true;
3157
+ }
3158
+ return (options.allowedOrigins ?? []).some(
3159
+ (allowed) => toOrigin(allowed) === resolved.origin
3160
+ );
3161
+ }
3162
+ function toOrigin(value) {
3163
+ try {
3164
+ const { origin } = new URL(value);
3165
+ return origin === "null" ? null : origin;
3166
+ } catch {
3167
+ return null;
3168
+ }
3169
+ }
3170
+ function safeRedirectTarget(candidate, fallback, options) {
3171
+ return isSafeRedirect(candidate, options) ? candidate : fallback;
3172
+ }
3173
+
3081
3174
  // src/webauthn.ts
3082
3175
  function base64urlToBuffer(base64url) {
3083
3176
  const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
@@ -3174,10 +3267,14 @@ export {
3174
3267
  AuthClient2 as AuthClient,
3175
3268
  AuthClientError,
3176
3269
  AuthManager,
3270
+ SESSION_STORAGE_KEY,
3177
3271
  base64urlToBuffer,
3178
3272
  bufferToBase64url,
3273
+ createLocalStorage,
3274
+ isSafeRedirect,
3179
3275
  prepareCreationOptions,
3180
3276
  prepareRequestOptions,
3277
+ safeRedirectTarget,
3181
3278
  serializeCredential
3182
3279
  };
3183
3280
  //# sourceMappingURL=index.js.map