@dloizides/auth-web 1.9.0 → 1.10.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 CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.10.0 (2026-07-17)
4
+
5
+ Additive release adding the shared **logout sequencer**. Purely additive — every
6
+ 1.9.x export is unchanged, no component behaviour changes, and apps that do not
7
+ import `performBffLogout` are byte-identical.
8
+
9
+ ### Added
10
+
11
+ - **`performBffLogout({ client, onClearSession?, onRedirect?, onError?, navigateToIdp? })`**
12
+ — the shared logout sequence, extracted because all six consuming apps
13
+ hand-rolled it in their own `AuthProvider` and the orderings disagreed. Runs:
14
+ 1. `onClearSession()` — local state first, so the UI reflects the sign-out
15
+ immediately.
16
+ 2. `await client.logout({ redirect: false })` — lets the `POST /bff/logout`
17
+ SETTLE, and takes the IdP logout URL rather than letting the client navigate
18
+ mid-sequence.
19
+ 3. Navigates exactly once, last: to the IdP logout URL when the BFF returned
20
+ one (ending the IdP browser session), otherwise `onRedirect()`. When it
21
+ navigates to the IdP it deliberately does NOT also call `onRedirect` — two
22
+ competing navigations would race, and the IdP round-trip already returns the
23
+ user to the app.
24
+
25
+ Never rejects; always navigates, even when the BFF logout fails (a down BFF
26
+ must not trap the user on a page they asked to leave).
27
+ - **`BffLogoutClient`** / **`PerformBffLogoutOptions`** types.
28
+
29
+ ### Why
30
+
31
+ `zygos-web` and `agora-web` navigated FIRST and called the BFF second:
32
+
33
+ ```ts
34
+ redirectTo('/(auth)/login'); // → window.location.replace() → document unload
35
+ await bffAuthClient.logout(); // races the unload — may never be sent
36
+ ```
37
+
38
+ That is wrong twice over:
39
+
40
+ 1. **The BFF call can be cancelled.** A document navigation kills in-flight
41
+ fetches, so `POST /bff/logout` may never reach the server: the SPA looks
42
+ logged out while the httpOnly session cookie stays alive. The `catch` logged
43
+ it "non-fatal", so the defect was silent.
44
+ 2. **The IdP session survives.** `BffAuthClient.logout()` ends the identity
45
+ provider's browser session with a top-level navigation to its RP-initiated
46
+ logout URL (only a real navigation carries the IdP's own cookie — a
47
+ back-channel POST cannot clear it). An app that navigates to its own login
48
+ route first pre-empts that navigation, so the IdP still considers the user
49
+ signed in and the next authorize round-trip silently re-authenticates them —
50
+ the "sign-out that signs you back in" bug the client's own docs warn about.
51
+
52
+ Synthetic tests that stub the client never reproduce either failure (there is no
53
+ real navigation to cancel the request), which is why they survived. The helper
54
+ owns the sequence so neither can be reintroduced per-app.
55
+
3
56
  ## 1.8.0 (2026-07-09)
4
57
 
5
58
  Additive release for the Move 2b passkey in-app rebuild — the passkey ceremony
package/dist/index.d.mts CHANGED
@@ -1694,30 +1694,6 @@ declare const enum PasskeyCeremonyStatus {
1694
1694
  Error = "error"
1695
1695
  }
1696
1696
 
1697
- /**
1698
- * Navigation helpers for the passkey (WebAuthn) BFF endpoints.
1699
- *
1700
- * Unlike the device-PIN client (which is plain `fetch`), the passkey login and
1701
- * registration ceremonies are NOT fetches — they are full-page BROWSER
1702
- * NAVIGATIONS. The BFF (`Bff.AspNetCore` 1.3.x) drives the WebAuthn ceremony
1703
- * through Keycloak's hosted pages, then redirects the browser back:
1704
- * - login: `/bff/passkey/login?returnUrl=<rel>` → on success the browser
1705
- * lands on `returnUrl` with the session cookie already set;
1706
- * - registration: `/bff/passkey/register?returnUrl=<rel>` → Keycloak re-auths
1707
- * the user (password) then runs the credential-creation ceremony, returning
1708
- * the browser to `returnUrl?passkey=registered`;
1709
- * - failure / cancel: the BFF redirects to `<login>?passkeyError=cancelled` or
1710
- * `<login>?passkeyError=failed`.
1711
- *
1712
- * react-query-FREE by construction: there is NO fetch here at all — every
1713
- * function is a pure `window.location` read or `window.location.assign`. The
1714
- * login route group has no QueryClient provider, so the passkey login button
1715
- * (which lives there) MUST avoid any react-query primitive; navigation satisfies
1716
- * that trivially.
1717
- *
1718
- * All reads are SSR-safe: when `window` is undefined (server render / native)
1719
- * they return a benign default rather than throwing.
1720
- */
1721
1697
  /** The recognised passkey error codes the BFF sends back via `?passkeyError=`. */
1722
1698
  type PasskeyErrorCode = 'cancelled' | 'failed';
1723
1699
  /**
@@ -1949,6 +1925,97 @@ declare function resolvePostLoginRoute(user: BffUser, table: RoleRouteTable): st
1949
1925
  */
1950
1926
  declare function resolveAccessibleRoutes(user: BffUser, table: RoleRouteTable): RoleRoute[];
1951
1927
 
