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 ( + + + + + + + + + + + + + + + ); +};