Skip to content

Commit

Permalink
Merge pull request #26 from bento-platform/feat/auth/multi-resource-p…
Browse files Browse the repository at this point in the history
…erms

Multi-resource hook for fetching permissions
  • Loading branch information
davidlougheed authored Apr 19, 2024
2 parents 6fd0201 + a86f79d commit 0c9310d
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 63 deletions.
57 changes: 38 additions & 19 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ThunkAction } from "redux-thunk";

import { useBentoAuthContext } from "./contexts";
import { Resource, makeResourceKey } from "./resources";
import { fetchResourcePermissions, refreshTokens, tokenHandoff } from "./redux/authSlice";
import { AuthSliceState, fetchResourcesPermissions, refreshTokens, tokenHandoff } from "./redux/authSlice";
import { LS_SIGN_IN_POPUP, createAuthURL } from "./performAuth";
import { fetchOpenIdConfigurationIfNecessary } from "./redux/openIdConfigSlice";
import { getIsAuthenticated, logMissingAuthContext, makeAuthorizationHeader } from "./utils";
Expand All @@ -16,47 +16,66 @@ const AUTH_RESULT_TYPE = "authResult";

type MessageHandlerFunc = (e: MessageEvent) => void;

export const useAuthState = (): AuthSliceState => useSelector((state: RootState) => state.auth);

export const useIsAuthenticated = () => {
const { idTokenContents } = useSelector((state: RootState) => state.auth);
const { idTokenContents } = useAuthState();
return getIsAuthenticated(idTokenContents);
};

export const useAccessToken = () => useSelector((state: RootState) => state.auth.accessToken);
export const useAccessToken = () => useAuthState().accessToken;

export const useAuthorizationHeader = () => {
const accessToken = useAccessToken();
return useMemo(() => makeAuthorizationHeader(accessToken), [accessToken]);
};

