Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add users with roles and finish role-based page authorization #3002

Closed
wants to merge 77 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
9bd84ab
Set routes for auth, try Google provider
apedroferreira Nov 21, 2023
89247f7
Add Github login
apedroferreira Nov 22, 2023
a9a1df4
Add header bar, logout functionality, user session provider
apedroferreira Nov 23, 2023
5c63e8e
Sign in directly from header bar
apedroferreira Nov 23, 2023
cb46225
Make header bar fixed, make Github and Google login buttons match bra…
apedroferreira Nov 24, 2023
b5ee279
Better error ahandling UX, show only the relevant auth providers
apedroferreira Nov 24, 2023
465d8cb
Only show login/logout for protected pages + add protected page attri…
apedroferreira Nov 24, 2023
cfb5a09
Refactor for application.yml as source of provider
apedroferreira Dec 4, 2023
811f156
Merge remote-tracking branch 'upstream/master' into authentication
apedroferreira Dec 4, 2023
5f18dc8
UI fixes, more setup for auth provider setting
apedroferreira Dec 4, 2023
5d6a4ca
Check email domain for all auth providers
apedroferreira Dec 5, 2023
a960f38
Improve auth error handling
apedroferreira Dec 5, 2023
f3236e6
Temporarily disable some auth options to test in live application wit…
apedroferreira Dec 5, 2023
cdaf5d2
Fix auth redirect path and header spacing, enable trusted host option
apedroferreira Dec 5, 2023
eb3469a
Forgot this
apedroferreira Dec 5, 2023
12b1e89
Skip CSRF check
apedroferreira Dec 5, 2023
45c9662
Add proper CSRF protection
apedroferreira Dec 6, 2023
4dcfbbd
Try not having explicit redirect to see if https:// callbacks work in…
apedroferreira Dec 6, 2023
884b199
Try sending callback url in client
apedroferreira Dec 6, 2023
18b78a4
try setting redirect again in server
apedroferreira Dec 6, 2023
8b08e93
Try using url in redirect
apedroferreira Dec 6, 2023
e20122a
Pass values from client again
apedroferreira Dec 6, 2023
0a2fabc
Manually form redirect URL
apedroferreira Dec 6, 2023
15feb91
Refactor
apedroferreira Dec 6, 2023
89dc111
Merge remote-tracking branch 'upstream/master' into authentication
apedroferreira Dec 7, 2023
9e1a588
Add authentication configuration controls
apedroferreira Dec 7, 2023
b4291e9
Add future link to docs/guides
apedroferreira Dec 7, 2023
39fe44e
Update values in app.yaml file
apedroferreira Dec 7, 2023
4ca271d
Add secret configuration, finish making values sync with app.yaml
apedroferreira Dec 8, 2023
e6c1ebd
Simplify a lot - previous approach was not looking good
apedroferreira Dec 8, 2023
5791d1f
Restrict pages for authenticated users
apedroferreira Dec 8, 2023
d2c63ca
UI improvements, bug fixes - missing custom sign in page and multiple…
apedroferreira Dec 12, 2023
6d3d9e0
Multiple auth providers
apedroferreira Dec 12, 2023
29cec80
Sign in page
apedroferreira Dec 13, 2023
0a97a9c
Show errors in sign in page
apedroferreira Dec 13, 2023
af3c8de
Improve link text
apedroferreira Dec 13, 2023
24381ab
Omit error if auth secret is not set
apedroferreira Dec 13, 2023
32744d3
Temporary change to test live application
apedroferreira Dec 13, 2023
bc1bec4
Revert last change
apedroferreira Dec 13, 2023
60e59da
Merge remote-tracking branch 'upstream/master' into authentication
apedroferreira Dec 13, 2023
882d8a7
Adjustments and set feature flag to false
apedroferreira Dec 13, 2023
62dde6b
Readd spacing
apedroferreira Dec 13, 2023
f32ef1e
Merge remote-tracking branch 'upstream/master' into authentication
apedroferreira Dec 13, 2023
dd82d5c
Update schemas
apedroferreira Dec 14, 2023
84ee5ac
Avoid calling auth endpoints if auth is not set, use absolute paths
apedroferreira Dec 14, 2023
be8f467
Reuse paths
apedroferreira Dec 14, 2023
0689e11
Remove unneeded query and loaders
apedroferreira Dec 14, 2023
1a27d54
Disable feature flag
apedroferreira Dec 14, 2023
c7e27db
Remove unused import
apedroferreira Dec 14, 2023
a375a9a
Separate component not needed anymore
apedroferreira Dec 14, 2023
8431485
Fix images in custom server runtime, fix not being able to click thro…
apedroferreira Dec 14, 2023
fa5965a
Make auth work in custom server
apedroferreira Dec 14, 2023
7b75c68
Update tsup.config.ts
Janpot Dec 15, 2023
b6f4909
fix
Janpot Dec 15, 2023
d629045
Merge remote-tracking branch 'upstream/master' into authentication
apedroferreira Dec 15, 2023
3de2e0e
Update docs/schemas/v1/definitions.json
apedroferreira Dec 15, 2023
86bac82
Update packages/toolpad-app/src/server/schema.ts
apedroferreira Dec 15, 2023
a4b59b9
Self-review, spacing
apedroferreira Dec 15, 2023
a6b3af4
Always show alert to prevent layout shift
apedroferreira Dec 15, 2023
33fad52
UI for assigning roles to users by email
apedroferreira Dec 15, 2023
3a68f03
Roles UI ( still neds a few bug fixes
apedroferreira Dec 19, 2023
06d710b
Finish roles UI, add roles to users and client-side route restrictions
apedroferreira Dec 21, 2023
8d64a96
Review changes except HTTP auth blocking
apedroferreira Dec 21, 2023
414ba2b
Block unauthenticated users at the HTTP level
apedroferreira Dec 26, 2023
b9dabb7
Improve wording
apedroferreira Dec 26, 2023
90de20b
Merge remote-tracking branch 'upstream/master' into authentication
apedroferreira Dec 26, 2023
9bbfeec
More improvements
apedroferreira Dec 26, 2023
8cdd171
Disable feature flag
apedroferreira Dec 26, 2023
2bea45c
Add space
apedroferreira Dec 26, 2023
1e2fc0c
Update JSON schemas
apedroferreira Dec 26, 2023
27b772a
Merge remote-tracking branch 'origin/authentication' into auth-roles
apedroferreira Dec 27, 2023
83874f8
Hide pages in sidebar if user does not have the necessary roles
apedroferreira Dec 27, 2023
e56e002
Fix page crash when there are no pages
apedroferreira Dec 27, 2023
ff39633
Generate JSON schemas
apedroferreira Dec 27, 2023
84961dd
Merge remote-tracking branch 'upstream/master' into auth-roles
apedroferreira Dec 27, 2023
0565d71
Disable feature flag
apedroferreira Dec 27, 2023
9acb08a
Merge remote-tracking branch 'upstream/master' into auth-roles
apedroferreira Jan 2, 2024
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
25 changes: 25 additions & 0 deletions docs/schemas/v1/definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,31 @@
]
},
"description": "Available roles for this application. These can be assigned to users."
},
"users": {
Copy link
Member

@Janpot Janpot Jan 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can start with authentication only for this PR.

I don't think it makes sense at the moment for the toolpad configuration to act as a user pool. This would require developers to have to hardcode each user in the configuration. And likely be a privacy problem for public repos.

We will create a role mapping once we have an authentication provider that supports passing roles. So not for GitHub/Google. We don't support RBAC for our free version anyway.

edit: just realized that the authentication part of this PR is stacked from another PR. The comment still stands though. We won't support rbac for the free version. We should probably add azure AD provider first before creating a role mapping.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I can keep most of this PR on hold if that's the best course and see what can be merged for now.
We can also have a more in-depth discussion about this and see if there's any more solutions we can agree on.

"type": "array",
"items": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "The email address of the user."
},
"roles": {
"type": "array",
"items": {
"type": "string"
},
"description": "Names of the roles assigned to the user."
}
},
"required": [
"email",
"roles"
],
"additionalProperties": false
},
"description": "User roles for this application."
}
},
"additionalProperties": false,
Expand Down
7 changes: 7 additions & 0 deletions packages/toolpad-app/src/appDom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export interface AppNode extends AppDomNodeBase {
readonly name: string;
readonly description?: string;
}[];
readonly users?: {
readonly email: string;
readonly roles: string[];
}[];
};
};
}
Expand Down Expand Up @@ -1083,6 +1087,9 @@ export function createDefaultDom(): AppDom {
attributes: {
title: 'Page 1',
display: 'shell',
authorization: {
allowAll: true,
},
},
});

