✨(auth) add silent login
Currently users already logged in to the SSO have to click on the login button again to be connected. This extra step should not be necessary. This commit uses the "silent=true" parameter to the login endpoint to avoid the extra step.
This commit is contained in:
@@ -11,6 +11,7 @@ and this project adheres to
|
||||
- ✨(frontend) integrate configurable Waffle #1795
|
||||
- ✨ Import of documents #1609
|
||||
- 🚨(CI) gives warning if theme not updated #1811
|
||||
- ✨(auth) add silent login #1690
|
||||
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -48,7 +48,7 @@ LOGIN_REDIRECT_URL=http://localhost:3000
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
|
||||
@@ -549,6 +549,16 @@ class Base(Configuration):
|
||||
SESSION_COOKIE_NAME = "docs_sessionid"
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = values.Value(
|
||||
"lasuite.oidc_login.views.OIDCAuthenticationRequestView",
|
||||
environ_name="OIDC_AUTHENTICATE_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_CALLBACK_CLASS = values.Value(
|
||||
"lasuite.oidc_login.views.OIDCAuthenticationCallbackView",
|
||||
environ_name="OIDC_CALLBACK_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True,
|
||||
environ_name="OIDC_CREATE_USER",
|
||||
|
||||
16
src/frontend/apps/e2e/__tests__/app-impress/login.spec.ts
Normal file
16
src/frontend/apps/e2e/__tests__/app-impress/login.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Login: Not logged', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('It tries silent login', async ({ page }) => {
|
||||
const silentLoginRequest = page.waitForRequest((request) =>
|
||||
request.url().includes('/api/v1.0/authenticate/?silent=true'),
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await silentLoginRequest;
|
||||
expect(silentLoginRequest).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,40 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { gotoLogout } from '../utils';
|
||||
import { SILENT_LOGIN_RETRY } from '../conf';
|
||||
import { gotoLogout, gotoSilentLogin } from '../utils';
|
||||
|
||||
// Mock the Crisp service
|
||||
vi.mock('@/services/Crisp', () => ({
|
||||
terminateCrispSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Add mock on window.location.replace
|
||||
const mockReplace = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
...window.location,
|
||||
replace: mockReplace,
|
||||
href: 'http://test.jest/',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
|
||||
describe('utils', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
mockReplace.mockClear();
|
||||
setItemSpy.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('checks support session is terminated when logout', async () => {
|
||||
const { terminateCrispSession } = await import('@/services/Crisp');
|
||||
|
||||
// Mock window.location.replace
|
||||
const mockReplace = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
...window.location,
|
||||
replace: mockReplace,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
gotoLogout();
|
||||
|
||||
expect(terminateCrispSession).toHaveBeenCalled();
|
||||
@@ -35,4 +42,13 @@ describe('utils', () => {
|
||||
'http://test.jest/api/v1.0/logout/',
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the gotoSilentLogin', async () => {
|
||||
gotoSilentLogin();
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
'http://test.jest/api/v1.0/authenticate/?silent=true&next=http%3A%2F%2Ftest.jest%2F',
|
||||
);
|
||||
expect(setItemSpy).toHaveBeenCalledWith(SILENT_LOGIN_RETRY, 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { PropsWithChildren, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Loading } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
|
||||
import { HOME_URL } from '../conf';
|
||||
import { useAuth } from '../hooks';
|
||||
import { getAuthUrl, gotoLogin } from '../utils';
|
||||
import {
|
||||
getAuthUrl,
|
||||
gotoLogin,
|
||||
gotoSilentLogin,
|
||||
hasTrySilent,
|
||||
resetSilent,
|
||||
} from '../utils';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
|
||||
useAuth();
|
||||
const { replace, pathname } = useRouter();
|
||||
const { data: config } = useConfig();
|
||||
const {
|
||||
isLoading: isAuthLoading,
|
||||
pathAllowed,
|
||||
isFetchedAfterMount,
|
||||
authenticated,
|
||||
fetchStatus,
|
||||
} = useAuth();
|
||||
const isLoading = fetchStatus !== 'idle' || isAuthLoading;
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
const { data: config } = useConfig();
|
||||
const shouldTrySilentLogin = useMemo(
|
||||
() => !authenticated && !hasTrySilent() && !isLoading && !isRedirecting,
|
||||
[authenticated, isLoading, isRedirecting],
|
||||
);
|
||||
const shouldTryLogin =
|
||||
!authenticated && !isLoading && !isRedirecting && !pathAllowed;
|
||||
const { replace, pathname } = useRouter();
|
||||
|
||||
/**
|
||||
* If the user is authenticated and initially wanted to access a specific page, redirect him to that page now.
|
||||
@@ -23,6 +41,10 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTrySilent()) {
|
||||
resetSilent();
|
||||
}
|
||||
|
||||
const authUrl = getAuthUrl();
|
||||
if (authUrl) {
|
||||
setIsRedirecting(true);
|
||||
@@ -34,7 +56,13 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
* If the user is not authenticated and not on a allowed pages
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isLoading || authenticated || pathAllowed || isRedirecting) {
|
||||
if (shouldTrySilentLogin) {
|
||||
setIsRedirecting(true);
|
||||
gotoSilentLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldTryLogin) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,19 +84,17 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
setIsRedirecting(true);
|
||||
gotoLogin();
|
||||
}, [
|
||||
authenticated,
|
||||
pathAllowed,
|
||||
config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED,
|
||||
replace,
|
||||
isLoading,
|
||||
isRedirecting,
|
||||
pathname,
|
||||
shouldTryLogin,
|
||||
shouldTrySilentLogin,
|
||||
]);
|
||||
|
||||
const shouldShowLoader =
|
||||
(isLoading && !isFetchedAfterMount) ||
|
||||
isRedirecting ||
|
||||
(!authenticated && !pathAllowed);
|
||||
(!authenticated && !pathAllowed) ||
|
||||
shouldTrySilentLogin;
|
||||
|
||||
if (shouldShowLoader) {
|
||||
return <Loading $height="100vh" $width="100vw" />;
|
||||
|
||||
@@ -4,3 +4,4 @@ export const HOME_URL = '/home/';
|
||||
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
|
||||
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
|
||||
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
|
||||
export const SILENT_LOGIN_RETRY = 'silent-login-retry';
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { terminateCrispSession } from '@/services/Crisp';
|
||||
import { safeLocalStorage } from '@/utils/storages';
|
||||
|
||||
import {
|
||||
HOME_URL,
|
||||
LOGIN_URL,
|
||||
LOGOUT_URL,
|
||||
PATH_AUTH_LOCAL_STORAGE,
|
||||
SILENT_LOGIN_RETRY,
|
||||
} from './conf';
|
||||
|
||||
/**
|
||||
* Get the stored auth URL from local storage
|
||||
*/
|
||||
export const getAuthUrl = () => {
|
||||
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
const path_auth = safeLocalStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
if (path_auth) {
|
||||
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
safeLocalStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
|
||||
return path_auth;
|
||||
}
|
||||
};
|
||||
@@ -27,7 +29,7 @@ export const setAuthUrl = () => {
|
||||
window.location.pathname !== '/' &&
|
||||
window.location.pathname !== `${HOME_URL}/`
|
||||
) {
|
||||
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.href);
|
||||
safeLocalStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.href);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,6 +41,29 @@ export const gotoLogin = (withRedirect = true) => {
|
||||
window.location.replace(LOGIN_URL);
|
||||
};
|
||||
|
||||
export const gotoSilentLogin = () => {
|
||||
// Already tried silent login, dont try again
|
||||
if (!hasTrySilent()) {
|
||||
const params = new URLSearchParams({
|
||||
silent: 'true',
|
||||
next: window.location.href,
|
||||
});
|
||||
|
||||
safeLocalStorage.setItem(SILENT_LOGIN_RETRY, 'true');
|
||||
|
||||
const REDIRECT = `${LOGIN_URL}?${params.toString()}`;
|
||||
window.location.replace(REDIRECT);
|
||||
}
|
||||
};
|
||||
|
||||
export const hasTrySilent = () => {
|
||||
return !!safeLocalStorage.getItem(SILENT_LOGIN_RETRY);
|
||||
};
|
||||
|
||||
export const resetSilent = () => {
|
||||
safeLocalStorage.removeItem(SILENT_LOGIN_RETRY);
|
||||
};
|
||||
|
||||
export const gotoLogout = () => {
|
||||
terminateCrispSession();
|
||||
window.location.replace(LOGOUT_URL);
|
||||
|
||||
@@ -171,8 +171,11 @@ const DocPage = ({ id }: DocProps) => {
|
||||
});
|
||||
}, [addTask, doc?.id, queryClient]);
|
||||
|
||||
if (isError && error) {
|
||||
if ([404, 401].includes(error.status)) {
|
||||
useEffect(() => {
|
||||
if (!isError || !error?.status || ![404, 401].includes(error.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let replacePath = `/${error.status}`;
|
||||
|
||||
if (error.status === 401) {
|
||||
@@ -183,7 +186,10 @@ const DocPage = ({ id }: DocProps) => {
|
||||
}
|
||||
|
||||
void replace(replacePath);
|
||||
}, [isError, error?.status, replace, authenticated, queryClient]);
|
||||
|
||||
if (isError && error?.status) {
|
||||
if ([404, 401].includes(error.status)) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user