1928
+ /**
1929
+ * `performBffLogout` — the shared logout sequencer.
1930
+ *
1931
+ * Every app hand-rolled its own logout ordering inside its `AuthProvider`, and
1932
+ * the orderings disagreed. Two of them (zygos-web, agora-web) navigated FIRST
1933
+ * and called the BFF second:
1934
+ *
1935
+ * ```ts
1936
+ * redirectTo('/(auth)/login'); // window.location.replace() → document unload
1937
+ * await bffAuthClient.logout(); // races the unload — may never be sent
1938
+ * ```
1939
+ *
1940
+ * That is wrong twice over:
1941
+ *
1942
+ * 1. **The BFF call can be cancelled.** A document navigation kills in-flight
1943
+ * fetches, so `POST /bff/logout` may never reach the server: the SPA looks
1944
+ * logged out while the httpOnly session cookie stays ALIVE. The `catch`
1945
+ * logged it "non-fatal", so the defect was silent.
1946
+ * 2. **The IdP session survives.** `BffAuthClient.logout()` ends the *identity
1947
+ * provider's* browser session by doing a top-level navigation to the IdP's
1948
+ * RP-initiated logout URL (only a real navigation carries the IdP's own
1949
+ * cookie — a back-channel POST cannot clear it). An app that navigates to
1950
+ * its own login route first pre-empts that navigation, so the IdP still
1951
+ * considers the user signed in and the next authorize round-trip silently
1952
+ * re-authenticates them. That is the "sign-out that signs you back in" bug
1953
+ * the client's own docs warn about.
1954
+ *
1955
+ * This helper owns the whole sequence so neither can be reintroduced per-app:
1956
+ *
1957
+ * 1. `onClearSession()` — drop local state immediately so the UI reflects the
1958
+ * sign-out right away (perceived performance; no spinner needed).
1959
+ * 2. `await client.logout({ redirect: false })` — let the network call SETTLE,
1960
+ * and take the IdP logout URL rather than letting the client navigate mid-
1961
+ * sequence. Errors are captured, never rethrown.
1962
+ * 3. Navigate LAST, exactly once, when nothing is in flight to cancel:
1963
+ * - an IdP logout URL → a top-level navigation to the IdP, which ends that
1964
+ * session and bounces the user back. `onRedirect` is deliberately NOT
1965
+ * called: two competing navigations would race, and the IdP round-trip
1966
+ * already returns the user to the app.
1967
+ * - otherwise (ROPC / OTP / device-PIN logins never had an IdP cookie, so
1968
+ * the URL is `null`) → `onRedirect()`, the app's own login route.
1969
+ *
1970
+ * Step 3 runs even when step 2 rejects: a BFF that is down must not trap the
1971
+ * user on a page they asked to leave. The local session is already gone, so the
1972
+ * client-side sign-out holds regardless.
1973
+ *
1974
+ * Headless and framework-free: it takes callbacks, so an app can pair it with
1975
+ * Redux, a context, expo-router, or `window.location` without this package
1976
+ * knowing about any of them.
1977
+ */
1978
+ /**
1979
+ * The only part of a `BffAuthClient` this helper needs. Structural, so it is
1980
+ * trivially fakeable in tests and does not couple to the full client surface.
1981
+ */
1982
+ interface BffLogoutClient {
1983
+ /**
1984
+ * `POST /bff/logout` — ends the server-side session. With `redirect: false`
1985
+ * it returns the IdP's RP-initiated logout URL (or `null` when the session
1986
+ * never involved an IdP browser cookie) instead of navigating there itself.
1987
+ */
1988
+ logout: (options?: {
1989
+ redirect?: boolean;
1990
+ }) => Promise<string | null>;
1991
+ }
1992
+ /** Callbacks + client for {@link performBffLogout}. */
1993
+ interface PerformBffLogoutOptions {
1994
+ /** The BFF auth client whose `logout()` ends the server-side session. */
1995
+ client: BffLogoutClient;
1996
+ /** Drop local session state. Runs BEFORE the network call. Optional. */
1997
+ onClearSession?: () => void;
1998
+ /**
1999
+ * Navigate to the app's logged-out route. Runs only AFTER the network call
2000
+ * settles, and only when there is no IdP logout URL to honour. Optional.
2001
+ */
2002
+ onRedirect?: () => void;
2003
+ /** Observe a failed logout step (e.g. to log it). Never rethrown. Optional. */
2004
+ onError?: (error: Error) => void;
2005
+ /**
2006
+ * Test seam: perform the top-level navigation to the IdP logout URL. Defaults
2007
+ * to `window.location.assign`. Must be a real navigation — a `fetch` here
2008
+ * would not carry the IdP cookie and would silently fail to end the session.
2009
+ */
2010
+ navigateToIdp?: (url: string) => void;
2011
+ }
2012
+ /**
2013
+ * End the session: clear locally, await the BFF, then navigate exactly once.
2014
+ *
2015
+ * Never rejects — callers may `void` it safely.
2016
+ */
2017
+ declare function performBffLogout(options: Readonly<PerformBffLogoutOptions>): Promise<void>;
2018
+
1952
2019
  /**
1953
2020
  * The discrete ways a password can fail the shared client-side policy.
1954
2021
  *
@@ -1994,4 +2061,4 @@ declare function validatePasswordPolicy(password: string): PasswordPolicyError[]
1994
2061
  /** `true` when the password satisfies every policy rule. */
1995
2062
  declare function isPasswordValid(password: string): boolean;
1996
2063
 
