From 0d035df69549b221d433d1f8ffa536caeb6c13dd Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Fri, 12 Jul 2024 13:57:45 +0200
Subject: [PATCH 1/2] fix: implement admin permissions
---
.../keycloak/config/realm-demo.json | 55 ++++++++++++++++++-
pwa/app/auth.tsx | 2 +-
pwa/components/admin/Admin.tsx | 8 ++-
pwa/components/admin/authProvider.tsx | 18 +++++-
pwa/tests/admin/User.spec.ts | 21 ++++++-
pwa/tests/admin/pages/UserPage.ts | 20 +++++++
6 files changed, 116 insertions(+), 8 deletions(-)
diff --git a/helm/api-platform/keycloak/config/realm-demo.json b/helm/api-platform/keycloak/config/realm-demo.json
index 58a9c1172..0d71fd3ee 100755
--- a/helm/api-platform/keycloak/config/realm-demo.json
+++ b/helm/api-platform/keycloak/config/realm-demo.json
@@ -85,10 +85,61 @@
],
"clientScopes": [
{
+ "id": "45ece602-0403-472f-bec5-0b1ed8d3c4ec",
"name": "roles",
+ "description": "OpenID Connect scope for add user roles to the access token",
+ "protocol": "openid-connect",
"attributes": {
- "include.in.token.scope": "true"
- }
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "gui.order": "",
+ "consent.screen.text": "${rolesScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "66670b4f-adc1-430b-bf4d-ac81cd03fb16",
+ "name": "client roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "resource_access.${client_id}.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ },
+ {
+ "id": "ac84d603-553b-487f-bc11-eb809a10df1a",
+ "name": "realm roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "introspection.token.claim": "true",
+ "userinfo.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "realm_access.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ },
+ {
+ "id": "a498765c-7294-481b-8143-318b06377834",
+ "name": "audience resolve",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-resolve-mapper",
+ "consentRequired": false,
+ "config": {
+ "introspection.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }
+ ]
}
],
"clients": [
diff --git a/pwa/app/auth.tsx b/pwa/app/auth.tsx
index 714409d08..7c3b81671 100644
--- a/pwa/app/auth.tsx
+++ b/pwa/app/auth.tsx
@@ -120,7 +120,7 @@ export const { handlers: { GET, POST }, auth } = NextAuth({
// https://authjs.dev/guides/basics/refresh-token-rotation#jwt-strategy
params: {
access_type: "offline",
- scope: "openid profile email",
+ scope: "openid profile email roles",
prompt: "consent",
},
},
diff --git a/pwa/components/admin/Admin.tsx b/pwa/components/admin/Admin.tsx
index adb0f6980..228a791ee 100644
--- a/pwa/components/admin/Admin.tsx
+++ b/pwa/components/admin/Admin.tsx
@@ -2,7 +2,7 @@
import Head from "next/head";
import { useContext, useRef, useState } from "react";
-import { type DataProvider, localStorageStore } from "react-admin";
+import { type DataProvider, localStorageStore, usePermissions } from "react-admin";
import { signIn, useSession } from "next-auth/react";
import SyncLoader from "react-spinners/SyncLoader";
import {
@@ -22,6 +22,7 @@ import { ENTRYPOINT } from "../../config/entrypoint";
import bookResourceProps from "./book";
import reviewResourceProps from "./review";
import i18nProvider from "./i18nProvider";
+import Logout from "./layout/Logout";
const apiDocumentationParser = (session: Session) => async () => {
try {
@@ -116,6 +117,7 @@ const AdminWithContext = ({ session }: { session: Session }) => {
const AdminWithOIDC = () => {
// Can't use next-auth/middleware because of https://github.com/nextauthjs/next-auth/discussions/7488
const { data: session, status } = useSession();
+ const { permissions } = usePermissions();
if (status === "loading") {
return ;
@@ -128,6 +130,10 @@ const AdminWithOIDC = () => {
return;
}
+ if (permissions !== 'admin') {
+ return
You are not allowed to access this page.
;
+ }
+
// @ts-ignore
return ;
};
diff --git a/pwa/components/admin/authProvider.tsx b/pwa/components/admin/authProvider.tsx
index e11727724..52fc1aea1 100644
--- a/pwa/components/admin/authProvider.tsx
+++ b/pwa/components/admin/authProvider.tsx
@@ -42,7 +42,23 @@ const authProvider: AuthProvider = {
return Promise.resolve();
},
- getPermissions: () => Promise.resolve(),
+ getPermissions: async () => {
+ const session = getSession();
+ const response = await fetch(`${NEXT_PUBLIC_OIDC_SERVER_URL}/protocol/openid-connect/userinfo`, {
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ // @ts-ignore
+ Authorization: `Bearer ${session?.accessToken}`,
+ },
+ });
+ const token = await response.json();
+
+ if (!!token?.realm_access?.roles) {
+ return Promise.resolve(token.realm_access.roles);
+ }
+
+ return Promise.reject();
+ },
getIdentity: async () => {
const session = getSession();
diff --git a/pwa/tests/admin/User.spec.ts b/pwa/tests/admin/User.spec.ts
index dd5920a9f..d42e61205 100644
--- a/pwa/tests/admin/User.spec.ts
+++ b/pwa/tests/admin/User.spec.ts
@@ -1,11 +1,9 @@
import { expect, test } from "./test";
test.describe("User authentication", () => {
- test.beforeEach(async ({ bookPage }) => {
+ test("I can sign out of Admin @login", async ({ bookPage, page }) => {
await bookPage.gotoList();
- });
- test("I can sign out of Admin @login", async ({ userPage, page }) => {
await page.getByLabel("Profile").click();
await page.getByRole("menu").getByText("Logout").waitFor({ state: "visible" });
await page.getByRole("menu").getByText("Logout").click();
@@ -21,4 +19,21 @@ test.describe("User authentication", () => {
await expect(page.locator("#kc-form-login")).toContainText("Login as user: john.doe@example.com");
await expect(page.locator("#kc-form-login")).toContainText("Login as admin: chuck.norris@example.com");
});
+
+ test("I see a permission denied error page if I don't have the right permissions @login", async ({ userPage, page }) => {
+ await userPage.goToAdminWithInvalidUser();
+
+ await expect(page.locator("#forbidden")).toContainText("You are not allowed to access this page.");
+ await page.getByLabel("Profile").click();
+ await page.getByRole("menu").getByText("Logout").waitFor({ state: "visible" });
+ await page.getByRole("menu").getByText("Logout").click();
+
+ await expect(page).toHaveURL(/\/$/);
+
+ // I should be logged out from Keycloak also
+ await page.goto("/admin");
+ await page.waitForURL(/\/oidc\/realms\/demo\/protocol\/openid-connect\/auth/);
+ // @ts-ignore assert declared on test.ts
+ await expect(page).toBeOnLoginPage();
+ });
});
diff --git a/pwa/tests/admin/pages/UserPage.ts b/pwa/tests/admin/pages/UserPage.ts
index d6196b120..cf9f0af9f 100644
--- a/pwa/tests/admin/pages/UserPage.ts
+++ b/pwa/tests/admin/pages/UserPage.ts
@@ -1,4 +1,24 @@
import { AbstractPage } from "./AbstractPage";
export class UserPage extends AbstractPage {
+ public async goToAdminWithInvalidUser() {
+ await this.registerMock();
+
+ await this.page.goto("/admin");
+ await this.loginWithPublicUser();
+ await this.page.waitForURL(/\/admin#\/admin/);
+
+ return this.page;
+ }
+
+ public async loginWithPublicUser() {
+ await this.page.getByLabel("Email").fill("john.doe@example.com");
+ await this.page.getByLabel("Password").fill("Pa55w0rd");
+ await this.page.getByRole("button", { name: "Sign In" }).click();
+ if (await this.page.getByRole("button", { name: "Sign in with Keycloak" }).count()) {
+ await this.page.getByRole("button", { name: "Sign in with Keycloak" }).click();
+ }
+
+ return this.page;
+ }
}
From a9bd17c5a4b49d07053bcec514b3d5fc98c68349 Mon Sep 17 00:00:00 2001
From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com>
Date: Mon, 15 Jul 2024 15:46:39 +0200
Subject: [PATCH 2/2] fix: move permissions to session
---
.../keycloak/config/realm-demo.json | 2 -
pwa/app/auth.tsx | 13 +++++
pwa/components/admin/Admin.tsx | 7 +--
pwa/components/admin/authProvider.tsx | 23 +++------
pwa/components/admin/book/BooksList.tsx | 50 +++++++++++++------
5 files changed, 56 insertions(+), 39 deletions(-)
diff --git a/helm/api-platform/keycloak/config/realm-demo.json b/helm/api-platform/keycloak/config/realm-demo.json
index 0d71fd3ee..82dc288b6 100755
--- a/helm/api-platform/keycloak/config/realm-demo.json
+++ b/helm/api-platform/keycloak/config/realm-demo.json
@@ -103,7 +103,6 @@
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
- "user.attribute": "foo",
"introspection.token.claim": "true",
"userinfo.token.claim": "true",
"access.token.claim": "true",
@@ -119,7 +118,6 @@
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
- "user.attribute": "foo",
"introspection.token.claim": "true",
"userinfo.token.claim": "true",
"access.token.claim": "true",
diff --git a/pwa/app/auth.tsx b/pwa/app/auth.tsx
index 7c3b81671..66c067173 100644
--- a/pwa/app/auth.tsx
+++ b/pwa/app/auth.tsx
@@ -7,12 +7,14 @@ import { NEXT_PUBLIC_OIDC_CLIENT_ID, NEXT_PUBLIC_OIDC_SERVER_URL, NEXT_PUBLIC_OI
export interface Session extends DefaultSession {
error?: "RefreshAccessTokenError"
accessToken: string
+ permissions: string
idToken: string
user?: User
}
interface JWT {
accessToken: string
+ permissions: string
idToken: string
expiresAt: number
refreshToken: string
@@ -31,9 +33,19 @@ export const { handlers: { GET, POST }, auth } = NextAuth({
// @ts-ignore
async jwt({ token, account }: { token: JWT, account: Account }): Promise {
if (account) {
+ // Retrieve user roles
+ const response = await fetch(`${NEXT_PUBLIC_OIDC_SERVER_URL_INTERNAL}/protocol/openid-connect/userinfo`, {
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Bearer ${account.access_token}`,
+ },
+ });
+ const token = await response.json();
+
// Save the access token and refresh token in the JWT on the initial login
return {
...token,
+ permissions: token?.realm_access?.roles,
accessToken: account.access_token,
idToken: account.id_token,
expiresAt: Math.floor(Date.now() / 1000 + account.expires_in),
@@ -85,6 +97,7 @@ export const { handlers: { GET, POST }, auth } = NextAuth({
async session({ session, token }: { session: Session, token: JWT }): Promise {
// Save the access token in the Session for API calls
if (token) {
+ session.permissions = token.permissions;
session.accessToken = token.accessToken;
session.idToken = token.idToken;
session.error = token.error;
diff --git a/pwa/components/admin/Admin.tsx b/pwa/components/admin/Admin.tsx
index 228a791ee..219ceded9 100644
--- a/pwa/components/admin/Admin.tsx
+++ b/pwa/components/admin/Admin.tsx
@@ -2,7 +2,7 @@
import Head from "next/head";
import { useContext, useRef, useState } from "react";
-import { type DataProvider, localStorageStore, usePermissions } from "react-admin";
+import { type DataProvider, localStorageStore } from "react-admin";
import { signIn, useSession } from "next-auth/react";
import SyncLoader from "react-spinners/SyncLoader";
import {
@@ -117,7 +117,6 @@ const AdminWithContext = ({ session }: { session: Session }) => {
const AdminWithOIDC = () => {
// Can't use next-auth/middleware because of https://github.com/nextauthjs/next-auth/discussions/7488
const { data: session, status } = useSession();
- const { permissions } = usePermissions();
if (status === "loading") {
return ;
@@ -130,10 +129,6 @@ const AdminWithOIDC = () => {
return;
}
- if (permissions !== 'admin') {
- return You are not allowed to access this page.
;
- }
-
// @ts-ignore
return ;
};
diff --git a/pwa/components/admin/authProvider.tsx b/pwa/components/admin/authProvider.tsx
index 52fc1aea1..49630b122 100644
--- a/pwa/components/admin/authProvider.tsx
+++ b/pwa/components/admin/authProvider.tsx
@@ -1,7 +1,7 @@
-import { AuthProvider } from "react-admin";
-import { getSession, signIn, signOut } from "next-auth/react";
+import {AuthProvider} from "react-admin";
+import {getSession, signIn, signOut} from "next-auth/react";
-import { NEXT_PUBLIC_OIDC_SERVER_URL } from "../../config/keycloak";
+import {NEXT_PUBLIC_OIDC_SERVER_URL} from "../../config/keycloak";
const authProvider: AuthProvider = {
// Nothing to do here, this function will never be called
@@ -44,20 +44,13 @@ const authProvider: AuthProvider = {
},
getPermissions: async () => {
const session = getSession();
- const response = await fetch(`${NEXT_PUBLIC_OIDC_SERVER_URL}/protocol/openid-connect/userinfo`, {
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- // @ts-ignore
- Authorization: `Bearer ${session?.accessToken}`,
- },
- });
- const token = await response.json();
-
- if (!!token?.realm_access?.roles) {
- return Promise.resolve(token.realm_access.roles);
+ // @ts-ignore
+ if (!session || !!session?.error) {
+ return Promise.reject();
}
- return Promise.reject();
+ // @ts-ignore
+ return Promise.resolve(session?.permissions);
},
getIdentity: async () => {
const session = getSession();
diff --git a/pwa/components/admin/book/BooksList.tsx b/pwa/components/admin/book/BooksList.tsx
index 32a7b7ee6..60d03b720 100644
--- a/pwa/components/admin/book/BooksList.tsx
+++ b/pwa/components/admin/book/BooksList.tsx
@@ -6,7 +6,9 @@ import {
List,
EditButton,
WrapperField,
+ usePermissions,
} from "react-admin";
+import { Card, CardContent } from "@mui/material";
import { ShowButton } from "./ShowButton";
import { RatingField } from "../review/RatingField";
@@ -28,19 +30,35 @@ const filters = [
,
];
-export const BooksList = () => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
+export const BooksList = () => {
+ const { isPending, permissions } = usePermissions();
+
+ if (isPending) {
+ return Waiting for permissions...
;
+ }
+
+ if (permissions !== 'admin') {
+ return (
+
+ You are not allowed to access this page.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};