SuperAPI AI Directory

ssd-bug-std-auth-redirect-loop

Security & auditing

View repository

Updated 10-15 21:41

About this skill

SSD Bug Fix: Auth Redirect Loop — blank page / endless redirect loop

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.tsx calls signinRedirect() immediately when getUser() 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_uri points to callback.html the 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 สำเร็จ