♻️(frontend) switch to Authorization Code flow
Instead of interacting with Keycloak, the frontend navigate to the /authenticate endpoint, which starts the Authorization code flow. When the flow is done, the backend redirect back to the SPA, passing a session cookie and a csrf cookie. Done: - Query GET user/me to determine if user is authenticated yet - Remove Keycloak js dependency, as all the OIDC logic is handled by the backend - Store user's data instead of the JWT token
This commit is contained in:
committed by
aleb_the_flash
parent
38c4d33791
commit
4cacfd3a45
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
4
src/frontend/apps/desk/src/custom-next.d.ts
vendored
4
src/frontend/apps/desk/src/custom-next.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
31
src/frontend/apps/desk/src/features/auth/api/getMe.tsx
Normal file
31
src/frontend/apps/desk/src/features/auth/api/getMe.tsx
Normal file
@@ -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<UserData>} A promise that resolves to the user data.
|
||||
*/
|
||||
export const getMe = async (): Promise<UserData> => {
|
||||
const response = await fetchAPI(`users/me/`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
|
||||
}
|
||||
return response.json() as Promise<UserData>;
|
||||
};
|
||||
1
src/frontend/apps/desk/src/features/auth/api/index.ts
Normal file
1
src/frontend/apps/desk/src/features/auth/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './getMe';
|
||||
@@ -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.');
|
||||
});
|
||||
};
|
||||
@@ -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<AuthStore>((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);
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user