Symptoms
- Some users on some devices open the site and get a blank page that does not load
- The browser redirects in a loop between the login page and the callback without stopping
- Occurs after deploying a new version, or when the IdP (SSO) session expires
- Does not happen to every user — only some devices or some networks
Root Cause
AuthProvider.tsxcallssigninRedirect()immediately whengetUser()does not find a user- On a device where the IdP session has expired → redirect to callback → callback fails → redirect again → endless loop
silent_redirect_uripoints tocallback.htmlthe same as signin → silent callback and signin callback get mixed up
Fix: 9 files (2 new files, 7 modified files)
Step 1: Create new file src/app/modules/_auth/authRedirect.ts
Create this file entirely new — it is a guard that prevents redirect loops by using sessionStorage as a marker
export const AUTH_REDIRECT_IN_PROGRESS_KEY = "auth_redirect_in_progress";
export const setAuthRedirectInProgress = () => {
window.sessionStorage.setItem(AUTH_REDIRECT_IN_PROGRESS_KEY, Date.now().toString());
};
export const clearAuthRedirectInProgress = () => {
window.sessionStorage.removeItem(AUTH_REDIRECT_IN_PROGRESS_KEY);
};
export const isAuthRedirectInProgress = () => {
return window.sessionStorage.getItem(AUTH_REDIRECT_IN_PROGRESS_KEY) !== null;
};
Step 2: Create new file silent-callback.html (project root)
Create it at the same root as callback.html — use a separate entry point for silent renew
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Now Loading...</title>
</head>
<body>
<script src="/configuration.js"></script>
<div id="root"></div>
<script type="module" src="/src/Callback.tsx"></script>
</body>
</html>
Step 3: Modify src/Const.ts
Change silent_redirect_uri to point to silent-callback.html instead of callback.html
// ก่อน
silent_redirect_uri: `${VITE_BASE_URL}/callback.html`,
// หลัง
silent_redirect_uri: `${VITE_BASE_URL}/silent-callback.html`,
Step 4: Modify src/Callback.tsx
Separate processSigninCallback and processSilentCallback from each other, then route by URL
import { UserManager, UserManagerSettings, WebStorageStateStore } from "oidc-client-ts";
import ReactDOM from "react-dom/client";
import { SSO_CONFIG, VITE_BASE_URL } from "./Const";
import { SigninCallback } from "./app/modules/_auth";
import { clearAuthRedirectInProgress } from "./app/modules/_auth/authRedirect";
let returnUrl = "/";
const userManagerSettings: UserManagerSettings = {
...SSO_CONFIG,
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
};
const oidcUserManager = new UserManager(userManagerSettings);
const processSigninCallback = async () => {
try {
const user = await oidcUserManager.signinRedirectCallback();
const state = user.state as any;
if (state && state.returnUrl !== "/not-found") {
returnUrl = state.returnUrl;
}
} catch {
returnUrl = "/";
} finally {
clearAuthRedirectInProgress();
oidcUserManager.clearStaleState();
document.location.href = `${VITE_BASE_URL}${returnUrl}`;
}
};
const processSilentCallback = async () => {
try {
await oidcUserManager.signinSilentCallback();
} catch {
// no-op: silent callback can fail when there is no IdP session
} finally {
clearAuthRedirectInProgress();
oidcUserManager.clearStaleState();
}
};
if (window.location.pathname.endsWith("/silent-callback.html")) {
processSilentCallback();
} else {
processSigninCallback();
}
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<SigninCallback />);
Step 5: Modify src/app/modules/_auth/components/AuthProvider.tsx
Add imports and add guard + silent-first flow functions:
Add imports at the top of the file:
import React, { useCallback, useEffect, useState } from "react";
import { clearAuthRedirectInProgress, isAuthRedirectInProgress, setAuthRedirectInProgress } from "../authRedirect";
Add 2 functions in the component (before the first useEffect):
const signinRedirectWithGuard = useCallback(async () => {
if (isAuthRedirectInProgress()) {
return;
}
setAuthRedirectInProgress();
try {
await oidcUserManager.signinRedirect({ state: getState() });
} catch (error) {
clearAuthRedirectInProgress();
throw error;
}
}, [getState, oidcUserManager]);
const signinSilentThenRedirect = useCallback(async () => {
try {
await oidcUserManager.signinSilent();
} catch {
await signinRedirectWithGuard();
}
}, [oidcUserManager, signinRedirectWithGuard]);
Move event listeners into useEffect with cleanup:
useEffect(() => {
const onAccessTokenExpired = async () => {
oidcUserManager.removeUser();
oidcUserManager.clearStaleState();
await signinSilentThenRedirect();
};
const onUserLoaded = (loadedUser: User) => {
clearAuthRedirectInProgress();
setLogin(loadedUser);
};
const onUserUnloaded = () => {
setLogout();
};
const onUserSignedOut = async () => {
oidcUserManager.removeUser();
oidcUserManager.clearStaleState();
await signinRedirectWithGuard();
};
const onAccessTokenExpiring = () => {
oidcUserManager.startSilentRenew();
};
oidcUserManager.events.addAccessTokenExpired(onAccessTokenExpired);
oidcUserManager.events.addUserLoaded(onUserLoaded);
oidcUserManager.events.addUserUnloaded(onUserUnloaded);
oidcUserManager.events.addUserSignedOut(onUserSignedOut);
oidcUserManager.events.addAccessTokenExpiring(onAccessTokenExpiring);
return () => {
oidcUserManager.events.removeAccessTokenExpired(onAccessTokenExpired);
oidcUserManager.events.removeUserLoaded(onUserLoaded);
oidcUserManager.events.removeUserUnloaded(onUserUnloaded);
oidcUserManager.events.removeUserSignedOut(onUserSignedOut);
oidcUserManager.events.removeAccessTokenExpiring(onAccessTokenExpiring);
};
}, [oidcUserManager, setLogin, setLogout, signinRedirectWithGuard, signinSilentThenRedirect]);
Change the startup useEffect:
useEffect(() => {
const processGetUser = async () => {
const user = await oidcUserManager.getUser();
if (user) {
clearAuthRedirectInProgress();
setLogin(user);
oidcUserManager.clearStaleState();
} else {
setLogout();
await signinSilentThenRedirect();
}
};
processGetUser();
}, [oidcUserManager, setLogin, setLogout, signinSilentThenRedirect]);
Wrap setLogin and setLogout with useCallback:
const setLogin = useCallback((user: User) => {
// ... เนื้อหาเดิม ...
}, []);
const setLogout = useCallback(() => {
// ... เนื้อหาเดิม ...
}, []);
Step 6: Modify src/app/modules/_auth/pages/SigninCallback.tsx
Add imports and fix catch/finally:
import { clearAuthRedirectInProgress, isAuthRedirectInProgress, setAuthRedirectInProgress } from "../authRedirect";
Change the catch block:
// ก่อน
} catch (error) {
await oidcUserManager.signinRedirect();
}
// หลัง
} catch {
if (!isAuthRedirectInProgress()) {
setAuthRedirectInProgress();
await oidcUserManager.signinRedirect();
}
} finally {
clearAuthRedirectInProgress();
}
Step 7: Modify src/app/modules/_auth/pages/SilentCallback.tsx
Add imports:
import { clearAuthRedirectInProgress } from "../authRedirect";
Wrap signinSilentCallback with try/finally:
// ก่อน
await oidcUserManager.signinSilentCallback(location.search);
// หลัง
try {
await oidcUserManager.signinSilentCallback(location.search);
} finally {
clearAuthRedirectInProgress();
oidcUserManager.clearStaleState();
}
Step 8: Edit public/web.config
Add a location rule for silent-callback.html first <system.webServer> main:
<location path="silent-callback.html">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
Step 9: Edit vite.config.ts
Add an entry for silent-callback in input:
input: {
index: "./index.html",
callback: "./callback.html",
"silent-callback": "./silent-callback.html", // เพิ่มบรรทัดนี้
},
Step 10: Verify
# 1. Build ต้องผ่าน
npm run build
# 2. ตรวจว่ามีไฟล์ silent-callback ใน dist/
ls dist/silent-callback.html
# 3. ทดสอบ flow
# - เปิด browser แบบ incognito
# - ไปที่ URL ของ app → ต้อง redirect ไป login ได้ปกติ
# - หลัง login → ต้อง redirect กลับมาที่ app ได้ปกติ
# - ใน sessionStorage → ไม่ควรมี auth_redirect_in_progress ค้างอยู่หลัง login สำเร็จ