I saw that there are already threads on the subject but they are mostly dead or in any case without any real solution.
I'm implementing a react and typescript client, in it I have installed the oidc-client module to be able to communicate with an external identityserver4 service.
Actually it seems that the flow is working, logins and logouts work fine with the right redirects. The only thing I noticed is that when I log in and I am redirected to the page that completes the login (/signin-callback), this error is triggered for a moment but then with the next render its redirect me to the home with the user correctly logged in.
This is the error:
the configuration i send to the idp:
public static config: UserManagerSettings = {
authority: 'https://idservices.idphost.it/',
client_id: 'myapp_client_id',
client_secret: 'myapp_client_secret',
redirect_uri: 'https://localhost:9099/signin-callback',
silent_redirect_uri: 'https://localhost:9099/silent-renew',
response_type: 'code', // 'code'
scope: 'openid profile email roles',
post_logout_redirect_uri: 'https://localhost:9099/signout-oidc',
loadUserInfo: true,
revokeAccessTokenOnSignout: true,
};
this is my authservice class:
import { UserManager, Log, User } from 'oidc-client';
import Constants from '../helpers/const/Constants';
export const AuthenticationResultStatus = {
Redirect: 'redirect',
Success: 'success',
Fail: 'fail',
};
export type UserState = {
redirectPath?: string;
};
export type AuthResponse = {
status: string;
message?: string;
userState?: UserState;
};
function buildError(message: string): AuthResponse {
return { status: AuthenticationResultStatus.Fail, message };
}
function buildSuccess(userState: UserState): AuthResponse {
return { status: AuthenticationResultStatus.Success, userState };
}
function buildRedirect(): AuthResponse {
return { status: AuthenticationResultStatus.Redirect };
}
type CallbackSub = {
callback: () => void;
subscription: number;
};
export class AuthService {
private userManager: UserManager;
private callbacks: CallbackSub[] = [];
private nextSubscriptionId = 0;
private currentUser: User = null;
public constructor(isClient = true) {
this.userManager = isClient ? new UserManager(Constants.globalOidcConfig) : new UserManager(Constants.globalOidcApiConfig);
// Logger
Log.logger = console;
Log.level = Log.WARN;
this.userManager.events.addAccessTokenExpiring(() => {
console.log('token expiring');
void this.trySilentSignIn();
});
this.userManager.events.addAccessTokenExpired(() => {
console.log('token expired');
void this.userManager.removeUser().then(() => {
this.updateState(null);
});
});
this.userManager.events.addSilentRenewError((e) => {
console.log('silent renew error', e.message);
});
this.userManager.events.addUserLoaded((user) => {
console.log('user loaded', user);
});
this.userManager.events.addUserUnloaded(() => {
console.log('user unloaded');
});
this.userManager.events.addUserSignedIn(() => {
console.log('user logged in to the token server');
});
this.userManager.events.addUserSignedOut(() => {
console.log('user logged out of the token server');
});
}
updateState(user: User): void {
this.currentUser = user;
this.notifySubscribers();
}
subscribe(callback: () => Promise<void>): number {
this.callbacks.push({
// eslint-disable-next-line @typescript-eslint/no-misused-promises
callback,
subscription: this.nextSubscriptionId,
});
this.nextSubscriptionId += 1;
return this.nextSubscriptionId - 1;
}
unsubscribe(subscriptionId: number): void {
const subscriptionIndex = this.callbacks
.map((element, index) => (element.subscription === subscriptionId ? { found: true, index } : { found: false }))
.filter((element) => element.found === true);
if (subscriptionIndex.length !== 1) {
throw new Error(`Found an invalid number of subscriptions ${subscriptionIndex.length}`);
}
this.callbacks.splice(subscriptionIndex[0].index, 1);
}
notifySubscribers(): void {
for (let i = 0; i < this.callbacks.length; i++) {
const { callback } = this.callbacks[i];
callback();
}
}
async getUser(): Promise<User> {
if (this.currentUser?.profile == null) {
this.currentUser = await this.userManager.getUser();
}
return this.currentUser;
}
async getAccessToken(): Promise<string> {
const user = await this.userManager.getUser();
return user && user.access_token;
}
async trySilentSignIn(): Promise<User> {
await this.userManager
.signinSilent()
.then((user: User) => {
this.updateState(user);
return user;
})
.catch((error: Error) => {
void this.userManager.getUser().then((user: User) => {
console.log('silent renew error', error.message);
this.updateState(user);
return undefined;
});
});
return undefined;
}
// We try to authenticate the user in two different ways:
// 1) We try to see if we can authenticate the user silently. This happens
// when the user is already logged in on the IdP and is done using a hidden iframe
// on the client.
// 3) If the method above fails, we redirect the browser to the IdP to perform a traditional
// redirect flow.
async signin(userState: UserState): Promise<AuthResponse> {
try {
// await this.userManager.signinRedirect({ useReplaceToNavigate: true, state: userState });
// return buildRedirect();
const silentUser = await this.userManager.signinSilent({ useReplaceToNavigate: true, state: userState });
this.updateState(silentUser);
return buildSuccess(silentUser.state as UserState);
} catch (silentError) {
// User might not be authenticated, fallback to redirect
console.log('Silent authentication error: ', silentError);
try {
await this.userManager.signinRedirect({ useReplaceToNavigate: true, state: userState });
return buildRedirect();
} catch (redirectError) {
console.log('Redirect authentication error: ', redirectError);
return buildError(redirectError as string);
}
}
}
async completeSignin(url?: string): Promise<AuthResponse> {
try {
const user = (await this.getUser()) || (await this.userManager.signinCallback(url));
const userState = user.state as UserState;
this.updateState(user);
return buildSuccess(userState);
} catch (error) {
console.log('There was an error signing in: ', error);
return buildError('There was an error signing in.');
}
}
// Redirect the browser to the IdP to perform a traditional
// post logout redirect flow.
async signout(): Promise<AuthResponse> {
try {
console.log('entered in logout');
await this.userManager.signoutRedirect();
return buildRedirect();
} catch (redirectSignOutError) {
console.log('Redirect signout error: ', redirectSignOutError);
return buildError(redirectSignOutError as string);
}
}
async completeSignout(url?: string): Promise<AuthResponse> {
try {
await this.userManager.signoutCallback(url);
this.updateState(null);
return buildSuccess(null);
} catch (error) {
console.log('There was an error trying to log out ', error);
return buildError(error as string);
}
}
}
const authService = new AuthService();
export default authService;
and these are my 4 components that manage the 4 phases (login - login callback - logout - logout callback)
loadinglogin.tsx:
import React, { FC, useEffect, useState } from 'react';
import { ColorRing } from 'react-loader-spinner';
import { Button } from 'antd';
import { withRouter } from '../../../helpers/rtrHelper';
import authService, { AuthenticationResultStatus } from '../../../services/AuthService';
import { getReturnUrl } from '../../../helpers/stringHelper';
import { LoadingLoginProps } from '../../../interfaces';
declare type LoginLoadingState = {
message: string,
};
class LoadingLogin extends React.PureComponent<LoadingLoginProps, LoginLoadingState> {
constructor(props: LoadingLoginProps) {
super(props);
this.state = {
message: '',
};
}
componentDidMount(): void {
void this.login();
}
// componentDidUpdate(prevProps: Readonly<LoadingLoginProps>, prevState: Readonly<LoginLoadingState>, snapshot?: any): void {
// void this.login();
// }
// async setLogin() {
// this.setState({ message: 'loggin in...' });
// await this.login();
// }
login = async (): Promise<void> => {
const redirectPath: string = getReturnUrl();
const result = await authService.signin({ redirectPath });
switch (result.status) {
case AuthenticationResultStatus.Redirect:
break;
case AuthenticationResultStatus.Success:
window.location.href = redirectPath;
break;
case AuthenticationResultStatus.Fail:
this.setState({ message: 'An error occured' });
break;
default:
throw new Error(`Invalid status result ${result.status}.`);
}
};
// <Button onClick={() => void this.setLogin()}>{message}</Button>
render() {
const { message } = this.state;
return (
message ? (
<strong>{message}</strong>
) : (
<div>
<ColorRing
colors={['red', 'blue', 'orange', 'yellow', 'magenta']}
/>
</div>
)
);
}
}
export default withRouter(LoadingLogin);
logincallback.tsx
import React, { FC, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ColorRing } from 'react-loader-spinner';
import authService, { AuthenticationResultStatus } from '../../../services/AuthService';
// import Loader from '../_Common/Loader';
const LoginCallback: FC = function LoginCallBack() {
const [message, setMessage] = useState('');
const navigate = useNavigate();
useEffect(() => {
const processSigninCallback = async () => {
const result = await authService.completeSignin();
const newLocal = result.userState ? result.userState.redirectPath : '/';
switch (result.status) {
case AuthenticationResultStatus.Redirect:
// There should not be any redirects as the only time completeSignIn finishes
// is when we are doing a redirect sign in flow.
throw new Error('Should not redirect.');
case AuthenticationResultStatus.Success:
navigate(newLocal);
break;
case AuthenticationResultStatus.Fail:
setMessage(result.message);
break;
default:
throw new Error(`Invalid authentication result status '${result.status}'.`);
}
};
void processSigninCallback();
}, [navigate]);
return message ? <div>{message}</div> : (
<ColorRing
colors={['red', 'blue', 'orange', 'yellow', 'magenta']}
/>
);
};
export default LoginCallback;
loadinglogout.tsx
import React, { PureComponent } from 'react';
import { ColorRing } from 'react-loader-spinner';
import authService, { AuthenticationResultStatus } from '../../../services/AuthService';
import { withRouter } from '../../../helpers/rtrHelper';
import { LoadingLogoutProps } from '../../../interfaces';
declare interface LoadingLogoutState {
message: string,
}
class LoadingLogout extends PureComponent<LoadingLogoutProps, LoadingLogoutState> {
public constructor(props: LoadingLogoutProps) {
super(props);
this.state = {
message: '',
};
}
componentDidMount(): void {
this.logout();
}
// componentDidUpdate(prevProps: Readonly<LoadingLogoutProps>, prevState: Readonly<LoadingLogoutState>, snapshot?: any): void {
// void this.logout();
// }
logout = () => {
const redirectPath = `${window.location.origin}/`;
authService.signout()
.then((result) => {
switch (result.status) {
case AuthenticationResultStatus.Redirect:
console.log('LOGOUT REDIRECT');
break;
case AuthenticationResultStatus.Success:
console.log(`LOGOUT SUCCESS - REDIRECT PATH: "${redirectPath}"`);
window.location.href = redirectPath;
break;
case AuthenticationResultStatus.Fail:
this.setState({ message: 'An error occurred' });
break;
default:
throw new Error(`Invalid status result ${result.status}.`);
}
})
.catch((error) => {
throw new Error('Invalid status result.');
});
};
render(): React.ReactNode {
const { message } = this.state;
return message ? <div>{message}</div> : (
<ColorRing
colors={['red', 'blue', 'orange', 'yellow', 'magenta']}
/>
);
}
}
export default withRouter(LoadingLogout);
logoutcallback.tsx
import React, { PureComponent } from 'react';
import { ColorRing } from 'react-loader-spinner';
import { LogoutCallbackProps } from '../../../interfaces';
import authService, { AuthenticationResultStatus } from '../../../services/AuthService';
import { withRouter } from '../../../helpers/rtrHelper';
declare interface LogoutCallBackState {
message: string,
}
class LogoutCallback extends PureComponent<LogoutCallbackProps, LogoutCallBackState> {
public constructor(props: LogoutCallbackProps) {
super(props);
this.state = {
message: 'ti sto scollegandooooOOOOOOOOOOOOO',
};
}
componentDidMount(): void {
this.processSignoutCallback();
}
// componentDidUpdate(prevProps: Readonly<LogoutCallbackProps>, prevState: Readonly<LogoutCallBackState>, snapshot?: any): void {
// void this.processSignoutCallback();
// }
processSignoutCallback = () => {
const { navigate } = this.props;
// const result = await authService.completeSignout();
authService.completeSignout()
.then((result) => {
const newLocal = result.userState ? result.userState.redirectPath : '/';
switch (result.status) {
case AuthenticationResultStatus.Success:
navigate(newLocal);
break;
case AuthenticationResultStatus.Fail:
this.setState({ message: result.message });
break;
default:
throw new Error('Invalid authentication result status.');
}
})
.catch((error) => {
throw new Error('Invalid authentication result status.');
});
};
render(): React.ReactNode {
const { message } = this.state;
return <h1>LOHOUYNG</h1>;
// return message ? <h1>{message}</h1> : (
// <ColorRing
// colors={['red', 'blue', 'orange', 'yellow', 'magenta']}
// />
// );
}
}
export default withRouter(LogoutCallback);
One thing I definitely noticed in debug is that in the LoginCallback component, the processSigninCallback function that I call in the useState hook is called twice, the first time result.status is Fail in my switch and the second is Success and I', being successfully redirected with the uploaded user.
these are my routes:
App.tsx
import React, { FC } from 'react';
import { Route, Routes } from 'react-router-dom';
import PrivateOutlet from './privateOutlet';
import PublicOutlet from './publicOutlet';
import { defaultPagination } from '../context/defaults';
// #region auth
import LoadingLogin from '../lib/Components/Auth/LoadingLogin';
import LoginCallback from '../lib/Components/Auth/LoginCallback';
import LoadingLogout from '../lib/Components/Auth/LoadingLogout';
import LogoutCallback from '../lib/Components/Auth/LogoutCallback';
import SilentRenew from '../lib/Components/Auth/SilentRenew';
// #endregion auth
import Desk from '../lib/Components/Desk';
import Login from '../lib/Components/Login';
import ProductsPage from '../lib/Components/ProductsPage';
import PersonalPage from '../lib/Components/PersonalPage';
import VideosPage from '../lib/Components/VideosPage';
import SingleVideoPage from '../lib/Components/SingleVideoPage';
import PageNotFound from '../lib/Components/PageNotFound';
import Header from '../lib/Components/Header';
const App: FC = function App() {
return (
<Routes>
<Route path="/" element={<PublicOutlet />}>
<Route index element={<Desk />} />
<Route path="prodotti" element={<ProductsPage />} />
<Route path="area_personale/:username" element={<PersonalPage />} />
<Route path="video" element={<VideosPage />} />
<Route path="video/:niceUrl" element={<SingleVideoPage />} />
<Route path="login" element={<LoadingLogin />} />
<Route path="signin-callback" element={<LoginCallback />} />
<Route path="logout" element={<LoadingLogout />} />
<Route path="signout-oidc" element={<LogoutCallback />} />
<Route path="silent-renew" element={<SilentRenew />} />
<Route path="*" element={<PageNotFound />} />
</Route>
</Routes>
);
};
export default App;
what could be the cause of this double call of which the first is always "broken", triggering the error of the state that does not match?

SOLVED
the problem is wrapping the whole app using
React.StrictMode, somehow (only locally not in production) it makes double calls. By removing it, double calls no longer occur