1997
- export { AuthScreen, type AuthScreenProps, AuthTestIds, type AuthTheme, type AuthThemeColors, AuthThemeProvider, type AuthThemeProviderProps, type AuthThemeRadii, type AuthThemeSpacing, type AuthThemeTypography, BffAuthStatus, type BffDeviceState, type BffLoginConfig, type CreateBffAuthClientOptions, DEFAULT_AUTH_CARD_MAX_WIDTH, DEFAULT_DEVICE_PIN_ENROLL_LABELS, DEFAULT_DEVICE_PIN_SETTINGS_LABELS, DEFAULT_DEVICE_PIN_UNLOCK_LABELS, DEFAULT_FORGOT_PASSWORD_FIELDS_LABELS, DEFAULT_FORGOT_PASSWORD_LABELS, DEFAULT_LOGIN_LABELS, DEFAULT_OTP_LABELS, DEFAULT_PASSKEY_LOGIN_LABELS, DEFAULT_PASSKEY_SETTINGS_LABELS, DEFAULT_PIN_LABELS, DEFAULT_PREFERRED_METHOD_HINT_LABELS, DEFAULT_PREFERRED_METHOD_SETTINGS_LABELS, DEFAULT_RESET_PASSWORD_LABELS, DEVICE_PIN_ALLOWED_DIGITS, DEVICE_PIN_DEFAULT_DIGITS, type DevicePinCapableClient, type DevicePinDisableCapableClient, type DevicePinEnrollCapableClient, DevicePinEnrollErrorKey, DevicePinEnrollForm, type DevicePinEnrollFormProps, type DevicePinEnrollLabels, type DevicePinEnrollResult, DevicePinErrorKey, DevicePinInput, type DevicePinInputProps, DevicePinLengthPicker, type DevicePinLengthPickerProps, DevicePinOffer, type DevicePinOfferProps, DevicePinSettingsCard, type DevicePinSettingsCardProps, type DevicePinSettingsLabels, type DevicePinUnlockCapableClient, type DevicePinUnlockLabels, type DevicePinUnlockResult, DevicePinUnlockScreen, type DevicePinUnlockScreenProps, type DevicePinUnlockedUser, ForgotPasswordFields, type ForgotPasswordFieldsLabels, type ForgotPasswordFieldsProps, ForgotPasswordForm, type ForgotPasswordFormLabels, type ForgotPasswordFormProps, type LoginConfigCapableClient, LoginForm, type LoginFormLabels, type LoginFormProps, OtpForm, type OtpFormLabels, type OtpFormProps, OtpLoginStep, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PasskeyCeremonyMode, PasskeyCeremonyStatus, type PasskeyErrorCode, PasskeyLoginButton, type PasskeyLoginButtonProps, type PasskeyLoginLabels, PasskeySettingsCard, type PasskeySettingsCardProps, type PasskeySettingsLabels, PasswordPolicyError, PinForm, type PinFormLabels, type PinFormProps, type PreferencesClientOptions, type PreferredMethodClient, PreferredMethodHint, type PreferredMethodHintLabels, type PreferredMethodHintProps, PreferredMethodSettingsCard, type PreferredMethodSettingsCardProps, type PreferredMethodSettingsLabels, ResetPasswordError, ResetPasswordForm, type ResetPasswordFormLabels, type ResetPasswordFormProps, type RoleRoute, type RoleRouteTable, type UseBffAuthOptions, type UseBffAuthResult, type UseBffForgotPasswordOptions, type UseBffLoginConfigResult, type UseBffResetPasswordOptions, type UseDevicePinDisableArgs, type UseDevicePinDisableResult, type UseDevicePinEnrollArgs, type UseDevicePinEnrollResult, type UseDevicePinUnlockArgs, type UseDevicePinUnlockResult, type UseForgotPasswordSubmitArgs, type UseForgotPasswordSubmitResult, type UseOtpLoginOptions, type UseOtpLoginResult, type UsePasskeyCeremonyOptions, type UsePasskeyCeremonyResult, type UsePinLoginOptions, type UsePinLoginResult, type UsePreferredMethodSettingArgs, type UsePreferredMethodSettingResult, type UseResetPasswordFormArgs, type UseResetPasswordFormResult, cachePreferredMethod, clearCachedPreferredMethod, collectUserRoles, createBffAuthClient, createPreferencesClient, defaultAuthTheme, isAllowedPinDigits, isPasswordValid, isValidForgotPasswordEmail, readCachedPreferredMethod, readPasskeyError, readPasskeyRegistered, resolveAccessibleRoutes, resolvePostLoginRoute, startPasskeyLogin, startPasskeyRegistration, syncPreferredMethodFromServer, useAuthTheme, useBffAuth, useBffForgotPassword, useBffLoginConfig, useBffResetPassword, useDevicePinDisable, useDevicePinEnroll, useDevicePinUnlock, useForgotPasswordSubmit, useOtpLogin, usePasskeyCeremony, usePinLogin, usePreferredMethodSetting, useResetPasswordForm, validatePasswordPolicy, withTestIdPrefix };
2064
+ export { AuthScreen, type AuthScreenProps, AuthTestIds, type AuthTheme, type AuthThemeColors, AuthThemeProvider, type AuthThemeProviderProps, type AuthThemeRadii, type AuthThemeSpacing, type AuthThemeTypography, BffAuthStatus, type BffDeviceState, type BffLoginConfig, type BffLogoutClient, type CreateBffAuthClientOptions, DEFAULT_AUTH_CARD_MAX_WIDTH, DEFAULT_DEVICE_PIN_ENROLL_LABELS, DEFAULT_DEVICE_PIN_SETTINGS_LABELS, DEFAULT_DEVICE_PIN_UNLOCK_LABELS, DEFAULT_FORGOT_PASSWORD_FIELDS_LABELS, DEFAULT_FORGOT_PASSWORD_LABELS, DEFAULT_LOGIN_LABELS, DEFAULT_OTP_LABELS, DEFAULT_PASSKEY_LOGIN_LABELS, DEFAULT_PASSKEY_SETTINGS_LABELS, DEFAULT_PIN_LABELS, DEFAULT_PREFERRED_METHOD_HINT_LABELS, DEFAULT_PREFERRED_METHOD_SETTINGS_LABELS, DEFAULT_RESET_PASSWORD_LABELS, DEVICE_PIN_ALLOWED_DIGITS, DEVICE_PIN_DEFAULT_DIGITS, type DevicePinCapableClient, type DevicePinDisableCapableClient, type DevicePinEnrollCapableClient, DevicePinEnrollErrorKey, DevicePinEnrollForm, type DevicePinEnrollFormProps, type DevicePinEnrollLabels, type DevicePinEnrollResult, DevicePinErrorKey, DevicePinInput, type DevicePinInputProps, DevicePinLengthPicker, type DevicePinLengthPickerProps, DevicePinOffer, type DevicePinOfferProps, DevicePinSettingsCard, type DevicePinSettingsCardProps, type DevicePinSettingsLabels, type DevicePinUnlockCapableClient, type DevicePinUnlockLabels, type DevicePinUnlockResult, DevicePinUnlockScreen, type DevicePinUnlockScreenProps, type DevicePinUnlockedUser, ForgotPasswordFields, type ForgotPasswordFieldsLabels, type ForgotPasswordFieldsProps, ForgotPasswordForm, type ForgotPasswordFormLabels, type ForgotPasswordFormProps, type LoginConfigCapableClient, LoginForm, type LoginFormLabels, type LoginFormProps, OtpForm, type OtpFormLabels, type OtpFormProps, OtpLoginStep, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PasskeyCeremonyMode, PasskeyCeremonyStatus, type PasskeyErrorCode, PasskeyLoginButton, type PasskeyLoginButtonProps, type PasskeyLoginLabels, PasskeySettingsCard, type PasskeySettingsCardProps, type PasskeySettingsLabels, PasswordPolicyError, type PerformBffLogoutOptions, PinForm, type PinFormLabels, type PinFormProps, type PreferencesClientOptions, type PreferredMethodClient, PreferredMethodHint, type PreferredMethodHintLabels, type PreferredMethodHintProps, PreferredMethodSettingsCard, type PreferredMethodSettingsCardProps, type PreferredMethodSettingsLabels, ResetPasswordError, ResetPasswordForm, type ResetPasswordFormLabels, type ResetPasswordFormProps, type RoleRoute, type RoleRouteTable, type UseBffAuthOptions, type UseBffAuthResult, type UseBffForgotPasswordOptions, type UseBffLoginConfigResult, type UseBffResetPasswordOptions, type UseDevicePinDisableArgs, type UseDevicePinDisableResult, type UseDevicePinEnrollArgs, type UseDevicePinEnrollResult, type UseDevicePinUnlockArgs, type UseDevicePinUnlockResult, type UseForgotPasswordSubmitArgs, type UseForgotPasswordSubmitResult, type UseOtpLoginOptions, type UseOtpLoginResult, type UsePasskeyCeremonyOptions, type UsePasskeyCeremonyResult, type UsePinLoginOptions, type UsePinLoginResult, type UsePreferredMethodSettingArgs, type UsePreferredMethodSettingResult, type UseResetPasswordFormArgs, type UseResetPasswordFormResult, cachePreferredMethod, clearCachedPreferredMethod, collectUserRoles, createBffAuthClient, createPreferencesClient, defaultAuthTheme, isAllowedPinDigits, isPasswordValid, isValidForgotPasswordEmail, performBffLogout, readCachedPreferredMethod, readPasskeyError, readPasskeyRegistered, resolveAccessibleRoutes, resolvePostLoginRoute, startPasskeyLogin, startPasskeyRegistration, syncPreferredMethodFromServer, useAuthTheme, useBffAuth, useBffForgotPassword, useBffLoginConfig, useBffResetPassword, useDevicePinDisable, useDevicePinEnroll, useDevicePinUnlock, useForgotPasswordSubmit, useOtpLogin, usePasskeyCeremony, usePinLogin, usePreferredMethodSetting, useResetPasswordForm, validatePasswordPolicy, withTestIdPrefix };
package/dist/index.d.ts CHANGED
@@ -1694,30 +1694,6 @@ declare const enum PasskeyCeremonyStatus {
1694
1694
  Error = "error"
1695
1695
  }
