diff --git a/README.md b/README.md
index 82919e5..bdf553b 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,6 @@
A simple React context for managing Globus-related authentication state, built on top of the [@globus/sdk](https://github.com/globus/globus-sdk-javascript).
-
## Installation
```
@@ -17,20 +16,20 @@ The package includes a `` that can be configured with a `client`, `sco
```tsx
import React, { useEffect } from "react";
-import { Provider, useGlobusAuth } from '@globus/react-auth-context';
+import { Provider, useGlobusAuth } from "@globus/react-auth-context";
/**
* Your registered Globus Client ID.
*/
-const client = '645b6bfb-4195-4010-83f5-a71332bd4761';
+const client = "645b6bfb-4195-4010-83f5-a71332bd4761";
/**
* Scopes required for your application on login.
*/
-const scopes = 'urn:globus:auth:scope:transfer.api.globus.org:all';
+const scopes = "urn:globus:auth:scope:transfer.api.globus.org:all";
/**
* Redirect URL that will complete the OAuth2 flow, this will also be the location you call `.handleCodeRedirect` from.
*/
-const redirect = '/';
+const redirect = "/";
const App = () => (
/**
@@ -41,13 +40,11 @@ const App = () => (
);
-
const ExampleComponent = () => {
/**
* The `useGlobusAuth` hook provides access to the authentication state and the `AuthorizationManager` instance.
*/
const { isAuthenticated, authorization } = useGlobusAuth();
-
useEffect(() => {
async function attempt() {
@@ -65,14 +62,49 @@ const ExampleComponent = () => {
return (
{isAuthenticated ? (
-
+
) : (
-
+
)}
);
};
```
+## Lifecycle Hooks
+
+`GlobusAuthLifecycleProvider` lets you run your own logic immediately before any OAuth redirect is triggered — via `login()`, `handleConsentRequiredError()`, or `handleAuthorizationRequirementsError()`.
+
+It's a separate, independently placeable provider: it does not replace `Provider`, and can be wrapped around any subtree beneath it. This is useful, for example, if you need to persist some client-side state (e.g., the current route) before the user is redirected away to authenticate.
+
+```tsx
+import {
+ Provider,
+ GlobusAuthLifecycleProvider,
+ useGlobusAuth,
+} from "@globus/react-auth-context";
+
+const App = () => (
+
+ {
+ // Runs immediately before `login`, `handleConsentRequiredError`,
+ // or `handleAuthorizationRequirementsError` redirect the user.
+ sessionStorage.setItem("returnTo", window.location.pathname);
+ }}
+ >
+
+
+
+);
+```
+
+Any call to `useGlobusAuth().authorization` from within the `GlobusAuthLifecycleProvider`'s subtree will use these hooks transparently — no changes are required to existing consumers of `useGlobusAuth`. If no `GlobusAuthLifecycleProvider` is present (or no `onBeforeRedirect` is provided), `useGlobusAuth` behaves exactly as it does without this provider.
+
+---
- [API Documentation](/docs/globals.md)
diff --git a/src/LifecycleContext.ts b/src/LifecycleContext.ts
new file mode 100644
index 0000000..2ec1d86
--- /dev/null
+++ b/src/LifecycleContext.ts
@@ -0,0 +1,15 @@
+import { createContext } from "react";
+
+export type LifecycleHandlers = {
+ /**
+ * Called immediately before any OAuth redirect-triggering method
+ * (`login`, `handleConsentRequiredError`, `handleAuthorizationRequirementsError`)
+ * is invoked on the `AuthorizationManager` returned by `useGlobusAuth()`.
+ */
+ onBeforeRedirect?: () => void;
+};
+
+const GlobusAuthLifecycleContext = createContext({});
+GlobusAuthLifecycleContext.displayName = "GlobusAuthLifecycleContext";
+
+export default GlobusAuthLifecycleContext;
diff --git a/src/LifecycleProvider.tsx b/src/LifecycleProvider.tsx
new file mode 100644
index 0000000..b52f93a
--- /dev/null
+++ b/src/LifecycleProvider.tsx
@@ -0,0 +1,33 @@
+import React, { useMemo } from "react";
+
+import GlobusAuthLifecycleContext, {
+ type LifecycleHandlers,
+} from "./LifecycleContext";
+
+export type LifecycleProviderProps =
+ React.PropsWithChildren;
+
+/**
+ * A provider that allows consumers to intercept OAuth redirect-triggering
+ * methods (`login`, `handleConsentRequiredError`, `handleAuthorizationRequirementsError`)
+ * on the `AuthorizationManager` returned by `useGlobusAuth()`, without prop-drilling
+ * a callback through the component tree.
+ *
+ * This is a separate, independently placeable provider – it does not replace
+ * `Provider` and can be placed anywhere below it in the tree.
+ */
+export const GlobusAuthLifecycleProvider = ({
+ onBeforeRedirect,
+ children,
+}: LifecycleProviderProps): JSX.Element => {
+ const value = useMemo(
+ () => ({ onBeforeRedirect }),
+ [onBeforeRedirect],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/index.ts b/src/index.ts
index f41e3f1..9eca24d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,4 +2,6 @@ export * as State from "./State";
export * as Context from "./Context";
export { Provider } from "./Provider";
export { reducer } from "./reducer";
-export { useGlobusAuth } from "./useGlobusAuth";
\ No newline at end of file
+export { useGlobusAuth } from "./useGlobusAuth";
+export { GlobusAuthLifecycleProvider } from "./LifecycleProvider";
+export type { LifecycleHandlers } from "./LifecycleContext";
\ No newline at end of file
diff --git a/src/useGlobusAuth.ts b/src/useGlobusAuth.ts
index abea6df..d985951 100644
--- a/src/useGlobusAuth.ts
+++ b/src/useGlobusAuth.ts
@@ -1,10 +1,30 @@
-import { useContext } from "react";
+import { useContext, useMemo } from "react";
import Context, { type GlobusAuthContextProps } from "./Context";
+import GlobusAuthLifecycleContext from "./LifecycleContext";
+import { wrapAuthorizationWithLifecycle } from "./wrapAuthorizationWithLifecycle";
export const useGlobusAuth = (): GlobusAuthContextProps => {
const context = useContext(Context);
+ const { onBeforeRedirect } = useContext(GlobusAuthLifecycleContext);
+
+ const authorization = context?.authorization;
+
+ const wrappedAuthorization = useMemo(() => {
+ if (!authorization || !onBeforeRedirect) {
+ return authorization;
+ }
+ return wrapAuthorizationWithLifecycle(authorization, onBeforeRedirect);
+ }, [authorization, onBeforeRedirect]);
+
+ const result = useMemo(() => {
+ if (!context || wrappedAuthorization === context.authorization) {
+ return context;
+ }
+ return { ...context, authorization: wrappedAuthorization };
+ }, [context, wrappedAuthorization]);
+
if (!context) {
console.warn('No context found for Globus Auth, please ensure useGlobusAuth() is being used in a child of a provider component.')
}
- return context as unknown as GlobusAuthContextProps;
+ return result as unknown as GlobusAuthContextProps;
};
diff --git a/src/wrapAuthorizationWithLifecycle.ts b/src/wrapAuthorizationWithLifecycle.ts
new file mode 100644
index 0000000..308b0ad
--- /dev/null
+++ b/src/wrapAuthorizationWithLifecycle.ts
@@ -0,0 +1,42 @@
+import type { AuthorizationManager } from "@globus/sdk/core/authorization/AuthorizationManager";
+
+/**
+ * Methods on `AuthorizationManager` that result in an OAuth redirect.
+ */
+const REDIRECT_METHODS = [
+ "login",
+ "handleConsentRequiredError",
+ "handleAuthorizationRequirementsError",
+] as const;
+
+type RedirectMethod = (typeof REDIRECT_METHODS)[number];
+
+const isRedirectMethod = (prop: string | symbol): prop is RedirectMethod =>
+ (REDIRECT_METHODS as readonly (string | symbol)[]).includes(prop);
+
+/**
+ * Wraps an `AuthorizationManager` so that `onBeforeRedirect` is called
+ * immediately before any of `REDIRECT_METHODS` are invoked.
+ *
+ * A `Proxy` is used (rather than spreading/subclassing) because
+ * `AuthorizationManager` relies on private class fields; methods and
+ * getters must be invoked with the original instance as `this` in order
+ * to access them.
+ */
+export const wrapAuthorizationWithLifecycle = (
+ authorization: AuthorizationManager,
+ onBeforeRedirect: () => void,
+): AuthorizationManager => {
+ return new Proxy(authorization, {
+ get(target, prop) {
+ const value = Reflect.get(target, prop);
+ if (isRedirectMethod(prop) && typeof value === "function") {
+ return (...args: unknown[]) => {
+ onBeforeRedirect();
+ return value.apply(target, args);
+ };
+ }
+ return value;
+ },
+ });
+};
diff --git a/test/LifecycleProvider.test.tsx b/test/LifecycleProvider.test.tsx
new file mode 100644
index 0000000..06b93fd
--- /dev/null
+++ b/test/LifecycleProvider.test.tsx
@@ -0,0 +1,112 @@
+import React, { useEffect } from "react";
+import "jest-location-mock";
+import "@testing-library/jest-dom";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+
+import { Provider, type Props } from "../src/Provider";
+import { useGlobusAuth } from "../src/useGlobusAuth";
+import { GlobusAuthLifecycleProvider } from "../src/LifecycleProvider";
+
+const props: Props = {
+ client: "dda4edf0-6a95-474d-92e1-9f46040b5d75",
+ scopes: "urn:globus:auth:scope:transfer.api.globus.org:all",
+ redirect: "https://example.com/callback",
+};
+
+/**
+ * Renders outside of any `GlobusAuthLifecycleProvider`, giving the test
+ * access to the raw (unwrapped) `AuthorizationManager` instance so its
+ * redirect-triggering methods can be stubbed.
+ */
+function CaptureRawAuthorization({
+ onReady,
+}: {
+ onReady: (authorization: NonNullable["authorization"]>) => void;
+}) {
+ const { authorization } = useGlobusAuth();
+ useEffect(() => {
+ if (authorization) {
+ onReady(authorization);
+ }
+ }, [authorization, onReady]);
+ return null;
+}
+
+function TriggerConsentRequiredError() {
+ const { authorization } = useGlobusAuth();
+ return (
+
+ );
+}
+
+describe("GlobusAuthLifecycleProvider", () => {
+ it("calls onBeforeRedirect before handleConsentRequiredError when placed inside the provider", async () => {
+ const order: string[] = [];
+ const onBeforeRedirect = jest.fn(() => order.push("onBeforeRedirect"));
+ let rawAuthorization: ReturnType["authorization"];
+
+ render(
+
+ {
+ rawAuthorization = authorization;
+ }}
+ />
+
+
+
+ ,
+ );
+
+ await waitFor(() => expect(rawAuthorization).toBeDefined());
+
+ // Stub the real method on the underlying instance so we can observe
+ // the order in which it is called relative to `onBeforeRedirect`.
+ rawAuthorization!.handleConsentRequiredError = jest.fn(() => {
+ order.push("handleConsentRequiredError");
+ return Promise.resolve();
+ });
+
+ fireEvent.click(screen.getByText("trigger-consent-required"));
+
+ await waitFor(() =>
+ expect(order).toEqual(["onBeforeRedirect", "handleConsentRequiredError"]),
+ );
+ expect(onBeforeRedirect).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not call onBeforeRedirect when used outside of the provider", async () => {
+ const onBeforeRedirect = jest.fn();
+ let rawAuthorization: ReturnType["authorization"];
+
+ render(
+
+ {
+ rawAuthorization = authorization;
+ }}
+ />
+
+ ,
+ );
+
+ await waitFor(() => expect(rawAuthorization).toBeDefined());
+
+ const handleConsentRequiredError = jest.fn(() => Promise.resolve());
+ rawAuthorization!.handleConsentRequiredError = handleConsentRequiredError;
+
+ fireEvent.click(screen.getByText("trigger-consent-required"));
+
+ await waitFor(() => expect(handleConsentRequiredError).toHaveBeenCalledTimes(1));
+ expect(onBeforeRedirect).not.toHaveBeenCalled();
+ });
+});