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

Feat(rbac frontend) Protected routes and menu #82

Merged
merged 15 commits into from
Jan 28, 2025
Merged
44 changes: 40 additions & 4 deletions docker-compose/data/keycloak/realm/BaSyx-realm.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
{
"id" : "bcb69552-bf11-4249-a3eb-d0c3ab54a570",
"realm" : "BaSyx",
"notBefore" : 0,
Expand Down Expand Up @@ -353,6 +353,22 @@
"clientRole" : true,
"containerId" : "08582273-4c73-482c-944f-ad3945539587",
"attributes" : { }
}, {
"id" : "3987d3b3-6a87-41a6-869c-a9625cff0d74",
"name" : "mnestix-admin",
"description" : "",
"composite" : false,
"clientRole" : true,
"containerId" : "08582273-4c73-482c-944f-ad3945539587",
"attributes" : { }
}, {
"id" : "25c6012e-8554-4f0d-9885-1bf4b7f043c4",
"name" : "mnestix-user",
"description" : "",
"composite" : false,
"clientRole" : true,
"containerId" : "08582273-4c73-482c-944f-ad3945539587",
"attributes" : { }
} ],
"realm-management" : [ {
"id" : "1752f599-6520-4588-9a85-75049a5f4ea7",
Expand Down Expand Up @@ -1472,6 +1488,9 @@
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "admin", "default-roles-basyx" ],
"clientRoles" : {
"mnestixApi-demo" : [ "mnestix-admin" ]
},
"notBefore" : 0,
"groups" : [ "/BaSyxGroup", "/mnestix" ]
}, {
Expand Down Expand Up @@ -2139,6 +2158,22 @@
"claim.name" : "realm_access.roles",
"jsonType.label" : "String"
}
}, {
"id" : "dc11a561-d1a9-433f-89c4-b69279b27a61",
"name" : "mnestix-roles-mapper",
"protocol" : "openid-connect",
"protocolMapper" : "oidc-usermodel-client-role-mapper",
"consentRequired" : false,
"config" : {
"introspection.token.claim" : "true",
"multivalued" : "true",
"userinfo.token.claim" : "true",
"id.token.claim" : "true",
"lightweight.claim" : "false",
"access.token.claim" : "true",
"claim.name" : "role",
"jsonType.label" : "String"
}
}, {
"id" : "2fe9cc2c-3f61-446e-9cf4-f34fe1964a1d",
"name" : "client roles",
Expand Down Expand Up @@ -2259,9 +2294,10 @@
"consentRequired" : false,
"config" : {
"included.client.audience" : "mnestixApi-demo",
"introspection.token.claim" : "true",
"userinfo.token.claim" : "false",
"id.token.claim" : "false",
"lightweight.claim" : "false",
"introspection.token.claim" : "true",
"access.token.claim" : "true"
}
} ]
Expand Down Expand Up @@ -2565,7 +2601,7 @@
"subType" : "anonymous",
"subComponents" : { },
"config" : {
"allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper" ]
"allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "saml-role-list-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper" ]
}
}, {
"id" : "7256d195-1e91-4f63-a9c4-6bef95243a92",
Expand Down Expand Up @@ -2602,7 +2638,7 @@
"subType" : "authenticated",
"subComponents" : { },
"config" : {
"allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper" ]
"allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper" ]
}
}, {
"id" : "face2c9e-4d23-44e2-9a09-74e1d8448bd3",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@testing-library/user-event": "^14.5.2",
"@types/flat": "^5.0.5",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.8",
"@types/lodash": "^4.17.0",
"@types/node": "^22.5.4",
"@types/react": "^18.2.79",
Expand Down Expand Up @@ -67,6 +68,7 @@
"eslint": "8.57",
"flat": "^6.0.1",
"isomorphic-fetch": "^3.0.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"next": "^14.2.3",
"next-auth": "^4.24.7",
Expand Down
2 changes: 1 addition & 1 deletion src/app/[locale]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function Page() {
};

return (
<PrivateRoute>
<PrivateRoute currentRoute={'/settings'}>
<Box sx={{ p: 4, width: '100%', margin: '0 auto' }}>
<Box sx={{ mb: 3 }}>
<ViewHeading title={<FormattedMessage {...messages.mnestix.settings} />} />
Expand Down
2 changes: 1 addition & 1 deletion src/app/[locale]/templates/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export default function Page() {
}

return (
<PrivateRoute>
<PrivateRoute currentRoute={'/templates'}>
<Box sx={{ p: 3, maxWidth: '1125px', width: '100%', margin: '0 auto' }}>
<Breadcrumbs
links={[
Expand Down
2 changes: 1 addition & 1 deletion src/app/[locale]/templates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export default function Page() {
};

return (
<PrivateRoute>
<PrivateRoute currentRoute={'/templates'}>
<Box sx={{ p: 3, maxWidth: '1125px', width: '100%', margin: '0 auto' }}>
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<ViewHeading title={<FormattedMessage {...messages.mnestix.templates} />} />
Expand Down
14 changes: 10 additions & 4 deletions src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import NextAuth, { DefaultSession } from 'next-auth';
import { authOptions } from 'authConfig';
import NextAuth, { DefaultSession, User } from 'next-auth';
import { authOptions } from 'components/authentication/authConfig';
import { MnestixRole } from 'components/authentication/AllowedRoutes';

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST }
export { handler as GET, handler as POST };

declare module 'next-auth' {
interface Session extends DefaultSession {
accessToken: string;
idToken: string;
user: {
roles: string[];
mnestixRole: MnestixRole;
allowedRoutes: string[];
} & User;
}
}
}
12 changes: 6 additions & 6 deletions src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
import { getServerSession } from 'next-auth';
import { authOptions } from 'authConfig';
import { authOptions } from 'components/authentication/authConfig';
import { NextResponse } from 'next/server';

export async function GET() {
const session = await getServerSession(authOptions);
const redirectUri = process.env.NEXTAUTH_URL ? process.env.NEXTAUTH_URL : '';
const endSessionUrl = `${process.env.KEYCLOAK_ISSUER}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`
const endSessionUrl = `${process.env.KEYCLOAK_ISSUER}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/logout`;
try {
if (session) {
const idToken = session.idToken;

const url = `${endSessionUrl}?id_token_hint=${idToken}&post_logout_redirect_uri=${redirectUri}`;

const response = await fetch(url, { method: 'GET' });

if (!response.ok) {
console.error('Failed to log out from Keycloak:', response.statusText);
return new NextResponse('Failed to log out', { status: 500 });
}

return NextResponse.redirect(redirectUri);
}

return new NextResponse('Unauthorized', { status: 401 });
} catch (err) {
console.error('Error logging out:', err);
return new NextResponse('Internal Server Error', { status: 500 });
}
}
}
15 changes: 15 additions & 0 deletions src/components/authentication/AllowedRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* This map is used to set the allowed routes per Role.
*/
const AllowedRoutes = {
mnestixAdmin: ['/templates', '/settings'],
mnestixUser: ['/templates'],
};

export default AllowedRoutes;

export enum MnestixRole {
MnestixGuest = 'guest',
MnestixAdmin = 'mnestix-admin',
MnestixUser = 'mnestix-user',
}
24 changes: 24 additions & 0 deletions src/components/authentication/NotAllowedPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Box, Typography } from '@mui/material';
import AuthenticationLock from 'assets/authentication_lock.svg';
import { useTranslations } from 'next-intl';

export function NotAllowedPrompt() {
const t = useTranslations('authentication');
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
width: 'auto',
m: 3,
mt: 5,
}}
>
<Typography variant="h2" sx={{ mb: 2 }} color="primary" align="center">
{t('contactAdmin')}
</Typography>
<AuthenticationLock />
</Box>
);
}
15 changes: 13 additions & 2 deletions src/components/authentication/PrivateRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@ import React from 'react';
import { useAuth } from 'lib/hooks/UseAuth';
import { useEnv } from 'app/env/provider';
import { AuthenticationPrompt } from 'components/authentication/AuthenticationPrompt';
import { NotAllowedPrompt } from 'components/authentication/NotAllowedPrompt';