1696
1696
 
1697
- /**
1698
- * Navigation helpers for the passkey (WebAuthn) BFF endpoints.
1699
- *
1700
- * Unlike the device-PIN client (which is plain `fetch`), the passkey login and
1701
- * registration ceremonies are NOT fetches — they are full-page BROWSER
1702
- * NAVIGATIONS. The BFF (`Bff.AspNetCore` 1.3.x) drives the WebAuthn ceremony
1703
- * through Keycloak's hosted pages, then redirects the browser back:
1704
- * - login: `/bff/passkey/login?returnUrl=<rel>` → on success the browser
1705
- * lands on `returnUrl` with the session cookie already set;
1706
- * - registration: `/bff/passkey/register?returnUrl=<rel>` → Keycloak re-auths
1707
- * the user (password) then runs the credential-creation ceremony, returning
1708
- * the browser to `returnUrl?passkey=registered`;
1709
- * - failure / cancel: the BFF redirects to `<login>?passkeyError=cancelled` or
1710
- * `<login>?passkeyError=failed`.
1711
- *
1712
- * react-query-FREE by construction: there is NO fetch here at all — every
1713
- * function is a pure `window.location` read or `window.location.assign`. The
1714
- * login route group has no QueryClient provider, so the passkey login button
1715
- * (which lives there) MUST avoid any react-query primitive; navigation satisfies
1716
- * that trivially.
1717
- *
1718
- * All reads are SSR-safe: when `window` is undefined (server render / native)
1719
- * they return a benign default rather than throwing.
1720
- */
1721
1697
  /** The recognised passkey error codes the BFF sends back via `?passkeyError=`. */