Expand Down
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
20 changes: 15 additions & 5 deletions packages/toolpad-app/src/runtime/ToolpadApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1485,10 +1485,11 @@ function RenderedPages({ pages, hasAuthentication = false, basename }: RenderedP
/>
);

if (!IS_RENDERED_IN_CANVAS && hasAuthentication && page.attributes.authorization) {
if (!IS_RENDERED_IN_CANVAS && hasAuthentication) {
pageContent = (
<RequireAuthorization
allowedRole={page.attributes.authorization.allowedRoles}
allowAll={page.attributes.authorization?.allowAll ?? true}
allowedRoles={page.attributes.authorization?.allowedRoles ?? []}
basename={basename}
>
{pageContent}
Expand Down Expand Up @@ -1548,19 +1549,27 @@ function ToolpadAppLayout({ dom, basename }: 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],
);

return (
Expand All @@ -1570,6 +1579,7 @@ function ToolpadAppLayout({ dom, basename }: ToolpadAppLayoutProps) {
hasNavigation={!IS_RENDERED_IN_CANVAS}
hasHeader={hasAuthentication && !IS_RENDERED_IN_CANVAS}
clipped={SHOW_PREVIEW_HEADER}
basename={basename}
>
<RenderedPages pages={pages} hasAuthentication={hasAuthentication} basename={basename} />
</AppLayout>
Expand Down
17 changes: 8 additions & 9 deletions packages/toolpad-app/src/runtime/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@ import { AUTH_SIGNIN_PATH, AuthContext } from './useAuth';

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

export function RequireAuthorization({
children,
allowedRole,
allowAll,
allowedRoles,
basename,
}: RequireAuthorizationProps) {
const { session, isSigningIn } = 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(() => {
Expand All @@ -45,11 +47,8 @@ export function RequireAuthorization({
}

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.
Expand Down
73 changes: 66 additions & 7 deletions packages/toolpad-app/src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,28 @@ import GithubProvider from '@auth/core/providers/github';
import GoogleProvider from '@auth/core/providers/google';
import { getToken } from '@auth/core/jwt';
import { asyncHandler } from '../utils/express';
import { adaptRequestFromExpressToFetch } from './httpApiAdapters';
import { ToolpadProject } from './localMode';
import * as appDom from '../appDom';
import { ToolpadProject } from './localMode';
import { adaptRequestFromExpressToFetch } from './httpApiAdapters';

async function getProfileRoles(email: string, project: ToolpadProject) {
const dom = await project.loadDom();

const app = appDom.getApp(dom);

let roles: string[] = [];
if (email) {
const authUser = app.attributes.authorization?.users?.find((user) => user.email === email);
roles = authUser?.roles ?? [];
}

return roles;
}

export function createAuthHandler(project: ToolpadProject): Router {
const { options } = project;
const { base } = options;

export function createAuthHandler(base: string): Router {
const router = express.Router();

router.use(
Expand All @@ -27,6 +44,18 @@ export function createAuthHandler(base: string): Router {
GithubProvider({
clientId: process.env.TOOLPAD_GITHUB_ID,
clientSecret: process.env.TOOLPAD_GITHUB_SECRET,
async profile(profile) {
const roles = await getProfileRoles(profile.email ?? '', project);

return {
...profile,
id: profile.email ?? String(profile.id),
name: profile.name,
email: profile.email,
image: profile.avatar_url,
Comment on lines +52 to +55
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id: profile.id.toString(),
name: profile.name ?? profile.login,
email: profile.email,
image: profile.avatar_url,

roles,
};
},
}),
GoogleProvider({
clientId: process.env.TOOLPAD_GOOGLE_CLIENT_ID,
Expand All @@ -38,6 +67,17 @@ export function createAuthHandler(base: string): Router {
response_type: 'code',
},
},
async profile(profile) {
const roles = await getProfileRoles(profile.email, project);

return {
id: profile.email,
Copy link
Member Author

@apedroferreira apedroferreira Jan 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: profile.email,
id: profile.sub,

?

name: profile.name,
email: profile.email,
image: profile.picture,
roles,
};
},
}),
],
secret: process.env.TOOLPAD_AUTH_SECRET,
Expand All @@ -57,6 +97,22 @@ export function createAuthHandler(base: string): Router {
async redirect({ baseUrl }) {
return `${baseUrl}${base}`;
},
jwt({ token, user }) {
if (user) {
token.roles = user.roles ?? [];
}
return token;
},
// @TODO: Types for session callback are broken as it says token does not exist but it does
// Github issue: https://github.com/nextauthjs/next-auth/issues/9437
// @ts-ignore
session({ session, token }) {
if (session.user) {
session.user.roles = token.roles ?? [];
}

return session;
},
},
})) as Response;

Expand Down Expand Up @@ -91,6 +147,7 @@ export async function createAuthPagesMiddleware(project: ToolpadProject) {

const signInPath = `${base}/signin`;

let isRedirect = false;
if (
hasAuthentication &&
req.get('sec-fetch-dest') === 'document' &&
Expand All @@ -111,11 +168,13 @@ export async function createAuthPagesMiddleware(project: ToolpadProject) {
}

if (!token) {
res.redirect(signInPath);
res.end();
} else {
next();
isRedirect = true;
}
}

if (isRedirect) {
res.redirect(signInPath);
res.end();
} else {
next();
}
Expand Down
2 changes: 1 addition & 1 deletion packages/toolpad-app/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ async function createDevHandler(project: ToolpadProject) {
handler.use('/api/runtime-rpc', createRpcHandler(runtimeRpcServer));

if (process.env.TOOLPAD_AUTH_SECRET) {
const authHandler = createAuthHandler(project.options.base);
const authHandler = createAuthHandler(project);
handler.use('/api/auth', express.urlencoded({ extended: true }), authHandler);
}

Expand Down
9 changes: 9 additions & 0 deletions packages/toolpad-app/src/server/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,15 @@ export const applicationSchema = toolpadObjectSchema(
)
.optional()
.describe('Available roles for this application. These can be assigned to users.'),
users: z
.array(
z.object({
email: z.string().describe('The email address of the user.'),
roles: z.array(z.string()).describe('Names of the roles assigned to the user.'),
}),
)
.optional()
.describe('User roles for this application.'),
})
.optional()
.describe('Authorization configuration for this application.'),
Expand Down
2 changes: 1 addition & 1 deletion packages/toolpad-app/src/server/toolpadAppServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function createProdHandler(project: ToolpadProject) {
handler.use('/api/runtime-rpc', createRpcHandler(runtimeRpcServer));

if (process.env.TOOLPAD_AUTH_SECRET) {
const authHandler = createAuthHandler(project.options.base);
const authHandler = createAuthHandler(project);
handler.use('/api/auth', express.urlencoded({ extended: true }), authHandler);
}

Expand Down
Loading
Loading