Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Adding OIDC to an React application with restricted routes

I want to add OIDC to my React application and I am using oidc-client-ts since it seems popular and is still being maintained. My problem is that I miss some React examples.

What I want is all but one routes to be protected. If the user is not authenticated, they should be redirected to the login screen which has a button to activate the auth-flow using a custom provider.

I have tried to use these two examples, but I am unsure how to glue them together and how to convert the Angular code to React.

So far I have wrapped the entire application in an AuthContext and made all but one route private as in the first example:

index.tsx:

<StrictMode>
    <AuthProvider>
        <BrowserRouter>
            <Routes>
                <Route path={routes.LOGIN} element={<LoginContainer />} />
                <Route element={<Layout />}>
                    <Route index element={<Home />} />
                    <Route path="/openid/callback" element={<AuthCallback />} />
                        // Other pages
                </Route>
                <Route path="*" element={<ErrorPage />} />
            </Routes>
        </BrowserRouter>
    </AuthProvider>
</StrictMode>

The Layout-component with a private route, to make all paths but "/login" private:

function RequireAuth({ children }: { children: JSX.Element }) {
    const auth = useAuth();

    if (!auth.user) {
        return <Navigate to="/login" replace />;
    }

    return children;
}

function Layout() {
    return (
        <RequireAuth>
            <>
                <Header />
                <Main />
                <Footer />
            </>
        </RequireAuth>
    );
}

AuthProvider:

const AuthContext = createContext<AuthContextType>(null!);

const useAuth = () => useContext(AuthContext);

function AuthProvider({ children }: { children: React.ReactNode }) {
    const [user, setUser] = useState<any>(null);
    const authService = new AuthService();

    const login = () => authService.login().then(user1 => setUser(user1));

    const loginCallback = async () => {
        const authedUser = await authService.loginCallback();
        setUser(authedUser);
    };

    const logout = () => authService.login().then(() => setUser(null));
    const value = { user, login, logout };

    return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export { AuthProvider, useAuth };

The authService is just copied from the angular example:

import { User, UserManager } from "oidc-client-ts";

export default class AuthService {
    userManager: UserManager;

    constructor() {
        const settings = {
            authority: "...",
            client_id: "...",
            redirect_uri: "http://localhost:3000/openid/callback",
            client_secret: "...",
            post_logout_redirect_uri: "http://localhost:3000/login"
        };
        this.userManager = new UserManager(settings);
    }

    public getUser(): Promise<User | null> {
        return this.userManager.getUser();
    }

    public login(): Promise<void> {
        return this.userManager.signinRedirect();
    }

    public loginCallback(): Promise<User> {
        return this.userManager.signinRedirectCallback();
    }

    public logout(): Promise<void> {
        return this.userManager.signoutRedirect();
    }
}

My issue is that I do not know how to set the user in the AuthProvider so I can check if I am auth'ed in the RequireAuth-component. It is not set in my then in the AuthProviders login and logout functions, so I just get redirected to the login-page whenever I try to login.

Can someone tell me how I can make the authentication flow using OIDC and restrict all my paths but one to authenticated users only?

Furthermore this answer says that there should be an AuthorizationCallback-component to parse the URL. When I use oidc-client-ts which seems to parse the data for me, do I really need this extra step or can I just have the redirect URL be "/" or "/home"?

Edit:

I found out that signinRedirect goes to a new URL which means that the rest of the script is never run. signinRedirectCallback is the call that returns the user. I will post it as an answer when I have figured out how to protect the routes properly. The check in RequireAuth is done before the user is set. How do I postpone the check until the user has been set so I do not redirect to login even though I am signed in? And if I refresh the page I lose the user state from AuthProvider and I will be sent to the login page even though there is an active session. I am unsure where and how I check if I have a session running when I load the app in a clean way.

like image 838
Kikkomann Avatar asked Nov 02 '25 13:11

Kikkomann


1 Answers

I managed to solve it with inspiration from Drew Reese's answer. The thing is that oidc-client-ts' signinRedirect will redirect to the authentication server and thus the React code will stop it's execution and the then block is never run. The trick is to use signinRedirectCallback which processes the response from the authorization endpoint after the login-call. So when I hit the redirect url (http://localhost:3000/openid/callback), I call the signinRedirectCallback to find out if I should go to the Home or Login-component. So all routes but/login and the post-login-redirect-url will be auth-protected:

<Routes>
  <Route path={routes.LOGIN} element={<LoginContainer />} />
  <Route path="/openid/callback" element={<AuthCallback />} />
  <Route element={<LayoutWithAuth />}>
    <Route index element={<Home />} />
    ...
  </Route>
  <Route path="*" element={<ErrorPage />} />
</Routes>;

Then on the redirect back to the app, loginCallback/signingRedirectCallback (the first is just forwarding the call to the authService), sets the user in the AuthContext (see it below) and I navigate to the home page:

AuthCallback:

function AuthCallback() {
  const auth = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    auth.loginCallback().then(() => {
      navigate(routes.INDEX);
    });
  }, []);

  return <div>Processing signin...</div>;
}

By making loginCallback async, I make sure than when I redirect to the login page, the user will be set when the LayoutWithAuth-component does the auth-check:

function RequireAuth({ children }: { children: JSX.Element }) {
  const auth = useAuth();
  return auth.user === undefined ? <Navigate to="/login" replace /> : children;
}

function LayoutWithAuth() {
  return (
    <RequireAuth>
      <>
        <Header />
        <Body />
        <Footer />
      </>
    </RequireAuth>
  );
}

oidc-client-ts saves the user in the sessionStorage, so if the page is refreshed, the AuthContext will first check the sessionStorage to see if the user is auth'ed:

AuthContext:

const AuthContext = createContext<AuthContextType>(null!);

const useAuth = () => useContext(AuthContext);

function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null | undefined>(
    JSON.parse(
      sessionStorage.getItem(process.env.REACT_APP_SESSION_ID!) || "null"
    ) || undefined
  );

  const authService = new AuthService();

  const loginCallback = async (): Promise<void> => {
    const authedUser = await authService.loginCallback();
    setUser(authedUser);
  };

  // Login and logout methods

  const value = { user, login, logout };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export { AuthProvider, useAuth };

Update: Adding the LoginContainer:

function LoginContainer() {
  const auth = useAuth();

  const login = () => {
    auth.login();
  };
  return (
    <Container>
      <Headline>Some headline</Headline>
      <Button variant="contained" onClick={() => login()}>
        {texts.BUTTON_LOGIN}
      </Button>
    </Container>
  );
}

export default LoginContainer;

like image 139
Kikkomann Avatar answered Nov 04 '25 06:11

Kikkomann



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!