export const useResourcePermissions = (resource: Resource, authzUrl: string) => {
export const useResourcesPermissions = (resources: Resource[], authzUrl: string) => {
const dispatch: AppDispatch = useDispatch();

const haveAuthorizationService = !!authzUrl;

const key = useMemo(() => makeResourceKey(resource), [resource]);
const keys = useMemo(() => resources.map((resource) => makeResourceKey(resource)), [resources]);

const { permissions, isFetching, hasAttempted, error } =
useSelector((state: RootState) => state.auth.resourcePermissions?.[key]) ?? {};
const { resourcePermissions } = useAuthState();

useEffect(() => {
if (!haveAuthorizationService || isFetching || permissions || hasAttempted) return;
dispatch(fetchResourcePermissions({ resource, authzUrl }));
const anyFetching = keys.some((key) => !!resourcePermissions[key]?.isFetching);
const allHavePermissions = keys.every((key) => !!resourcePermissions[key]?.permissions?.length);
const allAttempted = keys.every((key) => !!resourcePermissions[key]?.hasAttempted);

// If any permissions are currently fetching, or all requested permissions have already been tried/returned, we
// don't need to dispatch the fetch action:
if (!haveAuthorizationService || anyFetching || allHavePermissions || allAttempted) return;

dispatch(fetchResourcesPermissions({ resources, authzUrl }));
}, [
dispatch,
haveAuthorizationService,
isFetching,
permissions,
hasAttempted,
resource,
keys,
resourcePermissions,
authzUrl,
]);

return {
permissions: permissions ?? [],
isFetching: isFetching ?? false,
hasAttempted: hasAttempted ?? false,
error: error ?? "",
};
// Construct an object with resource keys yielding an object containing the permissions on the object
return useMemo(() => Object.fromEntries(keys.map((key) => {
const { permissions, isFetching, hasAttempted, error } = resourcePermissions[key] ?? {};
return [
key,
{
permissions: permissions ?? [],
isFetching: isFetching ?? false,
hasAttempted: hasAttempted ?? false,
error: error ?? "",
},
];
})), [keys, resourcePermissions]);
};

export const useResourcePermissions = (resource: Resource, authzUrl: string) => {
const key = makeResourceKey(resource);
const resourcesPermissions = useResourcesPermissions([resource], authzUrl);
return resourcesPermissions[key];
};

export const useHasResourcePermission = (resource: Resource, authzUrl: string, permission: string) => {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export * from "./utils";

export {
default as AuthReducer,
fetchResourcePermissions,
fetchResourcesPermissions,
refreshTokens,
signOut,
tokenHandoff
Expand Down
8 changes: 3 additions & 5 deletions src/performSignOut.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useDispatch } from "react-redux";

import { useBentoAuthContext } from "./contexts";
import { useOpenIdConfig } from "./hooks";
import { useAuthState, useOpenIdConfig } from "./hooks";
import { setLSNotSignedIn } from "./performAuth";
import { signOut } from "./redux/authSlice";
import { logMissingAuthContext } from "./utils";

import type { RootState } from "./redux/store";

export const usePerformSignOut = () => {
const dispatch = useDispatch();
const { clientId, postSignOutUrl } = useBentoAuthContext();
const { idToken } = useSelector((state: RootState) => state.auth);
const { idToken } = useAuthState();
const openIdConfig = useOpenIdConfig();
const endSessionEndpoint = openIdConfig?.end_session_endpoint;

Expand Down
89 changes: 51 additions & 38 deletions src/redux/authSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,36 @@ export const refreshTokens = createAsyncThunk<RefreshTokenPayload, string>(
}
);

type FetchPermissionPayload = {
type FetchPermissionsPayload = {
result: string[][];
};
type FetchPermissionParams = {
resource: Resource;
type FetchPermissionsParams = {
resources: Resource[];
authzUrl: string;
}
export const fetchResourcePermissions = createAsyncThunk<FetchPermissionPayload, FetchPermissionParams>(
"auth/FETCH_RESOURCE_PERMISSIONS",
async ({ resource, authzUrl }: FetchPermissionParams, { getState }) => {
export const fetchResourcesPermissions = createAsyncThunk<FetchPermissionsPayload, FetchPermissionsParams>(
"auth/FETCH_RESOURCES_PERMISSIONS",
async ({ resources, authzUrl }: FetchPermissionsParams, { getState }) => {
const url = `${authzUrl}/policy/permissions`;
const { auth } = getState() as RootState;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...makeAuthorizationHeader(auth.accessToken) },
body: JSON.stringify({resources: [resource]}),
body: JSON.stringify({ resources }),
});
return await response.json();
},
{
condition: ({ resource }, { getState }) => {
condition: ({ resources }, { getState }) => {
// allow action to fire if all requested resource permission-sets are not being fetched right now:

const { auth } = getState() as RootState;
const key = makeResourceKey(resource);
const rp = auth.resourcePermissions?.[key];
return !rp?.isFetching;

return resources.every((resource) => {
const key = makeResourceKey(resource);
const rp = auth.resourcePermissions?.[key];
return !rp?.isFetching;
});
},
}
);
Expand Down Expand Up @@ -258,37 +263,45 @@ export const authSlice = createSlice({

setLSNotSignedIn();
})
.addCase(fetchResourcePermissions.pending, (state, { meta }) => {
const key = makeResourceKey(meta.arg.resource);
state.resourcePermissions[key] = {
...state.resourcePermissions[key],
isFetching: true,
hasAttempted: false,
permissions: [],
error: "",
};
.addCase(fetchResourcesPermissions.pending, (state, { meta }) => {
for (const resource of meta.arg.resources) {
const key = makeResourceKey(resource);
state.resourcePermissions[key] = {
...state.resourcePermissions[key],
isFetching: true,
hasAttempted: false,
permissions: [],
error: "",
};
}
})
.addCase(fetchResourcePermissions.fulfilled, (state, { meta, payload }) => {
const key = makeResourceKey(meta.arg.resource);
state.resourcePermissions[key] = {
...state.resourcePermissions[key],
isFetching: false,
hasAttempted: true,
permissions: payload?.result?.[0] ?? [],
};
.addCase(fetchResourcesPermissions.fulfilled, (state, { meta, payload }) => {
const resources = meta.arg.resources;
for (const r in resources) {
const key = makeResourceKey(resources[r]);
state.resourcePermissions[key] = {
...state.resourcePermissions[key],
isFetching: false,
hasAttempted: true,
permissions: payload?.result?.[r] ?? [],
};
}
})
.addCase(fetchResourcePermissions.rejected, (state, { meta, error }) => {
const key = makeResourceKey(meta.arg.resource);
.addCase(fetchResourcesPermissions.rejected, (state, { meta, error }) => {
if (error) console.error(error);

const permissionsError = error.message ??
"An error occurred while fetching permissions for a resource";
state.resourcePermissions[key] = {
...state.resourcePermissions[key],
isFetching: false,
hasAttempted: true,
error: permissionsError,
};
for (const resource of meta.arg.resources) {
const key = makeResourceKey(resource);

const permissionsError = error.message ??
"An error occurred while fetching permissions for a resource";
state.resourcePermissions[key] = {
...state.resourcePermissions[key],
isFetching: false,
hasAttempted: true,
error: permissionsError,
};
}
});
},
});
Expand Down

0 comments on commit 0c9310d

Please sign in to comment.