1722
1698
  type PasskeyErrorCode = 'cancelled' | 'failed';
1723
1699
  /**
@@ -1949,6 +1925,97 @@ declare function resolvePostLoginRoute(user: BffUser, table: RoleRouteTable): st
1949
1925
  */
1950
1926
  declare function resolveAccessibleRoutes(user: BffUser, table: RoleRouteTable): RoleRoute[];
1951
1927
 
1928
+ /**
1929
+ * `performBffLogout` — the shared logout sequencer.
1930
+ *
1931
+ * Every app hand-rolled its own logout ordering inside its `AuthProvider`, and
1932
+ * the orderings disagreed. Two of them (zygos-web, agora-web) navigated FIRST
1933
+ * and called the BFF second:
1934
+ *
1935
+ * ```ts
1936
+ * redirectTo('/(auth)/login'); // window.location.replace() → document unload
1937
+ * await bffAuthClient.logout(); // races the unload — may never be sent
1938
+ * ```
1939
+ *
1940
+ * That is wrong twice over:
1941
+ *
1942
+ * 1. **The BFF call can be cancelled.** A document navigation kills in-flight
1943
+ * fetches, so `POST /bff/logout` may never reach the server: the SPA looks
1944
+ * logged out while the httpOnly session cookie stays ALIVE. The `catch`
1945
+ * logged it "non-fatal", so the defect was silent.
1946
+ * 2. **The IdP session survives.** `BffAuthClient.logout()` ends the *identity
1947
+ * provider's* browser session by doing a top-level navigation to the IdP's
1948
+ * RP-initiated logout URL (only a real navigation carries the IdP's own
1949
+ * cookie — a back-channel POST cannot clear it). An app that navigates to
1950
+ * its own login route first pre-empts that navigation, so the IdP still
1951
+ * considers the user signed in and the next authorize round-trip silently
1952
+ * re-authenticates them. That is the "sign-out that signs you back in" bug
1953
+ * the client's own docs warn about.
1954
+ *
1955
+ * This helper owns the whole sequence so neither can be reintroduced per-app:
1956
+ *
1957
+ * 1. `onClearSession()` — drop local state immediately so the UI reflects the
1958
+ * sign-out right away (perceived performance; no spinner needed).
1959
+ * 2. `await client.logout({ redirect: false })` — let the network call SETTLE,
1960
+ * and take the IdP logout URL rather than letting the client navigate mid-
1961
+ * sequence. Errors are captured, never rethrown.
1962
+ * 3. Navigate LAST, exactly once, when nothing is in flight to cancel:
1963
+ * - an IdP logout URL → a top-level navigation to the IdP, which ends that
1964
+ * session and bounces the user back. `onRedirect` is deliberately NOT
1965
+ * called: two competing navigations would race, and the IdP round-trip
1966
+ * already returns the user to the app.
1967
+ * - otherwise (ROPC / OTP / device-PIN logins never had an IdP cookie, so
1968
+ * the URL is `null`) → `onRedirect()`, the app's own login route.
1969
+ *
1970
+ * Step 3 runs even when step 2 rejects: a BFF that is down must not trap the
1971
+ * user on a page they asked to leave. The local session is already gone, so the
1972
+ * client-side sign-out holds regardless.
1973
+ *
1974
+ * Headless and framework-free: it takes callbacks, so an app can pair it with
1975
+ * Redux, a context, expo-router, or `window.location` without this package
1976
+ * knowing about any of them.
1977
+ */
1978
+ /**
1979
+ * The only part of a `BffAuthClient` this helper needs. Structural, so it is
1980
+ * trivially fakeable in tests and does not couple to the full client surface.
1981
+ */
1982
+ interface BffLogoutClient {
1983
+ /**
1984
+ * `POST /bff/logout` — ends the server-side session. With `redirect: false`
1985
+ * it returns the IdP's RP-initiated logout URL (or `null` when the session
1986
+ * never involved an IdP browser cookie) instead of navigating there itself.
1987
+ */
1988
+ logout: (options?: {
1989
+ redirect?: boolean;
1990
+ }) => Promise<string | null>;
1991
+ }
1992
+ /** Callbacks + client for {@link performBffLogout}. */
1993
+ interface PerformBffLogoutOptions {
1994
+ /** The BFF auth client whose `logout()` ends the server-side session. */
1995
+ client: BffLogoutClient;
1996
+ /** Drop local session state. Runs BEFORE the network call. Optional. */
1997
+ onClearSession?: () => void;
1998
+ /**
1999
+ * Navigate to the app's logged-out route. Runs only AFTER the network call
2000
+ * settles, and only when there is no IdP logout URL to honour. Optional.
2001
+ */
2002
+ onRedirect?: () => void;
2003
+ /** Observe a failed logout step (e.g. to log it). Never rethrown. Optional. */
2004
+ onError?: (error: Error) => void;
2005
+ /**
2006
+ * Test seam: perform the top-level navigation to the IdP logout URL. Defaults
2007
+ * to `window.location.assign`. Must be a real navigation — a `fetch` here
2008
+ * would not carry the IdP cookie and would silently fail to end the session.
2009
+ */
2010
+ navigateToIdp?: (url: string) => void;
2011
+ }
2012
+ /**
2013
+ * End the session: clear locally, await the BFF, then navigate exactly once.
2014
+ *
2015
+ * Never rejects — callers may `void` it safely.
2016
+ */
2017
+ declare function performBffLogout(options: Readonly<PerformBffLogoutOptions>): Promise<void>;
2018
+
1952
2019
  /**
1953
2020
  * The discrete ways a password can fail the shared client-side policy.
1954
2021
  *
@@ -1994,4 +2061,4 @@ declare function validatePasswordPolicy(password: string): PasswordPolicyError[]
1994
2061
  /** `true` when the password satisfies every policy rule. */
