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(); + }); +});