Skip to content

Commit

Permalink
Add RBAC with Azure AD authentication provider (#3077)
Browse files Browse the repository at this point in the history
  • Loading branch information
apedroferreira authored Jan 29, 2024
1 parent 8bf64f9 commit 6435f8f
Show file tree
Hide file tree
Showing 15 changed files with 513 additions and 132 deletions.
22 changes: 21 additions & 1 deletion docs/schemas/v1/definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,28 @@
"properties": {
"provider": {
"type": "string",
"enum": ["github", "google"],
"enum": ["github", "google", "azure-ad"],
"description": "Unique identifier for this authentication provider."
},
"roles": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "array",
"items": { "type": "string" },
"description": "Authentication provider roles to be mapped from."
},
"target": {
"type": "string",
"description": "Toolpad role to be mapped to."
}
},
"required": ["source", "target"],
"additionalProperties": false
},
"description": "Role mapping definition for this authentication provider."
}
},
"required": ["provider"],
Expand Down
17 changes: 17 additions & 0 deletions packages/toolpad-app/src/components/icons/AzureIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as React from 'react';

interface AzureIconProps {
size?: number;
color?: string;
}

export default function AzureIcon({ size = 18, color = 'currentColor' }: AzureIconProps) {
return (
<svg viewBox="0 0 59.242 47.271" width={size} height={size} xmlns="http://www.w3.org/2000/svg">
<path
d="m32.368 0-17.468 15.145-14.9 26.75h13.437zm2.323 3.543-7.454 21.008 14.291 17.956-27.728 4.764h45.442z"
fill={color}
/>
</svg>
);
}
9 changes: 6 additions & 3 deletions packages/toolpad-app/src/runtime/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,22 @@ interface AppPagesNavigationProps {
pages: NavigationEntry[];
clipped?: boolean;
search?: string;
basename: string;
}