1995
2062
  declare function isPasswordValid(password: string): boolean;
1996
2063
 
1997
- export { AuthScreen, type AuthScreenProps, AuthTestIds, type AuthTheme, type AuthThemeColors, AuthThemeProvider, type AuthThemeProviderProps, type AuthThemeRadii, type AuthThemeSpacing, type AuthThemeTypography, BffAuthStatus, type BffDeviceState, type BffLoginConfig, type CreateBffAuthClientOptions, DEFAULT_AUTH_CARD_MAX_WIDTH, DEFAULT_DEVICE_PIN_ENROLL_LABELS, DEFAULT_DEVICE_PIN_SETTINGS_LABELS, DEFAULT_DEVICE_PIN_UNLOCK_LABELS, DEFAULT_FORGOT_PASSWORD_FIELDS_LABELS, DEFAULT_FORGOT_PASSWORD_LABELS, DEFAULT_LOGIN_LABELS, DEFAULT_OTP_LABELS, DEFAULT_PASSKEY_LOGIN_LABELS, DEFAULT_PASSKEY_SETTINGS_LABELS, DEFAULT_PIN_LABELS, DEFAULT_PREFERRED_METHOD_HINT_LABELS, DEFAULT_PREFERRED_METHOD_SETTINGS_LABELS, DEFAULT_RESET_PASSWORD_LABELS, DEVICE_PIN_ALLOWED_DIGITS, DEVICE_PIN_DEFAULT_DIGITS, type DevicePinCapableClient, type DevicePinDisableCapableClient, type DevicePinEnrollCapableClient, DevicePinEnrollErrorKey, DevicePinEnrollForm, type DevicePinEnrollFormProps, type DevicePinEnrollLabels, type DevicePinEnrollResult, DevicePinErrorKey, DevicePinInput, type DevicePinInputProps, DevicePinLengthPicker, type DevicePinLengthPickerProps, DevicePinOffer, type DevicePinOfferProps, DevicePinSettingsCard, type DevicePinSettingsCardProps, type DevicePinSettingsLabels, type DevicePinUnlockCapableClient, type DevicePinUnlockLabels, type DevicePinUnlockResult, DevicePinUnlockScreen, type DevicePinUnlockScreenProps, type DevicePinUnlockedUser, ForgotPasswordFields, type ForgotPasswordFieldsLabels, type ForgotPasswordFieldsProps, ForgotPasswordForm, type ForgotPasswordFormLabels, type ForgotPasswordFormProps, type LoginConfigCapableClient, LoginForm, type LoginFormLabels, type LoginFormProps, OtpForm, type OtpFormLabels, type OtpFormProps, OtpLoginStep, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PasskeyCeremonyMode, PasskeyCeremonyStatus, type PasskeyErrorCode, PasskeyLoginButton, type PasskeyLoginButtonProps, type PasskeyLoginLabels, PasskeySettingsCard, type PasskeySettingsCardProps, type PasskeySettingsLabels, PasswordPolicyError, PinForm, type PinFormLabels, type PinFormProps, type PreferencesClientOptions, type PreferredMethodClient, PreferredMethodHint, type PreferredMethodHintLabels, type PreferredMethodHintProps, PreferredMethodSettingsCard, type PreferredMethodSettingsCardProps, type PreferredMethodSettingsLabels, ResetPasswordError, ResetPasswordForm, type ResetPasswordFormLabels, type ResetPasswordFormProps, type RoleRoute, type RoleRouteTable, type UseBffAuthOptions, type UseBffAuthResult, type UseBffForgotPasswordOptions, type UseBffLoginConfigResult, type UseBffResetPasswordOptions, type UseDevicePinDisableArgs, type UseDevicePinDisableResult, type UseDevicePinEnrollArgs, type UseDevicePinEnrollResult, type UseDevicePinUnlockArgs, type UseDevicePinUnlockResult, type UseForgotPasswordSubmitArgs, type UseForgotPasswordSubmitResult, type UseOtpLoginOptions, type UseOtpLoginResult, type UsePasskeyCeremonyOptions, type UsePasskeyCeremonyResult, type UsePinLoginOptions, type UsePinLoginResult, type UsePreferredMethodSettingArgs, type UsePreferredMethodSettingResult, type UseResetPasswordFormArgs, type UseResetPasswordFormResult, cachePreferredMethod, clearCachedPreferredMethod, collectUserRoles, createBffAuthClient, createPreferencesClient, defaultAuthTheme, isAllowedPinDigits, isPasswordValid, isValidForgotPasswordEmail, readCachedPreferredMethod, readPasskeyError, readPasskeyRegistered, resolveAccessibleRoutes, resolvePostLoginRoute, startPasskeyLogin, startPasskeyRegistration, syncPreferredMethodFromServer, useAuthTheme, useBffAuth, useBffForgotPassword, useBffLoginConfig, useBffResetPassword, useDevicePinDisable, useDevicePinEnroll, useDevicePinUnlock, useForgotPasswordSubmit, useOtpLogin, usePasskeyCeremony, usePinLogin, usePreferredMethodSetting, useResetPasswordForm, validatePasswordPolicy, withTestIdPrefix };
2064
+ export { AuthScreen, type AuthScreenProps, AuthTestIds, type AuthTheme, type AuthThemeColors, AuthThemeProvider, type AuthThemeProviderProps, type AuthThemeRadii, type AuthThemeSpacing, type AuthThemeTypography, BffAuthStatus, type BffDeviceState, type BffLoginConfig, type BffLogoutClient, type CreateBffAuthClientOptions, DEFAULT_AUTH_CARD_MAX_WIDTH, DEFAULT_DEVICE_PIN_ENROLL_LABELS, DEFAULT_DEVICE_PIN_SETTINGS_LABELS, DEFAULT_DEVICE_PIN_UNLOCK_LABELS, DEFAULT_FORGOT_PASSWORD_FIELDS_LABELS, DEFAULT_FORGOT_PASSWORD_LABELS, DEFAULT_LOGIN_LABELS, DEFAULT_OTP_LABELS, DEFAULT_PASSKEY_LOGIN_LABELS, DEFAULT_PASSKEY_SETTINGS_LABELS, DEFAULT_PIN_LABELS, DEFAULT_PREFERRED_METHOD_HINT_LABELS, DEFAULT_PREFERRED_METHOD_SETTINGS_LABELS, DEFAULT_RESET_PASSWORD_LABELS, DEVICE_PIN_ALLOWED_DIGITS, DEVICE_PIN_DEFAULT_DIGITS, type DevicePinCapableClient, type DevicePinDisableCapableClient, type DevicePinEnrollCapableClient, DevicePinEnrollErrorKey, DevicePinEnrollForm, type DevicePinEnrollFormProps, type DevicePinEnrollLabels, type DevicePinEnrollResult, DevicePinErrorKey, DevicePinInput, type DevicePinInputProps, DevicePinLengthPicker, type DevicePinLengthPickerProps, DevicePinOffer, type DevicePinOfferProps, DevicePinSettingsCard, type DevicePinSettingsCardProps, type DevicePinSettingsLabels, type DevicePinUnlockCapableClient, type DevicePinUnlockLabels, type DevicePinUnlockResult, DevicePinUnlockScreen, type DevicePinUnlockScreenProps, type DevicePinUnlockedUser, ForgotPasswordFields, type ForgotPasswordFieldsLabels, type ForgotPasswordFieldsProps, ForgotPasswordForm, type ForgotPasswordFormLabels, type ForgotPasswordFormProps, type LoginConfigCapableClient, LoginForm, type LoginFormLabels, type LoginFormProps, OtpForm, type OtpFormLabels, type OtpFormProps, OtpLoginStep, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PasskeyCeremonyMode, PasskeyCeremonyStatus, type PasskeyErrorCode, PasskeyLoginButton, type PasskeyLoginButtonProps, type PasskeyLoginLabels, PasskeySettingsCard, type PasskeySettingsCardProps, type PasskeySettingsLabels, PasswordPolicyError, type PerformBffLogoutOptions, PinForm, type PinFormLabels, type PinFormProps, type PreferencesClientOptions, type PreferredMethodClient, PreferredMethodHint, type PreferredMethodHintLabels, type PreferredMethodHintProps, PreferredMethodSettingsCard, type PreferredMethodSettingsCardProps, type PreferredMethodSettingsLabels, ResetPasswordError, ResetPasswordForm, type ResetPasswordFormLabels, type ResetPasswordFormProps, type RoleRoute, type RoleRouteTable, type UseBffAuthOptions, type UseBffAuthResult, type UseBffForgotPasswordOptions, type UseBffLoginConfigResult, type UseBffResetPasswordOptions, type UseDevicePinDisableArgs, type UseDevicePinDisableResult, type UseDevicePinEnrollArgs, type UseDevicePinEnrollResult, type UseDevicePinUnlockArgs, type UseDevicePinUnlockResult, type UseForgotPasswordSubmitArgs, type UseForgotPasswordSubmitResult, type UseOtpLoginOptions, type UseOtpLoginResult, type UsePasskeyCeremonyOptions, type UsePasskeyCeremonyResult, type UsePinLoginOptions, type UsePinLoginResult, type UsePreferredMethodSettingArgs, type UsePreferredMethodSettingResult, type UseResetPasswordFormArgs, type UseResetPasswordFormResult, cachePreferredMethod, clearCachedPreferredMethod, collectUserRoles, createBffAuthClient, createPreferencesClient, defaultAuthTheme, isAllowedPinDigits, isPasswordValid, isValidForgotPasswordEmail, performBffLogout, readCachedPreferredMethod, readPasskeyError, readPasskeyRegistered, resolveAccessibleRoutes, resolvePostLoginRoute, startPasskeyLogin, startPasskeyRegistration, syncPreferredMethodFromServer, useAuthTheme, useBffAuth, useBffForgotPassword, useBffLoginConfig, useBffResetPassword, useDevicePinDisable, useDevicePinEnroll, useDevicePinUnlock, useForgotPasswordSubmit, useOtpLogin, usePasskeyCeremony, usePinLogin, usePreferredMethodSetting, useResetPasswordForm, validatePasswordPolicy, withTestIdPrefix };
package/dist/index.js CHANGED
@@ -2520,6 +2520,18 @@ var PasskeyCeremonyMode = /* @__PURE__ */ ((PasskeyCeremonyMode2) => {
2520
2520
  return PasskeyCeremonyMode2;
2521
2521
  })(PasskeyCeremonyMode || {});
