diff --git a/src/frontend/apps/desk/.env.development b/src/frontend/apps/desk/.env.development index f661e98..96383c2 100644 --- a/src/frontend/apps/desk/.env.development +++ b/src/frontend/apps/desk/.env.development @@ -1,5 +1 @@ NEXT_PUBLIC_API_URL=http://localhost:8071/api/v1.0/ -NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080/ -NEXT_PUBLIC_KEYCLOAK_REALM=people -NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=people-front -NEXT_PUBLIC_KEYCLOAK_LOGIN=true diff --git a/src/frontend/apps/desk/package.json b/src/frontend/apps/desk/package.json index 60c11bf..a02a0e6 100644 --- a/src/frontend/apps/desk/package.json +++ b/src/frontend/apps/desk/package.json @@ -39,7 +39,6 @@ "fetch-mock": "9.11.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", - "keycloak-js": "23.0.6", "node-fetch": "2.7.0", "prettier": "3.2.5", "stylelint": "16.2.1", diff --git a/src/frontend/apps/desk/src/api/__tests__/fetchApi.test.tsx b/src/frontend/apps/desk/src/api/__tests__/fetchApi.test.tsx index 85dd963..0976bf4 100644 --- a/src/frontend/apps/desk/src/api/__tests__/fetchApi.test.tsx +++ b/src/frontend/apps/desk/src/api/__tests__/fetchApi.test.tsx @@ -1,9 +1,8 @@ import fetchMock from 'fetch-mock'; +import { fetchAPI } from '@/api'; import { useAuthStore } from '@/features/auth'; -import { fetchAPI } from '../fetchApi'; - describe('fetchAPI', () => { beforeEach(() => { process.env.NEXT_PUBLIC_API_URL = 'http://some.api.url/api/v1.0/'; @@ -20,29 +19,27 @@ describe('fetchAPI', () => { ); }); - it('adds the BEARER automatically', () => { - useAuthStore.setState({ token: 'my-token' }); - + it('adds the credentials automatically', () => { fetchMock.mock('http://some.api.url/api/v1.0/some/url', 200); void fetchAPI('some/url', { body: 'some body' }); expect(fetchMock.lastOptions()).toEqual({ body: 'some body', + credentials: 'include', headers: { - Authorization: 'Bearer my-token', 'Content-Type': 'application/json', }, }); }); it('logout if 401 response', async () => { - useAuthStore.setState({ token: 'my-token' }); + useAuthStore.setState({ userData: { email: 'test@test.com' } }); fetchMock.mock('http://some.api.url/api/v1.0/some/url', 401); await fetchAPI('some/url'); - expect(useAuthStore.getState().token).toBeNull(); + expect(useAuthStore.getState().userData).toBeUndefined(); }); }); diff --git a/src/frontend/apps/desk/src/api/fetchApi.ts b/src/frontend/apps/desk/src/api/fetchApi.ts index 84fb5b2..671e1d3 100644 --- a/src/frontend/apps/desk/src/api/fetchApi.ts +++ b/src/frontend/apps/desk/src/api/fetchApi.ts @@ -1,19 +1,41 @@ -import { useAuthStore } from '@/features/auth'; +import { login, useAuthStore } from '@/features/auth'; + +/** + * Retrieves the CSRF token from the document's cookies. + * + * @returns {string|null} The CSRF token if found in the cookies, or null if not present. + */ +function getCSRFToken() { + return document.cookie + .split(';') + .filter((cookie) => cookie.trim().startsWith('csrftoken=')) + .map((cookie) => cookie.split('=')[1]) + .pop(); +} export const fetchAPI = async (input: string, init?: RequestInit) => { const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}${input}`; - const { token, logout } = useAuthStore.getState(); + const { logout } = useAuthStore.getState(); + + const csrfToken = getCSRFToken(); const response = await fetch(apiUrl, { ...init, + credentials: 'include', headers: { ...init?.headers, 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + ...(csrfToken && { 'X-CSRFToken': csrfToken }), }, }); - response.status === 401 && logout(); + // todo - handle 401, redirect to login screen + // todo - please have a look to this documentation page https://mozilla-django-oidc.readthedocs.io/en/stable/xhr.html + if (response.status === 401) { + logout(); + // Fix - force re-logging the user, will be refactored + login(); + } return response; }; diff --git a/src/frontend/apps/desk/src/custom-next.d.ts b/src/frontend/apps/desk/src/custom-next.d.ts index 5dc9081..5919942 100644 --- a/src/frontend/apps/desk/src/custom-next.d.ts +++ b/src/frontend/apps/desk/src/custom-next.d.ts @@ -20,9 +20,5 @@ declare module '*.svg?url' { namespace NodeJS { interface ProcessEnv { NEXT_PUBLIC_API_URL?: string; - NEXT_PUBLIC_KEYCLOAK_URL?: string; - NEXT_PUBLIC_KEYCLOAK_REALM?: string; - NEXT_PUBLIC_KEYCLOAK_CLIENT_ID?: string; - NEXT_PUBLIC_KEYCLOAK_LOGIN?: string; } } diff --git a/src/frontend/apps/desk/src/features/auth/Auth.tsx b/src/frontend/apps/desk/src/features/auth/Auth.tsx index 6c9fb2b..865a815 100644 --- a/src/frontend/apps/desk/src/features/auth/Auth.tsx +++ b/src/frontend/apps/desk/src/features/auth/Auth.tsx @@ -6,15 +6,11 @@ import { Box } from '@/components'; import { useAuthStore } from './useAuthStore'; export const Auth = ({ children }: PropsWithChildren) => { - const { initAuth, authenticated, initialized } = useAuthStore(); + const { authenticated, initAuth } = useAuthStore(); useEffect(() => { - if (initialized) { - return; - } - initAuth(); - }, [initAuth, initialized]); + }, [initAuth]); if (!authenticated) { return ( diff --git a/src/frontend/apps/desk/src/features/auth/api/getMe.tsx b/src/frontend/apps/desk/src/features/auth/api/getMe.tsx new file mode 100644 index 0000000..9e6992e --- /dev/null +++ b/src/frontend/apps/desk/src/features/auth/api/getMe.tsx @@ -0,0 +1,31 @@ +import { fetchAPI } from '@/api'; + +/** + * Represents user data retrieved from the API. + * This interface is incomplete, and will be + * refactored in a near future. + * + * @interface UserData + * @property {string} email - The email of the user. + */ +export interface UserData { + email: string; +} + +/** + * Asynchronously retrieves the current user's data from the API. + * This function is called during frontend initialization to check + * the user's authentication status through a session cookie. + * + * @async + * @function getMe + * @throws {Error} Throws an error if the API request fails. + * @returns {Promise} A promise that resolves to the user data. + */ +export const getMe = async (): Promise => { + const response = await fetchAPI(`users/me/`); + if (!response.ok) { + throw new Error(`Couldn't fetch user data: ${response.statusText}`); + } + return response.json() as Promise; +}; diff --git a/src/frontend/apps/desk/src/features/auth/api/index.ts b/src/frontend/apps/desk/src/features/auth/api/index.ts new file mode 100644 index 0000000..e2faeac --- /dev/null +++ b/src/frontend/apps/desk/src/features/auth/api/index.ts @@ -0,0 +1 @@ +export * from './getMe'; diff --git a/src/frontend/apps/desk/src/features/auth/keycloak.ts b/src/frontend/apps/desk/src/features/auth/keycloak.ts deleted file mode 100644 index 2e386fe..0000000 --- a/src/frontend/apps/desk/src/features/auth/keycloak.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Keycloak, { KeycloakConfig } from 'keycloak-js'; - -const keycloakConfig: KeycloakConfig = { - url: process.env.NEXT_PUBLIC_KEYCLOAK_URL, - realm: process.env.NEXT_PUBLIC_KEYCLOAK_REALM || '', - clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || '', -}; - -export const initKeycloak = (onAuthSuccess: (_token?: string) => void) => { - const keycloak = new Keycloak(keycloakConfig); - keycloak - .init({ - onLoad: 'login-required', - }) - .then((authenticated) => { - if (authenticated) { - onAuthSuccess(keycloak.token); - } - }) - .catch(() => { - throw new Error('Failed to initialize Keycloak.'); - }); -}; diff --git a/src/frontend/apps/desk/src/features/auth/useAuthStore.tsx b/src/frontend/apps/desk/src/features/auth/useAuthStore.tsx index 3b56929..3da8d07 100644 --- a/src/frontend/apps/desk/src/features/auth/useAuthStore.tsx +++ b/src/frontend/apps/desk/src/features/auth/useAuthStore.tsx @@ -1,40 +1,39 @@ import { create } from 'zustand'; -import { initKeycloak } from './keycloak'; +import { UserData, getMe } from '@/features/auth/api'; + +export const login = () => { + window.location.replace( + new URL('authenticate/', process.env.NEXT_PUBLIC_API_URL).href, + ); +}; interface AuthStore { authenticated: boolean; initAuth: () => void; - initialized: boolean; logout: () => void; - token: string | null; + userData?: UserData; } const initialState = { authenticated: false, - initialized: false, - token: null, + userData: undefined, }; export const useAuthStore = create((set) => ({ authenticated: initialState.authenticated, - initialized: initialState.initialized, - token: initialState.token, - - initAuth: () => - set((state) => { - if (process.env.NEXT_PUBLIC_KEYCLOAK_LOGIN && !state.initialized) { - initKeycloak((token) => set({ authenticated: true, token })); - return { initialized: true }; - } - - /** - * TODO: Implement OIDC production authentication - */ - - return {}; - }), + userData: initialState.userData, + initAuth: () => { + getMe() + .then((data: UserData) => { + set({ authenticated: true, userData: data }); + }) + .catch(() => { + // todo - implement a proper login screen to prevent automatic navigation. + login(); + }); + }, logout: () => { set(initialState); }, diff --git a/src/frontend/apps/e2e/__tests__/app-desk/common.ts b/src/frontend/apps/e2e/__tests__/app-desk/common.ts index 60ae6ab..4dc16d2 100644 --- a/src/frontend/apps/e2e/__tests__/app-desk/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-desk/common.ts @@ -14,6 +14,6 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => { .getByRole('textbox', { name: 'password' }) .fill(`password-e2e-${browserName}`); - await page.click('input[type="submit"]'); + await page.click('input[type="submit"]', { force: true }); } }; diff --git a/src/frontend/apps/e2e/playwright.config.ts b/src/frontend/apps/e2e/playwright.config.ts index 8f9c42d..8179436 100644 --- a/src/frontend/apps/e2e/playwright.config.ts +++ b/src/frontend/apps/e2e/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; -const PORT = process.env.PORT || 3200; +const PORT = process.env.PORT || 3000; const baseURL = `http://localhost:${PORT}`; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index c553d9f..1d9f050 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -3899,11 +3899,6 @@ balanced-match@^2.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== -base64-js@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -6554,11 +6549,6 @@ jest@29.7.0: import-local "^3.0.2" jest-cli "^29.7.0" -js-sha256@^0.10.1: - version "0.10.1" - resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d" - integrity sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw== - "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -6684,20 +6674,6 @@ jsonfile@^6.0.1: object.assign "^4.1.4" object.values "^1.1.6" -jwt-decode@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" - integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA== - -keycloak-js@23.0.6: - version "23.0.6" - resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-23.0.6.tgz#a494cc1eddf5462322a9f2247b381bc22fb43747" - integrity sha512-Pn7iIEHPn7BcQFCbViKRv+8+v9l82oWNRVQr9wQGjp2BNEl9JpTsXjp84xQjwzaLKghG7QV7VwZrWBhiXJeM0Q== - dependencies: - base64-js "^1.5.1" - js-sha256 "^0.10.1" - jwt-decode "^4.0.0" - keyv@^4.5.3, keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"