(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:
Anthony LC
2026-01-23 10:44:03 +01:00
parent 781f0815a8
commit c6ded3f267
9 changed files with 139 additions and 38 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -171,19 +171,25 @@ const DocPage = ({ id }: DocProps) => {
});
}, [addTask, doc?.id, queryClient]);
if (isError && error) {
if ([404, 401].includes(error.status)) {
let replacePath = `/${error.status}`;
useEffect(() => {
if (!isError || !error?.status || ![404, 401].includes(error.status)) {
return;
}
if (error.status === 401) {
if (authenticated) {
queryClient.setQueryData([KEY_AUTH], null);
}
setAuthUrl();
let replacePath = `/${error.status}`;
if (error.status === 401) {
if (authenticated) {
queryClient.setQueryData([KEY_AUTH], null);
}
setAuthUrl();
}
void replace(replacePath);
void replace(replacePath);
}, [isError, error?.status, replace, authenticated, queryClient]);
if (isError && error?.status) {
if ([404, 401].includes(error.status)) {
return <Loading />;
}