export function PrivateRoute({ children }: { children: React.JSX.Element }) {
export function PrivateRoute({ currentRoute, children }: { currentRoute: string; children: React.JSX.Element }) {
const auth = useAuth();
const env = useEnv();
const allowedRoutes = auth.getAccount()?.user.allowedRoutes ?? [];
const useAuthentication = env.AUTHENTICATION_FEATURE_FLAG;

return <>{!useAuthentication || auth.isLoggedIn ? <>{children}</> : <AuthenticationPrompt />}</>;
if (!useAuthentication) return <>{children}</>;

if (useAuthentication && auth.isLoggedIn) {
if (allowedRoutes.includes(currentRoute)) {
return <>{children}</>;
} else {
return <NotAllowedPrompt />;
}
}
return <AuthenticationPrompt />;
}
6 changes: 3 additions & 3 deletions src/components/authentication/SignInButton.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Button } from '@mui/material';
import { Login } from '@mui/icons-material';
import { FormattedMessage } from 'react-intl';
import { messages } from 'lib/i18n/localization';
import { useAuth } from 'lib/hooks/UseAuth';
import { useTranslations } from 'next-intl';

const SignInButton = () => {
const auth = useAuth();
const t = useTranslations('mainMenu');
return (
<Button
sx={{ m: 2, mt: 3, minWidth: '200px' }}
Expand All @@ -14,7 +14,7 @@ const SignInButton = () => {
onClick={() => auth.login()}
data-testid="sign-in-button"
>
<FormattedMessage {...messages.mnestix.login} />
{t('login')}
</Button>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AuthOptions } from 'next-auth';
import KeycloakProvider from 'next-auth/providers/keycloak';
import AzureADProvider from 'next-auth/providers/azure-ad';
import type { JWT } from 'next-auth/jwt';
import { JWT } from 'next-auth/jwt';
import jwt from 'jsonwebtoken';

const isEmptyOrWhiteSpace = (input: string | undefined) => {
return !input || input.trim() === '';
Expand Down Expand Up @@ -48,14 +49,26 @@ export const authOptions: AuthOptions = {
},
callbacks: {
async jwt({ token, account }) {
let roles = null;

const nowTimeStamp = Math.floor(Date.now() / 1000);

if (account) {
token.access_token = account.access_token;
token.id_token = account.id_token;
token.expires_at = account.expires_at;
token.refresh_token = account.refresh_token;
return token;

// The Roles are stored inside the access_token
if (account.access_token) {
const decodedToken = jwt.decode(account.access_token);
if (decodedToken) {
// @ts-expect-error role exits
roles = decodedToken?.role;
}
}
// Store Roles inside token
return { ...token, roles: roles };
} else if (nowTimeStamp < (token.expires_at as number)) {
return token;
}
Expand All @@ -75,6 +88,7 @@ export const authOptions: AuthOptions = {
async session({ session, token }) {
session.accessToken = token.access_token as string;
session.idToken = token.id_token as string;
session.user.roles = token.roles as string[];
return session;
},
},
Expand Down Expand Up @@ -133,4 +147,3 @@ const refreshAzureADToken = async (token: JWT) => {
refresh_token: refreshedTokens.refresh_token ?? token.refresh_token, // Fallback to old refresh token if not provided
};
};

Loading
Loading