diff --git a/compose.prod.yaml b/compose.prod.yaml index dc3220c0..376c9898 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -16,6 +16,8 @@ services: build: context: ./pwa target: prod + args: + AUTH_SECRET: ${AUTH_SECRET} environment: AUTH_SECRET: ${AUTH_SECRET} diff --git a/pwa/Dockerfile b/pwa/Dockerfile index 845b82ac..c8f81c96 100644 --- a/pwa/Dockerfile +++ b/pwa/Dockerfile @@ -41,6 +41,8 @@ RUN pnpm fetch --prod COPY --link . . +ARG AUTH_SECRET + RUN pnpm install --frozen-lockfile --offline --prod && \ pnpm run build diff --git a/pwa/app/auth.tsx b/pwa/app/auth.tsx index 2492438e..eb6323cd 100644 --- a/pwa/app/auth.tsx +++ b/pwa/app/auth.tsx @@ -1,5 +1,4 @@ import { type TokenSet } from "@auth/core/types"; -import { signOut as logout, type SignOutParams } from "next-auth/react"; import NextAuth, { type Session as DefaultSession, type User } from "next-auth"; import KeycloakProvider from "next-auth/providers/keycloak"; @@ -27,20 +26,6 @@ interface Account { refresh_token: string } -interface SignOutResponse { - url: string -} - -export async function signOut( - session: DefaultSession, - options?: SignOutParams -): Promise { - return await logout({ - // @ts-ignore - callbackUrl: `${OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${options?.callbackUrl ?? window.location.origin}`, - }); -} - export const { handlers: { GET, POST }, auth } = NextAuth({ callbacks: { // @ts-ignore diff --git a/pwa/components/admin/Admin.tsx b/pwa/components/admin/Admin.tsx index 4b6aac3d..6178b86b 100644 --- a/pwa/components/admin/Admin.tsx +++ b/pwa/components/admin/Admin.tsx @@ -1,3 +1,5 @@ +"use client"; + import Head from "next/head"; import { useContext, useRef, useState } from "react"; import { type DataProvider, Layout, type LayoutProps, localStorageStore, resolveBrowserLocale } from "react-admin"; diff --git a/pwa/components/admin/AppBar.tsx b/pwa/components/admin/AppBar.tsx index 5e8385a0..018ba6e0 100644 --- a/pwa/components/admin/AppBar.tsx +++ b/pwa/components/admin/AppBar.tsx @@ -1,13 +1,16 @@ -import { useContext, useState } from "react"; -import { AppBar, AppBarClasses, UserMenu, Logout, useStore } from "react-admin"; +import {ForwardedRef, forwardRef, useContext, useState} from "react"; +import { AppBar, AppBarClasses, LogoutClasses, UserMenu, useTranslate, useStore } from "react-admin"; import { type AppBarProps } from "react-admin"; -import { Button, Menu, MenuItem, Typography } from "@mui/material"; +import {Button, ListItemIcon, ListItemText, Menu, MenuItem, Typography} from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExitIcon from "@mui/icons-material/PowerSettingsNew"; +import { signOut, useSession } from "next-auth/react"; import DocContext from "../../components/admin/DocContext"; import HydraLogo from "../../components/admin/HydraLogo"; import OpenApiLogo from "../../components/admin/OpenApiLogo"; import Logo from "../../components/admin/Logo"; +import {OIDC_SERVER_URL} from "../../config/keycloak"; const DocTypeMenuButton = () => { const [anchorEl, setAnchorEl] = useState(null); @@ -62,11 +65,43 @@ const DocTypeMenuButton = () => { ); }; +const Logout = forwardRef((props, ref: ForwardedRef) => { + const { data: session } = useSession(); + const translate = useTranslate(); + + if (!session) { + return; + } + + const handleClick = () => signOut({ + // @ts-ignore + callbackUrl: `${OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${window.location.origin}`, + }); + + return ( + + + + + + {translate('ra.auth.logout', { _: 'Logout' })} + + + ); +}); +Logout.displayName = "Logout"; + const CustomAppBar = ({ ...props }: AppBarProps) => { return ( - + } {...props}> Promise.resolve(), logout: async () => { - const session = await auth(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data: session } = useSession(); if (!session) { return; } - await signOut(session, {callbackUrl: window.location.origin}); + await signOut({ + // @ts-ignore + callbackUrl: `${OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${window.location.origin}`, + }); }, checkError: async (error) => { - const session = await auth(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data: session } = useSession(); const status = error.status; // @ts-ignore if (!session || session?.error === "RefreshAccessTokenError" || status === 401) { @@ -29,7 +34,8 @@ const authProvider: AuthProvider = { } }, checkAuth: async () => { - const session = await auth(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data: session } = useSession(); // @ts-ignore if (!session || session?.error === "RefreshAccessTokenError") { await signIn("keycloak"); @@ -42,7 +48,8 @@ const authProvider: AuthProvider = { getPermissions: () => Promise.resolve(), // @ts-ignore getIdentity: async () => { - const session = await auth(); + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data: session } = useSession(); return session ? Promise.resolve(session.user) : Promise.reject(); }, diff --git a/pwa/components/common/Header.tsx b/pwa/components/common/Header.tsx index fafc35b7..ac32ac96 100644 --- a/pwa/components/common/Header.tsx +++ b/pwa/components/common/Header.tsx @@ -1,12 +1,12 @@ "use client"; -import { signIn, useSession } from "next-auth/react"; +import { signIn, signOut, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; import Link from "next/link"; import PersonOutlineIcon from "@mui/icons-material/PersonOutline"; import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; -import { signOut } from "../../app/auth"; +import { OIDC_SERVER_URL } from "../../config/keycloak"; export const Header = () => { const pathname = usePathname(); @@ -31,7 +31,10 @@ export const Header = () => { {status === "authenticated" && ( { e.preventDefault(); - signOut(session, {callbackUrl: `${window.location.origin}/books`}); + signOut({ + // @ts-ignore + callbackUrl: `${OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${window.location.origin}/books`, + }); }}> Sign out diff --git a/pwa/package.json b/pwa/package.json index 6a2ba033..d618dcb0 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -23,7 +23,8 @@ "autoprefixer": "^10.4.19", "formik": "^2.4.5", "next": "^14.2.2", - "next-auth": "5.0.0-beta.15", + "next-auth": "5.0.0-beta.16", + "picocolors": "^1.0.0", "postcss": "^8.4.38", "ra-i18n-polyglot": "^4.16.15", "ra-language-english": "^4.16.15", diff --git a/pwa/pnpm-lock.yaml b/pwa/pnpm-lock.yaml index 811a59fe..7015c7f3 100644 --- a/pwa/pnpm-lock.yaml +++ b/pwa/pnpm-lock.yaml @@ -48,8 +48,11 @@ importers: specifier: ^14.2.2 version: 14.2.2(@babel/core@7.24.4)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) next-auth: - specifier: 5.0.0-beta.15 - version: 5.0.0-beta.15(next@14.2.2(@babel/core@7.24.4)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0) + specifier: 5.0.0-beta.16 + version: 5.0.0-beta.16(next@14.2.2(@babel/core@7.24.4)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0) + picocolors: + specifier: ^1.0.0 + version: 1.0.0 postcss: specifier: ^8.4.38 version: 8.4.38 @@ -144,8 +147,8 @@ packages: '@api-platform/api-doc-parser@0.16.4': resolution: {integrity: sha512-AWszvsApnGyPY5hukIUQtUzgRbJFcekUEss1r4inH2Gv/ctlnC1Ktl2O/edNZEk/B8PH2DB2F1h7oa73cYrVJg==} - '@auth/core@0.28.0': - resolution: {integrity: sha512-/fh/tb/L4NMSYcyPoo4Imn8vN6MskcVfgESF8/ndgtI4fhD/7u7i5fTVzWgNRZ4ebIEGHNDbWFRxaTu1NtQgvA==} + '@auth/core@0.28.1': + resolution: {integrity: sha512-gvp74mypYZADpTlfGRp6HE0G3pIHWvtJpy+KZ+8FvY0cmlIpHog+jdMOdd29dQtLtN25kF2YbfHsesCFuGUQbg==} peerDependencies: '@simplewebauthn/browser': ^9.0.1 '@simplewebauthn/server': ^9.0.2 @@ -1868,8 +1871,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next-auth@5.0.0-beta.15: - resolution: {integrity: sha512-UQggNq8CDu3/w8CYkihKLLnRPNXel98K0j7mtjj9a6XTNYo4Hni8xg/2h1YhElW6vXE8mgtvmH11rU8NKw86jQ==} + next-auth@5.0.0-beta.16: + resolution: {integrity: sha512-dX2snB+ezN23tFzSes3n3uosT9iBf0eILPYWH/R2fd9n3ZzdMQlRzq7JIOPeS1aLc84IuRlyuyXyx9XmmZB6og==} peerDependencies: '@simplewebauthn/browser': ^9.0.1 '@simplewebauthn/server': ^9.0.2 @@ -2656,7 +2659,7 @@ snapshots: transitivePeerDependencies: - web-streams-polyfill - '@auth/core@0.28.0': + '@auth/core@0.28.1': dependencies: '@panva/hkdf': 1.1.1 '@types/cookie': 0.6.0 @@ -4529,9 +4532,9 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.15(next@14.2.2(@babel/core@7.24.4)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0): + next-auth@5.0.0-beta.16(next@14.2.2(@babel/core@7.24.4)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0): dependencies: - '@auth/core': 0.28.0 + '@auth/core': 0.28.1 next: 14.2.2(@babel/core@7.24.4)(@playwright/test@1.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 diff --git a/pwa/tests/User.spec.ts b/pwa/tests/User.spec.ts index a03bd1a1..2ea33234 100644 --- a/pwa/tests/User.spec.ts +++ b/pwa/tests/User.spec.ts @@ -5,7 +5,7 @@ test.describe("User authentication", () => { await bookPage.gotoList(); }); - test("I can log in @login", async ({ userPage, page }) => { + test("I can log in Books Store @login", async ({ userPage, page }) => { await expect(page.getByText("Log in")).toBeVisible(); await expect(page.getByText("Sign out")).toHaveCount(0); @@ -22,7 +22,7 @@ test.describe("User authentication", () => { await expect(page.getByText("Sign out")).toBeVisible(); }); - test("I can sign out @login", async ({ userPage, page }) => { + test("I can sign out of Books Store @login", async ({ userPage, page }) => { await page.getByText("Log in").click(); await userPage.login(); await page.getByText("Sign out").click(); diff --git a/pwa/tests/admin/User.spec.ts b/pwa/tests/admin/User.spec.ts new file mode 100644 index 00000000..dd5920a9 --- /dev/null +++ b/pwa/tests/admin/User.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "./test"; + +test.describe("User authentication", () => { + test.beforeEach(async ({ bookPage }) => { + 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(); + + 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(); + await expect(page.locator("#kc-header-wrapper")).toContainText("API Platform - Demo"); + 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"); + }); +}); diff --git a/pwa/tests/admin/pages/UserPage.ts b/pwa/tests/admin/pages/UserPage.ts new file mode 100644 index 00000000..d6196b12 --- /dev/null +++ b/pwa/tests/admin/pages/UserPage.ts @@ -0,0 +1,4 @@ +import { AbstractPage } from "./AbstractPage"; + +export class UserPage extends AbstractPage { +} diff --git a/pwa/tests/admin/test.ts b/pwa/tests/admin/test.ts index a5acafa9..0c86fb58 100644 --- a/pwa/tests/admin/test.ts +++ b/pwa/tests/admin/test.ts @@ -1,12 +1,30 @@ -import { test as playwrightTest } from "@playwright/test"; +import { Page, test as playwrightTest } from "@playwright/test"; import { expect } from "../test"; import { BookPage } from "./pages/BookPage"; import { ReviewPage } from "./pages/ReviewPage"; +import { UserPage } from "./pages/UserPage"; + +expect.extend({ + toBeOnLoginPage(page: Page) { + if (page.url().match(/\/oidc\/realms\/demo\/protocol\/openid-connect\/auth/)) { + return { + message: () => "passed", + pass: true, + }; + } + + return { + message: () => `toBeOnLoginPage() assertion failed.\nExpected "/oidc/realms/demo/protocol/openid-connect/auth", got "${page.url()}".`, + pass: false, + }; + }, +}); type Test = { bookPage: BookPage, reviewPage: ReviewPage, + userPage: UserPage, } export const test = playwrightTest.extend({ @@ -16,6 +34,9 @@ export const test = playwrightTest.extend({ reviewPage: async ({ page }, use) => { await use(new ReviewPage(page)); }, + userPage: async ({ page }, use) => { + await use(new UserPage(page)); + }, }); export { expect }; diff --git a/pwa/utils/review.ts b/pwa/utils/review.ts index b6cdc580..64cd1e87 100644 --- a/pwa/utils/review.ts +++ b/pwa/utils/review.ts @@ -17,27 +17,31 @@ export const usePermission = (review: Review, session: Session|null): boolean => } (async () => { - const response = await fetch(`${OIDC_SERVER_URL}/protocol/openid-connect/token`, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${session?.accessToken}`, - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:uma-ticket", - audience: OIDC_AUTHORIZATION_CLIENT_ID, - response_mode: "decision", - permission_resource_format: "uri", - permission_resource_matching_uri: "true", - // @ts-ignore - permission: review["@id"].toString(), - }), - method: "POST", - }); - const permission: Permission = await response.json(); - console.log(permission); + try { + const response = await fetch(`${OIDC_SERVER_URL}/protocol/openid-connect/token`, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${session?.accessToken}`, + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:uma-ticket", + audience: OIDC_AUTHORIZATION_CLIENT_ID, + response_mode: "decision", + permission_resource_format: "uri", + permission_resource_matching_uri: "true", + // @ts-ignore + permission: review["@id"].toString(), + }), + method: "POST", + }); + const permission: Permission = await response.json(); - if (permission.result) { - grant(true); + if (permission.result) { + grant(true); + } + } catch (error) { + console.error(error); + grant(false); } })(); }, [review]);