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



Solution 1:[1]

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

Solution 2:[2]

Issue

The login utility only returns a resolved Promise<void>, no user value, so while you can chain from the returned Promise there is no resolved value returned in the chain.

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

Solution

After a successful authentication it seems you must call getUser to get the current user value, a User object or null.

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

Here's a login implementation that gets the user after logging in.

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

  const login = async () => {
    try {
      await authService.login();
      const user = await authService.getUser();
      setUser(user);
    } catch(error) {
      // log error, set error state, ignore, etc...
      setUser(null);
    }
  };

  const logout = () => {
    try {
      await authService.logout();
    } catch(error) {
      // log error, set error state, ignore, etc...
    } finally {
      setUser(null);
    }
  };

  const value = { user, login, logout };

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

You could, of course, update your AuthService to return the authenticated user directly.

export default class AuthService {
  ...

  public login(): Promise<User | null> {
    return this.userManager.signinRedirect()
      .then(getUser)
      .catch(error => {
        // handle any errors, rejected Promises, etc...
        return null;
      });
  }

  // or

  public async login(): Promise<User | null> {
    try {
      await this.userManager.signinRedirect();
      return getUser();
    } catch(error) {
      // handle any errors, rejected Promises, etc...
      return null;
    }
  }

  ...
}

Now the original AuthContext login function will work as expected.

const login = () => authService.login()
  .then(user1 => setUser(user1))
  .catch((error) => {
    // log error, set error state, ignore, etc...
    setUser(null);
  });

or

  const login = async () => {
    try {
      const user = await authService.login();
      setUser(user);
    } catch(error) {
      // log error, set error state, ignore, etc...
      setUser(null);
    }
  };

Updates

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?

For this you can use a sort of "loading" state. I usually suggest either added an explicit loading state or using an initial auth state value that doesn't match either the authenticated or unauthenticated state.

For example, if null is the unauthenticated user, and some user object is an authenticated user, use an undefined initial value.

Auth context

const [user, setUser] = useState<any>();

...

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

  if (auth === undefined) {
    return null; // or loading spinner, etc...
  }

  return auth.user ? children : <Navigate to="/login" replace />;
}

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.

This is because when the page is reloaded, the entire app is remounted. Any local component state is lost. You can try initializing/persisting state to localStorage.

Example:

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<any>(
    JSON.parse(localStorage.getItem('user')) || undefined
  );

  useEffect(() => {
    localStorage.setItem('user', JSON.stringify(user));
  }, [user]);

  ...
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Kikkomann
Solution 2