function AppPagesNavigation({
activePageSlug,
pages,
clipped = false,
search,
basename,
}: AppPagesNavigationProps) {
const navListSubheaderId = React.useId();

const theme = useTheme();

const productIcon = theme.palette.mode === 'dark' ? productIconDark : productIconLight;

const initialPageSlug = pages[0].slug;

return (
<Drawer
variant="permanent"
Expand All @@ -77,7 +77,7 @@ function AppPagesNavigation({
<MuiLink
color="inherit"
aria-label="Go to home page"
href={initialPageSlug}
href={basename}
underline="none"
sx={{
ml: 3,
Expand Down Expand Up @@ -139,6 +139,7 @@ export interface ToolpadAppLayoutProps {
hasHeader?: boolean;
children?: React.ReactNode;
clipped?: boolean;
basename: string;
}

export function AppLayout({
Expand All @@ -148,6 +149,7 @@ export function AppLayout({
hasHeader = false,
children,
clipped,
basename,
}: ToolpadAppLayoutProps) {
const theme = useTheme();

Expand Down Expand Up @@ -194,6 +196,7 @@ export function AppLayout({
pages={pages}
clipped={clipped}
search={retainedSearch}
basename={basename}
/>
) : null}
<Box sx={{ minWidth: 0, flex: 1, position: 'relative', flexDirection: 'column' }}>
Expand Down
119 changes: 74 additions & 45 deletions packages/toolpad-app/src/runtime/SignInPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,51 +65,80 @@ export default function SignInPage() {
<Typography variant="subtitle1" mb={1}>
You must be authenticated to use this app.
</Typography>
{authProviders.includes('github') ? (
<LoadingButton
variant="contained"
onClick={handleSignIn('github')}
startIcon={<GitHubIcon />}
loading={isSigningIn && latestSelectedProvider === 'github'}
disabled={isSigningIn}
loadingPosition="start"
size="large"
sx={{
backgroundColor: '#24292F',
}}
>
Sign in with GitHub
</LoadingButton>
) : null}
{authProviders.includes('google') ? (
<LoadingButton
variant="contained"
onClick={handleSignIn('google')}
startIcon={
<img
alt="Google logo"
loading="lazy"
height="18"
width="18"
src="https://authjs.dev/img/providers/google.svg"
style={{ marginLeft: '2px', marginRight: '2px' }}
/>
}
loading={isSigningIn && latestSelectedProvider === 'google'}
disabled={isSigningIn}
loadingPosition="start"
size="large"
sx={{
backgroundColor: '#fff',
color: '#000',
'&:hover': {
color: theme.palette.primary.contrastText,
},
}}
>
Sign in with Google
</LoadingButton>
) : null}
<Stack sx={{ width: 300 }} gap={2}>
{authProviders.includes('github') ? (
<LoadingButton
variant="contained"
onClick={handleSignIn('github')}
startIcon={<GitHubIcon />}
loading={isSigningIn && latestSelectedProvider === 'github'}
disabled={isSigningIn}
loadingPosition="start"
size="large"
fullWidth
sx={{
backgroundColor: '#24292F',
}}
>
Sign in with GitHub
</LoadingButton>
) : null}
{authProviders.includes('google') ? (
<LoadingButton
variant="contained"
onClick={handleSignIn('google')}
startIcon={
<img
alt="Google logo"
loading="lazy"
height="18"
width="18"
src="https://authjs.dev/img/providers/google.svg"
style={{ marginLeft: '2px', marginRight: '2px' }}
/>
}
loading={isSigningIn && latestSelectedProvider === 'google'}
disabled={isSigningIn}
loadingPosition="start"
size="large"
fullWidth
sx={{
backgroundColor: '#fff',
color: '#000',
'&:hover': {
color: theme.palette.primary.contrastText,
},
}}
>
Sign in with Google
</LoadingButton>
) : null}
{authProviders.includes('azure-ad') ? (
<LoadingButton
variant="contained"
onClick={handleSignIn('azure-ad')}
startIcon={
<img
alt="Microsoft Azure logo"
loading="lazy"
height="18"
width="18"
src="https://authjs.dev/img/providers/azure.svg"
/>
}
loading={isSigningIn && latestSelectedProvider === 'azure-ad'}
disabled={isSigningIn}
loadingPosition="start"
size="large"
fullWidth
sx={{
backgroundColor: '##0072c6',
}}
>
Sign in with Azure AD
</LoadingButton>
) : null}
</Stack>
</Stack>
<Snackbar
open={!!errorSnackbarMessage}
Expand Down
35 changes: 25 additions & 10 deletions packages/toolpad-app/src/runtime/ToolpadApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1476,15 +1476,13 @@ function PageNotFound() {

interface RenderedPagesProps {
pages: appDom.PageNode[];
defaultPage: appDom.PageNode;
hasAuthentication?: boolean;
basename: string;
}

function RenderedPages({ pages, hasAuthentication = false, basename }: RenderedPagesProps) {
function RenderedPages({ pages, defaultPage, hasAuthentication = false }: RenderedPagesProps) {
const { search } = useLocation();

const defaultPage = pages[0];

const defaultPageNavigation = <Navigate to={`/pages/${defaultPage.name}${search}`} replace />;

return (
Expand All @@ -1502,8 +1500,8 @@ function RenderedPages({ pages, hasAuthentication = false, basename }: RenderedP
if (!IS_RENDERED_IN_CANVAS && hasAuthentication) {
pageContent = (
<RequireAuthorization
allowedRole={page.attributes.authorization?.allowedRoles ?? []}
basename={basename}
allowAll={page.attributes.authorization?.allowAll ?? true}
allowedRoles={page.attributes.authorization?.allowedRoles ?? []}
>
{pageContent}
</RequireAuthorization>
Expand Down Expand Up @@ -1563,30 +1561,47 @@ function ToolpadAppLayout({ dom, basename, clipped }: ToolpadAppLayoutProps) {
const root = appDom.getApp(dom);
const { pages = [] } = appDom.getChildNodes(dom, root);

const { hasAuthentication } = React.useContext(AuthContext);
const { session, hasAuthentication } = React.useContext(AuthContext);

const pageMatch = useMatch('/pages/:slug');
const activePageSlug = pageMatch?.params.slug;

const authFilteredPages = React.useMemo(() => {
const userRoles = session?.user?.roles ?? [];
return pages.filter((page) => {
const { allowAll = true, allowedRoles = [] } = page.attributes.authorization ?? {};
return allowAll || userRoles.some((role) => allowedRoles.includes(role));
});
}, [pages, session?.user?.roles]);

const navEntries = React.useMemo(
() =>
pages.map((page) => ({
authFilteredPages.map((page) => ({
slug: page.name,
displayName: appDom.getPageDisplayName(page),
hasShell: page?.attributes.display !== 'standalone',
})),
[pages],
[authFilteredPages],
);

if (!IS_RENDERED_IN_CANVAS && !session?.user && hasAuthentication) {
return <AppLoading />;
}

return (
<AppLayout
activePageSlug={activePageSlug}
pages={navEntries}
hasNavigation={!IS_RENDERED_IN_CANVAS}
hasHeader={hasAuthentication && !IS_RENDERED_IN_CANVAS}
clipped={clipped}
basename={basename}
>
<RenderedPages pages={pages} hasAuthentication={hasAuthentication} basename={basename} />
<RenderedPages
pages={pages}
defaultPage={authFilteredPages[0] ?? pages[0]}
hasAuthentication={hasAuthentication}
/>
</AppLayout>
);
}
Expand Down
53 changes: 13 additions & 40 deletions packages/toolpad-app/src/runtime/auth.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,40 @@
import * as React from 'react';
import { asArray } from '@mui/toolpad-utils/collections';
import { Box, CircularProgress, Container } from '@mui/material';
import { AUTH_SIGNIN_PATH, AuthContext } from './useAuth';
import { Box } from '@mui/material';
import { AuthContext } from './useAuth';

export interface RequireAuthorizationProps {
children?: React.ReactNode;
allowedRole?: string | string[];
basename: string;
allowAll?: boolean;
allowedRoles?: string[];
}

export function RequireAuthorization({
children,
allowedRole,
basename,
allowAll,
allowedRoles,
}: RequireAuthorizationProps) {
const { session, isSigningIn } = React.useContext(AuthContext);
const { session } = React.useContext(AuthContext);
const user = session?.user ?? null;

const allowedRolesSet = React.useMemo<Set<string>>(
() => new Set(asArray(allowedRole ?? [])),
[allowedRole],
() => new Set(asArray(allowedRoles ?? [])),
[allowedRoles],
);

React.useEffect(() => {
if (!user && !isSigningIn) {
window.location.replace(`${basename}${AUTH_SIGNIN_PATH}`);
}
}, [basename, isSigningIn, user]);

if (!user) {
return (
<Container
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
mt: 4,
}}
>
<CircularProgress color="primary" size={56} />
</Container>
);
}

let reason = null;
if (!user.roles || user.roles.length <= 0) {
reason = 'User has no roles defined.';
} else if (!user.roles.some((role) => allowedRolesSet.has(role))) {
const rolesList = user?.roles?.map((role) => JSON.stringify(role)).join(', ');
reason = `User with role(s) ${rolesList} is not allowed access to this resource.`;
if (!allowAll && !user?.roles.some((role) => allowedRolesSet.has(role))) {
reason = `User does not have the roles to access this page.`;
}

// @TODO: Once we have roles we can add back this check.
const skipReason = true;
return !skipReason && reason ? (
return reason ? (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mt: 2,
mt: 6,
}}
>
Unauthorized. {reason}
Expand Down
Loading

0 comments on commit 6435f8f

Please sign in to comment.