♻️(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:
Lebaud Antoine
2024-02-14 23:47:43 +01:00
committed by aleb_the_flash
parent 38c4d33791
commit 4cacfd3a45
13 changed files with 87 additions and 97 deletions

View File

@@ -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

View File

@@ -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",

View File

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

View File

@@ -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;
};

View File

@@ -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;
}
}

View File

@@ -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 (

View 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>;
};

View File

@@ -0,0 +1 @@
export * from './getMe';

View File

@@ -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.');
});
};

View File

@@ -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);
},

View File

@@ -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 });
}
};

View File

@@ -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}`;

View File

@@ -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"