diff --git a/helm/api-platform/keycloak/config/realm-demo.json b/helm/api-platform/keycloak/config/realm-demo.json index 58a9c1172..82dc288b6 100755 --- a/helm/api-platform/keycloak/config/realm-demo.json +++ b/helm/api-platform/keycloak/config/realm-demo.json @@ -85,10 +85,59 @@ ], "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": { + "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": { + "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..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; @@ -120,7 +133,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..219ceded9 100644 --- a/pwa/components/admin/Admin.tsx +++ b/pwa/components/admin/Admin.tsx @@ -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 { diff --git a/pwa/components/admin/authProvider.tsx b/pwa/components/admin/authProvider.tsx index e11727724..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 @@ -42,7 +42,16 @@ const authProvider: AuthProvider = { return Promise.resolve(); }, - getPermissions: () => Promise.resolve(), + getPermissions: async () => { + const session = getSession(); + // @ts-ignore + if (!session || !!session?.error) { + 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 ( + + + + + + + + + + + + + + + ); +}; 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; + } }