2522
2522
 
2523
+ // src/passkey/browserLocation.ts
2524
+ function getBrowserLocation() {
2525
+ if (typeof window === "undefined") {
2526
+ return null;
2527
+ }
2528
+ const browserLocation = window.location;
2529
+ if (browserLocation === void 0) {
2530
+ return null;
2531
+ }
2532
+ return browserLocation;
2533
+ }
2534
+
2523
2535
  // src/passkey/passkeyNavigation.ts
2524
2536
  var PASSKEY_LOGIN_ENDPOINT = "/bff/passkey/login";
2525
2537
  var PASSKEY_REGISTER_ENDPOINT = "/bff/passkey/register";
@@ -2533,22 +2545,25 @@ function buildNavigationUrl(endpoint, returnUrl) {
2533
2545
  return `${endpoint}?${RETURN_URL_PARAM}=${encodeURIComponent(returnUrl)}`;
2534
2546
  }
2535
2547
  function readSearchParams() {
2536
- if (typeof window === "undefined") {
2548
+ const browserLocation = getBrowserLocation();
2549
+ if (browserLocation === null) {
2537
2550
  return null;
2538
2551
  }
2539
- return new URLSearchParams(window.location.search);
2552
+ return new URLSearchParams(browserLocation.search);
2540
2553
  }
2541
2554
  function startPasskeyLogin(returnUrl) {
2542
- if (typeof window === "undefined") {
2555
+ const browserLocation = getBrowserLocation();
2556
+ if (browserLocation === null) {
2543
2557
  return;
2544
2558
  }
2545
- window.location.assign(buildNavigationUrl(PASSKEY_LOGIN_ENDPOINT, returnUrl));
2559
+ browserLocation.assign(buildNavigationUrl(PASSKEY_LOGIN_ENDPOINT, returnUrl));
2546
2560
  }
2547
2561
  function startPasskeyRegistration(returnUrl) {
2548
- if (typeof window === "undefined") {
2562
+ const browserLocation = getBrowserLocation();
2563
+ if (browserLocation === null) {
2549
2564
  return;
2550
2565
  }
2551
- window.location.assign(buildNavigationUrl(PASSKEY_REGISTER_ENDPOINT, returnUrl));
2566
+ browserLocation.assign(buildNavigationUrl(PASSKEY_REGISTER_ENDPOINT, returnUrl));
2552
2567
  }
2553
2568
  function readPasskeyError() {
2554
2569
  const params = readSearchParams();
@@ -2725,8 +2740,9 @@ function usePasskeyCeremony(optionsArg) {
2725
2740
  onSuccess(user);
2726
2741
  return;
2727
2742
  }
2728
- if (mode === "login" /* Login */ && typeof window !== "undefined") {
2729
- window.location.assign(returnUrl);
2743
+ const browserLocation = getBrowserLocation();
2744
+ if (mode === "login" /* Login */ && browserLocation !== null) {
2745
+ browserLocation.assign(returnUrl);
2730
2746
  }
2731
2747
  },
2732
2748
  [mode, onSuccess, returnUrl]
@@ -2848,10 +2864,11 @@ function PasskeyLoginButton({
2848
2864
  }
2849
2865
  var DEFAULT_RETURN_PATH = "/";
2850
2866
  function readCurrentPath() {
2851
- if (typeof window === "undefined") {
2867
+ const browserLocation = getBrowserLocation();
2868
+ if (browserLocation === null) {
2852
2869
  return DEFAULT_RETURN_PATH;
2853
2870
  }
2854
- return window.location.pathname;
2871
+ return browserLocation.pathname;
2855
2872
  }
2856
2873
  function PasskeySettingsCard({
2857
2874
  returnUrl,
@@ -3269,6 +3286,43 @@ function resolveAccessibleRoutes(user, table) {
3269
3286
  return table.routes.filter((entry) => userRoles.has(entry.role));
3270
3287
  }
3271
3288
 
3289
+ // src/session/performBffLogout.ts
3290
+ function toError4(caught) {
3291
+ return caught instanceof Error ? caught : new Error(String(caught));
3292
+ }
3293
+ function runGuarded(callback, onError) {
3294
+ if (callback === void 0) {
3295
+ return;
3296
+ }
3297
+ try {
3298
+ callback();
3299
+ } catch (caught) {
3300
+ onError?.(toError4(caught));
3301
+ }
3302
+ }
3303
+ function assignLocation(url) {
3304
+ if (typeof window === "undefined") {
3305
+ return;
3306
+ }
3307
+ window.location.assign(url);
3308
+ }
3309
+ async function performBffLogout(options) {
3310
+ const { client, onClearSession, onRedirect, onError, navigateToIdp = assignLocation } = options;
3311
+ runGuarded(onClearSession, onError);
3312
+ let idpLogoutUrl = null;
3313
+ try {
3314
+ idpLogoutUrl = await client.logout({ redirect: false });
3315
+ } catch (caught) {
3316
+ onError?.(toError4(caught));
3317
+ }
3318
+ if (idpLogoutUrl !== null) {
3319
+ const url = idpLogoutUrl;
3320
+ runGuarded(() => navigateToIdp(url), onError);
3321
+ return;
3322
+ }
3323
+ runGuarded(onRedirect, onError);
3324
+ }
3325
+
3272
3326
  Object.defineProperty(exports, "BffAuthClient", {
3273
3327
  enumerable: true,
3274
3328
  get: function () { return authClient.BffAuthClient; }
@@ -3331,6 +3385,7 @@ exports.defaultAuthTheme = defaultAuthTheme;
3331
3385
  exports.isAllowedPinDigits = isAllowedPinDigits;
3332
3386
  exports.isPasswordValid = isPasswordValid;
3333
3387
  exports.isValidForgotPasswordEmail = isValidForgotPasswordEmail;
3388
+ exports.performBffLogout = performBffLogout;
3334
3389
  exports.readCachedPreferredMethod = readCachedPreferredMethod;
3335
3390
  exports.readPasskeyError = readPasskeyError;
3336
3391
  exports.readPasskeyRegistered = readPasskeyRegistered;