Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -17,20 +16,20 @@ The package includes a `<Provider>` 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 = () => (
/**
Expand All @@ -41,13 +40,11 @@ const App = () => (
</Provider>
);


const ExampleComponent = () => {
/**
* The `useGlobusAuth` hook provides access to the authentication state and the `AuthorizationManager` instance.
*/
const { isAuthenticated, authorization } = useGlobusAuth();


useEffect(() => {
async function attempt() {
Expand All @@ -65,14 +62,49 @@ const ExampleComponent = () => {
return (
<div>
{isAuthenticated ? (
<button onClick={async () => await auth.authorization?.revoke()}>Logout</button>
<button onClick={async () => await auth.authorization?.revoke()}>
Logout
</button>
) : (
<button onClick={async () => await auth.authorization?.login()}>Login</button>
<button onClick={async () => await auth.authorization?.login()}>
Login
</button>
)}
</div>
);
};
```

## 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 = () => (
<Provider client={client} scopes={scopes} redirect={redirect}>
<GlobusAuthLifecycleProvider
onBeforeRedirect={() => {
// Runs immediately before `login`, `handleConsentRequiredError`,
// or `handleAuthorizationRequirementsError` redirect the user.
sessionStorage.setItem("returnTo", window.location.pathname);
}}
>
<ExampleComponent />
</GlobusAuthLifecycleProvider>
</Provider>
);
```

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)
15 changes: 15 additions & 0 deletions src/LifecycleContext.ts
Original file line number Diff line number Diff line change
@@ -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<LifecycleHandlers>({});
GlobusAuthLifecycleContext.displayName = "GlobusAuthLifecycleContext";

export default GlobusAuthLifecycleContext;
33 changes: 33 additions & 0 deletions src/LifecycleProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useMemo } from "react";

import GlobusAuthLifecycleContext, {
type LifecycleHandlers,
} from "./LifecycleContext";

export type LifecycleProviderProps =
React.PropsWithChildren<LifecycleHandlers>;

/**
* 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<LifecycleHandlers>(
() => ({ onBeforeRedirect }),
[onBeforeRedirect],
);

return (
<GlobusAuthLifecycleContext.Provider value={value}>
{children}
</GlobusAuthLifecycleContext.Provider>
);
};
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
export { useGlobusAuth } from "./useGlobusAuth";
export { GlobusAuthLifecycleProvider } from "./LifecycleProvider";
export type { LifecycleHandlers } from "./LifecycleContext";
24 changes: 22 additions & 2 deletions src/useGlobusAuth.ts
Original file line number Diff line number Diff line change
@@ -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;
};
42 changes: 42 additions & 0 deletions src/wrapAuthorizationWithLifecycle.ts
Original file line number Diff line number Diff line change
@@ -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;
},
});
};
112 changes: 112 additions & 0 deletions test/LifecycleProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useGlobusAuth>["authorization"]>) => void;
}) {
const { authorization } = useGlobusAuth();
useEffect(() => {
if (authorization) {
onReady(authorization);
}
}, [authorization, onReady]);
return null;
}

function TriggerConsentRequiredError() {
const { authorization } = useGlobusAuth();
return (
<button
onClick={() =>
authorization?.handleConsentRequiredError({
code: "ConsentRequired",
required_scopes: [],
})
}
>
trigger-consent-required
</button>
);
}

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<typeof useGlobusAuth>["authorization"];

render(
<Provider {...props}>
<CaptureRawAuthorization
onReady={(authorization) => {
rawAuthorization = authorization;
}}
/>
<GlobusAuthLifecycleProvider onBeforeRedirect={onBeforeRedirect}>
<TriggerConsentRequiredError />
</GlobusAuthLifecycleProvider>
</Provider>,
);

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<typeof useGlobusAuth>["authorization"];

render(
<Provider {...props}>
<CaptureRawAuthorization
onReady={(authorization) => {
rawAuthorization = authorization;
}}
/>
<TriggerConsentRequiredError />
</Provider>,
);

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