♻️(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}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user