(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

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

View File

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

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